Compare commits
31 Commits
Author | SHA1 | Date | |
---|---|---|---|
3fc27b466a | |||
1d06999e41 | |||
1cf707140c | |||
e38c63f5ba | |||
d52b8b26f2 | |||
bb2128a7ed | |||
5e99a6f9de | |||
5406e6b8ec | |||
4ee0bc4e37 | |||
b99bb7dcbf | |||
98f925dc84 | |||
24e316b12f | |||
4063334953 | |||
e128b9848f | |||
9989ab3b35 | |||
b951db3f55 | |||
884a47bf36 | |||
b0f932445c | |||
2861cdda0b | |||
7ba8fcd6b7 | |||
850f0fad57 | |||
a770a17ee7 | |||
d15a66d9d9 | |||
30f011fcd5 | |||
15dbed2f0f | |||
18cac0345b | |||
334b1bc084 | |||
ba3c76c25f | |||
67b6f30c62 | |||
8ae311190f | |||
016164affb |
393
Cargo.lock
generated
393
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reminder-rs"
|
||||
version = "1.7.4"
|
||||
version = "1.7.14"
|
||||
authors = ["Jude Southworth <judesouthworth@pm.me>"]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0 only"
|
||||
@ -15,7 +15,7 @@ regex = "1.10"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
chrono = "0.4"
|
||||
chrono-tz = { version = "0.8", features = ["serde"] }
|
||||
chrono-tz = { version = "0.9", features = ["serde"] }
|
||||
lazy_static = "1.4"
|
||||
num-integer = "0.1"
|
||||
serde = "1.0"
|
||||
@ -25,7 +25,7 @@ rmp-serde = "1.1"
|
||||
rand = "0.8"
|
||||
levenshtein = "1.0"
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
|
||||
base64 = "0.21"
|
||||
base64 = "0.22"
|
||||
secrecy = "0.8.0"
|
||||
futures = "0.3.30"
|
||||
prometheus = "0.13.3"
|
||||
@ -59,8 +59,6 @@ assets = [
|
||||
["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"],
|
||||
["conf/default.env", "etc/reminder-rs/config.env", "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"]
|
||||
]
|
||||
conf-files = [
|
||||
|
13
healthcheck
13
healthcheck
@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n')
|
||||
|
||||
REGEX='mysql://([A-Za-z]+)@(.+)/(.+)'
|
||||
[[ $DATABASE_URL =~ $REGEX ]]
|
||||
|
||||
VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'")
|
||||
|
||||
if [ "$VAR" -gt 0 ]
|
||||
then
|
||||
echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL"
|
||||
fi
|
@ -26,7 +26,6 @@
|
||||
<link rel="stylesheet" href="/static/css/fa.css">
|
||||
<link rel="stylesheet" href="/static/css/font.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/dtsel.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
type UserInfo = {
|
||||
name: string;
|
||||
@ -49,6 +48,21 @@ export type Reminder = {
|
||||
utc_time: string;
|
||||
};
|
||||
|
||||
export type Todo = {
|
||||
id: number;
|
||||
channel_id: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CreateTodo = {
|
||||
channel_id: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type UpdateTodo = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ChannelInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -59,6 +73,11 @@ type RoleInfo = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type EmojiInfo = {
|
||||
fmt: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Template = {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -81,7 +100,7 @@ type Template = {
|
||||
|
||||
const USER_INFO_STALE_TIME = 120_000;
|
||||
const GUILD_INFO_STALE_TIME = 300_000;
|
||||
const OTHER_STALE_TIME = 15_000;
|
||||
const OTHER_STALE_TIME = 120_000;
|
||||
|
||||
export const fetchUserInfo = () => ({
|
||||
queryKey: ["USER_INFO"],
|
||||
@ -110,9 +129,13 @@ export const fetchGuildInfo = (guild: string) => ({
|
||||
export const fetchGuildChannels = (guild: string) => ({
|
||||
queryKey: ["GUILD_CHANNELS", guild],
|
||||
queryFn: () =>
|
||||
axios.get(`/dashboard/api/guild/${guild}/channels`).then((resp) => resp.data) as Promise<
|
||||
ChannelInfo[]
|
||||
>,
|
||||
axios
|
||||
.get(`/dashboard/api/guild/${guild}/channels`)
|
||||
.then((resp) =>
|
||||
resp.data.sort((a: ChannelInfo, b: ChannelInfo) =>
|
||||
a.name == b.name ? 0 : a.name > b.name ? 1 : -1,
|
||||
),
|
||||
) as Promise<ChannelInfo[]>,
|
||||
staleTime: GUILD_INFO_STALE_TIME,
|
||||
});
|
||||
|
||||
@ -125,6 +148,15 @@ export const fetchGuildRoles = (guild: string) => ({
|
||||
staleTime: GUILD_INFO_STALE_TIME,
|
||||
});
|
||||
|
||||
export const fetchGuildEmojis = (guild: string) => ({
|
||||
queryKey: ["GUILD_EMOJIS", guild],
|
||||
queryFn: () =>
|
||||
axios.get(`/dashboard/api/guild/${guild}/emojis`).then((resp) => resp.data) as Promise<
|
||||
EmojiInfo[]
|
||||
>,
|
||||
staleTime: GUILD_INFO_STALE_TIME,
|
||||
});
|
||||
|
||||
export const fetchGuildReminders = (guild: string) => ({
|
||||
queryKey: ["GUILD_REMINDERS", guild],
|
||||
queryFn: () =>
|
||||
@ -176,6 +208,28 @@ export const deleteGuildTemplate = (guild: string) => ({
|
||||
}),
|
||||
});
|
||||
|
||||
export const fetchGuildTodos = (guild: string) => ({
|
||||
queryKey: ["GUILD_TODOS", guild],
|
||||
queryFn: () =>
|
||||
axios.get(`/dashboard/api/guild/${guild}/todos`).then((resp) => resp.data) as Promise<
|
||||
Todo[]
|
||||
>,
|
||||
staleTime: OTHER_STALE_TIME,
|
||||
});
|
||||
|
||||
export const patchGuildTodo = (guild: string) => ({
|
||||
mutationFn: ({ id, todo }) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
|
||||
});
|
||||
|
||||
export const postGuildTodo = (guild: string) => ({
|
||||
mutationFn: (todo: CreateTodo) =>
|
||||
axios.post(`/dashboard/api/guild/${guild}/todos`, todo).then((resp) => resp.data),
|
||||
});
|
||||
|
||||
export const deleteGuildTodo = (guild: string) => ({
|
||||
mutationFn: (todoId: number) => axios.delete(`/dashboard/api/guild/${guild}/todos/${todoId}`),
|
||||
});
|
||||
|
||||
export const fetchUserReminders = () => ({
|
||||
queryKey: ["USER_REMINDERS"],
|
||||
queryFn: () =>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo } from "preact/hooks";
|
||||
import { useQuery } from "react-query";
|
||||
import { fetchGuildChannels, fetchGuildRoles } from "../../api";
|
||||
import { fetchGuildChannels, fetchGuildRoles, fetchGuildEmojis } from "../../api";
|
||||
import Tribute from "tributejs";
|
||||
import { useGuild } from "./useGuild";
|
||||
|
||||
@ -9,13 +9,16 @@ export const Mentions = ({ input }) => {
|
||||
|
||||
const { data: roles } = useQuery(fetchGuildRoles(guild));
|
||||
const { data: channels } = useQuery(fetchGuildChannels(guild));
|
||||
const { data: emojis } = useQuery(fetchGuildEmojis(guild));
|
||||
|
||||
const tribute = useMemo(() => {
|
||||
return new Tribute({
|
||||
collection: [
|
||||
{
|
||||
trigger: "@",
|
||||
values: (roles || []).map(({ id, name }) => ({ key: name, value: id })),
|
||||
values: (roles || [])
|
||||
.filter((role) => role.name === "@everyone")
|
||||
.map(({ id, name }) => ({ key: name, value: id })),
|
||||
allowSpaces: true,
|
||||
selectTemplate: (item) => `<@&${item.original.value}>`,
|
||||
menuItemTemplate: (item) => `@${item.original.key}`,
|
||||
@ -27,9 +30,16 @@ export const Mentions = ({ input }) => {
|
||||
selectTemplate: (item) => `<#${item.original.value}>`,
|
||||
menuItemTemplate: (item) => `#${item.original.key}`,
|
||||
},
|
||||
{
|
||||
trigger: ":",
|
||||
values: (emojis || []).map(({ fmt, name }) => ({ key: name, value: fmt })),
|
||||
allowSpaces: true,
|
||||
selectTemplate: (item) => item.original.value,
|
||||
menuItemTemplate: (item) => `:${item.original.key}:`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [roles, channels]);
|
||||
}, [roles, channels, emojis]);
|
||||
|
||||
useEffect(() => {
|
||||
tribute.detach(input.current);
|
||||
|
@ -6,6 +6,8 @@ import { Guild } from "../Guild";
|
||||
import { FlashProvider } from "./FlashProvider";
|
||||
import { TimezoneProvider } from "./TimezoneProvider";
|
||||
import { User } from "../User";
|
||||
import { GuildReminders } from "../Guild/GuildReminders";
|
||||
import { GuildTodos } from "../Guild/GuildTodos";
|
||||
|
||||
export function App() {
|
||||
const queryClient = new QueryClient();
|
||||
@ -18,15 +20,32 @@ export function App() {
|
||||
<div class="columns is-gapless dashboard-frame">
|
||||
<Sidebar />
|
||||
<div class="column is-main-content">
|
||||
<div style={{ margin: "0 12px 12px 12px" }}>
|
||||
<Switch>
|
||||
<Route path={"/@me/reminders"} component={User}></Route>
|
||||
<Route path={"/:guild/reminders"} component={Guild}></Route>
|
||||
<Route
|
||||
path={"/:guild/reminders"}
|
||||
component={() => (
|
||||
<Guild>
|
||||
<GuildReminders />
|
||||
</Guild>
|
||||
)}
|
||||
></Route>
|
||||
<Route
|
||||
path={"/:guild/todos"}
|
||||
component={() => (
|
||||
<Guild>
|
||||
<GuildTodos />
|
||||
</Guild>
|
||||
)}
|
||||
></Route>
|
||||
<Route>
|
||||
<Welcome />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</FlashProvider>
|
||||
|
@ -5,7 +5,12 @@ export const GuildError = () => {
|
||||
<div class="container has-text-centered">
|
||||
<p class="title">We couldn't get this server's data</p>
|
||||
<p class="subtitle">
|
||||
Please check Reminder Bot is in the server, and has correct permissions.
|
||||
The bot may have just been restarted, in which case please try again in a
|
||||
few minutes.
|
||||
<br />
|
||||
<br />
|
||||
Otherwise, please check Reminder Bot is in the server, and has correct
|
||||
permissions.
|
||||
</p>
|
||||
<a
|
||||
class="button is-size-4 is-rounded is-success"
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { useQuery } from "react-query";
|
||||
import { useQuery, useQueryClient } from "react-query";
|
||||
import { fetchGuildChannels, fetchGuildReminders } from "../../api";
|
||||
import { EditReminder } from "../Reminder/EditReminder";
|
||||
import { CreateReminder } from "../Reminder/CreateReminder";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import { Loader } from "../Loader";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
|
||||
@ -24,19 +24,25 @@ export const GuildReminders = () => {
|
||||
const { data: channels } = useQuery(fetchGuildChannels(guild));
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [sort, setSort] = useState(Sort.Time);
|
||||
const [sort, _setSort] = useState(Sort.Time);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let prevReminder = null;
|
||||
|
||||
const setSort = useCallback((sort) => {
|
||||
queryClient.invalidateQueries(["GUILD_REMINDERS"]);
|
||||
_setSort(sort);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isFetched && <Loader />}
|
||||
<div style={{ margin: "0 12px 12px 12px" }}>
|
||||
|
||||
<strong>Create Reminder</strong>
|
||||
<div id={"reminderCreator"}>
|
||||
<CreateReminder />
|
||||
</div>
|
||||
<br></br>
|
||||
<br />
|
||||
<div class={"field"}>
|
||||
<div class={"columns is-mobile"}>
|
||||
<div class={"column"}>
|
||||
@ -57,10 +63,7 @@ export const GuildReminders = () => {
|
||||
<option value={Sort.Name} selected={sort == Sort.Name}>
|
||||
Name
|
||||
</option>
|
||||
<option
|
||||
value={Sort.Channel}
|
||||
selected={sort == Sort.Channel}
|
||||
>
|
||||
<option value={Sort.Channel} selected={sort == Sort.Channel}>
|
||||
Channel
|
||||
</option>
|
||||
</select>
|
||||
@ -136,7 +139,6 @@ export const GuildReminders = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
62
reminder-dashboard/src/components/Guild/GuildTodos.tsx
Normal file
62
reminder-dashboard/src/components/Guild/GuildTodos.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useQuery } from "react-query";
|
||||
import { ChannelInfo, fetchGuildChannels, fetchGuildTodos } from "../../api";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
import { Todo } from "../Todo";
|
||||
import { Todo as TodoT } from "../../api";
|
||||
import { Loader } from "../Loader";
|
||||
import { CreateTodo } from "../Todo/CreateTodo";
|
||||
|
||||
export const GuildTodos = () => {
|
||||
const guild = useGuild();
|
||||
|
||||
const { isFetched, data: guildTodos } = useQuery(fetchGuildTodos(guild));
|
||||
const { data: channels } = useQuery(fetchGuildChannels(guild));
|
||||
|
||||
if (!isFetched || !channels) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const sortedTodos = guildTodos.sort((a, b) => (a.id < b.id ? -1 : 1));
|
||||
const globalTodos = sortedTodos.filter((todo) => todo.channel_id === null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<strong>Create todo list</strong>
|
||||
<CreateTodo channel={null} showSelector={true} />
|
||||
<strong>Todo lists</strong>
|
||||
{globalTodos.length > 0 && (
|
||||
<>
|
||||
<h2>Server</h2>
|
||||
{globalTodos.map((todo) => (
|
||||
<>
|
||||
<Todo todo={todo} key={todo.id} />
|
||||
</>
|
||||
))}
|
||||
<CreateTodo channel={null} />
|
||||
</>
|
||||
)}
|
||||
{channels
|
||||
.map(
|
||||
(channel) =>
|
||||
[channel, sortedTodos.filter((todo) => todo.channel_id === channel.id)] as [
|
||||
ChannelInfo,
|
||||
TodoT[],
|
||||
],
|
||||
)
|
||||
.filter(([_, todos]) => todos.length > 0)
|
||||
.map(([channel, todos]) => {
|
||||
return (
|
||||
<>
|
||||
<h2>#{channel.name}</h2>
|
||||
{todos.map((todo) => (
|
||||
<>
|
||||
<Todo todo={todo} key={todo.id} />
|
||||
</>
|
||||
))}
|
||||
<CreateTodo channel={channel.id} />
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
5
reminder-dashboard/src/components/Guild/index.scss
Normal file
5
reminder-dashboard/src/components/Guild/index.scss
Normal file
@ -0,0 +1,5 @@
|
||||
.page-links {
|
||||
> * {
|
||||
margin: 2px;
|
||||
}
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
import { useQuery } from "react-query";
|
||||
import { fetchGuildInfo } from "../../api";
|
||||
import { GuildReminders } from "./GuildReminders";
|
||||
import { GuildError } from "./GuildError";
|
||||
import { createPortal } from "preact/compat";
|
||||
import { createPortal, PropsWithChildren } from "preact/compat";
|
||||
import { Import } from "../Import";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
import { Link } from "wouter";
|
||||
import { usePathname } from "wouter/use-browser-location";
|
||||
|
||||
export const Guild = () => {
|
||||
import "./index.scss";
|
||||
|
||||
export const Guild = ({ children }: PropsWithChildren) => {
|
||||
const guild = useGuild();
|
||||
const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild));
|
||||
|
||||
@ -16,11 +19,32 @@ export const Guild = () => {
|
||||
return <GuildError />;
|
||||
} else {
|
||||
const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar"));
|
||||
const path = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{importModal}
|
||||
<GuildReminders />
|
||||
<div class="page-links">
|
||||
<Link
|
||||
class={`button is-outlined is-success is-small ${path.endsWith("reminders") && "is-focused"}`}
|
||||
href={`/${guild}/reminders`}
|
||||
>
|
||||
<span>Reminders</span>
|
||||
<span class="icon">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
class={`button is-outlined is-success is-small ${path.endsWith("todos") && "is-focused"}`}
|
||||
href={`/${guild}/todos`}
|
||||
>
|
||||
<span>Todo lists</span>
|
||||
<span class="icon">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ import { useRef, useState } from "preact/hooks";
|
||||
import { useParams } from "wouter";
|
||||
import axios from "axios";
|
||||
import { useFlash } from "../App/FlashContext";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
import { useQueryClient } from "react-query";
|
||||
|
||||
export const Import = () => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@ -27,7 +29,7 @@ export const Import = () => {
|
||||
};
|
||||
|
||||
const ImportModal = ({ setModalOpen }) => {
|
||||
const { guild } = useParams();
|
||||
const guild = useGuild();
|
||||
|
||||
const aRef = useRef<HTMLAnchorElement>();
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
@ -35,6 +37,8 @@ const ImportModal = ({ setModalOpen }) => {
|
||||
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
setModalOpen={setModalOpen}
|
||||
@ -121,7 +125,7 @@ const ImportModal = ({ setModalOpen }) => {
|
||||
|
||||
axios
|
||||
.put(`/dashboard/api/guild/${guild}/export/reminders`, {
|
||||
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
|
||||
body: dataUrl.split(",")[1],
|
||||
})
|
||||
.then(({ data }) => {
|
||||
setIsImporting(false);
|
||||
@ -130,6 +134,9 @@ const ImportModal = ({ setModalOpen }) => {
|
||||
flash({ message: data.error, type: "error" });
|
||||
} else {
|
||||
flash({ message: data.message, type: "success" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["GUILD_REMINDERS", guild],
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
|
@ -39,12 +39,42 @@ export const Attachment = () => {
|
||||
}}
|
||||
></input>
|
||||
<span class="file-cta">
|
||||
<span class="file-label">{attachment_name || "Add Attachment"}</span>
|
||||
<span
|
||||
class="file-label"
|
||||
style={{
|
||||
maxWidth: "200px",
|
||||
}}
|
||||
>
|
||||
{attachment_name || "Add Attachment"}
|
||||
</span>
|
||||
<span class="file-icon">
|
||||
<i class="fas fa-upload"></i>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{attachment_name && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setReminder((reminder) => ({
|
||||
...reminder,
|
||||
attachment: null,
|
||||
attachment_name: null,
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span class="sr-only">Remove attachment</span>
|
||||
<span class="icon">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -18,6 +18,12 @@ export const CreateButtonRow = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
...(guild ? postGuildReminder(guild) : postUserReminder()),
|
||||
onError: (error) => {
|
||||
flash({
|
||||
message: `An error occurred: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
flash({
|
||||
|
@ -12,24 +12,19 @@ export const EditButtonRow = () => {
|
||||
const [reminder, setReminder] = useReminder();
|
||||
|
||||
const [recentlySaved, setRecentlySaved] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const iconFlashTimeout = useRef(0);
|
||||
|
||||
const flash = useFlash();
|
||||
const mutation = useMutation({
|
||||
...(guild ? patchGuildReminder(guild) : patchUserReminder()),
|
||||
onError: (error) => {
|
||||
flash({
|
||||
message: `An error occurred: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
if (guild) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["GUILD_REMINDERS", guild],
|
||||
});
|
||||
} else {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["USER_REMINDERS"],
|
||||
});
|
||||
}
|
||||
|
||||
if (iconFlashTimeout.current !== null) {
|
||||
clearTimeout(iconFlashTimeout.current);
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { useQuery } from "react-query";
|
||||
import { useParams } from "wouter";
|
||||
import { fetchGuildChannels } from "../../api";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
|
||||
export const ChannelSelector = ({ channel, setChannel }) => {
|
||||
const { guild } = useParams();
|
||||
const guild = useGuild();
|
||||
const { isSuccess, data } = useQuery(fetchGuildChannels(guild));
|
||||
|
||||
return (
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { fetchGuildChannels, Reminder } from "../../api";
|
||||
import { DateTime } from "luxon";
|
||||
import { CreateButtonRow } from "./ButtonRow/CreateButtonRow";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { Message } from "./Message";
|
||||
import { Settings } from "./Settings";
|
||||
import { ReminderContext } from "./ReminderContext";
|
||||
import { useQuery } from "react-query";
|
||||
import {useState} from "preact/hooks";
|
||||
import {fetchGuildChannels, Reminder} from "../../api";
|
||||
import {DateTime} from "luxon";
|
||||
import {CreateButtonRow} from "./ButtonRow/CreateButtonRow";
|
||||
import {TopBar} from "./TopBar";
|
||||
import {Message} from "./Message";
|
||||
import {Settings} from "./Settings";
|
||||
import {ReminderContext} from "./ReminderContext";
|
||||
import {useQuery} from "react-query";
|
||||
import "./styles.scss";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
import { DEFAULT_COLOR } from "./Embed";
|
||||
import {useGuild} from "../App/useGuild";
|
||||
import {DEFAULT_COLOR} from "./Embed";
|
||||
|
||||
function defaultReminder(): Reminder {
|
||||
return {
|
||||
@ -38,7 +38,7 @@ function defaultReminder(): Reminder {
|
||||
tts: false,
|
||||
uid: "",
|
||||
username: "",
|
||||
utc_time: DateTime.now().toFormat("yyyy-LL-dd'T'HH:mm:ss"),
|
||||
utc_time: DateTime.now().setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss"),
|
||||
};
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ export const CreateReminder = () => {
|
||||
const [reminder, setReminder] = useState(defaultReminder());
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild));
|
||||
const {isSuccess, data: guildChannels} = useQuery(fetchGuildChannels(guild));
|
||||
|
||||
if (isSuccess && reminder.channel === null) {
|
||||
setReminder((reminder) => ({
|
||||
@ -61,15 +61,16 @@ export const CreateReminder = () => {
|
||||
<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 />
|
||||
<Message/>
|
||||
<Settings/>
|
||||
</div>
|
||||
<CreateButtonRow />
|
||||
<CreateButtonRow/>
|
||||
</div>
|
||||
</ReminderContext.Provider>
|
||||
);
|
||||
|
@ -28,9 +28,13 @@ export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Prop
|
||||
}
|
||||
|
||||
return (
|
||||
<ReminderContext.Provider value={[reminder, setReminder]}>
|
||||
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
|
||||
<ReminderContext.Provider value={[reminder, setReminder]} key={reminder.uid}>
|
||||
<div
|
||||
class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}
|
||||
id={`reminder-${reminder.uid.slice(0, 12)}`}
|
||||
>
|
||||
<TopBar
|
||||
isCreating={false}
|
||||
toggleCollapsed={() => {
|
||||
setCollapsed(!collapsed);
|
||||
}}
|
||||
|
@ -37,6 +37,11 @@ const ImagePickerModal = ({ setModalOpen, setImage }) => {
|
||||
}}
|
||||
onSubmitText={"Save"}
|
||||
>
|
||||
<p>
|
||||
Please note: if you attach an image directly from Discord, it will not be visible in
|
||||
the dashboard, but will be visible on reminders. Other image-sharing sites such as
|
||||
Imgur don't have this issue.
|
||||
</p>
|
||||
<input
|
||||
class="input"
|
||||
id="urlInput"
|
||||
|
@ -284,11 +284,7 @@ export const TimeInput = ({ defaultValue, onInput }) => {
|
||||
onInput={(ev) => {
|
||||
ev.currentTarget.value === ""
|
||||
? updateTime(null)
|
||||
: setTime(
|
||||
DateTime.fromISO(ev.currentTarget.value, { zone: timezone }).setZone(
|
||||
"UTC",
|
||||
),
|
||||
);
|
||||
: setTime(DateTime.fromISO(ev.currentTarget.value, { zone: "UTC" }));
|
||||
}}
|
||||
></input>
|
||||
</>
|
||||
|
@ -6,7 +6,7 @@ import { useCallback } from "preact/hooks";
|
||||
import { DateTime } from "luxon";
|
||||
import { Name } from "../Name";
|
||||
|
||||
export const Guild = ({ toggleCollapsed }) => {
|
||||
export const Guild = ({ toggleCollapsed, isCreating }) => {
|
||||
const guild = useGuild();
|
||||
const [reminder] = useReminder();
|
||||
|
||||
@ -55,7 +55,7 @@ export const Guild = ({ toggleCollapsed }) => {
|
||||
<div class="columns is-mobile column reminder-topbar">
|
||||
{isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>}
|
||||
<Name />
|
||||
<div class="invert-collapses time-bar">in {string}</div>
|
||||
{!isCreating && <div class="time-bar">in {string}</div>}
|
||||
<div class="hide-button-bar">
|
||||
<button class="button hide-box" onClick={toggleCollapsed}>
|
||||
<span class="is-sr-only">Hide reminder</span>
|
||||
|
@ -39,7 +39,7 @@ export const User = ({ toggleCollapsed }) => {
|
||||
return (
|
||||
<div class="columns is-mobile column reminder-topbar">
|
||||
<Name />
|
||||
<div class="invert-collapses time-bar">in {string}</div>
|
||||
<div class="time-bar">in {string}</div>
|
||||
<div class="hide-button-bar">
|
||||
<button class="button hide-box" onClick={toggleCollapsed}>
|
||||
<span class="is-sr-only">Hide reminder</span>
|
||||
|
@ -2,11 +2,11 @@ import { useGuild } from "../../App/useGuild";
|
||||
import { Guild } from "./Guild";
|
||||
import { User } from "./User";
|
||||
|
||||
export const TopBar = ({ toggleCollapsed }) => {
|
||||
export const TopBar = ({ toggleCollapsed, isCreating }) => {
|
||||
const guild = useGuild();
|
||||
|
||||
if (guild) {
|
||||
return <Guild toggleCollapsed={toggleCollapsed} />;
|
||||
return <Guild toggleCollapsed={toggleCollapsed} isCreating={isCreating} />;
|
||||
} else {
|
||||
return <User toggleCollapsed={toggleCollapsed} />;
|
||||
}
|
||||
|
@ -17,9 +17,6 @@ export const GuildEntry = ({ guild }: Props) => {
|
||||
? "is-active switch-pane"
|
||||
: "switch-pane"
|
||||
}
|
||||
data-pane="guild"
|
||||
data-guild={guild.id}
|
||||
data-name={guild.name}
|
||||
href={`/${guild.id}/reminders`}
|
||||
>
|
||||
<>
|
||||
|
@ -29,7 +29,6 @@ const SidebarContent = ({ guilds }: ContentProps) => {
|
||||
<li>
|
||||
<Link
|
||||
class={loc.startsWith("/@me") ? "is-active switch-pane" : "switch-pane"}
|
||||
data-pane="guild"
|
||||
href={"/@me/reminders"}
|
||||
>
|
||||
<>
|
||||
|
91
reminder-dashboard/src/components/Todo/CreateTodo.tsx
Normal file
91
reminder-dashboard/src/components/Todo/CreateTodo.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { fetchGuildChannels, postGuildTodo } from "../../api";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useFlash } from "../App/FlashContext";
|
||||
import { ICON_FLASH_TIME } from "../../consts";
|
||||
|
||||
export const CreateTodo = ({ showSelector = false, channel }) => {
|
||||
const guild = useGuild();
|
||||
|
||||
const [recentlyCreated, setRecentlyCreated] = useState(false);
|
||||
const [newTodo, setNewTodo] = useState({ value: "", channel_id: channel });
|
||||
|
||||
const flash = useFlash();
|
||||
|
||||
const { isSuccess, data: channels } = useQuery(fetchGuildChannels(guild));
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
...postGuildTodo(guild),
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
flash({
|
||||
message: data.error,
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
flash({
|
||||
message: "Todo created",
|
||||
type: "success",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["GUILD_TODOS", guild],
|
||||
});
|
||||
setRecentlyCreated(true);
|
||||
setTimeout(() => {
|
||||
setRecentlyCreated(false);
|
||||
}, ICON_FLASH_TIME);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="todo">
|
||||
<textarea
|
||||
class="input todo-input"
|
||||
onInput={(ev) => setNewTodo((todo) => ({ ...todo, value: ev.currentTarget.value }))}
|
||||
/>
|
||||
{showSelector && (
|
||||
<div class="control has-icons-left">
|
||||
<div class="select">
|
||||
<select
|
||||
name="channel"
|
||||
class="channel-selector"
|
||||
onInput={(ev) =>
|
||||
setNewTodo((todo) => ({
|
||||
...todo,
|
||||
channel_id: ev.currentTarget.value || null,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">(None)</option>
|
||||
{isSuccess &&
|
||||
channels.map((c) => <option value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="icon is-small is-left">
|
||||
<i class="fas fa-hashtag"></i>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => mutation.mutate(newTodo)} class="button is-success save-btn">
|
||||
<span class="icon">
|
||||
{mutation.isLoading ? (
|
||||
<span class="icon">
|
||||
<i class="fas fa-spin fa-cog"></i>
|
||||
</span>
|
||||
) : recentlyCreated ? (
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
) : (
|
||||
<span class="icon">
|
||||
<i class="fas fa-sparkles"></i>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
10
reminder-dashboard/src/components/Todo/index.scss
Normal file
10
reminder-dashboard/src/components/Todo/index.scss
Normal file
@ -0,0 +1,10 @@
|
||||
.todo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
|
||||
> * {
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
83
reminder-dashboard/src/components/Todo/index.tsx
Normal file
83
reminder-dashboard/src/components/Todo/index.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { deleteGuildTodo, patchGuildTodo, Todo as TodoT, UpdateTodo } from "../../api";
|
||||
|
||||
import "./index.scss";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useFlash } from "../App/FlashContext";
|
||||
import { useGuild } from "../App/useGuild";
|
||||
import { useState } from "preact/hooks";
|
||||
import { ICON_FLASH_TIME } from "../../consts";
|
||||
|
||||
type Props = {
|
||||
todo: TodoT;
|
||||
};
|
||||
|
||||
export const Todo = ({ todo }: Props) => {
|
||||
const guild = useGuild();
|
||||
|
||||
const [updatedTodo, setUpdatedTodo] = useState<UpdateTodo>({ value: todo.value });
|
||||
const [recentlySaved, setRecentlySaved] = useState(false);
|
||||
|
||||
const flash = useFlash();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
...deleteGuildTodo(guild),
|
||||
onSuccess: () => {
|
||||
flash({
|
||||
message: "Todo deleted",
|
||||
type: "success",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["GUILD_TODOS", guild],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const patchMutation = useMutation({
|
||||
...patchGuildTodo(guild),
|
||||
onError: (error) => {
|
||||
flash({
|
||||
message: `An error occurred: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
if (response.data.error) {
|
||||
setRecentlySaved(false);
|
||||
flash({ message: response.data.error, type: "error" });
|
||||
} else {
|
||||
setRecentlySaved(true);
|
||||
setTimeout(() => {
|
||||
setRecentlySaved(false);
|
||||
}, ICON_FLASH_TIME);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="todo">
|
||||
<textarea
|
||||
class="input todo-input"
|
||||
value={updatedTodo.value}
|
||||
onInput={(ev) =>
|
||||
setUpdatedTodo({
|
||||
value: ev.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={() => patchMutation.mutate({ id: todo.id, todo: updatedTodo })}
|
||||
class="button is-success save-btn"
|
||||
>
|
||||
<span class="icon">
|
||||
{recentlySaved ? <i class="fa fa-check"></i> : <i class="fa fa-save"></i>}
|
||||
</span>
|
||||
</button>
|
||||
<button onClick={() => deleteMutation.mutate(todo.id)} class="button is-danger">
|
||||
<span class="icon">
|
||||
<i class="fa fa-trash"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -9,10 +9,11 @@ lazy_static! {
|
||||
IntCounterVec::new(Opts::new("requests", "Web requests"), &["method", "status", "route"])
|
||||
.unwrap();
|
||||
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(
|
||||
Opts::new("reminders_failed", "Reminders failed"),
|
||||
&["channel", "error"]
|
||||
&["id", "channel", "error"]
|
||||
)
|
||||
.unwrap();
|
||||
pub static ref COMMAND_COUNTER: IntCounterVec =
|
||||
|
@ -26,7 +26,10 @@ use sqlx::{
|
||||
Executor,
|
||||
};
|
||||
|
||||
use crate::{metrics::REMINDER_COUNTER, Database};
|
||||
use crate::{
|
||||
metrics::{REMINDER_COUNTER, REMINDER_FAIL_COUNTER},
|
||||
Database,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TIMEFROM_REGEX: Regex =
|
||||
@ -442,12 +445,23 @@ impl Reminder {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -45,4 +45,5 @@ lazy_static! {
|
||||
.map(|inner| inner.parse::<u32>().ok())
|
||||
.flatten()
|
||||
.unwrap_or(600);
|
||||
pub static ref SALT: String = env::var("SALT").unwrap();
|
||||
}
|
||||
|
@ -5,6 +5,57 @@ mod catchers;
|
||||
mod guards;
|
||||
mod metrics;
|
||||
mod routes;
|
||||
pub mod string {
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
T: Display,
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(value)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
T: FromStr,
|
||||
T::Err: Display,
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod string_opt {
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
T: Display,
|
||||
S: Serializer,
|
||||
{
|
||||
if let Some(v) = value {
|
||||
serializer.collect_str(v)
|
||||
} else {
|
||||
serializer.serialize_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
T: FromStr,
|
||||
T::Err: Display,
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(deserializer)?
|
||||
.map(|s| s.parse().map_err(de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
use std::{env, path::Path};
|
||||
|
||||
@ -138,12 +189,17 @@ pub async fn initialize(
|
||||
routes::dashboard::api::guild::get_guild_info,
|
||||
routes::dashboard::api::guild::get_guild_channels,
|
||||
routes::dashboard::api::guild::get_guild_roles,
|
||||
routes::dashboard::api::guild::get_guild_emojis,
|
||||
routes::dashboard::api::guild::get_reminder_templates,
|
||||
routes::dashboard::api::guild::create_reminder_template,
|
||||
routes::dashboard::api::guild::delete_reminder_template,
|
||||
routes::dashboard::api::guild::create_guild_reminder,
|
||||
routes::dashboard::api::guild::get_reminders,
|
||||
routes::dashboard::api::guild::edit_reminder,
|
||||
routes::dashboard::api::guild::todos::create_todo,
|
||||
routes::dashboard::api::guild::todos::get_todo,
|
||||
routes::dashboard::api::guild::todos::update_todo,
|
||||
routes::dashboard::api::guild::todos::delete_todo,
|
||||
routes::dashboard::export::export_reminders,
|
||||
routes::dashboard::export::export_reminder_templates,
|
||||
routes::dashboard::export::export_todos,
|
||||
|
84
src/web/routes/dashboard/api/guild/emojis.rs
Normal file
84
src/web/routes/dashboard/api/guild/emojis.rs
Normal file
@ -0,0 +1,84 @@
|
||||
use std::{collections::HashMap, sync::OnceLock, time::Instant};
|
||||
|
||||
use log::warn;
|
||||
use rocket::{get, http::CookieJar, serde::json::json, State};
|
||||
use serde::Serialize;
|
||||
use serenity::{client::Context, model::id::GuildId};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::web::{check_authorization, routes::JsonResult};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct EmojiInfo {
|
||||
fmt: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EmojiCache {
|
||||
emojis: Vec<EmojiInfo>,
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
const CACHE_LENGTH: u64 = 120;
|
||||
|
||||
static EMOJI_CACHE: OnceLock<RwLock<HashMap<GuildId, EmojiCache>>> = OnceLock::new();
|
||||
|
||||
#[get("/api/guild/<id>/emojis")]
|
||||
pub async fn get_guild_emojis(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
) -> JsonResult {
|
||||
offline!(Ok(json!(vec![] as Vec<EmojiInfo>)));
|
||||
check_authorization(cookies, ctx.inner(), id).await?;
|
||||
|
||||
let cache_value = {
|
||||
let cache = EMOJI_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
|
||||
let read_lock = cache.read().await;
|
||||
read_lock.get(&GuildId::new(id)).cloned()
|
||||
};
|
||||
|
||||
if let Some(emojis) = cache_value
|
||||
.map(|v| {
|
||||
if Instant::now().duration_since(v.timestamp).as_secs() < CACHE_LENGTH {
|
||||
Some(v.emojis)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
{
|
||||
Ok(json!(emojis))
|
||||
} else {
|
||||
let emojis_res = ctx.http.get_emojis(GuildId::new(id)).await;
|
||||
|
||||
match emojis_res {
|
||||
Ok(emojis) => {
|
||||
let emojis = emojis
|
||||
.iter()
|
||||
.map(|emoji| EmojiInfo {
|
||||
fmt: format!("{}", emoji),
|
||||
name: emoji.name.to_string(),
|
||||
})
|
||||
.collect::<Vec<EmojiInfo>>();
|
||||
|
||||
{
|
||||
let cache = EMOJI_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
|
||||
let mut write_lock = cache.write().await;
|
||||
write_lock.insert(
|
||||
GuildId::new(id),
|
||||
EmojiCache { emojis: emojis.clone(), timestamp: Instant::now() },
|
||||
);
|
||||
}
|
||||
|
||||
Ok(json!(emojis))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not fetch emojis from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Could not get emojis")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,14 +1,17 @@
|
||||
mod channels;
|
||||
mod emojis;
|
||||
mod reminders;
|
||||
mod roles;
|
||||
mod templates;
|
||||
pub mod todos;
|
||||
|
||||
use std::env;
|
||||
|
||||
pub use channels::*;
|
||||
pub use channels::get_guild_channels;
|
||||
pub use emojis::get_guild_emojis;
|
||||
pub use reminders::*;
|
||||
use rocket::{get, http::CookieJar, serde::json::json, State};
|
||||
pub use roles::*;
|
||||
pub use roles::get_guild_roles;
|
||||
use serenity::{
|
||||
client::Context,
|
||||
model::id::{GuildId, RoleId},
|
||||
|
@ -17,7 +17,9 @@ use crate::web::{
|
||||
consts::MIN_INTERVAL,
|
||||
guards::transaction::Transaction,
|
||||
routes::{
|
||||
dashboard::{create_database_channel, create_reminder, PatchReminder, Reminder},
|
||||
dashboard::{
|
||||
create_database_channel, create_reminder, CreateReminder, GetReminder, PatchReminder,
|
||||
},
|
||||
JsonResult,
|
||||
},
|
||||
Database,
|
||||
@ -26,7 +28,7 @@ use crate::web::{
|
||||
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||
pub async fn create_guild_reminder(
|
||||
id: u64,
|
||||
reminder: Json<Reminder>,
|
||||
reminder: Json<CreateReminder>,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
mut transaction: Transaction<'_>,
|
||||
@ -78,9 +80,9 @@ pub async fn get_reminders(
|
||||
.join(",");
|
||||
|
||||
sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
GetReminder,
|
||||
"
|
||||
SELECT
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
@ -192,7 +194,7 @@ pub async fn edit_reminder(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error updating reminder interval: {:?}", e);
|
||||
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||
json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
|
||||
})?
|
||||
.days
|
||||
.unwrap_or(0),
|
||||
@ -206,7 +208,7 @@ pub async fn edit_reminder(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error updating reminder interval: {:?}", e);
|
||||
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||
json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
|
||||
})?
|
||||
.months
|
||||
.unwrap_or(0),
|
||||
@ -220,7 +222,7 @@ pub async fn edit_reminder(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error updating reminder interval: {:?}", e);
|
||||
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||
json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
|
||||
})?
|
||||
.seconds
|
||||
.unwrap_or(0),
|
||||
@ -249,7 +251,7 @@ pub async fn edit_reminder(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error updating reminder interval: {:?}", e);
|
||||
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||
json!({ "reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"] })
|
||||
})?;
|
||||
}
|
||||
|
||||
@ -321,8 +323,9 @@ pub async fn edit_reminder(
|
||||
}
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"SELECT reminders.attachment,
|
||||
GetReminder,
|
||||
"
|
||||
SELECT
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
@ -361,7 +364,7 @@ pub async fn edit_reminder(
|
||||
Err(e) => {
|
||||
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||
|
||||
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
|
||||
Err(json!({"reminder": Option::<GetReminder>::None, "errors": vec!["Unknown error"]}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
224
src/web/routes/dashboard/api/guild/todos.rs
Normal file
224
src/web/routes/dashboard/api/guild/todos.rs
Normal file
@ -0,0 +1,224 @@
|
||||
use log::warn;
|
||||
use rocket::{
|
||||
delete, get,
|
||||
http::CookieJar,
|
||||
patch, post,
|
||||
serde::json::{json, Json},
|
||||
State,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serenity::{
|
||||
all::{ChannelId, GuildId},
|
||||
prelude::Context,
|
||||
};
|
||||
|
||||
use crate::web::{
|
||||
check_authorization,
|
||||
guards::transaction::Transaction,
|
||||
routes::{dashboard::check_channel_matches_guild, JsonResult},
|
||||
string_opt,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTodo {
|
||||
#[serde(with = "string_opt")]
|
||||
channel_id: Option<u64>,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetTodo {
|
||||
id: u32,
|
||||
#[serde(with = "string_opt")]
|
||||
channel_id: Option<u64>,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTodo {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[post("/api/guild/<id>/todos", data = "<todo>")]
|
||||
pub async fn create_todo(
|
||||
id: u64,
|
||||
todo: Json<CreateTodo>,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
mut transaction: Transaction<'_>,
|
||||
) -> JsonResult {
|
||||
check_authorization(cookies, ctx.inner(), id).await?;
|
||||
|
||||
let guild_id = GuildId::new(id);
|
||||
if todo.value.len() > 2000 {
|
||||
return json_err!("Value too long");
|
||||
}
|
||||
|
||||
match todo.channel_id {
|
||||
Some(channel_id) => {
|
||||
if !check_channel_matches_guild(ctx, ChannelId::new(channel_id), guild_id) {
|
||||
warn!("Channel {} not found for guild {}", channel_id, guild_id);
|
||||
|
||||
return json_err!("Channel not found");
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO todos (guild_id, channel_id, value)
|
||||
VALUES (
|
||||
(SELECT id FROM guilds WHERE guild = ?),
|
||||
(SELECT id FROM channels WHERE channel = ?),
|
||||
?
|
||||
)
|
||||
",
|
||||
id,
|
||||
channel_id,
|
||||
todo.value
|
||||
)
|
||||
.execute(transaction.executor())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error creating todo: {:?}", e);
|
||||
json!({"errors": vec!["Unknown error"]})
|
||||
})?;
|
||||
}
|
||||
|
||||
None => {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO todos (guild_id, channel_id, value)
|
||||
VALUES (
|
||||
(SELECT id FROM guilds WHERE guild = ?),
|
||||
NULL,
|
||||
?
|
||||
)
|
||||
",
|
||||
id,
|
||||
todo.value
|
||||
)
|
||||
.execute(transaction.executor())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error creating todo: {:?}", e);
|
||||
json!({"errors": vec!["Unknown error"]})
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = transaction.commit().await {
|
||||
warn!("Couldn't commit transaction: {:?}", e);
|
||||
return json_err!("Couldn't commit transaction.");
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/todos")]
|
||||
pub async fn get_todo(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
mut transaction: Transaction<'_>,
|
||||
) -> JsonResult {
|
||||
check_authorization(cookies, ctx.inner(), id).await?;
|
||||
|
||||
let todos = sqlx::query_as!(
|
||||
GetTodo,
|
||||
"
|
||||
SELECT
|
||||
todos.id,
|
||||
channels.channel AS channel_id,
|
||||
value
|
||||
FROM todos
|
||||
INNER JOIN guilds
|
||||
ON guilds.id = todos.guild_id
|
||||
LEFT JOIN channels
|
||||
ON channels.id = todos.channel_id
|
||||
WHERE guilds.guild = ?
|
||||
",
|
||||
id
|
||||
)
|
||||
.fetch_all(transaction.executor())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error fetching todos: {:?}", e);
|
||||
json!({ "errors": vec!["Unknown error"] })
|
||||
})?;
|
||||
|
||||
Ok(json!(todos))
|
||||
}
|
||||
|
||||
#[patch("/api/guild/<guild_id>/todos/<todo_id>", data = "<todo>")]
|
||||
pub async fn update_todo(
|
||||
guild_id: u64,
|
||||
todo_id: u64,
|
||||
todo: Json<UpdateTodo>,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
mut transaction: Transaction<'_>,
|
||||
) -> JsonResult {
|
||||
check_authorization(cookies, ctx.inner(), guild_id).await?;
|
||||
|
||||
if todo.value.len() > 2000 {
|
||||
return json_err!("Value too long");
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE todos
|
||||
SET value = ?
|
||||
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
||||
AND id = ?
|
||||
",
|
||||
todo.value,
|
||||
guild_id,
|
||||
todo_id,
|
||||
)
|
||||
.execute(transaction.executor())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error updating todo: {:?}", e);
|
||||
json!({"errors": vec!["Unknown error"]})
|
||||
})?;
|
||||
|
||||
if let Err(e) = transaction.commit().await {
|
||||
warn!("Couldn't commit transaction: {:?}", e);
|
||||
return json_err!("Couldn't commit transaction.");
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
#[delete("/api/guild/<guild_id>/todos/<todo_id>")]
|
||||
pub async fn delete_todo(
|
||||
guild_id: u64,
|
||||
todo_id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
mut transaction: Transaction<'_>,
|
||||
) -> JsonResult {
|
||||
check_authorization(cookies, ctx.inner(), guild_id).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM todos
|
||||
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
||||
AND id = ?
|
||||
",
|
||||
guild_id,
|
||||
todo_id,
|
||||
)
|
||||
.execute(transaction.executor())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("Error deleting todo: {:?}", e);
|
||||
json!({"errors": vec!["Unknown error"]})
|
||||
})?;
|
||||
|
||||
if let Err(e) = transaction.commit().await {
|
||||
warn!("Couldn't commit transaction: {:?}", e);
|
||||
return json_err!("Couldn't commit transaction.");
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
@ -19,8 +19,7 @@ use crate::web::{
|
||||
guards::transaction::Transaction,
|
||||
routes::{
|
||||
dashboard::{
|
||||
create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv,
|
||||
TodoCsv,
|
||||
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
|
||||
},
|
||||
JsonResult,
|
||||
},
|
||||
@ -150,7 +149,7 @@ pub(crate) async fn import_reminders(
|
||||
|
||||
match channel_id.parse::<u64>() {
|
||||
Ok(channel_id) => {
|
||||
let reminder = Reminder {
|
||||
let reminder = CreateReminder {
|
||||
attachment: record.attachment,
|
||||
attachment_name: record.attachment_name,
|
||||
avatar: record.avatar,
|
||||
@ -177,7 +176,6 @@ pub(crate) async fn import_reminders(
|
||||
name: record.name,
|
||||
restartable: record.restartable,
|
||||
tts: record.tts,
|
||||
uid: generate_uid(),
|
||||
username: record.username,
|
||||
utc_time: record.utc_time,
|
||||
};
|
||||
|
@ -29,7 +29,7 @@ use crate::web::{
|
||||
},
|
||||
guards::transaction::Transaction,
|
||||
routes::JsonResult,
|
||||
Error,
|
||||
string, Error,
|
||||
};
|
||||
|
||||
pub mod api;
|
||||
@ -146,9 +146,39 @@ pub struct EmbedField {
|
||||
inline: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Reminder {
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateReminder {
|
||||
attachment: Option<Attachment>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
#[serde(with = "string")]
|
||||
channel: u64,
|
||||
content: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_description: String,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_title: String,
|
||||
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||
enabled: bool,
|
||||
expires: Option<NaiveDateTime>,
|
||||
interval_seconds: Option<u32>,
|
||||
interval_days: Option<u32>,
|
||||
interval_months: Option<u32>,
|
||||
#[serde(default = "name_default")]
|
||||
name: String,
|
||||
restartable: bool,
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
utc_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetReminder {
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
#[serde(with = "string")]
|
||||
@ -318,30 +348,6 @@ where
|
||||
Ok(Some(Option::deserialize(deserializer)?))
|
||||
}
|
||||
|
||||
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
|
||||
mod string {
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
T: Display,
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(value)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
T: FromStr,
|
||||
T::Err: Display,
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteReminder {
|
||||
uid: String,
|
||||
@ -363,7 +369,7 @@ pub(crate) async fn create_reminder(
|
||||
transaction: &mut Transaction<'_>,
|
||||
guild_id: GuildId,
|
||||
user_id: UserId,
|
||||
reminder: Reminder,
|
||||
reminder: CreateReminder,
|
||||
) -> JsonResult {
|
||||
// check guild in db
|
||||
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get())
|
||||
@ -382,23 +388,14 @@ pub(crate) async fn create_reminder(
|
||||
_ => {}
|
||||
}
|
||||
|
||||
{
|
||||
// validate channel
|
||||
let channel = ChannelId::new(reminder.channel).to_channel_cached(&ctx.cache);
|
||||
let channel_exists = channel.is_some();
|
||||
|
||||
let channel_matches_guild =
|
||||
channel.map_or(false, |c| c.guild(&ctx.cache).map_or(false, |c| c.id == guild_id));
|
||||
|
||||
if !channel_matches_guild || !channel_exists {
|
||||
if !check_channel_matches_guild(ctx, ChannelId::new(reminder.channel), guild_id) {
|
||||
warn!(
|
||||
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
|
||||
reminder.channel, guild_id, channel_exists
|
||||
"Error in `create_reminder`: channel {} not found for guild {}",
|
||||
reminder.channel, guild_id
|
||||
);
|
||||
|
||||
return Err(json!({"error": "Channel not found"}));
|
||||
}
|
||||
}
|
||||
|
||||
let channel =
|
||||
create_database_channel(&ctx, ChannelId::new(reminder.channel), transaction).await;
|
||||
@ -545,9 +542,9 @@ pub(crate) async fn create_reminder(
|
||||
.await
|
||||
{
|
||||
Ok(_) => sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
GetReminder,
|
||||
"
|
||||
SELECT
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
@ -595,6 +592,21 @@ pub(crate) async fn create_reminder(
|
||||
}
|
||||
}
|
||||
|
||||
fn check_channel_matches_guild(ctx: &Context, channel_id: ChannelId, guild_id: GuildId) -> bool {
|
||||
// validate channel
|
||||
let channel = channel_id.to_channel_cached(&ctx.cache);
|
||||
let channel_exists = channel.is_some();
|
||||
|
||||
if !channel_exists {
|
||||
return false;
|
||||
}
|
||||
|
||||
let channel_matches_guild =
|
||||
channel.map_or(false, |c| c.guild(&ctx.cache).map_or(false, |g| g.id == guild_id));
|
||||
|
||||
channel_matches_guild
|
||||
}
|
||||
|
||||
async fn create_database_channel(
|
||||
ctx: impl CacheHttp,
|
||||
channel: ChannelId,
|
||||
|
@ -1,91 +0,0 @@
|
||||
.date-selector-wrapper {
|
||||
width: 200px;
|
||||
padding: 3px;
|
||||
background-color: #fff;
|
||||
box-shadow: 1px 1px 10px 1px #5c5c5c;
|
||||
position: absolute;
|
||||
font-size: 12px;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-o-user-select: none;
|
||||
/* user-select: none; */
|
||||
}
|
||||
.cal-header, .cal-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.cal-cell, .cal-nav {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cal-day-names {
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.cal-day-names .cal-cell {
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
}
|
||||
.cal-cell-prev, .cal-cell-next {
|
||||
color: #777;
|
||||
}
|
||||
.cal-months .cal-row, .cal-years .cal-row {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
}
|
||||
.cal-nav-prev, .cal-nav-next {
|
||||
flex: 0.15;
|
||||
}
|
||||
.cal-nav-current {
|
||||
flex: 0.75;
|
||||
font-weight: bold;
|
||||
}
|
||||
.cal-months .cal-cell, .cal-years .cal-cell {
|
||||
flex: 0.25;
|
||||
}
|
||||
.cal-days .cal-cell {
|
||||
flex: 0.143;
|
||||
}
|
||||
.cal-value {
|
||||
color: #fff;
|
||||
background-color: #286090;
|
||||
}
|
||||
.cal-cell:hover, .cal-nav:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
.cal-value:hover {
|
||||
background-color: #204d74;
|
||||
}
|
||||
|
||||
/* time footer */
|
||||
.cal-time {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
height: 27px;
|
||||
line-height: 27px;
|
||||
}
|
||||
.cal-time-label, .cal-time-value {
|
||||
flex: 0.12;
|
||||
text-align: center;
|
||||
}
|
||||
.cal-time-slider {
|
||||
flex: 0.77;
|
||||
background-image: linear-gradient(to right, #d1d8dd, #d1d8dd);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 1px;
|
||||
background-position: left 50%;
|
||||
height: 100%;
|
||||
}
|
||||
.cal-time-slider input {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
background: 0 0;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
outline: 0;
|
||||
user-select: auto;
|
||||
}
|
@ -1,131 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
fetch("/admin/data")
|
||||
.then((resp) => resp.json())
|
||||
.then((data) => {
|
||||
document.querySelector("#backlog").textContent = data.backlog;
|
||||
document.querySelector("#reminders").textContent = data.count.reminders;
|
||||
document.querySelector("#intervals").textContent = data.count.intervals;
|
||||
|
||||
let historySent = data.historyLong.sent.reduce(
|
||||
(iv, frame) => iv + frame.count,
|
||||
0
|
||||
);
|
||||
let historyFailed = data.historyLong.failed.reduce(
|
||||
(iv, frame) => iv + frame.count,
|
||||
0
|
||||
);
|
||||
let rate = historyFailed / (historySent + historyFailed);
|
||||
let formatted = Math.round(rate * 10000) / 100;
|
||||
|
||||
document.querySelector("#historySent").textContent = historySent;
|
||||
document.querySelector("#historyFailed").textContent = historyFailed;
|
||||
document.querySelector("#failRate").textContent = `${formatted}%`;
|
||||
|
||||
new Chart(document.getElementById("schedule"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: [
|
||||
...data.scheduleShort.once,
|
||||
...data.scheduleShort.interval,
|
||||
].map((row) => luxon.DateTime.fromISO(row.time_key)),
|
||||
datasets: [
|
||||
{
|
||||
label: "Reminders",
|
||||
data: data.scheduleShort.once.map((row) => row.count),
|
||||
},
|
||||
{
|
||||
label: "Intervals",
|
||||
data: data.scheduleShort.interval.map((row) => row.count),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
type: "time",
|
||||
time: {
|
||||
unit: "minute",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new Chart(document.getElementById("scheduleLong"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: [
|
||||
...data.scheduleLong.once,
|
||||
...data.scheduleLong.interval,
|
||||
].map((row) => luxon.DateTime.fromISO(row.time_key)),
|
||||
datasets: [
|
||||
{
|
||||
label: "Reminders",
|
||||
data: data.scheduleLong.once.map((row) => row.count),
|
||||
},
|
||||
{
|
||||
label: "Intervals",
|
||||
data: data.scheduleLong.interval.map((row) => row.count),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
type: "time",
|
||||
time: {
|
||||
unit: "day",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new Chart(document.getElementById("historyLong"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: [...data.historyLong.sent, ...data.historyLong.failed].map(
|
||||
(row) => luxon.DateTime.fromISO(row.time_key)
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: "Success",
|
||||
data: data.historyLong.sent.map((row) => row.count),
|
||||
},
|
||||
{
|
||||
label: "Fail",
|
||||
data: data.historyLong.failed.map((row) => row.count),
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
type: "time",
|
||||
time: {
|
||||
unit: "day",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because one or more lines are too long
@ -1,7 +0,0 @@
|
||||
/*!
|
||||
* chartjs-adapter-luxon v1.3.1
|
||||
* https://www.chartjs.org
|
||||
* (c) 2023 chartjs-adapter-luxon Contributors
|
||||
* Released under the MIT license
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})}));
|
@ -1,931 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var BODYTYPES = ["DAYS", "MONTHS", "YEARS"];
|
||||
var MONTHS = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
var WEEKDAYS = [
|
||||
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
|
||||
];
|
||||
|
||||
/** @typedef {Object.<string, Function[]>} Handlers */
|
||||
/** @typedef {function(String, Function): null} AddHandler */
|
||||
/** @typedef {("DAYS"|"MONTHS"|"YEARS")} BodyType */
|
||||
/** @typedef {string|number} StringNum */
|
||||
/** @typedef {Object.<string, StringNum>} StringNumObj */
|
||||
|
||||
/**
|
||||
* The local state
|
||||
* @typedef {Object} InstanceState
|
||||
* @property {Date} value
|
||||
* @property {Number} year
|
||||
* @property {Number} month
|
||||
* @property {Number} day
|
||||
* @property {Number} time
|
||||
* @property {Number} hours
|
||||
* @property {Number} minutes
|
||||
* @property {Number} seconds
|
||||
* @property {BodyType} bodyType
|
||||
* @property {Boolean} visible
|
||||
* @property {Number} cancelBlur
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @property {String} dateFormat
|
||||
* @property {String} timeFormat
|
||||
* @property {Boolean} showDate
|
||||
* @property {Boolean} showTime
|
||||
* @property {Number} paddingX
|
||||
* @property {Number} paddingY
|
||||
* @property {BodyType} defaultView
|
||||
* @property {"TOP"|"BOTTOM"} direction
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @param {HTMLElement} elem
|
||||
* @param {Config} config
|
||||
*/
|
||||
function DTS(elem, config) {
|
||||
var config = config || {};
|
||||
|
||||
/** @type {Config} */
|
||||
var defaultConfig = {
|
||||
defaultView: BODYTYPES[0],
|
||||
dateFormat: "yyyy-mm-dd",
|
||||
timeFormat: "HH:MM:SS",
|
||||
showDate: true,
|
||||
showTime: false,
|
||||
paddingX: 5,
|
||||
paddingY: 5,
|
||||
direction: 'TOP'
|
||||
}
|
||||
|
||||
if (!elem) {
|
||||
throw TypeError("input element or selector required for contructor");
|
||||
}
|
||||
if (Object.getPrototypeOf(elem) === String.prototype) {
|
||||
var _elem = document.querySelectorAll(elem);
|
||||
if (!_elem[0]){
|
||||
throw Error('"' + elem + '" not found.');
|
||||
}
|
||||
elem = _elem[0];
|
||||
}
|
||||
this.config = setDefaults(config, defaultConfig);
|
||||
this.dateFormat = this.config.dateFormat;
|
||||
this.timeFormat = this.config.timeFormat;
|
||||
this.dateFormatRegEx = new RegExp("yyyy|yy|mm|dd", "gi");
|
||||
this.timeFormatRegEx = new RegExp("hh|mm|ss|a", "gi");
|
||||
this.inputElem = elem;
|
||||
this.dtbox = null;
|
||||
this.setup();
|
||||
}
|
||||
DTS.prototype.setup = function () {
|
||||
var handler = this.inputElemHandler.bind(this);
|
||||
this.inputElem.addEventListener("focus", handler, false)
|
||||
this.inputElem.addEventListener("blur", handler, false);
|
||||
}
|
||||
DTS.prototype.inputElemHandler = function (e) {
|
||||
if (e.type == "focus") {
|
||||
if (!this.dtbox) {
|
||||
this.dtbox = new DTBox(e.target, this);
|
||||
}
|
||||
this.dtbox.visible = true;
|
||||
} else if (e.type == "blur" && this.dtbox && this.dtbox.visible) {
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
if (self.dtbox.cancelBlur > 0) {
|
||||
self.dtbox.cancelBlur -= 1;
|
||||
} else {
|
||||
self.dtbox.visible = false;
|
||||
self.inputElem.blur();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @class
|
||||
* @param {HTMLElement} elem
|
||||
* @param {DTS} settings
|
||||
*/
|
||||
function DTBox(elem, settings) {
|
||||
/** @type {DTBox} */
|
||||
var self = this;
|
||||
|
||||
/** @type {Handlers} */
|
||||
var handlers = {};
|
||||
|
||||
/** @type {InstanceState} */
|
||||
var localState = {};
|
||||
|
||||
/**
|
||||
* @param {String} key
|
||||
* @param {*} default_val
|
||||
*/
|
||||
function getterSetter(key, default_val) {
|
||||
return {
|
||||
get: function () {
|
||||
var val = localState[key];
|
||||
return val === undefined ? default_val : val;
|
||||
},
|
||||
set: function (val) {
|
||||
var prevState = self.state;
|
||||
var _handlers = handlers[key] || [];
|
||||
localState[key] = val;
|
||||
for (var i = 0; i < _handlers.length; i++) {
|
||||
_handlers[i].bind(self)(localState, prevState);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** @type {AddHandler} */
|
||||
function addHandler(key, handlerFn) {
|
||||
if (!key || !handlerFn) {
|
||||
return false;
|
||||
}
|
||||
if (!handlers[key]) {
|
||||
handlers[key] = [];
|
||||
}
|
||||
handlers[key].push(handlerFn);
|
||||
}
|
||||
|
||||
Object.defineProperties(this, {
|
||||
visible: getterSetter("visible", false),
|
||||
bodyType: getterSetter("bodyType", settings.config.defaultView),
|
||||
value: getterSetter("value"),
|
||||
year: getterSetter("year", 0),
|
||||
month: getterSetter("month", 0),
|
||||
day: getterSetter("day", 0),
|
||||
hours: getterSetter("hours", 0),
|
||||
minutes: getterSetter("minutes", 0),
|
||||
seconds: getterSetter("seconds", 0),
|
||||
cancelBlur: getterSetter("cancelBlur", 0),
|
||||
addHandler: {value: addHandler},
|
||||
month_long: {
|
||||
get: function () {
|
||||
return MONTHS[self.month];
|
||||
},
|
||||
},
|
||||
month_short: {
|
||||
get: function () {
|
||||
return self.month_long.slice(0, 3);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
get: function () {
|
||||
return Object.assign({}, localState);
|
||||
},
|
||||
},
|
||||
time: {
|
||||
get: function() {
|
||||
var hours = self.hours * 60 * 60 * 1000;
|
||||
var minutes = self.minutes * 60 * 1000;
|
||||
var seconds = self.seconds * 1000;
|
||||
return hours + minutes + seconds;
|
||||
}
|
||||
},
|
||||
});
|
||||
this.el = {};
|
||||
this.settings = settings;
|
||||
this.elem = elem;
|
||||
this.setup();
|
||||
}
|
||||
DTBox.prototype.setup = function () {
|
||||
Object.defineProperties(this.el, {
|
||||
wrapper: { value: null, configurable: true },
|
||||
header: { value: null, configurable: true },
|
||||
body: { value: null, configurable: true },
|
||||
footer: { value: null, configurable: true }
|
||||
});
|
||||
this.setupWrapper();
|
||||
if (this.settings.config.showDate) {
|
||||
this.setupHeader();
|
||||
this.setupBody();
|
||||
}
|
||||
if (this.settings.config.showTime) {
|
||||
this.setupFooter();
|
||||
}
|
||||
|
||||
var self = this;
|
||||
this.addHandler("visible", function (state, prevState) {
|
||||
if (state.visible && !prevState.visible){
|
||||
document.body.appendChild(this.el.wrapper);
|
||||
|
||||
var parts = self.elem.value.split(/\s*,\s*/);
|
||||
var startDate = undefined;
|
||||
var startTime = 0;
|
||||
if (self.settings.config.showDate) {
|
||||
startDate = parseDate(parts[0], self.settings);
|
||||
}
|
||||
if (self.settings.config.showTime) {
|
||||
startTime = parseTime(parts[parts.length-1], self.settings);
|
||||
startTime = startTime || 0;
|
||||
}
|
||||
if (!(startDate && startDate.getTime())) {
|
||||
startDate = new Date();
|
||||
startDate = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate()
|
||||
);
|
||||
}
|
||||
var value = new Date(startDate.getTime() + startTime);
|
||||
self.value = value;
|
||||
self.year = value.getFullYear();
|
||||
self.month = value.getMonth();
|
||||
self.day = value.getDate();
|
||||
self.hours = value.getHours();
|
||||
self.minutes = value.getMinutes();
|
||||
self.seconds = value.getSeconds();
|
||||
|
||||
if (self.settings.config.showDate) {
|
||||
self.setHeaderContent();
|
||||
self.setBodyContent();
|
||||
}
|
||||
if (self.settings.config.showTime) {
|
||||
self.setFooterContent();
|
||||
}
|
||||
} else if (!state.visible && prevState.visible) {
|
||||
document.body.removeChild(this.el.wrapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
DTBox.prototype.setupWrapper = function () {
|
||||
if (!this.el.wrapper) {
|
||||
var el = document.createElement("div");
|
||||
el.classList.add("date-selector-wrapper");
|
||||
Object.defineProperty(this.el, "wrapper", { value: el });
|
||||
}
|
||||
var self = this;
|
||||
var htmlRoot = document.getElementsByTagName('html')[0];
|
||||
function setPosition(e){
|
||||
var minTopSpace = 300;
|
||||
var box = getOffset(self.elem);
|
||||
var config = self.settings.config;
|
||||
var paddingY = config.paddingY || 5;
|
||||
var paddingX = config.paddingX || 5;
|
||||
var top = box.top + self.elem.offsetHeight + paddingY;
|
||||
var left = box.left + paddingX;
|
||||
var bottom = htmlRoot.clientHeight - box.top + paddingY;
|
||||
|
||||
self.el.wrapper.style.left = `${left}px`;
|
||||
if (box.top > minTopSpace && config.direction != 'BOTTOM') {
|
||||
self.el.wrapper.style.bottom = `${bottom}px`;
|
||||
self.el.wrapper.style.top = '';
|
||||
} else {
|
||||
self.el.wrapper.style.top = `${top}px`;
|
||||
self.el.wrapper.style.bottom = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handler(e) {
|
||||
self.cancelBlur += 1;
|
||||
setTimeout(function(){
|
||||
self.elem.focus();
|
||||
}, 50);
|
||||
}
|
||||
setPosition();
|
||||
this.setPosition = setPosition;
|
||||
this.el.wrapper.addEventListener("mousedown", handler, false);
|
||||
this.el.wrapper.addEventListener("touchstart", handler, false);
|
||||
window.addEventListener('resize', this.setPosition);
|
||||
}
|
||||
DTBox.prototype.setupHeader = function () {
|
||||
if (!this.el.header) {
|
||||
var row = document.createElement("div");
|
||||
var classes = ["cal-nav-prev", "cal-nav-current", "cal-nav-next"];
|
||||
row.classList.add("cal-header");
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var cell = document.createElement("div");
|
||||
cell.classList.add("cal-nav", classes[i]);
|
||||
cell.onclick = this.onHeaderChange.bind(this);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
row.children[0].innerHTML = "<";
|
||||
row.children[2].innerHTML = ">";
|
||||
Object.defineProperty(this.el, "header", { value: row });
|
||||
tryAppendChild(row, this.el.wrapper);
|
||||
}
|
||||
this.setHeaderContent();
|
||||
}
|
||||
DTBox.prototype.setHeaderContent = function () {
|
||||
var content = this.year;
|
||||
if ("DAYS" == this.bodyType) {
|
||||
content = this.month_long + " " + content;
|
||||
} else if ("YEARS" == this.bodyType) {
|
||||
var start = this.year + 10 - (this.year % 10);
|
||||
content = start - 10 + "-" + (start - 1);
|
||||
}
|
||||
this.el.header.children[1].innerText = content;
|
||||
}
|
||||
DTBox.prototype.setupBody = function () {
|
||||
if (!this.el.body) {
|
||||
var el = document.createElement("div");
|
||||
el.classList.add("cal-body");
|
||||
Object.defineProperty(this.el, "body", { value: el });
|
||||
tryAppendChild(el, this.el.wrapper);
|
||||
}
|
||||
var toAppend = null;
|
||||
function makeGrid(rows, cols, className, firstRowClass, clickHandler) {
|
||||
var grid = document.createElement("div");
|
||||
grid.classList.add(className);
|
||||
for (var i = 1; i < rows + 1; i++) {
|
||||
var row = document.createElement("div");
|
||||
row.classList.add("cal-row", "cal-row-" + i);
|
||||
if (i == 1 && firstRowClass) {
|
||||
row.classList.add(firstRowClass);
|
||||
}
|
||||
for (var j = 1; j < cols + 1; j++) {
|
||||
var col = document.createElement("div");
|
||||
col.classList.add("cal-cell", "cal-col-" + j);
|
||||
col.onclick = clickHandler;
|
||||
row.appendChild(col);
|
||||
}
|
||||
grid.appendChild(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
if ("DAYS" == this.bodyType) {
|
||||
toAppend = this.el.body.calDays;
|
||||
if (!toAppend) {
|
||||
toAppend = makeGrid(7, 7, "cal-days", "cal-day-names", this.onDateSelected.bind(this));
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var cell = toAppend.children[0].children[i];
|
||||
cell.innerText = WEEKDAYS[i].slice(0, 2);
|
||||
cell.onclick = null;
|
||||
}
|
||||
this.el.body.calDays = toAppend;
|
||||
}
|
||||
} else if ("MONTHS" == this.bodyType) {
|
||||
toAppend = this.el.body.calMonths;
|
||||
if (!toAppend) {
|
||||
toAppend = makeGrid(3, 4, "cal-months", null, this.onMonthSelected.bind(this));
|
||||
for (var i = 0; i < 3; i++) {
|
||||
for (var j = 0; j < 4; j++) {
|
||||
var monthShort = MONTHS[4 * i + j].slice(0, 3);
|
||||
toAppend.children[i].children[j].innerText = monthShort;
|
||||
}
|
||||
}
|
||||
this.el.body.calMonths = toAppend;
|
||||
}
|
||||
} else if ("YEARS" == this.bodyType) {
|
||||
toAppend = this.el.body.calYears;
|
||||
if (!toAppend) {
|
||||
toAppend = makeGrid(3, 4, "cal-years", null, this.onYearSelected.bind(this));
|
||||
this.el.body.calYears = toAppend;
|
||||
}
|
||||
}
|
||||
empty(this.el.body);
|
||||
tryAppendChild(toAppend, this.el.body);
|
||||
this.setBodyContent();
|
||||
}
|
||||
DTBox.prototype.setBodyContent = function () {
|
||||
var grid = this.el.body.children[0];
|
||||
var classes = ["cal-cell-prev", "cal-cell-next", "cal-value"];
|
||||
if ("DAYS" == this.bodyType) {
|
||||
var oneDayMilliSecs = 24 * 60 * 60 * 1000;
|
||||
var start = new Date(this.year, this.month, 1);
|
||||
var adjusted = new Date(start.getTime() - oneDayMilliSecs * start.getDay());
|
||||
|
||||
grid.children[6].style.display = "";
|
||||
for (var i = 1; i < 7; i++) {
|
||||
for (var j = 0; j < 7; j++) {
|
||||
var cell = grid.children[i].children[j];
|
||||
var month = adjusted.getMonth();
|
||||
var date = adjusted.getDate();
|
||||
|
||||
cell.innerText = date;
|
||||
cell.classList.remove(classes[0], classes[1], classes[2]);
|
||||
if (month != this.month) {
|
||||
if (i == 6 && j == 0) {
|
||||
grid.children[6].style.display = "none";
|
||||
break;
|
||||
}
|
||||
cell.classList.add(month < this.month ? classes[0] : classes[1]);
|
||||
} else if (isEqualDate(adjusted, this.value)){
|
||||
cell.classList.add(classes[2]);
|
||||
}
|
||||
adjusted = new Date(adjusted.getTime() + oneDayMilliSecs);
|
||||
}
|
||||
}
|
||||
} else if ("YEARS" == this.bodyType) {
|
||||
var year = this.year - (this.year % 10) - 1;
|
||||
for (i = 0; i < 3; i++) {
|
||||
for (j = 0; j < 4; j++) {
|
||||
grid.children[i].children[j].innerText = year;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
grid.children[0].children[0].classList.add(classes[0]);
|
||||
grid.children[2].children[3].classList.add(classes[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onTimeChange = function(e) {
|
||||
e.stopPropagation();
|
||||
if (e.type == 'mousedown') {
|
||||
this.cancelBlur += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var el = e.target;
|
||||
this[el.name] = parseInt(el.value) || 0;
|
||||
this.setupFooter();
|
||||
if (e.type == 'change') {
|
||||
var self = this;
|
||||
setTimeout(function(){
|
||||
self.elem.focus();
|
||||
}, 50);
|
||||
}
|
||||
this.setInputValue();
|
||||
}
|
||||
|
||||
DTBox.prototype.setupFooter = function() {
|
||||
if (!this.el.footer) {
|
||||
var footer = document.createElement("div");
|
||||
var handler = this.onTimeChange.bind(this);
|
||||
var self = this;
|
||||
|
||||
function makeRow(label, name, range, changeHandler) {
|
||||
var row = document.createElement("div");
|
||||
row.classList.add('cal-time');
|
||||
|
||||
var labelCol = row.appendChild(document.createElement("div"));
|
||||
labelCol.classList.add('cal-time-label');
|
||||
labelCol.innerText = label;
|
||||
|
||||
var valueCol = row.appendChild(document.createElement("div"));
|
||||
valueCol.classList.add('cal-time-value');
|
||||
valueCol.innerText = '00';
|
||||
|
||||
var inputCol = row.appendChild(document.createElement("div"));
|
||||
var slider = inputCol.appendChild(document.createElement("input"));
|
||||
Object.assign(slider, {step:1, min:0, max:range, name:name, type:'range'});
|
||||
Object.defineProperty(footer, name, {value: slider});
|
||||
inputCol.classList.add('cal-time-slider');
|
||||
slider.onchange = changeHandler;
|
||||
slider.oninput = changeHandler;
|
||||
slider.onmousedown = changeHandler;
|
||||
self[name] = self[name] || parseInt(slider.value) || 0;
|
||||
footer.appendChild(row)
|
||||
}
|
||||
makeRow('HH:', 'hours', 23, handler);
|
||||
makeRow('MM:', 'minutes', 59, handler);
|
||||
makeRow('SS:', 'seconds', 59, handler);
|
||||
|
||||
footer.classList.add("cal-footer");
|
||||
Object.defineProperty(this.el, "footer", { value: footer });
|
||||
tryAppendChild(footer, this.el.wrapper);
|
||||
}
|
||||
this.setFooterContent();
|
||||
}
|
||||
|
||||
DTBox.prototype.setFooterContent = function() {
|
||||
if (this.el.footer) {
|
||||
var footer = this.el.footer;
|
||||
footer.hours.value = this.hours;
|
||||
footer.children[0].children[1].innerText = padded(this.hours, 2);
|
||||
footer.minutes.value = this.minutes;
|
||||
footer.children[1].children[1].innerText = padded(this.minutes, 2);
|
||||
footer.seconds.value = this.seconds;
|
||||
footer.children[2].children[1].innerText = padded(this.seconds, 2);
|
||||
}
|
||||
}
|
||||
|
||||
DTBox.prototype.setInputValue = function() {
|
||||
var date = new Date(this.year, this.month, this.day);
|
||||
var strings = [];
|
||||
if (this.settings.config.showDate) {
|
||||
strings.push(renderDate(date, this.settings));
|
||||
}
|
||||
if (this.settings.config.showTime) {
|
||||
var joined = new Date(date.getTime() + this.time);
|
||||
strings.push(renderTime(joined, this.settings));
|
||||
}
|
||||
this.elem.value = strings.join(', ');
|
||||
}
|
||||
|
||||
DTBox.prototype.onDateSelected = function (e) {
|
||||
var row = e.target.parentNode;
|
||||
var date = parseInt(e.target.innerText);
|
||||
if (!(row.nextSibling && row.nextSibling.nextSibling) && date < 8) {
|
||||
this.month += 1;
|
||||
} else if (!(row.previousSibling && row.previousSibling.previousSibling) && date > 7) {
|
||||
this.month -= 1;
|
||||
}
|
||||
this.day = parseInt(e.target.innerText);
|
||||
this.value = new Date(this.year, this.month, this.day);
|
||||
this.setInputValue();
|
||||
this.setHeaderContent();
|
||||
this.setBodyContent();
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onMonthSelected = function (e) {
|
||||
var col = 0;
|
||||
var row = 2;
|
||||
var cell = e.target;
|
||||
if (cell.parentNode.nextSibling){
|
||||
row = cell.parentNode.previousSibling ? 1: 0;
|
||||
}
|
||||
if (cell.previousSibling) {
|
||||
col = 3;
|
||||
if (cell.nextSibling) {
|
||||
col = cell.previousSibling.previousSibling ? 2 : 1;
|
||||
}
|
||||
}
|
||||
this.month = 4 * row + col;
|
||||
this.bodyType = "DAYS";
|
||||
this.setHeaderContent();
|
||||
this.setupBody();
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onYearSelected = function (e) {
|
||||
this.year = parseInt(e.target.innerText);
|
||||
this.bodyType = "MONTHS";
|
||||
this.setHeaderContent();
|
||||
this.setupBody();
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onHeaderChange = function (e) {
|
||||
var cell = e.target;
|
||||
if (cell.previousSibling && cell.nextSibling) {
|
||||
var idx = BODYTYPES.indexOf(this.bodyType);
|
||||
if (idx < 0 || !BODYTYPES[idx + 1]) {
|
||||
return;
|
||||
}
|
||||
this.bodyType = BODYTYPES[idx + 1];
|
||||
this.setupBody();
|
||||
} else {
|
||||
var sign = cell.previousSibling ? 1 : -1;
|
||||
switch (this.bodyType) {
|
||||
case "DAYS":
|
||||
this.month += sign * 1;
|
||||
break;
|
||||
case "MONTHS":
|
||||
this.year += sign * 1;
|
||||
break;
|
||||
case "YEARS":
|
||||
this.year += sign * 10;
|
||||
}
|
||||
if (this.month > 11 || this.month < 0) {
|
||||
this.year += Math.floor(this.month / 11);
|
||||
this.month = this.month > 11 ? 0 : 11;
|
||||
}
|
||||
}
|
||||
this.setHeaderContent();
|
||||
this.setBodyContent();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elem
|
||||
* @returns {{left:number, top:number}}
|
||||
*/
|
||||
function getOffset(elem) {
|
||||
var box = elem.getBoundingClientRect();
|
||||
var left = window.pageXOffset !== undefined ? window.pageXOffset :
|
||||
(document.documentElement || document.body.parentNode || document.body).scrollLeft;
|
||||
var top = window.pageYOffset !== undefined ? window.pageYOffset :
|
||||
(document.documentElement || document.body.parentNode || document.body).scrollTop;
|
||||
return { left: box.left + left, top: box.top + top };
|
||||
}
|
||||
function empty(e) {
|
||||
for (; e.children.length; ) e.removeChild(e.children[0]);
|
||||
}
|
||||
function tryAppendChild(newChild, refNode) {
|
||||
try {
|
||||
refNode.appendChild(newChild);
|
||||
return newChild;
|
||||
} catch (e) {
|
||||
console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @class */
|
||||
function hookFuncs() {
|
||||
/** @type {Handlers} */
|
||||
this._funcs = {};
|
||||
}
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {Function} func
|
||||
*/
|
||||
hookFuncs.prototype.add = function(key, func){
|
||||
if (!this._funcs[key]){
|
||||
this._funcs[key] = [];
|
||||
}
|
||||
this._funcs[key].push(func)
|
||||
}
|
||||
/**
|
||||
* @param {String} key
|
||||
* @returns {Function[]} handlers
|
||||
*/
|
||||
hookFuncs.prototype.get = function(key){
|
||||
return this._funcs[key] ? this._funcs[key] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array.<string>} arr
|
||||
* @param {String} string
|
||||
* @returns {Array.<string>} sorted string
|
||||
*/
|
||||
function sortByStringIndex(arr, string) {
|
||||
return arr.sort(function(a, b){
|
||||
var h = string.indexOf(a);
|
||||
var l = string.indexOf(b);
|
||||
var rank = 0;
|
||||
if (h < l) {
|
||||
rank = -1;
|
||||
} else if (l < h) {
|
||||
rank = 1;
|
||||
} else if (a.length > b.length) {
|
||||
rank = -1;
|
||||
} else if (b.length > a.length) {
|
||||
rank = 1;
|
||||
}
|
||||
return rank;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove keys from array that are not in format
|
||||
* @param {string[]} keys
|
||||
* @param {string} format
|
||||
* @returns {string[]} new filtered array
|
||||
*/
|
||||
function filterFormatKeys(keys, format) {
|
||||
var out = [];
|
||||
var formatIdx = 0;
|
||||
for (var i = 0; i<keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (format.slice(formatIdx).indexOf(key) > -1) {
|
||||
formatIdx += key.length;
|
||||
out.push(key);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {StringNumObj} FormatObj
|
||||
* @param {string} value
|
||||
* @param {string} format
|
||||
* @param {FormatObj} formatObj
|
||||
* @param {function(Object.<string, hookFuncs>): null} setHooks
|
||||
* @returns {FormatObj} formatObj
|
||||
*/
|
||||
function parseData(value, format, formatObj, setHooks) {
|
||||
var hooks = {
|
||||
canSkip: new hookFuncs(),
|
||||
updateValue: new hookFuncs(),
|
||||
}
|
||||
var keys = sortByStringIndex(Object.keys(formatObj), format);
|
||||
var filterdKeys = filterFormatKeys(keys, format);
|
||||
var vstart = 0; // value start
|
||||
if (setHooks) {
|
||||
setHooks(hooks);
|
||||
}
|
||||
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var fstart = format.indexOf(key);
|
||||
var _vstart = vstart; // next value start
|
||||
var val = null;
|
||||
var canSkip = false;
|
||||
var funcs = hooks.canSkip.get(key);
|
||||
|
||||
vstart = vstart || fstart;
|
||||
|
||||
for (var j = 0; j < funcs.length; j++) {
|
||||
if (funcs[j](formatObj)){
|
||||
canSkip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fstart > -1 && !canSkip) {
|
||||
var sep = null;
|
||||
var stop = vstart + key.length;
|
||||
var fnext = -1;
|
||||
var nextKeyIdx = i + 1;
|
||||
_vstart += key.length; // set next value start if current key is found
|
||||
|
||||
// get next format token used to determine separator
|
||||
while (fnext == -1 && nextKeyIdx < keys.length){
|
||||
var nextKey = keys[nextKeyIdx];
|
||||
nextKeyIdx += 1;
|
||||
if (filterdKeys.indexOf(nextKey) === -1) {
|
||||
continue;
|
||||
}
|
||||
fnext = nextKey ? format.indexOf(nextKey) : -1; // next format start
|
||||
}
|
||||
if (fnext > -1){
|
||||
sep = format.slice(stop, fnext);
|
||||
if (sep) {
|
||||
var _stop = value.slice(vstart).indexOf(sep);
|
||||
if (_stop && _stop > -1){
|
||||
stop = _stop + vstart;
|
||||
_vstart = stop + sep.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
val = parseInt(value.slice(vstart, stop));
|
||||
|
||||
var funcs = hooks.updateValue.get(key);
|
||||
for (var k = 0; k < funcs.length; k++) {
|
||||
val = funcs[k](val, formatObj, vstart, stop);
|
||||
}
|
||||
}
|
||||
formatObj[key] = { index: vstart, value: val };
|
||||
vstart = _vstart; // set next value start
|
||||
}
|
||||
return formatObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
* @param {DTS} settings
|
||||
* @returns {Date} date object
|
||||
*/
|
||||
function parseDate(value, settings) {
|
||||
/** @type {{yyyy:number=, yy:number=, mm:number=, dd:number=}} */
|
||||
var formatObj = {yyyy:null, yy:null, mm:null, dd:null};
|
||||
var format = ((settings.dateFormat) || '').toLowerCase();
|
||||
if (!format) {
|
||||
throw new TypeError('dateFormat not found (' + settings.dateFormat + ')');
|
||||
}
|
||||
var formatObj = parseData(value, format, formatObj, function(hooks){
|
||||
hooks.canSkip.add("yy", function(data){
|
||||
return data["yyyy"].value;
|
||||
});
|
||||
hooks.updateValue.add("yy", function(val){
|
||||
return 100 * Math.floor(new Date().getFullYear() / 100) + val;
|
||||
});
|
||||
});
|
||||
var year = formatObj["yyyy"].value || formatObj["yy"].value;
|
||||
var month = formatObj["mm"].value - 1;
|
||||
var date = formatObj["dd"].value;
|
||||
var result = new Date(year, month, date);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
* @param {DTS} settings
|
||||
* @returns {Number} time in milliseconds <= (24 * 60 * 60 * 1000) - 1
|
||||
*/
|
||||
function parseTime(value, settings) {
|
||||
var format = ((settings.timeFormat) || '').toLowerCase();
|
||||
if (!format) {
|
||||
throw new TypeError('timeFormat not found (' + settings.timeFormat + ')');
|
||||
}
|
||||
|
||||
/** @type {{hh:number=, mm:number=, ss:number=, a:string=}} */
|
||||
var formatObj = {hh:null, mm:null, ss:null, a:null};
|
||||
var formatObj = parseData(value, format, formatObj, function(hooks){
|
||||
hooks.updateValue.add("a", function(val, data, start, stop){
|
||||
return value.slice(start, start + 2);
|
||||
});
|
||||
});
|
||||
var hours = formatObj["hh"].value;
|
||||
var minutes = formatObj["mm"].value;
|
||||
var seconds = formatObj["ss"].value;
|
||||
var am_pm = formatObj["a"].value;
|
||||
var am_pm_lower = am_pm ? am_pm.toLowerCase() : am_pm;
|
||||
if (am_pm && ["am", "pm"].indexOf(am_pm_lower) > -1){
|
||||
if (am_pm_lower == 'am' && hours == 12){
|
||||
hours = 0;
|
||||
} else if (am_pm_lower == 'pm') {
|
||||
hours += 12;
|
||||
}
|
||||
}
|
||||
var time = hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000;
|
||||
return time;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} value
|
||||
* @param {DTS} settings
|
||||
* @returns {String} date string
|
||||
*/
|
||||
function renderDate(value, settings) {
|
||||
var format = settings.dateFormat.toLowerCase();
|
||||
var date = value.getDate();
|
||||
var month = value.getMonth() + 1;
|
||||
var year = value.getFullYear();
|
||||
var yearShort = year % 100;
|
||||
var formatObj = {
|
||||
dd: date < 10 ? "0" + date : date,
|
||||
mm: month < 10 ? "0" + month : month,
|
||||
yyyy: year,
|
||||
yy: yearShort < 10 ? "0" + yearShort : yearShort
|
||||
};
|
||||
var str = format.replace(settings.dateFormatRegEx, function (found) {
|
||||
return formatObj[found];
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} value
|
||||
* @param {DTS} settings
|
||||
* @returns {String} date string
|
||||
*/
|
||||
function renderTime(value, settings) {
|
||||
var Format = settings.timeFormat;
|
||||
var format = Format.toLowerCase();
|
||||
var hours = value.getHours();
|
||||
var minutes = value.getMinutes();
|
||||
var seconds = value.getSeconds();
|
||||
var am_pm = null;
|
||||
var hh_am_pm = null;
|
||||
if (format.indexOf('a') > -1) {
|
||||
am_pm = hours >= 12 ? 'pm' : 'am';
|
||||
am_pm = Format.indexOf('A') > -1 ? am_pm.toUpperCase() : am_pm;
|
||||
hh_am_pm = hours == 0 ? '12' : (hours > 12 ? hours%12 : hours);
|
||||
}
|
||||
var formatObj = {
|
||||
hh: am_pm ? hh_am_pm : (hours < 10 ? "0" + hours : hours),
|
||||
mm: minutes < 10 ? "0" + minutes : minutes,
|
||||
ss: seconds < 10 ? "0" + seconds : seconds,
|
||||
a: am_pm,
|
||||
};
|
||||
var str = format.replace(settings.timeFormatRegEx, function (found) {
|
||||
return formatObj[found];
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* checks if two dates are equal
|
||||
* @param {Date} date1
|
||||
* @param {Date} date2
|
||||
* @returns {Boolean} true or false
|
||||
*/
|
||||
function isEqualDate(date1, date2) {
|
||||
if (!(date1 && date2)) return false;
|
||||
return (date1.getFullYear() == date2.getFullYear() &&
|
||||
date1.getMonth() == date2.getMonth() &&
|
||||
date1.getDate() == date2.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} val
|
||||
* @param {Number} pad
|
||||
* @param {*} default_val
|
||||
* @returns {String} padded string
|
||||
*/
|
||||
function padded(val, pad, default_val) {
|
||||
var default_val = default_val || 0;
|
||||
var valStr = '' + (parseInt(val) || default_val);
|
||||
var diff = Math.max(pad, valStr.length) - valStr.length;
|
||||
return ('' + default_val).repeat(diff) + valStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template X
|
||||
* @template Y
|
||||
* @param {X} obj
|
||||
* @param {Y} objDefaults
|
||||
* @returns {X|Y} merged object
|
||||
*/
|
||||
function setDefaults(obj, objDefaults) {
|
||||
var keys = Object.keys(objDefaults);
|
||||
for (var i=0; i<keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
obj[key] = objDefaults[key];
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
window.dtsel = Object.create({},{
|
||||
DTS: { value: DTS },
|
||||
DTObj: { value: DTBox },
|
||||
fn: {
|
||||
value: Object.defineProperties({}, {
|
||||
empty: { value: empty },
|
||||
appendAfter: {
|
||||
value: function (newElem, refNode) {
|
||||
refNode.parentNode.insertBefore(newElem, refNode.nextSibling);
|
||||
},
|
||||
},
|
||||
getOffset: { value: getOffset },
|
||||
parseDate: { value: parseDate },
|
||||
renderDate: { value: renderDate },
|
||||
parseTime: {value: parseTime},
|
||||
renderTime: {value: renderTime},
|
||||
setDefaults: {value: setDefaults},
|
||||
}),
|
||||
},
|
||||
});
|
||||
})();
|
@ -1,23 +0,0 @@
|
||||
function collapse_all() {
|
||||
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
|
||||
el.classList.add("is-collapsed");
|
||||
});
|
||||
}
|
||||
|
||||
function expand_all() {
|
||||
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
|
||||
el.classList.remove("is-collapsed");
|
||||
});
|
||||
}
|
||||
|
||||
const expandAll = document.querySelector("#expandAll");
|
||||
|
||||
expandAll.addEventListener("change", (ev) => {
|
||||
if (ev.target.value === "expand") {
|
||||
expand_all();
|
||||
} else if (ev.target.value === "collapse") {
|
||||
collapse_all();
|
||||
}
|
||||
|
||||
ev.target.value = "";
|
||||
});
|
@ -1,94 +0,0 @@
|
||||
function get_interval(element) {
|
||||
let months = element.querySelector('input[name="interval_months"]').value;
|
||||
let days = element.querySelector('input[name="interval_days"]').value;
|
||||
let hours = element.querySelector('input[name="interval_hours"]').value;
|
||||
let minutes = element.querySelector('input[name="interval_minutes"]').value;
|
||||
let seconds = element.querySelector('input[name="interval_seconds"]').value;
|
||||
|
||||
return {
|
||||
months: parseInt(months) || null,
|
||||
days: parseInt(days) || null,
|
||||
seconds:
|
||||
(parseInt(hours) || 0) * 3600 +
|
||||
(parseInt(minutes) || 0) * 60 +
|
||||
(parseInt(seconds) || 0) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function update_interval(element) {
|
||||
let months = element.querySelector('input[name="interval_months"]');
|
||||
let days = element.querySelector('input[name="interval_days"]');
|
||||
let hours = element.querySelector('input[name="interval_hours"]');
|
||||
let minutes = element.querySelector('input[name="interval_minutes"]');
|
||||
let seconds = element.querySelector('input[name="interval_seconds"]');
|
||||
|
||||
let interval = get_interval(element);
|
||||
|
||||
if (interval.months === null && interval.days === null && interval.seconds === null) {
|
||||
months.value = "";
|
||||
days.value = "";
|
||||
hours.value = "";
|
||||
minutes.value = "";
|
||||
seconds.value = "";
|
||||
} else {
|
||||
months.value = months.value.padStart(1, "0");
|
||||
days.value = days.value.padStart(1, "0");
|
||||
hours.value = hours.value.padStart(2, "0");
|
||||
minutes.value = minutes.value.padStart(2, "0");
|
||||
seconds.value = seconds.value.padStart(2, "0");
|
||||
|
||||
if (seconds.value >= 60) {
|
||||
let quotient = Math.floor(seconds.value / 60);
|
||||
let remainder = seconds.value % 60;
|
||||
|
||||
seconds.value = String(remainder).padStart(2, "0");
|
||||
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(
|
||||
2,
|
||||
"0"
|
||||
);
|
||||
}
|
||||
if (minutes.value >= 60) {
|
||||
let quotient = Math.floor(minutes.value / 60);
|
||||
let remainder = minutes.value % 60;
|
||||
|
||||
minutes.value = String(remainder).padStart(2, "0");
|
||||
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const $intervalGroup = document.querySelector(".interval-group");
|
||||
|
||||
document.querySelector(".interval-group").addEventListener(
|
||||
"blur",
|
||||
(ev) => {
|
||||
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
|
||||
$intervalGroup.querySelectorAll("input").forEach((el) => {
|
||||
el.value = "";
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("remindersLoaded", (event) => {
|
||||
for (reminder of event.detail) {
|
||||
let $intervalGroup = reminder.node.querySelector(".interval-group");
|
||||
|
||||
$intervalGroup.addEventListener(
|
||||
"blur",
|
||||
(ev) => {
|
||||
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
|
||||
$intervalGroup.querySelectorAll("input").forEach((el) => {
|
||||
el.value = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
File diff suppressed because one or more lines are too long
2
static/js/js.cookie.min.js
vendored
2
static/js/js.cookie.min.js
vendored
@ -1,2 +0,0 @@
|
||||
/*! js-cookie v3.0.0-rc.0 | MIT */
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var r=e.Cookies,n=e.Cookies=t();n.noConflict=function(){return e.Cookies=r,n}}())}(this,function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)e[n]=r[n]}return e}var t={read:function(e){return e.replace(/%3B/g,";")},write:function(e){return e.replace(/;/g,"%3B")}};return function r(n,i){function o(r,o,u){if("undefined"!=typeof document){"number"==typeof(u=e({},i,u)).expires&&(u.expires=new Date(Date.now()+864e5*u.expires)),u.expires&&(u.expires=u.expires.toUTCString()),r=t.write(r).replace(/=/g,"%3D"),o=n.write(String(o),r);var c="";for(var f in u)u[f]&&(c+="; "+f,!0!==u[f]&&(c+="="+u[f].split(";")[0]));return document.cookie=r+"="+o+c}}return Object.create({set:o,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var r=document.cookie?document.cookie.split("; "):[],i={},o=0;o<r.length;o++){var u=r[o].split("="),c=u.slice(1).join("="),f=t.read(u[0]).replace(/%3D/g,"=");if(i[f]=n.read(c,f),e===f)break}return e?i[e]:i}},remove:function(t,r){o(t,"",e({},r,{expires:-1}))},withAttributes:function(t){return r(this.converter,e({},this.attributes,t))},withConverter:function(t){return r(e({},this.converter,t),this.attributes)}},{attributes:{value:Object.freeze(i)},converter:{value:Object.freeze(n)}})}(t,{path:"/"})});
|
1
static/js/luxon.min.js
vendored
1
static/js/luxon.min.js
vendored
File diff suppressed because one or more lines are too long
1102
static/js/main.js
1102
static/js/main.js
File diff suppressed because it is too large
Load Diff
@ -1,70 +0,0 @@
|
||||
let guildReminders = document.querySelector("#guildReminders");
|
||||
|
||||
function sort_by(cond) {
|
||||
if (cond === "channel") {
|
||||
[...guildReminders.children]
|
||||
.sort((a, b) => {
|
||||
let channel1 = a.querySelector("select.channel-selector").value;
|
||||
let channel2 = b.querySelector("select.channel-selector").value;
|
||||
|
||||
return channel1 > channel2 ? 1 : -1;
|
||||
})
|
||||
.forEach((node) => guildReminders.appendChild(node));
|
||||
|
||||
// go through and add channel categories
|
||||
let currentChannelGroup = null;
|
||||
for (let child of guildReminders.querySelectorAll("div.reminderContent")) {
|
||||
let thisChannelGroup = child.querySelector("select.channel-selector").value;
|
||||
|
||||
if (currentChannelGroup !== thisChannelGroup) {
|
||||
let newNode = document.createElement("div");
|
||||
newNode.textContent =
|
||||
"#" + channels.find((a) => a.id === thisChannelGroup).name;
|
||||
newNode.classList.add("channel-tag");
|
||||
|
||||
guildReminders.insertBefore(newNode, child);
|
||||
|
||||
currentChannelGroup = thisChannelGroup;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// remove any channel tags if previous ordering was by channel
|
||||
guildReminders.querySelectorAll("div.channel-tag").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
if (cond === "time") {
|
||||
[...guildReminders.children]
|
||||
.sort((a, b) => {
|
||||
let time1 = luxon.DateTime.fromISO(
|
||||
a.querySelector('input[name="time"]').value
|
||||
);
|
||||
let time2 = luxon.DateTime.fromISO(
|
||||
b.querySelector('input[name="time"]').value
|
||||
);
|
||||
|
||||
return time1 > time2 ? 1 : -1;
|
||||
})
|
||||
.forEach((node) => guildReminders.appendChild(node));
|
||||
} else {
|
||||
[...guildReminders.children]
|
||||
.sort((a, b) => {
|
||||
let name1 = a.querySelector('input[name="name"]').value;
|
||||
let name2 = b.querySelector('input[name="name"]').value;
|
||||
|
||||
return name1 > name2 ? 1 : -1;
|
||||
})
|
||||
.forEach((node) => guildReminders.appendChild(node));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selector = document.querySelector("#orderBy");
|
||||
|
||||
selector.addEventListener("change", () => {
|
||||
sort_by(selector.value);
|
||||
});
|
||||
|
||||
document.addEventListener("remindersLoaded", () => {
|
||||
sort_by(selector.value);
|
||||
});
|
@ -1,57 +0,0 @@
|
||||
let timezone = luxon.DateTime.now().zone.name;
|
||||
const browserTimezone = luxon.DateTime.now().zone.name;
|
||||
let botTimezone = "UTC";
|
||||
|
||||
function update_times() {
|
||||
document.querySelectorAll("span.set-timezone").forEach((element) => {
|
||||
element.textContent = timezone;
|
||||
});
|
||||
document.querySelectorAll("span.set-time").forEach((element) => {
|
||||
element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm");
|
||||
});
|
||||
document.querySelectorAll("span.browser-timezone").forEach((element) => {
|
||||
element.textContent = browserTimezone;
|
||||
});
|
||||
document.querySelectorAll("span.browser-time").forEach((element) => {
|
||||
element.textContent = luxon.DateTime.now().toFormat("HH:mm");
|
||||
});
|
||||
document.querySelectorAll("span.bot-timezone").forEach((element) => {
|
||||
element.textContent = botTimezone;
|
||||
});
|
||||
document.querySelectorAll("span.bot-time").forEach((element) => {
|
||||
element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm");
|
||||
});
|
||||
}
|
||||
|
||||
window.setInterval(() => {
|
||||
update_times();
|
||||
}, 30000);
|
||||
|
||||
document.getElementById("set-bot-timezone").addEventListener("click", () => {
|
||||
timezone = botTimezone;
|
||||
update_times();
|
||||
});
|
||||
document.getElementById("set-browser-timezone").addEventListener("click", () => {
|
||||
timezone = browserTimezone;
|
||||
update_times();
|
||||
});
|
||||
document.getElementById("update-bot-timezone").addEventListener("click", () => {
|
||||
timezone = browserTimezone;
|
||||
fetch("/dashboard/api/user", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ timezone: timezone }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
show_error(data.error);
|
||||
} else {
|
||||
botTimezone = browserTimezone;
|
||||
update_times();
|
||||
}
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user