1 Commits

Author SHA1 Message Date
3fc27b466a Bump ver 2024-04-20 22:14:49 +01:00
39 changed files with 1884 additions and 1431 deletions

669
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.23" version = "1.7.14"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0 only" license = "AGPL-3.0 only"
@ -34,7 +34,7 @@ rocket_dyn_templates = { version = "0.1.0", features = ["tera"] }
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
oauth2 = "4" oauth2 = "4"
csv = "1.2" csv = "1.2"
sd-notify = "0.4.1" axum = "0.7"
[dependencies.extract_derive] [dependencies.extract_derive]
path = "extract_derive" path = "extract_derive"

View File

@ -1,10 +0,0 @@
ALTER TABLE users ADD COLUMN `reset_inputs_on_create` BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN `use_browser_timezone` BOOLEAN NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN `dashboard_color_scheme` ENUM('system', 'light', 'dark') NOT NULL DEFAULT 'system';
ALTER TABLE users DROP COLUMN `language`;
ALTER TABLE users DROP COLUMN `patreon`;
ALTER TABLE users DROP COLUMN `name`;
ALTER TABLE users DROP PRIMARY KEY, ADD PRIMARY KEY (`user`);
ALTER TABLE users RENAME COLUMN `user` TO `id`;

View File

@ -1,22 +1,12 @@
server { server {
server_name www.reminder-bot.com; server_name www.reminder-bot.com;
return 301 https://reminder-bot.com$request_uri; return 301 $scheme://reminder-bot.com$request_uri;
} }
server { server {
listen 80; listen 80;
server_name beta.reminder-bot.com; server_name reminder-bot.com;
return 301 https://reminder-bot.com$request_uri;
}
server {
listen 443 ssl;
server_name beta.reminder-bot.com;
ssl_certificate /etc/letsencrypt/live/beta.reminder-bot.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/beta.reminder-bot.com/privkey.pem;
return 301 https://reminder-bot.com$request_uri; return 301 https://reminder-bot.com$request_uri;
} }
@ -35,8 +25,6 @@ server {
proxy_buffers 4 256k; proxy_buffers 4 256k;
proxy_busy_buffers_size 256k; proxy_busy_buffers_size 256k;
client_max_body_size 10M;
location / { location / {
proxy_pass http://localhost:18920; proxy_pass http://localhost:18920;
proxy_redirect off; proxy_redirect off;

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,6 @@ type UserInfo = {
name: string; name: string;
patreon: boolean; patreon: boolean;
timezone: string | null; timezone: string | null;
reset_inputs_on_create: boolean;
}; };
export type GuildInfo = { export type GuildInfo = {

View File

@ -7,10 +7,6 @@ import { useGuild } from "./useGuild";
export const Mentions = ({ input }) => { export const Mentions = ({ input }) => {
const guild = useGuild(); const guild = useGuild();
return <>{guild && <_Mentions guild={guild} input={input} />}</>;
};
const _Mentions = ({ guild, input }) => {
const { data: roles } = useQuery(fetchGuildRoles(guild)); const { data: roles } = useQuery(fetchGuildRoles(guild));
const { data: channels } = useQuery(fetchGuildChannels(guild)); const { data: channels } = useQuery(fetchGuildChannels(guild));
const { data: emojis } = useQuery(fetchGuildEmojis(guild)); const { data: emojis } = useQuery(fetchGuildEmojis(guild));
@ -21,7 +17,7 @@ const _Mentions = ({ guild, input }) => {
{ {
trigger: "@", trigger: "@",
values: (roles || []) values: (roles || [])
.filter((role) => role.name !== "@everyone") .filter((role) => role.name === "@everyone")
.map(({ id, name }) => ({ key: name, value: id })), .map(({ id, name }) => ({ key: name, value: id })),
allowSpaces: true, allowSpaces: true,
selectTemplate: (item) => `<@&${item.original.value}>`, selectTemplate: (item) => `<@&${item.original.value}>`,

View File

@ -12,17 +12,12 @@ import { GuildTodos } from "../Guild/GuildTodos";
export function App() { export function App() {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
let scheme = "light";
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
scheme = "dark";
}
return ( return (
<TimezoneProvider> <TimezoneProvider>
<FlashProvider> <FlashProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Router base={"/dashboard"}> <Router base={"/dashboard"}>
<div class={`columns is-gapless dashboard-frame scheme-${scheme}`}> <div class="columns is-gapless dashboard-frame">
<Sidebar /> <Sidebar />
<div class="column is-main-content"> <div class="column is-main-content">
<div style={{ margin: "0 12px 12px 12px" }}> <div style={{ margin: "0 12px 12px 12px" }}>

View File

@ -1,28 +1,21 @@
import { LoadTemplate } from "../LoadTemplate"; import { LoadTemplate } from "../LoadTemplate";
import { useReminder } from "../ReminderContext"; import { useReminder } from "../ReminderContext";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQueryClient } from "react-query";
import { import { postGuildReminder, postGuildTemplate, postUserReminder } from "../../../api";
fetchUserInfo,
postGuildReminder,
postGuildTemplate,
postUserReminder,
} from "../../../api";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../../consts"; import { ICON_FLASH_TIME } from "../../../consts";
import { useFlash } from "../../App/FlashContext"; import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild"; import { useGuild } from "../../App/useGuild";
import { defaultReminder } from "../CreateReminder";
export const CreateButtonRow = () => { export const CreateButtonRow = () => {
const guild = useGuild(); const guild = useGuild();
const [reminder, setReminder] = useReminder(); const [reminder] = useReminder();
const [recentlyCreated, setRecentlyCreated] = useState(false); const [recentlyCreated, setRecentlyCreated] = useState(false);
const [templateRecentlyCreated, setTemplateRecentlyCreated] = useState(false); const [templateRecentlyCreated, setTemplateRecentlyCreated] = useState(false);
const flash = useFlash(); const flash = useFlash();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: userInfo } = useQuery({ ...fetchUserInfo() });
const mutation = useMutation({ const mutation = useMutation({
...(guild ? postGuildReminder(guild) : postUserReminder()), ...(guild ? postGuildReminder(guild) : postUserReminder()),
onError: (error) => { onError: (error) => {
@ -51,9 +44,6 @@ export const CreateButtonRow = () => {
queryKey: ["USER_REMINDERS"], queryKey: ["USER_REMINDERS"],
}); });
} }
if (userInfo.reset_inputs_on_create) {
setReminder(() => defaultReminder());
}
setRecentlyCreated(true); setRecentlyCreated(true);
setTimeout(() => { setTimeout(() => {
setRecentlyCreated(false); setRecentlyCreated(false);

View File

@ -11,7 +11,7 @@ import "./styles.scss";
import {useGuild} from "../App/useGuild"; import {useGuild} from "../App/useGuild";
import {DEFAULT_COLOR} from "./Embed"; import {DEFAULT_COLOR} from "./Embed";
export function defaultReminder(): Reminder { function defaultReminder(): Reminder {
return { return {
attachment: null, attachment: null,
attachment_name: null, attachment_name: null,
@ -45,37 +45,6 @@ export function defaultReminder(): Reminder {
export const CreateReminder = () => { export const CreateReminder = () => {
const guild = useGuild(); const guild = useGuild();
if (guild) {
return <_Guild guild={guild} />;
} else {
return <_User />;
}
};
const _User = () => {
const [reminder, setReminder] = useState(defaultReminder());
const [collapsed, setCollapsed] = useState(false);
return (
<ReminderContext.Provider value={[reminder, setReminder]}>
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
<TopBar
isCreating={true}
toggleCollapsed={() => {
setCollapsed(!collapsed);
}}
/>
<div class="columns reminder-settings">
<Message />
<Settings />
</div>
<CreateButtonRow />
</div>
</ReminderContext.Provider>
);
};
const _Guild = ({ guild }) => {
const [reminder, setReminder] = useState(defaultReminder()); const [reminder, setReminder] = useState(defaultReminder());
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);

View File

@ -16,28 +16,28 @@ export const TimeInput = ({ defaultValue, onInput }) => {
const ref = useRef(null); const ref = useRef(null);
const [timezone] = useTimezone(); const [timezone] = useTimezone();
const [localTime, setLocalTime] = useState( const [time, setTime] = useState(
defaultValue ? DateTime.fromISO(defaultValue, { zone: "UTC" }).setZone(timezone) : null, defaultValue ? DateTime.fromISO(defaultValue, { zone: "UTC" }) : null,
); );
const updateTime = useCallback( const updateTime = useCallback(
(upd: TimeUpdate) => { (upd: TimeUpdate) => {
if (upd === null) { if (upd === null) {
setLocalTime(null); setTime(null);
} }
let newTime = localTime; let newTime = time;
if (newTime === null) { if (newTime === null) {
newTime = DateTime.now().setZone(timezone); newTime = DateTime.now().setZone("UTC");
} }
setLocalTime(newTime.set(upd)); setTime(newTime.setZone(timezone).set(upd).setZone("UTC"));
}, },
[localTime, timezone], [time, timezone],
); );
useEffect(() => { useEffect(() => {
onInput(localTime?.setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss")); onInput(time?.setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss"));
}, [localTime]); }, [time]);
const flash = useFlash(); const flash = useFlash();
@ -51,14 +51,14 @@ export const TimeInput = ({ defaultValue, onInput }) => {
let dt = DateTime.fromISO(pasteValue, { zone: timezone }); let dt = DateTime.fromISO(pasteValue, { zone: timezone });
if (dt.isValid) { if (dt.isValid) {
setLocalTime(dt); setTime(dt);
return; return;
} }
dt = DateTime.fromSQL(pasteValue); dt = DateTime.fromSQL(pasteValue);
if (dt.isValid) { if (dt.isValid) {
setLocalTime(dt); setTime(dt);
return; return;
} }
@ -83,8 +83,8 @@ export const TimeInput = ({ defaultValue, onInput }) => {
maxlength={4} maxlength={4}
placeholder="YYYY" placeholder="YYYY"
value={ value={
localTime time
? localTime.year.toLocaleString("en-US", { ? time.setZone(timezone).year.toLocaleString("en-US", {
minimumIntegerDigits: 4, minimumIntegerDigits: 4,
useGrouping: false, useGrouping: false,
}) })
@ -114,8 +114,8 @@ export const TimeInput = ({ defaultValue, onInput }) => {
maxlength={2} maxlength={2}
placeholder="MM" placeholder="MM"
value={ value={
localTime time
? localTime.month.toLocaleString("en-US", { ? time.setZone(timezone).month.toLocaleString("en-US", {
minimumIntegerDigits: 2, minimumIntegerDigits: 2,
}) })
: "" : ""
@ -144,10 +144,10 @@ export const TimeInput = ({ defaultValue, onInput }) => {
maxlength={2} maxlength={2}
placeholder="DD" placeholder="DD"
value={ value={
localTime time
? localTime.day.toLocaleString("en-US", { ? time
minimumIntegerDigits: 2, .setZone(timezone)
}) .day.toLocaleString("en-US", { minimumIntegerDigits: 2 })
: "" : ""
} }
onBlur={(ev) => { onBlur={(ev) => {
@ -173,10 +173,10 @@ export const TimeInput = ({ defaultValue, onInput }) => {
maxlength={2} maxlength={2}
placeholder="hh" placeholder="hh"
value={ value={
localTime time
? localTime.hour.toLocaleString("en-US", { ? time
minimumIntegerDigits: 2, .setZone(timezone)
}) .hour.toLocaleString("en-US", { minimumIntegerDigits: 2 })
: "" : ""
} }
onBlur={(ev) => { onBlur={(ev) => {
@ -203,8 +203,8 @@ export const TimeInput = ({ defaultValue, onInput }) => {
maxlength={2} maxlength={2}
placeholder="mm" placeholder="mm"
value={ value={
localTime time
? localTime.minute.toLocaleString("en-US", { ? time.setZone(timezone).minute.toLocaleString("en-US", {
minimumIntegerDigits: 2, minimumIntegerDigits: 2,
}) })
: "" : ""
@ -233,8 +233,8 @@ export const TimeInput = ({ defaultValue, onInput }) => {
maxlength={2} maxlength={2}
placeholder="ss" placeholder="ss"
value={ value={
localTime time
? localTime.second.toLocaleString("en-US", { ? time.setZone(timezone).second.toLocaleString("en-US", {
minimumIntegerDigits: 2, minimumIntegerDigits: 2,
}) })
: "" : ""
@ -276,17 +276,15 @@ export const TimeInput = ({ defaultValue, onInput }) => {
type="datetime-local" type="datetime-local"
step="1" step="1"
value={ value={
localTime time
? localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss") ? time.toFormat("yyyy-LL-dd'T'HH:mm:ss")
: DateTime.now().setZone(timezone).toFormat("yyyy-LL-dd'T'HH:mm:ss") : DateTime.now().toFormat("yyyy-LL-dd'T'HH:mm:ss")
} }
ref={ref} ref={ref}
onInput={(ev) => { onInput={(ev) => {
ev.currentTarget.value === "" ev.currentTarget.value === ""
? updateTime(null) ? updateTime(null)
: setLocalTime( : setTime(DateTime.fromISO(ev.currentTarget.value, { zone: "UTC" }));
DateTime.fromISO(ev.currentTarget.value, { zone: timezone }),
);
}} }}
></input> ></input>
</> </>

View File

@ -53,7 +53,7 @@ export const TimezonePicker = () => {
const TimezoneModal = ({ setModalOpen }) => { const TimezoneModal = ({ setModalOpen }) => {
const browserTimezone = DateTime.now().zoneName; const browserTimezone = DateTime.now().zoneName;
const [selectedZone] = useTimezone(); const [selectedZone, setSelectedZone] = useTimezone();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isLoading, isError, data } = useQuery(fetchUserInfo()); const { isLoading, isError, data } = useQuery(fetchUserInfo());
@ -86,6 +86,36 @@ const TimezoneModal = ({ setModalOpen }) => {
</p> </p>
<br></br> <br></br>
<div class="has-text-centered"> <div class="has-text-centered">
<button
class="button is-success"
style={{
margin: "2px",
}}
id="set-browser-timezone"
onClick={() => {
setSelectedZone(browserTimezone);
}}
>
<span>Use Browser Timezone</span>{" "}
<span class="icon">
<i class="fab fa-firefox-browser"></i>
</span>
</button>
<button
class="button is-success"
id="set-bot-timezone"
style={{
margin: "2px",
}}
onClick={() => {
setSelectedZone(data.timezone);
}}
>
<span>Use Bot Timezone</span>{" "}
<span class="icon">
<i class="fab fa-discord"></i>
</span>
</button>
<button <button
class="button is-success is-outlined" class="button is-success is-outlined"
id="update-bot-timezone" id="update-bot-timezone"

View File

@ -11,7 +11,12 @@ enum Sort {
} }
export const UserReminders = () => { export const UserReminders = () => {
const { isSuccess, isFetching, isFetched, data: reminders } = useQuery(fetchUserReminders()); const {
isSuccess,
isFetching,
isFetched,
data: guildReminders,
} = useQuery(fetchUserReminders());
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [sort, setSort] = useState(Sort.Time); const [sort, setSort] = useState(Sort.Time);
@ -80,7 +85,7 @@ export const UserReminders = () => {
<div id={"guildReminders"} className={isFetching ? "loading" : ""}> <div id={"guildReminders"} className={isFetching ? "loading" : ""}>
{isSuccess && {isSuccess &&
reminders guildReminders
.sort((r1, r2) => { .sort((r1, r2) => {
if (sort === Sort.Time) { if (sort === Sort.Time) {
return r1.utc_time > r2.utc_time ? 1 : -1; return r1.utc_time > r2.utc_time ? 1 : -1;

View File

@ -22,8 +22,8 @@ impl Recordable for Options {
CreateEmbed::new() CreateEmbed::new()
.title("Confirmations ephemeral") .title("Confirmations ephemeral")
.description(concat!( .description(concat!(
"Reminder and todo confirmations will be sent privately, and removed when ", "Reminder confirmations will be sent privately, and removed when your client",
"your client restarts." " restarts."
)) ))
.color(*THEME_COLOR), .color(*THEME_COLOR),
), ),

View File

@ -22,8 +22,8 @@ impl Recordable for Options {
CreateEmbed::new() CreateEmbed::new()
.title("Confirmations public") .title("Confirmations public")
.description(concat!( .description(concat!(
"Reminder and todo confirmations will be sent as regular messages, and", "Reminder confirmations will be sent as regular messages, and won't be ",
" won't be removed automatically." "removed automatically."
)) ))
.color(*THEME_COLOR), .color(*THEME_COLOR),
), ),

View File

@ -1,4 +1,3 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
@ -34,13 +33,7 @@ impl Recordable for Options {
.await .await
.unwrap(); .unwrap();
let ephemeral = ctx ctx.say("Item added to todo list").await?;
.guild_data()
.await
.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral))
.await?;
Ok(()) Ok(())
} }

View File

@ -1,8 +1,6 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
models::CtxData,
utils::{Extract, Recordable}, utils::{Extract, Recordable},
Context, Error, Context, Error,
}; };
@ -28,13 +26,7 @@ impl Recordable for Options {
.await .await
.unwrap(); .unwrap();
let ephemeral = ctx ctx.say("Item added to todo list").await?;
.guild_data()
.await
.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral))
.await?;
Ok(()) Ok(())
} }

View File

@ -1,8 +1,6 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
models::CtxData,
utils::{Extract, Recordable}, utils::{Extract, Recordable},
Context, Error, Context, Error,
}; };
@ -29,13 +27,7 @@ impl Recordable for Options {
.await .await
.unwrap(); .unwrap();
let ephemeral = ctx ctx.say("Item added to todo list").await?;
.guild_data()
.await
.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
ctx.send(CreateReply::default().content("Item added to todo list").ephemeral(ephemeral))
.await?;
Ok(()) Ok(())
} }

View File

@ -282,43 +282,13 @@ impl ComponentDataModel {
.await .await
.unwrap(); .unwrap();
let values = if let Some(uid) = selector.user_id { let values = sqlx::query!(
sqlx::query!( // fucking braindead mysql use <=> instead of = for null comparison
" "
SELECT todos.id, value FROM todos SELECT id, value FROM todos WHERE user_id <=> ? AND channel_id <=> ? AND guild_id <=> ?
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?
",
uid,
)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else if let Some(cid) = selector.channel_id {
sqlx::query!(
"
SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?
",
cid,
)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else {
sqlx::query!(
"
SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?
", ",
selector.user_id,
selector.channel_id,
selector.guild_id, selector.guild_id,
) )
.fetch_all(&data.database) .fetch_all(&data.database)
@ -326,8 +296,7 @@ impl ComponentDataModel {
.unwrap() .unwrap()
.iter() .iter()
.map(|row| (row.id as usize, row.value.clone())) .map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>() .collect::<Vec<(usize, String)>>();
};
let resp = show_todo_page( let resp = show_todo_page(
&values, &values,

View File

@ -58,10 +58,6 @@ async fn macro_check(ctx: Context<'_>) -> bool {
async fn check_self_permissions(ctx: Context<'_>) -> bool { async fn check_self_permissions(ctx: Context<'_>) -> bool {
let user_id = ctx.serenity_context().cache.current_user().id; let user_id = ctx.serenity_context().cache.current_user().id;
let app_permissions = match ctx {
Context::Application(app_ctx) => app_ctx.interaction.app_permissions,
_ => None,
};
match ctx.guild().map(|g| g.to_owned()) { match ctx.guild().map(|g| g.to_owned()) {
Some(guild) => { Some(guild) => {
@ -70,34 +66,42 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
.await .await
.map_or(false, |m| m.permissions(&ctx).map_or(false, |p| p.manage_webhooks())); .map_or(false, |m| m.permissions(&ctx).map_or(false, |p| p.manage_webhooks()));
if let Some(permissions) = app_permissions { let (view_channel, send_messages, embed_links) = ctx
return if permissions.send_messages() .channel_id()
&& permissions.embed_links() .to_channel(&ctx)
&& manage_webhooks .await
{ .ok()
.and_then(|c| {
if let Channel::Guild(channel) = c {
let perms = channel.permissions_for_user(&ctx, user_id).ok()?;
Some((perms.view_channel(), perms.send_messages(), perms.embed_links()))
} else {
None
}
})
.unwrap_or((false, false, false));
if manage_webhooks && send_messages && embed_links {
true true
} else { } else {
let _ = ctx let _ = ctx
.send(CreateReply::default().content(format!( .send(CreateReply::default().content(format!(
"The bot appears to be missing some permissions: "Please ensure the bot has the correct permissions:
{} **View Channel**
{} **Send Message** {} **Send Message**
{} **Embed Links** {} **Embed Links**
{} **Manage Webhooks** {} **Manage Webhooks**",
if view_channel { "" } else { "" },
Please check the bot's roles, and any channel overrides. Alternatively, giving the bot if send_messages { "" } else { "" },
\"Administrator\" will bypass permission checks", if embed_links { "" } else { "" },
if permissions.send_messages() { "" } else { "" },
if permissions.embed_links() { "" } else { "" },
if manage_webhooks { "" } else { "" }, if manage_webhooks { "" } else { "" },
))) )))
.await; .await;
false false
};
} }
manage_webhooks
} }
None => { None => {

View File

@ -212,6 +212,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
// Start metrics // Start metrics
init_metrics(); init_metrics();
tokio::spawn(async { metrics::serve().await });
let database = let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();

View File

@ -1,4 +1,6 @@
use axum::{routing::get, Router};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use log::warn;
use prometheus::{IntCounterVec, Opts, Registry}; use prometheus::{IntCounterVec, Opts, Registry};
lazy_static! { lazy_static! {
@ -24,3 +26,21 @@ pub fn init_metrics() {
REGISTRY.register(Box::new(REMINDER_FAIL_COUNTER.clone())).unwrap(); REGISTRY.register(Box::new(REMINDER_FAIL_COUNTER.clone())).unwrap();
REGISTRY.register(Box::new(COMMAND_COUNTER.clone())).unwrap(); REGISTRY.register(Box::new(COMMAND_COUNTER.clone())).unwrap();
} }
pub async fn serve() {
let app = Router::new().route("/metrics", get(metrics));
let listener = tokio::net::TcpListener::bind("localhost:31756").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn metrics() -> String {
let encoder = prometheus::TextEncoder::new();
let res_custom = encoder.encode_to_string(&REGISTRY.gather());
res_custom.unwrap_or_else(|e| {
warn!("Error encoding metrics: {:?}", e);
String::new()
})
}

View File

@ -55,7 +55,7 @@ pub struct ReminderBuilder {
tts: bool, tts: bool,
attachment_name: Option<String>, attachment_name: Option<String>,
attachment: Option<Vec<u8>>, attachment: Option<Vec<u8>>,
set_by: Option<u64>, set_by: Option<u32>,
} }
impl ReminderBuilder { impl ReminderBuilder {
@ -132,7 +132,7 @@ pub struct MultiReminderBuilder<'a> {
interval: Option<Interval>, interval: Option<Interval>,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
content: Content, content: Content,
set_by: Option<u64>, set_by: Option<u32>,
ctx: &'a Context<'a>, ctx: &'a Context<'a>,
guild_id: Option<GuildId>, guild_id: Option<GuildId>,
} }

View File

@ -6,7 +6,9 @@ use sqlx::MySqlPool;
use crate::consts::LOCAL_TIMEZONE; use crate::consts::LOCAL_TIMEZONE;
pub struct UserData { pub struct UserData {
pub id: u64, pub id: u32,
#[allow(dead_code)]
pub user: u64,
pub dm_channel: u32, pub dm_channel: u32,
pub timezone: String, pub timezone: String,
pub allowed_dm: bool, pub allowed_dm: bool,
@ -21,9 +23,7 @@ impl UserData {
match sqlx::query!( match sqlx::query!(
" "
SELECT IFNULL(timezone, 'UTC') AS timezone SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
FROM users
WHERE id = ?
", ",
user_id user_id
) )
@ -48,9 +48,7 @@ impl UserData {
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ?
FROM users
WHERE id = ?
", ",
*LOCAL_TIMEZONE, *LOCAL_TIMEZONE,
user_id.get() user_id.get()
@ -66,8 +64,7 @@ impl UserData {
sqlx::query!( sqlx::query!(
" "
INSERT IGNORE INTO channels (channel) INSERT IGNORE INTO channels (channel) VALUES (?)
VALUES (?)
", ",
dm_channel.id.get() dm_channel.id.get()
) )
@ -76,8 +73,7 @@ impl UserData {
sqlx::query!( sqlx::query!(
" "
INSERT INTO users (id, dm_channel, timezone) INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?)
VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)
", ",
user_id.get(), user_id.get(),
dm_channel.id.get(), dm_channel.id.get(),
@ -89,9 +85,7 @@ impl UserData {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, dm_channel, timezone, allowed_dm SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
FROM users
WHERE id = ?
", ",
user_id.get() user_id.get()
) )
@ -110,9 +104,7 @@ impl UserData {
pub async fn commit_changes(&self, pool: &MySqlPool) { pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!( sqlx::query!(
" "
UPDATE users UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ?
SET timezone = ?, allowed_dm = ?
WHERE id = ?
", ",
self.timezone, self.timezone,
self.allowed_dm, self.allowed_dm,

View File

@ -4,7 +4,6 @@ use std::env;
use log::{info, warn}; use log::{info, warn};
use poise::serenity_prelude::client::Context; use poise::serenity_prelude::client::Context;
use sd_notify::{self, NotifyState};
use sqlx::{Executor, MySql}; use sqlx::{Executor, MySql};
use tokio::{ use tokio::{
sync::broadcast::Receiver, sync::broadcast::Receiver,
@ -34,15 +33,6 @@ async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database>
.flatten() .flatten()
.unwrap_or(10); .unwrap_or(10);
let mut watchdog_interval = 0;
let watchdog = sd_notify::watchdog_enabled(false, &mut watchdog_interval);
if watchdog {
warn!("Watchdog enabled. Don't die!");
} else {
warn!("No watchdog running")
}
loop { loop {
let sleep_to = Instant::now() + Duration::from_secs(remind_interval); let sleep_to = Instant::now() + Duration::from_secs(remind_interval);
let reminders = sender::Reminder::fetch_reminders(pool).await; let reminders = sender::Reminder::fetch_reminders(pool).await;
@ -52,11 +42,9 @@ async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database>
for reminder in reminders { for reminder in reminders {
reminder.send(pool, ctx.clone()).await; reminder.send(pool, ctx.clone()).await;
let _ = sd_notify::notify(false, &[NotifyState::Watchdog]);
} }
} }
sleep_until(sleep_to).await; sleep_until(sleep_to).await;
let _ = sd_notify::notify(false, &[NotifyState::Watchdog]);
} }
} }

View File

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

View File

@ -2,10 +2,9 @@ mod consts;
#[macro_use] #[macro_use]
mod macros; mod macros;
mod catchers; mod catchers;
mod fairings;
mod guards; mod guards;
mod metrics;
mod routes; mod routes;
pub mod string { pub mod string {
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
@ -80,7 +79,7 @@ use sqlx::{MySql, Pool};
use crate::web::{ use crate::web::{
consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
fairings::metrics::MetricProducer, metrics::MetricProducer,
}; };
type Database = MySql; type Database = MySql;
@ -150,7 +149,6 @@ pub async fn initialize(
routes::report::report_error, routes::report::report_error,
routes::return_to_same_site, routes::return_to_same_site,
routes::terms, routes::terms,
routes::metrics,
], ],
) )
.mount( .mount(
@ -179,8 +177,6 @@ pub async fn initialize(
.mount( .mount(
"/dashboard", "/dashboard",
routes![ routes![
routes::dashboard::reminders_redirect,
routes::dashboard::todos_redirect,
routes::dashboard::dashboard, routes::dashboard::dashboard,
routes::dashboard::dashboard_home, routes::dashboard::dashboard_home,
routes::dashboard::api::delete_reminder, routes::dashboard::api::delete_reminder,

View File

@ -21,29 +21,16 @@ use serenity::{
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::web::guards::transaction::Transaction;
#[derive(Serialize)]
struct UserPreferences {
timezone: String,
use_browser_timezone: bool,
dashboard_color_scheme: String,
reset_inputs_on_create: bool,
}
#[derive(Serialize)] #[derive(Serialize)]
struct UserInfo { struct UserInfo {
name: String, name: String,
patreon: bool, patreon: bool,
preferences: UserPreferences, timezone: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateUserPreferences { pub struct UpdateUser {
timezone: Option<String>, timezone: String,
use_browser_timezone: Option<bool>,
dashboard_color_scheme: Option<String>,
reset_inputs_on_create: Option<bool>,
} }
#[get("/api/user")] #[get("/api/user")]
@ -52,16 +39,7 @@ pub async fn get_user_info(
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
) -> JsonValue { ) -> JsonValue {
offline!(json!(UserInfo { offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None }));
name: "Discord".to_string(),
patreon: true,
preferences: UserPreferences {
timezone: "UTC".to_string(),
use_browser_timezone: false,
dashboard_color_scheme: "system".to_string(),
reset_inputs_on_create: false,
}
}));
if let Some(user_id) = if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
@ -70,16 +48,8 @@ pub async fn get_user_info(
.member(&ctx.inner(), user_id) .member(&ctx.inner(), user_id)
.await; .await;
let prefs = sqlx::query!( let timezone = sqlx::query!(
" "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
SELECT
IFNULL(timezone, 'UTC') AS timezone,
use_browser_timezone,
dashboard_color_scheme,
reset_inputs_on_create
FROM users
WHERE id = ?
",
user_id user_id
) )
.fetch_one(pool.inner()) .fetch_one(pool.inner())
@ -95,12 +65,7 @@ pub async fn get_user_info(
.roles .roles
.contains(&RoleId::new(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) .contains(&RoleId::new(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
}), }),
preferences: UserPreferences { timezone,
timezone: prefs.timezone,
use_browser_timezone: prefs.use_browser_timezone,
dashboard_color_scheme: prefs.dashboard_color_scheme,
reset_inputs_on_create: prefs.reset_inputs_on_create,
},
}; };
json!(user_info) json!(user_info)
@ -109,55 +74,28 @@ pub async fn get_user_info(
} }
} }
#[patch("/api/user", data = "<preferences>")] #[patch("/api/user", data = "<user>")]
pub async fn update_user_info( pub async fn update_user_info(
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
preferences: Json<UpdateUserPreferences>, user: Json<UpdateUser>,
mut transaction: Transaction<'_>, pool: &State<Pool<MySql>>,
) -> JsonValue { ) -> JsonValue {
if let Some(user_id) = if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{ {
if let Some(timezone) = &preferences.timezone { if user.timezone.parse::<Tz>().is_ok() {
if timezone.parse::<Tz>().is_ok() {
let _ = sqlx::query!( let _ = sqlx::query!(
" "UPDATE users SET timezone = ? WHERE user = ?",
UPDATE users user.timezone,
SET timezone = ?
WHERE id = ?
",
timezone,
user_id, user_id,
) )
.execute(transaction.executor()) .execute(pool.inner())
.await; .await;
} else {
return json!({"error": "Timezone not recognised"});
}
}
if let Some(dashboard_color_scheme) = &preferences.dashboard_color_scheme {
if vec!["system", "light", "dark"].contains(dashboard_color_scheme) {
let _ = sqlx::query!(
"
UPDATE users
SET dashboard_color_scheme = ?
WHERE id = ?
",
dashboard_color_scheme,
user_id,
)
.execute(transaction.executor())
.await;
} else {
return json!({"error": "Color scheme not recognised"});
}
}
// todo handle other two options
transaction.commit().await;
json!({}) json!({})
} else {
json!({"error": "Timezone not recognized"})
}
} else { } else {
json!({"error": "Not authorized"}) json!({"error": "Not authorized"})
} }

View File

@ -697,18 +697,6 @@ pub enum DashboardPage {
NotConfigured(Template), NotConfigured(Template),
} }
// Legacy route to maintain compatibility with old dashboard routing
#[get("/?<id>")]
pub async fn reminders_redirect(id: &str) -> Redirect {
Redirect::to(format!("/dashboard/{}/reminders", id))
}
// Legacy route to maintain compatibility with old dashboard routing
#[get("/todo?<id>")]
pub async fn todos_redirect(id: &str) -> Redirect {
Redirect::to(format!("/dashboard/{}/todos", id))
}
#[get("/")] #[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage { pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {

View File

@ -2,14 +2,11 @@ pub mod dashboard;
pub mod login; pub mod login;
pub mod report; pub mod report;
use std::{collections::HashMap, net::IpAddr}; use std::collections::HashMap;
use log::warn;
use rocket::{get, request::FlashMessage, serde::json::Value as JsonValue}; use rocket::{get, request::FlashMessage, serde::json::Value as JsonValue};
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use crate::metrics::REGISTRY;
pub type JsonResult = Result<JsonValue, JsonValue>; pub type JsonResult = Result<JsonValue, JsonValue>;
#[get("/")] #[get("/")]
@ -110,19 +107,3 @@ pub async fn help_iemanager() -> Template {
let map: HashMap<&str, String> = HashMap::new(); let map: HashMap<&str, String> = HashMap::new();
Template::render("support/iemanager", &map) Template::render("support/iemanager", &map)
} }
#[get("/metrics")]
pub async fn metrics(client_ip: IpAddr) -> String {
if !client_ip.is_loopback() {
String::new()
} else {
let encoder = prometheus::TextEncoder::new();
let res_custom = encoder.encode_to_string(&REGISTRY.gather());
res_custom.unwrap_or_else(|e| {
warn!("Error encoding metrics: {:?}", e);
String::new()
})
}
}

View File

@ -7,9 +7,8 @@ Type=simple
ExecStart=/usr/bin/reminder-rs ExecStart=/usr/bin/reminder-rs
WorkingDirectory=/etc/reminder-rs WorkingDirectory=/etc/reminder-rs
Restart=always Restart=always
RestartSec=10 RestartSec=4
Environment="RUST_LOG=warn,rocket=info,reminder_rs=debug,postman=debug" Environment="reminder_rs=warn,postman=warn"
WatchdogSec=120
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -4,7 +4,6 @@
<html lang="EN"> <html lang="EN">
<head> <head>
<meta name="description" content="The most powerful Discord Reminders Bot"> <meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="keywords" content="discord,discord bot,reminders,reminders bot,discord reminders,discord automation,discord messages">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90" /> <meta name="yandex-verification" content="bb77b8681eb64a90" />

View File

@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<script src="/static/js/reporter.js" type="application/javascript"></script>
<meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32"
href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16"
href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Dashboard</title>
<!-- styles -->
<link rel="stylesheet" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css?v{{ version }}">
<link rel="stylesheet" href="/static/css/dtsel.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="/static/js/luxon.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.min.js" integrity="sha512-KJYWC7RKz/Abtsu1QXd7VJ1IJua7P7GTpl3IKUqfa21Otg2opvRYmkui/CXBC6qeDYCNlQZ7c+7JfDXnKdILUA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<nav class="navbar is-spaced is-size-4 is-hidden-desktop dashboard-navbar" role="navigation"
aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<figure class="image">
<img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo">
</figure>
</a>
<p class="navbar-item pageTitle">
</p>
<a role="button" class="dashboard-burger navbar-burger is-right" aria-label="menu" aria-expanded="false"
data-target="mobileSidebar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
</nav>
<div id="loader" class="is-hidden hero is-fullheight">
<div class="hero-body">
<div class="container has-text-centered">
<p class="title">
<i class="fas fa-cog fa-spin"></i>
</p>
<p class="subtitle">
<strong>Loading...</strong>
</p>
</div>
</div>
</div>
<!-- dead image used to check which other images are dead -->
<img src="" id="dead">
<div class="notification is-danger flash-message" id="errors">
<span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span>
</div>
<div class="notification is-success flash-message" id="success">
<span class="icon"><i class="far fa-check"></i></span> <span class="success-message"></span>
</div>
<div class="modal" id="addImageModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Enter Image URL</label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<input class="input" id="urlInput" placeholder="Image URL...">
</section>
<footer class="modal-card-foot">
<button class="button is-success" id="setImgUrl">Save</button>
<button class="button close-modal">Cancel</button>
</footer>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="pickColorModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="colorInput">Select Color</label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="colorpicker-container">
<div id="colorpicker"></div>
</div>
<input class="input" id="colorInput">
</section>
<footer class="modal-card-foot">
<button class="button is-success">Save</button>
<button class="button close-modal">Cancel</button>
</footer>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="chooseTimezoneModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Update Timezone <a href="/help/timezone"><span><i class="fa fa-question-circle"></i></span></a></label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<p>
Your configured timezone is: <strong><span class="set-timezone">%browsertimezone%</span></strong> (<span class="set-time">HH:mm</span>)
<br>
<br>
Your browser timezone is: <strong><span class="browser-timezone">%browsertimezone%</span></strong> (<span class="browser-time">HH:mm</span>)
<br>
Your bot timezone is: <strong><span class="bot-timezone">%bottimezone%</span></strong> (<span class="bot-time">HH:mm</span>)
</p>
<br>
<div class="has-text-centered">
<button class="button is-success close-modal" id="set-browser-timezone">Use Browser Timezone</button>
<button class="button is-link close-modal" id="set-bot-timezone">Use Bot Timezone</button>
<button class="button is-warning close-modal" id="update-bot-timezone">Set Bot Timezone</button>
</div>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="chooseTemplateModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Load Template</label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="templateSelect">
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-file-spreadsheet"></i>
</div>
</div>
<br>
<div class="has-text-centered">
<button class="button is-success close-modal" id="load-template">Load Template</button>
<button class="button is-danger" id="delete-template">Delete</button>
</div>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="dataManagerModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Import/Export Manager <a href="/help/iemanager"><span><i class="fa fa-question-circle"></i></span></a></label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="control">
<div class="field">
<label>
<input type="radio" class="default-width" name="exportSelect" value="reminders" checked>
Reminders
</label>
</div>
</div>
<br>
<div class="has-text-centered">
<div style="color: red">
Please first read the <a href="/help/iemanager">support page</a>
</div>
<button class="button is-success is-outlined" id="import-data">Import Data</button>
<button class="button is-success" id="export-data">Export Data</button>
</div>
<a id="downloader" download="export.csv" class="is-hidden"></a>
<input id="uploader" type="file" hidden></input>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="deleteReminderModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title">Delete Reminder</label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<p>
This reminder will be permanently deleted. Are you sure?
</p>
<br>
<div class="has-text-centered">
<button class="button is-danger" id="delete-reminder-confirm">Delete</button>
<button class="button is-light close-modal">Cancel</button>
</div>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="columns is-gapless dashboard-frame">
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">
<a href="/">
<div class="brand">
<img src="/static/img/logo_nobg.webp" alt="Reminder bot logo"
width="52px" height="52px"
class="dashboard-brand">
</div>
</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 160">
<g transform="scale(1, 0.5)">
<path fill="#8fb677" fill-opacity="1"
d="M0,192L60,170.7C120,149,240,107,360,96C480,85,600,107,720,138.7C840,171,960,213,1080,197.3C1200,181,1320,107,1380,69.3L1440,32L1440,0L1380,0C1320,0,1200,0,1080,0C960,0,840,0,720,0C600,0,480,0,360,0C240,0,120,0,60,0L0,0Z"></path>
</g>
</svg>
<aside class="menu">
<p class="menu-label">
Servers
</p>
<ul class="menu-list guildList">
</ul>
<div class="aside-footer">
<p class="menu-label">
Options
</p>
<ul class="menu-list">
<li>
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
<a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a>
<a href="/login/discord/logout">
<span class="icon"><i class="fas fa-sign-out"></i></span> Log out
</a>
<a href="https://discord.jellywx.com" class="feedback">
<span class="icon"><i class="fab fa-discord"></i></span> Give feedback
</a>
</li>
</ul>
</div>
</aside>
</div>
<div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
<a href="/">
<div class="brand">
<img src="/static/img/logo_nobg.webp" alt="Reminder bot logo"
class="dashboard-brand">
</div>
</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 160">
<g transform="scale(1, 0.5)">
<path fill="#8fb677" fill-opacity="1"
d="M0,192L60,170.7C120,149,240,107,360,96C480,85,600,107,720,138.7C840,171,960,213,1080,197.3C1200,181,1320,107,1380,69.3L1440,32L1440,0L1380,0C1320,0,1200,0,1080,0C960,0,840,0,720,0C600,0,480,0,360,0C240,0,120,0,60,0L0,0Z"></path>
</g>
</svg>
<aside class="menu">
<p class="menu-label">
Servers
</p>
<ul class="menu-list guildList">
</ul>
<div class="aside-footer">
<p class="menu-label">
Settings
</p>
<ul class="menu-list">
<li>
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
<a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a>
<a href="/login/discord/logout">
<span class="icon"><i class="fas fa-sign-out"></i></span> Log out
</a>
<a href="https://discord.jellywx.com/" class="feedback">
<span class="icon"><i class="fab fa-discord"></i></span> Give feedback
</a>
</li>
</ul>
</div>
</aside>
</div>
<!-- main content -->
<div class="column is-main-content">
<p class="title pageTitle"></p>
<section id="welcome">
<div class="has-text-centered">
<p class="title">Welcome!</p>
<p class="subtitle is-hidden-touch">Select an option from the side to get started</p>
<p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
</div>
</section>
<section id="reminders" class="is-hidden">
{% include "reminder_dashboard/reminder_dashboard" %}
</section>
<section id="reminder-errors" class="is-hidden">
{% include "reminder_dashboard/reminder_errors" %}
</section>
<section id="guild-error" class="is-hidden">
{% include "reminder_dashboard/guild_error" %}
</section>
<section id="user-error" class="is-hidden">
{% include "reminder_dashboard/user_error" %}
</section>
</div>
<!-- /main content -->
</div>
<template id="embedFieldTemplate">
<div data-inlined="1" class="embed-field-box">
<div class="is-flex">
<label>
<span class="is-sr-only">Field Title</span>
<textarea class="discord-field-title field-input message-input autoresize"
placeholder="Field Title..." rows="1"
maxlength="256" name="embed_field_title[]"></textarea>
</label>
<button class="button is-small inline-btn">
<span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i>
</button>
</div>
<label>
<span class="is-sr-only">Field Value</span>
<textarea
class="discord-field-value field-input message-input autoresize"
placeholder="Field Value..."
maxlength="1024" name="embed_field_value[]"
rows="1"></textarea>
</label>
</div>
</template>
<template id="guildListEntry">
<li>
<a class="switch-pane" data-pane="guild">
<span class="icon"><i class="fas fa-map-pin"></i></span> <span class="guild-name">%guildname%</span>
</a>
</li>
</template>
<template id="guildReminder">
{% include "reminder_dashboard/guild_reminder" %}
</template>
<script src="/static/js/iro.js"></script>
<script src="/static/js/dtsel.js"></script>
<script src="/static/js/interval.js?v{{ version }}"></script>
<script src="/static/js/timezone.js?v{{ version }}" defer></script>
<script src="/static/js/main.js?v{{ version }}" defer></script>
</body>
</html>

View File

@ -0,0 +1,17 @@
<div class="hero is-fullheight">
<div class="hero-body">
<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.
</p>
<a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
<p class="is-size-4">
<span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</div>
</div>

View File

@ -0,0 +1,269 @@
<div class="reminderContent {% if creating %}creator{% endif %}">
<div class="columns is-mobile column reminder-topbar">
{% if not creating %}
<div class="invert-collapses channel-bar">
#channel
</div>
{% endif %}
<div class="name-bar">
<div class="field">
<div class="control">
<label class="label sr-only">Reminder Name</label>
<input class="input" type="text" name="name" placeholder="Reminder Name" maxlength="100">
</div>
</div>
</div>
<div class="hide-button-bar">
<button class="button hide-box">
<span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<div class="columns reminder-settings">
<div class="column discord-frame">
<article class="media">
<figure class="media-left">
<p class="image is-32x32 customizable">
<a>
<img class="is-rounded avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
</a>
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="discord-message-header">
<label class="is-sr-only">Username Override</label>
<input class="discord-username message-input" placeholder="Username Override"
maxlength="32" name="username">
</div>
<label class="is-sr-only">Message</label>
<textarea class="message-input autoresize discord-content"
placeholder="Message Content..."
maxlength="2000" name="content" rows="1"></textarea>
<div class="discord-embed">
<div class="embed-body">
<button class="change-color button is-rounded is-small">
<span class="is-sr-only">Choose embed color</span><i class="fas fa-eye-dropper"></i>
</button>
<div class="a">
<div class="embed-author-box">
<div class="a">
<p class="image is-24x24 customizable">
<a>
<img class="is-rounded embed_author_url" src="/static/img/bg.webp" alt="Image for embed author">
</a>
</p>
</div>
<div class="b">
<label class="is-sr-only" for="embedAuthor">Embed Author</label>
<textarea
class="discord-embed-author message-input autoresize"
placeholder="Embed Author..." rows="1" maxlength="256"
name="embed_author"></textarea>
</div>
</div>
<label class="is-sr-only" for="embedTitle">Embed Title</label>
<textarea class="discord-title message-input autoresize"
placeholder="Embed Title..."
maxlength="256" rows="1"
name="embed_title"></textarea>
<br>
<label class="is-sr-only" for="embedDescription">Embed Description</label>
<textarea class="discord-description message-input autoresize "
placeholder="Embed Description..."
maxlength="4096" name="embed_description"
rows="1"></textarea>
<br>
<div class="embed-multifield-box">
<div data-inlined="1" class="embed-field-box">
<label class="is-sr-only" for="embedFieldTitle">Field Title</label>
<div class="is-flex">
<textarea class="discord-field-title field-input message-input autoresize"
placeholder="Field Title..." rows="1"
maxlength="256" name="embed_field_title[]"></textarea>
<button class="button is-small inline-btn">
<span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i>
</button>
</div>
<label class="is-sr-only" for="embedFieldValue">Field Value</label>
<textarea
class="discord-field-value field-input message-input autoresize "
placeholder="Field Value..."
maxlength="1024" name="embed_field_value[]"
rows="1"></textarea>
</div>
</div>
</div>
<div class="b">
<p class="image thumbnail customizable">
<a>
<img class="embed_thumbnail_url" src="/static/img/bg.webp" alt="Square thumbnail embedded image">
</a>
</p>
</div>
</div>
<p class="image is-400x300 customizable">
<a>
<img class="embed_image_url" src="/static/img/bg.webp" alt="Large embedded image">
</a>
</p>
<div class="embed-footer-box">
<p class="image is-20x20 customizable">
<a>
<img class="is-rounded embed_footer_url" src="/static/img/bg.webp" alt="Footer profile-like image">
</a>
</p>
<label class="is-sr-only" for="embedFooter">Embed Footer text</label>
<textarea class="discord-embed-footer message-input autoresize "
placeholder="Embed Footer..."
maxlength="2048" name="embed_footer" rows="1"></textarea>
</div>
</div>
</div>
</div>
</article>
</div>
<div class="column settings">
<div class="field channel-field">
<div class="collapses">
<label class="label" for="channelOption">Channel*</label>
</div>
<div class="control has-icons-left">
<div class="select">
<select name="channel" class="channel-selector">
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hashtag"></i>
</div>
</div>
</div>
<div class="field">
<div class="control">
<label class="label collapses">
Time*
<input class="input prefill-now" type="datetime-local" step="1" name="time">
</label>
</div>
</div>
<div class="collapses split-controls">
<div>
<div class="patreon-only">
<div class="patreon-invert foreground">
Intervals available on <a href="https://patreon.com/jellywx">Patreon</a> or <a href="https://gitea.jellypro.xyz/jude/reminder-bot">self-hosting</a>
</div>
<div class="field">
<label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
<div class="control intervalSelector">
<div class="input interval-group">
<div class="interval-group-left">
<span class="no-break">
<label>
<span class="is-sr-only">Interval months</span>
<input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
</label>
<label>
<span class="is-sr-only">Interval days</span>
<input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
</label>
</span>
<span class="no-break">
<label>
<span class="is-sr-only">Interval hours</span>
<input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
</label>
<label>
<span class="is-sr-only">Interval minutes</span>
<input class="w2" type="text" pattern="\d*" name="interval_minutes" maxlength="2" placeholder="MM">:
</label>
<label>
<span class="is-sr-only">Interval seconds</span>
<input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
</label>
</span>
</div>
<button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
</div>
</div>
</div>
<div class="field">
<div class="control">
<label class="label">
Expiration
<input class="input" type="datetime-local" step="1" name="expiration">
</label>
</div>
</div>
</div>
<div class="columns is-mobile tts-row">
<div class="column has-text-centered">
<div class="is-boxed">
<label class="label">Enable TTS <input type="checkbox" name="tts"></label>
</div>
</div>
<div class="column has-text-centered">
<div class="file is-small is-boxed">
<label class="file-label">
<input class="file-input" type="file" name="attachment">
<span class="file-cta">
<span class="file-label">
Add Attachment
</span>
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% if creating %}
<div class="button-row">
<div class="button-row-reminder">
<button class="button is-success" id="createReminder">
<span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
</button>
</div>
<div class="button-row-template">
<div>
<button class="button is-success is-outlined" id="createTemplate">
<span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
</button>
</div>
<div>
<button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
Load Template
</button>
</div>
</div>
</div>
{% else %}
<div class="button-row-edit">
<button class="button is-success save-btn">
<span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
</button>
<button class="button is-warning disable-enable">
</button>
<button class="button is-danger delete-reminder">
Delete
</button>
</div>
{% endif %}
</div>

View File

@ -0,0 +1,52 @@
<div class="create-reminder">
<strong>Create Reminder</strong>
<div id="reminderCreator">
{% set creating = true %}
{% include "reminder_dashboard/guild_reminder" %}
{% set creating = false %}
</div>
<br>
<div class="field">
<div class="columns is-mobile">
<div class="column">
<strong>Reminders</strong>
</div>
<div class="column is-narrow">
<div class="control has-icons-left">
<div class="select is-small">
<select id="orderBy">
<option value="time" selected>Time</option>
<option value="name">Name</option>
<option value="channel">Channel</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-sort-amount-down"></i>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="control has-icons-left">
<div class="select is-small">
<select id="expandAll">
<option value="" selected></option>
<option value="expand">Expand All</option>
<option value="collapse">Collapse All</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-expand-arrows"></i>
</div>
</div>
</div>
</div>
</div>
<div id="guildReminders">
</div>
</div>
<script src="/static/js/sort.js"></script>
<script src="/static/js/expand.js"></script>

View File

@ -0,0 +1,5 @@
<div>
</div>
<!--<script src="/static/js/reminder_errors.js"></script>-->

View File

@ -0,0 +1,12 @@
<div class="hero is-fullheight">
<div class="hero-body">
<div class="container has-text-centered">
<p class="title">
You do not have permissions for this server
</p>
<p class="subtitle">
Ask an admin to grant you the "Manage Messages" permission.
</p>
</div>
</div>
</div>