Compare commits
	
		
			54 Commits
		
	
	
		
			1.7.4
			...
			jude/fix-d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					9a6b65f3a3 | ||
| 
						 | 
					b6ff149d51 | ||
| 
						 | 
					748e33566b | ||
| 
						 | 
					d7e90614c8 | ||
| 
						 | 
					b5dbfe336d | ||
| 
						 | 
					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.27"
 | 
				
			||||||
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"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,7 @@
 | 
				
			|||||||
import { ChannelSelector } from "./ChannelSelector";
 | 
					import { ChannelSelector } from "./ChannelSelector";
 | 
				
			||||||
import { DateTime } from "luxon";
 | 
					 | 
				
			||||||
import { IntervalSelector } from "./IntervalSelector";
 | 
					import { IntervalSelector } from "./IntervalSelector";
 | 
				
			||||||
import { useQuery } from "react-query";
 | 
					import { useQuery } from "react-query";
 | 
				
			||||||
import { fetchUserInfo } from "../../api";
 | 
					import { fetchGuildInfo, fetchUserInfo } from "../../api";
 | 
				
			||||||
import { useReminder } from "./ReminderContext";
 | 
					import { useReminder } from "./ReminderContext";
 | 
				
			||||||
import { Attachment } from "./Attachment";
 | 
					import { Attachment } from "./Attachment";
 | 
				
			||||||
import { TTS } from "./TTS";
 | 
					import { TTS } from "./TTS";
 | 
				
			||||||
@@ -11,11 +10,12 @@ import { useGuild } from "../App/useGuild";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const Settings = () => {
 | 
					export const Settings = () => {
 | 
				
			||||||
    const guild = useGuild();
 | 
					    const guild = useGuild();
 | 
				
			||||||
    const { isSuccess: userFetched, data: userInfo } = useQuery(fetchUserInfo());
 | 
					    const { isSuccess: userFetched, data: userInfo } = useQuery({ ...fetchUserInfo() });
 | 
				
			||||||
 | 
					    const { isSuccess: guildFetched, data: guildInfo } = useQuery({ ...fetchGuildInfo(guild) });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const [reminder, setReminder] = useReminder();
 | 
					    const [reminder, setReminder] = useReminder();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!userFetched) {
 | 
					    if (!userFetched || !guildFetched) {
 | 
				
			||||||
        return <></>;
 | 
					        return <></>;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -59,7 +59,13 @@ export const Settings = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            <div class="collapses split-controls">
 | 
					            <div class="collapses split-controls">
 | 
				
			||||||
                <div>
 | 
					                <div>
 | 
				
			||||||
                    <div class={userInfo.patreon ? "patreon-only" : "patreon-only is-locked"}>
 | 
					                    <div
 | 
				
			||||||
 | 
					                        class={
 | 
				
			||||||
 | 
					                            userInfo.patreon || guildInfo.patreon
 | 
				
			||||||
 | 
					                                ? "patreon-only"
 | 
				
			||||||
 | 
					                                : "patreon-only is-locked"
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
                        <div class="patreon-invert foreground">
 | 
					                        <div class="patreon-invert foreground">
 | 
				
			||||||
                            Intervals available on <a href="https://patreon.com/jellywx">Patreon</a>{" "}
 | 
					                            Intervals available on <a href="https://patreon.com/jellywx">Patreon</a>{" "}
 | 
				
			||||||
                            or{" "}
 | 
					                            or{" "}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,7 @@ pub async fn delete_macro(
 | 
				
			|||||||
        SELECT m.id
 | 
					        SELECT m.id
 | 
				
			||||||
        FROM command_macro m
 | 
					        FROM command_macro m
 | 
				
			||||||
        INNER JOIN guilds
 | 
					        INNER JOIN guilds
 | 
				
			||||||
            ON guilds.guild = m.guild_id
 | 
					            ON guilds.id = m.guild_id
 | 
				
			||||||
        WHERE guild = ?
 | 
					        WHERE guild = ?
 | 
				
			||||||
            AND m.name = ?
 | 
					            AND m.name = ?
 | 
				
			||||||
        ",
 | 
					        ",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,9 +13,6 @@ pub async fn listener(
 | 
				
			|||||||
    data: &Data,
 | 
					    data: &Data,
 | 
				
			||||||
) -> Result<(), Error> {
 | 
					) -> Result<(), Error> {
 | 
				
			||||||
    match event {
 | 
					    match event {
 | 
				
			||||||
        FullEvent::Ready { .. } => {
 | 
					 | 
				
			||||||
            ctx.set_activity(Some(ActivityData::watching("for /remind")));
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        FullEvent::ChannelDelete { channel, .. } => {
 | 
					        FullEvent::ChannelDelete { channel, .. } => {
 | 
				
			||||||
            sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.get())
 | 
					            sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.get())
 | 
				
			||||||
                .execute(&data.database)
 | 
					                .execute(&data.database)
 | 
				
			||||||
@@ -58,9 +55,11 @@ To stay up to date on the latest features and fixes, join our [Discord](https://
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        FullEvent::GuildDelete { incomplete, .. } => {
 | 
					        FullEvent::GuildDelete { incomplete, .. } => {
 | 
				
			||||||
            let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.get())
 | 
					            if !incomplete.unavailable {
 | 
				
			||||||
                .execute(&data.database)
 | 
					                let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.get())
 | 
				
			||||||
                .await;
 | 
					                    .execute(&data.database)
 | 
				
			||||||
 | 
					                    .await;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        FullEvent::InteractionCreate { interaction } => {
 | 
					        FullEvent::InteractionCreate { interaction } => {
 | 
				
			||||||
            if let Some(component) = interaction.clone().message_component() {
 | 
					            if let Some(component) = interaction.clone().message_component() {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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 => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -39,6 +39,7 @@ use poise::serenity_prelude::{
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
    ClientBuilder,
 | 
					    ClientBuilder,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					use serenity::all::ActivityData;
 | 
				
			||||||
use sqlx::{MySql, Pool};
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
 | 
					use tokio::sync::{broadcast, broadcast::Sender, RwLock};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -212,7 +213,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();
 | 
				
			||||||
@@ -284,8 +284,10 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
 | 
				
			|||||||
        .options(options)
 | 
					        .options(options)
 | 
				
			||||||
        .build();
 | 
					        .build();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut client =
 | 
					    let mut client = ClientBuilder::new(&discord_token, GatewayIntents::GUILDS)
 | 
				
			||||||
        ClientBuilder::new(&discord_token, GatewayIntents::GUILDS).framework(framework).await?;
 | 
					        .framework(framework)
 | 
				
			||||||
 | 
					        .activity(ActivityData::watching("for /remind"))
 | 
				
			||||||
 | 
					        .await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    client.start_autosharded().await?;
 | 
					    client.start_autosharded().await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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()
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -68,7 +68,7 @@ impl Data {
 | 
				
			|||||||
        guild_id: GuildId,
 | 
					        guild_id: GuildId,
 | 
				
			||||||
    ) -> Result<Vec<CommandMacro>, Error> {
 | 
					    ) -> Result<Vec<CommandMacro>, Error> {
 | 
				
			||||||
        let rows = sqlx::query!(
 | 
					        let rows = sqlx::query!(
 | 
				
			||||||
            "SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
					            "SELECT name, description, commands FROM command_macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
				
			||||||
            guild_id.get()
 | 
					            guild_id.get()
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        .fetch_all(&self.database)
 | 
					        .fetch_all(&self.database)
 | 
				
			||||||
@@ -76,7 +76,7 @@ impl Data {
 | 
				
			|||||||
            guild_id,
 | 
					            guild_id,
 | 
				
			||||||
            name: row.name.clone(),
 | 
					            name: row.name.clone(),
 | 
				
			||||||
            description: row.description.clone(),
 | 
					            description: row.description.clone(),
 | 
				
			||||||
            commands: serde_json::from_str(&row.commands).unwrap(),
 | 
					            commands: serde_json::from_str(&row.commands.to_string()).unwrap(),
 | 
				
			||||||
        }).collect();
 | 
					        }).collect();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Ok(rows)
 | 
					        Ok(rows)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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