();
+ const flash = useFlash();
+
+ const [isImporting, setIsImporting] = useState(false);
+
+ return (
+
+ Import/Export Manager{" "}
+
+
+
+
+
+ >
+ }
+ >
+ <>
+
+
+
+
+
+
+
+
+ {
+ new Promise((resolve) => {
+ let fileReader = new FileReader();
+ fileReader.onload = (e) => resolve(fileReader.result);
+ fileReader.readAsDataURL(inputRef.current.files[0]);
+ }).then((dataUrl: string) => {
+ setIsImporting(true);
+
+ axios
+ .put(`/dashboard/api/guild/${guild}/export/reminders`, {
+ body: JSON.stringify({ body: dataUrl.split(",")[1] }),
+ })
+ .then(({ data }) => {
+ setIsImporting(false);
+
+ if (data.error) {
+ flash({ message: data.error, type: "error" });
+ } else {
+ flash({ message: data.message, type: "success" });
+ }
+ })
+ .then(() => {
+ delete inputRef.current.files[0];
+ });
+ });
+ }}
+ />
+ >
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Modal/index.tsx b/reminder-dashboard/src/components/Modal/index.tsx
new file mode 100644
index 0000000..d3e14f9
--- /dev/null
+++ b/reminder-dashboard/src/components/Modal/index.tsx
@@ -0,0 +1,61 @@
+import { JSX } from "preact";
+import { createPortal } from "preact/compat";
+
+type Props = {
+ setModalOpen: (open: boolean) => never;
+ title: string | JSX.Element;
+ onSubmitText?: string;
+ onSubmit?: () => void;
+ children: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
+};
+
+export const Modal = ({ setModalOpen, title, onSubmit, onSubmitText, children }: Props) => {
+ const body = document.querySelector("body");
+
+ return createPortal(
+
+
{
+ setModalOpen(false);
+ }}
+ >
+
+
+
+
+
+
+ {onSubmit && (
+
+ )}
+
+
+
,
+ body,
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Attachment.tsx b/reminder-dashboard/src/components/Reminder/Attachment.tsx
new file mode 100644
index 0000000..1e6d48c
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Attachment.tsx
@@ -0,0 +1,46 @@
+import { useReminder } from "./ReminderContext";
+
+export const Attachment = () => {
+ const [{ attachment_name }, setReminder] = useReminder();
+
+ return (
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/ButtonRow/CreateButtonRow.tsx b/reminder-dashboard/src/components/Reminder/ButtonRow/CreateButtonRow.tsx
new file mode 100644
index 0000000..72434f7
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/ButtonRow/CreateButtonRow.tsx
@@ -0,0 +1,122 @@
+import { LoadTemplate } from "../LoadTemplate";
+import { useReminder } from "../ReminderContext";
+import { useMutation, useQueryClient } from "react-query";
+import { postGuildReminder, postGuildTemplate } from "../../../api";
+import { useParams } from "wouter";
+import { useState } from "preact/hooks";
+import { ICON_FLASH_TIME } from "../../../consts";
+import { useFlash } from "../../App/FlashContext";
+
+export const CreateButtonRow = () => {
+ const { guild } = useParams();
+ const [reminder] = useReminder();
+
+ const [recentlyCreated, setRecentlyCreated] = useState(false);
+ const [templateRecentlyCreated, setTemplateRecentlyCreated] = useState(false);
+
+ const flash = useFlash();
+ const queryClient = useQueryClient();
+ const mutation = useMutation({
+ ...postGuildReminder(guild),
+ onSuccess: (data) => {
+ if (data.error) {
+ flash({
+ message: data.error,
+ type: "error",
+ });
+ } else {
+ flash({
+ message: "Reminder created",
+ type: "success",
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["GUILD_REMINDERS", guild],
+ });
+ setRecentlyCreated(true);
+ setTimeout(() => {
+ setRecentlyCreated(false);
+ }, ICON_FLASH_TIME);
+ }
+ },
+ });
+
+ const templateMutation = useMutation({
+ ...postGuildTemplate(guild),
+ onSuccess: (data) => {
+ if (data.error) {
+ flash({
+ message: data.error,
+ type: "error",
+ });
+ } else {
+ flash({
+ message: "Template created",
+ type: "success",
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["GUILD_TEMPLATES", guild],
+ });
+ setTemplateRecentlyCreated(true);
+ setTimeout(() => {
+ setTemplateRecentlyCreated(false);
+ }, ICON_FLASH_TIME);
+ }
+ },
+ });
+
+ return (
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/ButtonRow/DeleteButton.tsx b/reminder-dashboard/src/components/Reminder/ButtonRow/DeleteButton.tsx
new file mode 100644
index 0000000..1dbf33f
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/ButtonRow/DeleteButton.tsx
@@ -0,0 +1,74 @@
+import { useState } from "preact/hooks";
+import { Modal } from "../../Modal";
+import { useMutation, useQueryClient } from "react-query";
+import { useReminder } from "../ReminderContext";
+import { deleteGuildReminder } from "../../../api";
+import { useParams } from "wouter";
+import { useFlash } from "../../App/FlashContext";
+
+export const DeleteButton = () => {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ return (
+ <>
+
+ {modalOpen && }
+ >
+ );
+};
+
+const DeleteModal = ({ setModalOpen }) => {
+ const [reminder] = useReminder();
+ const { guild } = useParams();
+
+ const flash = useFlash();
+ const queryClient = useQueryClient();
+ const mutation = useMutation({
+ ...deleteGuildReminder(guild),
+ onSuccess: () => {
+ flash({
+ message: "Reminder deleted",
+ type: "success",
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["GUILD_REMINDERS", guild],
+ });
+ setModalOpen(false);
+ },
+ });
+
+ return (
+
+ <>
+ This reminder will be permanently deleted. Are you sure?
+
+
+
+
+
+ >
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/ButtonRow/EditButtonRow.tsx b/reminder-dashboard/src/components/Reminder/ButtonRow/EditButtonRow.tsx
new file mode 100644
index 0000000..a47b74f
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/ButtonRow/EditButtonRow.tsx
@@ -0,0 +1,88 @@
+import { useRef, useState } from "preact/hooks";
+import { useMutation, useQueryClient } from "react-query";
+import { patchGuildReminder } from "../../../api";
+import { useParams } from "wouter";
+import { ICON_FLASH_TIME } from "../../../consts";
+import { DeleteButton } from "./DeleteButton";
+import { useReminder } from "../ReminderContext";
+import { useFlash } from "../../App/FlashContext";
+
+export const EditButtonRow = () => {
+ const { guild } = useParams();
+ const [reminder, setReminder] = useReminder();
+
+ const [recentlySaved, setRecentlySaved] = useState(false);
+ const queryClient = useQueryClient();
+
+ const iconFlashTimeout = useRef(0);
+
+ const flash = useFlash();
+ const mutation = useMutation({
+ ...patchGuildReminder(guild),
+ onSuccess: (response) => {
+ queryClient.invalidateQueries({
+ queryKey: ["GUILD_REMINDERS", guild],
+ });
+
+ if (iconFlashTimeout.current !== null) {
+ clearTimeout(iconFlashTimeout.current);
+ }
+
+ if (response.data.errors.length > 0) {
+ setRecentlySaved(false);
+
+ for (const error of response.data.errors) {
+ flash({ message: error, type: "error" });
+ }
+ } else {
+ setRecentlySaved(true);
+
+ iconFlashTimeout.current = setTimeout(() => {
+ setRecentlySaved(false);
+ }, ICON_FLASH_TIME);
+
+ setReminder(response.data.reminder);
+ }
+ },
+ });
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/ChannelSelector.tsx b/reminder-dashboard/src/components/Reminder/ChannelSelector.tsx
new file mode 100644
index 0000000..a57ba96
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/ChannelSelector.tsx
@@ -0,0 +1,32 @@
+import { useQuery } from "react-query";
+import { useParams } from "wouter";
+import { fetchGuildChannels } from "../../api";
+
+export const ChannelSelector = ({ channel, setChannel }) => {
+ const { guild } = useParams();
+ const { isSuccess, data } = useQuery(fetchGuildChannels(guild));
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Content.tsx b/reminder-dashboard/src/components/Reminder/Content.tsx
new file mode 100644
index 0000000..81d4832
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Content.tsx
@@ -0,0 +1,25 @@
+import { useReminder } from "./ReminderContext";
+
+export const Content = () => {
+ const [reminder, setReminder] = useReminder();
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/CreateReminder.tsx b/reminder-dashboard/src/components/Reminder/CreateReminder.tsx
new file mode 100644
index 0000000..dd33e7c
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/CreateReminder.tsx
@@ -0,0 +1,74 @@
+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 { useParams } from "wouter";
+
+function defaultReminder(): Reminder {
+ return {
+ attachment: null,
+ attachment_name: null,
+ avatar: null,
+ channel: null,
+ content: "",
+ embed_author: "",
+ embed_author_url: null,
+ embed_color: 0,
+ embed_description: "",
+ embed_fields: [],
+ embed_footer: "",
+ embed_footer_url: null,
+ embed_image_url: null,
+ embed_thumbnail_url: null,
+ embed_title: "",
+ enabled: true,
+ expires: null,
+ interval_days: null,
+ interval_months: null,
+ interval_seconds: null,
+ name: "",
+ restartable: false,
+ tts: false,
+ uid: "",
+ username: "",
+ utc_time: DateTime.now(),
+ };
+}
+
+export const CreateReminder = () => {
+ const { guild } = useParams();
+
+ const [reminder, setReminder] = useState(defaultReminder());
+ const [collapsed, setCollapsed] = useState(false);
+
+ const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild));
+
+ if (isSuccess && reminder.channel === null) {
+ setReminder((reminder) => ({
+ ...reminder,
+ channel: reminder.channel || guildChannels[0].id,
+ }));
+ }
+
+ return (
+
+
+
{
+ setCollapsed(!collapsed);
+ }}
+ />
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/EditReminder.tsx b/reminder-dashboard/src/components/Reminder/EditReminder.tsx
new file mode 100644
index 0000000..b17db90
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/EditReminder.tsx
@@ -0,0 +1,45 @@
+import { Reminder } from "../../api";
+import { useEffect, useState } from "preact/hooks";
+import { EditButtonRow } from "./ButtonRow/EditButtonRow";
+import { Message } from "./Message";
+import { Settings } from "./Settings";
+import { ReminderContext } from "./ReminderContext";
+import { TopBar } from "./TopBar";
+
+type Props = {
+ reminder: Reminder;
+ globalCollapse: boolean;
+};
+
+export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Props) => {
+ const [propReminder, setPropReminder] = useState(initialReminder);
+ const [reminder, setReminder] = useState(initialReminder);
+ const [collapsed, setCollapsed] = useState(false);
+
+ useEffect(() => {
+ setCollapsed(globalCollapse);
+ }, [globalCollapse]);
+
+ // Reminder updated from web response
+ if (propReminder !== initialReminder) {
+ setReminder(initialReminder);
+ setPropReminder(initialReminder);
+ }
+
+ return (
+
+
+
{
+ setCollapsed(!collapsed);
+ }}
+ />
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Author.tsx b/reminder-dashboard/src/components/Reminder/Embed/Author.tsx
new file mode 100644
index 0000000..ad61836
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Author.tsx
@@ -0,0 +1,50 @@
+import { ImagePicker } from "../ImagePicker";
+import { Reminder } from "../../../api";
+
+type Props = {
+ name: string;
+ icon: string;
+ setReminder: (r: (reminder: Reminder) => Reminder) => void;
+};
+
+export const Author = ({ name, icon, setReminder }: Props) => {
+ return (
+
+
+
+ {
+ setReminder((reminder) => ({
+ ...reminder,
+ embed_author_url: url,
+ }));
+ }}
+ >
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Color.tsx b/reminder-dashboard/src/components/Reminder/Embed/Color.tsx
new file mode 100644
index 0000000..2af56e3
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Color.tsx
@@ -0,0 +1,68 @@
+import { useState } from "preact/hooks";
+import { HexColorPicker } from "react-colorful";
+import { Modal } from "../../Modal";
+import { Reminder } from "../../../api";
+
+type Props = {
+ color: string;
+ setReminder: (r: (reminder: Reminder) => Reminder) => void;
+};
+
+function colorToInt(hex: string) {
+ return parseInt(hex.substring(1), 16);
+}
+
+export const Color = ({ color, setReminder }: Props) => {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ return (
+ <>
+ {modalOpen && (
+
+ )}
+
+ >
+ );
+};
+
+const ColorModal = ({ setModalOpen, color, setReminder }) => {
+ return (
+
+
+ {
+ setReminder((reminder) => ({
+ ...reminder,
+ embed_color: colorToInt(color),
+ }));
+ }}
+ >
+
+
+ {
+ setReminder((reminder) => ({
+ ...reminder,
+ embed_color: colorToInt(ev.currentTarget.value),
+ }));
+ }}
+ >
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Description.tsx b/reminder-dashboard/src/components/Reminder/Embed/Description.tsx
new file mode 100644
index 0000000..cdf99b3
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Description.tsx
@@ -0,0 +1,18 @@
+export const Description = ({ description, onInput }) => (
+ <>
+
+
+ >
+);
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Fields/Field.tsx b/reminder-dashboard/src/components/Reminder/Embed/Fields/Field.tsx
new file mode 100644
index 0000000..31ef16c
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Fields/Field.tsx
@@ -0,0 +1,57 @@
+export const Field = ({ title, value, inline, index, onUpdate }) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Fields/index.tsx b/reminder-dashboard/src/components/Reminder/Embed/Fields/index.tsx
new file mode 100644
index 0000000..cb5b648
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Fields/index.tsx
@@ -0,0 +1,37 @@
+import { useReminder } from "../../ReminderContext";
+import { Field } from "./Field";
+
+export const Fields = () => {
+ const [{ embed_fields }, setReminder] = useReminder();
+
+ return (
+
+ {[...embed_fields, { value: "", title: "", inline: true }].map((field, index) => (
+ {
+ setReminder((reminder) => ({
+ ...reminder,
+ embed_fields: [
+ ...reminder.embed_fields,
+ { value: "", title: "", inline: true },
+ ]
+ .map((f, i) => {
+ if (i === index) {
+ return {
+ ...f,
+ ...props,
+ };
+ } else {
+ return f;
+ }
+ })
+ .filter((f) => f.value || f.title),
+ }));
+ }}
+ >
+ ))}
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Footer.tsx b/reminder-dashboard/src/components/Reminder/Embed/Footer.tsx
new file mode 100644
index 0000000..dd908ae
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Footer.tsx
@@ -0,0 +1,43 @@
+import { Reminder } from "../../../api";
+import { ImagePicker } from "../ImagePicker";
+
+type Props = {
+ footer: string;
+ icon: string;
+ setReminder: (r: (reminder: Reminder) => Reminder) => void;
+};
+
+export const Footer = ({ footer, icon, setReminder }: Props) => (
+
+);
diff --git a/reminder-dashboard/src/components/Reminder/Embed/Title.tsx b/reminder-dashboard/src/components/Reminder/Embed/Title.tsx
new file mode 100644
index 0000000..2ec0435
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/Title.tsx
@@ -0,0 +1,18 @@
+export const Title = ({ title, onInput }) => (
+ <>
+
+
+ >
+);
diff --git a/reminder-dashboard/src/components/Reminder/Embed/index.tsx b/reminder-dashboard/src/components/Reminder/Embed/index.tsx
new file mode 100644
index 0000000..914c3d1
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Embed/index.tsx
@@ -0,0 +1,90 @@
+import { Author } from "./Author";
+import { Title } from "./Title";
+import { Description } from "./Description";
+import { Footer } from "./Footer";
+import { Color } from "./Color";
+import { Fields } from "./Fields";
+import { Reminder } from "../../../api";
+import { ImagePicker } from "../ImagePicker";
+import { useReminder } from "../ReminderContext";
+
+function intToColor(num: number) {
+ return `#${num.toString(16).padStart(6, "0")}`;
+}
+
+const DEFAULT_COLOR = 9418359;
+
+export const Embed = () => {
+ const [reminder, setReminder] = useReminder();
+
+ return (
+
+
+
+
+
+
+ setReminder((reminder: Reminder) => ({
+ ...reminder,
+ embed_title: title,
+ }))
+ }
+ >
+
+
+ setReminder((reminder: Reminder) => ({
+ ...reminder,
+ embed_description: description,
+ }))
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {}}
+ />
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/ImagePicker.tsx b/reminder-dashboard/src/components/Reminder/ImagePicker.tsx
new file mode 100644
index 0000000..a4fd55b
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/ImagePicker.tsx
@@ -0,0 +1,49 @@
+import { Modal } from "../Modal";
+import { useState } from "preact/hooks";
+
+export const ImagePicker = ({ alt, url, setImage, ...props }) => {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ return (
+ <>
+ {
+ setModalOpen(true);
+ }}
+ role={"button"}
+ >
+
+
+ {modalOpen && (
+
+ )}
+ >
+ );
+};
+
+const ImagePickerModal = ({ setModalOpen, setImage }) => {
+ const [value, setValue] = useState("");
+
+ return (
+ {
+ setImage(value);
+ }}
+ onSubmitText={"Save"}
+ >
+ {
+ setValue(ev.currentTarget.value);
+ }}
+ >
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/IntervalSelector.tsx b/reminder-dashboard/src/components/Reminder/IntervalSelector.tsx
new file mode 100644
index 0000000..efa7ae7
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/IntervalSelector.tsx
@@ -0,0 +1,173 @@
+import { useCallback, useEffect, useState } from "preact/hooks";
+import { useReminder } from "./ReminderContext";
+
+function divmod(a: number, b: number) {
+ return [Math.floor(a / b), a % b];
+}
+
+function secondsToHMS(seconds: number) {
+ let hours: number, minutes: number;
+
+ [minutes, seconds] = divmod(seconds, 60);
+ [hours, minutes] = divmod(minutes, 60);
+
+ return [hours, minutes, seconds];
+}
+
+export const IntervalSelector = ({
+ months: monthsProp,
+ days: daysProp,
+ seconds: secondsProp,
+ setInterval,
+ clearInterval,
+}) => {
+ const [months, setMonths] = useState(monthsProp);
+ const [days, setDays] = useState(daysProp);
+
+ let [_hours, _minutes, _seconds] = [0, 0, 0];
+ if (secondsProp !== null) {
+ [_hours, _minutes, _seconds] = secondsToHMS(secondsProp);
+ }
+
+ const [seconds, setSeconds] = useState(_seconds);
+ const [minutes, setMinutes] = useState(_minutes);
+ const [hours, setHours] = useState(_hours);
+
+ useEffect(() => {
+ if (seconds || minutes || hours || days || months) {
+ setInterval({
+ seconds: (seconds || 0) + (minutes || 0) * 60 + (hours || 0) * 3600,
+ days: days || 0,
+ months: months || 0,
+ });
+ } else {
+ clearInterval();
+ }
+ }, [seconds, minutes, hours, days, months]);
+
+ const placeholder = useCallback(() => {
+ return seconds || minutes || hours || days || months ? "0" : "";
+ }, [seconds, minutes, hours, days, months]);
+
+ return (
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/LoadTemplate.tsx b/reminder-dashboard/src/components/Reminder/LoadTemplate.tsx
new file mode 100644
index 0000000..8f58e95
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/LoadTemplate.tsx
@@ -0,0 +1,114 @@
+import { useState } from "preact/hooks";
+import { Modal } from "../Modal";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import { deleteGuildTemplate, fetchGuildTemplates } from "../../api";
+import { useParams } from "wouter";
+import { useReminder } from "./ReminderContext";
+import { useFlash } from "../App/FlashContext";
+import { ICON_FLASH_TIME } from "../../consts";
+
+export const LoadTemplate = () => {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ return (
+
+
+ {modalOpen && }
+
+ );
+};
+
+const LoadTemplateModal = ({ setModalOpen }) => {
+ const { guild } = useParams();
+ const [reminder, setReminder] = useReminder();
+
+ const [selected, setSelected] = useState(null);
+ const flash = useFlash();
+
+ const queryClient = useQueryClient();
+ const { isSuccess, data: templates } = useQuery(fetchGuildTemplates(guild));
+ const mutation = useMutation({
+ ...deleteGuildTemplate(guild),
+ onSuccess: () => {
+ flash({ message: "Template deleted", type: "success" });
+ queryClient.invalidateQueries({
+ queryKey: ["GUILD_TEMPLATES", guild],
+ });
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Message.tsx b/reminder-dashboard/src/components/Reminder/Message.tsx
new file mode 100644
index 0000000..1b9ba10
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Message.tsx
@@ -0,0 +1,38 @@
+import { ImagePicker } from "./ImagePicker";
+import { Username } from "./Username";
+import { Content } from "./Content";
+import { Embed } from "./Embed";
+import { useReminder } from "./ReminderContext";
+
+export const Message = () => {
+ const [reminder, setReminder] = useReminder();
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Name.tsx b/reminder-dashboard/src/components/Reminder/Name.tsx
new file mode 100644
index 0000000..244c66d
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Name.tsx
@@ -0,0 +1,29 @@
+import { useReminder } from "./ReminderContext";
+
+export const Name = () => {
+ const [reminder, setReminder] = useReminder();
+
+ return (
+
+
+
+
+ {
+ setReminder((reminder) => ({
+ ...reminder,
+ name: ev.currentTarget.value,
+ }));
+ }}
+ >
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/ReminderContext.tsx b/reminder-dashboard/src/components/Reminder/ReminderContext.tsx
new file mode 100644
index 0000000..e4257eb
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/ReminderContext.tsx
@@ -0,0 +1,9 @@
+import { createContext } from "preact";
+import { useContext } from "preact/compat";
+import { Reminder } from "../../api";
+
+export const ReminderContext = createContext<
+ [Reminder, (r: (reminder: Reminder) => Reminder) => void]
+>([null, () => {}]);
+
+export const useReminder = () => useContext(ReminderContext);
diff --git a/reminder-dashboard/src/components/Reminder/Settings.tsx b/reminder-dashboard/src/components/Reminder/Settings.tsx
new file mode 100644
index 0000000..f919236
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Settings.tsx
@@ -0,0 +1,126 @@
+import { ChannelSelector } from "./ChannelSelector";
+import { DateTime } from "luxon";
+import { IntervalSelector } from "./IntervalSelector";
+import { useQuery } from "react-query";
+import { fetchUserInfo } from "../../api";
+import { useReminder } from "./ReminderContext";
+import { Attachment } from "./Attachment";
+import { TTS } from "./TTS";
+import { useTimezone } from "../App/TimezoneProvider";
+import { TimeInput } from "./TimeInput";
+
+export const Settings = () => {
+ const { isSuccess: userFetched, data: userInfo } = useQuery(fetchUserInfo());
+
+ const [reminder, setReminder] = useReminder();
+ const [timezone] = useTimezone();
+
+ if (!userFetched) {
+ return <>>;
+ }
+
+ return (
+
+
+
+
+
+
{
+ setReminder((reminder) => ({
+ ...reminder,
+ channel: channel,
+ }));
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ setReminder((reminder) => ({
+ ...reminder,
+ interval_months: months,
+ interval_days: days,
+ interval_seconds: seconds,
+ }));
+ }}
+ clearInterval={() => {
+ setReminder((reminder) => ({
+ ...reminder,
+ interval_months: null,
+ interval_days: null,
+ interval_seconds: null,
+ }));
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/TTS.tsx b/reminder-dashboard/src/components/Reminder/TTS.tsx
new file mode 100644
index 0000000..b803eb3
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/TTS.tsx
@@ -0,0 +1,24 @@
+import { useReminder } from "./ReminderContext";
+
+export const TTS = () => {
+ const [{ tts }, setReminder] = useReminder();
+
+ return (
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/TimeInput.tsx b/reminder-dashboard/src/components/Reminder/TimeInput.tsx
new file mode 100644
index 0000000..16d5d71
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/TimeInput.tsx
@@ -0,0 +1,208 @@
+import { useEffect, useRef, useState } from "preact/hooks";
+import { DateTime } from "luxon";
+import { useFlash } from "../App/FlashContext";
+
+export const TimeInput = ({ defaultValue, onInput }) => {
+ const ref = useRef(null);
+
+ const [time, setTime] = useState(defaultValue);
+
+ useEffect(() => {
+ onInput(time);
+ }, [time]);
+
+ const flash = useFlash();
+
+ return (
+ <>
+
+ {
+ setTime(DateTime.fromISO(ev.currentTarget.value));
+ }}
+ >
+ >
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/TopBar.tsx b/reminder-dashboard/src/components/Reminder/TopBar.tsx
new file mode 100644
index 0000000..db1a618
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/TopBar.tsx
@@ -0,0 +1,30 @@
+import { useReminder } from "./ReminderContext";
+import { Name } from "./Name";
+import { fetchGuildChannels, Reminder } from "../../api";
+import { useQuery } from "react-query";
+import { useParams } from "wouter";
+
+export const TopBar = ({ toggleCollapsed }) => {
+ const { guild } = useParams();
+ const [reminder] = useReminder();
+
+ const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild));
+
+ const channelName = (reminder: Reminder) => {
+ const channel = guildChannels.find((c) => c.id === reminder.channel);
+ return channel === undefined ? "" : channel.name;
+ };
+
+ return (
+
+ {isSuccess &&
#{channelName(reminder)}
}
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Reminder/Username.tsx b/reminder-dashboard/src/components/Reminder/Username.tsx
new file mode 100644
index 0000000..8e2d87a
--- /dev/null
+++ b/reminder-dashboard/src/components/Reminder/Username.tsx
@@ -0,0 +1,24 @@
+import { useReminder } from "./ReminderContext";
+
+export const Username = () => {
+ const [reminder, setReminder] = useReminder();
+
+ return (
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Sidebar/Brand.tsx b/reminder-dashboard/src/components/Sidebar/Brand.tsx
new file mode 100644
index 0000000..db7658c
--- /dev/null
+++ b/reminder-dashboard/src/components/Sidebar/Brand.tsx
@@ -0,0 +1,11 @@
+export const Brand = () => (
+
+
+
+);
diff --git a/reminder-dashboard/src/components/Sidebar/DesktopSidebar.tsx b/reminder-dashboard/src/components/Sidebar/DesktopSidebar.tsx
new file mode 100644
index 0000000..81c0064
--- /dev/null
+++ b/reminder-dashboard/src/components/Sidebar/DesktopSidebar.tsx
@@ -0,0 +1,5 @@
+export const DesktopSidebar = ({ children }) => {
+ return (
+ {children}
+ );
+};
diff --git a/reminder-dashboard/src/components/Sidebar/GuildEntry.tsx b/reminder-dashboard/src/components/Sidebar/GuildEntry.tsx
new file mode 100644
index 0000000..d65a7b7
--- /dev/null
+++ b/reminder-dashboard/src/components/Sidebar/GuildEntry.tsx
@@ -0,0 +1,32 @@
+import { GuildInfo } from "../../api";
+import { Link, useLocation } from "wouter";
+
+type Props = {
+ guild: GuildInfo;
+};
+
+export const GuildEntry = ({ guild }: Props) => {
+ const [loc] = useLocation();
+ const currentId = loc.match(/^\/(?\d+)/);
+
+ return (
+
+
+
+
+ {" "}
+ {guild.name}
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Sidebar/MobileSidebar.tsx b/reminder-dashboard/src/components/Sidebar/MobileSidebar.tsx
new file mode 100644
index 0000000..e8f0333
--- /dev/null
+++ b/reminder-dashboard/src/components/Sidebar/MobileSidebar.tsx
@@ -0,0 +1,54 @@
+import { useState } from "preact/hooks";
+
+export const MobileSidebar = ({ children }) => {
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/reminder-dashboard/src/components/Sidebar/Wave.tsx b/reminder-dashboard/src/components/Sidebar/Wave.tsx
new file mode 100644
index 0000000..609a30e
--- /dev/null
+++ b/reminder-dashboard/src/components/Sidebar/Wave.tsx
@@ -0,0 +1,11 @@
+export const Wave = () => (
+
+);
diff --git a/reminder-dashboard/src/components/Sidebar/index.tsx b/reminder-dashboard/src/components/Sidebar/index.tsx
new file mode 100644
index 0000000..906a292
--- /dev/null
+++ b/reminder-dashboard/src/components/Sidebar/index.tsx
@@ -0,0 +1,66 @@
+import { useQuery } from "react-query";
+import { DesktopSidebar } from "./DesktopSidebar";
+import { MobileSidebar } from "./MobileSidebar";
+import { Brand } from "./Brand";
+import { Wave } from "./Wave";
+import { GuildEntry } from "./GuildEntry";
+import { fetchUserGuilds, GuildInfo } from "../../api";
+import { TimezonePicker } from "../TimezonePicker";
+
+type ContentProps = {
+ guilds: GuildInfo[];
+};
+
+const SidebarContent = ({ guilds }: ContentProps) => {
+ const guildEntries = guilds.map((guild) => );
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+export const Sidebar = () => {
+ const { status, data } = useQuery(fetchUserGuilds());
+
+ let content = ;
+ if (status === "success") {
+ content = ;
+ }
+
+ return (
+ <>
+ {content}
+ {content}
+ >
+ );
+};
diff --git a/reminder-dashboard/src/components/TimezonePicker/index.tsx b/reminder-dashboard/src/components/TimezonePicker/index.tsx
new file mode 100644
index 0000000..25904de
--- /dev/null
+++ b/reminder-dashboard/src/components/TimezonePicker/index.tsx
@@ -0,0 +1,134 @@
+import { DateTime, SystemZone } from "luxon";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+import { fetchUserInfo, patchUserInfo } from "../../api";
+import { Modal } from "../Modal";
+import { useState } from "preact/hooks";
+import { useTimezone } from "../App/TimezoneProvider";
+
+type DisplayProps = {
+ timezone: string;
+};
+
+const TimezoneDisplay = ({ timezone }: DisplayProps) => {
+ const now = DateTime.now().setZone(timezone);
+
+ const hour = now.hour;
+ const minute = now.minute;
+
+ return (
+ <>
+
+ {timezone}
+ {" "}
+ (
+
+ {hour}:{minute}
+
+ )
+ >
+ );
+};
+
+export const TimezonePicker = () => {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ return (
+ <>
+ {
+ setModalOpen(true);
+ }}
+ >
+
+
+ {" "}
+ Timezone
+
+ {modalOpen && }
+ >
+ );
+};
+
+const TimezoneModal = ({ setModalOpen }) => {
+ const browserTimezone = DateTime.now().zoneName;
+ const [selectedZone, setSelectedZone] = useTimezone();
+
+ const queryClient = useQueryClient();
+ const { isLoading, isError, data } = useQuery(fetchUserInfo());
+ const userInfoMutation = useMutation({
+ ...patchUserInfo(),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["USER_INFO"]);
+ },
+ });
+
+ return (
+
+
+ Your configured timezone is:{" "}
+
+
+ Your browser timezone is:{" "}
+
+
+ {!isError && (
+ <>
+ Your bot timezone is:{" "}
+ {isLoading ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/reminder-dashboard/src/components/Welcome/index.tsx b/reminder-dashboard/src/components/Welcome/index.tsx
new file mode 100644
index 0000000..d9c5597
--- /dev/null
+++ b/reminder-dashboard/src/components/Welcome/index.tsx
@@ -0,0 +1,15 @@
+export const Welcome = () => (
+
+
+
Welcome!
+
Select an option from the side to get started
+
+ Press the{" "}
+
+
+ {" "}
+ to get started
+
+
+
+);
diff --git a/reminder-dashboard/src/consts.ts b/reminder-dashboard/src/consts.ts
new file mode 100644
index 0000000..d59664a
--- /dev/null
+++ b/reminder-dashboard/src/consts.ts
@@ -0,0 +1,2 @@
+export const ICON_FLASH_TIME = 2_500;
+export const MESSAGE_FLASH_TIME = 5_000;
diff --git a/reminder-dashboard/src/index.tsx b/reminder-dashboard/src/index.tsx
new file mode 100644
index 0000000..2d854e7
--- /dev/null
+++ b/reminder-dashboard/src/index.tsx
@@ -0,0 +1,4 @@
+import { render } from "preact";
+import { App } from "./components/App";
+
+render(, document.getElementById("app"));
diff --git a/reminder-dashboard/tsconfig.json b/reminder-dashboard/tsconfig.json
new file mode 100644
index 0000000..dd7fcb6
--- /dev/null
+++ b/reminder-dashboard/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "allowJs": true,
+ "checkJs": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact"
+ },
+ "include": ["node_modules/vite/client.d.ts", "**/*"]
+}
diff --git a/reminder-dashboard/vite.config.ts b/reminder-dashboard/vite.config.ts
new file mode 100644
index 0000000..0337f8d
--- /dev/null
+++ b/reminder-dashboard/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from "vite";
+import preact from "@preact/preset-vite";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [preact()],
+ build: {
+ assetsDir: "static/assets",
+ sourcemap: true,
+ copyPublicDir: false,
+ },
+});