More work on todo list support

This commit is contained in:
jude 2024-04-07 20:20:16 +01:00
parent 9989ab3b35
commit e128b9848f
20 changed files with 317 additions and 172 deletions

View File

@ -1,5 +1,4 @@
import axios from "axios"; import axios from "axios";
import { DateTime } from "luxon";
type UserInfo = { type UserInfo = {
name: string; name: string;

View File

@ -20,28 +20,30 @@ export function App() {
<div class="columns is-gapless dashboard-frame"> <div class="columns is-gapless dashboard-frame">
<Sidebar /> <Sidebar />
<div class="column is-main-content"> <div class="column is-main-content">
<Switch> <div style={{ margin: "0 12px 12px 12px" }}>
<Route path={"/@me/reminders"} component={User}></Route> <Switch>
<Route <Route path={"/@me/reminders"} component={User}></Route>
path={"/:guild/reminders"} <Route
component={() => ( path={"/:guild/reminders"}
<Guild> component={() => (
<GuildReminders /> <Guild>
</Guild> <GuildReminders />
)} </Guild>
></Route> )}
<Route ></Route>
path={"/:guild/todos"} <Route
component={ path={"/:guild/todos"}
<Guild> component={() => (
<GuildTodos /> <Guild>
</Guild> <GuildTodos />
} </Guild>
></Route> )}
<Route> ></Route>
<Welcome /> <Route>
</Route> <Welcome />
</Switch> </Route>
</Switch>
</div>
</div> </div>
</div> </div>
</Router> </Router>

View File

@ -37,111 +37,107 @@ export const GuildReminders = () => {
return ( return (
<> <>
{!isFetched && <Loader />} {!isFetched && <Loader />}
<div style={{ margin: "0 12px 12px 12px" }}>
<strong>Create Reminder</strong> <strong>Create Reminder</strong>
<div id={"reminderCreator"}> <div id={"reminderCreator"}>
<CreateReminder /> <CreateReminder />
</div> </div>
<br></br> <br />
<div class={"field"}> <div class={"field"}>
<div class={"columns is-mobile"}> <div class={"columns is-mobile"}>
<div class={"column"}> <div class={"column"}>
<strong>Reminders</strong> <strong>Reminders</strong>
</div> </div>
<div class={"column is-narrow"}> <div class={"column is-narrow"}>
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-small"> <div class="select is-small">
<select <select
id="orderBy" id="orderBy"
onInput={(ev) => { onInput={(ev) => {
setSort(ev.currentTarget.value as Sort); setSort(ev.currentTarget.value as Sort);
}} }}
> >
<option value={Sort.Time} selected={sort == Sort.Time}> <option value={Sort.Time} selected={sort == Sort.Time}>
Time Time
</option> </option>
<option value={Sort.Name} selected={sort == Sort.Name}> <option value={Sort.Name} selected={sort == Sort.Name}>
Name Name
</option> </option>
<option <option value={Sort.Channel} selected={sort == Sort.Channel}>
value={Sort.Channel} Channel
selected={sort == Sort.Channel} </option>
> </select>
Channel </div>
</option> <div class="icon is-small is-left">
</select> <i class="fas fa-sort-amount-down"></i>
</div>
<div class="icon is-small is-left">
<i class="fas fa-sort-amount-down"></i>
</div>
</div> </div>
</div> </div>
<div class={"column is-narrow"}> </div>
<div class="control has-icons-left"> <div class={"column is-narrow"}>
<div class="select is-small"> <div class="control has-icons-left">
<select <div class="select is-small">
id="expandAll" <select
onInput={(ev) => { id="expandAll"
if (ev.currentTarget.value === "expand") { onInput={(ev) => {
setCollapsed(false); if (ev.currentTarget.value === "expand") {
} else if (ev.currentTarget.value === "collapse") { setCollapsed(false);
setCollapsed(true); } else if (ev.currentTarget.value === "collapse") {
} setCollapsed(true);
}} }
> }}
<option value="" selected></option> >
<option value="expand">Expand All</option> <option value="" selected></option>
<option value="collapse">Collapse All</option> <option value="expand">Expand All</option>
</select> <option value="collapse">Collapse All</option>
</div> </select>
<div class="icon is-small is-left"> </div>
<i class="fas fa-expand-arrows"></i> <div class="icon is-small is-left">
</div> <i class="fas fa-expand-arrows"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div id={"guildReminders"} className={isFetching ? "loading" : ""}> <div id={"guildReminders"} className={isFetching ? "loading" : ""}>
{isSuccess && {isSuccess &&
guildReminders guildReminders
.sort((r1, r2) => { .sort((r1, r2) => {
if (sort === Sort.Time) { if (sort === Sort.Time) {
return r1.utc_time > r2.utc_time ? 1 : -1; return r1.utc_time > r2.utc_time ? 1 : -1;
} else if (sort === Sort.Name) { } else if (sort === Sort.Name) {
return r1.name > r2.name ? 1 : -1; return r1.name > r2.name ? 1 : -1;
} else { } else {
return r1.channel > r2.channel ? 1 : -1; return r1.channel > r2.channel ? 1 : -1;
} }
}) })
.map((reminder) => { .map((reminder) => {
let breaker = <></>; let breaker = <></>;
if (sort === Sort.Channel && channels) { if (sort === Sort.Channel && channels) {
if ( if (
prevReminder === null || prevReminder === null ||
prevReminder.channel !== reminder.channel prevReminder.channel !== reminder.channel
) { ) {
const channel = channels.find( const channel = channels.find(
(ch) => ch.id === reminder.channel, (ch) => ch.id === reminder.channel,
); );
breaker = <div class={"channel-tag"}>#{channel.name}</div>; breaker = <div class={"channel-tag"}>#{channel.name}</div>;
}
} }
}
prevReminder = reminder; prevReminder = reminder;
return ( return (
<> <>
{breaker} {breaker}
<EditReminder <EditReminder
key={reminder.uid} key={reminder.uid}
reminder={reminder} reminder={reminder}
globalCollapse={collapsed} globalCollapse={collapsed}
/> />
</> </>
); );
})} })}
</div>
</div> </div>
</> </>
); );

View File

@ -1,21 +1,52 @@
import { useQuery, useQueryClient } from "react-query"; import { useQuery } from "react-query";
import { fetchGuildChannels, fetchGuildTodos } from "../../api"; import { fetchGuildChannels, fetchGuildTodos } from "../../api";
import { useState } from "preact/hooks";
import { useGuild } from "../App/useGuild"; import { useGuild } from "../App/useGuild";
import { Todo } from "../Todo";
import { Loader } from "../Loader";
import { CreateTodo } from "../Todo/CreateTodo";
export const GuildTodos = () => { export const GuildTodos = () => {
const guild = useGuild(); const guild = useGuild();
const { const { isFetched, data: guildTodos } = useQuery(fetchGuildTodos(guild));
isSuccess,
isFetching,
isFetched,
data: guildReminders,
} = useQuery(fetchGuildTodos(guild));
const { data: channels } = useQuery(fetchGuildChannels(guild)); const { data: channels } = useQuery(fetchGuildChannels(guild));
const [collapsed, setCollapsed] = useState(false); if (!isFetched || !channels) {
const queryClient = useQueryClient(); return <Loader />;
}
return <></>; const sortedTodos = guildTodos.sort((a, b) => (a.channel_id < b.channel_id ? 0 : 1));
let prevChannel;
return (
<>
<strong>Create Todo</strong>
<CreateTodo />
<br />
<strong>Todo list</strong>
{sortedTodos.map((todo) => {
if (prevChannel !== todo.channel_id) {
prevChannel = todo.channel_id;
if (todo.channel_id === null) {
return (
<>
<h2>Server Todos</h2>
<Todo todo={todo} key={todo.id} />
</>
);
} else {
const channel = channels.find((ch) => ch.id === todo.channel_id);
return (
<>
<h2>#{channel.name} Todos</h2>
<Todo todo={todo} key={todo.id} />
</>
);
}
}
return <Todo todo={todo} key={todo.id} />;
})}
</>
);
}; };

View File

@ -0,0 +1,5 @@
.page-links {
> * {
margin: 2px;
}
}

View File

@ -4,6 +4,9 @@ import { GuildError } from "./GuildError";
import { createPortal, PropsWithChildren } from "preact/compat"; import { createPortal, PropsWithChildren } from "preact/compat";
import { Import } from "../Import"; import { Import } from "../Import";
import { useGuild } from "../App/useGuild"; import { useGuild } from "../App/useGuild";
import { Link } from "wouter";
import "./index.scss";
export const Guild = ({ children }: PropsWithChildren) => { export const Guild = ({ children }: PropsWithChildren) => {
const guild = useGuild(); const guild = useGuild();
@ -19,6 +22,23 @@ export const Guild = ({ children }: PropsWithChildren) => {
return ( return (
<> <>
{importModal} {importModal}
<div class="page-links">
<Link
class="button is-outlined is-success is-small"
href={`/${guild}/reminders`}
>
<span>Reminders</span>
<span class="icon">
<i class="fa fa-chevron-right"></i>
</span>
</Link>
<Link class="button is-outlined is-success is-small" href={`/${guild}/todos`}>
<span>Todo lists</span>
<span class="icon">
<i class="fa fa-chevron-right"></i>
</span>
</Link>
</div>
{children} {children}
</> </>
); );

View File

@ -1,9 +1,9 @@
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { useParams } from "wouter";
import { fetchGuildChannels } from "../../api"; import { fetchGuildChannels } from "../../api";
import { useGuild } from "../App/useGuild";
export const ChannelSelector = ({ channel, setChannel }) => { export const ChannelSelector = ({ channel, setChannel }) => {
const { guild } = useParams(); const guild = useGuild();
const { isSuccess, data } = useQuery(fetchGuildChannels(guild)); const { isSuccess, data } = useQuery(fetchGuildChannels(guild));
return ( return (

View File

@ -61,6 +61,7 @@ export const CreateReminder = () => {
<ReminderContext.Provider value={[reminder, setReminder]}> <ReminderContext.Provider value={[reminder, setReminder]}>
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}> <div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
<TopBar <TopBar
isCreating={true}
toggleCollapsed={() => { toggleCollapsed={() => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
}} }}

View File

@ -34,6 +34,7 @@ export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Prop
id={`reminder-${reminder.uid.slice(0, 12)}`} id={`reminder-${reminder.uid.slice(0, 12)}`}
> >
<TopBar <TopBar
isCreating={false}
toggleCollapsed={() => { toggleCollapsed={() => {
setCollapsed(!collapsed); setCollapsed(!collapsed);
}} }}

View File

@ -6,7 +6,7 @@ import { useCallback } from "preact/hooks";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Name } from "../Name"; import { Name } from "../Name";
export const Guild = ({ toggleCollapsed }) => { export const Guild = ({ toggleCollapsed, isCreating }) => {
const guild = useGuild(); const guild = useGuild();
const [reminder] = useReminder(); const [reminder] = useReminder();
@ -55,7 +55,7 @@ export const Guild = ({ toggleCollapsed }) => {
<div class="columns is-mobile column reminder-topbar"> <div class="columns is-mobile column reminder-topbar">
{isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>} {isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>}
<Name /> <Name />
<div class="time-bar">in {string}</div> {!isCreating && <div class="time-bar">in {string}</div>}
<div class="hide-button-bar"> <div class="hide-button-bar">
<button class="button hide-box" onClick={toggleCollapsed}> <button class="button hide-box" onClick={toggleCollapsed}>
<span class="is-sr-only">Hide reminder</span> <span class="is-sr-only">Hide reminder</span>

View File

@ -2,11 +2,11 @@ import { useGuild } from "../../App/useGuild";
import { Guild } from "./Guild"; import { Guild } from "./Guild";
import { User } from "./User"; import { User } from "./User";
export const TopBar = ({ toggleCollapsed }) => { export const TopBar = ({ toggleCollapsed, isCreating }) => {
const guild = useGuild(); const guild = useGuild();
if (guild) { if (guild) {
return <Guild toggleCollapsed={toggleCollapsed} />; return <Guild toggleCollapsed={toggleCollapsed} isCreating={isCreating} />;
} else { } else {
return <User toggleCollapsed={toggleCollapsed} />; return <User toggleCollapsed={toggleCollapsed} />;
} }

View File

@ -17,9 +17,6 @@ export const GuildEntry = ({ guild }: Props) => {
? "is-active switch-pane" ? "is-active switch-pane"
: "switch-pane" : "switch-pane"
} }
data-pane="guild"
data-guild={guild.id}
data-name={guild.name}
href={`/${guild.id}/reminders`} href={`/${guild.id}/reminders`}
> >
<> <>

View File

@ -29,7 +29,6 @@ const SidebarContent = ({ guilds }: ContentProps) => {
<li> <li>
<Link <Link
class={loc.startsWith("/@me") ? "is-active switch-pane" : "switch-pane"} class={loc.startsWith("/@me") ? "is-active switch-pane" : "switch-pane"}
data-pane="guild"
href={"/@me/reminders"} href={"/@me/reminders"}
> >
<> <>

View File

@ -0,0 +1,31 @@
import { useQuery } from "react-query";
import { fetchGuildChannels } from "../../api";
import { useGuild } from "../App/useGuild";
export const CreateTodo = () => {
const guild = useGuild();
const { isSuccess, data: channels } = useQuery(fetchGuildChannels(guild));
return (
<div class="todo">
<textarea class="input todo-input" onInput={() => null} />
<div class="control has-icons-left">
<div class="select">
<select name="channel" class="channel-selector" onInput={() => {}}>
<option value="">(None)</option>
{isSuccess && channels.map((c) => <option value={c.id}>{c.name}</option>)}
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hashtag"></i>
</div>
</div>
<button onClick={() => null} class="button is-success save-btn">
<span class="icon">
<i class="fa fa-sparkles"></i>
</span>
</button>
</div>
);
};

View File

@ -0,0 +1,10 @@
.todo {
display: flex;
flex-direction: row;
align-items: center;
margin: 6px 0;
> * {
margin: 0 3px;
}
}

View File

@ -1,19 +1,21 @@
import { Todo as TodoT } from "../../api"; import { Todo as TodoT } from "../../api";
import "./index.scss";
type Props = { type Props = {
todo: TodoT; todo: TodoT;
}; };
export const Todo = ({ todo }: Props) => { export const Todo = ({ todo }: Props) => {
return ( return (
<div> <div class="todo">
<textarea value={todo.value} onInput={() => null} /> <textarea class="input todo-input" value={todo.value} onInput={() => null} />
<button onClick={() => null} class="btn save-btn"> <button onClick={() => null} class="button is-success save-btn">
<span class="icon"> <span class="icon">
<i class="fa fa-save"></i> <i class="fa fa-save"></i>
</span> </span>
</button> </button>
<button onClick={() => null} class="btn delete-btn"> <button onClick={() => null} class="button is-danger">
<span class="icon"> <span class="icon">
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
</span> </span>

View File

@ -45,4 +45,5 @@ lazy_static! {
.map(|inner| inner.parse::<u32>().ok()) .map(|inner| inner.parse::<u32>().ok())
.flatten() .flatten()
.unwrap_or(600); .unwrap_or(600);
pub static ref SALT: String = env::var("SALT").unwrap();
} }

View File

@ -5,6 +5,46 @@ mod catchers;
mod guards; mod guards;
mod metrics; mod metrics;
mod routes; mod routes;
pub mod string {
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: FromStr,
T::Err: Display,
D: Deserializer<'de>,
{
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
}
}
pub mod string_opt {
use std::fmt::Display;
use serde::{Deserializer, Serializer};
pub fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
if let Some(v) = value {
serializer.collect_str(v)
} else {
serializer.serialize_none()
}
}
}
use std::{env, path::Path}; use std::{env, path::Path};

View File

@ -1,3 +1,4 @@
use log::warn;
use rocket::{ use rocket::{
delete, get, delete, get,
http::CookieJar, http::CookieJar,
@ -5,13 +6,23 @@ use rocket::{
serde::json::{json, Json}, serde::json::{json, Json},
State, State,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serenity::prelude::Context; use serenity::prelude::Context;
use crate::web::{check_authorization, guards::transaction::Transaction, routes::JsonResult}; use crate::web::{
check_authorization, guards::transaction::Transaction, routes::JsonResult, string_opt,
};
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateTodo { pub struct CreateTodo {
channel_id: Option<u64>,
value: String,
}
#[derive(Serialize)]
struct GetTodo {
id: u32,
#[serde(with = "string_opt")]
channel_id: Option<u64>, channel_id: Option<u64>,
value: String, value: String,
} }
@ -38,7 +49,30 @@ pub async fn get_todo(
) -> JsonResult { ) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?; check_authorization(cookies, ctx.inner(), id).await?;
Ok(json!([])) let todos = sqlx::query_as!(
GetTodo,
"
SELECT
todos.id,
channels.channel AS channel_id,
value
FROM todos
INNER JOIN guilds
ON guilds.id = todos.guild_id
LEFT JOIN channels
ON channels.id = todos.channel_id
WHERE guilds.guild = ?
",
id
)
.fetch_all(transaction.executor())
.await
.map_err(|e| {
warn!("Error fetching todos: {:?}", e);
json!({ "errors": vec!["Unknown error"] })
})?;
Ok(json!(todos))
} }
#[patch("/api/guild/<id>/todos")] #[patch("/api/guild/<id>/todos")]

View File

@ -29,7 +29,7 @@ use crate::web::{
}, },
guards::transaction::Transaction, guards::transaction::Transaction,
routes::JsonResult, routes::JsonResult,
Error, string, Error,
}; };
pub mod api; pub mod api;
@ -348,30 +348,6 @@ where
Ok(Some(Option::deserialize(deserializer)?)) Ok(Some(Option::deserialize(deserializer)?))
} }
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
mod string {
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: FromStr,
T::Err: Display,
D: Deserializer<'de>,
{
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeleteReminder { pub struct DeleteReminder {
uid: String, uid: String,