54 Commits

Author SHA1 Message Date
9a6b65f3a3 Don't delete guild data when guild becomes unavailable 2024-09-17 23:47:27 +01:00
b6ff149d51 Fix macro list/delete 2024-09-14 12:07:09 +01:00
748e33566b Fix patreon not sharing between guild members 2024-08-19 21:50:14 +01:00
d7e90614c8 Bump ver 2024-07-07 16:35:32 +01:00
b5dbfe336d Don't set activity in ready event 2024-07-07 16:31:23 +01:00
218be2f0b1 Bump ver 2024-06-18 19:32:47 +01:00
d7515f3611 Don't require View Channel permission 2024-06-18 19:28:53 +01:00
6ae1096d79 Bump ver 2024-06-12 17:44:55 +01:00
1f0d7adae3 Correct service file 2024-06-12 17:21:42 +01:00
fc96ae526f Default permission checks to true 2024-06-10 18:30:55 +01:00
8881ef0f85 Fix DM reminders trying to load guild data 2024-06-06 16:56:19 +01:00
5e82a687f9 Increase watchdog 2024-06-04 22:34:58 +01:00
de4ecf8dd6 QoL
* Made todo added responses ephemeral if /settings ephemeral is on
* Enabled systemd watchdog
* Move metrics to rocket
2024-06-04 18:40:49 +01:00
064efd4386 Bump version 2024-06-04 16:48:26 +01:00
65b8ba3b47 Redirect old dashboard routes to new routes 2024-06-04 16:42:42 +01:00
9d452ed8cb Fix role selector 2024-05-10 17:37:27 +01:00
441419b92b Bump ver 2024-05-04 13:00:30 +01:00
aecf2c15be Store times as local time not UTC 2024-05-04 10:24:20 +01:00
79da56c794 Bump ver 2024-05-03 16:26:52 +01:00
ef10902c1e Fix todo list deletion not working properly 2024-05-03 16:21:27 +01:00
c277f85c2a Bump dependencies 2024-05-03 16:07:34 +01:00
035653c7fa Bump ver 2024-04-29 08:57:47 +01:00
6358bc3deb Partially revert timezone change 2024-04-29 08:49:01 +01:00
9f5066f982 Bump ver 2024-04-29 08:46:36 +01:00
1d06999e41 Fix bugs with time picker
* Load UTC time correctly at page load
* Don't translate to/from timezone when using the browser date/time
  input
2024-04-16 12:44:19 +01:00
1cf707140c Bump version 2024-04-16 12:22:02 +01:00
e38c63f5ba Don't show empty channels 2024-04-16 11:42:19 +01:00
d52b8b26f2 Upgrade dependencies 2024-04-16 11:19:21 +01:00
bb2128a7ed Tweaks
* Don't show @everyone in the role picker
* Show some text on the image picker talking about Discord CDN
* Correct == in todos
2024-04-11 15:40:50 +01:00
5e99a6f9de Add create todo under each channel
Sort channels for consistency
2024-04-11 15:32:34 +01:00
5406e6b8ec Show all channels and filter todos accordingly 2024-04-11 15:26:24 +01:00
4ee0bc4e37 Bump ver 2024-04-11 12:43:22 +01:00
b99bb7dcbf Fix todo sorting 2024-04-11 12:39:02 +01:00
98f925dc84 Bump version 2024-04-10 18:54:30 +01:00
24e316b12f Add delete/patch todos 2024-04-10 18:42:29 +01:00
4063334953 More work on todo list 2024-04-09 21:21:46 +01:00
e128b9848f More work on todo list support 2024-04-07 20:20:16 +01:00
9989ab3b35 Start work on todo list support for dashboard 2024-04-06 14:27:58 +01:00
b951db3f55 Bump version 2024-03-31 13:30:30 +01:00
884a47bf36 Always show remaining time in top bar 2024-03-31 12:54:48 +01:00
b0f932445c Add server emoji picker 2024-03-31 12:49:52 +01:00
2861cdda0b Bump version 2024-03-29 16:28:09 +00:00
7ba8fcd6b7 Add note to the server data page that states the bot might be restarting 2024-03-29 16:24:24 +00:00
850f0fad57 Bump version 2024-03-29 16:22:13 +00:00
a770a17ee7 Don't invalidate reminders when saving 2024-03-29 16:15:01 +00:00
d15a66d9d9 Bump version 2024-03-28 19:36:23 +00:00
30f011fcd5 Don't send attachments over API 2024-03-28 19:34:30 +00:00
15dbed2f0f Bump version 2024-03-27 17:28:35 +00:00
18cac0345b Allow removing attachments
Show HTTP errors
2024-03-27 17:19:19 +00:00
334b1bc084 Bump version 2024-03-26 17:46:56 +00:00
ba3c76c25f Fix import/export showing "Malformed base64" 2024-03-26 17:43:35 +00:00
67b6f30c62 Add IDs to metrics 2024-03-25 16:41:49 +00:00
8ae311190f Fix panic????????? 2024-03-25 06:07:30 +00:00
016164affb Remove unused javascript/css 2024-03-24 21:00:27 +00:00
74 changed files with 2448 additions and 3941 deletions

838
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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 = [

View File

@ -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

View File

@ -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;

View File

@ -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>

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
import axios from "axios"; import axios from "axios";
import { DateTime } from "luxon";
type UserInfo = { type UserInfo = {
name: string; name: string;
@ -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: () =>

View File

@ -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);

View File

@ -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>

View File

@ -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"

View File

@ -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>
</> </>
); );

View 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} />
</>
);
})}
</>
);
};

View File

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

View File

@ -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}
</> </>
); );
} }

View File

@ -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(() => {

View File

@ -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>
); );
}; };

View File

@ -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({

View File

@ -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);
} }

View File

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

View File

@ -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);
}} }}

View File

@ -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);
}} }}

View File

@ -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"

View File

@ -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{" "}

View File

@ -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>

View File

@ -6,7 +6,7 @@ import { useCallback } from "preact/hooks";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Name } from "../Name"; import { Name } from "../Name";
export const Guild = ({ toggleCollapsed }) => { export const Guild = ({ toggleCollapsed, isCreating }) => {
const guild = useGuild(); const guild = useGuild();
const [reminder] = useReminder(); const [reminder] = useReminder();
@ -55,7 +55,7 @@ export const Guild = ({ toggleCollapsed }) => {
<div class="columns is-mobile column reminder-topbar"> <div class="columns is-mobile column reminder-topbar">
{isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>} {isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>}
<Name /> <Name />
<div class="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>

View File

@ -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>

View File

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

View File

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

View File

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

View File

@ -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"

View 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>
);
};

View File

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

View 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>
);
};

View File

@ -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;

View File

@ -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 = ?
", ",

View File

@ -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),
), ),
) )

View File

@ -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),
), ),

View File

@ -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(())
} }

View File

@ -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(())
} }

View File

@ -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(())
} }

View File

@ -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,

View File

@ -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() {

View File

@ -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 => {

View File

@ -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?;

View File

@ -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(&REGISTRY.gather());
res_custom.unwrap_or_else(|e| {
warn!("Error encoding metrics: {:?}", e);
String::new()
})
}

View File

@ -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)

View File

@ -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]);
} }
} }

View File

@ -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) {

View File

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

1
src/web/fairings/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod metrics;

View File

@ -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,

View 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")
}
}
}
}

View File

@ -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},

View File

@ -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"]}))
} }
} }
} }

View 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!({}))
}

View File

@ -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,
}; };

View File

@ -29,7 +29,7 @@ use crate::web::{
}, },
guards::transaction::Transaction, guards::transaction::Transaction,
routes::JsonResult, routes::JsonResult,
Error, string, Error,
}; };
pub mod api; pub mod api;
@ -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() {

View File

@ -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(&REGISTRY.gather());
res_custom.unwrap_or_else(|e| {
warn!("Error encoding metrics: {:?}", e);
String::new()
})
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -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()}})}));

View File

@ -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 = "&lt;";
row.children[2].innerHTML = "&gt;";
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},
}),
},
});
})();

View File

@ -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 = "";
});

View File

@ -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

View File

@ -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:"/"})});

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -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);
});

View File

@ -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();
}
});
});

View File

@ -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

View File

@ -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" />