Compare commits
	
		
			49 Commits
		
	
	
		
			1.7.4
			...
			218be2f0b1
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 218be2f0b1 | ||
|  | d7515f3611 | ||
|  | 6ae1096d79 | ||
|  | 1f0d7adae3 | ||
|  | fc96ae526f | ||
|  | 8881ef0f85 | ||
|  | 5e82a687f9 | ||
|  | de4ecf8dd6 | ||
|  | 064efd4386 | ||
|  | 65b8ba3b47 | ||
| 9d452ed8cb | |||
|  | 441419b92b | ||
|  | aecf2c15be | ||
|  | 79da56c794 | ||
|  | ef10902c1e | ||
|  | c277f85c2a | ||
|  | 035653c7fa | ||
|  | 6358bc3deb | ||
|  | 9f5066f982 | ||
|  | 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 | ||
|  | 67b6f30c62 | ||
|  | 8ae311190f | ||
|  | 016164affb | 
							
								
								
									
										838
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										838
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										10
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | |||||||
| [package] | [package] | ||||||
| name = "reminder-rs" | name = "reminder-rs" | ||||||
| version = "1.7.4" | version = "1.7.23" | ||||||
| authors = ["Jude Southworth <judesouthworth@pm.me>"] | authors = ["Jude Southworth <judesouthworth@pm.me>"] | ||||||
| edition = "2021" | edition = "2021" | ||||||
| license = "AGPL-3.0 only" | license = "AGPL-3.0 only" | ||||||
| @@ -15,7 +15,7 @@ regex = "1.10" | |||||||
| log = "0.4" | log = "0.4" | ||||||
| env_logger = "0.11" | env_logger = "0.11" | ||||||
| chrono = "0.4" | chrono = "0.4" | ||||||
| chrono-tz = { version = "0.8", features = ["serde"] } | chrono-tz = { version = "0.9", features = ["serde"] } | ||||||
| lazy_static = "1.4" | lazy_static = "1.4" | ||||||
| num-integer = "0.1" | num-integer = "0.1" | ||||||
| serde = "1.0" | serde = "1.0" | ||||||
| @@ -25,7 +25,7 @@ rmp-serde = "1.1" | |||||||
| rand = "0.8" | rand = "0.8" | ||||||
| levenshtein = "1.0" | levenshtein = "1.0" | ||||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] } | sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] } | ||||||
| base64 = "0.21" | base64 = "0.22" | ||||||
| secrecy = "0.8.0" | secrecy = "0.8.0" | ||||||
| futures = "0.3.30" | futures = "0.3.30" | ||||||
| prometheus = "0.13.3" | prometheus = "0.13.3" | ||||||
| @@ -34,7 +34,7 @@ rocket_dyn_templates = { version = "0.1.0", features = ["tera"] } | |||||||
| serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||||
| oauth2 = "4" | oauth2 = "4" | ||||||
| csv = "1.2" | csv = "1.2" | ||||||
| axum = "0.7" | sd-notify = "0.4.1" | ||||||
|  |  | ||||||
| [dependencies.extract_derive] | [dependencies.extract_derive] | ||||||
| path = "extract_derive" | path = "extract_derive" | ||||||
| @@ -59,8 +59,6 @@ assets = [ | |||||||
|     ["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"], |     ["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"], | ||||||
|     ["conf/default.env", "etc/reminder-rs/config.env", "600"], |     ["conf/default.env", "etc/reminder-rs/config.env", "600"], | ||||||
|     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], |     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], | ||||||
|     ["healthcheck", "lib/reminder-rs/healthcheck", "755"], |  | ||||||
|     ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"], |  | ||||||
|     # ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] |     # ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] | ||||||
| ] | ] | ||||||
| conf-files = [ | conf-files = [ | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								healthcheck
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								healthcheck
									
									
									
									
									
								
							| @@ -1,13 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
|  |  | ||||||
| export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n') |  | ||||||
|  |  | ||||||
| REGEX='mysql://([A-Za-z]+)@(.+)/(.+)' |  | ||||||
| [[ $DATABASE_URL =~ $REGEX ]] |  | ||||||
|  |  | ||||||
| VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'") |  | ||||||
|  |  | ||||||
| if [ "$VAR" -gt 0 ] |  | ||||||
| then |  | ||||||
|   echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL" |  | ||||||
| fi |  | ||||||
| @@ -1,12 +1,22 @@ | |||||||
| server { | server { | ||||||
|     server_name www.reminder-bot.com; |     server_name www.reminder-bot.com; | ||||||
| 
 | 
 | ||||||
|     return 301 $scheme://reminder-bot.com$request_uri; |     return 301 https://reminder-bot.com$request_uri; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| server { | server { | ||||||
|     listen 80; |     listen 80; | ||||||
|     server_name reminder-bot.com; |     server_name beta.reminder-bot.com; | ||||||
|  | 
 | ||||||
|  |     return 301 https://reminder-bot.com$request_uri; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | server { | ||||||
|  |     listen 443 ssl; | ||||||
|  |     server_name beta.reminder-bot.com; | ||||||
|  | 
 | ||||||
|  |     ssl_certificate /etc/letsencrypt/live/beta.reminder-bot.com/fullchain.pem; | ||||||
|  |     ssl_certificate_key /etc/letsencrypt/live/beta.reminder-bot.com/privkey.pem; | ||||||
| 
 | 
 | ||||||
|     return 301 https://reminder-bot.com$request_uri; |     return 301 https://reminder-bot.com$request_uri; | ||||||
| } | } | ||||||
| @@ -25,6 +35,8 @@ server { | |||||||
|     proxy_buffers 4 256k; |     proxy_buffers 4 256k; | ||||||
|     proxy_busy_buffers_size 256k; |     proxy_busy_buffers_size 256k; | ||||||
| 
 | 
 | ||||||
|  |     client_max_body_size 10M; | ||||||
|  | 
 | ||||||
|     location / { |     location / { | ||||||
|         proxy_pass http://localhost:18920; |         proxy_pass http://localhost:18920; | ||||||
|         proxy_redirect off; |         proxy_redirect off; | ||||||
| @@ -26,7 +26,6 @@ | |||||||
| 	<link rel="stylesheet" href="/static/css/fa.css"> | 	<link rel="stylesheet" href="/static/css/fa.css"> | ||||||
| 	<link rel="stylesheet" href="/static/css/font.css"> | 	<link rel="stylesheet" href="/static/css/font.css"> | ||||||
| 	<link rel="stylesheet" href="/static/css/style.css"> | 	<link rel="stylesheet" href="/static/css/style.css"> | ||||||
| 	<link rel="stylesheet" href="/static/css/dtsel.css"> |  | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
| 	<div id="app"></div> | 	<div id="app"></div> | ||||||
|   | |||||||
							
								
								
									
										1321
									
								
								reminder-dashboard/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1321
									
								
								reminder-dashboard/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,5 +1,4 @@ | |||||||
| import axios from "axios"; | import axios from "axios"; | ||||||
| import { DateTime } from "luxon"; |  | ||||||
|  |  | ||||||
| type UserInfo = { | type UserInfo = { | ||||||
|     name: string; |     name: string; | ||||||
| @@ -49,6 +48,21 @@ export type Reminder = { | |||||||
|     utc_time: string; |     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 = { | export type ChannelInfo = { | ||||||
|     id: string; |     id: string; | ||||||
|     name: string; |     name: string; | ||||||
| @@ -59,6 +73,11 @@ type RoleInfo = { | |||||||
|     name: string; |     name: string; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | type EmojiInfo = { | ||||||
|  |     fmt: string; | ||||||
|  |     name: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
| type Template = { | type Template = { | ||||||
|     id: number; |     id: number; | ||||||
|     name: string; |     name: string; | ||||||
| @@ -81,7 +100,7 @@ type Template = { | |||||||
|  |  | ||||||
| const USER_INFO_STALE_TIME = 120_000; | const USER_INFO_STALE_TIME = 120_000; | ||||||
| const GUILD_INFO_STALE_TIME = 300_000; | const GUILD_INFO_STALE_TIME = 300_000; | ||||||
| const OTHER_STALE_TIME = 15_000; | const OTHER_STALE_TIME = 120_000; | ||||||
|  |  | ||||||
| export const fetchUserInfo = () => ({ | export const fetchUserInfo = () => ({ | ||||||
|     queryKey: ["USER_INFO"], |     queryKey: ["USER_INFO"], | ||||||
| @@ -110,9 +129,13 @@ export const fetchGuildInfo = (guild: string) => ({ | |||||||
| export const fetchGuildChannels = (guild: string) => ({ | export const fetchGuildChannels = (guild: string) => ({ | ||||||
|     queryKey: ["GUILD_CHANNELS", guild], |     queryKey: ["GUILD_CHANNELS", guild], | ||||||
|     queryFn: () => |     queryFn: () => | ||||||
|         axios.get(`/dashboard/api/guild/${guild}/channels`).then((resp) => resp.data) as Promise< |         axios | ||||||
|             ChannelInfo[] |             .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, |     staleTime: GUILD_INFO_STALE_TIME, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -125,6 +148,15 @@ export const fetchGuildRoles = (guild: string) => ({ | |||||||
|     staleTime: GUILD_INFO_STALE_TIME, |     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) => ({ | export const fetchGuildReminders = (guild: string) => ({ | ||||||
|     queryKey: ["GUILD_REMINDERS", guild], |     queryKey: ["GUILD_REMINDERS", guild], | ||||||
|     queryFn: () => |     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 = () => ({ | export const fetchUserReminders = () => ({ | ||||||
|     queryKey: ["USER_REMINDERS"], |     queryKey: ["USER_REMINDERS"], | ||||||
|     queryFn: () => |     queryFn: () => | ||||||
|   | |||||||
| @@ -1,21 +1,28 @@ | |||||||
| import { useEffect, useMemo } from "preact/hooks"; | import { useEffect, useMemo } from "preact/hooks"; | ||||||
| import { useQuery } from "react-query"; | import { useQuery } from "react-query"; | ||||||
| import { fetchGuildChannels, fetchGuildRoles } from "../../api"; | import { fetchGuildChannels, fetchGuildRoles, fetchGuildEmojis } from "../../api"; | ||||||
| import Tribute from "tributejs"; | import Tribute from "tributejs"; | ||||||
| import { useGuild } from "./useGuild"; | import { useGuild } from "./useGuild"; | ||||||
|  |  | ||||||
| export const Mentions = ({ input }) => { | export const Mentions = ({ input }) => { | ||||||
|     const guild = useGuild(); |     const guild = useGuild(); | ||||||
|  |  | ||||||
|  |     return <>{guild && <_Mentions guild={guild} input={input} />}</>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const _Mentions = ({ guild, input }) => { | ||||||
|     const { data: roles } = useQuery(fetchGuildRoles(guild)); |     const { data: roles } = useQuery(fetchGuildRoles(guild)); | ||||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); |     const { data: channels } = useQuery(fetchGuildChannels(guild)); | ||||||
|  |     const { data: emojis } = useQuery(fetchGuildEmojis(guild)); | ||||||
|  |  | ||||||
|     const tribute = useMemo(() => { |     const tribute = useMemo(() => { | ||||||
|         return new Tribute({ |         return new Tribute({ | ||||||
|             collection: [ |             collection: [ | ||||||
|                 { |                 { | ||||||
|                     trigger: "@", |                     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, |                     allowSpaces: true, | ||||||
|                     selectTemplate: (item) => `<@&${item.original.value}>`, |                     selectTemplate: (item) => `<@&${item.original.value}>`, | ||||||
|                     menuItemTemplate: (item) => `@${item.original.key}`, |                     menuItemTemplate: (item) => `@${item.original.key}`, | ||||||
| @@ -27,9 +34,16 @@ export const Mentions = ({ input }) => { | |||||||
|                     selectTemplate: (item) => `<#${item.original.value}>`, |                     selectTemplate: (item) => `<#${item.original.value}>`, | ||||||
|                     menuItemTemplate: (item) => `#${item.original.key}`, |                     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(() => { |     useEffect(() => { | ||||||
|         tribute.detach(input.current); |         tribute.detach(input.current); | ||||||
|   | |||||||
| @@ -6,6 +6,8 @@ import { Guild } from "../Guild"; | |||||||
| import { FlashProvider } from "./FlashProvider"; | import { FlashProvider } from "./FlashProvider"; | ||||||
| import { TimezoneProvider } from "./TimezoneProvider"; | import { TimezoneProvider } from "./TimezoneProvider"; | ||||||
| import { User } from "../User"; | import { User } from "../User"; | ||||||
|  | import { GuildReminders } from "../Guild/GuildReminders"; | ||||||
|  | import { GuildTodos } from "../Guild/GuildTodos"; | ||||||
|  |  | ||||||
| export function App() { | export function App() { | ||||||
|     const queryClient = new QueryClient(); |     const queryClient = new QueryClient(); | ||||||
| @@ -18,13 +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 path={"/:guild/reminders"} component={Guild}></Route> |                                         <Route path={"/@me/reminders"} component={User}></Route> | ||||||
|                                     <Route> |                                         <Route | ||||||
|                                         <Welcome /> |                                             path={"/:guild/reminders"} | ||||||
|                                     </Route> |                                             component={() => ( | ||||||
|                                 </Switch> |                                                 <Guild> | ||||||
|  |                                                     <GuildReminders /> | ||||||
|  |                                                 </Guild> | ||||||
|  |                                             )} | ||||||
|  |                                         ></Route> | ||||||
|  |                                         <Route | ||||||
|  |                                             path={"/:guild/todos"} | ||||||
|  |                                             component={() => ( | ||||||
|  |                                                 <Guild> | ||||||
|  |                                                     <GuildTodos /> | ||||||
|  |                                                 </Guild> | ||||||
|  |                                             )} | ||||||
|  |                                         ></Route> | ||||||
|  |                                         <Route> | ||||||
|  |                                             <Welcome /> | ||||||
|  |                                         </Route> | ||||||
|  |                                     </Switch> | ||||||
|  |                                 </div> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                     </Router> |                     </Router> | ||||||
|   | |||||||
| @@ -5,7 +5,12 @@ export const GuildError = () => { | |||||||
|                 <div class="container has-text-centered"> |                 <div class="container has-text-centered"> | ||||||
|                     <p class="title">We couldn't get this server's data</p> |                     <p class="title">We couldn't get this server's data</p> | ||||||
|                     <p class="subtitle"> |                     <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> |                     </p> | ||||||
|                     <a |                     <a | ||||||
|                         class="button is-size-4 is-rounded is-success" |                         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 { fetchGuildChannels, fetchGuildReminders } from "../../api"; | ||||||
| import { EditReminder } from "../Reminder/EditReminder"; | import { EditReminder } from "../Reminder/EditReminder"; | ||||||
| import { CreateReminder } from "../Reminder/CreateReminder"; | import { CreateReminder } from "../Reminder/CreateReminder"; | ||||||
| import { useState } from "preact/hooks"; | import { useCallback, useState } from "preact/hooks"; | ||||||
| import { Loader } from "../Loader"; | import { Loader } from "../Loader"; | ||||||
| import { useGuild } from "../App/useGuild"; | import { useGuild } from "../App/useGuild"; | ||||||
|  |  | ||||||
| @@ -24,118 +24,120 @@ export const GuildReminders = () => { | |||||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); |     const { data: channels } = useQuery(fetchGuildChannels(guild)); | ||||||
|  |  | ||||||
|     const [collapsed, setCollapsed] = useState(false); |     const [collapsed, setCollapsed] = useState(false); | ||||||
|     const [sort, setSort] = useState(Sort.Time); |     const [sort, _setSort] = useState(Sort.Time); | ||||||
|  |     const queryClient = useQueryClient(); | ||||||
|  |  | ||||||
|     let prevReminder = null; |     let prevReminder = null; | ||||||
|  |  | ||||||
|  |     const setSort = useCallback((sort) => { | ||||||
|  |         queryClient.invalidateQueries(["GUILD_REMINDERS"]); | ||||||
|  |         _setSort(sort); | ||||||
|  |     }, []); | ||||||
|  |  | ||||||
|     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> | ||||||
|         </> |         </> | ||||||
|     ); |     ); | ||||||
|   | |||||||
							
								
								
									
										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 { useQuery } from "react-query"; | ||||||
| import { fetchGuildInfo } from "../../api"; | import { fetchGuildInfo } from "../../api"; | ||||||
| import { GuildReminders } from "./GuildReminders"; |  | ||||||
| import { GuildError } from "./GuildError"; | import { GuildError } from "./GuildError"; | ||||||
| import { createPortal } 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 { usePathname } from "wouter/use-browser-location"; | ||||||
|  |  | ||||||
| export const Guild = () => { | import "./index.scss"; | ||||||
|  |  | ||||||
|  | export const Guild = ({ children }: PropsWithChildren) => { | ||||||
|     const guild = useGuild(); |     const guild = useGuild(); | ||||||
|     const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild)); |     const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild)); | ||||||
|  |  | ||||||
| @@ -16,11 +19,32 @@ export const Guild = () => { | |||||||
|         return <GuildError />; |         return <GuildError />; | ||||||
|     } else { |     } else { | ||||||
|         const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar")); |         const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar")); | ||||||
|  |         const path = usePathname(); | ||||||
|  |  | ||||||
|         return ( |         return ( | ||||||
|             <> |             <> | ||||||
|                 {importModal} |                 {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 { useParams } from "wouter"; | ||||||
| import axios from "axios"; | import axios from "axios"; | ||||||
| import { useFlash } from "../App/FlashContext"; | import { useFlash } from "../App/FlashContext"; | ||||||
|  | import { useGuild } from "../App/useGuild"; | ||||||
|  | import { useQueryClient } from "react-query"; | ||||||
|  |  | ||||||
| export const Import = () => { | export const Import = () => { | ||||||
|     const [modalOpen, setModalOpen] = useState(false); |     const [modalOpen, setModalOpen] = useState(false); | ||||||
| @@ -27,7 +29,7 @@ export const Import = () => { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| const ImportModal = ({ setModalOpen }) => { | const ImportModal = ({ setModalOpen }) => { | ||||||
|     const { guild } = useParams(); |     const guild = useGuild(); | ||||||
|  |  | ||||||
|     const aRef = useRef<HTMLAnchorElement>(); |     const aRef = useRef<HTMLAnchorElement>(); | ||||||
|     const inputRef = useRef<HTMLInputElement>(); |     const inputRef = useRef<HTMLInputElement>(); | ||||||
| @@ -35,6 +37,8 @@ const ImportModal = ({ setModalOpen }) => { | |||||||
|  |  | ||||||
|     const [isImporting, setIsImporting] = useState(false); |     const [isImporting, setIsImporting] = useState(false); | ||||||
|  |  | ||||||
|  |     const queryClient = useQueryClient(); | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <Modal |         <Modal | ||||||
|             setModalOpen={setModalOpen} |             setModalOpen={setModalOpen} | ||||||
| @@ -121,7 +125,7 @@ const ImportModal = ({ setModalOpen }) => { | |||||||
|  |  | ||||||
|                             axios |                             axios | ||||||
|                                 .put(`/dashboard/api/guild/${guild}/export/reminders`, { |                                 .put(`/dashboard/api/guild/${guild}/export/reminders`, { | ||||||
|                                     body: JSON.stringify({ body: dataUrl.split(",")[1] }), |                                     body: dataUrl.split(",")[1], | ||||||
|                                 }) |                                 }) | ||||||
|                                 .then(({ data }) => { |                                 .then(({ data }) => { | ||||||
|                                     setIsImporting(false); |                                     setIsImporting(false); | ||||||
| @@ -130,6 +134,9 @@ const ImportModal = ({ setModalOpen }) => { | |||||||
|                                         flash({ message: data.error, type: "error" }); |                                         flash({ message: data.error, type: "error" }); | ||||||
|                                     } else { |                                     } else { | ||||||
|                                         flash({ message: data.message, type: "success" }); |                                         flash({ message: data.message, type: "success" }); | ||||||
|  |                                         queryClient.invalidateQueries({ | ||||||
|  |                                             queryKey: ["GUILD_REMINDERS", guild], | ||||||
|  |                                         }); | ||||||
|                                     } |                                     } | ||||||
|                                 }) |                                 }) | ||||||
|                                 .then(() => { |                                 .then(() => { | ||||||
|   | |||||||
| @@ -39,12 +39,42 @@ export const Attachment = () => { | |||||||
|                     }} |                     }} | ||||||
|                 ></input> |                 ></input> | ||||||
|                 <span class="file-cta"> |                 <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"> |                     <span class="file-icon"> | ||||||
|                         <i class="fas fa-upload"></i> |                         <i class="fas fa-upload"></i> | ||||||
|                     </span> |                     </span> | ||||||
|                 </span> |                 </span> | ||||||
|             </label> |             </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> |         </div> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -18,6 +18,12 @@ export const CreateButtonRow = () => { | |||||||
|     const queryClient = useQueryClient(); |     const queryClient = useQueryClient(); | ||||||
|     const mutation = useMutation({ |     const mutation = useMutation({ | ||||||
|         ...(guild ? postGuildReminder(guild) : postUserReminder()), |         ...(guild ? postGuildReminder(guild) : postUserReminder()), | ||||||
|  |         onError: (error) => { | ||||||
|  |             flash({ | ||||||
|  |                 message: `An error occurred: ${error}`, | ||||||
|  |                 type: "error", | ||||||
|  |             }); | ||||||
|  |         }, | ||||||
|         onSuccess: (data) => { |         onSuccess: (data) => { | ||||||
|             if (data.error) { |             if (data.error) { | ||||||
|                 flash({ |                 flash({ | ||||||
|   | |||||||
| @@ -12,24 +12,19 @@ export const EditButtonRow = () => { | |||||||
|     const [reminder, setReminder] = useReminder(); |     const [reminder, setReminder] = useReminder(); | ||||||
|  |  | ||||||
|     const [recentlySaved, setRecentlySaved] = useState(false); |     const [recentlySaved, setRecentlySaved] = useState(false); | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|  |  | ||||||
|     const iconFlashTimeout = useRef(0); |     const iconFlashTimeout = useRef(0); | ||||||
|  |  | ||||||
|     const flash = useFlash(); |     const flash = useFlash(); | ||||||
|     const mutation = useMutation({ |     const mutation = useMutation({ | ||||||
|         ...(guild ? patchGuildReminder(guild) : patchUserReminder()), |         ...(guild ? patchGuildReminder(guild) : patchUserReminder()), | ||||||
|  |         onError: (error) => { | ||||||
|  |             flash({ | ||||||
|  |                 message: `An error occurred: ${error}`, | ||||||
|  |                 type: "error", | ||||||
|  |             }); | ||||||
|  |         }, | ||||||
|         onSuccess: (response) => { |         onSuccess: (response) => { | ||||||
|             if (guild) { |  | ||||||
|                 queryClient.invalidateQueries({ |  | ||||||
|                     queryKey: ["GUILD_REMINDERS", guild], |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 queryClient.invalidateQueries({ |  | ||||||
|                     queryKey: ["USER_REMINDERS"], |  | ||||||
|                 }); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             if (iconFlashTimeout.current !== null) { |             if (iconFlashTimeout.current !== null) { | ||||||
|                 clearTimeout(iconFlashTimeout.current); |                 clearTimeout(iconFlashTimeout.current); | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -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 ( | ||||||
|   | |||||||
| @@ -38,13 +38,44 @@ function defaultReminder(): Reminder { | |||||||
|         tts: false, |         tts: false, | ||||||
|         uid: "", |         uid: "", | ||||||
|         username: "", |         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"), | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
| export const CreateReminder = () => { | export const CreateReminder = () => { | ||||||
|     const guild = useGuild(); |     const guild = useGuild(); | ||||||
|  |  | ||||||
|  |     if (guild) { | ||||||
|  |         return <_Guild guild={guild} />; | ||||||
|  |     } else { | ||||||
|  |         return <_User />; | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const _User = () => { | ||||||
|  |     const [reminder, setReminder] = useState(defaultReminder()); | ||||||
|  |     const [collapsed, setCollapsed] = useState(false); | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <ReminderContext.Provider value={[reminder, setReminder]}> | ||||||
|  |             <div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}> | ||||||
|  |                 <TopBar | ||||||
|  |                     isCreating={true} | ||||||
|  |                     toggleCollapsed={() => { | ||||||
|  |                         setCollapsed(!collapsed); | ||||||
|  |                     }} | ||||||
|  |                 /> | ||||||
|  |                 <div class="columns reminder-settings"> | ||||||
|  |                     <Message /> | ||||||
|  |                     <Settings /> | ||||||
|  |                 </div> | ||||||
|  |                 <CreateButtonRow /> | ||||||
|  |             </div> | ||||||
|  |         </ReminderContext.Provider> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const _Guild = ({ guild }) => { | ||||||
|     const [reminder, setReminder] = useState(defaultReminder()); |     const [reminder, setReminder] = useState(defaultReminder()); | ||||||
|     const [collapsed, setCollapsed] = useState(false); |     const [collapsed, setCollapsed] = useState(false); | ||||||
|  |  | ||||||
| @@ -61,6 +92,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); | ||||||
|                     }} |                     }} | ||||||
|   | |||||||
| @@ -28,9 +28,13 @@ export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Prop | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <ReminderContext.Provider value={[reminder, setReminder]}> |         <ReminderContext.Provider value={[reminder, setReminder]} key={reminder.uid}> | ||||||
|             <div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}> |             <div | ||||||
|  |                 class={collapsed ? "reminderContent is-collapsed" : "reminderContent"} | ||||||
|  |                 id={`reminder-${reminder.uid.slice(0, 12)}`} | ||||||
|  |             > | ||||||
|                 <TopBar |                 <TopBar | ||||||
|  |                     isCreating={false} | ||||||
|                     toggleCollapsed={() => { |                     toggleCollapsed={() => { | ||||||
|                         setCollapsed(!collapsed); |                         setCollapsed(!collapsed); | ||||||
|                     }} |                     }} | ||||||
|   | |||||||
| @@ -37,6 +37,11 @@ const ImagePickerModal = ({ setModalOpen, setImage }) => { | |||||||
|             }} |             }} | ||||||
|             onSubmitText={"Save"} |             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 |             <input | ||||||
|                 class="input" |                 class="input" | ||||||
|                 id="urlInput" |                 id="urlInput" | ||||||
|   | |||||||
| @@ -16,28 +16,28 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|     const ref = useRef(null); |     const ref = useRef(null); | ||||||
|  |  | ||||||
|     const [timezone] = useTimezone(); |     const [timezone] = useTimezone(); | ||||||
|     const [time, setTime] = useState( |     const [localTime, setLocalTime] = useState( | ||||||
|         defaultValue ? DateTime.fromISO(defaultValue, { zone: "UTC" }) : null, |         defaultValue ? DateTime.fromISO(defaultValue, { zone: "UTC" }).setZone(timezone) : null, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     const updateTime = useCallback( |     const updateTime = useCallback( | ||||||
|         (upd: TimeUpdate) => { |         (upd: TimeUpdate) => { | ||||||
|             if (upd === null) { |             if (upd === null) { | ||||||
|                 setTime(null); |                 setLocalTime(null); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             let newTime = time; |             let newTime = localTime; | ||||||
|             if (newTime === null) { |             if (newTime === null) { | ||||||
|                 newTime = DateTime.now().setZone("UTC"); |                 newTime = DateTime.now().setZone(timezone); | ||||||
|             } |             } | ||||||
|             setTime(newTime.setZone(timezone).set(upd).setZone("UTC")); |             setLocalTime(newTime.set(upd)); | ||||||
|         }, |         }, | ||||||
|         [time, timezone], |         [localTime, timezone], | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         onInput(time?.setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss")); |         onInput(localTime?.setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss")); | ||||||
|     }, [time]); |     }, [localTime]); | ||||||
|  |  | ||||||
|     const flash = useFlash(); |     const flash = useFlash(); | ||||||
|  |  | ||||||
| @@ -51,14 +51,14 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                     let dt = DateTime.fromISO(pasteValue, { zone: timezone }); |                     let dt = DateTime.fromISO(pasteValue, { zone: timezone }); | ||||||
|  |  | ||||||
|                     if (dt.isValid) { |                     if (dt.isValid) { | ||||||
|                         setTime(dt); |                         setLocalTime(dt); | ||||||
|                         return; |                         return; | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     dt = DateTime.fromSQL(pasteValue); |                     dt = DateTime.fromSQL(pasteValue); | ||||||
|  |  | ||||||
|                     if (dt.isValid) { |                     if (dt.isValid) { | ||||||
|                         setTime(dt); |                         setLocalTime(dt); | ||||||
|                         return; |                         return; | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
| @@ -83,8 +83,8 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                             maxlength={4} |                             maxlength={4} | ||||||
|                             placeholder="YYYY" |                             placeholder="YYYY" | ||||||
|                             value={ |                             value={ | ||||||
|                                 time |                                 localTime | ||||||
|                                     ? time.setZone(timezone).year.toLocaleString("en-US", { |                                     ? localTime.year.toLocaleString("en-US", { | ||||||
|                                           minimumIntegerDigits: 4, |                                           minimumIntegerDigits: 4, | ||||||
|                                           useGrouping: false, |                                           useGrouping: false, | ||||||
|                                       }) |                                       }) | ||||||
| @@ -114,8 +114,8 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                             maxlength={2} |                             maxlength={2} | ||||||
|                             placeholder="MM" |                             placeholder="MM" | ||||||
|                             value={ |                             value={ | ||||||
|                                 time |                                 localTime | ||||||
|                                     ? time.setZone(timezone).month.toLocaleString("en-US", { |                                     ? localTime.month.toLocaleString("en-US", { | ||||||
|                                           minimumIntegerDigits: 2, |                                           minimumIntegerDigits: 2, | ||||||
|                                       }) |                                       }) | ||||||
|                                     : "" |                                     : "" | ||||||
| @@ -144,10 +144,10 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                             maxlength={2} |                             maxlength={2} | ||||||
|                             placeholder="DD" |                             placeholder="DD" | ||||||
|                             value={ |                             value={ | ||||||
|                                 time |                                 localTime | ||||||
|                                     ? time |                                     ? localTime.day.toLocaleString("en-US", { | ||||||
|                                           .setZone(timezone) |                                           minimumIntegerDigits: 2, | ||||||
|                                           .day.toLocaleString("en-US", { minimumIntegerDigits: 2 }) |                                       }) | ||||||
|                                     : "" |                                     : "" | ||||||
|                             } |                             } | ||||||
|                             onBlur={(ev) => { |                             onBlur={(ev) => { | ||||||
| @@ -173,10 +173,10 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                             maxlength={2} |                             maxlength={2} | ||||||
|                             placeholder="hh" |                             placeholder="hh" | ||||||
|                             value={ |                             value={ | ||||||
|                                 time |                                 localTime | ||||||
|                                     ? time |                                     ? localTime.hour.toLocaleString("en-US", { | ||||||
|                                           .setZone(timezone) |                                           minimumIntegerDigits: 2, | ||||||
|                                           .hour.toLocaleString("en-US", { minimumIntegerDigits: 2 }) |                                       }) | ||||||
|                                     : "" |                                     : "" | ||||||
|                             } |                             } | ||||||
|                             onBlur={(ev) => { |                             onBlur={(ev) => { | ||||||
| @@ -203,8 +203,8 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                             maxlength={2} |                             maxlength={2} | ||||||
|                             placeholder="mm" |                             placeholder="mm" | ||||||
|                             value={ |                             value={ | ||||||
|                                 time |                                 localTime | ||||||
|                                     ? time.setZone(timezone).minute.toLocaleString("en-US", { |                                     ? localTime.minute.toLocaleString("en-US", { | ||||||
|                                           minimumIntegerDigits: 2, |                                           minimumIntegerDigits: 2, | ||||||
|                                       }) |                                       }) | ||||||
|                                     : "" |                                     : "" | ||||||
| @@ -233,8 +233,8 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                             maxlength={2} |                             maxlength={2} | ||||||
|                             placeholder="ss" |                             placeholder="ss" | ||||||
|                             value={ |                             value={ | ||||||
|                                 time |                                 localTime | ||||||
|                                     ? time.setZone(timezone).second.toLocaleString("en-US", { |                                     ? localTime.second.toLocaleString("en-US", { | ||||||
|                                           minimumIntegerDigits: 2, |                                           minimumIntegerDigits: 2, | ||||||
|                                       }) |                                       }) | ||||||
|                                     : "" |                                     : "" | ||||||
| @@ -276,18 +276,16 @@ export const TimeInput = ({ defaultValue, onInput }) => { | |||||||
|                 type="datetime-local" |                 type="datetime-local" | ||||||
|                 step="1" |                 step="1" | ||||||
|                 value={ |                 value={ | ||||||
|                     time |                     localTime | ||||||
|                         ? time.toFormat("yyyy-LL-dd'T'HH:mm:ss") |                         ? localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss") | ||||||
|                         : DateTime.now().toFormat("yyyy-LL-dd'T'HH:mm:ss") |                         : DateTime.now().setZone(timezone).toFormat("yyyy-LL-dd'T'HH:mm:ss") | ||||||
|                 } |                 } | ||||||
|                 ref={ref} |                 ref={ref} | ||||||
|                 onInput={(ev) => { |                 onInput={(ev) => { | ||||||
|                     ev.currentTarget.value === "" |                     ev.currentTarget.value === "" | ||||||
|                         ? updateTime(null) |                         ? updateTime(null) | ||||||
|                         : setTime( |                         : setLocalTime( | ||||||
|                               DateTime.fromISO(ev.currentTarget.value, { zone: timezone }).setZone( |                               DateTime.fromISO(ev.currentTarget.value, { zone: timezone }), | ||||||
|                                   "UTC", |  | ||||||
|                               ), |  | ||||||
|                           ); |                           ); | ||||||
|                 }} |                 }} | ||||||
|             ></input> |             ></input> | ||||||
|   | |||||||
| @@ -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="invert-collapses 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> | ||||||
|   | |||||||
| @@ -39,7 +39,7 @@ export const User = ({ toggleCollapsed }) => { | |||||||
|     return ( |     return ( | ||||||
|         <div class="columns is-mobile column reminder-topbar"> |         <div class="columns is-mobile column reminder-topbar"> | ||||||
|             <Name /> |             <Name /> | ||||||
|             <div class="invert-collapses time-bar">in {string}</div> |             <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> | ||||||
|   | |||||||
| @@ -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} />; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -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`} | ||||||
|             > |             > | ||||||
|                 <> |                 <> | ||||||
|   | |||||||
| @@ -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"} | ||||||
|                         > |                         > | ||||||
|                             <> |                             <> | ||||||
|   | |||||||
| @@ -53,7 +53,7 @@ export const TimezonePicker = () => { | |||||||
|  |  | ||||||
| const TimezoneModal = ({ setModalOpen }) => { | const TimezoneModal = ({ setModalOpen }) => { | ||||||
|     const browserTimezone = DateTime.now().zoneName; |     const browserTimezone = DateTime.now().zoneName; | ||||||
|     const [selectedZone, setSelectedZone] = useTimezone(); |     const [selectedZone] = useTimezone(); | ||||||
|  |  | ||||||
|     const queryClient = useQueryClient(); |     const queryClient = useQueryClient(); | ||||||
|     const { isLoading, isError, data } = useQuery(fetchUserInfo()); |     const { isLoading, isError, data } = useQuery(fetchUserInfo()); | ||||||
| @@ -86,36 +86,6 @@ const TimezoneModal = ({ setModalOpen }) => { | |||||||
|             </p> |             </p> | ||||||
|             <br></br> |             <br></br> | ||||||
|             <div class="has-text-centered"> |             <div class="has-text-centered"> | ||||||
|                 <button |  | ||||||
|                     class="button is-success" |  | ||||||
|                     style={{ |  | ||||||
|                         margin: "2px", |  | ||||||
|                     }} |  | ||||||
|                     id="set-browser-timezone" |  | ||||||
|                     onClick={() => { |  | ||||||
|                         setSelectedZone(browserTimezone); |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     <span>Use Browser Timezone</span>{" "} |  | ||||||
|                     <span class="icon"> |  | ||||||
|                         <i class="fab fa-firefox-browser"></i> |  | ||||||
|                     </span> |  | ||||||
|                 </button> |  | ||||||
|                 <button |  | ||||||
|                     class="button is-success" |  | ||||||
|                     id="set-bot-timezone" |  | ||||||
|                     style={{ |  | ||||||
|                         margin: "2px", |  | ||||||
|                     }} |  | ||||||
|                     onClick={() => { |  | ||||||
|                         setSelectedZone(data.timezone); |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     <span>Use Bot Timezone</span>{" "} |  | ||||||
|                     <span class="icon"> |  | ||||||
|                         <i class="fab fa-discord"></i> |  | ||||||
|                     </span> |  | ||||||
|                 </button> |  | ||||||
|                 <button |                 <button | ||||||
|                     class="button is-success is-outlined" |                     class="button is-success is-outlined" | ||||||
|                     id="update-bot-timezone" |                     id="update-bot-timezone" | ||||||
|   | |||||||
							
								
								
									
										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> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
| @@ -11,12 +11,7 @@ enum Sort { | |||||||
| } | } | ||||||
|  |  | ||||||
| export const UserReminders = () => { | export const UserReminders = () => { | ||||||
|     const { |     const { isSuccess, isFetching, isFetched, data: reminders } = useQuery(fetchUserReminders()); | ||||||
|         isSuccess, |  | ||||||
|         isFetching, |  | ||||||
|         isFetched, |  | ||||||
|         data: guildReminders, |  | ||||||
|     } = useQuery(fetchUserReminders()); |  | ||||||
|  |  | ||||||
|     const [collapsed, setCollapsed] = useState(false); |     const [collapsed, setCollapsed] = useState(false); | ||||||
|     const [sort, setSort] = useState(Sort.Time); |     const [sort, setSort] = useState(Sort.Time); | ||||||
| @@ -85,7 +80,7 @@ export const UserReminders = () => { | |||||||
|  |  | ||||||
|                 <div id={"guildReminders"} className={isFetching ? "loading" : ""}> |                 <div id={"guildReminders"} className={isFetching ? "loading" : ""}> | ||||||
|                     {isSuccess && |                     {isSuccess && | ||||||
|                         guildReminders |                         reminders | ||||||
|                             .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; | ||||||
|   | |||||||
| @@ -22,9 +22,9 @@ impl Recordable for Options { | |||||||
|                 CreateEmbed::new() |                 CreateEmbed::new() | ||||||
|                     .title("Confirmations ephemeral") |                     .title("Confirmations ephemeral") | ||||||
|                     .description(concat!( |                     .description(concat!( | ||||||
|                     "Reminder confirmations will be sent privately, and removed when your client", |                         "Reminder and todo confirmations will be sent privately, and removed when ", | ||||||
|                     " restarts." |                         "your client restarts." | ||||||
|                 )) |                     )) | ||||||
|                     .color(*THEME_COLOR), |                     .color(*THEME_COLOR), | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -22,8 +22,8 @@ impl Recordable for Options { | |||||||
|                 CreateEmbed::new() |                 CreateEmbed::new() | ||||||
|                     .title("Confirmations public") |                     .title("Confirmations public") | ||||||
|                     .description(concat!( |                     .description(concat!( | ||||||
|                         "Reminder confirmations will be sent as regular messages, and won't be ", |                         "Reminder and todo confirmations will be sent as regular messages, and", | ||||||
|                         "removed automatically." |                         " won't be removed automatically." | ||||||
|                     )) |                     )) | ||||||
|                     .color(*THEME_COLOR), |                     .color(*THEME_COLOR), | ||||||
|             ), |             ), | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | use poise::CreateReply; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
| @@ -33,7 +34,13 @@ impl Recordable for Options { | |||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
|  |  | ||||||
|         ctx.say("Item added to todo list").await?; |         let ephemeral = ctx | ||||||
|  |             .guild_data() | ||||||
|  |             .await | ||||||
|  |             .map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); | ||||||
|  |  | ||||||
|  |         ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral)) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
|  | use poise::CreateReply; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|  |     models::CtxData, | ||||||
|     utils::{Extract, Recordable}, |     utils::{Extract, Recordable}, | ||||||
|     Context, Error, |     Context, Error, | ||||||
| }; | }; | ||||||
| @@ -26,7 +28,13 @@ impl Recordable for Options { | |||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
|  |  | ||||||
|         ctx.say("Item added to todo list").await?; |         let ephemeral = ctx | ||||||
|  |             .guild_data() | ||||||
|  |             .await | ||||||
|  |             .map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); | ||||||
|  |  | ||||||
|  |         ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral)) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
|  | use poise::CreateReply; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|  |     models::CtxData, | ||||||
|     utils::{Extract, Recordable}, |     utils::{Extract, Recordable}, | ||||||
|     Context, Error, |     Context, Error, | ||||||
| }; | }; | ||||||
| @@ -27,7 +29,13 @@ impl Recordable for Options { | |||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
|  |  | ||||||
|         ctx.say("Item added to todo list").await?; |         let ephemeral = ctx | ||||||
|  |             .guild_data() | ||||||
|  |             .await | ||||||
|  |             .map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); | ||||||
|  |  | ||||||
|  |         ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral)) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -282,21 +282,52 @@ impl ComponentDataModel { | |||||||
|                         .await |                         .await | ||||||
|                         .unwrap(); |                         .unwrap(); | ||||||
|  |  | ||||||
|                         let values = sqlx::query!( |                         let values = if let Some(uid) = selector.user_id { | ||||||
|                             // fucking braindead mysql use <=> instead of = for null comparison |                             sqlx::query!( | ||||||
|                             " |                                 " | ||||||
|                             SELECT id, value FROM todos WHERE user_id <=> ? AND channel_id <=> ? AND guild_id <=> ? |                             SELECT todos.id, value FROM todos | ||||||
|  |                             INNER JOIN users ON todos.user_id = users.id | ||||||
|  |                             WHERE users.user = ? | ||||||
|                             ", |                             ", | ||||||
|                             selector.user_id, |                                 uid, | ||||||
|                             selector.channel_id, |                             ) | ||||||
|                             selector.guild_id, |                             .fetch_all(&data.database) | ||||||
|                         ) |                             .await | ||||||
|                         .fetch_all(&data.database) |                             .unwrap() | ||||||
|                         .await |                             .iter() | ||||||
|                         .unwrap() |                             .map(|row| (row.id as usize, row.value.clone())) | ||||||
|                         .iter() |                             .collect::<Vec<(usize, String)>>() | ||||||
|                         .map(|row| (row.id as usize, row.value.clone())) |                         } else if let Some(cid) = selector.channel_id { | ||||||
|                         .collect::<Vec<(usize, String)>>(); |                             sqlx::query!( | ||||||
|  |                                 " | ||||||
|  |                             SELECT todos.id, value FROM todos | ||||||
|  |                             INNER JOIN channels ON todos.channel_id = channels.id | ||||||
|  |                             WHERE channels.channel = ? | ||||||
|  |                             ", | ||||||
|  |                                 cid, | ||||||
|  |                             ) | ||||||
|  |                             .fetch_all(&data.database) | ||||||
|  |                             .await | ||||||
|  |                             .unwrap() | ||||||
|  |                             .iter() | ||||||
|  |                             .map(|row| (row.id as usize, row.value.clone())) | ||||||
|  |                             .collect::<Vec<(usize, String)>>() | ||||||
|  |                         } else { | ||||||
|  |                             sqlx::query!( | ||||||
|  |                                 " | ||||||
|  |                             SELECT todos.id, value FROM todos | ||||||
|  |                             INNER JOIN guilds ON todos.guild_id = guilds.id | ||||||
|  |                             WHERE guilds.guild = ? | ||||||
|  |                             ", | ||||||
|  |                                 selector.guild_id, | ||||||
|  |                             ) | ||||||
|  |                             .fetch_all(&data.database) | ||||||
|  |                             .await | ||||||
|  |                             .unwrap() | ||||||
|  |                             .iter() | ||||||
|  |                             .map(|row| (row.id as usize, row.value.clone())) | ||||||
|  |                             .collect::<Vec<(usize, String)>>() | ||||||
|  |                         }; | ||||||
|  |  | ||||||
|                         let resp = show_todo_page( |                         let resp = show_todo_page( | ||||||
|                             &values, |                             &values, | ||||||
|   | |||||||
							
								
								
									
										58
									
								
								src/hooks.rs
									
									
									
									
									
								
							
							
						
						
									
										58
									
								
								src/hooks.rs
									
									
									
									
									
								
							| @@ -58,6 +58,10 @@ async fn macro_check(ctx: Context<'_>) -> bool { | |||||||
|  |  | ||||||
| async fn check_self_permissions(ctx: Context<'_>) -> bool { | async fn check_self_permissions(ctx: Context<'_>) -> bool { | ||||||
|     let user_id = ctx.serenity_context().cache.current_user().id; |     let user_id = ctx.serenity_context().cache.current_user().id; | ||||||
|  |     let app_permissions = match ctx { | ||||||
|  |         Context::Application(app_ctx) => app_ctx.interaction.app_permissions, | ||||||
|  |         _ => None, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     match ctx.guild().map(|g| g.to_owned()) { |     match ctx.guild().map(|g| g.to_owned()) { | ||||||
|         Some(guild) => { |         Some(guild) => { | ||||||
| @@ -66,42 +70,34 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool { | |||||||
|                 .await |                 .await | ||||||
|                 .map_or(false, |m| m.permissions(&ctx).map_or(false, |p| p.manage_webhooks())); |                 .map_or(false, |m| m.permissions(&ctx).map_or(false, |p| p.manage_webhooks())); | ||||||
|  |  | ||||||
|             let (view_channel, send_messages, embed_links) = ctx |             if let Some(permissions) = app_permissions { | ||||||
|                 .channel_id() |                 return if permissions.send_messages() | ||||||
|                 .to_channel(&ctx) |                     && permissions.embed_links() | ||||||
|                 .await |                     && manage_webhooks | ||||||
|                 .ok() |                 { | ||||||
|                 .and_then(|c| { |                     true | ||||||
|                     if let Channel::Guild(channel) = c { |                 } else { | ||||||
|                         let perms = channel.permissions_for_user(&ctx, user_id).ok()?; |                     let _ = ctx | ||||||
|  |                         .send(CreateReply::default().content(format!( | ||||||
|  |                             "The bot appears to be missing some permissions: | ||||||
|  |  | ||||||
|                         Some((perms.view_channel(), perms.send_messages(), perms.embed_links())) |  | ||||||
|                     } else { |  | ||||||
|                         None |  | ||||||
|                     } |  | ||||||
|                 }) |  | ||||||
|                 .unwrap_or((false, false, false)); |  | ||||||
|  |  | ||||||
|             if manage_webhooks && send_messages && embed_links { |  | ||||||
|                 true |  | ||||||
|             } else { |  | ||||||
|                 let _ = ctx |  | ||||||
|                     .send(CreateReply::default().content(format!( |  | ||||||
|                         "Please ensure the bot has the correct permissions: |  | ||||||
|  |  | ||||||
| {}     **View Channel** |  | ||||||
| {}     **Send Message** | {}     **Send Message** | ||||||
| {}     **Embed Links** | {}     **Embed Links** | ||||||
| {}     **Manage Webhooks**", | {}     **Manage Webhooks** | ||||||
|                         if view_channel { "✅" } else { "❌" }, |  | ||||||
|                         if send_messages { "✅" } else { "❌" }, |  | ||||||
|                         if embed_links { "✅" } else { "❌" }, |  | ||||||
|                         if manage_webhooks { "✅" } else { "❌" }, |  | ||||||
|                     ))) |  | ||||||
|                     .await; |  | ||||||
|  |  | ||||||
|                 false | Please check the bot's roles, and any channel overrides. Alternatively, giving the bot | ||||||
|  | \"Administrator\" will bypass permission checks", | ||||||
|  |                             if permissions.send_messages() { "✅" } else { "❌" }, | ||||||
|  |                             if permissions.embed_links() { "✅" } else { "❌" }, | ||||||
|  |                             if manage_webhooks { "✅" } else { "❌" }, | ||||||
|  |                         ))) | ||||||
|  |                         .await; | ||||||
|  |  | ||||||
|  |                     false | ||||||
|  |                 }; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             manage_webhooks | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         None => { |         None => { | ||||||
|   | |||||||
| @@ -212,7 +212,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | |||||||
|  |  | ||||||
|     // Start metrics |     // Start metrics | ||||||
|     init_metrics(); |     init_metrics(); | ||||||
|     tokio::spawn(async { metrics::serve().await }); |  | ||||||
|  |  | ||||||
|     let database = |     let database = | ||||||
|         Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); |         Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| use axum::{routing::get, Router}; |  | ||||||
| use lazy_static::lazy_static; | use lazy_static::lazy_static; | ||||||
| use log::warn; |  | ||||||
| use prometheus::{IntCounterVec, Opts, Registry}; | use prometheus::{IntCounterVec, Opts, Registry}; | ||||||
|  |  | ||||||
| lazy_static! { | lazy_static! { | ||||||
| @@ -9,10 +7,11 @@ lazy_static! { | |||||||
|         IntCounterVec::new(Opts::new("requests", "Web requests"), &["method", "status", "route"]) |         IntCounterVec::new(Opts::new("requests", "Web requests"), &["method", "status", "route"]) | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|     pub static ref REMINDER_COUNTER: IntCounterVec = |     pub static ref REMINDER_COUNTER: IntCounterVec = | ||||||
|         IntCounterVec::new(Opts::new("reminders_sent", "Reminders sent"), &["channel"]).unwrap(); |         IntCounterVec::new(Opts::new("reminders_sent", "Reminders sent"), &["id", "channel"]) | ||||||
|  |             .unwrap(); | ||||||
|     pub static ref REMINDER_FAIL_COUNTER: IntCounterVec = IntCounterVec::new( |     pub static ref REMINDER_FAIL_COUNTER: IntCounterVec = IntCounterVec::new( | ||||||
|         Opts::new("reminders_failed", "Reminders failed"), |         Opts::new("reminders_failed", "Reminders failed"), | ||||||
|         &["channel", "error"] |         &["id", "channel", "error"] | ||||||
|     ) |     ) | ||||||
|     .unwrap(); |     .unwrap(); | ||||||
|     pub static ref COMMAND_COUNTER: IntCounterVec = |     pub static ref COMMAND_COUNTER: IntCounterVec = | ||||||
| @@ -25,21 +24,3 @@ pub fn init_metrics() { | |||||||
|     REGISTRY.register(Box::new(REMINDER_FAIL_COUNTER.clone())).unwrap(); |     REGISTRY.register(Box::new(REMINDER_FAIL_COUNTER.clone())).unwrap(); | ||||||
|     REGISTRY.register(Box::new(COMMAND_COUNTER.clone())).unwrap(); |     REGISTRY.register(Box::new(COMMAND_COUNTER.clone())).unwrap(); | ||||||
| } | } | ||||||
|  |  | ||||||
| pub async fn serve() { |  | ||||||
|     let app = Router::new().route("/metrics", get(metrics)); |  | ||||||
|  |  | ||||||
|     let listener = tokio::net::TcpListener::bind("localhost:31756").await.unwrap(); |  | ||||||
|     axum::serve(listener, app).await.unwrap(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async fn metrics() -> String { |  | ||||||
|     let encoder = prometheus::TextEncoder::new(); |  | ||||||
|     let res_custom = encoder.encode_to_string(®ISTRY.gather()); |  | ||||||
|  |  | ||||||
|     res_custom.unwrap_or_else(|e| { |  | ||||||
|         warn!("Error encoding metrics: {:?}", e); |  | ||||||
|  |  | ||||||
|         String::new() |  | ||||||
|     }) |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ use std::env; | |||||||
|  |  | ||||||
| use log::{info, warn}; | use log::{info, warn}; | ||||||
| use poise::serenity_prelude::client::Context; | use poise::serenity_prelude::client::Context; | ||||||
|  | use sd_notify::{self, NotifyState}; | ||||||
| use sqlx::{Executor, MySql}; | use sqlx::{Executor, MySql}; | ||||||
| use tokio::{ | use tokio::{ | ||||||
|     sync::broadcast::Receiver, |     sync::broadcast::Receiver, | ||||||
| @@ -33,6 +34,15 @@ async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> | |||||||
|         .flatten() |         .flatten() | ||||||
|         .unwrap_or(10); |         .unwrap_or(10); | ||||||
|  |  | ||||||
|  |     let mut watchdog_interval = 0; | ||||||
|  |     let watchdog = sd_notify::watchdog_enabled(false, &mut watchdog_interval); | ||||||
|  |  | ||||||
|  |     if watchdog { | ||||||
|  |         warn!("Watchdog enabled. Don't die!"); | ||||||
|  |     } else { | ||||||
|  |         warn!("No watchdog running") | ||||||
|  |     } | ||||||
|  |  | ||||||
|     loop { |     loop { | ||||||
|         let sleep_to = Instant::now() + Duration::from_secs(remind_interval); |         let sleep_to = Instant::now() + Duration::from_secs(remind_interval); | ||||||
|         let reminders = sender::Reminder::fetch_reminders(pool).await; |         let reminders = sender::Reminder::fetch_reminders(pool).await; | ||||||
| @@ -42,9 +52,11 @@ async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> | |||||||
|  |  | ||||||
|             for reminder in reminders { |             for reminder in reminders { | ||||||
|                 reminder.send(pool, ctx.clone()).await; |                 reminder.send(pool, ctx.clone()).await; | ||||||
|  |                 let _ = sd_notify::notify(false, &[NotifyState::Watchdog]); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         sleep_until(sleep_to).await; |         sleep_until(sleep_to).await; | ||||||
|  |         let _ = sd_notify::notify(false, &[NotifyState::Watchdog]); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -26,7 +26,10 @@ use sqlx::{ | |||||||
|     Executor, |     Executor, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| use crate::{metrics::REMINDER_COUNTER, Database}; | use crate::{ | ||||||
|  |     metrics::{REMINDER_COUNTER, REMINDER_FAIL_COUNTER}, | ||||||
|  |     Database, | ||||||
|  | }; | ||||||
|  |  | ||||||
| lazy_static! { | lazy_static! { | ||||||
|     pub static ref TIMEFROM_REGEX: Regex = |     pub static ref TIMEFROM_REGEX: Regex = | ||||||
| @@ -442,12 +445,23 @@ impl Reminder { | |||||||
|             None => error.to_string(), |             None => error.to_string(), | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         REMINDER_COUNTER.with_label_values(&[self.channel_id.to_string().as_str(), &message]).inc(); |         REMINDER_FAIL_COUNTER | ||||||
|  |             .get_metric_with_label_values(&[ | ||||||
|  |                 self.id.to_string().as_str(), | ||||||
|  |                 self.channel_id.to_string().as_str(), | ||||||
|  |                 &message, | ||||||
|  |             ]) | ||||||
|  |             .map_or_else(|e| warn!("Couldn't count failed reminder: {:?}", e), |c| c.inc()); | ||||||
|         error!("[Reminder {}] {}", self.id, message); |         error!("[Reminder {}] {}", self.id, message); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async fn log_success(&self) { |     async fn log_success(&self) { | ||||||
|         REMINDER_COUNTER.with_label_values(&[self.channel_id.to_string().as_str()]).inc() |         REMINDER_COUNTER | ||||||
|  |             .get_metric_with_label_values(&[ | ||||||
|  |                 self.id.to_string().as_str(), | ||||||
|  |                 self.channel_id.to_string().as_str(), | ||||||
|  |             ]) | ||||||
|  |             .map_or_else(|e| warn!("Couldn't count sent reminder: {:?}", e), |c| c.inc()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { |     async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||||
|   | |||||||
| @@ -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(); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								src/web/fairings/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/web/fairings/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | pub mod metrics; | ||||||
| @@ -2,10 +2,62 @@ mod consts; | |||||||
| #[macro_use] | #[macro_use] | ||||||
| mod macros; | mod macros; | ||||||
| mod catchers; | mod catchers; | ||||||
|  | mod fairings; | ||||||
| mod guards; | mod guards; | ||||||
| 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, 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}; | use std::{env, path::Path}; | ||||||
|  |  | ||||||
| use log::{error, info, warn}; | use log::{error, info, warn}; | ||||||
| @@ -28,7 +80,7 @@ use sqlx::{MySql, Pool}; | |||||||
|  |  | ||||||
| use crate::web::{ | use crate::web::{ | ||||||
|     consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, |     consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, | ||||||
|     metrics::MetricProducer, |     fairings::metrics::MetricProducer, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type Database = MySql; | type Database = MySql; | ||||||
| @@ -98,6 +150,7 @@ pub async fn initialize( | |||||||
|                 routes::report::report_error, |                 routes::report::report_error, | ||||||
|                 routes::return_to_same_site, |                 routes::return_to_same_site, | ||||||
|                 routes::terms, |                 routes::terms, | ||||||
|  |                 routes::metrics, | ||||||
|             ], |             ], | ||||||
|         ) |         ) | ||||||
|         .mount( |         .mount( | ||||||
| @@ -126,6 +179,8 @@ pub async fn initialize( | |||||||
|         .mount( |         .mount( | ||||||
|             "/dashboard", |             "/dashboard", | ||||||
|             routes![ |             routes![ | ||||||
|  |                 routes::dashboard::reminders_redirect, | ||||||
|  |                 routes::dashboard::todos_redirect, | ||||||
|                 routes::dashboard::dashboard, |                 routes::dashboard::dashboard, | ||||||
|                 routes::dashboard::dashboard_home, |                 routes::dashboard::dashboard_home, | ||||||
|                 routes::dashboard::api::delete_reminder, |                 routes::dashboard::api::delete_reminder, | ||||||
| @@ -138,12 +193,17 @@ pub async fn initialize( | |||||||
|                 routes::dashboard::api::guild::get_guild_info, |                 routes::dashboard::api::guild::get_guild_info, | ||||||
|                 routes::dashboard::api::guild::get_guild_channels, |                 routes::dashboard::api::guild::get_guild_channels, | ||||||
|                 routes::dashboard::api::guild::get_guild_roles, |                 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::get_reminder_templates, | ||||||
|                 routes::dashboard::api::guild::create_reminder_template, |                 routes::dashboard::api::guild::create_reminder_template, | ||||||
|                 routes::dashboard::api::guild::delete_reminder_template, |                 routes::dashboard::api::guild::delete_reminder_template, | ||||||
|                 routes::dashboard::api::guild::create_guild_reminder, |                 routes::dashboard::api::guild::create_guild_reminder, | ||||||
|                 routes::dashboard::api::guild::get_reminders, |                 routes::dashboard::api::guild::get_reminders, | ||||||
|                 routes::dashboard::api::guild::edit_reminder, |                 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_reminders, | ||||||
|                 routes::dashboard::export::export_reminder_templates, |                 routes::dashboard::export::export_reminder_templates, | ||||||
|                 routes::dashboard::export::export_todos, |                 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 channels; | ||||||
|  | mod emojis; | ||||||
| mod reminders; | mod reminders; | ||||||
| mod roles; | mod roles; | ||||||
| mod templates; | mod templates; | ||||||
|  | pub mod todos; | ||||||
|  |  | ||||||
| use std::env; | use std::env; | ||||||
|  |  | ||||||
| pub use channels::*; | pub use channels::get_guild_channels; | ||||||
|  | pub use emojis::get_guild_emojis; | ||||||
| pub use reminders::*; | pub use reminders::*; | ||||||
| use rocket::{get, http::CookieJar, serde::json::json, State}; | use rocket::{get, http::CookieJar, serde::json::json, State}; | ||||||
| pub use roles::*; | pub use roles::get_guild_roles; | ||||||
| use serenity::{ | use serenity::{ | ||||||
|     client::Context, |     client::Context, | ||||||
|     model::id::{GuildId, RoleId}, |     model::id::{GuildId, RoleId}, | ||||||
|   | |||||||
| @@ -17,7 +17,9 @@ use crate::web::{ | |||||||
|     consts::MIN_INTERVAL, |     consts::MIN_INTERVAL, | ||||||
|     guards::transaction::Transaction, |     guards::transaction::Transaction, | ||||||
|     routes::{ |     routes::{ | ||||||
|         dashboard::{create_database_channel, create_reminder, PatchReminder, Reminder}, |         dashboard::{ | ||||||
|  |             create_database_channel, create_reminder, CreateReminder, GetReminder, PatchReminder, | ||||||
|  |         }, | ||||||
|         JsonResult, |         JsonResult, | ||||||
|     }, |     }, | ||||||
|     Database, |     Database, | ||||||
| @@ -26,7 +28,7 @@ use crate::web::{ | |||||||
| #[post("/api/guild/<id>/reminders", data = "<reminder>")] | #[post("/api/guild/<id>/reminders", data = "<reminder>")] | ||||||
| pub async fn create_guild_reminder( | pub async fn create_guild_reminder( | ||||||
|     id: u64, |     id: u64, | ||||||
|     reminder: Json<Reminder>, |     reminder: Json<CreateReminder>, | ||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     ctx: &State<Context>, |     ctx: &State<Context>, | ||||||
|     mut transaction: Transaction<'_>, |     mut transaction: Transaction<'_>, | ||||||
| @@ -78,9 +80,9 @@ pub async fn get_reminders( | |||||||
|                 .join(","); |                 .join(","); | ||||||
|  |  | ||||||
|             sqlx::query_as_unchecked!( |             sqlx::query_as_unchecked!( | ||||||
|                 Reminder, |                 GetReminder, | ||||||
|                 "SELECT |                 " | ||||||
|                  reminders.attachment, |                 SELECT | ||||||
|                  reminders.attachment_name, |                  reminders.attachment_name, | ||||||
|                  reminders.avatar, |                  reminders.avatar, | ||||||
|                  channels.channel, |                  channels.channel, | ||||||
| @@ -192,7 +194,7 @@ pub async fn edit_reminder( | |||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| { |                 .map_err(|e| { | ||||||
|                     warn!("Error updating reminder interval: {:?}", 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 |                 .days | ||||||
|                 .unwrap_or(0), |                 .unwrap_or(0), | ||||||
| @@ -206,7 +208,7 @@ pub async fn edit_reminder( | |||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| { |                 .map_err(|e| { | ||||||
|                     warn!("Error updating reminder interval: {:?}", 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 |                 .months | ||||||
|                 .unwrap_or(0), |                 .unwrap_or(0), | ||||||
| @@ -220,7 +222,7 @@ pub async fn edit_reminder( | |||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| { |                 .map_err(|e| { | ||||||
|                     warn!("Error updating reminder interval: {:?}", 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 |                 .seconds | ||||||
|                 .unwrap_or(0), |                 .unwrap_or(0), | ||||||
| @@ -249,7 +251,7 @@ pub async fn edit_reminder( | |||||||
|         .await |         .await | ||||||
|         .map_err(|e| { |         .map_err(|e| { | ||||||
|             warn!("Error updating reminder interval: {:?}", 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!( |     match sqlx::query_as_unchecked!( | ||||||
|         Reminder, |         GetReminder, | ||||||
|         "SELECT reminders.attachment, |         " | ||||||
|  |         SELECT | ||||||
|          reminders.attachment_name, |          reminders.attachment_name, | ||||||
|          reminders.avatar, |          reminders.avatar, | ||||||
|          channels.channel, |          channels.channel, | ||||||
| @@ -361,7 +364,7 @@ pub async fn edit_reminder( | |||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             warn!("Error exiting `edit_reminder': {:?}", 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, |     guards::transaction::Transaction, | ||||||
|     routes::{ |     routes::{ | ||||||
|         dashboard::{ |         dashboard::{ | ||||||
|             create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, |             create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv, | ||||||
|             TodoCsv, |  | ||||||
|         }, |         }, | ||||||
|         JsonResult, |         JsonResult, | ||||||
|     }, |     }, | ||||||
| @@ -150,7 +149,7 @@ pub(crate) async fn import_reminders( | |||||||
|  |  | ||||||
|                         match channel_id.parse::<u64>() { |                         match channel_id.parse::<u64>() { | ||||||
|                             Ok(channel_id) => { |                             Ok(channel_id) => { | ||||||
|                                 let reminder = Reminder { |                                 let reminder = CreateReminder { | ||||||
|                                     attachment: record.attachment, |                                     attachment: record.attachment, | ||||||
|                                     attachment_name: record.attachment_name, |                                     attachment_name: record.attachment_name, | ||||||
|                                     avatar: record.avatar, |                                     avatar: record.avatar, | ||||||
| @@ -177,7 +176,6 @@ pub(crate) async fn import_reminders( | |||||||
|                                     name: record.name, |                                     name: record.name, | ||||||
|                                     restartable: record.restartable, |                                     restartable: record.restartable, | ||||||
|                                     tts: record.tts, |                                     tts: record.tts, | ||||||
|                                     uid: generate_uid(), |  | ||||||
|                                     username: record.username, |                                     username: record.username, | ||||||
|                                     utc_time: record.utc_time, |                                     utc_time: record.utc_time, | ||||||
|                                 }; |                                 }; | ||||||
|   | |||||||
| @@ -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; | ||||||
| @@ -146,9 +146,39 @@ pub struct EmbedField { | |||||||
|     inline: bool, |     inline: bool, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize)] | #[derive(Deserialize)] | ||||||
| pub struct Reminder { | pub struct CreateReminder { | ||||||
|     attachment: Option<Attachment>, |     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>, |     attachment_name: Option<String>, | ||||||
|     avatar: Option<String>, |     avatar: Option<String>, | ||||||
|     #[serde(with = "string")] |     #[serde(with = "string")] | ||||||
| @@ -318,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, | ||||||
| @@ -363,7 +369,7 @@ pub(crate) async fn create_reminder( | |||||||
|     transaction: &mut Transaction<'_>, |     transaction: &mut Transaction<'_>, | ||||||
|     guild_id: GuildId, |     guild_id: GuildId, | ||||||
|     user_id: UserId, |     user_id: UserId, | ||||||
|     reminder: Reminder, |     reminder: CreateReminder, | ||||||
| ) -> JsonResult { | ) -> JsonResult { | ||||||
|     // check guild in db |     // check guild in db | ||||||
|     match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get()) |     match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get()) | ||||||
| @@ -382,22 +388,13 @@ pub(crate) async fn create_reminder( | |||||||
|         _ => {} |         _ => {} | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     { |     if !check_channel_matches_guild(ctx, ChannelId::new(reminder.channel), guild_id) { | ||||||
|         // validate channel |         warn!( | ||||||
|         let channel = ChannelId::new(reminder.channel).to_channel_cached(&ctx.cache); |             "Error in `create_reminder`: channel {} not found for guild {}", | ||||||
|         let channel_exists = channel.is_some(); |             reminder.channel, guild_id | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         let channel_matches_guild = |         return Err(json!({"error": "Channel not found"})); | ||||||
|             channel.map_or(false, |c| c.guild(&ctx.cache).map_or(false, |c| c.id == guild_id)); |  | ||||||
|  |  | ||||||
|         if !channel_matches_guild || !channel_exists { |  | ||||||
|             warn!( |  | ||||||
|                 "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", |  | ||||||
|                 reminder.channel, guild_id, channel_exists |  | ||||||
|             ); |  | ||||||
|  |  | ||||||
|             return Err(json!({"error": "Channel not found"})); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let channel = |     let channel = | ||||||
| @@ -545,9 +542,9 @@ pub(crate) async fn create_reminder( | |||||||
|     .await |     .await | ||||||
|     { |     { | ||||||
|         Ok(_) => sqlx::query_as_unchecked!( |         Ok(_) => sqlx::query_as_unchecked!( | ||||||
|             Reminder, |             GetReminder, | ||||||
|             "SELECT |             " | ||||||
|              reminders.attachment, |             SELECT | ||||||
|              reminders.attachment_name, |              reminders.attachment_name, | ||||||
|              reminders.avatar, |              reminders.avatar, | ||||||
|              channels.channel, |              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( | async fn create_database_channel( | ||||||
|     ctx: impl CacheHttp, |     ctx: impl CacheHttp, | ||||||
|     channel: ChannelId, |     channel: ChannelId, | ||||||
| @@ -685,6 +697,18 @@ pub enum DashboardPage { | |||||||
|     NotConfigured(Template), |     NotConfigured(Template), | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Legacy route to maintain compatibility with old dashboard routing | ||||||
|  | #[get("/?<id>")] | ||||||
|  | pub async fn reminders_redirect(id: &str) -> Redirect { | ||||||
|  |     Redirect::to(format!("/dashboard/{}/reminders", id)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Legacy route to maintain compatibility with old dashboard routing | ||||||
|  | #[get("/todo?<id>")] | ||||||
|  | pub async fn todos_redirect(id: &str) -> Redirect { | ||||||
|  |     Redirect::to(format!("/dashboard/{}/todos", id)) | ||||||
|  | } | ||||||
|  |  | ||||||
| #[get("/")] | #[get("/")] | ||||||
| pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage { | pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage { | ||||||
|     if cookies.get_private("userid").is_some() { |     if cookies.get_private("userid").is_some() { | ||||||
|   | |||||||
| @@ -2,11 +2,14 @@ pub mod dashboard; | |||||||
| pub mod login; | pub mod login; | ||||||
| pub mod report; | pub mod report; | ||||||
|  |  | ||||||
| use std::collections::HashMap; | use std::{collections::HashMap, net::IpAddr}; | ||||||
|  |  | ||||||
|  | use log::warn; | ||||||
| use rocket::{get, request::FlashMessage, serde::json::Value as JsonValue}; | use rocket::{get, request::FlashMessage, serde::json::Value as JsonValue}; | ||||||
| use rocket_dyn_templates::Template; | use rocket_dyn_templates::Template; | ||||||
|  |  | ||||||
|  | use crate::metrics::REGISTRY; | ||||||
|  |  | ||||||
| pub type JsonResult = Result<JsonValue, JsonValue>; | pub type JsonResult = Result<JsonValue, JsonValue>; | ||||||
|  |  | ||||||
| #[get("/")] | #[get("/")] | ||||||
| @@ -107,3 +110,19 @@ pub async fn help_iemanager() -> Template { | |||||||
|     let map: HashMap<&str, String> = HashMap::new(); |     let map: HashMap<&str, String> = HashMap::new(); | ||||||
|     Template::render("support/iemanager", &map) |     Template::render("support/iemanager", &map) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[get("/metrics")] | ||||||
|  | pub async fn metrics(client_ip: IpAddr) -> String { | ||||||
|  |     if !client_ip.is_loopback() { | ||||||
|  |         String::new() | ||||||
|  |     } else { | ||||||
|  |         let encoder = prometheus::TextEncoder::new(); | ||||||
|  |         let res_custom = encoder.encode_to_string(®ISTRY.gather()); | ||||||
|  |  | ||||||
|  |         res_custom.unwrap_or_else(|e| { | ||||||
|  |             warn!("Error encoding metrics: {:?}", e); | ||||||
|  |  | ||||||
|  |             String::new() | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,91 +0,0 @@ | |||||||
| .date-selector-wrapper { |  | ||||||
|     width: 200px; |  | ||||||
|     padding: 3px; |  | ||||||
|     background-color: #fff; |  | ||||||
|     box-shadow: 1px 1px 10px 1px #5c5c5c; |  | ||||||
|     position: absolute; |  | ||||||
|     font-size: 12px; |  | ||||||
|     -webkit-user-select: none; |  | ||||||
|     -khtml-user-select: none; |  | ||||||
|     -moz-user-select: none; |  | ||||||
|     -ms-user-select: none; |  | ||||||
|     -o-user-select: none; |  | ||||||
|     /* user-select: none; */ |  | ||||||
| } |  | ||||||
| .cal-header, .cal-row { |  | ||||||
|     display: flex; |  | ||||||
|     width: 100%; |  | ||||||
|     height: 30px; |  | ||||||
|     line-height: 30px; |  | ||||||
|     text-align: center; |  | ||||||
| } |  | ||||||
| .cal-cell, .cal-nav { |  | ||||||
|     cursor: pointer; |  | ||||||
| } |  | ||||||
| .cal-day-names { |  | ||||||
|     height: 25px; |  | ||||||
|     line-height: 25px; |  | ||||||
| } |  | ||||||
| .cal-day-names .cal-cell { |  | ||||||
|     cursor: default; |  | ||||||
|     font-weight: bold; |  | ||||||
| } |  | ||||||
| .cal-cell-prev, .cal-cell-next { |  | ||||||
|     color: #777; |  | ||||||
| } |  | ||||||
| .cal-months .cal-row, .cal-years .cal-row { |  | ||||||
|     height: 60px; |  | ||||||
|     line-height: 60px; |  | ||||||
| } |  | ||||||
| .cal-nav-prev, .cal-nav-next { |  | ||||||
|     flex: 0.15; |  | ||||||
| } |  | ||||||
| .cal-nav-current { |  | ||||||
|     flex: 0.75; |  | ||||||
|     font-weight: bold; |  | ||||||
| } |  | ||||||
| .cal-months .cal-cell, .cal-years .cal-cell { |  | ||||||
|     flex: 0.25; |  | ||||||
| } |  | ||||||
| .cal-days .cal-cell { |  | ||||||
|     flex: 0.143; |  | ||||||
| } |  | ||||||
| .cal-value { |  | ||||||
|     color: #fff; |  | ||||||
|     background-color: #286090; |  | ||||||
| } |  | ||||||
| .cal-cell:hover, .cal-nav:hover { |  | ||||||
|     background-color: #eee; |  | ||||||
| } |  | ||||||
| .cal-value:hover { |  | ||||||
|     background-color: #204d74; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* time footer */ |  | ||||||
| .cal-time { |  | ||||||
|     display: flex; |  | ||||||
|     justify-content: flex-start; |  | ||||||
|     height: 27px; |  | ||||||
|     line-height: 27px; |  | ||||||
| } |  | ||||||
| .cal-time-label, .cal-time-value { |  | ||||||
|     flex: 0.12; |  | ||||||
|     text-align: center; |  | ||||||
| } |  | ||||||
| .cal-time-slider { |  | ||||||
|     flex: 0.77; |  | ||||||
|     background-image: linear-gradient(to right, #d1d8dd, #d1d8dd); |  | ||||||
|     background-repeat: no-repeat; |  | ||||||
|     background-size: 100% 1px; |  | ||||||
|     background-position: left 50%; |  | ||||||
|     height: 100%; |  | ||||||
| } |  | ||||||
| .cal-time-slider input { |  | ||||||
|     width: 100%; |  | ||||||
|     -webkit-appearance: none; |  | ||||||
|     background: 0 0; |  | ||||||
|     cursor: pointer; |  | ||||||
|     height: 100%; |  | ||||||
|     outline: 0; |  | ||||||
|     user-select: auto; |  | ||||||
| } |  | ||||||
| @@ -1,131 +0,0 @@ | |||||||
| document.addEventListener("DOMContentLoaded", () => { |  | ||||||
|     fetch("/admin/data") |  | ||||||
|         .then((resp) => resp.json()) |  | ||||||
|         .then((data) => { |  | ||||||
|             document.querySelector("#backlog").textContent = data.backlog; |  | ||||||
|             document.querySelector("#reminders").textContent = data.count.reminders; |  | ||||||
|             document.querySelector("#intervals").textContent = data.count.intervals; |  | ||||||
|  |  | ||||||
|             let historySent = data.historyLong.sent.reduce( |  | ||||||
|                 (iv, frame) => iv + frame.count, |  | ||||||
|                 0 |  | ||||||
|             ); |  | ||||||
|             let historyFailed = data.historyLong.failed.reduce( |  | ||||||
|                 (iv, frame) => iv + frame.count, |  | ||||||
|                 0 |  | ||||||
|             ); |  | ||||||
|             let rate = historyFailed / (historySent + historyFailed); |  | ||||||
|             let formatted = Math.round(rate * 10000) / 100; |  | ||||||
|  |  | ||||||
|             document.querySelector("#historySent").textContent = historySent; |  | ||||||
|             document.querySelector("#historyFailed").textContent = historyFailed; |  | ||||||
|             document.querySelector("#failRate").textContent = `${formatted}%`; |  | ||||||
|  |  | ||||||
|             new Chart(document.getElementById("schedule"), { |  | ||||||
|                 type: "bar", |  | ||||||
|                 data: { |  | ||||||
|                     labels: [ |  | ||||||
|                         ...data.scheduleShort.once, |  | ||||||
|                         ...data.scheduleShort.interval, |  | ||||||
|                     ].map((row) => luxon.DateTime.fromISO(row.time_key)), |  | ||||||
|                     datasets: [ |  | ||||||
|                         { |  | ||||||
|                             label: "Reminders", |  | ||||||
|                             data: data.scheduleShort.once.map((row) => row.count), |  | ||||||
|                         }, |  | ||||||
|                         { |  | ||||||
|                             label: "Intervals", |  | ||||||
|                             data: data.scheduleShort.interval.map((row) => row.count), |  | ||||||
|                         }, |  | ||||||
|                     ], |  | ||||||
|                 }, |  | ||||||
|                 options: { |  | ||||||
|                     responsive: true, |  | ||||||
|                     maintainAspectRatio: false, |  | ||||||
|                     scales: { |  | ||||||
|                         x: { |  | ||||||
|                             stacked: true, |  | ||||||
|                             type: "time", |  | ||||||
|                             time: { |  | ||||||
|                                 unit: "minute", |  | ||||||
|                             }, |  | ||||||
|                         }, |  | ||||||
|                         y: { |  | ||||||
|                             stacked: true, |  | ||||||
|                         }, |  | ||||||
|                     }, |  | ||||||
|                 }, |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             new Chart(document.getElementById("scheduleLong"), { |  | ||||||
|                 type: "bar", |  | ||||||
|                 data: { |  | ||||||
|                     labels: [ |  | ||||||
|                         ...data.scheduleLong.once, |  | ||||||
|                         ...data.scheduleLong.interval, |  | ||||||
|                     ].map((row) => luxon.DateTime.fromISO(row.time_key)), |  | ||||||
|                     datasets: [ |  | ||||||
|                         { |  | ||||||
|                             label: "Reminders", |  | ||||||
|                             data: data.scheduleLong.once.map((row) => row.count), |  | ||||||
|                         }, |  | ||||||
|                         { |  | ||||||
|                             label: "Intervals", |  | ||||||
|                             data: data.scheduleLong.interval.map((row) => row.count), |  | ||||||
|                         }, |  | ||||||
|                     ], |  | ||||||
|                 }, |  | ||||||
|                 options: { |  | ||||||
|                     responsive: true, |  | ||||||
|                     maintainAspectRatio: false, |  | ||||||
|                     scales: { |  | ||||||
|                         x: { |  | ||||||
|                             stacked: true, |  | ||||||
|                             type: "time", |  | ||||||
|                             time: { |  | ||||||
|                                 unit: "day", |  | ||||||
|                             }, |  | ||||||
|                         }, |  | ||||||
|                         y: { |  | ||||||
|                             stacked: true, |  | ||||||
|                         }, |  | ||||||
|                     }, |  | ||||||
|                 }, |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             new Chart(document.getElementById("historyLong"), { |  | ||||||
|                 type: "bar", |  | ||||||
|                 data: { |  | ||||||
|                     labels: [...data.historyLong.sent, ...data.historyLong.failed].map( |  | ||||||
|                         (row) => luxon.DateTime.fromISO(row.time_key) |  | ||||||
|                     ), |  | ||||||
|                     datasets: [ |  | ||||||
|                         { |  | ||||||
|                             label: "Success", |  | ||||||
|                             data: data.historyLong.sent.map((row) => row.count), |  | ||||||
|                         }, |  | ||||||
|                         { |  | ||||||
|                             label: "Fail", |  | ||||||
|                             data: data.historyLong.failed.map((row) => row.count), |  | ||||||
|                         }, |  | ||||||
|                     ], |  | ||||||
|                 }, |  | ||||||
|                 options: { |  | ||||||
|                     responsive: true, |  | ||||||
|                     maintainAspectRatio: false, |  | ||||||
|                     scales: { |  | ||||||
|                         x: { |  | ||||||
|                             stacked: true, |  | ||||||
|                             type: "time", |  | ||||||
|                             time: { |  | ||||||
|                                 unit: "day", |  | ||||||
|                             }, |  | ||||||
|                         }, |  | ||||||
|                         y: { |  | ||||||
|                             stacked: true, |  | ||||||
|                         }, |  | ||||||
|                     }, |  | ||||||
|                 }, |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
| }); |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -1,7 +0,0 @@ | |||||||
| /*! |  | ||||||
|  * chartjs-adapter-luxon v1.3.1 |  | ||||||
|  * https://www.chartjs.org |  | ||||||
|  * (c) 2023 chartjs-adapter-luxon Contributors |  | ||||||
|  * Released under the MIT license |  | ||||||
|  */ |  | ||||||
| !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})})); |  | ||||||
| @@ -1,931 +0,0 @@ | |||||||
| (function () { |  | ||||||
|     "use strict"; |  | ||||||
|  |  | ||||||
|     var BODYTYPES = ["DAYS", "MONTHS", "YEARS"]; |  | ||||||
|     var MONTHS = [ |  | ||||||
|         "January", "February", "March", "April", "May", "June", |  | ||||||
|         "July", "August", "September", "October", "November", "December" |  | ||||||
|     ]; |  | ||||||
|     var WEEKDAYS = [ |  | ||||||
|         "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" |  | ||||||
|     ]; |  | ||||||
|  |  | ||||||
|     /** @typedef {Object.<string, Function[]>} Handlers */ |  | ||||||
|     /** @typedef {function(String, Function): null} AddHandler */ |  | ||||||
|     /** @typedef {("DAYS"|"MONTHS"|"YEARS")} BodyType */ |  | ||||||
|     /** @typedef {string|number} StringNum */ |  | ||||||
|     /** @typedef {Object.<string, StringNum>} StringNumObj */ |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * The local state |  | ||||||
|      * @typedef {Object} InstanceState |  | ||||||
|      * @property {Date} value |  | ||||||
|      * @property {Number} year |  | ||||||
|      * @property {Number} month |  | ||||||
|      * @property {Number} day |  | ||||||
|      * @property {Number} time |  | ||||||
|      * @property {Number} hours |  | ||||||
|      * @property {Number} minutes |  | ||||||
|      * @property {Number} seconds |  | ||||||
|      * @property {BodyType} bodyType |  | ||||||
|      * @property {Boolean} visible |  | ||||||
|      * @property {Number} cancelBlur |  | ||||||
|      */ |  | ||||||
|  |  | ||||||
|     /**  |  | ||||||
|      * @typedef {Object} Config |  | ||||||
|      * @property {String} dateFormat |  | ||||||
|      * @property {String} timeFormat |  | ||||||
|      * @property {Boolean} showDate |  | ||||||
|      * @property {Boolean} showTime |  | ||||||
|      * @property {Number} paddingX |  | ||||||
|      * @property {Number} paddingY |  | ||||||
|      * @property {BodyType} defaultView |  | ||||||
|      * @property {"TOP"|"BOTTOM"} direction |  | ||||||
|     */ |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @class |  | ||||||
|      * @param {HTMLElement} elem  |  | ||||||
|      * @param {Config} config  |  | ||||||
|      */ |  | ||||||
|     function DTS(elem, config) { |  | ||||||
|         var config = config || {}; |  | ||||||
|  |  | ||||||
|         /** @type {Config} */ |  | ||||||
|         var defaultConfig = { |  | ||||||
|             defaultView: BODYTYPES[0], |  | ||||||
|             dateFormat: "yyyy-mm-dd", |  | ||||||
|             timeFormat: "HH:MM:SS", |  | ||||||
|             showDate: true, |  | ||||||
|             showTime: false, |  | ||||||
|             paddingX: 5, |  | ||||||
|             paddingY: 5, |  | ||||||
|             direction: 'TOP' |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (!elem) { |  | ||||||
|             throw TypeError("input element or selector required for contructor"); |  | ||||||
|         } |  | ||||||
|         if (Object.getPrototypeOf(elem) === String.prototype) { |  | ||||||
|             var _elem = document.querySelectorAll(elem); |  | ||||||
|             if (!_elem[0]){ |  | ||||||
|                 throw Error('"' + elem + '" not found.'); |  | ||||||
|             } |  | ||||||
|             elem = _elem[0]; |  | ||||||
|         } |  | ||||||
|         this.config = setDefaults(config, defaultConfig); |  | ||||||
|         this.dateFormat = this.config.dateFormat; |  | ||||||
|         this.timeFormat = this.config.timeFormat; |  | ||||||
|         this.dateFormatRegEx = new RegExp("yyyy|yy|mm|dd", "gi"); |  | ||||||
|         this.timeFormatRegEx = new RegExp("hh|mm|ss|a", "gi"); |  | ||||||
|         this.inputElem = elem; |  | ||||||
|         this.dtbox = null; |  | ||||||
|         this.setup(); |  | ||||||
|     } |  | ||||||
|     DTS.prototype.setup = function () { |  | ||||||
|         var handler = this.inputElemHandler.bind(this); |  | ||||||
|         this.inputElem.addEventListener("focus", handler, false) |  | ||||||
|         this.inputElem.addEventListener("blur", handler, false); |  | ||||||
|     } |  | ||||||
|     DTS.prototype.inputElemHandler = function (e) { |  | ||||||
|         if (e.type == "focus") { |  | ||||||
|             if (!this.dtbox) { |  | ||||||
|                 this.dtbox = new DTBox(e.target, this); |  | ||||||
|             } |  | ||||||
|             this.dtbox.visible = true; |  | ||||||
|         } else if (e.type == "blur" && this.dtbox && this.dtbox.visible) { |  | ||||||
|             var self = this; |  | ||||||
|             setTimeout(function () { |  | ||||||
|                 if (self.dtbox.cancelBlur > 0) { |  | ||||||
|                     self.dtbox.cancelBlur -= 1; |  | ||||||
|                  } else { |  | ||||||
|                     self.dtbox.visible = false; |  | ||||||
|                     self.inputElem.blur(); |  | ||||||
|                  } |  | ||||||
|             }, 100); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     /** |  | ||||||
|      * @class |  | ||||||
|      * @param {HTMLElement} elem  |  | ||||||
|      * @param {DTS} settings  |  | ||||||
|      */ |  | ||||||
|     function DTBox(elem, settings) { |  | ||||||
|         /** @type {DTBox} */ |  | ||||||
|         var self = this; |  | ||||||
|  |  | ||||||
|         /** @type {Handlers} */ |  | ||||||
|         var handlers = {}; |  | ||||||
|  |  | ||||||
|         /** @type {InstanceState} */ |  | ||||||
|         var localState = {}; |  | ||||||
|  |  | ||||||
|         /** |  | ||||||
|          * @param {String} key  |  | ||||||
|          * @param {*} default_val  |  | ||||||
|          */ |  | ||||||
|         function getterSetter(key, default_val) { |  | ||||||
|             return { |  | ||||||
|                 get: function () { |  | ||||||
|                     var val = localState[key]; |  | ||||||
|                     return val === undefined ? default_val : val; |  | ||||||
|                 }, |  | ||||||
|                 set: function (val) { |  | ||||||
|                     var prevState = self.state; |  | ||||||
|                     var _handlers = handlers[key] || []; |  | ||||||
|                     localState[key] = val; |  | ||||||
|                     for (var i = 0; i < _handlers.length; i++) { |  | ||||||
|                         _handlers[i].bind(self)(localState, prevState); |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|             }; |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         /** @type {AddHandler} */ |  | ||||||
|         function addHandler(key, handlerFn) { |  | ||||||
|             if (!key || !handlerFn) { |  | ||||||
|                 return false; |  | ||||||
|             } |  | ||||||
|             if (!handlers[key]) { |  | ||||||
|                 handlers[key] = []; |  | ||||||
|             } |  | ||||||
|             handlers[key].push(handlerFn); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Object.defineProperties(this, { |  | ||||||
|             visible: getterSetter("visible", false), |  | ||||||
|             bodyType: getterSetter("bodyType", settings.config.defaultView), |  | ||||||
|             value: getterSetter("value"), |  | ||||||
|             year: getterSetter("year", 0), |  | ||||||
|             month: getterSetter("month", 0), |  | ||||||
|             day: getterSetter("day", 0), |  | ||||||
|             hours: getterSetter("hours", 0), |  | ||||||
|             minutes: getterSetter("minutes", 0), |  | ||||||
|             seconds: getterSetter("seconds", 0), |  | ||||||
|             cancelBlur: getterSetter("cancelBlur", 0), |  | ||||||
|             addHandler: {value: addHandler}, |  | ||||||
|             month_long: { |  | ||||||
|                 get: function () { |  | ||||||
|                     return MONTHS[self.month]; |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|             month_short: { |  | ||||||
|                 get: function () { |  | ||||||
|                     return self.month_long.slice(0, 3); |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|             state: { |  | ||||||
|                 get: function () { |  | ||||||
|                     return Object.assign({}, localState); |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|             time: { |  | ||||||
|                 get: function() { |  | ||||||
|                     var hours = self.hours * 60 * 60 * 1000; |  | ||||||
|                     var minutes = self.minutes * 60 * 1000; |  | ||||||
|                     var seconds = self.seconds * 1000; |  | ||||||
|                     return  hours + minutes + seconds; |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|         }); |  | ||||||
|         this.el = {}; |  | ||||||
|         this.settings = settings; |  | ||||||
|         this.elem = elem; |  | ||||||
|         this.setup(); |  | ||||||
|     } |  | ||||||
|     DTBox.prototype.setup = function () { |  | ||||||
|         Object.defineProperties(this.el, { |  | ||||||
|             wrapper: { value: null, configurable: true }, |  | ||||||
|             header: { value: null, configurable: true }, |  | ||||||
|             body: { value: null, configurable: true }, |  | ||||||
|             footer: { value: null, configurable: true } |  | ||||||
|         }); |  | ||||||
|         this.setupWrapper(); |  | ||||||
|         if (this.settings.config.showDate) { |  | ||||||
|             this.setupHeader(); |  | ||||||
|             this.setupBody(); |  | ||||||
|         } |  | ||||||
|         if (this.settings.config.showTime) { |  | ||||||
|             this.setupFooter(); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var self = this; |  | ||||||
|         this.addHandler("visible", function (state, prevState) { |  | ||||||
|             if (state.visible && !prevState.visible){ |  | ||||||
|                 document.body.appendChild(this.el.wrapper); |  | ||||||
|  |  | ||||||
|                 var parts = self.elem.value.split(/\s*,\s*/); |  | ||||||
|                 var startDate = undefined; |  | ||||||
|                 var startTime = 0; |  | ||||||
|                 if (self.settings.config.showDate) { |  | ||||||
|                     startDate = parseDate(parts[0], self.settings); |  | ||||||
|                 } |  | ||||||
|                 if (self.settings.config.showTime) { |  | ||||||
|                     startTime = parseTime(parts[parts.length-1], self.settings); |  | ||||||
|                     startTime = startTime || 0; |  | ||||||
|                 } |  | ||||||
|                 if (!(startDate && startDate.getTime())) { |  | ||||||
|                     startDate = new Date(); |  | ||||||
|                     startDate = new Date( |  | ||||||
|                         startDate.getFullYear(), |  | ||||||
|                         startDate.getMonth(), |  | ||||||
|                         startDate.getDate() |  | ||||||
|                     ); |  | ||||||
|                 } |  | ||||||
|                 var value = new Date(startDate.getTime() + startTime); |  | ||||||
|                 self.value = value; |  | ||||||
|                 self.year = value.getFullYear(); |  | ||||||
|                 self.month = value.getMonth(); |  | ||||||
|                 self.day = value.getDate(); |  | ||||||
|                 self.hours = value.getHours(); |  | ||||||
|                 self.minutes = value.getMinutes(); |  | ||||||
|                 self.seconds = value.getSeconds(); |  | ||||||
|  |  | ||||||
|                 if (self.settings.config.showDate) { |  | ||||||
|                     self.setHeaderContent(); |  | ||||||
|                     self.setBodyContent(); |  | ||||||
|                 } |  | ||||||
|                 if (self.settings.config.showTime) { |  | ||||||
|                     self.setFooterContent(); |  | ||||||
|                 } |  | ||||||
|             } else if (!state.visible && prevState.visible) { |  | ||||||
|                 document.body.removeChild(this.el.wrapper); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|     DTBox.prototype.setupWrapper = function () { |  | ||||||
|         if (!this.el.wrapper) { |  | ||||||
|             var el = document.createElement("div"); |  | ||||||
|             el.classList.add("date-selector-wrapper"); |  | ||||||
|             Object.defineProperty(this.el, "wrapper", { value: el }); |  | ||||||
|         } |  | ||||||
|         var self = this; |  | ||||||
|         var htmlRoot = document.getElementsByTagName('html')[0]; |  | ||||||
|         function setPosition(e){ |  | ||||||
|             var minTopSpace = 300; |  | ||||||
|             var box = getOffset(self.elem); |  | ||||||
|             var config = self.settings.config; |  | ||||||
|             var paddingY = config.paddingY || 5; |  | ||||||
|             var paddingX = config.paddingX || 5; |  | ||||||
|             var top = box.top + self.elem.offsetHeight + paddingY; |  | ||||||
|             var left = box.left + paddingX; |  | ||||||
|             var bottom = htmlRoot.clientHeight - box.top + paddingY; |  | ||||||
|  |  | ||||||
|             self.el.wrapper.style.left = `${left}px`; |  | ||||||
|             if (box.top > minTopSpace && config.direction != 'BOTTOM') { |  | ||||||
|                 self.el.wrapper.style.bottom = `${bottom}px`; |  | ||||||
|                 self.el.wrapper.style.top = ''; |  | ||||||
|             } else { |  | ||||||
|                 self.el.wrapper.style.top = `${top}px`; |  | ||||||
|                 self.el.wrapper.style.bottom = '';  |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         function handler(e) { |  | ||||||
|             self.cancelBlur += 1; |  | ||||||
|             setTimeout(function(){ |  | ||||||
|                 self.elem.focus(); |  | ||||||
|             }, 50); |  | ||||||
|         } |  | ||||||
|         setPosition(); |  | ||||||
|         this.setPosition = setPosition; |  | ||||||
|         this.el.wrapper.addEventListener("mousedown", handler, false); |  | ||||||
|         this.el.wrapper.addEventListener("touchstart", handler, false); |  | ||||||
|         window.addEventListener('resize', this.setPosition); |  | ||||||
|     } |  | ||||||
|     DTBox.prototype.setupHeader = function () { |  | ||||||
|         if (!this.el.header) { |  | ||||||
|             var row = document.createElement("div"); |  | ||||||
|             var classes = ["cal-nav-prev", "cal-nav-current", "cal-nav-next"]; |  | ||||||
|             row.classList.add("cal-header"); |  | ||||||
|             for (var i = 0; i < 3; i++) { |  | ||||||
|                 var cell = document.createElement("div"); |  | ||||||
|                 cell.classList.add("cal-nav", classes[i]); |  | ||||||
|                 cell.onclick = this.onHeaderChange.bind(this); |  | ||||||
|                 row.appendChild(cell); |  | ||||||
|             } |  | ||||||
|             row.children[0].innerHTML = "<"; |  | ||||||
|             row.children[2].innerHTML = ">"; |  | ||||||
|             Object.defineProperty(this.el, "header", { value: row }); |  | ||||||
|             tryAppendChild(row, this.el.wrapper); |  | ||||||
|         } |  | ||||||
|         this.setHeaderContent(); |  | ||||||
|     } |  | ||||||
|     DTBox.prototype.setHeaderContent = function () { |  | ||||||
|         var content = this.year; |  | ||||||
|         if ("DAYS" == this.bodyType) { |  | ||||||
|             content = this.month_long + " " + content; |  | ||||||
|         } else if ("YEARS" == this.bodyType) { |  | ||||||
|             var start = this.year + 10 - (this.year % 10); |  | ||||||
|             content = start - 10 + "-" + (start - 1); |  | ||||||
|         } |  | ||||||
|         this.el.header.children[1].innerText = content; |  | ||||||
|     } |  | ||||||
|     DTBox.prototype.setupBody = function () { |  | ||||||
|         if (!this.el.body) { |  | ||||||
|             var el = document.createElement("div"); |  | ||||||
|             el.classList.add("cal-body"); |  | ||||||
|             Object.defineProperty(this.el, "body", { value: el }); |  | ||||||
|             tryAppendChild(el, this.el.wrapper); |  | ||||||
|         } |  | ||||||
|         var toAppend = null; |  | ||||||
|         function makeGrid(rows, cols, className, firstRowClass, clickHandler) { |  | ||||||
|             var grid = document.createElement("div"); |  | ||||||
|             grid.classList.add(className); |  | ||||||
|             for (var i = 1; i < rows + 1; i++) { |  | ||||||
|                 var row = document.createElement("div"); |  | ||||||
|                 row.classList.add("cal-row", "cal-row-" + i); |  | ||||||
|                 if (i == 1 && firstRowClass) { |  | ||||||
|                     row.classList.add(firstRowClass); |  | ||||||
|                 } |  | ||||||
|                 for (var j = 1; j < cols + 1; j++) { |  | ||||||
|                     var col = document.createElement("div"); |  | ||||||
|                     col.classList.add("cal-cell", "cal-col-" + j); |  | ||||||
|                     col.onclick = clickHandler; |  | ||||||
|                     row.appendChild(col); |  | ||||||
|                 } |  | ||||||
|                 grid.appendChild(row); |  | ||||||
|             } |  | ||||||
|             return grid; |  | ||||||
|         } |  | ||||||
|         if ("DAYS" == this.bodyType) { |  | ||||||
|             toAppend = this.el.body.calDays; |  | ||||||
|             if (!toAppend) { |  | ||||||
|                 toAppend = makeGrid(7, 7, "cal-days", "cal-day-names", this.onDateSelected.bind(this)); |  | ||||||
|                 for (var i = 0; i < 7; i++) { |  | ||||||
|                     var cell = toAppend.children[0].children[i]; |  | ||||||
|                     cell.innerText = WEEKDAYS[i].slice(0, 2); |  | ||||||
|                     cell.onclick = null; |  | ||||||
|                 } |  | ||||||
|                 this.el.body.calDays = toAppend; |  | ||||||
|             } |  | ||||||
|         } else if ("MONTHS" == this.bodyType) { |  | ||||||
|             toAppend = this.el.body.calMonths; |  | ||||||
|             if (!toAppend) { |  | ||||||
|                 toAppend = makeGrid(3, 4, "cal-months", null, this.onMonthSelected.bind(this)); |  | ||||||
|                 for (var i = 0; i < 3; i++) { |  | ||||||
|                     for (var j = 0; j < 4; j++) { |  | ||||||
|                         var monthShort = MONTHS[4 * i + j].slice(0, 3); |  | ||||||
|                         toAppend.children[i].children[j].innerText = monthShort; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                 this.el.body.calMonths = toAppend; |  | ||||||
|             } |  | ||||||
|         } else if ("YEARS" == this.bodyType) { |  | ||||||
|             toAppend = this.el.body.calYears; |  | ||||||
|             if (!toAppend) { |  | ||||||
|                 toAppend = makeGrid(3, 4, "cal-years", null, this.onYearSelected.bind(this)); |  | ||||||
|                 this.el.body.calYears = toAppend; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         empty(this.el.body); |  | ||||||
|         tryAppendChild(toAppend, this.el.body); |  | ||||||
|         this.setBodyContent(); |  | ||||||
|     } |  | ||||||
|     DTBox.prototype.setBodyContent = function () { |  | ||||||
|         var grid = this.el.body.children[0]; |  | ||||||
|         var classes = ["cal-cell-prev", "cal-cell-next", "cal-value"]; |  | ||||||
|         if ("DAYS" == this.bodyType) { |  | ||||||
|             var oneDayMilliSecs = 24 * 60 * 60 * 1000; |  | ||||||
|             var start = new Date(this.year, this.month, 1); |  | ||||||
|             var adjusted = new Date(start.getTime() - oneDayMilliSecs * start.getDay()); |  | ||||||
|  |  | ||||||
|             grid.children[6].style.display = ""; |  | ||||||
|             for (var i = 1; i < 7; i++) { |  | ||||||
|                 for (var j = 0; j < 7; j++) { |  | ||||||
|                     var cell = grid.children[i].children[j]; |  | ||||||
|                     var month = adjusted.getMonth(); |  | ||||||
|                     var date = adjusted.getDate(); |  | ||||||
|                      |  | ||||||
|                     cell.innerText = date; |  | ||||||
|                     cell.classList.remove(classes[0], classes[1], classes[2]); |  | ||||||
|                     if (month != this.month) { |  | ||||||
|                         if (i == 6 && j == 0) { |  | ||||||
|                             grid.children[6].style.display = "none"; |  | ||||||
|                             break; |  | ||||||
|                         } |  | ||||||
|                         cell.classList.add(month < this.month ? classes[0] : classes[1]); |  | ||||||
|                     } else if (isEqualDate(adjusted, this.value)){ |  | ||||||
|                         cell.classList.add(classes[2]); |  | ||||||
|                     } |  | ||||||
|                     adjusted = new Date(adjusted.getTime() + oneDayMilliSecs); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } else if ("YEARS" == this.bodyType) { |  | ||||||
|             var year = this.year - (this.year % 10) - 1; |  | ||||||
|             for (i = 0; i < 3; i++) { |  | ||||||
|                 for (j = 0; j < 4; j++) { |  | ||||||
|                     grid.children[i].children[j].innerText = year; |  | ||||||
|                     year += 1; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             grid.children[0].children[0].classList.add(classes[0]); |  | ||||||
|             grid.children[2].children[3].classList.add(classes[1]); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** @param {Event} e */ |  | ||||||
|     DTBox.prototype.onTimeChange = function(e) { |  | ||||||
|         e.stopPropagation(); |  | ||||||
|         if (e.type == 'mousedown') { |  | ||||||
|             this.cancelBlur += 1; |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         var el = e.target; |  | ||||||
|         this[el.name] = parseInt(el.value) || 0; |  | ||||||
|         this.setupFooter(); |  | ||||||
|         if (e.type == 'change') { |  | ||||||
|             var self = this; |  | ||||||
|             setTimeout(function(){ |  | ||||||
|                 self.elem.focus(); |  | ||||||
|             }, 50); |  | ||||||
|         } |  | ||||||
|         this.setInputValue(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     DTBox.prototype.setupFooter = function() { |  | ||||||
|         if (!this.el.footer) { |  | ||||||
|             var footer = document.createElement("div"); |  | ||||||
|             var handler = this.onTimeChange.bind(this); |  | ||||||
|             var self = this; |  | ||||||
|              |  | ||||||
|             function makeRow(label, name, range, changeHandler) { |  | ||||||
|                 var row = document.createElement("div"); |  | ||||||
|                 row.classList.add('cal-time'); |  | ||||||
|  |  | ||||||
|                 var labelCol = row.appendChild(document.createElement("div")); |  | ||||||
|                 labelCol.classList.add('cal-time-label'); |  | ||||||
|                 labelCol.innerText = label; |  | ||||||
|  |  | ||||||
|                 var valueCol = row.appendChild(document.createElement("div")); |  | ||||||
|                 valueCol.classList.add('cal-time-value'); |  | ||||||
|                 valueCol.innerText = '00'; |  | ||||||
|  |  | ||||||
|                 var inputCol = row.appendChild(document.createElement("div")); |  | ||||||
|                 var slider = inputCol.appendChild(document.createElement("input")); |  | ||||||
|                 Object.assign(slider, {step:1, min:0, max:range, name:name, type:'range'}); |  | ||||||
|                 Object.defineProperty(footer, name, {value: slider}); |  | ||||||
|                 inputCol.classList.add('cal-time-slider'); |  | ||||||
|                 slider.onchange = changeHandler; |  | ||||||
|                 slider.oninput = changeHandler; |  | ||||||
|                 slider.onmousedown = changeHandler; |  | ||||||
|                 self[name] = self[name] || parseInt(slider.value) || 0; |  | ||||||
|                 footer.appendChild(row) |  | ||||||
|             } |  | ||||||
|             makeRow('HH:', 'hours', 23, handler); |  | ||||||
|             makeRow('MM:', 'minutes', 59, handler); |  | ||||||
|             makeRow('SS:', 'seconds', 59, handler); |  | ||||||
|  |  | ||||||
|             footer.classList.add("cal-footer"); |  | ||||||
|             Object.defineProperty(this.el, "footer", { value: footer }); |  | ||||||
|             tryAppendChild(footer, this.el.wrapper); |  | ||||||
|         } |  | ||||||
|         this.setFooterContent(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     DTBox.prototype.setFooterContent = function() { |  | ||||||
|         if (this.el.footer) { |  | ||||||
|             var footer = this.el.footer; |  | ||||||
|             footer.hours.value = this.hours; |  | ||||||
|             footer.children[0].children[1].innerText = padded(this.hours, 2); |  | ||||||
|             footer.minutes.value = this.minutes; |  | ||||||
|             footer.children[1].children[1].innerText = padded(this.minutes, 2); |  | ||||||
|             footer.seconds.value = this.seconds; |  | ||||||
|             footer.children[2].children[1].innerText = padded(this.seconds, 2); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     DTBox.prototype.setInputValue = function() { |  | ||||||
|         var date = new Date(this.year, this.month, this.day); |  | ||||||
|         var strings = []; |  | ||||||
|         if (this.settings.config.showDate) { |  | ||||||
|             strings.push(renderDate(date, this.settings)); |  | ||||||
|         } |  | ||||||
|         if (this.settings.config.showTime) { |  | ||||||
|             var joined = new Date(date.getTime() + this.time); |  | ||||||
|             strings.push(renderTime(joined, this.settings)); |  | ||||||
|         } |  | ||||||
|         this.elem.value = strings.join(', '); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     DTBox.prototype.onDateSelected = function (e) { |  | ||||||
|         var row = e.target.parentNode; |  | ||||||
|         var date = parseInt(e.target.innerText); |  | ||||||
|         if (!(row.nextSibling && row.nextSibling.nextSibling) && date < 8) { |  | ||||||
|             this.month += 1; |  | ||||||
|         } else if (!(row.previousSibling && row.previousSibling.previousSibling) && date > 7) { |  | ||||||
|             this.month -= 1; |  | ||||||
|         } |  | ||||||
|         this.day = parseInt(e.target.innerText); |  | ||||||
|         this.value = new Date(this.year, this.month, this.day); |  | ||||||
|         this.setInputValue(); |  | ||||||
|         this.setHeaderContent(); |  | ||||||
|         this.setBodyContent(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** @param {Event} e */ |  | ||||||
|     DTBox.prototype.onMonthSelected = function (e) { |  | ||||||
|         var col = 0; |  | ||||||
|         var row = 2; |  | ||||||
|         var cell = e.target; |  | ||||||
|         if (cell.parentNode.nextSibling){ |  | ||||||
|             row = cell.parentNode.previousSibling ? 1: 0; |  | ||||||
|         } |  | ||||||
|         if (cell.previousSibling) { |  | ||||||
|             col = 3; |  | ||||||
|             if (cell.nextSibling) { |  | ||||||
|                 col = cell.previousSibling.previousSibling ? 2 : 1; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         this.month = 4 * row + col; |  | ||||||
|         this.bodyType = "DAYS"; |  | ||||||
|         this.setHeaderContent(); |  | ||||||
|         this.setupBody(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** @param {Event} e */ |  | ||||||
|     DTBox.prototype.onYearSelected = function (e) { |  | ||||||
|         this.year = parseInt(e.target.innerText); |  | ||||||
|         this.bodyType = "MONTHS"; |  | ||||||
|         this.setHeaderContent(); |  | ||||||
|         this.setupBody(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** @param {Event} e */ |  | ||||||
|     DTBox.prototype.onHeaderChange = function (e) { |  | ||||||
|         var cell = e.target; |  | ||||||
|         if (cell.previousSibling && cell.nextSibling) { |  | ||||||
|             var idx = BODYTYPES.indexOf(this.bodyType); |  | ||||||
|             if (idx < 0 || !BODYTYPES[idx + 1]) { |  | ||||||
|                 return; |  | ||||||
|             } |  | ||||||
|             this.bodyType = BODYTYPES[idx + 1]; |  | ||||||
|             this.setupBody(); |  | ||||||
|         } else { |  | ||||||
|             var sign = cell.previousSibling ? 1 : -1; |  | ||||||
|             switch (this.bodyType) { |  | ||||||
|                 case "DAYS": |  | ||||||
|                     this.month += sign * 1; |  | ||||||
|                     break; |  | ||||||
|                 case "MONTHS": |  | ||||||
|                     this.year += sign * 1; |  | ||||||
|                     break; |  | ||||||
|                 case "YEARS": |  | ||||||
|                     this.year += sign * 10; |  | ||||||
|             } |  | ||||||
|             if (this.month > 11 || this.month < 0) { |  | ||||||
|                 this.year += Math.floor(this.month / 11); |  | ||||||
|                 this.month = this.month > 11 ? 0 : 11; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         this.setHeaderContent(); |  | ||||||
|         this.setBodyContent(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @param {HTMLElement} elem  |  | ||||||
|      * @returns {{left:number, top:number}} |  | ||||||
|      */ |  | ||||||
|     function getOffset(elem) { |  | ||||||
|         var box = elem.getBoundingClientRect(); |  | ||||||
|         var left = window.pageXOffset !== undefined ? window.pageXOffset :  |  | ||||||
|             (document.documentElement || document.body.parentNode || document.body).scrollLeft; |  | ||||||
|         var top = window.pageYOffset !== undefined ? window.pageYOffset :  |  | ||||||
|             (document.documentElement || document.body.parentNode || document.body).scrollTop; |  | ||||||
|         return { left: box.left + left, top: box.top + top }; |  | ||||||
|     } |  | ||||||
|     function empty(e) { |  | ||||||
|         for (; e.children.length; ) e.removeChild(e.children[0]); |  | ||||||
|     } |  | ||||||
|     function tryAppendChild(newChild, refNode) { |  | ||||||
|         try { |  | ||||||
|             refNode.appendChild(newChild); |  | ||||||
|             return newChild; |  | ||||||
|         } catch (e) { |  | ||||||
|             console.trace(e); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** @class */ |  | ||||||
|     function hookFuncs() { |  | ||||||
|         /** @type {Handlers} */ |  | ||||||
|         this._funcs = {}; |  | ||||||
|     } |  | ||||||
|     /** |  | ||||||
|      * @param {string} key  |  | ||||||
|      * @param {Function} func  |  | ||||||
|      */ |  | ||||||
|     hookFuncs.prototype.add = function(key, func){ |  | ||||||
|         if (!this._funcs[key]){ |  | ||||||
|             this._funcs[key] = []; |  | ||||||
|         } |  | ||||||
|         this._funcs[key].push(func) |  | ||||||
|     } |  | ||||||
|     /** |  | ||||||
|      * @param {String} key  |  | ||||||
|      * @returns {Function[]} handlers |  | ||||||
|      */ |  | ||||||
|     hookFuncs.prototype.get = function(key){ |  | ||||||
|         return this._funcs[key] ? this._funcs[key] : []; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @param {Array.<string>} arr  |  | ||||||
|      * @param {String} string  |  | ||||||
|      * @returns {Array.<string>} sorted string |  | ||||||
|      */ |  | ||||||
|     function sortByStringIndex(arr, string) { |  | ||||||
|         return arr.sort(function(a, b){ |  | ||||||
|             var h = string.indexOf(a); |  | ||||||
|             var l = string.indexOf(b); |  | ||||||
|             var rank = 0; |  | ||||||
|             if (h < l) { |  | ||||||
|                 rank = -1; |  | ||||||
|             } else if (l < h) { |  | ||||||
|                 rank = 1; |  | ||||||
|             } else if (a.length > b.length) { |  | ||||||
|                 rank = -1; |  | ||||||
|             } else if (b.length > a.length) { |  | ||||||
|                 rank = 1; |  | ||||||
|             } |  | ||||||
|             return rank; |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Remove keys from array that are not in format |  | ||||||
|      * @param {string[]} keys  |  | ||||||
|      * @param {string} format  |  | ||||||
|      * @returns {string[]} new filtered array |  | ||||||
|      */ |  | ||||||
|     function filterFormatKeys(keys, format) { |  | ||||||
|         var out = []; |  | ||||||
|         var formatIdx = 0; |  | ||||||
|         for (var i = 0; i<keys.length; i++) { |  | ||||||
|             var key = keys[i]; |  | ||||||
|             if (format.slice(formatIdx).indexOf(key) > -1) { |  | ||||||
|                 formatIdx += key.length; |  | ||||||
|                 out.push(key); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         return out; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @template {StringNumObj} FormatObj |  | ||||||
|      * @param {string} value  |  | ||||||
|      * @param {string} format  |  | ||||||
|      * @param {FormatObj} formatObj  |  | ||||||
|      * @param {function(Object.<string, hookFuncs>): null} setHooks  |  | ||||||
|      * @returns {FormatObj} formatObj |  | ||||||
|      */ |  | ||||||
|     function parseData(value, format, formatObj, setHooks) { |  | ||||||
|         var hooks = { |  | ||||||
|             canSkip: new hookFuncs(), |  | ||||||
|             updateValue: new hookFuncs(), |  | ||||||
|         } |  | ||||||
|         var keys = sortByStringIndex(Object.keys(formatObj), format); |  | ||||||
|         var filterdKeys = filterFormatKeys(keys, format); |  | ||||||
|         var vstart = 0; // value start |  | ||||||
|         if (setHooks) { |  | ||||||
|             setHooks(hooks); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         for (var i = 0; i < keys.length; i++) { |  | ||||||
|             var key = keys[i]; |  | ||||||
|             var fstart = format.indexOf(key); |  | ||||||
|             var _vstart = vstart; // next value start |  | ||||||
|             var val = null; |  | ||||||
|             var canSkip = false; |  | ||||||
|             var funcs = hooks.canSkip.get(key); |  | ||||||
|  |  | ||||||
|             vstart = vstart || fstart; |  | ||||||
|  |  | ||||||
|             for (var j = 0; j < funcs.length; j++) { |  | ||||||
|                 if (funcs[j](formatObj)){ |  | ||||||
|                     canSkip = true; |  | ||||||
|                     break; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             if (fstart > -1 && !canSkip) { |  | ||||||
|                 var sep = null; |  | ||||||
|                 var stop = vstart + key.length; |  | ||||||
|                 var fnext = -1; |  | ||||||
|                 var nextKeyIdx = i + 1; |  | ||||||
|                 _vstart += key.length; // set next value start if current key is found |  | ||||||
|  |  | ||||||
|                 // get next format token used to determine separator |  | ||||||
|                 while (fnext == -1 && nextKeyIdx < keys.length){ |  | ||||||
|                     var nextKey = keys[nextKeyIdx]; |  | ||||||
|                     nextKeyIdx += 1; |  | ||||||
|                     if (filterdKeys.indexOf(nextKey) === -1) { |  | ||||||
|                         continue; |  | ||||||
|                     } |  | ||||||
|                     fnext = nextKey ? format.indexOf(nextKey) : -1; // next format start |  | ||||||
|                 } |  | ||||||
|                 if (fnext > -1){ |  | ||||||
|                     sep = format.slice(stop, fnext); |  | ||||||
|                     if (sep) { |  | ||||||
|                         var _stop = value.slice(vstart).indexOf(sep); |  | ||||||
|                         if (_stop && _stop > -1){ |  | ||||||
|                             stop = _stop + vstart; |  | ||||||
|                             _vstart = stop + sep.length; |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|                 val = parseInt(value.slice(vstart, stop)); |  | ||||||
|  |  | ||||||
|                 var funcs = hooks.updateValue.get(key); |  | ||||||
|                 for (var k = 0; k < funcs.length; k++) { |  | ||||||
|                     val = funcs[k](val, formatObj, vstart, stop); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             formatObj[key] = { index: vstart, value: val }; |  | ||||||
|             vstart = _vstart; // set next value start |  | ||||||
|         } |  | ||||||
|         return formatObj; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @param {String} value  |  | ||||||
|      * @param {DTS} settings  |  | ||||||
|      * @returns {Date} date object |  | ||||||
|      */ |  | ||||||
|     function parseDate(value, settings) { |  | ||||||
|         /** @type {{yyyy:number=, yy:number=, mm:number=, dd:number=}} */ |  | ||||||
|         var formatObj = {yyyy:null, yy:null, mm:null, dd:null}; |  | ||||||
|         var format = ((settings.dateFormat) || '').toLowerCase(); |  | ||||||
|         if (!format) { |  | ||||||
|             throw new TypeError('dateFormat not found (' + settings.dateFormat + ')'); |  | ||||||
|         } |  | ||||||
|         var formatObj = parseData(value, format, formatObj, function(hooks){ |  | ||||||
|             hooks.canSkip.add("yy", function(data){ |  | ||||||
|                 return data["yyyy"].value; |  | ||||||
|             }); |  | ||||||
|             hooks.updateValue.add("yy", function(val){ |  | ||||||
|                 return 100 * Math.floor(new Date().getFullYear() / 100) + val; |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|         var year = formatObj["yyyy"].value || formatObj["yy"].value; |  | ||||||
|         var month = formatObj["mm"].value - 1; |  | ||||||
|         var date = formatObj["dd"].value; |  | ||||||
|         var result = new Date(year, month, date); |  | ||||||
|         return result; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @param {String} value  |  | ||||||
|      * @param {DTS} settings  |  | ||||||
|      * @returns {Number} time in milliseconds <= (24 * 60 * 60 * 1000) - 1 |  | ||||||
|      */ |  | ||||||
|     function parseTime(value, settings) { |  | ||||||
|         var format = ((settings.timeFormat) || '').toLowerCase(); |  | ||||||
|         if (!format) { |  | ||||||
|             throw new TypeError('timeFormat not found (' + settings.timeFormat + ')'); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /** @type {{hh:number=, mm:number=, ss:number=, a:string=}} */ |  | ||||||
|         var formatObj = {hh:null, mm:null, ss:null, a:null}; |  | ||||||
|         var formatObj = parseData(value, format, formatObj, function(hooks){ |  | ||||||
|             hooks.updateValue.add("a", function(val, data, start, stop){ |  | ||||||
|                 return value.slice(start, start + 2); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|         var hours = formatObj["hh"].value; |  | ||||||
|         var minutes = formatObj["mm"].value; |  | ||||||
|         var seconds = formatObj["ss"].value; |  | ||||||
|         var am_pm = formatObj["a"].value; |  | ||||||
|         var am_pm_lower = am_pm ? am_pm.toLowerCase() : am_pm; |  | ||||||
|         if (am_pm && ["am", "pm"].indexOf(am_pm_lower) > -1){ |  | ||||||
|             if (am_pm_lower == 'am' && hours == 12){ |  | ||||||
|                 hours = 0; |  | ||||||
|             } else if (am_pm_lower == 'pm') { |  | ||||||
|                 hours += 12; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         var time = hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000; |  | ||||||
|         return time; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @param {Date} value  |  | ||||||
|      * @param {DTS} settings  |  | ||||||
|      * @returns {String} date string |  | ||||||
|      */ |  | ||||||
|     function renderDate(value, settings) { |  | ||||||
|         var format = settings.dateFormat.toLowerCase(); |  | ||||||
|         var date = value.getDate(); |  | ||||||
|         var month = value.getMonth() + 1; |  | ||||||
|         var year = value.getFullYear(); |  | ||||||
|         var yearShort = year % 100; |  | ||||||
|         var formatObj = { |  | ||||||
|             dd: date < 10 ? "0" + date : date, |  | ||||||
|             mm: month < 10 ? "0" + month : month, |  | ||||||
|             yyyy: year, |  | ||||||
|             yy: yearShort < 10 ? "0" + yearShort : yearShort |  | ||||||
|         }; |  | ||||||
|         var str = format.replace(settings.dateFormatRegEx, function (found) { |  | ||||||
|             return formatObj[found]; |  | ||||||
|         }); |  | ||||||
|         return str; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @param {Date} value  |  | ||||||
|      * @param {DTS} settings  |  | ||||||
|      * @returns {String} date string |  | ||||||
|      */ |  | ||||||
|     function renderTime(value, settings) { |  | ||||||
|         var Format = settings.timeFormat; |  | ||||||
|         var format = Format.toLowerCase(); |  | ||||||
|         var hours = value.getHours(); |  | ||||||
|         var minutes = value.getMinutes(); |  | ||||||
|         var seconds = value.getSeconds(); |  | ||||||
|         var am_pm = null; |  | ||||||
|         var hh_am_pm = null; |  | ||||||
|         if (format.indexOf('a') > -1) { |  | ||||||
|             am_pm = hours >= 12 ? 'pm' : 'am'; |  | ||||||
|             am_pm = Format.indexOf('A') > -1 ? am_pm.toUpperCase() : am_pm; |  | ||||||
|             hh_am_pm = hours == 0 ? '12' : (hours > 12 ? hours%12 : hours); |  | ||||||
|         } |  | ||||||
|         var formatObj = { |  | ||||||
|             hh: am_pm ? hh_am_pm : (hours < 10 ? "0" + hours : hours), |  | ||||||
|             mm: minutes < 10 ? "0" + minutes : minutes, |  | ||||||
|             ss: seconds < 10 ? "0" + seconds : seconds, |  | ||||||
|             a: am_pm, |  | ||||||
|         }; |  | ||||||
|         var str = format.replace(settings.timeFormatRegEx, function (found) { |  | ||||||
|             return formatObj[found]; |  | ||||||
|         }); |  | ||||||
|         return str; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * checks if two dates are equal |  | ||||||
|      * @param {Date} date1  |  | ||||||
|      * @param {Date} date2  |  | ||||||
|      * @returns {Boolean} true or false |  | ||||||
|      */ |  | ||||||
|     function isEqualDate(date1, date2) { |  | ||||||
|         if (!(date1 && date2)) return false; |  | ||||||
|         return (date1.getFullYear() == date2.getFullYear() &&  |  | ||||||
|                 date1.getMonth() == date2.getMonth() &&  |  | ||||||
|                 date1.getDate() == date2.getDate()); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @param {Number} val  |  | ||||||
|      * @param {Number} pad  |  | ||||||
|      * @param {*} default_val  |  | ||||||
|      * @returns {String} padded string |  | ||||||
|      */ |  | ||||||
|     function padded(val, pad, default_val) { |  | ||||||
|         var default_val = default_val || 0; |  | ||||||
|         var valStr = '' + (parseInt(val) || default_val); |  | ||||||
|         var diff = Math.max(pad, valStr.length) - valStr.length; |  | ||||||
|         return ('' + default_val).repeat(diff) + valStr; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * @template X |  | ||||||
|      * @template Y |  | ||||||
|      * @param {X} obj  |  | ||||||
|      * @param {Y} objDefaults  |  | ||||||
|      * @returns {X|Y} merged object |  | ||||||
|      */ |  | ||||||
|     function setDefaults(obj, objDefaults) { |  | ||||||
|         var keys = Object.keys(objDefaults); |  | ||||||
|         for (var i=0; i<keys.length; i++) { |  | ||||||
|             var key = keys[i]; |  | ||||||
|             if (!Object.prototype.hasOwnProperty.call(obj, key)) { |  | ||||||
|                 obj[key] = objDefaults[key]; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         return obj; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     window.dtsel = Object.create({},{ |  | ||||||
|         DTS: { value: DTS }, |  | ||||||
|         DTObj: { value: DTBox }, |  | ||||||
|         fn: { |  | ||||||
|             value: Object.defineProperties({}, { |  | ||||||
|                 empty: { value: empty }, |  | ||||||
|                 appendAfter: { |  | ||||||
|                     value: function (newElem, refNode) { |  | ||||||
|                         refNode.parentNode.insertBefore(newElem, refNode.nextSibling); |  | ||||||
|                     }, |  | ||||||
|                 }, |  | ||||||
|                 getOffset: { value: getOffset }, |  | ||||||
|                 parseDate: { value: parseDate }, |  | ||||||
|                 renderDate: { value: renderDate }, |  | ||||||
|                 parseTime: {value: parseTime}, |  | ||||||
|                 renderTime: {value: renderTime}, |  | ||||||
|                 setDefaults: {value: setDefaults}, |  | ||||||
|             }), |  | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
| })(); |  | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| function collapse_all() { |  | ||||||
|     document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => { |  | ||||||
|         el.classList.add("is-collapsed"); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function expand_all() { |  | ||||||
|     document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => { |  | ||||||
|         el.classList.remove("is-collapsed"); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const expandAll = document.querySelector("#expandAll"); |  | ||||||
|  |  | ||||||
| expandAll.addEventListener("change", (ev) => { |  | ||||||
|     if (ev.target.value === "expand") { |  | ||||||
|         expand_all(); |  | ||||||
|     } else if (ev.target.value === "collapse") { |  | ||||||
|         collapse_all(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     ev.target.value = ""; |  | ||||||
| }); |  | ||||||
| @@ -1,94 +0,0 @@ | |||||||
| function get_interval(element) { |  | ||||||
|     let months = element.querySelector('input[name="interval_months"]').value; |  | ||||||
|     let days = element.querySelector('input[name="interval_days"]').value; |  | ||||||
|     let hours = element.querySelector('input[name="interval_hours"]').value; |  | ||||||
|     let minutes = element.querySelector('input[name="interval_minutes"]').value; |  | ||||||
|     let seconds = element.querySelector('input[name="interval_seconds"]').value; |  | ||||||
|  |  | ||||||
|     return { |  | ||||||
|         months: parseInt(months) || null, |  | ||||||
|         days: parseInt(days) || null, |  | ||||||
|         seconds: |  | ||||||
|             (parseInt(hours) || 0) * 3600 + |  | ||||||
|                 (parseInt(minutes) || 0) * 60 + |  | ||||||
|                 (parseInt(seconds) || 0) || null, |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function update_interval(element) { |  | ||||||
|     let months = element.querySelector('input[name="interval_months"]'); |  | ||||||
|     let days = element.querySelector('input[name="interval_days"]'); |  | ||||||
|     let hours = element.querySelector('input[name="interval_hours"]'); |  | ||||||
|     let minutes = element.querySelector('input[name="interval_minutes"]'); |  | ||||||
|     let seconds = element.querySelector('input[name="interval_seconds"]'); |  | ||||||
|  |  | ||||||
|     let interval = get_interval(element); |  | ||||||
|  |  | ||||||
|     if (interval.months === null && interval.days === null && interval.seconds === null) { |  | ||||||
|         months.value = ""; |  | ||||||
|         days.value = ""; |  | ||||||
|         hours.value = ""; |  | ||||||
|         minutes.value = ""; |  | ||||||
|         seconds.value = ""; |  | ||||||
|     } else { |  | ||||||
|         months.value = months.value.padStart(1, "0"); |  | ||||||
|         days.value = days.value.padStart(1, "0"); |  | ||||||
|         hours.value = hours.value.padStart(2, "0"); |  | ||||||
|         minutes.value = minutes.value.padStart(2, "0"); |  | ||||||
|         seconds.value = seconds.value.padStart(2, "0"); |  | ||||||
|  |  | ||||||
|         if (seconds.value >= 60) { |  | ||||||
|             let quotient = Math.floor(seconds.value / 60); |  | ||||||
|             let remainder = seconds.value % 60; |  | ||||||
|  |  | ||||||
|             seconds.value = String(remainder).padStart(2, "0"); |  | ||||||
|             minutes.value = String(Number(minutes.value) + Number(quotient)).padStart( |  | ||||||
|                 2, |  | ||||||
|                 "0" |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
|         if (minutes.value >= 60) { |  | ||||||
|             let quotient = Math.floor(minutes.value / 60); |  | ||||||
|             let remainder = minutes.value % 60; |  | ||||||
|  |  | ||||||
|             minutes.value = String(remainder).padStart(2, "0"); |  | ||||||
|             hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0"); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const $intervalGroup = document.querySelector(".interval-group"); |  | ||||||
|  |  | ||||||
| document.querySelector(".interval-group").addEventListener( |  | ||||||
|     "blur", |  | ||||||
|     (ev) => { |  | ||||||
|         if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup); |  | ||||||
|     }, |  | ||||||
|     true |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| $intervalGroup.querySelector("button.clear").addEventListener("click", () => { |  | ||||||
|     $intervalGroup.querySelectorAll("input").forEach((el) => { |  | ||||||
|         el.value = ""; |  | ||||||
|     }); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| document.addEventListener("remindersLoaded", (event) => { |  | ||||||
|     for (reminder of event.detail) { |  | ||||||
|         let $intervalGroup = reminder.node.querySelector(".interval-group"); |  | ||||||
|  |  | ||||||
|         $intervalGroup.addEventListener( |  | ||||||
|             "blur", |  | ||||||
|             (ev) => { |  | ||||||
|                 if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup); |  | ||||||
|             }, |  | ||||||
|             true |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         $intervalGroup.querySelector("button.clear").addEventListener("click", () => { |  | ||||||
|             $intervalGroup.querySelectorAll("input").forEach((el) => { |  | ||||||
|                 el.value = ""; |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| }); |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										2
									
								
								static/js/js.cookie.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								static/js/js.cookie.min.js
									
									
									
									
										vendored
									
									
								
							| @@ -1,2 +0,0 @@ | |||||||
| /*! js-cookie v3.0.0-rc.0 | MIT */ |  | ||||||
| !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var r=e.Cookies,n=e.Cookies=t();n.noConflict=function(){return e.Cookies=r,n}}())}(this,function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)e[n]=r[n]}return e}var t={read:function(e){return e.replace(/%3B/g,";")},write:function(e){return e.replace(/;/g,"%3B")}};return function r(n,i){function o(r,o,u){if("undefined"!=typeof document){"number"==typeof(u=e({},i,u)).expires&&(u.expires=new Date(Date.now()+864e5*u.expires)),u.expires&&(u.expires=u.expires.toUTCString()),r=t.write(r).replace(/=/g,"%3D"),o=n.write(String(o),r);var c="";for(var f in u)u[f]&&(c+="; "+f,!0!==u[f]&&(c+="="+u[f].split(";")[0]));return document.cookie=r+"="+o+c}}return Object.create({set:o,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var r=document.cookie?document.cookie.split("; "):[],i={},o=0;o<r.length;o++){var u=r[o].split("="),c=u.slice(1).join("="),f=t.read(u[0]).replace(/%3D/g,"=");if(i[f]=n.read(c,f),e===f)break}return e?i[e]:i}},remove:function(t,r){o(t,"",e({},r,{expires:-1}))},withAttributes:function(t){return r(this.converter,e({},this.attributes,t))},withConverter:function(t){return r(e({},this.converter,t),this.attributes)}},{attributes:{value:Object.freeze(i)},converter:{value:Object.freeze(n)}})}(t,{path:"/"})}); |  | ||||||
							
								
								
									
										1
									
								
								static/js/luxon.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								static/js/luxon.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1102
									
								
								static/js/main.js
									
									
									
									
									
								
							
							
						
						
									
										1102
									
								
								static/js/main.js
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,70 +0,0 @@ | |||||||
| let guildReminders = document.querySelector("#guildReminders"); |  | ||||||
|  |  | ||||||
| function sort_by(cond) { |  | ||||||
|     if (cond === "channel") { |  | ||||||
|         [...guildReminders.children] |  | ||||||
|             .sort((a, b) => { |  | ||||||
|                 let channel1 = a.querySelector("select.channel-selector").value; |  | ||||||
|                 let channel2 = b.querySelector("select.channel-selector").value; |  | ||||||
|  |  | ||||||
|                 return channel1 > channel2 ? 1 : -1; |  | ||||||
|             }) |  | ||||||
|             .forEach((node) => guildReminders.appendChild(node)); |  | ||||||
|  |  | ||||||
|         // go through and add channel categories |  | ||||||
|         let currentChannelGroup = null; |  | ||||||
|         for (let child of guildReminders.querySelectorAll("div.reminderContent")) { |  | ||||||
|             let thisChannelGroup = child.querySelector("select.channel-selector").value; |  | ||||||
|  |  | ||||||
|             if (currentChannelGroup !== thisChannelGroup) { |  | ||||||
|                 let newNode = document.createElement("div"); |  | ||||||
|                 newNode.textContent = |  | ||||||
|                     "#" + channels.find((a) => a.id === thisChannelGroup).name; |  | ||||||
|                 newNode.classList.add("channel-tag"); |  | ||||||
|  |  | ||||||
|                 guildReminders.insertBefore(newNode, child); |  | ||||||
|  |  | ||||||
|                 currentChannelGroup = thisChannelGroup; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } else { |  | ||||||
|         // remove any channel tags if previous ordering was by channel |  | ||||||
|         guildReminders.querySelectorAll("div.channel-tag").forEach((el) => { |  | ||||||
|             el.remove(); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         if (cond === "time") { |  | ||||||
|             [...guildReminders.children] |  | ||||||
|                 .sort((a, b) => { |  | ||||||
|                     let time1 = luxon.DateTime.fromISO( |  | ||||||
|                         a.querySelector('input[name="time"]').value |  | ||||||
|                     ); |  | ||||||
|                     let time2 = luxon.DateTime.fromISO( |  | ||||||
|                         b.querySelector('input[name="time"]').value |  | ||||||
|                     ); |  | ||||||
|  |  | ||||||
|                     return time1 > time2 ? 1 : -1; |  | ||||||
|                 }) |  | ||||||
|                 .forEach((node) => guildReminders.appendChild(node)); |  | ||||||
|         } else { |  | ||||||
|             [...guildReminders.children] |  | ||||||
|                 .sort((a, b) => { |  | ||||||
|                     let name1 = a.querySelector('input[name="name"]').value; |  | ||||||
|                     let name2 = b.querySelector('input[name="name"]').value; |  | ||||||
|  |  | ||||||
|                     return name1 > name2 ? 1 : -1; |  | ||||||
|                 }) |  | ||||||
|                 .forEach((node) => guildReminders.appendChild(node)); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const selector = document.querySelector("#orderBy"); |  | ||||||
|  |  | ||||||
| selector.addEventListener("change", () => { |  | ||||||
|     sort_by(selector.value); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| document.addEventListener("remindersLoaded", () => { |  | ||||||
|     sort_by(selector.value); |  | ||||||
| }); |  | ||||||
| @@ -1,57 +0,0 @@ | |||||||
| let timezone = luxon.DateTime.now().zone.name; |  | ||||||
| const browserTimezone = luxon.DateTime.now().zone.name; |  | ||||||
| let botTimezone = "UTC"; |  | ||||||
|  |  | ||||||
| function update_times() { |  | ||||||
|     document.querySelectorAll("span.set-timezone").forEach((element) => { |  | ||||||
|         element.textContent = timezone; |  | ||||||
|     }); |  | ||||||
|     document.querySelectorAll("span.set-time").forEach((element) => { |  | ||||||
|         element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm"); |  | ||||||
|     }); |  | ||||||
|     document.querySelectorAll("span.browser-timezone").forEach((element) => { |  | ||||||
|         element.textContent = browserTimezone; |  | ||||||
|     }); |  | ||||||
|     document.querySelectorAll("span.browser-time").forEach((element) => { |  | ||||||
|         element.textContent = luxon.DateTime.now().toFormat("HH:mm"); |  | ||||||
|     }); |  | ||||||
|     document.querySelectorAll("span.bot-timezone").forEach((element) => { |  | ||||||
|         element.textContent = botTimezone; |  | ||||||
|     }); |  | ||||||
|     document.querySelectorAll("span.bot-time").forEach((element) => { |  | ||||||
|         element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm"); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| window.setInterval(() => { |  | ||||||
|     update_times(); |  | ||||||
| }, 30000); |  | ||||||
|  |  | ||||||
| document.getElementById("set-bot-timezone").addEventListener("click", () => { |  | ||||||
|     timezone = botTimezone; |  | ||||||
|     update_times(); |  | ||||||
| }); |  | ||||||
| document.getElementById("set-browser-timezone").addEventListener("click", () => { |  | ||||||
|     timezone = browserTimezone; |  | ||||||
|     update_times(); |  | ||||||
| }); |  | ||||||
| document.getElementById("update-bot-timezone").addEventListener("click", () => { |  | ||||||
|     timezone = browserTimezone; |  | ||||||
|     fetch("/dashboard/api/user", { |  | ||||||
|         method: "PATCH", |  | ||||||
|         headers: { |  | ||||||
|             Accept: "application/json", |  | ||||||
|             "Content-Type": "application/json", |  | ||||||
|         }, |  | ||||||
|         body: JSON.stringify({ timezone: timezone }), |  | ||||||
|     }) |  | ||||||
|         .then((response) => response.json()) |  | ||||||
|         .then((data) => { |  | ||||||
|             if (data.error) { |  | ||||||
|                 show_error(data.error); |  | ||||||
|             } else { |  | ||||||
|                 botTimezone = browserTimezone; |  | ||||||
|                 update_times(); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
| }); |  | ||||||
| @@ -7,8 +7,9 @@ Type=simple | |||||||
| ExecStart=/usr/bin/reminder-rs | ExecStart=/usr/bin/reminder-rs | ||||||
| WorkingDirectory=/etc/reminder-rs | WorkingDirectory=/etc/reminder-rs | ||||||
| Restart=always | Restart=always | ||||||
| RestartSec=4 | RestartSec=10 | ||||||
| Environment="reminder_rs=warn,postman=warn" | Environment="RUST_LOG=warn,rocket=info,reminder_rs=debug,postman=debug" | ||||||
|  | WatchdogSec=120 | ||||||
|  |  | ||||||
| [Install] | [Install] | ||||||
| WantedBy=multi-user.target | WantedBy=multi-user.target | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ | |||||||
| <html lang="EN"> | <html lang="EN"> | ||||||
| <head> | <head> | ||||||
|     <meta name="description" content="The most powerful Discord Reminders Bot"> |     <meta name="description" content="The most powerful Discord Reminders Bot"> | ||||||
|  |     <meta name="keywords" content="discord,discord bot,reminders,reminders bot,discord reminders,discord automation,discord messages"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|     <meta charset="UTF-8"> |     <meta charset="UTF-8"> | ||||||
|     <meta name="yandex-verification" content="bb77b8681eb64a90" /> |     <meta name="yandex-verification" content="bb77b8681eb64a90" /> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user