Compare commits
	
		
			28 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 3fc27b466a | ||
|  | 1d06999e41 | ||
|  | 1cf707140c | ||
|  | e38c63f5ba | ||
|  | d52b8b26f2 | ||
| bb2128a7ed | |||
| 5e99a6f9de | |||
| 5406e6b8ec | |||
|  | 4ee0bc4e37 | ||
|  | b99bb7dcbf | ||
|  | 98f925dc84 | ||
|  | 24e316b12f | ||
|  | 4063334953 | ||
|  | e128b9848f | ||
|  | 9989ab3b35 | ||
|  | b951db3f55 | ||
|  | 884a47bf36 | ||
|  | b0f932445c | ||
|  | 2861cdda0b | ||
|  | 7ba8fcd6b7 | ||
|  | 850f0fad57 | ||
|  | a770a17ee7 | ||
|  | d15a66d9d9 | ||
|  | 30f011fcd5 | ||
|  | 15dbed2f0f | ||
|  | 18cac0345b | ||
|  | 334b1bc084 | ||
|  | ba3c76c25f | 
							
								
								
									
										393
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										393
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,6 +1,6 @@ | ||||
| [package] | ||||
| name = "reminder-rs" | ||||
| version = "1.7.4-2" | ||||
| version = "1.7.14" | ||||
| authors = ["Jude Southworth <judesouthworth@pm.me>"] | ||||
| edition = "2021" | ||||
| license = "AGPL-3.0 only" | ||||
| @@ -15,7 +15,7 @@ regex = "1.10" | ||||
| log = "0.4" | ||||
| env_logger = "0.11" | ||||
| chrono = "0.4" | ||||
| chrono-tz = { version = "0.8", features = ["serde"] } | ||||
| chrono-tz = { version = "0.9", features = ["serde"] } | ||||
| lazy_static = "1.4" | ||||
| num-integer = "0.1" | ||||
| serde = "1.0" | ||||
| @@ -25,7 +25,7 @@ rmp-serde = "1.1" | ||||
| rand = "0.8" | ||||
| levenshtein = "1.0" | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] } | ||||
| base64 = "0.21" | ||||
| base64 = "0.22" | ||||
| secrecy = "0.8.0" | ||||
| futures = "0.3.30" | ||||
| prometheus = "0.13.3" | ||||
|   | ||||
| @@ -26,7 +26,6 @@ | ||||
| 	<link rel="stylesheet" href="/static/css/fa.css"> | ||||
| 	<link rel="stylesheet" href="/static/css/font.css"> | ||||
| 	<link rel="stylesheet" href="/static/css/style.css"> | ||||
| 	<link rel="stylesheet" href="/static/css/dtsel.css"> | ||||
| </head> | ||||
| <body> | ||||
| 	<div id="app"></div> | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import axios from "axios"; | ||||
| import { DateTime } from "luxon"; | ||||
|  | ||||
| type UserInfo = { | ||||
|     name: string; | ||||
| @@ -49,6 +48,21 @@ export type Reminder = { | ||||
|     utc_time: string; | ||||
| }; | ||||
|  | ||||
| export type Todo = { | ||||
|     id: number; | ||||
|     channel_id: string; | ||||
|     value: string; | ||||
| }; | ||||
|  | ||||
| export type CreateTodo = { | ||||
|     channel_id: string; | ||||
|     value: string; | ||||
| }; | ||||
|  | ||||
| export type UpdateTodo = { | ||||
|     value: string; | ||||
| }; | ||||
|  | ||||
| export type ChannelInfo = { | ||||
|     id: string; | ||||
|     name: string; | ||||
| @@ -59,6 +73,11 @@ type RoleInfo = { | ||||
|     name: string; | ||||
| }; | ||||
|  | ||||
| type EmojiInfo = { | ||||
|     fmt: string; | ||||
|     name: string; | ||||
| }; | ||||
|  | ||||
| type Template = { | ||||
|     id: number; | ||||
|     name: string; | ||||
| @@ -81,7 +100,7 @@ type Template = { | ||||
|  | ||||
| const USER_INFO_STALE_TIME = 120_000; | ||||
| const GUILD_INFO_STALE_TIME = 300_000; | ||||
| const OTHER_STALE_TIME = 15_000; | ||||
| const OTHER_STALE_TIME = 120_000; | ||||
|  | ||||
| export const fetchUserInfo = () => ({ | ||||
|     queryKey: ["USER_INFO"], | ||||
| @@ -110,9 +129,13 @@ export const fetchGuildInfo = (guild: string) => ({ | ||||
| export const fetchGuildChannels = (guild: string) => ({ | ||||
|     queryKey: ["GUILD_CHANNELS", guild], | ||||
|     queryFn: () => | ||||
|         axios.get(`/dashboard/api/guild/${guild}/channels`).then((resp) => resp.data) as Promise< | ||||
|             ChannelInfo[] | ||||
|         >, | ||||
|         axios | ||||
|             .get(`/dashboard/api/guild/${guild}/channels`) | ||||
|             .then((resp) => | ||||
|                 resp.data.sort((a: ChannelInfo, b: ChannelInfo) => | ||||
|                     a.name == b.name ? 0 : a.name > b.name ? 1 : -1, | ||||
|                 ), | ||||
|             ) as Promise<ChannelInfo[]>, | ||||
|     staleTime: GUILD_INFO_STALE_TIME, | ||||
| }); | ||||
|  | ||||
| @@ -125,6 +148,15 @@ export const fetchGuildRoles = (guild: string) => ({ | ||||
|     staleTime: GUILD_INFO_STALE_TIME, | ||||
| }); | ||||
|  | ||||
| export const fetchGuildEmojis = (guild: string) => ({ | ||||
|     queryKey: ["GUILD_EMOJIS", guild], | ||||
|     queryFn: () => | ||||
|         axios.get(`/dashboard/api/guild/${guild}/emojis`).then((resp) => resp.data) as Promise< | ||||
|             EmojiInfo[] | ||||
|         >, | ||||
|     staleTime: GUILD_INFO_STALE_TIME, | ||||
| }); | ||||
|  | ||||
| export const fetchGuildReminders = (guild: string) => ({ | ||||
|     queryKey: ["GUILD_REMINDERS", guild], | ||||
|     queryFn: () => | ||||
| @@ -176,6 +208,28 @@ export const deleteGuildTemplate = (guild: string) => ({ | ||||
|         }), | ||||
| }); | ||||
|  | ||||
| export const fetchGuildTodos = (guild: string) => ({ | ||||
|     queryKey: ["GUILD_TODOS", guild], | ||||
|     queryFn: () => | ||||
|         axios.get(`/dashboard/api/guild/${guild}/todos`).then((resp) => resp.data) as Promise< | ||||
|             Todo[] | ||||
|         >, | ||||
|     staleTime: OTHER_STALE_TIME, | ||||
| }); | ||||
|  | ||||
| export const patchGuildTodo = (guild: string) => ({ | ||||
|     mutationFn: ({ id, todo }) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo), | ||||
| }); | ||||
|  | ||||
| export const postGuildTodo = (guild: string) => ({ | ||||
|     mutationFn: (todo: CreateTodo) => | ||||
|         axios.post(`/dashboard/api/guild/${guild}/todos`, todo).then((resp) => resp.data), | ||||
| }); | ||||
|  | ||||
| export const deleteGuildTodo = (guild: string) => ({ | ||||
|     mutationFn: (todoId: number) => axios.delete(`/dashboard/api/guild/${guild}/todos/${todoId}`), | ||||
| }); | ||||
|  | ||||
| export const fetchUserReminders = () => ({ | ||||
|     queryKey: ["USER_REMINDERS"], | ||||
|     queryFn: () => | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { useEffect, useMemo } from "preact/hooks"; | ||||
| import { useQuery } from "react-query"; | ||||
| import { fetchGuildChannels, fetchGuildRoles } from "../../api"; | ||||
| import { fetchGuildChannels, fetchGuildRoles, fetchGuildEmojis } from "../../api"; | ||||
| import Tribute from "tributejs"; | ||||
| import { useGuild } from "./useGuild"; | ||||
|  | ||||
| @@ -9,13 +9,16 @@ export const Mentions = ({ input }) => { | ||||
|  | ||||
|     const { data: roles } = useQuery(fetchGuildRoles(guild)); | ||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); | ||||
|     const { data: emojis } = useQuery(fetchGuildEmojis(guild)); | ||||
|  | ||||
|     const tribute = useMemo(() => { | ||||
|         return new Tribute({ | ||||
|             collection: [ | ||||
|                 { | ||||
|                     trigger: "@", | ||||
|                     values: (roles || []).map(({ id, name }) => ({ key: name, value: id })), | ||||
|                     values: (roles || []) | ||||
|                         .filter((role) => role.name === "@everyone") | ||||
|                         .map(({ id, name }) => ({ key: name, value: id })), | ||||
|                     allowSpaces: true, | ||||
|                     selectTemplate: (item) => `<@&${item.original.value}>`, | ||||
|                     menuItemTemplate: (item) => `@${item.original.key}`, | ||||
| @@ -27,9 +30,16 @@ export const Mentions = ({ input }) => { | ||||
|                     selectTemplate: (item) => `<#${item.original.value}>`, | ||||
|                     menuItemTemplate: (item) => `#${item.original.key}`, | ||||
|                 }, | ||||
|                 { | ||||
|                     trigger: ":", | ||||
|                     values: (emojis || []).map(({ fmt, name }) => ({ key: name, value: fmt })), | ||||
|                     allowSpaces: true, | ||||
|                     selectTemplate: (item) => item.original.value, | ||||
|                     menuItemTemplate: (item) => `:${item.original.key}:`, | ||||
|                 }, | ||||
|             ], | ||||
|         }); | ||||
|     }, [roles, channels]); | ||||
|     }, [roles, channels, emojis]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         tribute.detach(input.current); | ||||
|   | ||||
| @@ -6,6 +6,8 @@ import { Guild } from "../Guild"; | ||||
| import { FlashProvider } from "./FlashProvider"; | ||||
| import { TimezoneProvider } from "./TimezoneProvider"; | ||||
| import { User } from "../User"; | ||||
| import { GuildReminders } from "../Guild/GuildReminders"; | ||||
| import { GuildTodos } from "../Guild/GuildTodos"; | ||||
|  | ||||
| export function App() { | ||||
|     const queryClient = new QueryClient(); | ||||
| @@ -18,15 +20,32 @@ export function App() { | ||||
|                         <div class="columns is-gapless dashboard-frame"> | ||||
|                             <Sidebar /> | ||||
|                             <div class="column is-main-content"> | ||||
|                                 <div style={{ margin: "0 12px 12px 12px" }}> | ||||
|                                     <Switch> | ||||
|                                         <Route path={"/@me/reminders"} component={User}></Route> | ||||
|                                     <Route path={"/:guild/reminders"} component={Guild}></Route> | ||||
|                                         <Route | ||||
|                                             path={"/:guild/reminders"} | ||||
|                                             component={() => ( | ||||
|                                                 <Guild> | ||||
|                                                     <GuildReminders /> | ||||
|                                                 </Guild> | ||||
|                                             )} | ||||
|                                         ></Route> | ||||
|                                         <Route | ||||
|                                             path={"/:guild/todos"} | ||||
|                                             component={() => ( | ||||
|                                                 <Guild> | ||||
|                                                     <GuildTodos /> | ||||
|                                                 </Guild> | ||||
|                                             )} | ||||
|                                         ></Route> | ||||
|                                         <Route> | ||||
|                                             <Welcome /> | ||||
|                                         </Route> | ||||
|                                     </Switch> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </Router> | ||||
|                 </QueryClientProvider> | ||||
|             </FlashProvider> | ||||
|   | ||||
| @@ -5,7 +5,12 @@ export const GuildError = () => { | ||||
|                 <div class="container has-text-centered"> | ||||
|                     <p class="title">We couldn't get this server's data</p> | ||||
|                     <p class="subtitle"> | ||||
|                         Please check Reminder Bot is in the server, and has correct permissions. | ||||
|                         The bot may have just been restarted, in which case please try again in a | ||||
|                         few minutes. | ||||
|                         <br /> | ||||
|                         <br /> | ||||
|                         Otherwise, please check Reminder Bot is in the server, and has correct | ||||
|                         permissions. | ||||
|                     </p> | ||||
|                     <a | ||||
|                         class="button is-size-4 is-rounded is-success" | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import { useQuery } from "react-query"; | ||||
| import { useQuery, useQueryClient } from "react-query"; | ||||
| import { fetchGuildChannels, fetchGuildReminders } from "../../api"; | ||||
| import { EditReminder } from "../Reminder/EditReminder"; | ||||
| import { CreateReminder } from "../Reminder/CreateReminder"; | ||||
| import { useState } from "preact/hooks"; | ||||
| import { useCallback, useState } from "preact/hooks"; | ||||
| import { Loader } from "../Loader"; | ||||
| import { useGuild } from "../App/useGuild"; | ||||
|  | ||||
| @@ -24,19 +24,25 @@ export const GuildReminders = () => { | ||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); | ||||
|  | ||||
|     const [collapsed, setCollapsed] = useState(false); | ||||
|     const [sort, setSort] = useState(Sort.Time); | ||||
|     const [sort, _setSort] = useState(Sort.Time); | ||||
|     const queryClient = useQueryClient(); | ||||
|  | ||||
|     let prevReminder = null; | ||||
|  | ||||
|     const setSort = useCallback((sort) => { | ||||
|         queryClient.invalidateQueries(["GUILD_REMINDERS"]); | ||||
|         _setSort(sort); | ||||
|     }, []); | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             {!isFetched && <Loader />} | ||||
|             <div style={{ margin: "0 12px 12px 12px" }}> | ||||
|  | ||||
|             <strong>Create Reminder</strong> | ||||
|             <div id={"reminderCreator"}> | ||||
|                 <CreateReminder /> | ||||
|             </div> | ||||
|                 <br></br> | ||||
|             <br /> | ||||
|             <div class={"field"}> | ||||
|                 <div class={"columns is-mobile"}> | ||||
|                     <div class={"column"}> | ||||
| @@ -57,10 +63,7 @@ export const GuildReminders = () => { | ||||
|                                     <option value={Sort.Name} selected={sort == Sort.Name}> | ||||
|                                         Name | ||||
|                                     </option> | ||||
|                                         <option | ||||
|                                             value={Sort.Channel} | ||||
|                                             selected={sort == Sort.Channel} | ||||
|                                         > | ||||
|                                     <option value={Sort.Channel} selected={sort == Sort.Channel}> | ||||
|                                         Channel | ||||
|                                     </option> | ||||
|                                 </select> | ||||
| @@ -136,7 +139,6 @@ export const GuildReminders = () => { | ||||
|                             ); | ||||
|                         })} | ||||
|             </div> | ||||
|             </div> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										62
									
								
								reminder-dashboard/src/components/Guild/GuildTodos.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								reminder-dashboard/src/components/Guild/GuildTodos.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import { useQuery } from "react-query"; | ||||
| import { ChannelInfo, fetchGuildChannels, fetchGuildTodos } from "../../api"; | ||||
| import { useGuild } from "../App/useGuild"; | ||||
| import { Todo } from "../Todo"; | ||||
| import { Todo as TodoT } from "../../api"; | ||||
| import { Loader } from "../Loader"; | ||||
| import { CreateTodo } from "../Todo/CreateTodo"; | ||||
|  | ||||
| export const GuildTodos = () => { | ||||
|     const guild = useGuild(); | ||||
|  | ||||
|     const { isFetched, data: guildTodos } = useQuery(fetchGuildTodos(guild)); | ||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); | ||||
|  | ||||
|     if (!isFetched || !channels) { | ||||
|         return <Loader />; | ||||
|     } | ||||
|  | ||||
|     const sortedTodos = guildTodos.sort((a, b) => (a.id < b.id ? -1 : 1)); | ||||
|     const globalTodos = sortedTodos.filter((todo) => todo.channel_id === null); | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <strong>Create todo list</strong> | ||||
|             <CreateTodo channel={null} showSelector={true} /> | ||||
|             <strong>Todo lists</strong> | ||||
|             {globalTodos.length > 0 && ( | ||||
|                 <> | ||||
|                     <h2>Server</h2> | ||||
|                     {globalTodos.map((todo) => ( | ||||
|                         <> | ||||
|                             <Todo todo={todo} key={todo.id} /> | ||||
|                         </> | ||||
|                     ))} | ||||
|                     <CreateTodo channel={null} /> | ||||
|                 </> | ||||
|             )} | ||||
|             {channels | ||||
|                 .map( | ||||
|                     (channel) => | ||||
|                         [channel, sortedTodos.filter((todo) => todo.channel_id === channel.id)] as [ | ||||
|                             ChannelInfo, | ||||
|                             TodoT[], | ||||
|                         ], | ||||
|                 ) | ||||
|                 .filter(([_, todos]) => todos.length > 0) | ||||
|                 .map(([channel, todos]) => { | ||||
|                     return ( | ||||
|                         <> | ||||
|                             <h2>#{channel.name}</h2> | ||||
|                             {todos.map((todo) => ( | ||||
|                                 <> | ||||
|                                     <Todo todo={todo} key={todo.id} /> | ||||
|                                 </> | ||||
|                             ))} | ||||
|                             <CreateTodo channel={channel.id} /> | ||||
|                         </> | ||||
|                     ); | ||||
|                 })} | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										5
									
								
								reminder-dashboard/src/components/Guild/index.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								reminder-dashboard/src/components/Guild/index.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| .page-links { | ||||
|     > * { | ||||
|         margin: 2px; | ||||
|     } | ||||
| } | ||||
| @@ -1,12 +1,15 @@ | ||||
| import { useQuery } from "react-query"; | ||||
| import { fetchGuildInfo } from "../../api"; | ||||
| import { GuildReminders } from "./GuildReminders"; | ||||
| import { GuildError } from "./GuildError"; | ||||
| import { createPortal } from "preact/compat"; | ||||
| import { createPortal, PropsWithChildren } from "preact/compat"; | ||||
| import { Import } from "../Import"; | ||||
| import { useGuild } from "../App/useGuild"; | ||||
| import { Link } from "wouter"; | ||||
| import { usePathname } from "wouter/use-browser-location"; | ||||
|  | ||||
| export const Guild = () => { | ||||
| import "./index.scss"; | ||||
|  | ||||
| export const Guild = ({ children }: PropsWithChildren) => { | ||||
|     const guild = useGuild(); | ||||
|     const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild)); | ||||
|  | ||||
| @@ -16,11 +19,32 @@ export const Guild = () => { | ||||
|         return <GuildError />; | ||||
|     } else { | ||||
|         const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar")); | ||||
|         const path = usePathname(); | ||||
|  | ||||
|         return ( | ||||
|             <> | ||||
|                 {importModal} | ||||
|                 <GuildReminders /> | ||||
|                 <div class="page-links"> | ||||
|                     <Link | ||||
|                         class={`button is-outlined is-success is-small ${path.endsWith("reminders") && "is-focused"}`} | ||||
|                         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 ${path.endsWith("todos") && "is-focused"}`} | ||||
|                         href={`/${guild}/todos`} | ||||
|                     > | ||||
|                         <span>Todo lists</span> | ||||
|                         <span class="icon"> | ||||
|                             <i class="fa fa-chevron-right"></i> | ||||
|                         </span> | ||||
|                     </Link> | ||||
|                 </div> | ||||
|                 {children} | ||||
|             </> | ||||
|         ); | ||||
|     } | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import { useRef, useState } from "preact/hooks"; | ||||
| import { useParams } from "wouter"; | ||||
| import axios from "axios"; | ||||
| import { useFlash } from "../App/FlashContext"; | ||||
| import { useGuild } from "../App/useGuild"; | ||||
| import { useQueryClient } from "react-query"; | ||||
|  | ||||
| export const Import = () => { | ||||
|     const [modalOpen, setModalOpen] = useState(false); | ||||
| @@ -27,7 +29,7 @@ export const Import = () => { | ||||
| }; | ||||
|  | ||||
| const ImportModal = ({ setModalOpen }) => { | ||||
|     const { guild } = useParams(); | ||||
|     const guild = useGuild(); | ||||
|  | ||||
|     const aRef = useRef<HTMLAnchorElement>(); | ||||
|     const inputRef = useRef<HTMLInputElement>(); | ||||
| @@ -35,6 +37,8 @@ const ImportModal = ({ setModalOpen }) => { | ||||
|  | ||||
|     const [isImporting, setIsImporting] = useState(false); | ||||
|  | ||||
|     const queryClient = useQueryClient(); | ||||
|  | ||||
|     return ( | ||||
|         <Modal | ||||
|             setModalOpen={setModalOpen} | ||||
| @@ -121,7 +125,7 @@ const ImportModal = ({ setModalOpen }) => { | ||||
|  | ||||
|                             axios | ||||
|                                 .put(`/dashboard/api/guild/${guild}/export/reminders`, { | ||||
|                                     body: JSON.stringify({ body: dataUrl.split(",")[1] }), | ||||
|                                     body: dataUrl.split(",")[1], | ||||
|                                 }) | ||||
|                                 .then(({ data }) => { | ||||
|                                     setIsImporting(false); | ||||
| @@ -130,6 +134,9 @@ const ImportModal = ({ setModalOpen }) => { | ||||
|                                         flash({ message: data.error, type: "error" }); | ||||
|                                     } else { | ||||
|                                         flash({ message: data.message, type: "success" }); | ||||
|                                         queryClient.invalidateQueries({ | ||||
|                                             queryKey: ["GUILD_REMINDERS", guild], | ||||
|                                         }); | ||||
|                                     } | ||||
|                                 }) | ||||
|                                 .then(() => { | ||||
|   | ||||
| @@ -39,12 +39,42 @@ export const Attachment = () => { | ||||
|                     }} | ||||
|                 ></input> | ||||
|                 <span class="file-cta"> | ||||
|                     <span class="file-label">{attachment_name || "Add Attachment"}</span> | ||||
|                     <span | ||||
|                         class="file-label" | ||||
|                         style={{ | ||||
|                             maxWidth: "200px", | ||||
|                         }} | ||||
|                     > | ||||
|                         {attachment_name || "Add Attachment"} | ||||
|                     </span> | ||||
|                     <span class="file-icon"> | ||||
|                         <i class="fas fa-upload"></i> | ||||
|                     </span> | ||||
|                 </span> | ||||
|             </label> | ||||
|             {attachment_name && ( | ||||
|                 <> | ||||
|                     <button | ||||
|                         onClick={() => { | ||||
|                             setReminder((reminder) => ({ | ||||
|                                 ...reminder, | ||||
|                                 attachment: null, | ||||
|                                 attachment_name: null, | ||||
|                             })); | ||||
|                         }} | ||||
|                         style={{ | ||||
|                             border: "none", | ||||
|                             background: "none", | ||||
|                             cursor: "pointer", | ||||
|                         }} | ||||
|                     > | ||||
|                         <span class="sr-only">Remove attachment</span> | ||||
|                         <span class="icon"> | ||||
|                             <i class="fas fa-trash"></i> | ||||
|                         </span> | ||||
|                     </button> | ||||
|                 </> | ||||
|             )} | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
|   | ||||
| @@ -18,6 +18,12 @@ export const CreateButtonRow = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|     const mutation = useMutation({ | ||||
|         ...(guild ? postGuildReminder(guild) : postUserReminder()), | ||||
|         onError: (error) => { | ||||
|             flash({ | ||||
|                 message: `An error occurred: ${error}`, | ||||
|                 type: "error", | ||||
|             }); | ||||
|         }, | ||||
|         onSuccess: (data) => { | ||||
|             if (data.error) { | ||||
|                 flash({ | ||||
|   | ||||
| @@ -12,24 +12,19 @@ export const EditButtonRow = () => { | ||||
|     const [reminder, setReminder] = useReminder(); | ||||
|  | ||||
|     const [recentlySaved, setRecentlySaved] = useState(false); | ||||
|     const queryClient = useQueryClient(); | ||||
|  | ||||
|     const iconFlashTimeout = useRef(0); | ||||
|  | ||||
|     const flash = useFlash(); | ||||
|     const mutation = useMutation({ | ||||
|         ...(guild ? patchGuildReminder(guild) : patchUserReminder()), | ||||
|         onError: (error) => { | ||||
|             flash({ | ||||
|                 message: `An error occurred: ${error}`, | ||||
|                 type: "error", | ||||
|             }); | ||||
|         }, | ||||
|         onSuccess: (response) => { | ||||
|             if (guild) { | ||||
|                 queryClient.invalidateQueries({ | ||||
|                     queryKey: ["GUILD_REMINDERS", guild], | ||||
|                 }); | ||||
|             } else { | ||||
|                 queryClient.invalidateQueries({ | ||||
|                     queryKey: ["USER_REMINDERS"], | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             if (iconFlashTimeout.current !== null) { | ||||
|                 clearTimeout(iconFlashTimeout.current); | ||||
|             } | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import { useQuery } from "react-query"; | ||||
| import { useParams } from "wouter"; | ||||
| import { fetchGuildChannels } from "../../api"; | ||||
| import { useGuild } from "../App/useGuild"; | ||||
|  | ||||
| export const ChannelSelector = ({ channel, setChannel }) => { | ||||
|     const { guild } = useParams(); | ||||
|     const guild = useGuild(); | ||||
|     const { isSuccess, data } = useQuery(fetchGuildChannels(guild)); | ||||
|  | ||||
|     return ( | ||||
|   | ||||
| @@ -38,7 +38,7 @@ function defaultReminder(): Reminder { | ||||
|         tts: false, | ||||
|         uid: "", | ||||
|         username: "", | ||||
|         utc_time: DateTime.now().toFormat("yyyy-LL-dd'T'HH:mm:ss"), | ||||
|         utc_time: DateTime.now().setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss"), | ||||
|     }; | ||||
| } | ||||
|  | ||||
| @@ -61,6 +61,7 @@ export const CreateReminder = () => { | ||||
|         <ReminderContext.Provider value={[reminder, setReminder]}> | ||||
|             <div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}> | ||||
|                 <TopBar | ||||
|                     isCreating={true} | ||||
|                     toggleCollapsed={() => { | ||||
|                         setCollapsed(!collapsed); | ||||
|                     }} | ||||
|   | ||||
| @@ -28,9 +28,13 @@ export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Prop | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <ReminderContext.Provider value={[reminder, setReminder]}> | ||||
|             <div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}> | ||||
|         <ReminderContext.Provider value={[reminder, setReminder]} key={reminder.uid}> | ||||
|             <div | ||||
|                 class={collapsed ? "reminderContent is-collapsed" : "reminderContent"} | ||||
|                 id={`reminder-${reminder.uid.slice(0, 12)}`} | ||||
|             > | ||||
|                 <TopBar | ||||
|                     isCreating={false} | ||||
|                     toggleCollapsed={() => { | ||||
|                         setCollapsed(!collapsed); | ||||
|                     }} | ||||
|   | ||||
| @@ -37,6 +37,11 @@ const ImagePickerModal = ({ setModalOpen, setImage }) => { | ||||
|             }} | ||||
|             onSubmitText={"Save"} | ||||
|         > | ||||
|             <p> | ||||
|                 Please note: if you attach an image directly from Discord, it will not be visible in | ||||
|                 the dashboard, but will be visible on reminders. Other image-sharing sites such as | ||||
|                 Imgur don't have this issue. | ||||
|             </p> | ||||
|             <input | ||||
|                 class="input" | ||||
|                 id="urlInput" | ||||
|   | ||||
| @@ -284,11 +284,7 @@ export const TimeInput = ({ defaultValue, onInput }) => { | ||||
|                 onInput={(ev) => { | ||||
|                     ev.currentTarget.value === "" | ||||
|                         ? updateTime(null) | ||||
|                         : setTime( | ||||
|                               DateTime.fromISO(ev.currentTarget.value, { zone: timezone }).setZone( | ||||
|                                   "UTC", | ||||
|                               ), | ||||
|                           ); | ||||
|                         : setTime(DateTime.fromISO(ev.currentTarget.value, { zone: "UTC" })); | ||||
|                 }} | ||||
|             ></input> | ||||
|         </> | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { useCallback } from "preact/hooks"; | ||||
| import { DateTime } from "luxon"; | ||||
| import { Name } from "../Name"; | ||||
|  | ||||
| export const Guild = ({ toggleCollapsed }) => { | ||||
| export const Guild = ({ toggleCollapsed, isCreating }) => { | ||||
|     const guild = useGuild(); | ||||
|     const [reminder] = useReminder(); | ||||
|  | ||||
| @@ -55,7 +55,7 @@ export const Guild = ({ toggleCollapsed }) => { | ||||
|         <div class="columns is-mobile column reminder-topbar"> | ||||
|             {isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>} | ||||
|             <Name /> | ||||
|             <div class="invert-collapses time-bar">in {string}</div> | ||||
|             {!isCreating && <div class="time-bar">in {string}</div>} | ||||
|             <div class="hide-button-bar"> | ||||
|                 <button class="button hide-box" onClick={toggleCollapsed}> | ||||
|                     <span class="is-sr-only">Hide reminder</span> | ||||
|   | ||||
| @@ -39,7 +39,7 @@ export const User = ({ toggleCollapsed }) => { | ||||
|     return ( | ||||
|         <div class="columns is-mobile column reminder-topbar"> | ||||
|             <Name /> | ||||
|             <div class="invert-collapses time-bar">in {string}</div> | ||||
|             <div class="time-bar">in {string}</div> | ||||
|             <div class="hide-button-bar"> | ||||
|                 <button class="button hide-box" onClick={toggleCollapsed}> | ||||
|                     <span class="is-sr-only">Hide reminder</span> | ||||
|   | ||||
| @@ -2,11 +2,11 @@ import { useGuild } from "../../App/useGuild"; | ||||
| import { Guild } from "./Guild"; | ||||
| import { User } from "./User"; | ||||
|  | ||||
| export const TopBar = ({ toggleCollapsed }) => { | ||||
| export const TopBar = ({ toggleCollapsed, isCreating }) => { | ||||
|     const guild = useGuild(); | ||||
|  | ||||
|     if (guild) { | ||||
|         return <Guild toggleCollapsed={toggleCollapsed} />; | ||||
|         return <Guild toggleCollapsed={toggleCollapsed} isCreating={isCreating} />; | ||||
|     } else { | ||||
|         return <User toggleCollapsed={toggleCollapsed} />; | ||||
|     } | ||||
|   | ||||
| @@ -17,9 +17,6 @@ export const GuildEntry = ({ guild }: Props) => { | ||||
|                         ? "is-active switch-pane" | ||||
|                         : "switch-pane" | ||||
|                 } | ||||
|                 data-pane="guild" | ||||
|                 data-guild={guild.id} | ||||
|                 data-name={guild.name} | ||||
|                 href={`/${guild.id}/reminders`} | ||||
|             > | ||||
|                 <> | ||||
|   | ||||
| @@ -29,7 +29,6 @@ const SidebarContent = ({ guilds }: ContentProps) => { | ||||
|                     <li> | ||||
|                         <Link | ||||
|                             class={loc.startsWith("/@me") ? "is-active switch-pane" : "switch-pane"} | ||||
|                             data-pane="guild" | ||||
|                             href={"/@me/reminders"} | ||||
|                         > | ||||
|                             <> | ||||
|   | ||||
							
								
								
									
										91
									
								
								reminder-dashboard/src/components/Todo/CreateTodo.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								reminder-dashboard/src/components/Todo/CreateTodo.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| import { useMutation, useQuery, useQueryClient } from "react-query"; | ||||
| import { fetchGuildChannels, postGuildTodo } from "../../api"; | ||||
| import { useGuild } from "../App/useGuild"; | ||||
| import { useState } from "preact/hooks"; | ||||
| import { useFlash } from "../App/FlashContext"; | ||||
| import { ICON_FLASH_TIME } from "../../consts"; | ||||
|  | ||||
| export const CreateTodo = ({ showSelector = false, channel }) => { | ||||
|     const guild = useGuild(); | ||||
|  | ||||
|     const [recentlyCreated, setRecentlyCreated] = useState(false); | ||||
|     const [newTodo, setNewTodo] = useState({ value: "", channel_id: channel }); | ||||
|  | ||||
|     const flash = useFlash(); | ||||
|  | ||||
|     const { isSuccess, data: channels } = useQuery(fetchGuildChannels(guild)); | ||||
|  | ||||
|     const queryClient = useQueryClient(); | ||||
|     const mutation = useMutation({ | ||||
|         ...postGuildTodo(guild), | ||||
|         onSuccess: (data) => { | ||||
|             if (data.error) { | ||||
|                 flash({ | ||||
|                     message: data.error, | ||||
|                     type: "error", | ||||
|                 }); | ||||
|             } else { | ||||
|                 flash({ | ||||
|                     message: "Todo created", | ||||
|                     type: "success", | ||||
|                 }); | ||||
|                 queryClient.invalidateQueries({ | ||||
|                     queryKey: ["GUILD_TODOS", guild], | ||||
|                 }); | ||||
|                 setRecentlyCreated(true); | ||||
|                 setTimeout(() => { | ||||
|                     setRecentlyCreated(false); | ||||
|                 }, ICON_FLASH_TIME); | ||||
|             } | ||||
|         }, | ||||
|     }); | ||||
|  | ||||
|     return ( | ||||
|         <div class="todo"> | ||||
|             <textarea | ||||
|                 class="input todo-input" | ||||
|                 onInput={(ev) => setNewTodo((todo) => ({ ...todo, value: ev.currentTarget.value }))} | ||||
|             /> | ||||
|             {showSelector && ( | ||||
|                 <div class="control has-icons-left"> | ||||
|                     <div class="select"> | ||||
|                         <select | ||||
|                             name="channel" | ||||
|                             class="channel-selector" | ||||
|                             onInput={(ev) => | ||||
|                                 setNewTodo((todo) => ({ | ||||
|                                     ...todo, | ||||
|                                     channel_id: ev.currentTarget.value || null, | ||||
|                                 })) | ||||
|                             } | ||||
|                         > | ||||
|                             <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={() => mutation.mutate(newTodo)} class="button is-success save-btn"> | ||||
|                 <span class="icon"> | ||||
|                     {mutation.isLoading ? ( | ||||
|                         <span class="icon"> | ||||
|                             <i class="fas fa-spin fa-cog"></i> | ||||
|                         </span> | ||||
|                     ) : recentlyCreated ? ( | ||||
|                         <span class="icon"> | ||||
|                             <i class="fas fa-check"></i> | ||||
|                         </span> | ||||
|                     ) : ( | ||||
|                         <span class="icon"> | ||||
|                             <i class="fas fa-sparkles"></i> | ||||
|                         </span> | ||||
|                     )} | ||||
|                 </span> | ||||
|             </button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										10
									
								
								reminder-dashboard/src/components/Todo/index.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								reminder-dashboard/src/components/Todo/index.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| .todo { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: center; | ||||
|     margin: 6px 0; | ||||
|  | ||||
|     > * { | ||||
|         margin: 0 3px; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										83
									
								
								reminder-dashboard/src/components/Todo/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								reminder-dashboard/src/components/Todo/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| import { deleteGuildTodo, patchGuildTodo, Todo as TodoT, UpdateTodo } from "../../api"; | ||||
|  | ||||
| import "./index.scss"; | ||||
| import { useMutation, useQueryClient } from "react-query"; | ||||
| import { useFlash } from "../App/FlashContext"; | ||||
| import { useGuild } from "../App/useGuild"; | ||||
| import { useState } from "preact/hooks"; | ||||
| import { ICON_FLASH_TIME } from "../../consts"; | ||||
|  | ||||
| type Props = { | ||||
|     todo: TodoT; | ||||
| }; | ||||
|  | ||||
| export const Todo = ({ todo }: Props) => { | ||||
|     const guild = useGuild(); | ||||
|  | ||||
|     const [updatedTodo, setUpdatedTodo] = useState<UpdateTodo>({ value: todo.value }); | ||||
|     const [recentlySaved, setRecentlySaved] = useState(false); | ||||
|  | ||||
|     const flash = useFlash(); | ||||
|  | ||||
|     const queryClient = useQueryClient(); | ||||
|     const deleteMutation = useMutation({ | ||||
|         ...deleteGuildTodo(guild), | ||||
|         onSuccess: () => { | ||||
|             flash({ | ||||
|                 message: "Todo deleted", | ||||
|                 type: "success", | ||||
|             }); | ||||
|             queryClient.invalidateQueries({ | ||||
|                 queryKey: ["GUILD_TODOS", guild], | ||||
|             }); | ||||
|         }, | ||||
|     }); | ||||
|  | ||||
|     const patchMutation = useMutation({ | ||||
|         ...patchGuildTodo(guild), | ||||
|         onError: (error) => { | ||||
|             flash({ | ||||
|                 message: `An error occurred: ${error}`, | ||||
|                 type: "error", | ||||
|             }); | ||||
|         }, | ||||
|         onSuccess: (response) => { | ||||
|             if (response.data.error) { | ||||
|                 setRecentlySaved(false); | ||||
|                 flash({ message: response.data.error, type: "error" }); | ||||
|             } else { | ||||
|                 setRecentlySaved(true); | ||||
|                 setTimeout(() => { | ||||
|                     setRecentlySaved(false); | ||||
|                 }, ICON_FLASH_TIME); | ||||
|             } | ||||
|         }, | ||||
|     }); | ||||
|  | ||||
|     return ( | ||||
|         <div class="todo"> | ||||
|             <textarea | ||||
|                 class="input todo-input" | ||||
|                 value={updatedTodo.value} | ||||
|                 onInput={(ev) => | ||||
|                     setUpdatedTodo({ | ||||
|                         value: ev.currentTarget.value, | ||||
|                     }) | ||||
|                 } | ||||
|             /> | ||||
|             <button | ||||
|                 onClick={() => patchMutation.mutate({ id: todo.id, todo: updatedTodo })} | ||||
|                 class="button is-success save-btn" | ||||
|             > | ||||
|                 <span class="icon"> | ||||
|                     {recentlySaved ? <i class="fa fa-check"></i> : <i class="fa fa-save"></i>} | ||||
|                 </span> | ||||
|             </button> | ||||
|             <button onClick={() => deleteMutation.mutate(todo.id)} class="button is-danger"> | ||||
|                 <span class="icon"> | ||||
|                     <i class="fa fa-trash"></i> | ||||
|                 </span> | ||||
|             </button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -45,4 +45,5 @@ lazy_static! { | ||||
|         .map(|inner| inner.parse::<u32>().ok()) | ||||
|         .flatten() | ||||
|         .unwrap_or(600); | ||||
|     pub static ref SALT: String = env::var("SALT").unwrap(); | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,57 @@ mod catchers; | ||||
| mod guards; | ||||
| mod metrics; | ||||
| 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, str::FromStr}; | ||||
|  | ||||
|     use serde::{de, Deserialize, 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() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error> | ||||
|     where | ||||
|         T: FromStr, | ||||
|         T::Err: Display, | ||||
|         D: Deserializer<'de>, | ||||
|     { | ||||
|         Option::<String>::deserialize(deserializer)? | ||||
|             .map(|s| s.parse().map_err(de::Error::custom)) | ||||
|             .transpose() | ||||
|     } | ||||
| } | ||||
|  | ||||
| use std::{env, path::Path}; | ||||
|  | ||||
| @@ -138,12 +189,17 @@ pub async fn initialize( | ||||
|                 routes::dashboard::api::guild::get_guild_info, | ||||
|                 routes::dashboard::api::guild::get_guild_channels, | ||||
|                 routes::dashboard::api::guild::get_guild_roles, | ||||
|                 routes::dashboard::api::guild::get_guild_emojis, | ||||
|                 routes::dashboard::api::guild::get_reminder_templates, | ||||
|                 routes::dashboard::api::guild::create_reminder_template, | ||||
|                 routes::dashboard::api::guild::delete_reminder_template, | ||||
|                 routes::dashboard::api::guild::create_guild_reminder, | ||||
|                 routes::dashboard::api::guild::get_reminders, | ||||
|                 routes::dashboard::api::guild::edit_reminder, | ||||
|                 routes::dashboard::api::guild::todos::create_todo, | ||||
|                 routes::dashboard::api::guild::todos::get_todo, | ||||
|                 routes::dashboard::api::guild::todos::update_todo, | ||||
|                 routes::dashboard::api::guild::todos::delete_todo, | ||||
|                 routes::dashboard::export::export_reminders, | ||||
|                 routes::dashboard::export::export_reminder_templates, | ||||
|                 routes::dashboard::export::export_todos, | ||||
|   | ||||
							
								
								
									
										84
									
								
								src/web/routes/dashboard/api/guild/emojis.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/web/routes/dashboard/api/guild/emojis.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| use std::{collections::HashMap, sync::OnceLock, time::Instant}; | ||||
|  | ||||
| use log::warn; | ||||
| use rocket::{get, http::CookieJar, serde::json::json, State}; | ||||
| use serde::Serialize; | ||||
| use serenity::{client::Context, model::id::GuildId}; | ||||
| use tokio::sync::RwLock; | ||||
|  | ||||
| use crate::web::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[derive(Serialize, Clone)] | ||||
| struct EmojiInfo { | ||||
|     fmt: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| struct EmojiCache { | ||||
|     emojis: Vec<EmojiInfo>, | ||||
|     timestamp: Instant, | ||||
| } | ||||
|  | ||||
| const CACHE_LENGTH: u64 = 120; | ||||
|  | ||||
| static EMOJI_CACHE: OnceLock<RwLock<HashMap<GuildId, EmojiCache>>> = OnceLock::new(); | ||||
|  | ||||
| #[get("/api/guild/<id>/emojis")] | ||||
| pub async fn get_guild_emojis( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
| ) -> JsonResult { | ||||
|     offline!(Ok(json!(vec![] as Vec<EmojiInfo>))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let cache_value = { | ||||
|         let cache = EMOJI_CACHE.get_or_init(|| RwLock::new(HashMap::new())); | ||||
|         let read_lock = cache.read().await; | ||||
|         read_lock.get(&GuildId::new(id)).cloned() | ||||
|     }; | ||||
|  | ||||
|     if let Some(emojis) = cache_value | ||||
|         .map(|v| { | ||||
|             if Instant::now().duration_since(v.timestamp).as_secs() < CACHE_LENGTH { | ||||
|                 Some(v.emojis) | ||||
|             } else { | ||||
|                 None | ||||
|             } | ||||
|         }) | ||||
|         .flatten() | ||||
|     { | ||||
|         Ok(json!(emojis)) | ||||
|     } else { | ||||
|         let emojis_res = ctx.http.get_emojis(GuildId::new(id)).await; | ||||
|  | ||||
|         match emojis_res { | ||||
|             Ok(emojis) => { | ||||
|                 let emojis = emojis | ||||
|                     .iter() | ||||
|                     .map(|emoji| EmojiInfo { | ||||
|                         fmt: format!("{}", emoji), | ||||
|                         name: emoji.name.to_string(), | ||||
|                     }) | ||||
|                     .collect::<Vec<EmojiInfo>>(); | ||||
|  | ||||
|                 { | ||||
|                     let cache = EMOJI_CACHE.get_or_init(|| RwLock::new(HashMap::new())); | ||||
|                     let mut write_lock = cache.write().await; | ||||
|                     write_lock.insert( | ||||
|                         GuildId::new(id), | ||||
|                         EmojiCache { emojis: emojis.clone(), timestamp: Instant::now() }, | ||||
|                     ); | ||||
|                 } | ||||
|  | ||||
|                 Ok(json!(emojis)) | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 warn!("Could not fetch emojis from {}: {:?}", id, e); | ||||
|  | ||||
|                 json_err!("Could not get emojis") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,14 +1,17 @@ | ||||
| mod channels; | ||||
| mod emojis; | ||||
| mod reminders; | ||||
| mod roles; | ||||
| mod templates; | ||||
| pub mod todos; | ||||
|  | ||||
| use std::env; | ||||
|  | ||||
| pub use channels::*; | ||||
| pub use channels::get_guild_channels; | ||||
| pub use emojis::get_guild_emojis; | ||||
| pub use reminders::*; | ||||
| use rocket::{get, http::CookieJar, serde::json::json, State}; | ||||
| pub use roles::*; | ||||
| pub use roles::get_guild_roles; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{GuildId, RoleId}, | ||||
|   | ||||
| @@ -17,7 +17,9 @@ use crate::web::{ | ||||
|     consts::MIN_INTERVAL, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::{ | ||||
|         dashboard::{create_database_channel, create_reminder, PatchReminder, Reminder}, | ||||
|         dashboard::{ | ||||
|             create_database_channel, create_reminder, CreateReminder, GetReminder, PatchReminder, | ||||
|         }, | ||||
|         JsonResult, | ||||
|     }, | ||||
|     Database, | ||||
| @@ -26,7 +28,7 @@ use crate::web::{ | ||||
| #[post("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn create_guild_reminder( | ||||
|     id: u64, | ||||
|     reminder: Json<Reminder>, | ||||
|     reminder: Json<CreateReminder>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| @@ -78,9 +80,9 @@ pub async fn get_reminders( | ||||
|                 .join(","); | ||||
|  | ||||
|             sqlx::query_as_unchecked!( | ||||
|                 Reminder, | ||||
|                 "SELECT | ||||
|                  reminders.attachment, | ||||
|                 GetReminder, | ||||
|                 " | ||||
|                 SELECT | ||||
|                  reminders.attachment_name, | ||||
|                  reminders.avatar, | ||||
|                  channels.channel, | ||||
| @@ -192,7 +194,7 @@ pub async fn edit_reminder( | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                     json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .days | ||||
|                 .unwrap_or(0), | ||||
| @@ -206,7 +208,7 @@ pub async fn edit_reminder( | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                     json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .months | ||||
|                 .unwrap_or(0), | ||||
| @@ -220,7 +222,7 @@ pub async fn edit_reminder( | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                     json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .seconds | ||||
|                 .unwrap_or(0), | ||||
| @@ -249,7 +251,7 @@ pub async fn edit_reminder( | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             warn!("Error updating reminder interval: {:?}", e); | ||||
|             json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|             json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] }) | ||||
|         })?; | ||||
|     } | ||||
|  | ||||
| @@ -321,8 +323,9 @@ pub async fn edit_reminder( | ||||
|     } | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         Reminder, | ||||
|         "SELECT reminders.attachment, | ||||
|         GetReminder, | ||||
|         " | ||||
|         SELECT | ||||
|          reminders.attachment_name, | ||||
|          reminders.avatar, | ||||
|          channels.channel, | ||||
| @@ -361,7 +364,7 @@ pub async fn edit_reminder( | ||||
|         Err(e) => { | ||||
|             warn!("Error exiting `edit_reminder': {:?}", e); | ||||
|  | ||||
|             Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})) | ||||
|             Err(json!({"reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"]})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										224
									
								
								src/web/routes/dashboard/api/guild/todos.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								src/web/routes/dashboard/api/guild/todos.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,224 @@ | ||||
| use log::warn; | ||||
| use rocket::{ | ||||
|     delete, get, | ||||
|     http::CookieJar, | ||||
|     patch, post, | ||||
|     serde::json::{json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     all::{ChannelId, GuildId}, | ||||
|     prelude::Context, | ||||
| }; | ||||
|  | ||||
| use crate::web::{ | ||||
|     check_authorization, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::{dashboard::check_channel_matches_guild, JsonResult}, | ||||
|     string_opt, | ||||
| }; | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct CreateTodo { | ||||
|     #[serde(with = "string_opt")] | ||||
|     channel_id: Option<u64>, | ||||
|     value: String, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| pub struct GetTodo { | ||||
|     id: u32, | ||||
|     #[serde(with = "string_opt")] | ||||
|     channel_id: Option<u64>, | ||||
|     value: String, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct UpdateTodo { | ||||
|     value: String, | ||||
| } | ||||
|  | ||||
| #[post("/api/guild/<id>/todos", data = "<todo>")] | ||||
| pub async fn create_todo( | ||||
|     id: u64, | ||||
|     todo: Json<CreateTodo>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let guild_id = GuildId::new(id); | ||||
|     if todo.value.len() > 2000 { | ||||
|         return json_err!("Value too long"); | ||||
|     } | ||||
|  | ||||
|     match todo.channel_id { | ||||
|         Some(channel_id) => { | ||||
|             if !check_channel_matches_guild(ctx, ChannelId::new(channel_id), guild_id) { | ||||
|                 warn!("Channel {} not found for guild {}", channel_id, guild_id); | ||||
|  | ||||
|                 return json_err!("Channel not found"); | ||||
|             } | ||||
|  | ||||
|             sqlx::query!( | ||||
|                 " | ||||
|                 INSERT INTO todos (guild_id, channel_id, value) | ||||
|                 VALUES ( | ||||
|                     (SELECT id FROM guilds WHERE guild = ?), | ||||
|                     (SELECT id FROM channels WHERE channel = ?), | ||||
|                     ? | ||||
|                 ) | ||||
|                 ", | ||||
|                 id, | ||||
|                 channel_id, | ||||
|                 todo.value | ||||
|             ) | ||||
|             .execute(transaction.executor()) | ||||
|             .await | ||||
|             .map_err(|e| { | ||||
|                 warn!("Error creating todo: {:?}", e); | ||||
|                 json!({"errors": vec!["Unknown error"]}) | ||||
|             })?; | ||||
|         } | ||||
|  | ||||
|         None => { | ||||
|             sqlx::query!( | ||||
|                 " | ||||
|                 INSERT INTO todos (guild_id, channel_id, value) | ||||
|                 VALUES ( | ||||
|                     (SELECT id FROM guilds WHERE guild = ?), | ||||
|                     NULL, | ||||
|                     ? | ||||
|                 ) | ||||
|                 ", | ||||
|                 id, | ||||
|                 todo.value | ||||
|             ) | ||||
|             .execute(transaction.executor()) | ||||
|             .await | ||||
|             .map_err(|e| { | ||||
|                 warn!("Error creating todo: {:?}", e); | ||||
|                 json!({"errors": vec!["Unknown error"]}) | ||||
|             })?; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if let Err(e) = transaction.commit().await { | ||||
|         warn!("Couldn't commit transaction: {:?}", e); | ||||
|         return json_err!("Couldn't commit transaction."); | ||||
|     } | ||||
|  | ||||
|     Ok(json!({})) | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/todos")] | ||||
| pub async fn get_todo( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     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/<guild_id>/todos/<todo_id>", data = "<todo>")] | ||||
| pub async fn update_todo( | ||||
|     guild_id: u64, | ||||
|     todo_id: u64, | ||||
|     todo: Json<UpdateTodo>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), guild_id).await?; | ||||
|  | ||||
|     if todo.value.len() > 2000 { | ||||
|         return json_err!("Value too long"); | ||||
|     } | ||||
|  | ||||
|     sqlx::query!( | ||||
|         " | ||||
|         UPDATE todos | ||||
|         SET value = ? | ||||
|         WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) | ||||
|             AND id = ? | ||||
|         ", | ||||
|         todo.value, | ||||
|         guild_id, | ||||
|         todo_id, | ||||
|     ) | ||||
|     .execute(transaction.executor()) | ||||
|     .await | ||||
|     .map_err(|e| { | ||||
|         warn!("Error updating todo: {:?}", e); | ||||
|         json!({"errors": vec!["Unknown error"]}) | ||||
|     })?; | ||||
|  | ||||
|     if let Err(e) = transaction.commit().await { | ||||
|         warn!("Couldn't commit transaction: {:?}", e); | ||||
|         return json_err!("Couldn't commit transaction."); | ||||
|     } | ||||
|  | ||||
|     Ok(json!({})) | ||||
| } | ||||
|  | ||||
| #[delete("/api/guild/<guild_id>/todos/<todo_id>")] | ||||
| pub async fn delete_todo( | ||||
|     guild_id: u64, | ||||
|     todo_id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), guild_id).await?; | ||||
|  | ||||
|     sqlx::query!( | ||||
|         " | ||||
|         DELETE FROM todos | ||||
|         WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) | ||||
|             AND id = ? | ||||
|         ", | ||||
|         guild_id, | ||||
|         todo_id, | ||||
|     ) | ||||
|     .execute(transaction.executor()) | ||||
|     .await | ||||
|     .map_err(|e| { | ||||
|         warn!("Error deleting todo: {:?}", e); | ||||
|         json!({"errors": vec!["Unknown error"]}) | ||||
|     })?; | ||||
|  | ||||
|     if let Err(e) = transaction.commit().await { | ||||
|         warn!("Couldn't commit transaction: {:?}", e); | ||||
|         return json_err!("Couldn't commit transaction."); | ||||
|     } | ||||
|  | ||||
|     Ok(json!({})) | ||||
| } | ||||
| @@ -19,8 +19,7 @@ use crate::web::{ | ||||
|     guards::transaction::Transaction, | ||||
|     routes::{ | ||||
|         dashboard::{ | ||||
|             create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, | ||||
|             TodoCsv, | ||||
|             create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv, | ||||
|         }, | ||||
|         JsonResult, | ||||
|     }, | ||||
| @@ -150,7 +149,7 @@ pub(crate) async fn import_reminders( | ||||
|  | ||||
|                         match channel_id.parse::<u64>() { | ||||
|                             Ok(channel_id) => { | ||||
|                                 let reminder = Reminder { | ||||
|                                 let reminder = CreateReminder { | ||||
|                                     attachment: record.attachment, | ||||
|                                     attachment_name: record.attachment_name, | ||||
|                                     avatar: record.avatar, | ||||
| @@ -177,7 +176,6 @@ pub(crate) async fn import_reminders( | ||||
|                                     name: record.name, | ||||
|                                     restartable: record.restartable, | ||||
|                                     tts: record.tts, | ||||
|                                     uid: generate_uid(), | ||||
|                                     username: record.username, | ||||
|                                     utc_time: record.utc_time, | ||||
|                                 }; | ||||
|   | ||||
| @@ -29,7 +29,7 @@ use crate::web::{ | ||||
|     }, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::JsonResult, | ||||
|     Error, | ||||
|     string, Error, | ||||
| }; | ||||
|  | ||||
| pub mod api; | ||||
| @@ -146,9 +146,39 @@ pub struct EmbedField { | ||||
|     inline: bool, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct Reminder { | ||||
| #[derive(Deserialize)] | ||||
| pub struct CreateReminder { | ||||
|     attachment: Option<Attachment>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     #[serde(with = "string")] | ||||
|     channel: u64, | ||||
|     content: String, | ||||
|     embed_author: String, | ||||
|     embed_author_url: Option<String>, | ||||
|     embed_color: u32, | ||||
|     embed_description: String, | ||||
|     embed_footer: String, | ||||
|     embed_footer_url: Option<String>, | ||||
|     embed_image_url: Option<String>, | ||||
|     embed_thumbnail_url: Option<String>, | ||||
|     embed_title: String, | ||||
|     embed_fields: Option<Json<Vec<EmbedField>>>, | ||||
|     enabled: bool, | ||||
|     expires: Option<NaiveDateTime>, | ||||
|     interval_seconds: Option<u32>, | ||||
|     interval_days: Option<u32>, | ||||
|     interval_months: Option<u32>, | ||||
|     #[serde(default = "name_default")] | ||||
|     name: String, | ||||
|     restartable: bool, | ||||
|     tts: bool, | ||||
|     username: Option<String>, | ||||
|     utc_time: NaiveDateTime, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| pub struct GetReminder { | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     #[serde(with = "string")] | ||||
| @@ -318,30 +348,6 @@ where | ||||
|     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)] | ||||
| pub struct DeleteReminder { | ||||
|     uid: String, | ||||
| @@ -363,7 +369,7 @@ pub(crate) async fn create_reminder( | ||||
|     transaction: &mut Transaction<'_>, | ||||
|     guild_id: GuildId, | ||||
|     user_id: UserId, | ||||
|     reminder: Reminder, | ||||
|     reminder: CreateReminder, | ||||
| ) -> JsonResult { | ||||
|     // check guild in db | ||||
|     match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get()) | ||||
| @@ -382,23 +388,14 @@ pub(crate) async fn create_reminder( | ||||
|         _ => {} | ||||
|     } | ||||
|  | ||||
|     { | ||||
|         // validate channel | ||||
|         let channel = ChannelId::new(reminder.channel).to_channel_cached(&ctx.cache); | ||||
|         let channel_exists = channel.is_some(); | ||||
|  | ||||
|         let channel_matches_guild = | ||||
|             channel.map_or(false, |c| c.guild(&ctx.cache).map_or(false, |c| c.id == guild_id)); | ||||
|  | ||||
|         if !channel_matches_guild || !channel_exists { | ||||
|     if !check_channel_matches_guild(ctx, ChannelId::new(reminder.channel), guild_id) { | ||||
|         warn!( | ||||
|                 "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", | ||||
|                 reminder.channel, guild_id, channel_exists | ||||
|             "Error in `create_reminder`: channel {} not found for guild {}", | ||||
|             reminder.channel, guild_id | ||||
|         ); | ||||
|  | ||||
|         return Err(json!({"error": "Channel not found"})); | ||||
|     } | ||||
|     } | ||||
|  | ||||
|     let channel = | ||||
|         create_database_channel(&ctx, ChannelId::new(reminder.channel), transaction).await; | ||||
| @@ -545,9 +542,9 @@ pub(crate) async fn create_reminder( | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => sqlx::query_as_unchecked!( | ||||
|             Reminder, | ||||
|             "SELECT | ||||
|              reminders.attachment, | ||||
|             GetReminder, | ||||
|             " | ||||
|             SELECT | ||||
|              reminders.attachment_name, | ||||
|              reminders.avatar, | ||||
|              channels.channel, | ||||
| @@ -595,6 +592,21 @@ pub(crate) async fn create_reminder( | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn check_channel_matches_guild(ctx: &Context, channel_id: ChannelId, guild_id: GuildId) -> bool { | ||||
|     // validate channel | ||||
|     let channel = channel_id.to_channel_cached(&ctx.cache); | ||||
|     let channel_exists = channel.is_some(); | ||||
|  | ||||
|     if !channel_exists { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     let channel_matches_guild = | ||||
|         channel.map_or(false, |c| c.guild(&ctx.cache).map_or(false, |g| g.id == guild_id)); | ||||
|  | ||||
|     channel_matches_guild | ||||
| } | ||||
|  | ||||
| async fn create_database_channel( | ||||
|     ctx: impl CacheHttp, | ||||
|     channel: ChannelId, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user