Compare commits
	
		
			10 Commits
		
	
	
		
			218be2f0b1
			...
			jude/orpha
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 5f703e8538 | ||
|  | 2993505a47 | ||
|  | b225ad7e45 | ||
|  | ee89cb40c5 | ||
|  | b6b5e6d2b2 | ||
|  | adf29dca5d | ||
|  | ea3fe3f543 | ||
|  | 109cf16dbb | ||
|  | 6726ca0c2d | ||
| 38133be15d | 
							
								
								
									
										29
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,30 +1,5 @@ | |||||||
| target | /target | ||||||
| .env | .env | ||||||
| /venv | /venv | ||||||
| .cargo | .cargo | ||||||
| .idea | /.idea | ||||||
| web/static/index.html |  | ||||||
| web/static/assets |  | ||||||
| # Logs |  | ||||||
| logs |  | ||||||
| *.log |  | ||||||
| npm-debug.log* |  | ||||||
| yarn-debug.log* |  | ||||||
| yarn-error.log* |  | ||||||
| pnpm-debug.log* |  | ||||||
| lerna-debug.log* |  | ||||||
|  |  | ||||||
| node_modules |  | ||||||
| dist |  | ||||||
| dist-ssr |  | ||||||
| *.local |  | ||||||
|  |  | ||||||
| # Editor directories and files |  | ||||||
| .vscode/* |  | ||||||
| !.vscode/extensions.json |  | ||||||
| .DS_Store |  | ||||||
| *.suo |  | ||||||
| *.ntvs* |  | ||||||
| *.njsproj |  | ||||||
| *.sln |  | ||||||
| *.sw? |  | ||||||
|   | |||||||
							
								
								
									
										2829
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2829
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										47
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,21 +1,22 @@ | |||||||
| [package] | [package] | ||||||
| name = "reminder-rs" | name = "reminder-rs" | ||||||
| version = "1.7.23" | version = "1.6.38" | ||||||
| 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" | ||||||
| description = "Reminder Bot for Discord, now in Rust" | description = "Reminder Bot for Discord, now in Rust" | ||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| poise = "0.6.1" | poise = "0.5.5" | ||||||
| dotenv = "0.15" | dotenv = "0.15" | ||||||
| tokio = { version = "1", features = ["process", "full"] } | tokio = { version = "1", features = ["process", "full"] } | ||||||
| reqwest = { version = "0.12", features = ["json"] } | reqwest = "0.11" | ||||||
| regex = "1.10" | lazy-regex = "2.3.0" | ||||||
|  | regex = "1.6" | ||||||
| log = "0.4" | log = "0.4" | ||||||
| env_logger = "0.11" | env_logger = "0.10" | ||||||
| chrono = "0.4" | chrono = "0.4" | ||||||
| chrono-tz = { version = "0.9", features = ["serde"] } | chrono-tz = { version = "0.8", features = ["serde"] } | ||||||
| lazy_static = "1.4" | lazy_static = "1.4" | ||||||
| num-integer = "0.1" | num-integer = "0.1" | ||||||
| serde = "1.0" | serde = "1.0" | ||||||
| @@ -24,23 +25,14 @@ serde_repr = "0.1" | |||||||
| rmp-serde = "1.1" | rmp-serde = "1.1" | ||||||
| rand = "0.8" | rand = "0.8" | ||||||
| levenshtein = "1.0" | levenshtein = "1.0" | ||||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] } | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||||
| base64 = "0.22" | base64 = "0.21.0" | ||||||
| secrecy = "0.8.0" |  | ||||||
| futures = "0.3.30" |  | ||||||
| prometheus = "0.13.3" |  | ||||||
| rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] } |  | ||||||
| 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"] } |  | ||||||
| oauth2 = "4" |  | ||||||
| csv = "1.2" |  | ||||||
| sd-notify = "0.4.1" |  | ||||||
|  |  | ||||||
| [dependencies.extract_derive] | [dependencies.postman] | ||||||
| path = "extract_derive" | path = "postman" | ||||||
|  |  | ||||||
| [dependencies.recordable_derive] | [dependencies.reminder_web] | ||||||
| path = "recordable_derive" | path = "web" | ||||||
|  |  | ||||||
| [package.metadata.deb] | [package.metadata.deb] | ||||||
| depends = "$auto, python3-dateparser (>= 1.0.0)" | depends = "$auto, python3-dateparser (>= 1.0.0)" | ||||||
| @@ -48,17 +40,12 @@ suggests = "mysql-server-8.0, nginx" | |||||||
| maintainer-scripts = "debian" | maintainer-scripts = "debian" | ||||||
| assets = [ | assets = [ | ||||||
|     ["target/release/reminder-rs", "usr/bin/reminder-rs", "755"], |     ["target/release/reminder-rs", "usr/bin/reminder-rs", "755"], | ||||||
|     ["static/css/*", "lib/reminder-rs/static/css", "644"], |  | ||||||
|     ["static/favicon/*", "lib/reminder-rs/static/favicon", "644"], |  | ||||||
|     ["static/img/*", "lib/reminder-rs/static/img", "644"], |  | ||||||
|     ["static/js/*", "lib/reminder-rs/static/js", "644"], |  | ||||||
|     ["static/webfonts/*", "lib/reminder-rs/static/webfonts", "644"], |  | ||||||
|     ["static/site.webmanifest", "lib/reminder-rs/static/site.webmanifest", "644"], |  | ||||||
|     ["templates/**/*", "lib/reminder-rs/templates", "644"], |  | ||||||
|     ["reminder-dashboard/dist/static/assets/*", "lib/reminder-rs/static/assets", "644"], |  | ||||||
|     ["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"], |  | ||||||
|     ["conf/default.env", "etc/reminder-rs/config.env", "600"], |     ["conf/default.env", "etc/reminder-rs/config.env", "600"], | ||||||
|     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], |     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], | ||||||
|  |     ["web/static/**/*", "lib/reminder-rs/static", "644"], | ||||||
|  |     ["web/templates/**/*", "lib/reminder-rs/templates", "644"], | ||||||
|  |     ["healthcheck", "lib/reminder-rs/healthcheck", "755"], | ||||||
|  |     ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"], | ||||||
| #    ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] | #    ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] | ||||||
| ] | ] | ||||||
| conf-files = [ | conf-files = [ | ||||||
|   | |||||||
| @@ -4,6 +4,6 @@ ENV RUSTUP_HOME=/usr/local/rustup \ | |||||||
|     CARGO_HOME=/usr/local/cargo \ |     CARGO_HOME=/usr/local/cargo \ | ||||||
|     PATH=/usr/local/cargo/bin:$PATH |     PATH=/usr/local/cargo/bin:$PATH | ||||||
|  |  | ||||||
| RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0 npm | RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0 | ||||||
| RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly | ||||||
| RUN cargo install cargo-deb | RUN cargo install cargo-deb | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								Rocket.toml
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								Rocket.toml
									
									
									
									
									
								
							| @@ -1,28 +1,28 @@ | |||||||
| [default] | [default] | ||||||
| address = "0.0.0.0" | address = "0.0.0.0" | ||||||
| port = 18920 | port = 18920 | ||||||
| template_dir = "templates" | template_dir = "web/templates" | ||||||
| limits = { json = "10MiB" } | limits = { json = "10MiB" } | ||||||
|  |  | ||||||
| [debug] | [debug] | ||||||
| secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY=" | secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY=" | ||||||
|  |  | ||||||
| [debug.tls] | [debug.tls] | ||||||
| certs = "private/rsa_sha256_cert.pem" | certs = "web/private/rsa_sha256_cert.pem" | ||||||
| key = "private/rsa_sha256_key.pem" | key = "web/private/rsa_sha256_key.pem" | ||||||
|  |  | ||||||
| [debug.rsa_sha256.tls] | [debug.rsa_sha256.tls] | ||||||
| certs = "private/rsa_sha256_cert.pem" | certs = "web/private/rsa_sha256_cert.pem" | ||||||
| key = "private/rsa_sha256_key.pem" | key = "web/private/rsa_sha256_key.pem" | ||||||
|  |  | ||||||
| [debug.ecdsa_nistp256_sha256.tls] | [debug.ecdsa_nistp256_sha256.tls] | ||||||
| certs = "private/ecdsa_nistp256_sha256_cert.pem" | certs = "web/private/ecdsa_nistp256_sha256_cert.pem" | ||||||
| key = "private/ecdsa_nistp256_sha256_key_pkcs8.pem" | key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem" | ||||||
|  |  | ||||||
| [debug.ecdsa_nistp384_sha384.tls] | [debug.ecdsa_nistp384_sha384.tls] | ||||||
| certs = "private/ecdsa_nistp384_sha384_cert.pem" | certs = "web/private/ecdsa_nistp384_sha384_cert.pem" | ||||||
| key = "private/ecdsa_nistp384_sha384_key_pkcs8.pem" | key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem" | ||||||
|  |  | ||||||
| [debug.ed25519.tls] | [debug.ed25519.tls] | ||||||
| certs = "private/ed25519_cert.pem" | certs = "web/private/ed25519_cert.pem" | ||||||
| key = "private/ed25519_key.pem" | key = "eb/private/ed25519_key.pem" | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								build.rs
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								build.rs
									
									
									
									
									
								
							| @@ -1,13 +1,3 @@ | |||||||
| use std::{path::Path, process::Command}; |  | ||||||
|  |  | ||||||
| fn main() { | fn main() { | ||||||
|     println!("cargo:rerun-if-changed=migrations"); |     println!("cargo:rerun-if-changed=migrations"); | ||||||
|     println!("cargo:rerun-if-changed=reminder-dashboard"); |  | ||||||
|  |  | ||||||
|     Command::new("npm") |  | ||||||
|         .arg("run") |  | ||||||
|         .arg("build") |  | ||||||
|         .current_dir(Path::new("reminder-dashboard")) |  | ||||||
|         .spawn() |  | ||||||
|         .expect("Failed to build NPM"); |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								extract_derive/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										46
									
								
								extract_derive/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,46 +0,0 @@ | |||||||
| # This file is automatically @generated by Cargo. |  | ||||||
| # It is not intended for manual editing. |  | ||||||
| version = 3 |  | ||||||
|  |  | ||||||
| [[package]] |  | ||||||
| name = "extract_macro" |  | ||||||
| version = "0.1.0" |  | ||||||
| dependencies = [ |  | ||||||
|  "quote", |  | ||||||
|  "syn", |  | ||||||
| ] |  | ||||||
|  |  | ||||||
| [[package]] |  | ||||||
| name = "proc-macro2" |  | ||||||
| version = "1.0.78" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" |  | ||||||
| dependencies = [ |  | ||||||
|  "unicode-ident", |  | ||||||
| ] |  | ||||||
|  |  | ||||||
| [[package]] |  | ||||||
| name = "quote" |  | ||||||
| version = "1.0.35" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" |  | ||||||
| dependencies = [ |  | ||||||
|  "proc-macro2", |  | ||||||
| ] |  | ||||||
|  |  | ||||||
| [[package]] |  | ||||||
| name = "syn" |  | ||||||
| version = "2.0.49" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496" |  | ||||||
| dependencies = [ |  | ||||||
|  "proc-macro2", |  | ||||||
|  "quote", |  | ||||||
|  "unicode-ident", |  | ||||||
| ] |  | ||||||
|  |  | ||||||
| [[package]] |  | ||||||
| name = "unicode-ident" |  | ||||||
| version = "1.0.12" |  | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" |  | ||||||
| checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" |  | ||||||
| @@ -1,11 +0,0 @@ | |||||||
| [package] |  | ||||||
| name = "extract_derive" |  | ||||||
| version = "0.1.0" |  | ||||||
| edition = "2021" |  | ||||||
|  |  | ||||||
| [lib] |  | ||||||
| proc-macro = true |  | ||||||
|  |  | ||||||
| [dependencies] |  | ||||||
| quote = "1.0.35" |  | ||||||
| syn = { version = "2.0.49", features = ["full"] } |  | ||||||
| @@ -1,53 +0,0 @@ | |||||||
| use proc_macro::TokenStream; |  | ||||||
| use syn::{spanned::Spanned, Data, Fields}; |  | ||||||
|  |  | ||||||
| #[proc_macro_derive(Extract)] |  | ||||||
| pub fn extract(input: TokenStream) -> TokenStream { |  | ||||||
|     let ast = syn::parse_macro_input!(input); |  | ||||||
|  |  | ||||||
|     impl_extract(&ast) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fn impl_extract(ast: &syn::DeriveInput) -> TokenStream { |  | ||||||
|     let name = &ast.ident; |  | ||||||
|  |  | ||||||
|     match &ast.data { |  | ||||||
|         // Dispatch over struct: extract args directly from context |  | ||||||
|         Data::Struct(st) => match &st.fields { |  | ||||||
|             Fields::Named(fields) => { |  | ||||||
|                 let extracted = fields.named.iter().map(|field| { |  | ||||||
|                     let ident = &field.ident; |  | ||||||
|                     let ty = &field.ty; |  | ||||||
|  |  | ||||||
|                     quote::quote_spanned! {field.span()=> |  | ||||||
|                         #ident : crate::utils::extract_arg!(ctx, #ident, #ty) |  | ||||||
|                     } |  | ||||||
|                 }); |  | ||||||
|  |  | ||||||
|                 TokenStream::from(quote::quote! { |  | ||||||
|                     impl Extract for #name { |  | ||||||
|                         fn extract(ctx: crate::ApplicationContext) -> Self { |  | ||||||
|                             Self { |  | ||||||
|                                 #(#extracted,)* |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 }) |  | ||||||
|             } |  | ||||||
|             Fields::Unit => TokenStream::from(quote::quote! { |  | ||||||
|                 impl Extract for #name { |  | ||||||
|                     fn extract(ctx: crate::ApplicationContext) -> Self { |  | ||||||
|                         Self {} |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             }), |  | ||||||
|             _ => { |  | ||||||
|                 panic!("Only named/unit structs can derive Extract"); |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|  |  | ||||||
|         _ => { |  | ||||||
|             panic!("Only structs can derive Extract"); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n') | ||||||
|  |  | ||||||
|  | REGEX='mysql://([A-Za-z]+)@(.+)/(.+)' | ||||||
|  | [[ $DATABASE_URL =~ $REGEX ]] | ||||||
|  |  | ||||||
|  | VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'") | ||||||
|  |  | ||||||
|  | if [ "$VAR" -gt 0 ] | ||||||
|  | then | ||||||
|  |   echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL" | ||||||
|  | fi | ||||||
							
								
								
									
										19
									
								
								migrations/20230812111348_orphan_reminders.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								migrations/20230812111348_orphan_reminders.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | -- Drop existing constraint | ||||||
|  | ALTER TABLE `reminders` DROP CONSTRAINT `reminders_ibfk_1`; | ||||||
|  |  | ||||||
|  | ALTER TABLE `reminders` MODIFY COLUMN `channel_id` INT UNSIGNED; | ||||||
|  | ALTER TABLE `reminders` ADD COLUMN `guild_id` INT UNSIGNED; | ||||||
|  |  | ||||||
|  | ALTER TABLE `reminders` | ||||||
|  |     ADD CONSTRAINT `guild_id_fk` | ||||||
|  |         FOREIGN KEY (`guild_id`) | ||||||
|  |         REFERENCES `guilds`(`id`) | ||||||
|  |         ON DELETE CASCADE; | ||||||
|  |  | ||||||
|  | ALTER TABLE `reminders` | ||||||
|  |     ADD CONSTRAINT `channel_id_fk` | ||||||
|  |         FOREIGN KEY (`channel_id`) | ||||||
|  |         REFERENCES `channels`(`id`) | ||||||
|  |         ON DELETE SET NULL; | ||||||
|  |  | ||||||
|  | UPDATE `reminders` SET `guild_id` = (SELECT guilds.`id` FROM `channels` INNER JOIN `guilds` ON channels.guild_id = guilds.id WHERE reminders.channel_id = channels.id); | ||||||
							
								
								
									
										4
									
								
								migrations/20230903131153_reminder_status_timing.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/20230903131153_reminder_status_timing.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | ALTER TABLE reminders ADD COLUMN `status_change_time` DATETIME; | ||||||
|  |  | ||||||
|  | -- This is a best-guess as to the status change time. | ||||||
|  | UPDATE reminders SET `status_change_time` = `utc_time`; | ||||||
| @@ -1,3 +0,0 @@ | |||||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED; |  | ||||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED; |  | ||||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED; |  | ||||||
| @@ -1,50 +0,0 @@ | |||||||
| CREATE TABLE command_macro ( |  | ||||||
|     id INT UNSIGNED AUTO_INCREMENT, |  | ||||||
|     guild_id INT UNSIGNED NOT NULL, |  | ||||||
|  |  | ||||||
|     name VARCHAR(100) NOT NULL, |  | ||||||
|     description VARCHAR(100), |  | ||||||
|     commands JSON NOT NULL, |  | ||||||
|  |  | ||||||
|     FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, |  | ||||||
|     PRIMARY KEY (id) |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| # New JSON structure is {command_name: "Remind", "<option name>": "<option value>", ...} |  | ||||||
| INSERT INTO command_macro (guild_id, description, name, commands) |  | ||||||
| SELECT |  | ||||||
|     guild_id, |  | ||||||
|     description, |  | ||||||
|     name, |  | ||||||
|     ( |  | ||||||
|         SELECT JSON_ARRAYAGG( |  | ||||||
|             ( |  | ||||||
|                 SELECT JSON_OBJECTAGG(t2.name, t2.value) |  | ||||||
|                 FROM JSON_TABLE( |  | ||||||
|                     JSON_ARRAY_APPEND(t1.options, '$', JSON_OBJECT('name', 'command_name', 'value', t1.command_name)), |  | ||||||
|                     '$[*]' COLUMNS ( |  | ||||||
|                         name VARCHAR(64) PATH '$.name' ERROR ON ERROR, |  | ||||||
|                         value TEXT PATH '$.value' ERROR ON ERROR |  | ||||||
|                     )) AS t2 |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         FROM macro m2 |  | ||||||
|         JOIN JSON_TABLE( |  | ||||||
|             commands, |  | ||||||
|             '$[*]' COLUMNS ( |  | ||||||
|                 command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR, |  | ||||||
|                 options JSON PATH '$.options' ERROR ON ERROR |  | ||||||
|             )) AS t1 |  | ||||||
|         WHERE m1.id = m2.id |  | ||||||
|     ) |  | ||||||
| FROM macro m1; |  | ||||||
|  |  | ||||||
| # # Check which commands are used in macros |  | ||||||
| # SELECT DISTINCT command_name |  | ||||||
| # FROM macro m2 |  | ||||||
| # JOIN JSON_TABLE( |  | ||||||
| #     commands, |  | ||||||
| #     '$[*]' COLUMNS ( |  | ||||||
| #         command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR, |  | ||||||
| #         options JSON PATH '$.options' ERROR ON ERROR |  | ||||||
| #     )) AS t1 |  | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| -- Add migration script here |  | ||||||
| ALTER TABLE reminders |  | ||||||
|     ADD INDEX `utc_time_index` (`utc_time`); |  | ||||||
| ALTER TABLE reminders |  | ||||||
|     ADD INDEX `status_index` (`status`); |  | ||||||
| @@ -1,53 +0,0 @@ | |||||||
| server { |  | ||||||
|     server_name www.reminder-bot.com; |  | ||||||
|  |  | ||||||
|     return 301 https://reminder-bot.com$request_uri; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| server { |  | ||||||
|     listen 80; |  | ||||||
|     server_name beta.reminder-bot.com; |  | ||||||
|  |  | ||||||
|     return 301 https://reminder-bot.com$request_uri; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| server { |  | ||||||
|     listen 443 ssl; |  | ||||||
|     server_name beta.reminder-bot.com; |  | ||||||
|  |  | ||||||
|     ssl_certificate /etc/letsencrypt/live/beta.reminder-bot.com/fullchain.pem; |  | ||||||
|     ssl_certificate_key /etc/letsencrypt/live/beta.reminder-bot.com/privkey.pem; |  | ||||||
|  |  | ||||||
|     return 301 https://reminder-bot.com$request_uri; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| server { |  | ||||||
|     listen 443 ssl; |  | ||||||
|     server_name reminder-bot.com; |  | ||||||
|  |  | ||||||
|     ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem; |  | ||||||
|     ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem; |  | ||||||
|  |  | ||||||
|     access_log /var/log/nginx/access.log; |  | ||||||
|     error_log /var/log/nginx/error.log; |  | ||||||
|  |  | ||||||
|     proxy_buffer_size 128k; |  | ||||||
|     proxy_buffers 4 256k; |  | ||||||
|     proxy_busy_buffers_size 256k; |  | ||||||
|  |  | ||||||
|     client_max_body_size 10M; |  | ||||||
|  |  | ||||||
|     location / { |  | ||||||
|         proxy_pass http://localhost:18920; |  | ||||||
|         proxy_redirect off; |  | ||||||
|         proxy_set_header Host $host; |  | ||||||
|         proxy_set_header X-Real-IP $remote_addr; |  | ||||||
|         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |  | ||||||
|         proxy_set_header X-Forwarded-Proto $scheme; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     location /static { |  | ||||||
|         alias /var/www/reminder-rs/static; |  | ||||||
|         expires 30d; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										41
									
								
								nginx/reminder-rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								nginx/reminder-rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | server { | ||||||
|  |         server_name www.reminder-bot.com; | ||||||
|  |  | ||||||
|  |         return 301 $scheme://reminder-bot.com$request_uri; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | server { | ||||||
|  |         listen 80; | ||||||
|  |         server_name reminder-bot.com; | ||||||
|  |  | ||||||
|  | 	    return 301 https://reminder-bot.com$request_uri; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | server { | ||||||
|  |         listen 443 ssl; | ||||||
|  |         server_name reminder-bot.com; | ||||||
|  |  | ||||||
|  |         ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem; | ||||||
|  |         ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem; | ||||||
|  |  | ||||||
|  |         access_log /var/log/nginx/access.log; | ||||||
|  |         error_log /var/log/nginx/error.log; | ||||||
|  |  | ||||||
|  |         proxy_buffer_size 128k; | ||||||
|  |         proxy_buffers 4 256k; | ||||||
|  |         proxy_busy_buffers_size 256k; | ||||||
|  |  | ||||||
|  |         location / { | ||||||
|  |                 proxy_pass http://localhost:18920; | ||||||
|  |                 proxy_redirect off; | ||||||
|  |                 proxy_set_header Host $host; | ||||||
|  |                 proxy_set_header X-Real-IP $remote_addr; | ||||||
|  |                 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||||||
|  | 		        proxy_set_header X-Forwarded-Proto $scheme; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         location /static { | ||||||
|  |                 alias /var/www/reminder-rs/static; | ||||||
|  |                 expires 30d; | ||||||
|  |         } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								postman/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								postman/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | [package] | ||||||
|  | name = "postman" | ||||||
|  | version = "0.1.0" | ||||||
|  | edition = "2021" | ||||||
|  |  | ||||||
|  | [dependencies] | ||||||
|  | tokio = { version = "1", features = ["process", "full"] } | ||||||
|  | regex = "1.4" | ||||||
|  | log = "0.4" | ||||||
|  | chrono = "0.4" | ||||||
|  | chrono-tz = { version = "0.5", features = ["serde"] } | ||||||
|  | lazy_static = "1.4" | ||||||
|  | num-integer = "0.1" | ||||||
|  | serde = "1.0" | ||||||
|  | sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | ||||||
|  | serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||||
| @@ -3,8 +3,7 @@ mod sender; | |||||||
| use std::env; | use std::env; | ||||||
| 
 | 
 | ||||||
| use log::{info, warn}; | use log::{info, warn}; | ||||||
| use poise::serenity_prelude::client::Context; | use serenity::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]); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,23 +1,22 @@ | |||||||
| use std::env; | use std::env; | ||||||
| 
 | 
 | ||||||
| use chrono::{DateTime, Days, Months, TimeDelta}; | use chrono::{DateTime, Days, Duration, Months}; | ||||||
| use chrono_tz::Tz; | use chrono_tz::Tz; | ||||||
| use lazy_static::lazy_static; | use lazy_static::lazy_static; | ||||||
| use log::{error, info, warn}; | use log::{error, info, warn}; | ||||||
| use num_integer::Integer; | use num_integer::Integer; | ||||||
| use poise::serenity_prelude::{ | use regex::{Captures, Regex}; | ||||||
|     all::{CreateAttachment, CreateEmbedFooter}, | use serde::Deserialize; | ||||||
|     builder::{CreateEmbed, CreateEmbedAuthor, CreateMessage, ExecuteWebhook}, | use serenity::{ | ||||||
|  |     builder::CreateEmbed, | ||||||
|     http::{CacheHttp, Http, HttpError}, |     http::{CacheHttp, Http, HttpError}, | ||||||
|     model::{ |     model::{ | ||||||
|         channel::Channel, |         channel::{Channel, Embed as SerenityEmbed}, | ||||||
|         id::{ChannelId, MessageId}, |         id::ChannelId, | ||||||
|         webhook::Webhook, |         webhook::Webhook, | ||||||
|     }, |     }, | ||||||
|     Error, Result, |     Error, Result, | ||||||
| }; | }; | ||||||
| use regex::{Captures, Regex}; |  | ||||||
| use serde::Deserialize; |  | ||||||
| use sqlx::{ | use sqlx::{ | ||||||
|     types::{ |     types::{ | ||||||
|         chrono::{NaiveDateTime, Utc}, |         chrono::{NaiveDateTime, Utc}, | ||||||
| @@ -26,10 +25,7 @@ use sqlx::{ | |||||||
|     Executor, |     Executor, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::Database; | ||||||
|     metrics::{REMINDER_COUNTER, REMINDER_FAIL_COUNTER}, |  | ||||||
|     Database, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| lazy_static! { | lazy_static! { | ||||||
|     pub static ref TIMEFROM_REGEX: Regex = |     pub static ref TIMEFROM_REGEX: Regex = | ||||||
| @@ -69,15 +65,15 @@ pub fn substitute(string: &str) -> String { | |||||||
|         let format = caps.name("format").map(|m| m.as_str()); |         let format = caps.name("format").map(|m| m.as_str()); | ||||||
| 
 | 
 | ||||||
|         if let (Some(final_time), Some(format)) = (final_time, format) { |         if let (Some(final_time), Some(format)) = (final_time, format) { | ||||||
|             match DateTime::from_timestamp(final_time, 0) { |             match NaiveDateTime::from_timestamp_opt(final_time, 0) { | ||||||
|                 Some(dt) => { |                 Some(dt) => { | ||||||
|                     let now = Utc::now(); |                     let now = Utc::now().naive_utc(); | ||||||
| 
 | 
 | ||||||
|                     let difference = { |                     let difference = { | ||||||
|                         if now < dt { |                         if now < dt { | ||||||
|                             dt - Utc::now() |                             dt - Utc::now().naive_utc() | ||||||
|                         } else { |                         } else { | ||||||
|                             Utc::now() - dt |                             Utc::now().naive_utc() - dt | ||||||
|                         } |                         } | ||||||
|                     }; |                     }; | ||||||
| 
 | 
 | ||||||
| @@ -198,36 +194,43 @@ impl Embed { | |||||||
| 
 | 
 | ||||||
| impl Into<CreateEmbed> for Embed { | impl Into<CreateEmbed> for Embed { | ||||||
|     fn into(self) -> CreateEmbed { |     fn into(self) -> CreateEmbed { | ||||||
|         let mut author = CreateEmbedAuthor::new(&self.author); |         let mut c = CreateEmbed::default(); | ||||||
|         if let Some(author_icon) = &self.author_url { |  | ||||||
|             author = author.icon_url(author_icon); |  | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
|         let mut footer = CreateEmbedFooter::new(&self.footer); |         c.title(&self.title) | ||||||
|         if let Some(footer_icon) = &self.footer_url { |  | ||||||
|             footer = footer.icon_url(footer_icon); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let mut embed = CreateEmbed::default() |  | ||||||
|             .title(&self.title) |  | ||||||
|             .description(&self.description) |             .description(&self.description) | ||||||
|             .color(self.color) |             .color(self.color) | ||||||
|             .author(author) |             .author(|a| { | ||||||
|             .footer(footer); |                 a.name(&self.author); | ||||||
|  | 
 | ||||||
|  |                 if let Some(author_icon) = &self.author_url { | ||||||
|  |                     a.icon_url(author_icon); | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 a | ||||||
|  |             }) | ||||||
|  |             .footer(|f| { | ||||||
|  |                 f.text(&self.footer); | ||||||
|  | 
 | ||||||
|  |                 if let Some(footer_icon) = &self.footer_url { | ||||||
|  |                     f.icon_url(footer_icon); | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 f | ||||||
|  |             }); | ||||||
| 
 | 
 | ||||||
|         for field in &self.fields.0 { |         for field in &self.fields.0 { | ||||||
|             embed = embed.field(&field.title, &field.value, field.inline); |             c.field(&field.title, &field.value, field.inline); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if let Some(image_url) = &self.image_url { |         if let Some(image_url) = &self.image_url { | ||||||
|             embed = embed.image(image_url); |             c.image(image_url); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if let Some(thumbnail_url) = &self.thumbnail_url { |         if let Some(thumbnail_url) = &self.thumbnail_url { | ||||||
|             embed = embed.thumbnail(thumbnail_url); |             c.thumbnail(thumbnail_url); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         embed |         c | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @@ -235,7 +238,6 @@ pub struct Reminder { | |||||||
|     id: u32, |     id: u32, | ||||||
| 
 | 
 | ||||||
|     channel_id: u64, |     channel_id: u64, | ||||||
|     thread_id: Option<u64>, |  | ||||||
|     webhook_id: Option<u64>, |     webhook_id: Option<u64>, | ||||||
|     webhook_token: Option<String>, |     webhook_token: Option<String>, | ||||||
| 
 | 
 | ||||||
| @@ -270,7 +272,6 @@ impl Reminder { | |||||||
|     reminders.`id` AS id, |     reminders.`id` AS id, | ||||||
| 
 | 
 | ||||||
|     channels.`channel` AS channel_id, |     channels.`channel` AS channel_id, | ||||||
|                 reminders.`thread_id` AS thread_id, |  | ||||||
|     channels.`webhook_id` AS webhook_id, |     channels.`webhook_id` AS webhook_id, | ||||||
|     channels.`webhook_token` AS webhook_token, |     channels.`webhook_token` AS webhook_token, | ||||||
| 
 | 
 | ||||||
| @@ -309,6 +310,7 @@ impl Reminder { | |||||||
|             reminders |             reminders | ||||||
|         WHERE |         WHERE | ||||||
|             reminders.`utc_time` <= NOW() AND |             reminders.`utc_time` <= NOW() AND | ||||||
|  |             reminders.`channel_id` IS NOT NULL AND | ||||||
|             `status` = 'pending' AND |             `status` = 'pending' AND | ||||||
|             ( |             ( | ||||||
|                 reminders.`interval_seconds` IS NOT NULL |                 reminders.`interval_seconds` IS NOT NULL | ||||||
| @@ -342,9 +344,7 @@ impl Reminder { | |||||||
| 
 | 
 | ||||||
|     async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { |     async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||||
|         let _ = sqlx::query!( |         let _ = sqlx::query!( | ||||||
|             " |             "UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?", | ||||||
|             UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ? |  | ||||||
|             ",
 |  | ||||||
|             self.channel_id |             self.channel_id | ||||||
|         ) |         ) | ||||||
|         .execute(pool) |         .execute(pool) | ||||||
| @@ -400,18 +400,13 @@ impl Reminder { | |||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 if let Some(interval) = self.interval_seconds { |                 if let Some(interval) = self.interval_seconds { | ||||||
|                     updated_reminder_time += TimeDelta::try_seconds(interval as i64) |                     updated_reminder_time += Duration::seconds(interval as i64); | ||||||
|                         .unwrap_or_else(|| { |  | ||||||
|                             warn!("{}: Could not add {} seconds to a reminder", self.id, interval); |  | ||||||
|                             fail_count += 1; |  | ||||||
| 
 |  | ||||||
|                             TimeDelta::zero() |  | ||||||
|                         }); |  | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             if fail_count >= 4 { |             if fail_count >= 4 { | ||||||
|                 self.log_error( |                 self.log_error( | ||||||
|  |                     pool, | ||||||
|                     "Failed to update 4 times and so is being deleted", |                     "Failed to update 4 times and so is being deleted", | ||||||
|                     None::<&'static str>, |                     None::<&'static str>, | ||||||
|                 ) |                 ) | ||||||
| @@ -434,7 +429,12 @@ impl Reminder { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async fn log_error(&self, error: &'static str, debug_info: Option<impl std::fmt::Debug>) { |     async fn log_error( | ||||||
|  |         &self, | ||||||
|  |         pool: impl Executor<'_, Database = Database> + Copy, | ||||||
|  |         error: &'static str, | ||||||
|  |         debug_info: Option<impl std::fmt::Debug>, | ||||||
|  |     ) { | ||||||
|         let message = match debug_info { |         let message = match debug_info { | ||||||
|             Some(info) => format!( |             Some(info) => format!( | ||||||
|                 "{}
 |                 "{}
 | ||||||
| @@ -445,27 +445,41 @@ impl Reminder { | |||||||
|             None => error.to_string(), |             None => error.to_string(), | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         REMINDER_FAIL_COUNTER |  | ||||||
|             .get_metric_with_label_values(&[ |  | ||||||
|                 self.id.to_string().as_str(), |  | ||||||
|                 self.channel_id.to_string().as_str(), |  | ||||||
|                 &message, |  | ||||||
|             ]) |  | ||||||
|             .map_or_else(|e| warn!("Couldn't count failed reminder: {:?}", e), |c| c.inc()); |  | ||||||
|         error!("[Reminder {}] {}", self.id, message); |         error!("[Reminder {}] {}", self.id, message); | ||||||
|  | 
 | ||||||
|  |         if *LOG_TO_DATABASE { | ||||||
|  |             sqlx::query!( | ||||||
|  |                 "INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)", | ||||||
|  |                 self.id, | ||||||
|  |                 message, | ||||||
|  |             ) | ||||||
|  |             .execute(pool) | ||||||
|  |             .await | ||||||
|  |             .expect("Could not log error to database"); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async fn log_success(&self) { |     async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||||
|         REMINDER_COUNTER |         if *LOG_TO_DATABASE { | ||||||
|             .get_metric_with_label_values(&[ |             sqlx::query!( | ||||||
|                 self.id.to_string().as_str(), |                 "INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)", | ||||||
|                 self.channel_id.to_string().as_str(), |                 self.id, | ||||||
|             ]) |             ) | ||||||
|             .map_or_else(|e| warn!("Couldn't count sent reminder: {:?}", e), |c| c.inc()); |             .execute(pool) | ||||||
|  |             .await | ||||||
|  |             .expect("Could not log success to database"); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { |     async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||||
|         sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id) |         sqlx::query!( | ||||||
|  |             " | ||||||
|  |             UPDATE reminders | ||||||
|  |             SET `status` = 'sent', `status_change_time` = NOW() | ||||||
|  |             WHERE `id` = ? | ||||||
|  |             ",
 | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|         .execute(pool) |         .execute(pool) | ||||||
|         .await |         .await | ||||||
|         .expect(&format!("Could not delete Reminder {}", self.id)); |         .expect(&format!("Could not delete Reminder {}", self.id)); | ||||||
| @@ -486,8 +500,8 @@ impl Reminder { | |||||||
|         .expect(&format!("Could not delete Reminder {}", self.id)); |         .expect(&format!("Could not delete Reminder {}", self.id)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async fn pin_message<M: Into<MessageId>>(&self, message_id: M, http: impl AsRef<Http>) { |     async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) { | ||||||
|         let _ = http.as_ref().pin_message(self.channel_id.into(), message_id.into(), None).await; |         let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub async fn send( |     pub async fn send( | ||||||
| @@ -500,28 +514,28 @@ impl Reminder { | |||||||
|             reminder: &Reminder, |             reminder: &Reminder, | ||||||
|             embed: Option<CreateEmbed>, |             embed: Option<CreateEmbed>, | ||||||
|         ) -> Result<()> { |         ) -> Result<()> { | ||||||
|             let channel = if let Some(thread_id) = reminder.thread_id { |             let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await; | ||||||
|                 ChannelId::new(thread_id).to_channel(&cache_http).await |  | ||||||
|             } else { |  | ||||||
|                 ChannelId::new(reminder.channel_id).to_channel(&cache_http).await |  | ||||||
|             }; |  | ||||||
| 
 | 
 | ||||||
|             let mut message = CreateMessage::new().content(&reminder.content).tts(reminder.tts); |             match channel { | ||||||
|  |                 Ok(Channel::Guild(channel)) => { | ||||||
|  |                     match channel | ||||||
|  |                         .send_message(&cache_http, |m| { | ||||||
|  |                             m.content(&reminder.content).tts(reminder.tts); | ||||||
| 
 | 
 | ||||||
|                             if let (Some(attachment), Some(name)) = |                             if let (Some(attachment), Some(name)) = | ||||||
|                                 (&reminder.attachment, &reminder.attachment_name) |                                 (&reminder.attachment, &reminder.attachment_name) | ||||||
|                             { |                             { | ||||||
|                 message = |                                 m.add_file((attachment as &[u8], name.as_str())); | ||||||
|                     message.add_file(CreateAttachment::bytes(attachment as &[u8], name.as_str())); |  | ||||||
|                             } |                             } | ||||||
| 
 | 
 | ||||||
|                             if let Some(embed) = embed { |                             if let Some(embed) = embed { | ||||||
|                 message = message.embed(embed); |                                 m.set_embed(embed); | ||||||
|                             } |                             } | ||||||
| 
 | 
 | ||||||
|             match channel { |                             m | ||||||
|                 Ok(Channel::Guild(channel)) => { |                         }) | ||||||
|                     match channel.send_message(&cache_http, message).await { |                         .await | ||||||
|  |                     { | ||||||
|                         Ok(m) => { |                         Ok(m) => { | ||||||
|                             if reminder.pin { |                             if reminder.pin { | ||||||
|                                 reminder.pin_message(m.id, cache_http.http()).await; |                                 reminder.pin_message(m.id, cache_http.http()).await; | ||||||
| @@ -533,7 +547,24 @@ impl Reminder { | |||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|                 Ok(Channel::Private(channel)) => { |                 Ok(Channel::Private(channel)) => { | ||||||
|                     match channel.send_message(&cache_http.http(), message).await { |                     match channel | ||||||
|  |                         .send_message(&cache_http.http(), |m| { | ||||||
|  |                             m.content(&reminder.content).tts(reminder.tts); | ||||||
|  | 
 | ||||||
|  |                             if let (Some(attachment), Some(name)) = | ||||||
|  |                                 (&reminder.attachment, &reminder.attachment_name) | ||||||
|  |                             { | ||||||
|  |                                 m.add_file((attachment as &[u8], name.as_str())); | ||||||
|  |                             } | ||||||
|  | 
 | ||||||
|  |                             if let Some(embed) = embed { | ||||||
|  |                                 m.set_embed(embed); | ||||||
|  |                             } | ||||||
|  | 
 | ||||||
|  |                             m | ||||||
|  |                         }) | ||||||
|  |                         .await | ||||||
|  |                     { | ||||||
|                         Ok(m) => { |                         Ok(m) => { | ||||||
|                             if reminder.pin { |                             if reminder.pin { | ||||||
|                                 reminder.pin_message(m.id, cache_http.http()).await; |                                 reminder.pin_message(m.id, cache_http.http()).await; | ||||||
| @@ -555,38 +586,35 @@ impl Reminder { | |||||||
|             webhook: Webhook, |             webhook: Webhook, | ||||||
|             embed: Option<CreateEmbed>, |             embed: Option<CreateEmbed>, | ||||||
|         ) -> Result<()> { |         ) -> Result<()> { | ||||||
|             let mut builder = if let Some(thread_id) = reminder.thread_id { |             match webhook | ||||||
|                 ExecuteWebhook::new() |                 .execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| { | ||||||
|                     .content(&reminder.content) |                     w.content(&reminder.content).tts(reminder.tts); | ||||||
|                     .tts(reminder.tts) |  | ||||||
|                     .in_thread(thread_id) |  | ||||||
|             } else { |  | ||||||
|                 ExecuteWebhook::new().content(&reminder.content).tts(reminder.tts) |  | ||||||
|             }; |  | ||||||
| 
 | 
 | ||||||
|                     if let Some(username) = &reminder.username { |                     if let Some(username) = &reminder.username { | ||||||
|                         if !username.is_empty() { |                         if !username.is_empty() { | ||||||
|                     builder = builder.username(username); |                             w.username(username); | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
| 
 | 
 | ||||||
|                     if let Some(avatar) = &reminder.avatar { |                     if let Some(avatar) = &reminder.avatar { | ||||||
|                 builder = builder.avatar_url(avatar); |                         w.avatar_url(avatar); | ||||||
|                     } |                     } | ||||||
| 
 | 
 | ||||||
|                     if let (Some(attachment), Some(name)) = |                     if let (Some(attachment), Some(name)) = | ||||||
|                         (&reminder.attachment, &reminder.attachment_name) |                         (&reminder.attachment, &reminder.attachment_name) | ||||||
|                     { |                     { | ||||||
|                 builder = |                         w.add_file((attachment as &[u8], name.as_str())); | ||||||
|                     builder.add_file(CreateAttachment::bytes(attachment as &[u8], name.as_str())); |  | ||||||
|                     } |                     } | ||||||
| 
 | 
 | ||||||
|                     if let Some(embed) = embed { |                     if let Some(embed) = embed { | ||||||
|                 builder = builder.embeds(vec![embed]); |                         w.embeds(vec![SerenityEmbed::fake(|c| { | ||||||
|  |                             *c = embed; | ||||||
|  |                             c | ||||||
|  |                         })]); | ||||||
|                     } |                     } | ||||||
| 
 | 
 | ||||||
|             match webhook |                     w | ||||||
|                 .execute(&cache_http.http(), reminder.pin || reminder.restartable, builder) |                 }) | ||||||
|                 .await |                 .await | ||||||
|             { |             { | ||||||
|                 Ok(m) => { |                 Ok(m) => { | ||||||
| @@ -609,9 +637,7 @@ impl Reminder { | |||||||
|                     .map_or(true, |inner| inner >= Utc::now().naive_local())) |                     .map_or(true, |inner| inner >= Utc::now().naive_local())) | ||||||
|         { |         { | ||||||
|             let _ = sqlx::query!( |             let _ = sqlx::query!( | ||||||
|                 " |                 "UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?", | ||||||
|                 UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ? |  | ||||||
|                 ",
 |  | ||||||
|                 self.channel_id |                 self.channel_id | ||||||
|             ) |             ) | ||||||
|             .execute(pool) |             .execute(pool) | ||||||
| @@ -622,10 +648,8 @@ impl Reminder { | |||||||
|             let result = if let (Some(webhook_id), Some(webhook_token)) = |             let result = if let (Some(webhook_id), Some(webhook_token)) = | ||||||
|                 (self.webhook_id, &self.webhook_token) |                 (self.webhook_id, &self.webhook_token) | ||||||
|             { |             { | ||||||
|                 let webhook_res = cache_http |                 let webhook_res = | ||||||
|                     .http() |                     cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await; | ||||||
|                     .get_webhook_with_token(webhook_id.into(), webhook_token) |  | ||||||
|                     .await; |  | ||||||
| 
 | 
 | ||||||
|                 if let Ok(webhook) = webhook_res { |                 if let Ok(webhook) = webhook_res { | ||||||
|                     send_to_webhook(cache_http, &self, webhook, embed).await |                     send_to_webhook(cache_http, &self, webhook, embed).await | ||||||
| @@ -641,10 +665,11 @@ impl Reminder { | |||||||
| 
 | 
 | ||||||
|             if let Err(e) = result { |             if let Err(e) = result { | ||||||
|                 if let Error::Http(error) = e { |                 if let Error::Http(error) = e { | ||||||
|                     if let HttpError::UnsuccessfulRequest(http_error) = error { |                     if let HttpError::UnsuccessfulRequest(http_error) = *error { | ||||||
|                         match http_error.error.code { |                         match http_error.error.code { | ||||||
|                             10003 => { |                             10003 => { | ||||||
|                                 self.log_error( |                                 self.log_error( | ||||||
|  |                                     pool, | ||||||
|                                     "Could not be sent as channel does not exist", |                                     "Could not be sent as channel does not exist", | ||||||
|                                     None::<&'static str>, |                                     None::<&'static str>, | ||||||
|                                 ) |                                 ) | ||||||
| @@ -657,6 +682,7 @@ impl Reminder { | |||||||
|                             } |                             } | ||||||
|                             10004 => { |                             10004 => { | ||||||
|                                 self.log_error( |                                 self.log_error( | ||||||
|  |                                     pool, | ||||||
|                                     "Could not be sent as guild does not exist", |                                     "Could not be sent as guild does not exist", | ||||||
|                                     None::<&'static str>, |                                     None::<&'static str>, | ||||||
|                                 ) |                                 ) | ||||||
| @@ -666,6 +692,7 @@ impl Reminder { | |||||||
|                             } |                             } | ||||||
|                             50001 => { |                             50001 => { | ||||||
|                                 self.log_error( |                                 self.log_error( | ||||||
|  |                                     pool, | ||||||
|                                     "Could not be sent as missing access", |                                     "Could not be sent as missing access", | ||||||
|                                     None::<&'static str>, |                                     None::<&'static str>, | ||||||
|                                 ) |                                 ) | ||||||
| @@ -674,6 +701,7 @@ impl Reminder { | |||||||
|                             } |                             } | ||||||
|                             50007 => { |                             50007 => { | ||||||
|                                 self.log_error( |                                 self.log_error( | ||||||
|  |                                     pool, | ||||||
|                                     "Could not be sent as user has DMs disabled", |                                     "Could not be sent as user has DMs disabled", | ||||||
|                                     None::<&'static str>, |                                     None::<&'static str>, | ||||||
|                                 ) |                                 ) | ||||||
| @@ -683,6 +711,7 @@ impl Reminder { | |||||||
|                             } |                             } | ||||||
|                             50013 => { |                             50013 => { | ||||||
|                                 self.log_error( |                                 self.log_error( | ||||||
|  |                                     pool, | ||||||
|                                     "Could not be sent as permissions are invalid", |                                     "Could not be sent as permissions are invalid", | ||||||
|                                     None::<&'static str>, |                                     None::<&'static str>, | ||||||
|                                 ) |                                 ) | ||||||
| @@ -694,21 +723,25 @@ impl Reminder { | |||||||
|                                 .await; |                                 .await; | ||||||
|                             } |                             } | ||||||
|                             _ => { |                             _ => { | ||||||
|                                 self.log_error("HTTP error sending reminder", Some(http_error)) |                                 self.log_error( | ||||||
|  |                                     pool, | ||||||
|  |                                     "HTTP error sending reminder", | ||||||
|  |                                     Some(http_error), | ||||||
|  |                                 ) | ||||||
|                                 .await; |                                 .await; | ||||||
|                                 self.refresh(pool).await; |                                 self.refresh(pool).await; | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     } else { |                     } else { | ||||||
|                         self.log_error("(Likely) a parsing error", Some(error)).await; |                         self.log_error(pool, "(Likely) a parsing error", Some(error)).await; | ||||||
|                         self.refresh(pool).await; |                         self.refresh(pool).await; | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
|                     self.log_error("Non-HTTP error", Some(e)).await; |                     self.log_error(pool, "Non-HTTP error", Some(e)).await; | ||||||
|                     self.refresh(pool).await; |                     self.refresh(pool).await; | ||||||
|                 } |                 } | ||||||
|             } else { |             } else { | ||||||
|                 self.log_success().await; |                 self.log_success(pool).await; | ||||||
|                 self.refresh(pool).await; |                 self.refresh(pool).await; | ||||||
|             } |             } | ||||||
|         } else { |         } else { | ||||||
| @@ -1,11 +0,0 @@ | |||||||
| [package] |  | ||||||
| name = "recordable_derive" |  | ||||||
| version = "0.1.0" |  | ||||||
| edition = "2021" |  | ||||||
|  |  | ||||||
| [lib] |  | ||||||
| proc-macro = true |  | ||||||
|  |  | ||||||
| [dependencies] |  | ||||||
| quote = "1.0.35" |  | ||||||
| syn = { version = "2.0.49", features = ["full"] } |  | ||||||
| @@ -1,42 +0,0 @@ | |||||||
| use proc_macro::TokenStream; |  | ||||||
| use syn::{spanned::Spanned, Data}; |  | ||||||
|  |  | ||||||
| /// Macro to allow Recordable to be implemented on an enum by dispatching over each variant. |  | ||||||
| #[proc_macro_derive(Recordable)] |  | ||||||
| pub fn extract(input: TokenStream) -> TokenStream { |  | ||||||
|     let ast = syn::parse_macro_input!(input); |  | ||||||
|  |  | ||||||
|     impl_recordable(&ast) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fn impl_recordable(ast: &syn::DeriveInput) -> TokenStream { |  | ||||||
|     let name = &ast.ident; |  | ||||||
|  |  | ||||||
|     match &ast.data { |  | ||||||
|         Data::Enum(en) => { |  | ||||||
|             let extracted = en.variants.iter().map(|var| { |  | ||||||
|                 let ident = &var.ident; |  | ||||||
|  |  | ||||||
|                 quote::quote_spanned! {var.span()=> |  | ||||||
|                     Self::#ident (opt) => opt.run(ctx).await? |  | ||||||
|                 } |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             TokenStream::from(quote::quote! { |  | ||||||
|                 impl Recordable for #name { |  | ||||||
|                     async fn run(self, ctx: crate::Context<'_>) -> Result<(), crate::Error> { |  | ||||||
|                         match self { |  | ||||||
|                             #(#extracted,)* |  | ||||||
|                         } |  | ||||||
|  |  | ||||||
|                         Ok(()) |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             }) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         _ => { |  | ||||||
|             panic!("Only enums can derive Recordable"); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,2 +0,0 @@ | |||||||
| printWidth = 100 |  | ||||||
| tabWidth = 4 |  | ||||||
| @@ -1,19 +0,0 @@ | |||||||
| # reminder-dashboard |  | ||||||
|  |  | ||||||
| The re-re-rewrite of the dashboard. |  | ||||||
|  |  | ||||||
| ## Why |  | ||||||
|  |  | ||||||
| The existing beta variant of the dashboard is written using vanilla JavaScript. This is fine, |  | ||||||
| but annoying to update. This would've been okay if I was more dedicated to updating the vanilla |  | ||||||
| JavaScript too, but I want to experiment with "new" things. |  | ||||||
|  |  | ||||||
| This also allows me to expand my frontend skills, which is relevant to part of my job. |  | ||||||
|  |  | ||||||
| ## Developing |  | ||||||
|  |  | ||||||
| 1. Run both `npm run dev` and `cargo run` |  | ||||||
| 2. Symlink assets: assuming cloned |  | ||||||
|    into `$HOME`, `ln -s $HOME/reminder-bot/reminder-dashboard/dist/index.html $HOME/reminder-bot/web/static/index.html` |  | ||||||
|    and |  | ||||||
|    `ln -s $HOME/reminder-bot/reminder-dashboard/dist/static/assets $HOME/reminder-bot/web/static/assets` |  | ||||||
| @@ -1,34 +0,0 @@ | |||||||
| <!DOCTYPE html> |  | ||||||
| <html lang="EN"> |  | ||||||
| <head> |  | ||||||
| 	<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/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"> |  | ||||||
| </head> |  | ||||||
| <body> |  | ||||||
| 	<div id="app"></div> |  | ||||||
| 	<script type="module" src="/src/index.tsx"></script> |  | ||||||
| </body> |  | ||||||
| </html> |  | ||||||
							
								
								
									
										5626
									
								
								reminder-dashboard/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5626
									
								
								reminder-dashboard/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,32 +0,0 @@ | |||||||
| { |  | ||||||
| 	"name": "example", |  | ||||||
| 	"private": true, |  | ||||||
| 	"type": "module", |  | ||||||
| 	"scripts": { |  | ||||||
| 		"dev": "vite build --watch --mode development", |  | ||||||
| 		"build": "vite build", |  | ||||||
| 		"preview": "vite preview" |  | ||||||
| 	}, |  | ||||||
| 	"dependencies": { |  | ||||||
| 		"axios": "^1.5.1", |  | ||||||
| 		"bulma": "^0.9.4", |  | ||||||
| 		"luxon": "^3.4.3", |  | ||||||
| 		"preact": "^10.13.1", |  | ||||||
| 		"react-colorful": "^5.6.1", |  | ||||||
| 		"react-query": "^3.39.3", |  | ||||||
| 		"tributejs": "^5.1.3", |  | ||||||
| 		"use-debounce": "^10.0.0", |  | ||||||
| 		"wouter": "^3.0" |  | ||||||
| 	}, |  | ||||||
| 	"devDependencies": { |  | ||||||
| 		"@preact/preset-vite": "^2.5.0", |  | ||||||
| 		"@types/luxon": "^3.3.2", |  | ||||||
| 		"eslint": "^8.50.0", |  | ||||||
| 		"eslint-config-preact": "^1.3.0", |  | ||||||
| 		"prettier": "^3.0.3", |  | ||||||
| 		"react-datepicker": "^4.21.0", |  | ||||||
| 		"sass": "^1.71.1", |  | ||||||
| 		"typescript": "^5.2.2", |  | ||||||
| 		"vite": "^5.1" |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| @@ -1,247 +0,0 @@ | |||||||
| import axios from "axios"; |  | ||||||
|  |  | ||||||
| type UserInfo = { |  | ||||||
|     name: string; |  | ||||||
|     patreon: boolean; |  | ||||||
|     timezone: string | null; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export type GuildInfo = { |  | ||||||
|     id: string; |  | ||||||
|     patreon: boolean; |  | ||||||
|     name: string; |  | ||||||
|     error?: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| type EmbedField = { |  | ||||||
|     title: string; |  | ||||||
|     value: string; |  | ||||||
|     inline: boolean; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export type Reminder = { |  | ||||||
|     attachment: string | null; |  | ||||||
|     attachment_name: string | null; |  | ||||||
|     avatar: string | null; |  | ||||||
|     channel: string; |  | ||||||
|     content: string; |  | ||||||
|     embed_author: string; |  | ||||||
|     embed_author_url: string | null; |  | ||||||
|     embed_color: number; |  | ||||||
|     embed_description: string; |  | ||||||
|     embed_footer: string; |  | ||||||
|     embed_footer_url: string | null; |  | ||||||
|     embed_image_url: string | null; |  | ||||||
|     embed_thumbnail_url: string | null; |  | ||||||
|     embed_title: string; |  | ||||||
|     embed_fields: EmbedField[] | null; |  | ||||||
|     enabled: boolean; |  | ||||||
|     expires: string | null; |  | ||||||
|     interval_seconds: number | null; |  | ||||||
|     interval_days: number | null; |  | ||||||
|     interval_months: number | null; |  | ||||||
|     name: string; |  | ||||||
|     restartable: boolean; |  | ||||||
|     tts: boolean; |  | ||||||
|     uid: string; |  | ||||||
|     username: string; |  | ||||||
|     utc_time: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export type Todo = { |  | ||||||
|     id: number; |  | ||||||
|     channel_id: string; |  | ||||||
|     value: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export type CreateTodo = { |  | ||||||
|     channel_id: string; |  | ||||||
|     value: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export type UpdateTodo = { |  | ||||||
|     value: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export type ChannelInfo = { |  | ||||||
|     id: string; |  | ||||||
|     name: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| type RoleInfo = { |  | ||||||
|     id: string; |  | ||||||
|     name: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| type EmojiInfo = { |  | ||||||
|     fmt: string; |  | ||||||
|     name: string; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| type Template = { |  | ||||||
|     id: number; |  | ||||||
|     name: string; |  | ||||||
|     attachment: string | null; |  | ||||||
|     attachment_name: string | null; |  | ||||||
|     avatar: string | null; |  | ||||||
|     channel: string; |  | ||||||
|     content: string; |  | ||||||
|     embed_author: string; |  | ||||||
|     embed_author_url: string | null; |  | ||||||
|     embed_color: number; |  | ||||||
|     embed_description: string; |  | ||||||
|     embed_footer: string; |  | ||||||
|     embed_footer_url: string | null; |  | ||||||
|     embed_image_url: string | null; |  | ||||||
|     embed_thumbnail_url: string | null; |  | ||||||
|     embed_title: string; |  | ||||||
|     embed_fields: EmbedField[] | null; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const USER_INFO_STALE_TIME = 120_000; |  | ||||||
| const GUILD_INFO_STALE_TIME = 300_000; |  | ||||||
| const OTHER_STALE_TIME = 120_000; |  | ||||||
|  |  | ||||||
| export const fetchUserInfo = () => ({ |  | ||||||
|     queryKey: ["USER_INFO"], |  | ||||||
|     queryFn: () => axios.get("/dashboard/api/user").then((resp) => resp.data) as Promise<UserInfo>, |  | ||||||
|     staleTime: USER_INFO_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const patchUserInfo = () => ({ |  | ||||||
|     mutationFn: (timezone: string) => axios.patch(`/dashboard/api/user`, { timezone }), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchUserGuilds = () => ({ |  | ||||||
|     queryKey: ["USER_GUILDS"], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get("/dashboard/api/user/guilds").then((resp) => resp.data) as Promise<GuildInfo[]>, |  | ||||||
|     staleTime: USER_INFO_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchGuildInfo = (guild: string) => ({ |  | ||||||
|     queryKey: ["GUILD_INFO", guild], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get(`/dashboard/api/guild/${guild}`).then((resp) => resp.data) as Promise<GuildInfo>, |  | ||||||
|     staleTime: GUILD_INFO_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchGuildChannels = (guild: string) => ({ |  | ||||||
|     queryKey: ["GUILD_CHANNELS", guild], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios |  | ||||||
|             .get(`/dashboard/api/guild/${guild}/channels`) |  | ||||||
|             .then((resp) => |  | ||||||
|                 resp.data.sort((a: ChannelInfo, b: ChannelInfo) => |  | ||||||
|                     a.name == b.name ? 0 : a.name > b.name ? 1 : -1, |  | ||||||
|                 ), |  | ||||||
|             ) as Promise<ChannelInfo[]>, |  | ||||||
|     staleTime: GUILD_INFO_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchGuildRoles = (guild: string) => ({ |  | ||||||
|     queryKey: ["GUILD_ROLES", guild], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get(`/dashboard/api/guild/${guild}/roles`).then((resp) => resp.data) as Promise< |  | ||||||
|             RoleInfo[] |  | ||||||
|         >, |  | ||||||
|     staleTime: GUILD_INFO_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchGuildEmojis = (guild: string) => ({ |  | ||||||
|     queryKey: ["GUILD_EMOJIS", guild], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get(`/dashboard/api/guild/${guild}/emojis`).then((resp) => resp.data) as Promise< |  | ||||||
|             EmojiInfo[] |  | ||||||
|         >, |  | ||||||
|     staleTime: GUILD_INFO_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchGuildReminders = (guild: string) => ({ |  | ||||||
|     queryKey: ["GUILD_REMINDERS", guild], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get(`/dashboard/api/guild/${guild}/reminders`).then((resp) => resp.data) as Promise< |  | ||||||
|             Reminder[] |  | ||||||
|         >, |  | ||||||
|     staleTime: OTHER_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const patchGuildReminder = (guild: string) => ({ |  | ||||||
|     mutationFn: (reminder: Reminder) => |  | ||||||
|         axios.patch(`/dashboard/api/guild/${guild}/reminders`, reminder), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const postGuildReminder = (guild: string) => ({ |  | ||||||
|     mutationFn: (reminder: Reminder) => |  | ||||||
|         axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const deleteReminder = () => ({ |  | ||||||
|     mutationFn: (reminder: Reminder) => |  | ||||||
|         axios.delete(`/dashboard/api/reminders`, { |  | ||||||
|             data: { |  | ||||||
|                 uid: reminder.uid, |  | ||||||
|             }, |  | ||||||
|         }), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchGuildTemplates = (guild: string) => ({ |  | ||||||
|     queryKey: ["GUILD_TEMPLATES", guild], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get(`/dashboard/api/guild/${guild}/templates`).then((resp) => resp.data) as Promise< |  | ||||||
|             Template[] |  | ||||||
|         >, |  | ||||||
|     staleTime: OTHER_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const postGuildTemplate = (guild: string) => ({ |  | ||||||
|     mutationFn: (reminder: Reminder) => |  | ||||||
|         axios.post(`/dashboard/api/guild/${guild}/templates`, reminder).then((resp) => resp.data), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const deleteGuildTemplate = (guild: string) => ({ |  | ||||||
|     mutationFn: (template: Template) => |  | ||||||
|         axios.delete(`/dashboard/api/guild/${guild}/templates`, { |  | ||||||
|             data: { |  | ||||||
|                 id: template.id, |  | ||||||
|             }, |  | ||||||
|         }), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchGuildTodos = (guild: string) => ({ |  | ||||||
|     queryKey: ["GUILD_TODOS", guild], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get(`/dashboard/api/guild/${guild}/todos`).then((resp) => resp.data) as Promise< |  | ||||||
|             Todo[] |  | ||||||
|         >, |  | ||||||
|     staleTime: OTHER_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const patchGuildTodo = (guild: string) => ({ |  | ||||||
|     mutationFn: ({ id, todo }) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const postGuildTodo = (guild: string) => ({ |  | ||||||
|     mutationFn: (todo: CreateTodo) => |  | ||||||
|         axios.post(`/dashboard/api/guild/${guild}/todos`, todo).then((resp) => resp.data), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const deleteGuildTodo = (guild: string) => ({ |  | ||||||
|     mutationFn: (todoId: number) => axios.delete(`/dashboard/api/guild/${guild}/todos/${todoId}`), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const fetchUserReminders = () => ({ |  | ||||||
|     queryKey: ["USER_REMINDERS"], |  | ||||||
|     queryFn: () => |  | ||||||
|         axios.get(`/dashboard/api/user/reminders`).then((resp) => resp.data) as Promise<Reminder[]>, |  | ||||||
|     staleTime: OTHER_STALE_TIME, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const postUserReminder = () => ({ |  | ||||||
|     mutationFn: (reminder: Reminder) => |  | ||||||
|         axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data), |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const patchUserReminder = () => ({ |  | ||||||
|     mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder), |  | ||||||
| }); |  | ||||||
| @@ -1,7 +0,0 @@ | |||||||
| import { createContext } from "preact"; |  | ||||||
| import { useContext } from "preact/compat"; |  | ||||||
| import { Message } from "./FlashProvider"; |  | ||||||
|  |  | ||||||
| export const FlashContext = createContext(null as (message: Message) => void); |  | ||||||
|  |  | ||||||
| export const useFlash = () => useContext(FlashContext); |  | ||||||
| @@ -1,43 +0,0 @@ | |||||||
| import { FlashContext } from "./FlashContext"; |  | ||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { MESSAGE_FLASH_TIME } from "../../consts"; |  | ||||||
|  |  | ||||||
| export type Message = { |  | ||||||
|     message: string; |  | ||||||
|     type: "error" | "success"; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const FlashProvider = ({ children }) => { |  | ||||||
|     const [messages, setMessages] = useState([] as Message[]); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <FlashContext.Provider |  | ||||||
|             value={(message: Message) => { |  | ||||||
|                 setMessages((messages: Message[]) => [...messages, message]); |  | ||||||
|                 setTimeout(() => { |  | ||||||
|                     setMessages((messages) => [...messages].splice(1)); |  | ||||||
|                 }, MESSAGE_FLASH_TIME); |  | ||||||
|             }} |  | ||||||
|         > |  | ||||||
|             <> |  | ||||||
|                 {children} |  | ||||||
|                 <div class="flash-container"> |  | ||||||
|                     {messages.map((message) => { |  | ||||||
|                         const className = message.type === "error" ? "is-danger" : "is-success"; |  | ||||||
|                         const icon = |  | ||||||
|                             message.type === "error" ? "fa-exclamation-circle" : "fa-check"; |  | ||||||
|  |  | ||||||
|                         return ( |  | ||||||
|                             <div class={`notification flash-message is-active ${className}`}> |  | ||||||
|                                 <span class="icon"> |  | ||||||
|                                     <i class={`far ${icon}`}></i> |  | ||||||
|                                 </span>{" "} |  | ||||||
|                                 <span class="error-message">{message.message}</span> |  | ||||||
|                             </div> |  | ||||||
|                         ); |  | ||||||
|                     })} |  | ||||||
|                 </div> |  | ||||||
|             </> |  | ||||||
|         </FlashContext.Provider> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,56 +0,0 @@ | |||||||
| import { useEffect, useMemo } from "preact/hooks"; |  | ||||||
| import { useQuery } from "react-query"; |  | ||||||
| import { fetchGuildChannels, fetchGuildRoles, fetchGuildEmojis } from "../../api"; |  | ||||||
| import Tribute from "tributejs"; |  | ||||||
| import { useGuild } from "./useGuild"; |  | ||||||
|  |  | ||||||
| export const Mentions = ({ input }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     return <>{guild && <_Mentions guild={guild} input={input} />}</>; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const _Mentions = ({ guild, input }) => { |  | ||||||
|     const { data: roles } = useQuery(fetchGuildRoles(guild)); |  | ||||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); |  | ||||||
|     const { data: emojis } = useQuery(fetchGuildEmojis(guild)); |  | ||||||
|  |  | ||||||
|     const tribute = useMemo(() => { |  | ||||||
|         return new Tribute({ |  | ||||||
|             collection: [ |  | ||||||
|                 { |  | ||||||
|                     trigger: "@", |  | ||||||
|                     values: (roles || []) |  | ||||||
|                         .filter((role) => role.name !== "@everyone") |  | ||||||
|                         .map(({ id, name }) => ({ key: name, value: id })), |  | ||||||
|                     allowSpaces: true, |  | ||||||
|                     selectTemplate: (item) => `<@&${item.original.value}>`, |  | ||||||
|                     menuItemTemplate: (item) => `@${item.original.key}`, |  | ||||||
|                 }, |  | ||||||
|                 { |  | ||||||
|                     trigger: "#", |  | ||||||
|                     values: (channels || []).map(({ id, name }) => ({ key: name, value: id })), |  | ||||||
|                     allowSpaces: true, |  | ||||||
|                     selectTemplate: (item) => `<#${item.original.value}>`, |  | ||||||
|                     menuItemTemplate: (item) => `#${item.original.key}`, |  | ||||||
|                 }, |  | ||||||
|                 { |  | ||||||
|                     trigger: ":", |  | ||||||
|                     values: (emojis || []).map(({ fmt, name }) => ({ key: name, value: fmt })), |  | ||||||
|                     allowSpaces: true, |  | ||||||
|                     selectTemplate: (item) => item.original.value, |  | ||||||
|                     menuItemTemplate: (item) => `:${item.original.key}:`, |  | ||||||
|                 }, |  | ||||||
|             ], |  | ||||||
|         }); |  | ||||||
|     }, [roles, channels, emojis]); |  | ||||||
|  |  | ||||||
|     useEffect(() => { |  | ||||||
|         tribute.detach(input.current); |  | ||||||
|         if (input.current !== null) { |  | ||||||
|             tribute.attach(input.current); |  | ||||||
|         } |  | ||||||
|     }, [tribute]); |  | ||||||
|  |  | ||||||
|     return <></>; |  | ||||||
| }; |  | ||||||
| @@ -1,20 +0,0 @@ | |||||||
| import { createContext } from "preact"; |  | ||||||
| import { useContext } from "preact/compat"; |  | ||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { DateTime } from "luxon"; |  | ||||||
|  |  | ||||||
| type TTimezoneContext = [string, (tz: string) => void]; |  | ||||||
|  |  | ||||||
| const TimezoneContext = createContext(["UTC", () => {}] as TTimezoneContext); |  | ||||||
|  |  | ||||||
| export const TimezoneProvider = ({ children }) => { |  | ||||||
|     const [timezone, setTimezone] = useState(DateTime.now().zoneName); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <TimezoneContext.Provider value={[timezone, setTimezone]}> |  | ||||||
|             {children} |  | ||||||
|         </TimezoneContext.Provider> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const useTimezone = () => useContext(TimezoneContext); |  | ||||||
| @@ -1,54 +0,0 @@ | |||||||
| import { Sidebar } from "../Sidebar"; |  | ||||||
| import { QueryClient, QueryClientProvider } from "react-query"; |  | ||||||
| import { Route, Router, Switch } from "wouter"; |  | ||||||
| import { Welcome } from "../Welcome"; |  | ||||||
| import { Guild } from "../Guild"; |  | ||||||
| import { FlashProvider } from "./FlashProvider"; |  | ||||||
| import { TimezoneProvider } from "./TimezoneProvider"; |  | ||||||
| import { User } from "../User"; |  | ||||||
| import { GuildReminders } from "../Guild/GuildReminders"; |  | ||||||
| import { GuildTodos } from "../Guild/GuildTodos"; |  | ||||||
|  |  | ||||||
| export function App() { |  | ||||||
|     const queryClient = new QueryClient(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <TimezoneProvider> |  | ||||||
|             <FlashProvider> |  | ||||||
|                 <QueryClientProvider client={queryClient}> |  | ||||||
|                     <Router base={"/dashboard"}> |  | ||||||
|                         <div class="columns is-gapless dashboard-frame"> |  | ||||||
|                             <Sidebar /> |  | ||||||
|                             <div class="column is-main-content"> |  | ||||||
|                                 <div style={{ margin: "0 12px 12px 12px" }}> |  | ||||||
|                                     <Switch> |  | ||||||
|                                         <Route path={"/@me/reminders"} component={User}></Route> |  | ||||||
|                                         <Route |  | ||||||
|                                             path={"/:guild/reminders"} |  | ||||||
|                                             component={() => ( |  | ||||||
|                                                 <Guild> |  | ||||||
|                                                     <GuildReminders /> |  | ||||||
|                                                 </Guild> |  | ||||||
|                                             )} |  | ||||||
|                                         ></Route> |  | ||||||
|                                         <Route |  | ||||||
|                                             path={"/:guild/todos"} |  | ||||||
|                                             component={() => ( |  | ||||||
|                                                 <Guild> |  | ||||||
|                                                     <GuildTodos /> |  | ||||||
|                                                 </Guild> |  | ||||||
|                                             )} |  | ||||||
|                                         ></Route> |  | ||||||
|                                         <Route> |  | ||||||
|                                             <Welcome /> |  | ||||||
|                                         </Route> |  | ||||||
|                                     </Switch> |  | ||||||
|                                 </div> |  | ||||||
|                             </div> |  | ||||||
|                         </div> |  | ||||||
|                     </Router> |  | ||||||
|                 </QueryClientProvider> |  | ||||||
|             </FlashProvider> |  | ||||||
|         </TimezoneProvider> |  | ||||||
|     ); |  | ||||||
| } |  | ||||||
| @@ -1,6 +0,0 @@ | |||||||
| import { useParams } from "wouter"; |  | ||||||
|  |  | ||||||
| export const useGuild = () => { |  | ||||||
|     const { guild } = useParams() as { guild?: string }; |  | ||||||
|     return guild || null; |  | ||||||
| }; |  | ||||||
| @@ -1,30 +0,0 @@ | |||||||
| export const GuildError = () => { |  | ||||||
|     return ( |  | ||||||
|         <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"> |  | ||||||
|                         The bot may have just been restarted, in which case please try again in a |  | ||||||
|                         few minutes. |  | ||||||
|                         <br /> |  | ||||||
|                         <br /> |  | ||||||
|                         Otherwise, please check Reminder Bot is in the server, and has correct |  | ||||||
|                         permissions. |  | ||||||
|                     </p> |  | ||||||
|                     <a |  | ||||||
|                         class="button is-size-4 is-rounded is-success" |  | ||||||
|                         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> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,144 +0,0 @@ | |||||||
| import { useQuery, useQueryClient } from "react-query"; |  | ||||||
| import { fetchGuildChannels, fetchGuildReminders } from "../../api"; |  | ||||||
| import { EditReminder } from "../Reminder/EditReminder"; |  | ||||||
| import { CreateReminder } from "../Reminder/CreateReminder"; |  | ||||||
| import { useCallback, useState } from "preact/hooks"; |  | ||||||
| import { Loader } from "../Loader"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
|  |  | ||||||
| enum Sort { |  | ||||||
|     Time = "time", |  | ||||||
|     Name = "name", |  | ||||||
|     Channel = "channel", |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const GuildReminders = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     const { |  | ||||||
|         isSuccess, |  | ||||||
|         isFetching, |  | ||||||
|         isFetched, |  | ||||||
|         data: guildReminders, |  | ||||||
|     } = useQuery(fetchGuildReminders(guild)); |  | ||||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); |  | ||||||
|  |  | ||||||
|     const [collapsed, setCollapsed] = useState(false); |  | ||||||
|     const [sort, _setSort] = useState(Sort.Time); |  | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|  |  | ||||||
|     let prevReminder = null; |  | ||||||
|  |  | ||||||
|     const setSort = useCallback((sort) => { |  | ||||||
|         queryClient.invalidateQueries(["GUILD_REMINDERS"]); |  | ||||||
|         _setSort(sort); |  | ||||||
|     }, []); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             {!isFetched && <Loader />} |  | ||||||
|  |  | ||||||
|             <strong>Create Reminder</strong> |  | ||||||
|             <div id={"reminderCreator"}> |  | ||||||
|                 <CreateReminder /> |  | ||||||
|             </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" |  | ||||||
|                                     onInput={(ev) => { |  | ||||||
|                                         setSort(ev.currentTarget.value as Sort); |  | ||||||
|                                     }} |  | ||||||
|                                 > |  | ||||||
|                                     <option value={Sort.Time} selected={sort == Sort.Time}> |  | ||||||
|                                         Time |  | ||||||
|                                     </option> |  | ||||||
|                                     <option value={Sort.Name} selected={sort == Sort.Name}> |  | ||||||
|                                         Name |  | ||||||
|                                     </option> |  | ||||||
|                                     <option value={Sort.Channel} selected={sort == Sort.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" |  | ||||||
|                                     onInput={(ev) => { |  | ||||||
|                                         if (ev.currentTarget.value === "expand") { |  | ||||||
|                                             setCollapsed(false); |  | ||||||
|                                         } else if (ev.currentTarget.value === "collapse") { |  | ||||||
|                                             setCollapsed(true); |  | ||||||
|                                         } |  | ||||||
|                                     }} |  | ||||||
|                                 > |  | ||||||
|                                     <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"} className={isFetching ? "loading" : ""}> |  | ||||||
|                 {isSuccess && |  | ||||||
|                     guildReminders |  | ||||||
|                         .sort((r1, r2) => { |  | ||||||
|                             if (sort === Sort.Time) { |  | ||||||
|                                 return r1.utc_time > r2.utc_time ? 1 : -1; |  | ||||||
|                             } else if (sort === Sort.Name) { |  | ||||||
|                                 return r1.name > r2.name ? 1 : -1; |  | ||||||
|                             } else { |  | ||||||
|                                 return r1.channel > r2.channel ? 1 : -1; |  | ||||||
|                             } |  | ||||||
|                         }) |  | ||||||
|                         .map((reminder) => { |  | ||||||
|                             let breaker = <></>; |  | ||||||
|                             if (sort === Sort.Channel && channels) { |  | ||||||
|                                 if ( |  | ||||||
|                                     prevReminder === null || |  | ||||||
|                                     prevReminder.channel !== reminder.channel |  | ||||||
|                                 ) { |  | ||||||
|                                     const channel = channels.find( |  | ||||||
|                                         (ch) => ch.id === reminder.channel, |  | ||||||
|                                     ); |  | ||||||
|                                     breaker = <div class={"channel-tag"}>#{channel.name}</div>; |  | ||||||
|                                 } |  | ||||||
|                             } |  | ||||||
|  |  | ||||||
|                             prevReminder = reminder; |  | ||||||
|  |  | ||||||
|                             return ( |  | ||||||
|                                 <> |  | ||||||
|                                     {breaker} |  | ||||||
|                                     <EditReminder |  | ||||||
|                                         key={reminder.uid} |  | ||||||
|                                         reminder={reminder} |  | ||||||
|                                         globalCollapse={collapsed} |  | ||||||
|                                     /> |  | ||||||
|                                 </> |  | ||||||
|                             ); |  | ||||||
|                         })} |  | ||||||
|             </div> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,62 +0,0 @@ | |||||||
| import { useQuery } from "react-query"; |  | ||||||
| import { ChannelInfo, fetchGuildChannels, fetchGuildTodos } from "../../api"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
| import { Todo } from "../Todo"; |  | ||||||
| import { Todo as TodoT } from "../../api"; |  | ||||||
| import { Loader } from "../Loader"; |  | ||||||
| import { CreateTodo } from "../Todo/CreateTodo"; |  | ||||||
|  |  | ||||||
| export const GuildTodos = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     const { isFetched, data: guildTodos } = useQuery(fetchGuildTodos(guild)); |  | ||||||
|     const { data: channels } = useQuery(fetchGuildChannels(guild)); |  | ||||||
|  |  | ||||||
|     if (!isFetched || !channels) { |  | ||||||
|         return <Loader />; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     const sortedTodos = guildTodos.sort((a, b) => (a.id < b.id ? -1 : 1)); |  | ||||||
|     const globalTodos = sortedTodos.filter((todo) => todo.channel_id === null); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <strong>Create todo list</strong> |  | ||||||
|             <CreateTodo channel={null} showSelector={true} /> |  | ||||||
|             <strong>Todo lists</strong> |  | ||||||
|             {globalTodos.length > 0 && ( |  | ||||||
|                 <> |  | ||||||
|                     <h2>Server</h2> |  | ||||||
|                     {globalTodos.map((todo) => ( |  | ||||||
|                         <> |  | ||||||
|                             <Todo todo={todo} key={todo.id} /> |  | ||||||
|                         </> |  | ||||||
|                     ))} |  | ||||||
|                     <CreateTodo channel={null} /> |  | ||||||
|                 </> |  | ||||||
|             )} |  | ||||||
|             {channels |  | ||||||
|                 .map( |  | ||||||
|                     (channel) => |  | ||||||
|                         [channel, sortedTodos.filter((todo) => todo.channel_id === channel.id)] as [ |  | ||||||
|                             ChannelInfo, |  | ||||||
|                             TodoT[], |  | ||||||
|                         ], |  | ||||||
|                 ) |  | ||||||
|                 .filter(([_, todos]) => todos.length > 0) |  | ||||||
|                 .map(([channel, todos]) => { |  | ||||||
|                     return ( |  | ||||||
|                         <> |  | ||||||
|                             <h2>#{channel.name}</h2> |  | ||||||
|                             {todos.map((todo) => ( |  | ||||||
|                                 <> |  | ||||||
|                                     <Todo todo={todo} key={todo.id} /> |  | ||||||
|                                 </> |  | ||||||
|                             ))} |  | ||||||
|                             <CreateTodo channel={channel.id} /> |  | ||||||
|                         </> |  | ||||||
|                     ); |  | ||||||
|                 })} |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| .page-links { |  | ||||||
|     > * { |  | ||||||
|         margin: 2px; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,51 +0,0 @@ | |||||||
| import { useQuery } from "react-query"; |  | ||||||
| import { fetchGuildInfo } from "../../api"; |  | ||||||
| import { GuildError } from "./GuildError"; |  | ||||||
| import { createPortal, PropsWithChildren } from "preact/compat"; |  | ||||||
| import { Import } from "../Import"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
| import { Link } from "wouter"; |  | ||||||
| import { usePathname } from "wouter/use-browser-location"; |  | ||||||
|  |  | ||||||
| import "./index.scss"; |  | ||||||
|  |  | ||||||
| export const Guild = ({ children }: PropsWithChildren) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild)); |  | ||||||
|  |  | ||||||
|     if (!isSuccess) { |  | ||||||
|         return <></>; |  | ||||||
|     } else if (guildInfo.error) { |  | ||||||
|         return <GuildError />; |  | ||||||
|     } else { |  | ||||||
|         const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar")); |  | ||||||
|         const path = usePathname(); |  | ||||||
|  |  | ||||||
|         return ( |  | ||||||
|             <> |  | ||||||
|                 {importModal} |  | ||||||
|                 <div class="page-links"> |  | ||||||
|                     <Link |  | ||||||
|                         class={`button is-outlined is-success is-small ${path.endsWith("reminders") && "is-focused"}`} |  | ||||||
|                         href={`/${guild}/reminders`} |  | ||||||
|                     > |  | ||||||
|                         <span>Reminders</span> |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fa fa-chevron-right"></i> |  | ||||||
|                         </span> |  | ||||||
|                     </Link> |  | ||||||
|                     <Link |  | ||||||
|                         class={`button is-outlined is-success is-small ${path.endsWith("todos") && "is-focused"}`} |  | ||||||
|                         href={`/${guild}/todos`} |  | ||||||
|                     > |  | ||||||
|                         <span>Todo lists</span> |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fa fa-chevron-right"></i> |  | ||||||
|                         </span> |  | ||||||
|                     </Link> |  | ||||||
|                 </div> |  | ||||||
|                 {children} |  | ||||||
|             </> |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
| }; |  | ||||||
| @@ -1,151 +0,0 @@ | |||||||
| import { Modal } from "../Modal"; |  | ||||||
| import { useRef, useState } from "preact/hooks"; |  | ||||||
| import { useParams } from "wouter"; |  | ||||||
| import axios from "axios"; |  | ||||||
| import { useFlash } from "../App/FlashContext"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
| import { useQueryClient } from "react-query"; |  | ||||||
|  |  | ||||||
| export const Import = () => { |  | ||||||
|     const [modalOpen, setModalOpen] = useState(false); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <a |  | ||||||
|                 class="show-modal" |  | ||||||
|                 data-modal="chooseTimezoneModal" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(true); |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 <span class="icon"> |  | ||||||
|                     <i class="fas fa-exchange"></i> |  | ||||||
|                 </span>{" "} |  | ||||||
|                 Import/Export |  | ||||||
|             </a> |  | ||||||
|             {modalOpen && <ImportModal setModalOpen={setModalOpen} />} |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const ImportModal = ({ setModalOpen }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     const aRef = useRef<HTMLAnchorElement>(); |  | ||||||
|     const inputRef = useRef<HTMLInputElement>(); |  | ||||||
|     const flash = useFlash(); |  | ||||||
|  |  | ||||||
|     const [isImporting, setIsImporting] = useState(false); |  | ||||||
|  |  | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <Modal |  | ||||||
|             setModalOpen={setModalOpen} |  | ||||||
|             title={ |  | ||||||
|                 <> |  | ||||||
|                     Import/Export Manager{" "} |  | ||||||
|                     <a href="/help/iemanager"> |  | ||||||
|                         <span> |  | ||||||
|                             <i class="fa fa-question-circle"></i> |  | ||||||
|                         </span> |  | ||||||
|                     </a> |  | ||||||
|                 </> |  | ||||||
|             } |  | ||||||
|         > |  | ||||||
|             <> |  | ||||||
|                 <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" |  | ||||||
|                         style={{ margin: "2px" }} |  | ||||||
|                         id="import-data" |  | ||||||
|                         disabled={isImporting} |  | ||||||
|                         onClick={() => { |  | ||||||
|                             inputRef.current.click(); |  | ||||||
|                         }} |  | ||||||
|                     > |  | ||||||
|                         Import Data |  | ||||||
|                     </button> |  | ||||||
|                     <button |  | ||||||
|                         class="button is-success" |  | ||||||
|                         style={{ margin: "2px" }} |  | ||||||
|                         id="export-data" |  | ||||||
|                         onClick={() => |  | ||||||
|                             axios |  | ||||||
|                                 .get(`/dashboard/api/guild/${guild}/export/reminders`) |  | ||||||
|                                 .then(({ data, status }) => { |  | ||||||
|                                     if (status === 200) { |  | ||||||
|                                         aRef.current.href = `data:text/plain;charset=utf-8,${encodeURIComponent( |  | ||||||
|                                             data.body, |  | ||||||
|                                         )}`; |  | ||||||
|                                         aRef.current.click(); |  | ||||||
|                                     } else { |  | ||||||
|                                         flash({ |  | ||||||
|                                             message: `Unexpected status ${status}`, |  | ||||||
|                                             type: "error", |  | ||||||
|                                         }); |  | ||||||
|                                     } |  | ||||||
|                                 }) |  | ||||||
|                         } |  | ||||||
|                     > |  | ||||||
|                         Export Data |  | ||||||
|                     </button> |  | ||||||
|                 </div> |  | ||||||
|                 <a ref={aRef} id="downloader" download="export.csv" class="is-hidden" /> |  | ||||||
|                 <input |  | ||||||
|                     ref={inputRef} |  | ||||||
|                     id="uploader" |  | ||||||
|                     type="file" |  | ||||||
|                     hidden |  | ||||||
|                     onChange={() => { |  | ||||||
|                         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: dataUrl.split(",")[1], |  | ||||||
|                                 }) |  | ||||||
|                                 .then(({ data }) => { |  | ||||||
|                                     setIsImporting(false); |  | ||||||
|  |  | ||||||
|                                     if (data.error) { |  | ||||||
|                                         flash({ message: data.error, type: "error" }); |  | ||||||
|                                     } else { |  | ||||||
|                                         flash({ message: data.message, type: "success" }); |  | ||||||
|                                         queryClient.invalidateQueries({ |  | ||||||
|                                             queryKey: ["GUILD_REMINDERS", guild], |  | ||||||
|                                         }); |  | ||||||
|                                     } |  | ||||||
|                                 }) |  | ||||||
|                                 .then(() => { |  | ||||||
|                                     delete inputRef.current.files[0]; |  | ||||||
|                                 }); |  | ||||||
|                         }); |  | ||||||
|                     }} |  | ||||||
|                 /> |  | ||||||
|             </> |  | ||||||
|         </Modal> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,8 +0,0 @@ | |||||||
| export const Loader = () => ( |  | ||||||
|     <div className={"load-screen"}> |  | ||||||
|         <div> |  | ||||||
|             <p>Loading...</p> |  | ||||||
|             <i className={"fa fa-cog fa-spin"}></i> |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
| ); |  | ||||||
| @@ -1,61 +0,0 @@ | |||||||
| 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( |  | ||||||
|         <div class="modal is-active"> |  | ||||||
|             <div |  | ||||||
|                 class="modal-background" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(false); |  | ||||||
|                 }} |  | ||||||
|             ></div> |  | ||||||
|             <div class="modal-card"> |  | ||||||
|                 <header class="modal-card-head"> |  | ||||||
|                     <label class="modal-card-title">{title}</label> |  | ||||||
|                     <button |  | ||||||
|                         class="delete close-modal" |  | ||||||
|                         aria-label="close" |  | ||||||
|                         onClick={() => { |  | ||||||
|                             setModalOpen(false); |  | ||||||
|                         }} |  | ||||||
|                     ></button> |  | ||||||
|                 </header> |  | ||||||
|                 <section class="modal-card-body">{children}</section> |  | ||||||
|                 {onSubmit && ( |  | ||||||
|                     <footer class="modal-card-foot"> |  | ||||||
|                         <button class="button is-success" onClick={onSubmit}> |  | ||||||
|                             {onSubmitText || "Save"} |  | ||||||
|                         </button> |  | ||||||
|                         <button |  | ||||||
|                             class="button close-modal" |  | ||||||
|                             onClick={() => { |  | ||||||
|                                 setModalOpen(false); |  | ||||||
|                             }} |  | ||||||
|                         > |  | ||||||
|                             Cancel |  | ||||||
|                         </button> |  | ||||||
|                     </footer> |  | ||||||
|                 )} |  | ||||||
|             </div> |  | ||||||
|             <button |  | ||||||
|                 class="modal-close is-large close-modal" |  | ||||||
|                 aria-label="close" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(false); |  | ||||||
|                 }} |  | ||||||
|             ></button> |  | ||||||
|         </div>, |  | ||||||
|         body, |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,80 +0,0 @@ | |||||||
| import { useReminder } from "./ReminderContext"; |  | ||||||
| import { useFlash } from "../App/FlashContext"; |  | ||||||
|  |  | ||||||
| export const Attachment = () => { |  | ||||||
|     const [{ attachment_name }, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     const flash = useFlash(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="file is-small is-boxed"> |  | ||||||
|             <label class="file-label"> |  | ||||||
|                 <input |  | ||||||
|                     class="file-input" |  | ||||||
|                     type="file" |  | ||||||
|                     name="attachment" |  | ||||||
|                     onInput={async (ev) => { |  | ||||||
|                         const input = ev.currentTarget; |  | ||||||
|  |  | ||||||
|                         let file = input.files[0]; |  | ||||||
|  |  | ||||||
|                         if (file.size >= 8 * 1024 * 1024) { |  | ||||||
|                             flash({ message: "File too large (max. 8MB).", type: "error" }); |  | ||||||
|                             return; |  | ||||||
|                         } |  | ||||||
|  |  | ||||||
|                         let attachment: string = await new Promise((resolve) => { |  | ||||||
|                             let fileReader = new FileReader(); |  | ||||||
|                             fileReader.onload = () => resolve(fileReader.result as string); |  | ||||||
|                             fileReader.readAsDataURL(file); |  | ||||||
|                         }); |  | ||||||
|                         attachment = attachment.split(",")[1]; |  | ||||||
|                         const attachment_name = file.name; |  | ||||||
|  |  | ||||||
|                         setReminder((reminder) => ({ |  | ||||||
|                             ...reminder, |  | ||||||
|                             attachment, |  | ||||||
|                             attachment_name, |  | ||||||
|                         })); |  | ||||||
|                     }} |  | ||||||
|                 ></input> |  | ||||||
|                 <span class="file-cta"> |  | ||||||
|                     <span |  | ||||||
|                         class="file-label" |  | ||||||
|                         style={{ |  | ||||||
|                             maxWidth: "200px", |  | ||||||
|                         }} |  | ||||||
|                     > |  | ||||||
|                         {attachment_name || "Add Attachment"} |  | ||||||
|                     </span> |  | ||||||
|                     <span class="file-icon"> |  | ||||||
|                         <i class="fas fa-upload"></i> |  | ||||||
|                     </span> |  | ||||||
|                 </span> |  | ||||||
|             </label> |  | ||||||
|             {attachment_name && ( |  | ||||||
|                 <> |  | ||||||
|                     <button |  | ||||||
|                         onClick={() => { |  | ||||||
|                             setReminder((reminder) => ({ |  | ||||||
|                                 ...reminder, |  | ||||||
|                                 attachment: null, |  | ||||||
|                                 attachment_name: null, |  | ||||||
|                             })); |  | ||||||
|                         }} |  | ||||||
|                         style={{ |  | ||||||
|                             border: "none", |  | ||||||
|                             background: "none", |  | ||||||
|                             cursor: "pointer", |  | ||||||
|                         }} |  | ||||||
|                     > |  | ||||||
|                         <span class="sr-only">Remove attachment</span> |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fas fa-trash"></i> |  | ||||||
|                         </span> |  | ||||||
|                     </button> |  | ||||||
|                 </> |  | ||||||
|             )} |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,28 +0,0 @@ | |||||||
| import { ImagePicker } from "./ImagePicker"; |  | ||||||
| import { useReminder } from "./ReminderContext"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const Avatar = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const [reminder, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     return guild ? ( |  | ||||||
|         <ImagePicker |  | ||||||
|             class="is-rounded avatar" |  | ||||||
|             url={reminder.avatar || "/static/img/icon.png"} |  | ||||||
|             alt="Image for discord avatar" |  | ||||||
|             setImage={(url: string) => { |  | ||||||
|                 setReminder((reminder) => ({ |  | ||||||
|                     ...reminder, |  | ||||||
|                     avatar: url, |  | ||||||
|                 })); |  | ||||||
|             }} |  | ||||||
|         ></ImagePicker> |  | ||||||
|     ) : ( |  | ||||||
|         <img |  | ||||||
|             class="is-rounded avatar" |  | ||||||
|             alt="Image for discord avatar" |  | ||||||
|             src={"/static/img/icon.png"} |  | ||||||
|         ></img> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,136 +0,0 @@ | |||||||
| import { LoadTemplate } from "../LoadTemplate"; |  | ||||||
| import { useReminder } from "../ReminderContext"; |  | ||||||
| import { useMutation, useQueryClient } from "react-query"; |  | ||||||
| import { postGuildReminder, postGuildTemplate, postUserReminder } from "../../../api"; |  | ||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { ICON_FLASH_TIME } from "../../../consts"; |  | ||||||
| import { useFlash } from "../../App/FlashContext"; |  | ||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const CreateButtonRow = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const [reminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     const [recentlyCreated, setRecentlyCreated] = useState(false); |  | ||||||
|     const [templateRecentlyCreated, setTemplateRecentlyCreated] = useState(false); |  | ||||||
|  |  | ||||||
|     const flash = useFlash(); |  | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|     const mutation = useMutation({ |  | ||||||
|         ...(guild ? postGuildReminder(guild) : postUserReminder()), |  | ||||||
|         onError: (error) => { |  | ||||||
|             flash({ |  | ||||||
|                 message: `An error occurred: ${error}`, |  | ||||||
|                 type: "error", |  | ||||||
|             }); |  | ||||||
|         }, |  | ||||||
|         onSuccess: (data) => { |  | ||||||
|             if (data.error) { |  | ||||||
|                 flash({ |  | ||||||
|                     message: data.error, |  | ||||||
|                     type: "error", |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 flash({ |  | ||||||
|                     message: "Reminder created", |  | ||||||
|                     type: "success", |  | ||||||
|                 }); |  | ||||||
|                 if (guild) { |  | ||||||
|                     queryClient.invalidateQueries({ |  | ||||||
|                         queryKey: ["GUILD_REMINDERS", guild], |  | ||||||
|                     }); |  | ||||||
|                 } else { |  | ||||||
|                     queryClient.invalidateQueries({ |  | ||||||
|                         queryKey: ["USER_REMINDERS"], |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
|                 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 ( |  | ||||||
|         <div class="button-row"> |  | ||||||
|             <div class="button-row-reminder"> |  | ||||||
|                 <button |  | ||||||
|                     class="button is-success" |  | ||||||
|                     onClick={() => { |  | ||||||
|                         mutation.mutate(reminder); |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     <span>Create Reminder</span>{" "} |  | ||||||
|                     {mutation.isLoading ? ( |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fas fa-spin fa-cog"></i> |  | ||||||
|                         </span> |  | ||||||
|                     ) : recentlyCreated ? ( |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fas fa-check"></i> |  | ||||||
|                         </span> |  | ||||||
|                     ) : ( |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fas fa-sparkles"></i> |  | ||||||
|                         </span> |  | ||||||
|                     )} |  | ||||||
|                 </button> |  | ||||||
|             </div> |  | ||||||
|             {guild && ( |  | ||||||
|                 <div class="button-row-template"> |  | ||||||
|                     <div> |  | ||||||
|                         <button |  | ||||||
|                             class="button is-success is-outlined" |  | ||||||
|                             onClick={() => { |  | ||||||
|                                 templateMutation.mutate(reminder); |  | ||||||
|                             }} |  | ||||||
|                         > |  | ||||||
|                             <span>Create Template</span>{" "} |  | ||||||
|                             {templateMutation.isLoading ? ( |  | ||||||
|                                 <span class="icon"> |  | ||||||
|                                     <i class="fas fa-spin fa-cog"></i> |  | ||||||
|                                 </span> |  | ||||||
|                             ) : templateRecentlyCreated ? ( |  | ||||||
|                                 <span class="icon"> |  | ||||||
|                                     <i class="fas fa-check"></i> |  | ||||||
|                                 </span> |  | ||||||
|                             ) : ( |  | ||||||
|                                 <span class="icon"> |  | ||||||
|                                     <i class="fas fa-file-spreadsheet"></i> |  | ||||||
|                                 </span> |  | ||||||
|                             )} |  | ||||||
|                         </button> |  | ||||||
|                     </div> |  | ||||||
|                     <div> |  | ||||||
|                         <LoadTemplate /> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             )} |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,81 +0,0 @@ | |||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { Modal } from "../../Modal"; |  | ||||||
| import { useMutation, useQueryClient } from "react-query"; |  | ||||||
| import { useReminder } from "../ReminderContext"; |  | ||||||
| import { deleteReminder } from "../../../api"; |  | ||||||
| import { useParams } from "wouter"; |  | ||||||
| import { useFlash } from "../../App/FlashContext"; |  | ||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const DeleteButton = () => { |  | ||||||
|     const [modalOpen, setModalOpen] = useState(false); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <button |  | ||||||
|                 class="button is-danger delete-reminder" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(true); |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 Delete |  | ||||||
|             </button> |  | ||||||
|             {modalOpen && <DeleteModal setModalOpen={setModalOpen}></DeleteModal>} |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const DeleteModal = ({ setModalOpen }) => { |  | ||||||
|     const [reminder] = useReminder(); |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     const flash = useFlash(); |  | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|     const mutation = useMutation({ |  | ||||||
|         ...deleteReminder(), |  | ||||||
|         onSuccess: () => { |  | ||||||
|             flash({ |  | ||||||
|                 message: "Reminder deleted", |  | ||||||
|                 type: "success", |  | ||||||
|             }); |  | ||||||
|             if (guild) { |  | ||||||
|                 queryClient.invalidateQueries({ |  | ||||||
|                     queryKey: ["GUILD_REMINDERS", guild], |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 queryClient.invalidateQueries({ |  | ||||||
|                     queryKey: ["USER_REMINDERS"], |  | ||||||
|                 }); |  | ||||||
|             } |  | ||||||
|             setModalOpen(false); |  | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <Modal setModalOpen={setModalOpen} title={"Delete Reminder"}> |  | ||||||
|             <> |  | ||||||
|                 <p>This reminder will be permanently deleted. Are you sure?</p> |  | ||||||
|                 <br></br> |  | ||||||
|                 <div class="has-text-centered"> |  | ||||||
|                     <button |  | ||||||
|                         class="button is-danger" |  | ||||||
|                         onClick={() => { |  | ||||||
|                             mutation.mutate(reminder); |  | ||||||
|                         }} |  | ||||||
|                         disabled={mutation.isLoading} |  | ||||||
|                     > |  | ||||||
|                         Delete |  | ||||||
|                     </button> |  | ||||||
|                     <button |  | ||||||
|                         class="button is-light close-modal" |  | ||||||
|                         onClick={() => { |  | ||||||
|                             setModalOpen(false); |  | ||||||
|                         }} |  | ||||||
|                     > |  | ||||||
|                         Cancel |  | ||||||
|                     </button> |  | ||||||
|                 </div> |  | ||||||
|             </> |  | ||||||
|         </Modal> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,89 +0,0 @@ | |||||||
| import { useRef, useState } from "preact/hooks"; |  | ||||||
| import { useMutation, useQueryClient } from "react-query"; |  | ||||||
| import { patchGuildReminder, patchUserReminder } from "../../../api"; |  | ||||||
| import { ICON_FLASH_TIME } from "../../../consts"; |  | ||||||
| import { DeleteButton } from "./DeleteButton"; |  | ||||||
| import { useReminder } from "../ReminderContext"; |  | ||||||
| import { useFlash } from "../../App/FlashContext"; |  | ||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const EditButtonRow = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const [reminder, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     const [recentlySaved, setRecentlySaved] = useState(false); |  | ||||||
|  |  | ||||||
|     const iconFlashTimeout = useRef(0); |  | ||||||
|  |  | ||||||
|     const flash = useFlash(); |  | ||||||
|     const mutation = useMutation({ |  | ||||||
|         ...(guild ? patchGuildReminder(guild) : patchUserReminder()), |  | ||||||
|         onError: (error) => { |  | ||||||
|             flash({ |  | ||||||
|                 message: `An error occurred: ${error}`, |  | ||||||
|                 type: "error", |  | ||||||
|             }); |  | ||||||
|         }, |  | ||||||
|         onSuccess: (response) => { |  | ||||||
|             if (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 ( |  | ||||||
|         <div class="button-row-edit"> |  | ||||||
|             <button |  | ||||||
|                 class="button is-success save-btn" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     mutation.mutate(reminder); |  | ||||||
|                 }} |  | ||||||
|                 disabled={mutation.isLoading} |  | ||||||
|             > |  | ||||||
|                 <span>Save</span>{" "} |  | ||||||
|                 {mutation.isLoading ? ( |  | ||||||
|                     <span class="icon"> |  | ||||||
|                         <i class="fas fa-spin fa-cog"></i> |  | ||||||
|                     </span> |  | ||||||
|                 ) : recentlySaved ? ( |  | ||||||
|                     <span class="icon"> |  | ||||||
|                         <i class="fas fa-check"></i> |  | ||||||
|                     </span> |  | ||||||
|                 ) : ( |  | ||||||
|                     <span class="icon"> |  | ||||||
|                         <i class="fas fa-save"></i> |  | ||||||
|                     </span> |  | ||||||
|                 )} |  | ||||||
|             </button> |  | ||||||
|             <button |  | ||||||
|                 class="button is-warning" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     mutation.mutate({ |  | ||||||
|                         ...reminder, |  | ||||||
|                         enabled: !reminder.enabled, |  | ||||||
|                     }); |  | ||||||
|                 }} |  | ||||||
|                 disabled={mutation.isLoading} |  | ||||||
|             > |  | ||||||
|                 {reminder.enabled ? "Disable" : "Enable"} |  | ||||||
|             </button> |  | ||||||
|             <DeleteButton /> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,32 +0,0 @@ | |||||||
| import { useQuery } from "react-query"; |  | ||||||
| import { fetchGuildChannels } from "../../api"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const ChannelSelector = ({ channel, setChannel }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const { isSuccess, data } = useQuery(fetchGuildChannels(guild)); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="control has-icons-left"> |  | ||||||
|             <div class="select"> |  | ||||||
|                 <select |  | ||||||
|                     name="channel" |  | ||||||
|                     class="channel-selector" |  | ||||||
|                     onInput={(ev) => { |  | ||||||
|                         setChannel(ev.currentTarget.value); |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     {isSuccess && |  | ||||||
|                         data.map((c) => ( |  | ||||||
|                             <option value={c.id} selected={c.id === channel}> |  | ||||||
|                                 {c.name} |  | ||||||
|                             </option> |  | ||||||
|                         ))} |  | ||||||
|                 </select> |  | ||||||
|             </div> |  | ||||||
|             <div class="icon is-small is-left"> |  | ||||||
|                 <i class="fas fa-hashtag"></i> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,32 +0,0 @@ | |||||||
| import { useReminder } from "./ReminderContext"; |  | ||||||
| import { useRef } from "preact/hooks"; |  | ||||||
| import { Mentions } from "../App/Mentions"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const Content = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const [reminder, setReminder] = useReminder(); |  | ||||||
|     const input = useRef(null); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             {guild && <Mentions input={input} />} |  | ||||||
|             <label class="is-sr-only">Content</label> |  | ||||||
|             <textarea |  | ||||||
|                 class="message-input autoresize discord-content" |  | ||||||
|                 placeholder="Content..." |  | ||||||
|                 maxlength={2000} |  | ||||||
|                 name="content" |  | ||||||
|                 rows={1} |  | ||||||
|                 ref={input} |  | ||||||
|                 value={reminder.content} |  | ||||||
|                 onInput={(ev) => { |  | ||||||
|                     setReminder((reminder) => ({ |  | ||||||
|                         ...reminder, |  | ||||||
|                         content: ev.currentTarget.value, |  | ||||||
|                     })); |  | ||||||
|                 }} |  | ||||||
|             ></textarea> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,108 +0,0 @@ | |||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { fetchGuildChannels, Reminder } from "../../api"; |  | ||||||
| import { DateTime } from "luxon"; |  | ||||||
| import { CreateButtonRow } from "./ButtonRow/CreateButtonRow"; |  | ||||||
| import { TopBar } from "./TopBar"; |  | ||||||
| import { Message } from "./Message"; |  | ||||||
| import { Settings } from "./Settings"; |  | ||||||
| import { ReminderContext } from "./ReminderContext"; |  | ||||||
| import { useQuery } from "react-query"; |  | ||||||
| import "./styles.scss"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
| import { DEFAULT_COLOR } from "./Embed"; |  | ||||||
|  |  | ||||||
| function defaultReminder(): Reminder { |  | ||||||
|     return { |  | ||||||
|         attachment: null, |  | ||||||
|         attachment_name: null, |  | ||||||
|         avatar: null, |  | ||||||
|         channel: null, |  | ||||||
|         content: "", |  | ||||||
|         embed_author: "", |  | ||||||
|         embed_author_url: null, |  | ||||||
|         embed_color: DEFAULT_COLOR, |  | ||||||
|         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().setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss"), |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const CreateReminder = () => { |  | ||||||
|     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 [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 ( |  | ||||||
|         <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> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,50 +0,0 @@ | |||||||
| 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"; |  | ||||||
| import "./styles.scss"; |  | ||||||
|  |  | ||||||
| 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 ( |  | ||||||
|         <ReminderContext.Provider value={[reminder, setReminder]} key={reminder.uid}> |  | ||||||
|             <div |  | ||||||
|                 class={collapsed ? "reminderContent is-collapsed" : "reminderContent"} |  | ||||||
|                 id={`reminder-${reminder.uid.slice(0, 12)}`} |  | ||||||
|             > |  | ||||||
|                 <TopBar |  | ||||||
|                     isCreating={false} |  | ||||||
|                     toggleCollapsed={() => { |  | ||||||
|                         setCollapsed(!collapsed); |  | ||||||
|                     }} |  | ||||||
|                 /> |  | ||||||
|                 <div class="columns reminder-settings"> |  | ||||||
|                     <Message /> |  | ||||||
|                     <Settings /> |  | ||||||
|                 </div> |  | ||||||
|                 <EditButtonRow /> |  | ||||||
|             </div> |  | ||||||
|         </ReminderContext.Provider> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,58 +0,0 @@ | |||||||
| import { ImagePicker } from "../ImagePicker"; |  | ||||||
| import { Reminder } from "../../../api"; |  | ||||||
| import { Mentions } from "../../App/Mentions"; |  | ||||||
| import { useRef } from "preact/hooks"; |  | ||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
|  |  | ||||||
| type Props = { |  | ||||||
|     name: string; |  | ||||||
|     icon: string; |  | ||||||
|     setReminder: (r: (reminder: Reminder) => Reminder) => void; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const Author = ({ name, icon, setReminder }: Props) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const input = useRef(null); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="embed-author-box"> |  | ||||||
|             <div class="a"> |  | ||||||
|                 <p class="image is-24x24 customizable"> |  | ||||||
|                     <ImagePicker |  | ||||||
|                         class="is-rounded embed_author_url" |  | ||||||
|                         url={icon} |  | ||||||
|                         alt="Image for embed author" |  | ||||||
|                         setImage={(url: string) => { |  | ||||||
|                             setReminder((reminder) => ({ |  | ||||||
|                                 ...reminder, |  | ||||||
|                                 embed_author_url: url, |  | ||||||
|                             })); |  | ||||||
|                         }} |  | ||||||
|                     ></ImagePicker> |  | ||||||
|                 </p> |  | ||||||
|             </div> |  | ||||||
|  |  | ||||||
|             <div class="b"> |  | ||||||
|                 {guild && <Mentions input={input} />} |  | ||||||
|                 <label class="is-sr-only" for="embedAuthor"> |  | ||||||
|                     Embed Author |  | ||||||
|                 </label> |  | ||||||
|                 <textarea |  | ||||||
|                     class="discord-embed-author message-input autoresize" |  | ||||||
|                     placeholder="Embed Author..." |  | ||||||
|                     rows={1} |  | ||||||
|                     ref={input} |  | ||||||
|                     maxlength={256} |  | ||||||
|                     name="embed_author" |  | ||||||
|                     value={name} |  | ||||||
|                     onInput={(ev) => { |  | ||||||
|                         setReminder((reminder) => ({ |  | ||||||
|                             ...reminder, |  | ||||||
|                             embed_author: ev.currentTarget.value, |  | ||||||
|                         })); |  | ||||||
|                     }} |  | ||||||
|                 ></textarea> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,70 +0,0 @@ | |||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { HexColorPicker } from "react-colorful"; |  | ||||||
| import { Modal } from "../../Modal"; |  | ||||||
| import { Reminder } from "../../../api"; |  | ||||||
| import { useDebouncedCallback } from "use-debounce"; |  | ||||||
|  |  | ||||||
| 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 && ( |  | ||||||
|                 <ColorModal |  | ||||||
|                     color={color} |  | ||||||
|                     setModalOpen={setModalOpen} |  | ||||||
|                     setReminder={setReminder} |  | ||||||
|                 ></ColorModal> |  | ||||||
|             )} |  | ||||||
|             <button |  | ||||||
|                 class="change-color button is-rounded is-small" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(true); |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 <span class="is-sr-only">Choose embed color</span> |  | ||||||
|                 <i class="fas fa-eye-dropper"></i> |  | ||||||
|             </button> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const ColorModal = ({ setModalOpen, color, setReminder }) => { |  | ||||||
|     const setDebounced = useDebouncedCallback((color) => { |  | ||||||
|         setReminder((reminder: Reminder) => ({ |  | ||||||
|             ...reminder, |  | ||||||
|             embed_color: colorToInt(color), |  | ||||||
|         })); |  | ||||||
|     }, 100); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <Modal setModalOpen={setModalOpen} title={"Select Color"}> |  | ||||||
|             <div class="colorpicker-container"> |  | ||||||
|                 <HexColorPicker |  | ||||||
|                     color={color} |  | ||||||
|                     onChange={(color: string) => { |  | ||||||
|                         setDebounced(color); |  | ||||||
|                     }} |  | ||||||
|                 ></HexColorPicker> |  | ||||||
|             </div> |  | ||||||
|             <br></br> |  | ||||||
|             <input |  | ||||||
|                 class="input" |  | ||||||
|                 id="colorInput" |  | ||||||
|                 value={color} |  | ||||||
|                 onInput={(ev) => { |  | ||||||
|                     setDebounced(ev.currentTarget.value); |  | ||||||
|                 }} |  | ||||||
|             ></input> |  | ||||||
|         </Modal> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,29 +0,0 @@ | |||||||
| import { Mentions } from "../../App/Mentions"; |  | ||||||
| import { useRef } from "preact/hooks"; |  | ||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const Description = ({ description, onInput }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const input = useRef(null); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             {guild && <Mentions input={input} />} |  | ||||||
|             <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} |  | ||||||
|                 ref={input} |  | ||||||
|                 value={description} |  | ||||||
|                 onInput={(ev) => { |  | ||||||
|                     onInput(ev.currentTarget.value); |  | ||||||
|                 }} |  | ||||||
|             ></textarea> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,66 +0,0 @@ | |||||||
| import { useRef } from "preact/hooks"; |  | ||||||
| import { Mentions } from "../../../App/Mentions"; |  | ||||||
| import { useGuild } from "../../../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const Field = ({ title, value, inline, index, onUpdate }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const input = useRef(null); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div data-inlined={inline ? "1" : "0"} class="embed-field-box" key={index}> |  | ||||||
|             <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[]" |  | ||||||
|                     value={title} |  | ||||||
|                     onInput={(ev) => |  | ||||||
|                         onUpdate({ |  | ||||||
|                             index, |  | ||||||
|                             title: ev.currentTarget.value, |  | ||||||
|                         }) |  | ||||||
|                     } |  | ||||||
|                 /> |  | ||||||
|                 {(value !== "" || title !== "") && ( |  | ||||||
|                     <button |  | ||||||
|                         class="button is-small inline-btn" |  | ||||||
|                         onClick={() => { |  | ||||||
|                             onUpdate({ |  | ||||||
|                                 index, |  | ||||||
|                                 inline: !inline, |  | ||||||
|                             }); |  | ||||||
|                         }} |  | ||||||
|                     > |  | ||||||
|                         <span class="is-sr-only">Toggle field inline</span> |  | ||||||
|                         <i class="fas fa-arrows-h"></i> |  | ||||||
|                     </button> |  | ||||||
|                 )} |  | ||||||
|             </div> |  | ||||||
|  |  | ||||||
|             {guild && <Mentions input={input} />} |  | ||||||
|             <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} |  | ||||||
|                 ref={input} |  | ||||||
|                 value={value} |  | ||||||
|                 onInput={(ev) => |  | ||||||
|                     onUpdate({ |  | ||||||
|                         index, |  | ||||||
|                         value: ev.currentTarget.value, |  | ||||||
|                     }) |  | ||||||
|                 } |  | ||||||
|             ></textarea> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,37 +0,0 @@ | |||||||
| import { useReminder } from "../../ReminderContext"; |  | ||||||
| import { Field } from "./Field"; |  | ||||||
|  |  | ||||||
| export const Fields = () => { |  | ||||||
|     const [{ embed_fields }, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class={"embed-multifield-box"}> |  | ||||||
|             {[...embed_fields, { value: "", title: "", inline: true }].map((field, index) => ( |  | ||||||
|                 <Field |  | ||||||
|                     {...field} |  | ||||||
|                     index={index} |  | ||||||
|                     onUpdate={({ index, ...props }) => { |  | ||||||
|                         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), |  | ||||||
|                         })); |  | ||||||
|                     }} |  | ||||||
|                 ></Field> |  | ||||||
|             ))} |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,53 +0,0 @@ | |||||||
| import { Reminder } from "../../../api"; |  | ||||||
| import { ImagePicker } from "../ImagePicker"; |  | ||||||
| import { Mentions } from "../../App/Mentions"; |  | ||||||
| import { useRef } from "preact/hooks"; |  | ||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
|  |  | ||||||
| type Props = { |  | ||||||
|     footer: string; |  | ||||||
|     icon: string; |  | ||||||
|     setReminder: (r: (reminder: Reminder) => Reminder) => void; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const Footer = ({ footer, icon, setReminder }: Props) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const input = useRef(null); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="embed-footer-box"> |  | ||||||
|             <p class="image is-20x20 customizable"> |  | ||||||
|                 <ImagePicker |  | ||||||
|                     class="is-rounded embed_footer_url" |  | ||||||
|                     url={icon} |  | ||||||
|                     alt="Footer profile-like image" |  | ||||||
|                     setImage={(url: string) => { |  | ||||||
|                         setReminder((reminder) => ({ |  | ||||||
|                             ...reminder, |  | ||||||
|                             embed_footer_url: url, |  | ||||||
|                         })); |  | ||||||
|                     }} |  | ||||||
|                 ></ImagePicker> |  | ||||||
|             </p> |  | ||||||
|             <label class="is-sr-only" for="embedFooter"> |  | ||||||
|                 Embed Footer text |  | ||||||
|             </label> |  | ||||||
|             {guild && <Mentions input={input} />} |  | ||||||
|             <textarea |  | ||||||
|                 class="discord-embed-footer message-input autoresize " |  | ||||||
|                 placeholder="Embed Footer..." |  | ||||||
|                 maxlength={2048} |  | ||||||
|                 name="embed_footer" |  | ||||||
|                 rows={1} |  | ||||||
|                 ref={input} |  | ||||||
|                 value={footer} |  | ||||||
|                 onInput={(ev) => { |  | ||||||
|                     setReminder((reminder) => ({ |  | ||||||
|                         ...reminder, |  | ||||||
|                         embed_footer: ev.currentTarget.value, |  | ||||||
|                     })); |  | ||||||
|                 }} |  | ||||||
|             ></textarea> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,29 +0,0 @@ | |||||||
| import { useRef } from "preact/hooks"; |  | ||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
| import { Mentions } from "../../App/Mentions"; |  | ||||||
|  |  | ||||||
| export const Title = ({ title, onInput }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const input = useRef(null); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             {guild && <Mentions input={input} />} |  | ||||||
|             <label class="is-sr-only" for="embedTitle"> |  | ||||||
|                 Embed Title |  | ||||||
|             </label> |  | ||||||
|             <textarea |  | ||||||
|                 class="discord-title message-input  autoresize" |  | ||||||
|                 placeholder="Embed Title..." |  | ||||||
|                 maxlength={256} |  | ||||||
|                 rows={1} |  | ||||||
|                 ref={input} |  | ||||||
|                 name="embed_title" |  | ||||||
|                 value={title} |  | ||||||
|                 onInput={(ev) => { |  | ||||||
|                     onInput(ev.currentTarget.value); |  | ||||||
|                 }} |  | ||||||
|             ></textarea> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,100 +0,0 @@ | |||||||
| 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")}`; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const DEFAULT_COLOR = 9418359; |  | ||||||
|  |  | ||||||
| export const Embed = () => { |  | ||||||
|     const [reminder, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div |  | ||||||
|             class="discord-embed" |  | ||||||
|             style={{ |  | ||||||
|                 borderLeftColor: intToColor(reminder.embed_color || DEFAULT_COLOR), |  | ||||||
|             }} |  | ||||||
|         > |  | ||||||
|             <div class="embed-body"> |  | ||||||
|                 <Color |  | ||||||
|                     color={intToColor(reminder.embed_color || DEFAULT_COLOR)} |  | ||||||
|                     setReminder={setReminder} |  | ||||||
|                 ></Color> |  | ||||||
|                 <div class="a"> |  | ||||||
|                     <Author |  | ||||||
|                         name={reminder.embed_author} |  | ||||||
|                         icon={reminder.embed_author_url} |  | ||||||
|                         setReminder={setReminder} |  | ||||||
|                     ></Author> |  | ||||||
|                     <Title |  | ||||||
|                         title={reminder.embed_title} |  | ||||||
|                         onInput={(title: string) => |  | ||||||
|                             setReminder((reminder: Reminder) => ({ |  | ||||||
|                                 ...reminder, |  | ||||||
|                                 embed_title: title, |  | ||||||
|                             })) |  | ||||||
|                         } |  | ||||||
|                     ></Title> |  | ||||||
|                     <br></br> |  | ||||||
|                     <Description |  | ||||||
|                         description={reminder.embed_description} |  | ||||||
|                         onInput={(description: string) => |  | ||||||
|                             setReminder((reminder: Reminder) => ({ |  | ||||||
|                                 ...reminder, |  | ||||||
|                                 embed_description: description, |  | ||||||
|                             })) |  | ||||||
|                         } |  | ||||||
|                     /> |  | ||||||
|                     <br /> |  | ||||||
|  |  | ||||||
|                     <Fields /> |  | ||||||
|                 </div> |  | ||||||
|  |  | ||||||
|                 <div class="b"> |  | ||||||
|                     <p class="image thumbnail customizable"> |  | ||||||
|                         <ImagePicker |  | ||||||
|                             class="embed_thumbnail_url" |  | ||||||
|                             url={reminder.embed_thumbnail_url} |  | ||||||
|                             alt="Square thumbnail embedded image" |  | ||||||
|                             setImage={(url: string) => |  | ||||||
|                                 setReminder((reminder: Reminder) => ({ |  | ||||||
|                                     ...reminder, |  | ||||||
|                                     embed_thumbnail_url: url || null, |  | ||||||
|                                 })) |  | ||||||
|                             } |  | ||||||
|                         /> |  | ||||||
|                     </p> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|  |  | ||||||
|             <p class="image is-400x300 customizable"> |  | ||||||
|                 <ImagePicker |  | ||||||
|                     class="embed_image_url" |  | ||||||
|                     url={reminder.embed_image_url} |  | ||||||
|                     alt="Large embedded image" |  | ||||||
|                     setImage={(url: string) => |  | ||||||
|                         setReminder((reminder: Reminder) => ({ |  | ||||||
|                             ...reminder, |  | ||||||
|                             embed_image_url: url || null, |  | ||||||
|                         })) |  | ||||||
|                     } |  | ||||||
|                 /> |  | ||||||
|             </p> |  | ||||||
|  |  | ||||||
|             <Footer |  | ||||||
|                 footer={reminder.embed_footer} |  | ||||||
|                 icon={reminder.embed_footer_url} |  | ||||||
|                 setReminder={setReminder} |  | ||||||
|             /> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,55 +0,0 @@ | |||||||
| import { Modal } from "../Modal"; |  | ||||||
| import { useState } from "preact/hooks"; |  | ||||||
|  |  | ||||||
| export const ImagePicker = ({ alt, url, setImage, ...props }) => { |  | ||||||
|     const [modalOpen, setModalOpen] = useState(false); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <a |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(true); |  | ||||||
|                 }} |  | ||||||
|                 role={"button"} |  | ||||||
|             > |  | ||||||
|                 <img {...props} src={url || "/static/img/bg.webp"} alt={alt}></img> |  | ||||||
|             </a> |  | ||||||
|             {modalOpen && ( |  | ||||||
|                 <ImagePickerModal |  | ||||||
|                     setModalOpen={setModalOpen} |  | ||||||
|                     setImage={setImage} |  | ||||||
|                 ></ImagePickerModal> |  | ||||||
|             )} |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const ImagePickerModal = ({ setModalOpen, setImage }) => { |  | ||||||
|     const [value, setValue] = useState(""); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <Modal |  | ||||||
|             setModalOpen={setModalOpen} |  | ||||||
|             title={"Enter Image URL"} |  | ||||||
|             onSubmit={() => { |  | ||||||
|                 setImage(value); |  | ||||||
|                 setModalOpen(false); |  | ||||||
|             }} |  | ||||||
|             onSubmitText={"Save"} |  | ||||||
|         > |  | ||||||
|             <p> |  | ||||||
|                 Please note: if you attach an image directly from Discord, it will not be visible in |  | ||||||
|                 the dashboard, but will be visible on reminders. Other image-sharing sites such as |  | ||||||
|                 Imgur don't have this issue. |  | ||||||
|             </p> |  | ||||||
|             <input |  | ||||||
|                 class="input" |  | ||||||
|                 id="urlInput" |  | ||||||
|                 placeholder="Image URL..." |  | ||||||
|                 onInput={(ev) => { |  | ||||||
|                     setValue(ev.currentTarget.value); |  | ||||||
|                 }} |  | ||||||
|             ></input> |  | ||||||
|         </Modal> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,173 +0,0 @@ | |||||||
| import { useCallback, useEffect, useState } from "preact/hooks"; |  | ||||||
| import "./style.scss"; |  | ||||||
|  |  | ||||||
| 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 ( |  | ||||||
|         <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="" |  | ||||||
|                                 value={months || placeholder()} |  | ||||||
|                                 onInput={(ev) => { |  | ||||||
|                                     const value = ev.currentTarget.value; |  | ||||||
|                                     if (value && !isNaN(parseInt(value))) { |  | ||||||
|                                         setMonths(parseInt(ev.currentTarget.value)); |  | ||||||
|                                     } |  | ||||||
|                                 }} |  | ||||||
|                             ></input>{" "} |  | ||||||
|                             <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="" |  | ||||||
|                                 value={days || placeholder()} |  | ||||||
|                                 onInput={(ev) => { |  | ||||||
|                                     const value = ev.currentTarget.value; |  | ||||||
|                                     if (value && !isNaN(parseInt(value))) { |  | ||||||
|                                         setDays(parseInt(ev.currentTarget.value)); |  | ||||||
|                                     } |  | ||||||
|                                 }} |  | ||||||
|                             ></input>{" "} |  | ||||||
|                             <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" |  | ||||||
|                                 value={hours || placeholder()} |  | ||||||
|                                 onInput={(ev) => { |  | ||||||
|                                     const value = ev.currentTarget.value; |  | ||||||
|                                     if (value && !isNaN(parseInt(value))) { |  | ||||||
|                                         setHours(parseInt(ev.currentTarget.value)); |  | ||||||
|                                     } |  | ||||||
|                                 }} |  | ||||||
|                             ></input> |  | ||||||
|                             : |  | ||||||
|                         </label> |  | ||||||
|                         <label> |  | ||||||
|                             <span class="is-sr-only">Interval minutes</span> |  | ||||||
|                             <input |  | ||||||
|                                 class="w2" |  | ||||||
|                                 type="text" |  | ||||||
|                                 pattern="\d*" |  | ||||||
|                                 name="interval_minutes" |  | ||||||
|                                 maxlength={2} |  | ||||||
|                                 placeholder="MM" |  | ||||||
|                                 value={minutes || placeholder()} |  | ||||||
|                                 onInput={(ev) => { |  | ||||||
|                                     const value = ev.currentTarget.value; |  | ||||||
|                                     if (value && !isNaN(parseInt(value))) { |  | ||||||
|                                         setMinutes(parseInt(ev.currentTarget.value)); |  | ||||||
|                                     } |  | ||||||
|                                 }} |  | ||||||
|                             ></input> |  | ||||||
|                             : |  | ||||||
|                         </label> |  | ||||||
|                         <label> |  | ||||||
|                             <span class="is-sr-only">Interval seconds</span> |  | ||||||
|                             <input |  | ||||||
|                                 class="w2" |  | ||||||
|                                 type="text" |  | ||||||
|                                 pattern="\d*" |  | ||||||
|                                 name="interval_seconds" |  | ||||||
|                                 maxlength={2} |  | ||||||
|                                 placeholder="SS" |  | ||||||
|                                 value={seconds || placeholder()} |  | ||||||
|                                 onInput={(ev) => { |  | ||||||
|                                     const value = ev.currentTarget.value; |  | ||||||
|                                     if (value && !isNaN(parseInt(value))) { |  | ||||||
|                                         setSeconds(parseInt(ev.currentTarget.value)); |  | ||||||
|                                     } |  | ||||||
|                                 }} |  | ||||||
|                             ></input> |  | ||||||
|                         </label> |  | ||||||
|                     </span> |  | ||||||
|                 </div> |  | ||||||
|                 <button |  | ||||||
|                     class="clear" |  | ||||||
|                     onClick={() => { |  | ||||||
|                         setMonths(0); |  | ||||||
|                         setDays(0); |  | ||||||
|                         setSeconds(0); |  | ||||||
|                         setMinutes(0); |  | ||||||
|                         setHours(0); |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     <span class="is-sr-only">Clear interval</span> |  | ||||||
|                     <span class="icon"> |  | ||||||
|                         <i class="fas fa-trash"></i> |  | ||||||
|                     </span> |  | ||||||
|                 </button> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,42 +0,0 @@ | |||||||
| div.interval-group { |  | ||||||
|     height: unset !important; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.interval-group .clear:focus { |  | ||||||
|     outline: none; |  | ||||||
|     box-shadow: none !important; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.interval-group .no-break { |  | ||||||
|     text-wrap: avoid; |  | ||||||
|     white-space: nowrap; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.interval-group .clear { |  | ||||||
|     border: none; |  | ||||||
|     background: none; |  | ||||||
|     padding: 1px; |  | ||||||
|     margin-right: -3px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.interval-group > .interval-group-left input { |  | ||||||
|     -webkit-appearance: none; |  | ||||||
|     border-style: none; |  | ||||||
|     background-color: #eee; |  | ||||||
|     font-size: 1rem; |  | ||||||
|     font-family: monospace; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.interval-group > .interval-group-left input.w2 { |  | ||||||
|     width: 3ch; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.interval-group > .interval-group-left input.w3 { |  | ||||||
|     width: 3ch; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.interval-group { |  | ||||||
|     display: flex; |  | ||||||
|     flex-direction: row; |  | ||||||
|     justify-content: space-between; |  | ||||||
| } |  | ||||||
| @@ -1,114 +0,0 @@ | |||||||
| 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 ( |  | ||||||
|         <div> |  | ||||||
|             <button |  | ||||||
|                 class="button is-outlined show-modal is-pulled-right" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(true); |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 Load Template |  | ||||||
|             </button> |  | ||||||
|             {modalOpen && <LoadTemplateModal setModalOpen={setModalOpen}></LoadTemplateModal>} |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| 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 ( |  | ||||||
|         <Modal setModalOpen={setModalOpen} title={"Load Template"}> |  | ||||||
|             <div class="control has-icons-left"> |  | ||||||
|                 <div class="select is-fullwidth"> |  | ||||||
|                     <select |  | ||||||
|                         id="templateSelect" |  | ||||||
|                         onChange={(ev) => { |  | ||||||
|                             setSelected(ev.currentTarget.value); |  | ||||||
|                         }} |  | ||||||
|                         disabled={mutation.isLoading} |  | ||||||
|                     > |  | ||||||
|                         <option disabled={true} selected={true}> |  | ||||||
|                             Choose template... |  | ||||||
|                         </option> |  | ||||||
|                         {isSuccess && |  | ||||||
|                             templates.map((template) => ( |  | ||||||
|                                 <option value={template.id}>{template.name}</option> |  | ||||||
|                             ))} |  | ||||||
|                     </select> |  | ||||||
|                 </div> |  | ||||||
|                 <div class="icon is-small is-left"> |  | ||||||
|                     <i class="fas fa-file-spreadsheet"></i> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|             <br></br> |  | ||||||
|             <div class="has-text-centered"> |  | ||||||
|                 <button |  | ||||||
|                     class="button is-success close-modal" |  | ||||||
|                     id="load-template" |  | ||||||
|                     style={{ margin: "2px" }} |  | ||||||
|                     onClick={() => { |  | ||||||
|                         const template = templates.find( |  | ||||||
|                             (template) => template.id.toString() === selected, |  | ||||||
|                         ); |  | ||||||
|  |  | ||||||
|                         setReminder((reminder) => ({ |  | ||||||
|                             ...reminder, |  | ||||||
|                             ...template, |  | ||||||
|                             // drop the template's ID |  | ||||||
|                             id: undefined, |  | ||||||
|                         })); |  | ||||||
|  |  | ||||||
|                         flash({ message: "Template loaded", type: "success" }); |  | ||||||
|                         setModalOpen(false); |  | ||||||
|                     }} |  | ||||||
|                     disabled={mutation.isLoading} |  | ||||||
|                 > |  | ||||||
|                     Load Template |  | ||||||
|                 </button> |  | ||||||
|                 <button |  | ||||||
|                     class="button is-danger" |  | ||||||
|                     id="delete-template" |  | ||||||
|                     style={{ margin: "2px" }} |  | ||||||
|                     onClick={() => { |  | ||||||
|                         const template = templates.find( |  | ||||||
|                             (template) => template.id.toString() === selected, |  | ||||||
|                         ); |  | ||||||
|  |  | ||||||
|                         mutation.mutate(template); |  | ||||||
|                     }} |  | ||||||
|                     disabled={mutation.isLoading} |  | ||||||
|                 > |  | ||||||
|                     Delete |  | ||||||
|                 </button> |  | ||||||
|             </div> |  | ||||||
|         </Modal> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| import { Username } from "./Username"; |  | ||||||
| import { Content } from "./Content"; |  | ||||||
| import { Embed } from "./Embed"; |  | ||||||
| import { Avatar } from "./Avatar"; |  | ||||||
|  |  | ||||||
| export const Message = () => ( |  | ||||||
|     <div class="column discord-frame"> |  | ||||||
|         <article class="media"> |  | ||||||
|             <figure class="media-left"> |  | ||||||
|                 <p class="image is-32x32 customizable"> |  | ||||||
|                     <Avatar /> |  | ||||||
|                 </p> |  | ||||||
|             </figure> |  | ||||||
|             <div class="media-content"> |  | ||||||
|                 <div class="content"> |  | ||||||
|                     <Username /> |  | ||||||
|                     <Content /> |  | ||||||
|                     <Embed /> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </article> |  | ||||||
|     </div> |  | ||||||
| ); |  | ||||||
| @@ -1,29 +0,0 @@ | |||||||
| import { useReminder } from "./ReminderContext"; |  | ||||||
|  |  | ||||||
| export const Name = () => { |  | ||||||
|     const [reminder, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <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} |  | ||||||
|                         value={reminder.name} |  | ||||||
|                         onInput={(ev) => { |  | ||||||
|                             setReminder((reminder) => ({ |  | ||||||
|                                 ...reminder, |  | ||||||
|                                 name: ev.currentTarget.value, |  | ||||||
|                             })); |  | ||||||
|                         }} |  | ||||||
|                     ></input> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,9 +0,0 @@ | |||||||
| 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); |  | ||||||
| @@ -1,128 +0,0 @@ | |||||||
| 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 { TimeInput } from "./TimeInput"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const Settings = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const { isSuccess: userFetched, data: userInfo } = useQuery(fetchUserInfo()); |  | ||||||
|  |  | ||||||
|     const [reminder, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     if (!userFetched) { |  | ||||||
|         return <></>; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="column settings"> |  | ||||||
|             {guild && ( |  | ||||||
|                 <div class="field channel-field"> |  | ||||||
|                     <div class="collapses"> |  | ||||||
|                         <label class="label" for="channelOption"> |  | ||||||
|                             Channel* |  | ||||||
|                         </label> |  | ||||||
|                     </div> |  | ||||||
|                     <ChannelSelector |  | ||||||
|                         channel={reminder.channel} |  | ||||||
|                         setChannel={(channel: string) => { |  | ||||||
|                             setReminder((reminder) => ({ |  | ||||||
|                                 ...reminder, |  | ||||||
|                                 channel: channel, |  | ||||||
|                             })); |  | ||||||
|                         }} |  | ||||||
|                     /> |  | ||||||
|                 </div> |  | ||||||
|             )} |  | ||||||
|  |  | ||||||
|             <div class="field"> |  | ||||||
|                 <div class="control"> |  | ||||||
|                     <label class="label collapses"> |  | ||||||
|                         Time* |  | ||||||
|                         <TimeInput |  | ||||||
|                             defaultValue={reminder.utc_time} |  | ||||||
|                             onInput={(time: string) => { |  | ||||||
|                                 setReminder((reminder) => ({ |  | ||||||
|                                     ...reminder, |  | ||||||
|                                     utc_time: time, |  | ||||||
|                                 })); |  | ||||||
|                             }} |  | ||||||
|                         /> |  | ||||||
|                     </label> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|  |  | ||||||
|             <div class="collapses split-controls"> |  | ||||||
|                 <div> |  | ||||||
|                     <div class={userInfo.patreon ? "patreon-only" : "patreon-only is-locked"}> |  | ||||||
|                         <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> |  | ||||||
|                             <IntervalSelector |  | ||||||
|                                 months={reminder.interval_months} |  | ||||||
|                                 days={reminder.interval_days} |  | ||||||
|                                 seconds={reminder.interval_seconds} |  | ||||||
|                                 setInterval={({ seconds, days, months }) => { |  | ||||||
|                                     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, |  | ||||||
|                                     })); |  | ||||||
|                                 }} |  | ||||||
|                             ></IntervalSelector> |  | ||||||
|                         </div> |  | ||||||
|  |  | ||||||
|                         <div class="field"> |  | ||||||
|                             <div class="control"> |  | ||||||
|                                 <label class="label"> |  | ||||||
|                                     Expiration |  | ||||||
|                                     <TimeInput |  | ||||||
|                                         defaultValue={reminder.expires} |  | ||||||
|                                         onInput={(time: string) => { |  | ||||||
|                                             setReminder((reminder) => ({ |  | ||||||
|                                                 ...reminder, |  | ||||||
|                                                 expires: time, |  | ||||||
|                                             })); |  | ||||||
|                                         }} |  | ||||||
|                                     /> |  | ||||||
|                                 </label> |  | ||||||
|                             </div> |  | ||||||
|                         </div> |  | ||||||
|                     </div> |  | ||||||
|  |  | ||||||
|                     <div class="columns is-mobile tts-row"> |  | ||||||
|                         <div class="column has-text-centered"> |  | ||||||
|                             <TTS /> |  | ||||||
|                         </div> |  | ||||||
|                         <div class="column has-text-centered"> |  | ||||||
|                             <Attachment /> |  | ||||||
|                         </div> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,24 +0,0 @@ | |||||||
| import { useReminder } from "./ReminderContext"; |  | ||||||
|  |  | ||||||
| export const TTS = () => { |  | ||||||
|     const [{ tts }, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="is-boxed"> |  | ||||||
|             <label class="label"> |  | ||||||
|                 Enable TTS{" "} |  | ||||||
|                 <input |  | ||||||
|                     type="checkbox" |  | ||||||
|                     name="tts" |  | ||||||
|                     checked={tts} |  | ||||||
|                     onInput={(ev) => { |  | ||||||
|                         setReminder((reminder) => ({ |  | ||||||
|                             ...reminder, |  | ||||||
|                             tts: ev.currentTarget.checked, |  | ||||||
|                         })); |  | ||||||
|                     }} |  | ||||||
|                 ></input> |  | ||||||
|             </label> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,294 +0,0 @@ | |||||||
| import { useCallback, useEffect, useRef, useState } from "preact/hooks"; |  | ||||||
| import { DateTime } from "luxon"; |  | ||||||
| import { useFlash } from "../App/FlashContext"; |  | ||||||
| import { useTimezone } from "../App/TimezoneProvider"; |  | ||||||
|  |  | ||||||
| type TimeUpdate = { |  | ||||||
|     year?: number | null; |  | ||||||
|     month?: number; |  | ||||||
|     day?: number; |  | ||||||
|     hour?: number; |  | ||||||
|     minute?: number; |  | ||||||
|     second?: number; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const TimeInput = ({ defaultValue, onInput }) => { |  | ||||||
|     const ref = useRef(null); |  | ||||||
|  |  | ||||||
|     const [timezone] = useTimezone(); |  | ||||||
|     const [localTime, setLocalTime] = useState( |  | ||||||
|         defaultValue ? DateTime.fromISO(defaultValue, { zone: "UTC" }).setZone(timezone) : null, |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     const updateTime = useCallback( |  | ||||||
|         (upd: TimeUpdate) => { |  | ||||||
|             if (upd === null) { |  | ||||||
|                 setLocalTime(null); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             let newTime = localTime; |  | ||||||
|             if (newTime === null) { |  | ||||||
|                 newTime = DateTime.now().setZone(timezone); |  | ||||||
|             } |  | ||||||
|             setLocalTime(newTime.set(upd)); |  | ||||||
|         }, |  | ||||||
|         [localTime, timezone], |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     useEffect(() => { |  | ||||||
|         onInput(localTime?.setZone("UTC").toFormat("yyyy-LL-dd'T'HH:mm:ss")); |  | ||||||
|     }, [localTime]); |  | ||||||
|  |  | ||||||
|     const flash = useFlash(); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <div |  | ||||||
|                 class={"input"} |  | ||||||
|                 onPaste={(ev) => { |  | ||||||
|                     ev.preventDefault(); |  | ||||||
|                     const pasteValue = ev.clipboardData.getData("text/plain"); |  | ||||||
|                     let dt = DateTime.fromISO(pasteValue, { zone: timezone }); |  | ||||||
|  |  | ||||||
|                     if (dt.isValid) { |  | ||||||
|                         setLocalTime(dt); |  | ||||||
|                         return; |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     dt = DateTime.fromSQL(pasteValue); |  | ||||||
|  |  | ||||||
|                     if (dt.isValid) { |  | ||||||
|                         setLocalTime(dt); |  | ||||||
|                         return; |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     flash({ |  | ||||||
|                         message: "Couldn't parse your clipboard data as a valid date-time", |  | ||||||
|                         type: "error", |  | ||||||
|                     }); |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 <div style={{ flexGrow: "1" }}> |  | ||||||
|                     <label> |  | ||||||
|                         <span class="is-sr-only">Years input</span> |  | ||||||
|                         <input |  | ||||||
|                             style={{ |  | ||||||
|                                 borderStyle: "none", |  | ||||||
|                                 fontFamily: "monospace", |  | ||||||
|                                 width: "calc(4ch + 4px)", |  | ||||||
|                                 fontSize: "1rem", |  | ||||||
|                             }} |  | ||||||
|                             type="text" |  | ||||||
|                             pattern="\d*" |  | ||||||
|                             maxlength={4} |  | ||||||
|                             placeholder="YYYY" |  | ||||||
|                             value={ |  | ||||||
|                                 localTime |  | ||||||
|                                     ? localTime.year.toLocaleString("en-US", { |  | ||||||
|                                           minimumIntegerDigits: 4, |  | ||||||
|                                           useGrouping: false, |  | ||||||
|                                       }) |  | ||||||
|                                     : "" |  | ||||||
|                             } |  | ||||||
|                             onBlur={(ev) => { |  | ||||||
|                                 ev.currentTarget.value |  | ||||||
|                                     ? updateTime({ |  | ||||||
|                                           year: parseInt(ev.currentTarget.value), |  | ||||||
|                                       }) |  | ||||||
|                                     : updateTime(null); |  | ||||||
|                             }} |  | ||||||
|                         ></input>{" "} |  | ||||||
|                     </label> |  | ||||||
|                     - |  | ||||||
|                     <label> |  | ||||||
|                         <span class="is-sr-only">Months input</span> |  | ||||||
|                         <input |  | ||||||
|                             style={{ |  | ||||||
|                                 borderStyle: "none", |  | ||||||
|                                 fontFamily: "monospace", |  | ||||||
|                                 width: "calc(2ch + 4px)", |  | ||||||
|                                 fontSize: "1rem", |  | ||||||
|                             }} |  | ||||||
|                             type="text" |  | ||||||
|                             pattern="\d*" |  | ||||||
|                             maxlength={2} |  | ||||||
|                             placeholder="MM" |  | ||||||
|                             value={ |  | ||||||
|                                 localTime |  | ||||||
|                                     ? localTime.month.toLocaleString("en-US", { |  | ||||||
|                                           minimumIntegerDigits: 2, |  | ||||||
|                                       }) |  | ||||||
|                                     : "" |  | ||||||
|                             } |  | ||||||
|                             onBlur={(ev) => { |  | ||||||
|                                 ev.currentTarget.value |  | ||||||
|                                     ? updateTime({ |  | ||||||
|                                           month: parseInt(ev.currentTarget.value), |  | ||||||
|                                       }) |  | ||||||
|                                     : updateTime(null); |  | ||||||
|                             }} |  | ||||||
|                         ></input>{" "} |  | ||||||
|                     </label> |  | ||||||
|                     - |  | ||||||
|                     <label> |  | ||||||
|                         <span class="is-sr-only">Days input</span> |  | ||||||
|                         <input |  | ||||||
|                             style={{ |  | ||||||
|                                 borderStyle: "none", |  | ||||||
|                                 fontFamily: "monospace", |  | ||||||
|                                 width: "calc(2ch + 4px)", |  | ||||||
|                                 fontSize: "1rem", |  | ||||||
|                             }} |  | ||||||
|                             type="text" |  | ||||||
|                             pattern="\d*" |  | ||||||
|                             maxlength={2} |  | ||||||
|                             placeholder="DD" |  | ||||||
|                             value={ |  | ||||||
|                                 localTime |  | ||||||
|                                     ? localTime.day.toLocaleString("en-US", { |  | ||||||
|                                           minimumIntegerDigits: 2, |  | ||||||
|                                       }) |  | ||||||
|                                     : "" |  | ||||||
|                             } |  | ||||||
|                             onBlur={(ev) => { |  | ||||||
|                                 ev.currentTarget.value |  | ||||||
|                                     ? updateTime({ |  | ||||||
|                                           day: parseInt(ev.currentTarget.value), |  | ||||||
|                                       }) |  | ||||||
|                                     : updateTime(null); |  | ||||||
|                             }} |  | ||||||
|                         ></input>{" "} |  | ||||||
|                     </label> |  | ||||||
|                     <label style={{ marginLeft: "8px" }}> |  | ||||||
|                         <span class="is-sr-only">Hours input</span> |  | ||||||
|                         <input |  | ||||||
|                             style={{ |  | ||||||
|                                 borderStyle: "none", |  | ||||||
|                                 fontFamily: "monospace", |  | ||||||
|                                 width: "calc(2ch + 4px)", |  | ||||||
|                                 fontSize: "1rem", |  | ||||||
|                             }} |  | ||||||
|                             type="text" |  | ||||||
|                             pattern="\d*" |  | ||||||
|                             maxlength={2} |  | ||||||
|                             placeholder="hh" |  | ||||||
|                             value={ |  | ||||||
|                                 localTime |  | ||||||
|                                     ? localTime.hour.toLocaleString("en-US", { |  | ||||||
|                                           minimumIntegerDigits: 2, |  | ||||||
|                                       }) |  | ||||||
|                                     : "" |  | ||||||
|                             } |  | ||||||
|                             onBlur={(ev) => { |  | ||||||
|                                 ev.currentTarget.value |  | ||||||
|                                     ? updateTime({ |  | ||||||
|                                           hour: parseInt(ev.currentTarget.value), |  | ||||||
|                                       }) |  | ||||||
|                                     : updateTime(null); |  | ||||||
|                             }} |  | ||||||
|                         ></input>{" "} |  | ||||||
|                     </label> |  | ||||||
|                     : |  | ||||||
|                     <label> |  | ||||||
|                         <span class="is-sr-only">Minutes input</span> |  | ||||||
|                         <input |  | ||||||
|                             style={{ |  | ||||||
|                                 borderStyle: "none", |  | ||||||
|                                 fontFamily: "monospace", |  | ||||||
|                                 width: "calc(2ch + 4px)", |  | ||||||
|                                 fontSize: "1rem", |  | ||||||
|                             }} |  | ||||||
|                             type="text" |  | ||||||
|                             pattern="\d*" |  | ||||||
|                             maxlength={2} |  | ||||||
|                             placeholder="mm" |  | ||||||
|                             value={ |  | ||||||
|                                 localTime |  | ||||||
|                                     ? localTime.minute.toLocaleString("en-US", { |  | ||||||
|                                           minimumIntegerDigits: 2, |  | ||||||
|                                       }) |  | ||||||
|                                     : "" |  | ||||||
|                             } |  | ||||||
|                             onBlur={(ev) => { |  | ||||||
|                                 ev.currentTarget.value |  | ||||||
|                                     ? updateTime({ |  | ||||||
|                                           minute: parseInt(ev.currentTarget.value), |  | ||||||
|                                       }) |  | ||||||
|                                     : updateTime(null); |  | ||||||
|                             }} |  | ||||||
|                         ></input>{" "} |  | ||||||
|                     </label> |  | ||||||
|                     : |  | ||||||
|                     <label> |  | ||||||
|                         <span class="is-sr-only">Seconds input</span> |  | ||||||
|                         <input |  | ||||||
|                             style={{ |  | ||||||
|                                 borderStyle: "none", |  | ||||||
|                                 fontFamily: "monospace", |  | ||||||
|                                 width: "calc(2ch + 4px)", |  | ||||||
|                                 fontSize: "1rem", |  | ||||||
|                             }} |  | ||||||
|                             type="text" |  | ||||||
|                             pattern="\d*" |  | ||||||
|                             maxlength={2} |  | ||||||
|                             placeholder="ss" |  | ||||||
|                             value={ |  | ||||||
|                                 localTime |  | ||||||
|                                     ? localTime.second.toLocaleString("en-US", { |  | ||||||
|                                           minimumIntegerDigits: 2, |  | ||||||
|                                       }) |  | ||||||
|                                     : "" |  | ||||||
|                             } |  | ||||||
|                             onBlur={(ev) => { |  | ||||||
|                                 ev.currentTarget.value |  | ||||||
|                                     ? updateTime({ |  | ||||||
|                                           second: parseInt(ev.currentTarget.value), |  | ||||||
|                                       }) |  | ||||||
|                                     : updateTime(null); |  | ||||||
|                             }} |  | ||||||
|                         ></input>{" "} |  | ||||||
|                     </label> |  | ||||||
|                 </div> |  | ||||||
|                 <button |  | ||||||
|                     style={{ |  | ||||||
|                         background: "none", |  | ||||||
|                         border: "none", |  | ||||||
|                         padding: "1px", |  | ||||||
|                         marginRight: "-3px", |  | ||||||
|                     }} |  | ||||||
|                     onClick={() => { |  | ||||||
|                         ref.current.showPicker(); |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     <span class="is-sr-only">Show time picker</span> |  | ||||||
|                     <span class="icon"> |  | ||||||
|                         <i class="fas fa-calendar"></i> |  | ||||||
|                     </span> |  | ||||||
|                 </button> |  | ||||||
|             </div> |  | ||||||
|             <input |  | ||||||
|                 style={{ |  | ||||||
|                     position: "absolute", |  | ||||||
|                     left: 0, |  | ||||||
|                     visibility: "hidden", |  | ||||||
|                 }} |  | ||||||
|                 class={"input"} |  | ||||||
|                 type="datetime-local" |  | ||||||
|                 step="1" |  | ||||||
|                 value={ |  | ||||||
|                     localTime |  | ||||||
|                         ? localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss") |  | ||||||
|                         : DateTime.now().setZone(timezone).toFormat("yyyy-LL-dd'T'HH:mm:ss") |  | ||||||
|                 } |  | ||||||
|                 ref={ref} |  | ||||||
|                 onInput={(ev) => { |  | ||||||
|                     ev.currentTarget.value === "" |  | ||||||
|                         ? updateTime(null) |  | ||||||
|                         : setLocalTime( |  | ||||||
|                               DateTime.fromISO(ev.currentTarget.value, { zone: timezone }), |  | ||||||
|                           ); |  | ||||||
|                 }} |  | ||||||
|             ></input> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,67 +0,0 @@ | |||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
| import { useReminder } from "../ReminderContext"; |  | ||||||
| import { useQuery } from "react-query"; |  | ||||||
| import { fetchGuildChannels, Reminder } from "../../../api"; |  | ||||||
| import { useCallback } from "preact/hooks"; |  | ||||||
| import { DateTime } from "luxon"; |  | ||||||
| import { Name } from "../Name"; |  | ||||||
|  |  | ||||||
| export const Guild = ({ toggleCollapsed, isCreating }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const [reminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild)); |  | ||||||
|  |  | ||||||
|     const channelName = useCallback( |  | ||||||
|         (reminder: Reminder) => { |  | ||||||
|             const channel = guildChannels.find((c) => c.id === reminder.channel); |  | ||||||
|             return channel === undefined ? "" : channel.name; |  | ||||||
|         }, |  | ||||||
|         [guildChannels], |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     let days, hours, minutes, seconds; |  | ||||||
|     seconds = Math.floor( |  | ||||||
|         DateTime.fromISO(reminder.utc_time, { zone: "UTC" }).diffNow("seconds").seconds, |  | ||||||
|     ); |  | ||||||
|     [days, seconds] = [Math.floor(seconds / 86400), seconds % 86400]; |  | ||||||
|     [hours, seconds] = [Math.floor(seconds / 3600), seconds % 3600]; |  | ||||||
|     [minutes, seconds] = [Math.floor(seconds / 60), seconds % 60]; |  | ||||||
|  |  | ||||||
|     let string; |  | ||||||
|     if (days !== 0) { |  | ||||||
|         if (hours !== 0) { |  | ||||||
|             string = `${days} days, ${hours} hours`; |  | ||||||
|         } else { |  | ||||||
|             string = `${days} days`; |  | ||||||
|         } |  | ||||||
|     } else if (hours !== 0) { |  | ||||||
|         if (minutes !== 0) { |  | ||||||
|             string = `${hours} hours, ${minutes} minutes`; |  | ||||||
|         } else { |  | ||||||
|             string = `${hours} hours`; |  | ||||||
|         } |  | ||||||
|     } else if (minutes !== 0) { |  | ||||||
|         if (seconds !== 0) { |  | ||||||
|             string = `${minutes} minutes, ${seconds} seconds`; |  | ||||||
|         } else { |  | ||||||
|             string = `${minutes} minutes`; |  | ||||||
|         } |  | ||||||
|     } else { |  | ||||||
|         string = `${seconds} seconds`; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="columns is-mobile column reminder-topbar"> |  | ||||||
|             {isSuccess && <div class="invert-collapses channel-bar">#{channelName(reminder)}</div>} |  | ||||||
|             <Name /> |  | ||||||
|             {!isCreating && <div class="time-bar">in {string}</div>} |  | ||||||
|             <div class="hide-button-bar"> |  | ||||||
|                 <button class="button hide-box" onClick={toggleCollapsed}> |  | ||||||
|                     <span class="is-sr-only">Hide reminder</span> |  | ||||||
|                     <i class="fas fa-chevron-down"></i> |  | ||||||
|                 </button> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,51 +0,0 @@ | |||||||
| import { Name } from "../Name"; |  | ||||||
| import { DateTime } from "luxon"; |  | ||||||
| import { useReminder } from "../ReminderContext"; |  | ||||||
|  |  | ||||||
| export const User = ({ toggleCollapsed }) => { |  | ||||||
|     const [reminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     let days, hours, minutes, seconds; |  | ||||||
|     seconds = Math.floor( |  | ||||||
|         DateTime.fromISO(reminder.utc_time, { zone: "UTC" }).diffNow("seconds").seconds, |  | ||||||
|     ); |  | ||||||
|     [days, seconds] = [Math.floor(seconds / 86400), seconds % 86400]; |  | ||||||
|     [hours, seconds] = [Math.floor(seconds / 3600), seconds % 3600]; |  | ||||||
|     [minutes, seconds] = [Math.floor(seconds / 60), seconds % 60]; |  | ||||||
|  |  | ||||||
|     let string; |  | ||||||
|     if (days !== 0) { |  | ||||||
|         if (hours !== 0) { |  | ||||||
|             string = `${days} days, ${hours} hours`; |  | ||||||
|         } else { |  | ||||||
|             string = `${days} days`; |  | ||||||
|         } |  | ||||||
|     } else if (hours !== 0) { |  | ||||||
|         if (minutes !== 0) { |  | ||||||
|             string = `${hours} hours, ${minutes} minutes`; |  | ||||||
|         } else { |  | ||||||
|             string = `${hours} hours`; |  | ||||||
|         } |  | ||||||
|     } else if (minutes !== 0) { |  | ||||||
|         if (seconds !== 0) { |  | ||||||
|             string = `${minutes} minutes, ${seconds} seconds`; |  | ||||||
|         } else { |  | ||||||
|             string = `${minutes} minutes`; |  | ||||||
|         } |  | ||||||
|     } else { |  | ||||||
|         string = `${seconds} seconds`; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="columns is-mobile column reminder-topbar"> |  | ||||||
|             <Name /> |  | ||||||
|             <div class="time-bar">in {string}</div> |  | ||||||
|             <div class="hide-button-bar"> |  | ||||||
|                 <button class="button hide-box" onClick={toggleCollapsed}> |  | ||||||
|                     <span class="is-sr-only">Hide reminder</span> |  | ||||||
|                     <i class="fas fa-chevron-down"></i> |  | ||||||
|                 </button> |  | ||||||
|             </div> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,13 +0,0 @@ | |||||||
| import { useGuild } from "../../App/useGuild"; |  | ||||||
| import { Guild } from "./Guild"; |  | ||||||
| import { User } from "./User"; |  | ||||||
|  |  | ||||||
| export const TopBar = ({ toggleCollapsed, isCreating }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     if (guild) { |  | ||||||
|         return <Guild toggleCollapsed={toggleCollapsed} isCreating={isCreating} />; |  | ||||||
|     } else { |  | ||||||
|         return <User toggleCollapsed={toggleCollapsed} />; |  | ||||||
|     } |  | ||||||
| }; |  | ||||||
| @@ -1,30 +0,0 @@ | |||||||
| import { useReminder } from "./ReminderContext"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
|  |  | ||||||
| export const Username = () => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|     const [reminder, setReminder] = useReminder(); |  | ||||||
|  |  | ||||||
|     return guild ? ( |  | ||||||
|         <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" |  | ||||||
|                 value={reminder.username || "Reminder"} |  | ||||||
|                 onBlur={(ev) => { |  | ||||||
|                     setReminder((reminder) => ({ |  | ||||||
|                         ...reminder, |  | ||||||
|                         username: ev.currentTarget.value, |  | ||||||
|                     })); |  | ||||||
|                 }} |  | ||||||
|             ></input> |  | ||||||
|         </div> |  | ||||||
|     ) : ( |  | ||||||
|         <div class="discord-message-header"> |  | ||||||
|             <span class="discord-username">Reminder Bot</span> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,28 +0,0 @@ | |||||||
| .time-bar { |  | ||||||
|     display: flex; |  | ||||||
|     justify-content: center; |  | ||||||
|     flex-direction: column; |  | ||||||
|     font-style: italic; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .tribute-container { |  | ||||||
|     background-color: #2b2d31; |  | ||||||
|     color: #fff; |  | ||||||
|     border-radius: 8px; |  | ||||||
|     margin: 4px; |  | ||||||
|     padding: 4px; |  | ||||||
|     box-shadow: 0 0 5px 0 rgba(0,0,0,0.75); |  | ||||||
|  |  | ||||||
|     .highlight { |  | ||||||
|         background-color: #35373c; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     li { |  | ||||||
|         padding: 8px 12px; |  | ||||||
|         border-radius: 8px; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| textarea.autoresize { |  | ||||||
|     resize: vertical !important; |  | ||||||
| } |  | ||||||
| @@ -1,11 +0,0 @@ | |||||||
| export const Brand = () => ( |  | ||||||
|     <div class="brand"> |  | ||||||
|         <img |  | ||||||
|             src="/static/img/logo_nobg.webp" |  | ||||||
|             alt="Reminder bot logo" |  | ||||||
|             width="52px" |  | ||||||
|             height="52px" |  | ||||||
|             class="dashboard-brand" |  | ||||||
|         ></img> |  | ||||||
|     </div> |  | ||||||
| ); |  | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| export const DesktopSidebar = ({ children }) => { |  | ||||||
|     return ( |  | ||||||
|         <div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">{children}</div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,28 +0,0 @@ | |||||||
| 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(/^\/(?<id>\d+)/); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <li> |  | ||||||
|             <Link |  | ||||||
|                 class={ |  | ||||||
|                     currentId !== null && guild.id === currentId.groups.id |  | ||||||
|                         ? "is-active switch-pane" |  | ||||||
|                         : "switch-pane" |  | ||||||
|                 } |  | ||||||
|                 href={`/${guild.id}/reminders`} |  | ||||||
|             > |  | ||||||
|                 <> |  | ||||||
|                     <span class="guild-name">{guild.name}</span> |  | ||||||
|                 </> |  | ||||||
|             </Link> |  | ||||||
|         </li> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,65 +0,0 @@ | |||||||
| import { useState } from "preact/hooks"; |  | ||||||
|  |  | ||||||
| export const MobileSidebar = ({ children }) => { |  | ||||||
|     const [sidebarOpen, setSidebarOpen] = useState(false); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <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" |  | ||||||
|                             style={{ |  | ||||||
|                                 maxWidth: "28px", |  | ||||||
|                                 maxHeight: "28px", |  | ||||||
|                                 overflow: "hidden", |  | ||||||
|                                 display: "flex", |  | ||||||
|                             }} |  | ||||||
|                         > |  | ||||||
|                             <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" |  | ||||||
|                         onClick={() => { |  | ||||||
|                             setSidebarOpen(!sidebarOpen); |  | ||||||
|                         }} |  | ||||||
|                     > |  | ||||||
|                         <span aria-hidden="true"></span> |  | ||||||
|                         <span aria-hidden="true"></span> |  | ||||||
|                         <span aria-hidden="true"></span> |  | ||||||
|                     </a> |  | ||||||
|                 </div> |  | ||||||
|             </nav> |  | ||||||
|             <div |  | ||||||
|                 class="dashboard-sidebar mobile-sidebar is-hidden-desktop" |  | ||||||
|                 id="mobileSidebar" |  | ||||||
|                 style={{ |  | ||||||
|                     display: sidebarOpen ? "inherit" : "none", |  | ||||||
|                 }} |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setSidebarOpen(!sidebarOpen); |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 {children} |  | ||||||
|             </div> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,11 +0,0 @@ | |||||||
| export const Wave = () => ( |  | ||||||
|     <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> |  | ||||||
| ); |  | ||||||
| @@ -1,89 +0,0 @@ | |||||||
| 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, fetchUserInfo, GuildInfo } from "../../api"; |  | ||||||
| import { TimezonePicker } from "../TimezonePicker"; |  | ||||||
| import "./styles.scss"; |  | ||||||
| import { Link, useLocation } from "wouter"; |  | ||||||
|  |  | ||||||
| type ContentProps = { |  | ||||||
|     guilds: GuildInfo[]; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const SidebarContent = ({ guilds }: ContentProps) => { |  | ||||||
|     const guildEntries = guilds.map((guild) => <GuildEntry guild={guild} />); |  | ||||||
|     const [loc] = useLocation(); |  | ||||||
|     const { data: userInfo } = useQuery({ ...fetchUserInfo() }); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <a href="/"> |  | ||||||
|                 <Brand /> |  | ||||||
|             </a> |  | ||||||
|             <Wave /> |  | ||||||
|             <aside class="menu"> |  | ||||||
|                 <ul class="menu-list"> |  | ||||||
|                     <li> |  | ||||||
|                         <Link |  | ||||||
|                             class={loc.startsWith("/@me") ? "is-active switch-pane" : "switch-pane"} |  | ||||||
|                             href={"/@me/reminders"} |  | ||||||
|                         > |  | ||||||
|                             <> |  | ||||||
|                                 <span class="guild-name">@{userInfo?.name || "unknown"}</span> |  | ||||||
|                             </> |  | ||||||
|                         </Link> |  | ||||||
|                     </li> |  | ||||||
|                 </ul> |  | ||||||
|                 <p class="menu-label">Servers</p> |  | ||||||
|                 <ul class="menu-list guildList">{guildEntries}</ul> |  | ||||||
|                 <div |  | ||||||
|                     class="aside-footer" |  | ||||||
|                     style={{ |  | ||||||
|                         position: "sticky", |  | ||||||
|                         bottom: "0px", |  | ||||||
|                         backgroundColor: "rgb(54, 54, 54)", |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     <p class="menu-label">Options</p> |  | ||||||
|                     <ul class="menu-list"> |  | ||||||
|                         <li> |  | ||||||
|                             <div id="bottom-sidebar"></div> |  | ||||||
|                             <TimezonePicker /> |  | ||||||
|                             <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> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const Sidebar = () => { |  | ||||||
|     const { status, data } = useQuery(fetchUserGuilds()); |  | ||||||
|  |  | ||||||
|     let content = <SidebarContent guilds={[]}></SidebarContent>; |  | ||||||
|     if (status === "success") { |  | ||||||
|         content = <SidebarContent guilds={data}></SidebarContent>; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <DesktopSidebar>{content}</DesktopSidebar> |  | ||||||
|             <MobileSidebar>{content}</MobileSidebar> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,51 +0,0 @@ | |||||||
| div.brand { |  | ||||||
|     text-align: center; |  | ||||||
|     height: 52px; |  | ||||||
|     background-color: #8fb677; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| img.dashboard-brand { |  | ||||||
|     text-align: center; |  | ||||||
|     height: 100%; |  | ||||||
|     width: auto; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| ul.guildList { |  | ||||||
|     flex-grow: 1; |  | ||||||
|     flex-shrink: 1; |  | ||||||
|     overflow: auto; |  | ||||||
|     margin-bottom: 12px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.dashboard-sidebar svg { |  | ||||||
|     flex-shrink: 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.mobile-sidebar { |  | ||||||
|     z-index: 100; |  | ||||||
|     min-height: 100vh; |  | ||||||
|     position: absolute; |  | ||||||
|     top: 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.mobile-sidebar.is-active { |  | ||||||
|     display: flex; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| aside.menu { |  | ||||||
|     display: flex; |  | ||||||
|     flex-direction: column; |  | ||||||
|     flex-grow: 1; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .dashboard-sidebar { |  | ||||||
|     display: flex; |  | ||||||
|     flex-direction: column; |  | ||||||
|     background-color: #363636; |  | ||||||
|     width: 230px !important; |  | ||||||
|     padding-right: 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .aside-footer { |  | ||||||
|     justify-self: end; |  | ||||||
| } |  | ||||||
| @@ -1,104 +0,0 @@ | |||||||
| import { DateTime } 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.toString().padStart(2, "0"); |  | ||||||
|     const minute = now.minute.toString().padStart(2, "0"); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <strong> |  | ||||||
|                 <span class="set-timezone">{timezone}</span> |  | ||||||
|             </strong>{" "} |  | ||||||
|             ( |  | ||||||
|             <span class="set-time"> |  | ||||||
|                 {hour}:{minute} |  | ||||||
|             </span> |  | ||||||
|             ) |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const TimezonePicker = () => { |  | ||||||
|     const [modalOpen, setModalOpen] = useState(false); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <a |  | ||||||
|                 class="show-modal" |  | ||||||
|                 data-modal="chooseTimezoneModal" |  | ||||||
|                 onClick={() => { |  | ||||||
|                     setModalOpen(true); |  | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 <span class="icon"> |  | ||||||
|                     <i class="fas fa-map-marked"></i> |  | ||||||
|                 </span>{" "} |  | ||||||
|                 Timezone |  | ||||||
|             </a> |  | ||||||
|             {modalOpen && <TimezoneModal setModalOpen={setModalOpen} />} |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const TimezoneModal = ({ setModalOpen }) => { |  | ||||||
|     const browserTimezone = DateTime.now().zoneName; |  | ||||||
|     const [selectedZone] = useTimezone(); |  | ||||||
|  |  | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|     const { isLoading, isError, data } = useQuery(fetchUserInfo()); |  | ||||||
|     const userInfoMutation = useMutation({ |  | ||||||
|         ...patchUserInfo(), |  | ||||||
|         onSuccess: () => { |  | ||||||
|             queryClient.invalidateQueries(["USER_INFO"]); |  | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <Modal title={"Timezone"} setModalOpen={setModalOpen}> |  | ||||||
|             <p> |  | ||||||
|                 Your configured timezone is:{" "} |  | ||||||
|                 <TimezoneDisplay timezone={selectedZone}></TimezoneDisplay> |  | ||||||
|                 <br /> |  | ||||||
|                 Your browser timezone is:{" "} |  | ||||||
|                 <TimezoneDisplay timezone={browserTimezone}></TimezoneDisplay> |  | ||||||
|                 <br /> |  | ||||||
|                 {!isError && ( |  | ||||||
|                     <> |  | ||||||
|                         Your bot timezone is:{" "} |  | ||||||
|                         {isLoading ? ( |  | ||||||
|                             <i className="fas fa-cog fa-spin"></i> |  | ||||||
|                         ) : ( |  | ||||||
|                             <TimezoneDisplay timezone={data.timezone || "UTC"}></TimezoneDisplay> |  | ||||||
|                         )} |  | ||||||
|                     </> |  | ||||||
|                 )} |  | ||||||
|             </p> |  | ||||||
|             <br></br> |  | ||||||
|             <div class="has-text-centered"> |  | ||||||
|                 <button |  | ||||||
|                     class="button is-success is-outlined" |  | ||||||
|                     id="update-bot-timezone" |  | ||||||
|                     style={{ |  | ||||||
|                         margin: "2px", |  | ||||||
|                     }} |  | ||||||
|                     onClick={() => { |  | ||||||
|                         userInfoMutation.mutate(browserTimezone); |  | ||||||
|                     }} |  | ||||||
|                 > |  | ||||||
|                     Set Bot Timezone |  | ||||||
|                 </button> |  | ||||||
|             </div> |  | ||||||
|         </Modal> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,91 +0,0 @@ | |||||||
| import { useMutation, useQuery, useQueryClient } from "react-query"; |  | ||||||
| import { fetchGuildChannels, postGuildTodo } from "../../api"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { useFlash } from "../App/FlashContext"; |  | ||||||
| import { ICON_FLASH_TIME } from "../../consts"; |  | ||||||
|  |  | ||||||
| export const CreateTodo = ({ showSelector = false, channel }) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     const [recentlyCreated, setRecentlyCreated] = useState(false); |  | ||||||
|     const [newTodo, setNewTodo] = useState({ value: "", channel_id: channel }); |  | ||||||
|  |  | ||||||
|     const flash = useFlash(); |  | ||||||
|  |  | ||||||
|     const { isSuccess, data: channels } = useQuery(fetchGuildChannels(guild)); |  | ||||||
|  |  | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|     const mutation = useMutation({ |  | ||||||
|         ...postGuildTodo(guild), |  | ||||||
|         onSuccess: (data) => { |  | ||||||
|             if (data.error) { |  | ||||||
|                 flash({ |  | ||||||
|                     message: data.error, |  | ||||||
|                     type: "error", |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 flash({ |  | ||||||
|                     message: "Todo created", |  | ||||||
|                     type: "success", |  | ||||||
|                 }); |  | ||||||
|                 queryClient.invalidateQueries({ |  | ||||||
|                     queryKey: ["GUILD_TODOS", guild], |  | ||||||
|                 }); |  | ||||||
|                 setRecentlyCreated(true); |  | ||||||
|                 setTimeout(() => { |  | ||||||
|                     setRecentlyCreated(false); |  | ||||||
|                 }, ICON_FLASH_TIME); |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="todo"> |  | ||||||
|             <textarea |  | ||||||
|                 class="input todo-input" |  | ||||||
|                 onInput={(ev) => setNewTodo((todo) => ({ ...todo, value: ev.currentTarget.value }))} |  | ||||||
|             /> |  | ||||||
|             {showSelector && ( |  | ||||||
|                 <div class="control has-icons-left"> |  | ||||||
|                     <div class="select"> |  | ||||||
|                         <select |  | ||||||
|                             name="channel" |  | ||||||
|                             class="channel-selector" |  | ||||||
|                             onInput={(ev) => |  | ||||||
|                                 setNewTodo((todo) => ({ |  | ||||||
|                                     ...todo, |  | ||||||
|                                     channel_id: ev.currentTarget.value || null, |  | ||||||
|                                 })) |  | ||||||
|                             } |  | ||||||
|                         > |  | ||||||
|                             <option value="">(None)</option> |  | ||||||
|                             {isSuccess && |  | ||||||
|                                 channels.map((c) => <option value={c.id}>{c.name}</option>)} |  | ||||||
|                         </select> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="icon is-small is-left"> |  | ||||||
|                         <i class="fas fa-hashtag"></i> |  | ||||||
|                     </div> |  | ||||||
|                 </div> |  | ||||||
|             )} |  | ||||||
|             <button onClick={() => mutation.mutate(newTodo)} class="button is-success save-btn"> |  | ||||||
|                 <span class="icon"> |  | ||||||
|                     {mutation.isLoading ? ( |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fas fa-spin fa-cog"></i> |  | ||||||
|                         </span> |  | ||||||
|                     ) : recentlyCreated ? ( |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fas fa-check"></i> |  | ||||||
|                         </span> |  | ||||||
|                     ) : ( |  | ||||||
|                         <span class="icon"> |  | ||||||
|                             <i class="fas fa-sparkles"></i> |  | ||||||
|                         </span> |  | ||||||
|                     )} |  | ||||||
|                 </span> |  | ||||||
|             </button> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,10 +0,0 @@ | |||||||
| .todo { |  | ||||||
|     display: flex; |  | ||||||
|     flex-direction: row; |  | ||||||
|     align-items: center; |  | ||||||
|     margin: 6px 0; |  | ||||||
|  |  | ||||||
|     > * { |  | ||||||
|         margin: 0 3px; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,83 +0,0 @@ | |||||||
| import { deleteGuildTodo, patchGuildTodo, Todo as TodoT, UpdateTodo } from "../../api"; |  | ||||||
|  |  | ||||||
| import "./index.scss"; |  | ||||||
| import { useMutation, useQueryClient } from "react-query"; |  | ||||||
| import { useFlash } from "../App/FlashContext"; |  | ||||||
| import { useGuild } from "../App/useGuild"; |  | ||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { ICON_FLASH_TIME } from "../../consts"; |  | ||||||
|  |  | ||||||
| type Props = { |  | ||||||
|     todo: TodoT; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const Todo = ({ todo }: Props) => { |  | ||||||
|     const guild = useGuild(); |  | ||||||
|  |  | ||||||
|     const [updatedTodo, setUpdatedTodo] = useState<UpdateTodo>({ value: todo.value }); |  | ||||||
|     const [recentlySaved, setRecentlySaved] = useState(false); |  | ||||||
|  |  | ||||||
|     const flash = useFlash(); |  | ||||||
|  |  | ||||||
|     const queryClient = useQueryClient(); |  | ||||||
|     const deleteMutation = useMutation({ |  | ||||||
|         ...deleteGuildTodo(guild), |  | ||||||
|         onSuccess: () => { |  | ||||||
|             flash({ |  | ||||||
|                 message: "Todo deleted", |  | ||||||
|                 type: "success", |  | ||||||
|             }); |  | ||||||
|             queryClient.invalidateQueries({ |  | ||||||
|                 queryKey: ["GUILD_TODOS", guild], |  | ||||||
|             }); |  | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     const patchMutation = useMutation({ |  | ||||||
|         ...patchGuildTodo(guild), |  | ||||||
|         onError: (error) => { |  | ||||||
|             flash({ |  | ||||||
|                 message: `An error occurred: ${error}`, |  | ||||||
|                 type: "error", |  | ||||||
|             }); |  | ||||||
|         }, |  | ||||||
|         onSuccess: (response) => { |  | ||||||
|             if (response.data.error) { |  | ||||||
|                 setRecentlySaved(false); |  | ||||||
|                 flash({ message: response.data.error, type: "error" }); |  | ||||||
|             } else { |  | ||||||
|                 setRecentlySaved(true); |  | ||||||
|                 setTimeout(() => { |  | ||||||
|                     setRecentlySaved(false); |  | ||||||
|                 }, ICON_FLASH_TIME); |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <div class="todo"> |  | ||||||
|             <textarea |  | ||||||
|                 class="input todo-input" |  | ||||||
|                 value={updatedTodo.value} |  | ||||||
|                 onInput={(ev) => |  | ||||||
|                     setUpdatedTodo({ |  | ||||||
|                         value: ev.currentTarget.value, |  | ||||||
|                     }) |  | ||||||
|                 } |  | ||||||
|             /> |  | ||||||
|             <button |  | ||||||
|                 onClick={() => patchMutation.mutate({ id: todo.id, todo: updatedTodo })} |  | ||||||
|                 class="button is-success save-btn" |  | ||||||
|             > |  | ||||||
|                 <span class="icon"> |  | ||||||
|                     {recentlySaved ? <i class="fa fa-check"></i> : <i class="fa fa-save"></i>} |  | ||||||
|                 </span> |  | ||||||
|             </button> |  | ||||||
|             <button onClick={() => deleteMutation.mutate(todo.id)} class="button is-danger"> |  | ||||||
|                 <span class="icon"> |  | ||||||
|                     <i class="fa fa-trash"></i> |  | ||||||
|                 </span> |  | ||||||
|             </button> |  | ||||||
|         </div> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,102 +0,0 @@ | |||||||
| import { useQuery } from "react-query"; |  | ||||||
| import { fetchUserReminders } from "../../api"; |  | ||||||
| import { EditReminder } from "../Reminder/EditReminder"; |  | ||||||
| import { CreateReminder } from "../Reminder/CreateReminder"; |  | ||||||
| import { useState } from "preact/hooks"; |  | ||||||
| import { Loader } from "../Loader"; |  | ||||||
|  |  | ||||||
| enum Sort { |  | ||||||
|     Time = "time", |  | ||||||
|     Name = "name", |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const UserReminders = () => { |  | ||||||
|     const { isSuccess, isFetching, isFetched, data: reminders } = useQuery(fetchUserReminders()); |  | ||||||
|  |  | ||||||
|     const [collapsed, setCollapsed] = useState(false); |  | ||||||
|     const [sort, setSort] = useState(Sort.Time); |  | ||||||
|  |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             {!isFetched && <Loader />} |  | ||||||
|             <div style={{ margin: "0 12px 12px 12px" }}> |  | ||||||
|                 <strong>Create Reminder</strong> |  | ||||||
|                 <div id={"reminderCreator"}> |  | ||||||
|                     <CreateReminder /> |  | ||||||
|                 </div> |  | ||||||
|                 <br></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" |  | ||||||
|                                         onInput={(ev) => { |  | ||||||
|                                             setSort(ev.currentTarget.value as Sort); |  | ||||||
|                                         }} |  | ||||||
|                                     > |  | ||||||
|                                         <option value={Sort.Time} selected={sort == Sort.Time}> |  | ||||||
|                                             Time |  | ||||||
|                                         </option> |  | ||||||
|                                         <option value={Sort.Name} selected={sort == Sort.Name}> |  | ||||||
|                                             Name |  | ||||||
|                                         </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" |  | ||||||
|                                         onInput={(ev) => { |  | ||||||
|                                             if (ev.currentTarget.value === "expand") { |  | ||||||
|                                                 setCollapsed(false); |  | ||||||
|                                             } else if (ev.currentTarget.value === "collapse") { |  | ||||||
|                                                 setCollapsed(true); |  | ||||||
|                                             } |  | ||||||
|                                         }} |  | ||||||
|                                     > |  | ||||||
|                                         <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"} className={isFetching ? "loading" : ""}> |  | ||||||
|                     {isSuccess && |  | ||||||
|                         reminders |  | ||||||
|                             .sort((r1, r2) => { |  | ||||||
|                                 if (sort === Sort.Time) { |  | ||||||
|                                     return r1.utc_time > r2.utc_time ? 1 : -1; |  | ||||||
|                                 } else { |  | ||||||
|                                     return r1.name > r2.name ? 1 : -1; |  | ||||||
|                                 } |  | ||||||
|                             }) |  | ||||||
|                             .map((reminder) => ( |  | ||||||
|                                 <EditReminder |  | ||||||
|                                     key={reminder.uid} |  | ||||||
|                                     reminder={reminder} |  | ||||||
|                                     globalCollapse={collapsed} |  | ||||||
|                                 /> |  | ||||||
|                             ))} |  | ||||||
|                 </div> |  | ||||||
|             </div> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,9 +0,0 @@ | |||||||
| import { UserReminders } from "./UserReminders"; |  | ||||||
|  |  | ||||||
| export const User = () => { |  | ||||||
|     return ( |  | ||||||
|         <> |  | ||||||
|             <UserReminders /> |  | ||||||
|         </> |  | ||||||
|     ); |  | ||||||
| }; |  | ||||||
| @@ -1,19 +0,0 @@ | |||||||
| export const Welcome = () => ( |  | ||||||
|     <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> |  | ||||||
|             <br></br> |  | ||||||
|             <p> |  | ||||||
|                 <strong>Please report bugs!</strong> I can't fix issues if I am unaware of them. |  | ||||||
|             </p> |  | ||||||
|         </div> |  | ||||||
|     </section> |  | ||||||
| ); |  | ||||||
| @@ -1,2 +0,0 @@ | |||||||
| export const ICON_FLASH_TIME = 2_500; |  | ||||||
| export const MESSAGE_FLASH_TIME = 5_000; |  | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| import { render } from "preact"; |  | ||||||
| import { App } from "./components/App"; |  | ||||||
| import "./styles.scss"; |  | ||||||
|  |  | ||||||
| render(<App />, document.getElementById("app")); |  | ||||||
| @@ -1,49 +0,0 @@ | |||||||
| /* override styles for when the div is collapsed */ |  | ||||||
| div.reminderContent.is-collapsed .column.discord-frame { |  | ||||||
|     display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .column.settings { |  | ||||||
|     display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .reminder-settings { |  | ||||||
|     margin-bottom: 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .button-row { |  | ||||||
|     display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .button-row-edit { |  | ||||||
|     display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .reminder-topbar { |  | ||||||
|     padding-bottom: 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .invert-collapses { |  | ||||||
|     display: inline-flex; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent .invert-collapses { |  | ||||||
|     display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed input[name="name"] { |  | ||||||
|     display: inline-flex; |  | ||||||
|     flex-grow: 1; |  | ||||||
|     border: none; |  | ||||||
|     background: none; |  | ||||||
|     box-shadow: none; |  | ||||||
|     opacity: 1; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .hide-box { |  | ||||||
|     display: inline-flex; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .hide-box i { |  | ||||||
|     transform: rotate(90deg); |  | ||||||
| } |  | ||||||
| @@ -1,13 +0,0 @@ | |||||||
| { |  | ||||||
| 	"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", "**/*"] |  | ||||||
| } |  | ||||||
| @@ -1,12 +0,0 @@ | |||||||
| 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, |  | ||||||
|     }, |  | ||||||
| }); |  | ||||||
| @@ -1,11 +0,0 @@ | |||||||
| pub mod set; |  | ||||||
| pub mod unset; |  | ||||||
|  |  | ||||||
| use crate::{Context, Error}; |  | ||||||
|  |  | ||||||
| /// Configure whether other users can set reminders to your direct messages |  | ||||||
| #[poise::command(slash_command, rename = "dm")] |  | ||||||
| #[cfg(not(test))] |  | ||||||
| pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
| @@ -1,39 +0,0 @@ | |||||||
| use poise::{serenity_prelude::CreateEmbed, CreateReply}; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
|  |  | ||||||
| use crate::{ |  | ||||||
|     consts::THEME_COLOR, |  | ||||||
|     models::CtxData, |  | ||||||
|     utils::{Extract, Recordable}, |  | ||||||
|     Context, Error, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize, Extract)] |  | ||||||
| pub struct Options; |  | ||||||
|  |  | ||||||
| impl Recordable for Options { |  | ||||||
|     async fn run(self, ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|         let mut user_data = ctx.author_data().await?; |  | ||||||
|         user_data.allowed_dm = true; |  | ||||||
|         user_data.commit_changes(&ctx.data().database).await; |  | ||||||
|  |  | ||||||
|         ctx.send( |  | ||||||
|             CreateReply::default().ephemeral(true).embed( |  | ||||||
|                 CreateEmbed::new() |  | ||||||
|                     .title("DMs permitted") |  | ||||||
|                     .description("You will receive a message if a user sets a DM reminder for you.") |  | ||||||
|                     .color(*THEME_COLOR), |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
|  |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// Allow other users to set reminders in your direct messages |  | ||||||
| #[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")] |  | ||||||
| #[cfg(not(test))] |  | ||||||
| pub async fn set(ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|     (Options {}).run(ctx).await |  | ||||||
| } |  | ||||||
| @@ -1,41 +0,0 @@ | |||||||
| use poise::{serenity_prelude::CreateEmbed, CreateReply}; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
|  |  | ||||||
| use crate::{ |  | ||||||
|     consts::THEME_COLOR, |  | ||||||
|     models::CtxData, |  | ||||||
|     utils::{Extract, Recordable}, |  | ||||||
|     Context, Error, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize, Extract)] |  | ||||||
| pub struct Options; |  | ||||||
|  |  | ||||||
| impl Recordable for Options { |  | ||||||
|     async fn run(self, ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|         let mut user_data = ctx.author_data().await?; |  | ||||||
|         user_data.allowed_dm = false; |  | ||||||
|         user_data.commit_changes(&ctx.data().database).await; |  | ||||||
|  |  | ||||||
|         ctx.send( |  | ||||||
|             CreateReply::default().ephemeral(true).embed( |  | ||||||
|                 CreateEmbed::new() |  | ||||||
|                     .title("DMs blocked") |  | ||||||
|                     .description(concat!( |  | ||||||
|                         "You can still set DM reminders for yourself or for users with", |  | ||||||
|                         " DMs enabled." |  | ||||||
|                     )) |  | ||||||
|                     .color(*THEME_COLOR), |  | ||||||
|             ), |  | ||||||
|         ) |  | ||||||
|         .await?; |  | ||||||
|  |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// Block other users from setting reminders in your direct messages |  | ||||||
| #[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")] |  | ||||||
| pub async fn unset(ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|     (Options {}).run(ctx).await |  | ||||||
| } |  | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| use std::time::{SystemTime, UNIX_EPOCH}; | use std::time::{SystemTime, UNIX_EPOCH}; | ||||||
|  |  | ||||||
| use chrono_tz::TZ_VARIANTS; | use chrono_tz::TZ_VARIANTS; | ||||||
| use poise::serenity_prelude::AutocompleteChoice; | use poise::AutocompleteChoice; | ||||||
|  |  | ||||||
| use crate::{models::CtxData, time_parser::natural_parser, Context}; | use crate::{models::CtxData, time_parser::natural_parser, Context}; | ||||||
|  |  | ||||||
| @@ -22,12 +22,11 @@ pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Str | |||||||
|     sqlx::query!( |     sqlx::query!( | ||||||
|         " |         " | ||||||
| SELECT name | SELECT name | ||||||
|         FROM command_macro | FROM macro | ||||||
| WHERE | WHERE | ||||||
|     guild_id = (SELECT id FROM guilds WHERE guild = ?) |     guild_id = (SELECT id FROM guilds WHERE guild = ?) | ||||||
|             AND name LIKE CONCAT(?, '%') |     AND name LIKE CONCAT(?, '%')", | ||||||
|         ", |         ctx.guild_id().unwrap().0, | ||||||
|         ctx.guild_id().unwrap().get(), |  | ||||||
|         partial, |         partial, | ||||||
|     ) |     ) | ||||||
|     .fetch_all(&ctx.data().database) |     .fetch_all(&ctx.data().database) | ||||||
| @@ -38,9 +37,15 @@ pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Str | |||||||
|     .collect() |     .collect() | ||||||
| } | } | ||||||
|  |  | ||||||
| pub async fn time_hint_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<AutocompleteChoice> { | pub async fn time_hint_autocomplete( | ||||||
|  |     ctx: Context<'_>, | ||||||
|  |     partial: &str, | ||||||
|  | ) -> Vec<AutocompleteChoice<String>> { | ||||||
|     if partial.is_empty() { |     if partial.is_empty() { | ||||||
|         vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())] |         vec![AutocompleteChoice { | ||||||
|  |             name: "Start typing a time...".to_string(), | ||||||
|  |             value: "now".to_string(), | ||||||
|  |         }] | ||||||
|     } else { |     } else { | ||||||
|         match natural_parser(partial, &ctx.timezone().await.to_string()).await { |         match natural_parser(partial, &ctx.timezone().await.to_string()).await { | ||||||
|             Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) { |             Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) { | ||||||
| @@ -48,49 +53,64 @@ pub async fn time_hint_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Auto | |||||||
|                     let diff = timestamp - now.as_secs() as i64; |                     let diff = timestamp - now.as_secs() as i64; | ||||||
|  |  | ||||||
|                     if diff < 0 { |                     if diff < 0 { | ||||||
|                         vec![AutocompleteChoice::new( |                         vec![AutocompleteChoice { | ||||||
|                             "Time is in the past".to_string(), |                             name: "Time is in the past".to_string(), | ||||||
|                             "1 year ago".to_string(), |                             value: "1 year ago".to_string(), | ||||||
|                         )] |                         }] | ||||||
|                     } else { |                     } else { | ||||||
|                         if diff > 86400 { |                         if diff > 86400 { | ||||||
|                             vec![ |                             vec![ | ||||||
|                                 AutocompleteChoice::new(partial.to_string(), partial.to_string()), |                                 AutocompleteChoice { | ||||||
|                                 AutocompleteChoice::new( |                                     name: partial.to_string(), | ||||||
|                                     format!( |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|  |                                 AutocompleteChoice { | ||||||
|  |                                     name: format!( | ||||||
|                                         "In approximately {} days, {} hours", |                                         "In approximately {} days, {} hours", | ||||||
|                                         diff / 86400, |                                         diff / 86400, | ||||||
|                                         (diff % 86400) / 3600 |                                         (diff % 86400) / 3600 | ||||||
|                                     ), |                                     ), | ||||||
|                                     partial.to_string(), |                                     value: partial.to_string(), | ||||||
|                                 ), |                                 }, | ||||||
|                             ] |                             ] | ||||||
|                         } else if diff > 3600 { |                         } else if diff > 3600 { | ||||||
|                             vec![ |                             vec![ | ||||||
|                                 AutocompleteChoice::new(partial.to_string(), partial.to_string()), |                                 AutocompleteChoice { | ||||||
|                                 AutocompleteChoice::new( |                                     name: partial.to_string(), | ||||||
|                                     format!("In approximately {} hours", diff / 3600), |                                     value: partial.to_string(), | ||||||
|                                     partial.to_string(), |                                 }, | ||||||
|                                 ), |                                 AutocompleteChoice { | ||||||
|  |                                     name: format!("In approximately {} hours", diff / 3600), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|                             ] |                             ] | ||||||
|                         } else { |                         } else { | ||||||
|                             vec![ |                             vec![ | ||||||
|                                 AutocompleteChoice::new(partial.to_string(), partial.to_string()), |                                 AutocompleteChoice { | ||||||
|                                 AutocompleteChoice::new( |                                     name: partial.to_string(), | ||||||
|                                     format!("In approximately {} minutes", diff / 60), |                                     value: partial.to_string(), | ||||||
|                                     partial.to_string(), |                                 }, | ||||||
|                                 ), |                                 AutocompleteChoice { | ||||||
|  |                                     name: format!("In approximately {} minutes", diff / 60), | ||||||
|  |                                     value: partial.to_string(), | ||||||
|  |                                 }, | ||||||
|                             ] |                             ] | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|                 Err(_) => { |                 Err(_) => { | ||||||
|                     vec![AutocompleteChoice::new(partial.to_string(), partial.to_string())] |                     vec![AutocompleteChoice { | ||||||
|  |                         name: partial.to_string(), | ||||||
|  |                         value: partial.to_string(), | ||||||
|  |                     }] | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
|  |  | ||||||
|             None => { |             None => { | ||||||
|                 vec![AutocompleteChoice::new("Time not recognised".to_string(), "now".to_string())] |                 vec![AutocompleteChoice { | ||||||
|  |                     name: "Time not recognised".to_string(), | ||||||
|  |                     value: "now".to_string(), | ||||||
|  |                 }] | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,36 +0,0 @@ | |||||||
| use chrono::Utc; |  | ||||||
| use poise::CreateReply; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
|  |  | ||||||
| use crate::{ |  | ||||||
|     models::CtxData, |  | ||||||
|     utils::{Extract, Recordable}, |  | ||||||
|     Context, Error, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize, Extract)] |  | ||||||
| pub struct Options; |  | ||||||
|  |  | ||||||
| impl Recordable for Options { |  | ||||||
|     async fn run(self, ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|         ctx.defer_ephemeral().await?; |  | ||||||
|  |  | ||||||
|         let tz = ctx.timezone().await; |  | ||||||
|         let now = Utc::now().with_timezone(&tz); |  | ||||||
|  |  | ||||||
|         ctx.send(CreateReply::default().ephemeral(true).content(format!( |  | ||||||
|             "Time in **{}**: `{}`", |  | ||||||
|             tz, |  | ||||||
|             now.format("%H:%M") |  | ||||||
|         ))) |  | ||||||
|         .await?; |  | ||||||
|  |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// View the current time in your selected timezone |  | ||||||
| #[poise::command(slash_command, rename = "clock", identifying_name = "clock")] |  | ||||||
| pub async fn command(ctx: Context<'_>) -> Result<(), Error> { |  | ||||||
|     (Options {}).run(ctx).await |  | ||||||
| } |  | ||||||
| @@ -1,27 +0,0 @@ | |||||||
| use chrono::Utc; |  | ||||||
| use poise::{ |  | ||||||
|     serenity_prelude::{Mentionable, User}, |  | ||||||
|     CreateReply, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| use crate::{models::CtxData, Context, Error}; |  | ||||||
|  |  | ||||||
| /// View the current time in a user's selected timezone |  | ||||||
| #[poise::command(context_menu_command = "View Local Time")] |  | ||||||
| pub async fn clock_context_menu(ctx: Context<'_>, user: User) -> Result<(), Error> { |  | ||||||
|     ctx.defer_ephemeral().await?; |  | ||||||
|  |  | ||||||
|     let user_data = ctx.user_data(user.id).await?; |  | ||||||
|     let tz = user_data.timezone(); |  | ||||||
|  |  | ||||||
|     let now = Utc::now().with_timezone(&tz); |  | ||||||
|  |  | ||||||
|     ctx.send(CreateReply::default().ephemeral(true).content(format!( |  | ||||||
|         "Time in {}'s timezone: `{}`", |  | ||||||
|         user.mention(), |  | ||||||
|         now.format("%H:%M") |  | ||||||
|     ))) |  | ||||||
|     .await?; |  | ||||||
|  |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
| @@ -17,21 +17,15 @@ pub async fn delete_macro( | |||||||
| ) -> Result<(), Error> { | ) -> Result<(), Error> { | ||||||
|     match sqlx::query!( |     match sqlx::query!( | ||||||
|         " |         " | ||||||
|         SELECT m.id | SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
 | ||||||
|         FROM command_macro m |         ctx.guild_id().unwrap().0, | ||||||
|         INNER JOIN guilds |  | ||||||
|             ON guilds.guild = m.guild_id |  | ||||||
|         WHERE guild = ? |  | ||||||
|             AND m.name = ? |  | ||||||
|         ",
 |  | ||||||
|         ctx.guild_id().unwrap().get(), |  | ||||||
|         name |         name | ||||||
|     ) |     ) | ||||||
|     .fetch_one(&ctx.data().database) |     .fetch_one(&ctx.data().database) | ||||||
|     .await |     .await | ||||||
|     { |     { | ||||||
|         Ok(row) => { |         Ok(row) => { | ||||||
|             sqlx::query!("DELETE FROM command_macro WHERE id = ?", row.id) |             sqlx::query!("DELETE FROM macro WHERE id = ?", row.id) | ||||||
|                 .execute(&ctx.data().database) |                 .execute(&ctx.data().database) | ||||||
|                 .await |                 .await | ||||||
|                 .unwrap(); |                 .unwrap(); | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user