From a0d24aabca3ce102f8c9fc15fe90936ae3efb78c Mon Sep 17 00:00:00 2001 From: Riley Champion <47626030+RileyChampion@users.noreply.github.com> Date: Thu, 2 Jan 2025 15:14:20 -0700 Subject: [PATCH 01/89] Events Table, Create/Edit Routes MVP (#1) * Starter Event table, event CRUD commands * Added create/update template + additional event CRUD commands * Some fmt changes * Address fmt changes * Remove delete event for now * Added delete route and accessible via event-update page * Add chrono dependency, list of events and add a change to initialize tera priort to AppState to register filters for templating --- Cargo.toml | 2 +- src/app/mod.rs | 133 ++++++++++++++++++++++++++++++- src/utils/db.rs | 101 ++++++++++++++++++++++- templates/event-create.tera.html | 45 +++++++++++ templates/event-list.tera.html | 35 ++++++++ templates/event.tera.html | 49 ++++++++++++ 6 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 templates/event-create.tera.html create mode 100644 templates/event-list.tera.html create mode 100644 templates/event.tera.html diff --git a/Cargo.toml b/Cargo.toml index 2edaac27..bad8f667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ futures = "0.3" serde = { version = "1", features = ["derive"] } toml = "0.8" rand = "0.8" - +chrono = "0.4" # Add a little optimization to debug builds [profile.dev] diff --git a/src/app/mod.rs b/src/app/mod.rs index 4a0a617d..97ff5285 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,11 +1,13 @@ -use std::{sync::Arc, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use crate::utils::{config::*, db::Db, email::Email}; -use tera::Tera; +use chrono::{DateTime, Local, NaiveDateTime, Utc}; +use serde::Deserialize; +use tera::{Tera, Value}; use anyhow::Result; use axum::{ - extract::{MatchedPath, Query, Request, State}, + extract::{MatchedPath, Path, Query, Request, State}, http::{header, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, @@ -25,10 +27,33 @@ struct AppState { mail: Email, } +fn format_datetime(value: &Value, args: &HashMap) -> tera::Result { + // Extract the input string from the value + let input = value.as_str().ok_or_else(|| tera::Error::msg("Value must be a string"))?; + + // Parse the input string as a NaiveDateTime + let naive_datetime = NaiveDateTime::parse_from_str(input, "%Y-%m-%dT%H:%M") + .map_err(|_| tera::Error::msg("Failed to parse date"))?; + + // Convert NaiveDateTime to DateTime + let datetime: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); + + // Check for a custom format in arguments, or use a default + let format = args.get("format").and_then(Value::as_str).unwrap_or("%m.%d.%Y"); + + // Format the date-time + let formatted = datetime.format(format).to_string(); + Ok(Value::String(formatted)) +} + pub async fn build(config: Config) -> Result { + let mut tera_templates = Tera::new("templates/*")?; + // Register fiters to use while templating + tera_templates.register_filter("format_datetime", format_datetime); + let state = AppState { config: config.clone(), - templates: Tera::new("templates/*")?, + templates: tera_templates, db: Db::connect(&config.app.db).await?, mail: Email::connect(config.email).await?, }; @@ -39,6 +64,12 @@ pub async fn build(config: Config) -> Result { .route("/login", get(login)) .route("/register", get(register)) .route("/register", post(register_form)) + .route("/event/create", get(event_create)) + .route("/event/create", post(create_event_form)) + .route("/events", get(event_list)) + .route("/event/:event_id", get(event_update)) + .route("/event/:event_id/update", post(update_event_form)) + .route("/event/:event_id/delete", post(deleting_event)) .nest_service("/assets", ServeDir::new("assets")) .layer( TraceLayer::new_for_http() @@ -153,6 +184,100 @@ async fn register_form( Ok(headers.into_response()) } +#[derive(Deserialize)] +struct EventsParam { + #[serde(default = "default_to_false")] + past: bool, +} +fn default_to_false() -> bool { + false +} +async fn event_list( + State(state): State>, + Query(param): Query, +) -> AppResult { + let mut ctx = tera::Context::new(); + + let events = state + .db + .get_all_events(Local::now().format("fmt").to_string(), param.past) + .await?; + ctx.insert("events", &events); + let html = state.templates.render("event-list.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +async fn event_create(State(state): State>) -> AppResult { + let ctx = tera::Context::new(); + let html = state.templates.render("event-create.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +#[derive(serde::Deserialize)] +struct EventCreateForm { + title: String, + artist: String, + description: String, + start_date: String, +} +async fn create_event_form( + State(state): State>, + Form(form): Form, +) -> AppResult { + let _event_id = state + .db + .create_event(&form.title, &form.artist, &form.description, &form.start_date) + .await?; + Ok("Event created.") +} + +async fn event_update( + State(state): State>, + Path(event_id): Path, +) -> AppResult { + let mut ctx = tera::Context::new(); + let Some(event) = state.db.lookup_event_by_event_id(&event_id.parse().unwrap()).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + ctx.insert("event", &event); + + let html = state.templates.render("event.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +#[derive(serde::Deserialize)] +struct EventUpdateForm { + title: String, + artist: String, + description: String, + start_date: String, +} +async fn update_event_form( + State(state): State>, + Path(event_id): Path, + Form(form): Form, +) -> AppResult { + state + .db + .update_event( + event_id.parse().unwrap(), + &form.title, + &form.artist, + &form.description, + &form.start_date, + ) + .await?; + Ok("Event updated.") +} + +async fn deleting_event( + State(state): State>, + Path(event_id): Path, +) -> AppResult { + state.db.delete_event(event_id.parse().unwrap()).await?; + Ok("Event deleted.") +} + struct AppError(anyhow::Error); type AppResult = Result; impl IntoResponse for AppError { diff --git a/src/utils/db.rs b/src/utils/db.rs index 811c5aa0..9f004a72 100644 --- a/src/utils/db.rs +++ b/src/utils/db.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::Result; use lettre::message::Mailbox; use rand::{rngs::OsRng, Rng as _}; -use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool}; +use sqlx::{migrate::MigrateDatabase, sqlite::SqliteQueryResult, Error, Sqlite, SqlitePool}; #[derive(Clone)] pub struct Db { @@ -19,6 +19,17 @@ pub struct User { pub created_at: String, } +#[derive(sqlx::FromRow, serde::Serialize)] +pub struct Event { + pub id: i64, + pub title: String, + pub artist: String, + pub description: String, + pub start_date: String, + pub created_at: String, + pub updated_at: String, +} + impl Db { pub async fn connect(file: &Path) -> Result { let url = format!("sqlite://{}", file.display()); @@ -68,6 +79,20 @@ impl Db { .execute(&self.pool) .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS events ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + title TEXT NOT NULL, \ + artist TEXT NOT NULL, \ + description TEXT NOT NULL, \ + start_date TIMESTAMP NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ + )", + ) + .execute(&self.pool) + .await?; + Ok(()) } @@ -141,4 +166,78 @@ impl Db { .await?; Ok(row.map(|r| r.0)) } + // Lookup Event by id + pub async fn lookup_event_by_event_id(&self, id: &i64) -> Result> { + let event = sqlx::query_as::<_, Event>( + "SELECT e.* \ + FROM events e \ + WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(event) + } + // Get all Events + pub async fn get_all_events(&self, date: String, past: bool) -> Result, Error> { + let events = if !past { + sqlx::query_as::<_, Event>("SELECT e.* FROM events e WHERE start_date >= ?") + .bind(date) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query_as::<_, Event>("SELECT e.* FROM events e WHERE start_date < ?") + .bind(date) + .fetch_all(&self.pool) + .await? + }; + Ok(events) + } + // Create Event + pub async fn create_event( + &self, + title: &str, + artist: &str, + description: &str, + start_date: &str, + ) -> Result { + let row = + sqlx::query("INSERT INTO events (title, artist, description, start_date) VALUES (?, ?, ?, ?)") + .bind(title) + .bind(artist) + .bind(description) + .bind(start_date) + .execute(&self.pool) + .await?; + Ok(row.last_insert_rowid()) + } + // Update Event + pub async fn update_event( + &self, + id: i64, + title: &str, + artist: &str, + description: &str, + start_date: &str, + ) -> Result { + sqlx::query( + "UPDATE events + SET title = ?, artist = ?, description = ?, start_date = ? + WHERE id = ?", + ) + .bind(title) + .bind(artist) + .bind(description) + .bind(start_date.to_string()) + .bind(id) + .execute(&self.pool) + .await + } + // Remove Event + pub async fn delete_event(&self, id: i64) -> Result { + sqlx::query("DELETE FROM events WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await + } } diff --git a/templates/event-create.tera.html b/templates/event-create.tera.html new file mode 100644 index 00000000..9bcad06c --- /dev/null +++ b/templates/event-create.tera.html @@ -0,0 +1,45 @@ + + + + + + + WLSD + + + +
+

Let's Create an Event

+
+ + + + + + + + + + + + + + + + +
+
+ + \ No newline at end of file diff --git a/templates/event-list.tera.html b/templates/event-list.tera.html new file mode 100644 index 00000000..a1982e50 --- /dev/null +++ b/templates/event-list.tera.html @@ -0,0 +1,35 @@ + + + + + + WLSD + + + +

Upcoming Events:

+ {% for event in events %} + + {% endfor %} + + \ No newline at end of file diff --git a/templates/event.tera.html b/templates/event.tera.html new file mode 100644 index 00000000..8fe51f26 --- /dev/null +++ b/templates/event.tera.html @@ -0,0 +1,49 @@ + + + + + + WLSD + + + + {% if event %} +

Update Event: {{ event.title }}

+
+ + + + + + + + + + + + + + + +
+
+ +
+ {% else %} +

Event does not exist...

+ {% endif %} + + \ No newline at end of file From 85ca5f959d46881f170c722c9de8761aedebc5cc Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Thu, 2 Jan 2025 17:43:34 -0500 Subject: [PATCH 02/89] Rename to lsd, cleanup --- .github/workflows/deploy.yaml | 16 +++++++++------- Cargo.toml | 2 +- README.md | 8 ++++---- config/dev.toml | 4 ++-- config/prod.toml | 8 ++++---- scripts/bootstrap.sh | 25 +++++++++---------------- scripts/deploy.sh | 6 +++--- src/main.rs | 4 ++-- src/utils/config.rs | 12 ++++++++++-- src/utils/email.rs | 2 +- 10 files changed, 45 insertions(+), 42 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4a4a632e..eeb0db49 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -17,13 +17,15 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: install gcc-aarch64-linux-gnu run: sudo apt install -y gcc-aarch64-linux-gnu - - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.SSH_KEY }} - - name: ssh-keyscan + + - name: Setup SSH key run: | - mkdir -p ~/.ssh - ssh-keyscan wlsd.lightandsound.design > ~/.ssh/known_hosts + mkdir ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.ROOT_SSH_PRIVKEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan beta.lightandsound.design > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts + - name: deploy - run: scripts/deploy.sh ec2-user@wlsd.lightandsound.design + run: scripts/deploy.sh ec2-user@beta.lightandsound.design diff --git a/Cargo.toml b/Cargo.toml index bad8f667..86975890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "wlsd" +name = "lsd" version = "0.1.0" edition = "2021" diff --git a/README.md b/README.md index d863e93d..ffb5b6b4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# WLSD +# lightandsound.design Coming to you live. @@ -13,8 +13,8 @@ rustc --version Clone the repo: ```sh -git clone https://github.com/foltik/wlsd -cd wlsd +git clone https://github.com/foltik/lsd +cd lsd ``` To automatically recompile and rerun when you make changes, use `cargo-watch`: @@ -32,4 +32,4 @@ mailtutan ## Workflow * Make commits in a separate branch, and open a PR against `main` -* When new commits land in `main`, a github action will automatically deploy the app to https://wlsd.foltz.io +* When new commits land in `main`, a github action will automatically deploy the app to https://beta.lightandsound.design diff --git a/config/dev.toml b/config/dev.toml index abea9524..a275b481 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -7,5 +7,5 @@ http_addr = "[::]:8080" https_addr = "[::]:4433" [email] -addr = "smtp://localhost:1025" -from = "WLSD " +smtp_addr = "smtp://localhost:1025" +from = "WLSD " diff --git a/config/prod.toml b/config/prod.toml index 2e966456..9106d327 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -1,5 +1,5 @@ [app] -url = "https://wlsd.lightandsound.design" +url = "https://beta.lightandsound.design" db = "db.sqlite" [net] @@ -7,11 +7,11 @@ http_addr = "[::]:80" https_addr = "[::]:443" [acme] -domain = "wlsd.lightandsound.design" +domain = "beta.lightandsound.design" email = "studio249@foltz.io" dir = "acme" prod = true [email] -addr = "smtp://localhost:1080" -from = "WLSD " +smtp_addr = "smtp://localhost:1025" +from = "WLSD " diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 2fa91abb..0b50fdfd 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -10,34 +10,27 @@ ssh $1 <<'EOS' sudo yum update -y # create a user -if ! id wlsd &>/dev/null; then - sudo adduser wlsd +if ! id lsd &>/dev/null; then + sudo adduser lsd fi -# add ssh keys -cat > .ssh/authorized_keys </dev/null +sudo tee /etc/systemd/system/lsd.service </dev/null [Unit] -Description=WLSD +Description=LSD After=network.target [Service] Type=simple -User=wlsd -WorkingDirectory=/home/wlsd -ExecStart=/home/wlsd/wlsd /home/wlsd/config/prod.toml +User=lsd +WorkingDirectory=/home/lsd +ExecStart=/home/lsd/lsd /home/lsd/config/prod.toml Restart=always [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload -sudo systemctl enable wlsd -sudo systemctl restart wlsd +sudo systemctl enable lsd +sudo systemctl restart lsd EOS diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 5d40d145..7e1eb696 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -9,8 +9,8 @@ cargo build --profile prod --target aarch64-unknown-linux-gnu ls -l target/aarch64-unknown-linux-gnu/ ls -l target/aarch64-unknown-linux-gnu/* -rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/prod/wlsd $1:/home/wlsd/ +rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/prod/lsd $1:/home/lsd/ ssh $1 <<'EOS' -sudo setcap 'cap_net_bind_service=+ep' /home/wlsd/wlsd -sudo systemctl restart wlsd +sudo setcap 'cap_net_bind_service=+ep' /home/lsd/lsd +sudo systemctl restart lsd EOS diff --git a/src/main.rs b/src/main.rs index a9791970..1eb1575c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ async fn main() -> Result<()> { tracing_subscriber::fmt().init(); // Load the server config - let file = std::env::args().nth(1).context("usage: wlsd ")?; + let file = std::env::args().nth(1).context("usage: lsd ")?; let config = Config::load(&file).await?; let app = app::build(config.clone()).await?.into_make_service(); @@ -42,7 +42,7 @@ async fn main() -> Result<()> { tokio::spawn(async move { loop { match acme.next().await.unwrap() { - Ok(ok) => tracing::info!("acme: {:?}", ok), + Ok(ok) => tracing::debug!("acme: {:?}", ok), Err(err) => tracing::error!("acme: {}", err), } } diff --git a/src/utils/config.rs b/src/utils/config.rs index acbdceb5..ad2ae2a3 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -26,25 +26,33 @@ pub struct AppConfig { pub db: PathBuf, } +/// Networking configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct NetConfig { + /// HTTP server bind address. pub http_addr: SocketAddr, + /// HTTS server bind address. pub https_addr: SocketAddr, } /// LetsEncrypt ACME TLS certificate configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AcmeConfig { + /// Domain to request a cert for. pub domain: String, + /// Contact email. pub email: String, - /// Directory where certificates and credentials are stored. + /// Directory to store certs and credentials in. pub dir: String, /// Whether to use the production or staging ACME server. pub prod: bool, } +/// Email configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct EmailConfig { - pub addr: String, + /// SMTP address, starting with `smtp://`. + pub smtp_addr: String, + /// Mailbox to send email from. pub from: Mailbox, } diff --git a/src/utils/email.rs b/src/utils/email.rs index 9ffe592c..a47428e6 100644 --- a/src/utils/email.rs +++ b/src/utils/email.rs @@ -16,7 +16,7 @@ impl Email { pub async fn connect(config: EmailConfig) -> Result { // we need this for smtps let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - Ok(Self { addr: config.addr, from: config.from }) + Ok(Self { addr: config.smtp_addr, from: config.from }) } pub fn builder(&self) -> MessageBuilder { From 9920a5e3e8ee171edc64d5ae1ad2fb5d05a52635 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Thu, 2 Jan 2025 17:49:50 -0500 Subject: [PATCH 03/89] Use release instead of prod profile --- Cargo.toml | 6 ------ scripts/deploy.sh | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 86975890..04f2e868 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,9 +31,3 @@ opt-level = 1 # And since they don't get recompiled often, fully optimize dependencies [profile.dev.package."*"] opt-level = 3 - -# Production build with more intense optimization -[profile.prod] -inherits = "release" -lto = true -codegen-units = 1 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 7e1eb696..a87696ab 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -5,11 +5,11 @@ if [ $# -ne 1 ]; then exit 1 fi -cargo build --profile prod --target aarch64-unknown-linux-gnu +cargo build --release --target aarch64-unknown-linux-gnu ls -l target/aarch64-unknown-linux-gnu/ ls -l target/aarch64-unknown-linux-gnu/* -rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/prod/lsd $1:/home/lsd/ +rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/release/lsd $1:/home/lsd/ ssh $1 <<'EOS' sudo setcap 'cap_net_bind_service=+ep' /home/lsd/lsd sudo systemctl restart lsd From 5c29de53a8f13f7acc4d30a17f2e970ae51fffef Mon Sep 17 00:00:00 2001 From: Jack Foltz <5026551+foltik@users.noreply.github.com> Date: Fri, 3 Jan 2025 10:15:05 -0500 Subject: [PATCH 04/89] more upgrades (#2) * WIP: events fixes * WIP: posts * cleanup * Don't run tests on every push --- .github/workflows/test.yaml | 2 - Cargo.toml | 4 +- src/app/auth.rs | 117 +++++++++++++ src/app/events.rs | 116 +++++++++++++ src/app/home.rs | 30 ++++ src/app/mod.rs | 294 +++----------------------------- src/app/posts.rs | 55 ++++++ src/utils/config.rs | 3 + src/utils/db.rs | 61 ++++++- src/utils/email.rs | 15 +- src/utils/mod.rs | 3 + src/utils/tera.rs | 47 +++++ src/utils/tracing.rs | 29 ++++ src/utils/types.rs | 34 ++++ templates/event-list.tera.html | 4 +- templates/post-create.tera.html | 42 +++++ templates/post.tera.html | 29 ++++ 17 files changed, 595 insertions(+), 290 deletions(-) create mode 100644 src/app/auth.rs create mode 100644 src/app/events.rs create mode 100644 src/app/home.rs create mode 100644 src/app/posts.rs create mode 100644 src/utils/tera.rs create mode 100644 src/utils/tracing.rs create mode 100644 src/utils/types.rs create mode 100644 templates/post-create.tera.html create mode 100644 templates/post.tera.html diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e510b0d1..63324dd5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,8 +1,6 @@ name: Test on: pull_request: - push: - branches: ["*"] jobs: test: diff --git a/Cargo.toml b/Cargo.toml index 04f2e868..63547454 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ axum-server = { version = "0.7", features = ["tls-rustls"] } axum-extra = { version = "0.9", features = ["cookie"] } tower-http = { version = "0.6", features = ["fs", "trace"] } tera = "1" -sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "tokio1", "tokio1-rustls-tls", "serde"] } tokio = { version = "1", features = ["rt-multi-thread", "fs", "net", "sync", "macros"] } rustls = "0.23" @@ -23,7 +23,7 @@ futures = "0.3" serde = { version = "1", features = ["derive"] } toml = "0.8" rand = "0.8" -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } # Add a little optimization to debug builds [profile.dev] diff --git a/src/app/auth.rs b/src/app/auth.rs new file mode 100644 index 00000000..d84d3272 --- /dev/null +++ b/src/app/auth.rs @@ -0,0 +1,117 @@ +//! A simple passwordless authentication flow using one-time links sent via email. +//! +//! We choose this scheme instead of one with usernames/passwords to reduce +//! friction and simplify onboarding. +//! +//! # High-level flow +//! +//! 1. **Email input**: User enters their email and submits the login form. +//! 2. **Token generated**: Server creates a short-lived login token and emails it to the user. +//! 3. **Link clicked**: User clicks the link, passing the token back to the server. +//! - **Login**: If the user is already registered, they get a new session cookie. +//! - **Registration**: Otherwise, they're prompted to enter their first/last name. +//! Upon submission, the user is registered and they get a new session cookie. + +use axum::{ + extract::{Query, State}, + http::{header, StatusCode}, + response::{Html, IntoResponse, Redirect, Response}, + routing::get, + Form, +}; +use lettre::message::Mailbox; + +use crate::utils::types::{AppResult, AppRouter, SharedAppState}; + +/// Add all `auth` routes to the router. +pub fn register_routes(router: AppRouter) -> AppRouter { + router + .route("/login", get(login_page).post(login_form)) + .route("/register", get(register_page).post(register_form)) +} + +/// Display the login page. +async fn login_page( + State(state): State, + Query(login): Query, +) -> AppResult { + let Some(user) = state.db.lookup_user_by_login_token(&login.token).await? else { + return Ok(StatusCode::FORBIDDEN.into_response()); + }; + + let session_token = state.db.create_session_token(user.id).await?; + let headers = ( + // TODO: expiration date + [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], + Redirect::to(&state.config.app.url), + ); + Ok(headers.into_response()) +} +#[derive(serde::Deserialize)] +struct LoginQuery { + token: String, +} + +/// Process the login form. +async fn login_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let login_token = state.db.create_login_token(&form.email).await?; + + let url = &state.config.app.url; + let url = match state.db.lookup_user_by_email(&form.email).await? { + Some(_) => format!("{url}/login?token={login_token}"), + None => format!("{url}/register?token={login_token}"), + }; + + let msg = state.mail.builder().to(form.email).body(url)?; + state.mail.send(msg).await?; + + Ok("Check your email!") +} +#[derive(serde::Deserialize)] +struct LoginForm { + email: Mailbox, +} + +/// Display the registration page. +async fn register_page( + State(state): State, + Query(register): Query, +) -> AppResult { + let mut ctx = tera::Context::new(); + ctx.insert("token", ®ister.token); + let html = state.templates.render("register.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} +#[derive(serde::Deserialize)] +struct RegisterQuery { + token: String, +} + +/// Process the registration form and create a new user. +async fn register_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let Some(email) = state.db.lookup_email_by_login_token(&form.token).await? else { + return Ok(StatusCode::FORBIDDEN.into_response()); + }; + + let user_id = state.db.create_user(&form.first_name, &form.last_name, &email).await?; + let session_token = state.db.create_session_token(user_id).await?; + + // TODO: Expiration date on the cookie + let headers = ( + [(header::SET_COOKIE, format!("session={session_token}; Secure"))], + Redirect::to(&state.config.app.url), + ); + Ok(headers.into_response()) +} +#[derive(serde::Deserialize)] +struct RegisterForm { + token: String, + first_name: String, + last_name: String, +} diff --git a/src/app/events.rs b/src/app/events.rs new file mode 100644 index 00000000..06614705 --- /dev/null +++ b/src/app/events.rs @@ -0,0 +1,116 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{Html, IntoResponse, Response}, + routing::get, + Form, +}; +use chrono::Local; + +use crate::utils::types::{AppResult, AppRouter, SharedAppState}; + +/// Add all `events` routes to the router. +pub fn register_routes(router: AppRouter) -> AppRouter { + router + .route("/events", get(list_events_page)) + .route("/e/new", get(create_event_page).post(create_event_form)) + .route( + "/e/:event_id", + // TODO: Move to a separate `/e/:event_id/edit` route, and add a `/e/:event_id` to just view the event. + get(update_event_page).post(update_event_form).delete(delete_event), + ) +} + +/// Display a list of all events. +async fn list_events_page( + State(state): State, + Query(param): Query, +) -> AppResult { + let events = state.db.get_all_events(Local::now(), param.past.unwrap_or(false)).await?; + + let mut ctx = tera::Context::new(); + ctx.insert("events", &events); + + let html = state.templates.render("event-list.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} +#[derive(serde::Deserialize)] +struct ListEvents { + past: Option, +} + +/// Display the form to create a new event. +async fn create_event_page(State(state): State) -> AppResult { + let ctx = tera::Context::new(); + let html = state.templates.render("event-create.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Process the form and create a new event. +async fn create_event_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let _event_id = state + .db + .create_event(&form.title, &form.artist, &form.description, &form.start_date) + .await?; + Ok("Event created.") +} +#[derive(serde::Deserialize)] +struct CreateEvent { + title: String, + artist: String, + description: String, + start_date: String, +} + +/// Display the form to update an event. +async fn update_event_page( + State(state): State, + Path(event_id): Path, +) -> AppResult { + let mut ctx = tera::Context::new(); + let Some(event) = state.db.lookup_event_by_event_id(&event_id.parse().unwrap()).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + ctx.insert("event", &event); + + let html = state.templates.render("event.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Process the form and update an event. +async fn update_event_form( + State(state): State, + Path(event_id): Path, + Form(form): Form, +) -> AppResult { + state + .db + .update_event( + event_id.parse().unwrap(), + &form.title, + &form.artist, + &form.description, + &form.start_date, + ) + .await?; + Ok("Event updated.") +} +#[derive(serde::Deserialize)] +struct UpdateEvent { + title: String, + artist: String, + description: String, + start_date: String, +} + +/// Delete an event. +async fn delete_event( + State(state): State, + Path(event_id): Path, +) -> AppResult { + state.db.delete_event(event_id.parse().unwrap()).await?; + Ok("Event deleted.") +} diff --git a/src/app/home.rs b/src/app/home.rs new file mode 100644 index 00000000..fa0ac95d --- /dev/null +++ b/src/app/home.rs @@ -0,0 +1,30 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{Html, IntoResponse, Response}, + routing::get, +}; +use axum_extra::extract::CookieJar; + +use crate::utils::types::{AppResult, AppRouter, SharedAppState}; + +/// Add all `home` routes to the router. +pub fn register_routes(router: AppRouter) -> AppRouter { + router.route("/", get(home_page)) +} + +/// Display the front page. +async fn home_page(State(state): State, cookies: CookieJar) -> AppResult { + let mut ctx = tera::Context::new(); + ctx.insert("message", "Hello, world!"); + + if let Some(session_token) = cookies.get("session") { + let Some(user) = state.db.lookup_user_from_session_token(session_token.value()).await? else { + return Ok(StatusCode::FORBIDDEN.into_response()); + }; + ctx.insert("user", &user); + } + + let html = state.templates.render("home.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 97ff5285..be6b702a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,295 +1,43 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use anyhow::Result; +use axum::Router; +use std::sync::Arc; +use tera::Tera; +use tower_http::services::ServeDir; -use crate::utils::{config::*, db::Db, email::Email}; -use chrono::{DateTime, Local, NaiveDateTime, Utc}; -use serde::Deserialize; -use tera::{Tera, Value}; +use crate::utils::{self, config::*, db::Db, email::Email}; -use anyhow::Result; -use axum::{ - extract::{MatchedPath, Path, Query, Request, State}, - http::{header, StatusCode}, - response::{Html, IntoResponse, Redirect, Response}, - routing::{get, post}, - Form, Router, -}; -use axum_extra::extract::CookieJar; -use lettre::message::Mailbox; -use tower_http::{services::ServeDir, trace::TraceLayer}; -use tracing::Span; +mod auth; +mod events; +mod home; +mod posts; #[derive(Clone)] #[allow(unused)] -struct AppState { +pub struct AppState { config: Config, templates: Tera, db: Db, mail: Email, } -fn format_datetime(value: &Value, args: &HashMap) -> tera::Result { - // Extract the input string from the value - let input = value.as_str().ok_or_else(|| tera::Error::msg("Value must be a string"))?; - - // Parse the input string as a NaiveDateTime - let naive_datetime = NaiveDateTime::parse_from_str(input, "%Y-%m-%dT%H:%M") - .map_err(|_| tera::Error::msg("Failed to parse date"))?; - - // Convert NaiveDateTime to DateTime - let datetime: DateTime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); - - // Check for a custom format in arguments, or use a default - let format = args.get("format").and_then(Value::as_str).unwrap_or("%m.%d.%Y"); - - // Format the date-time - let formatted = datetime.format(format).to_string(); - Ok(Value::String(formatted)) -} - pub async fn build(config: Config) -> Result { - let mut tera_templates = Tera::new("templates/*")?; - // Register fiters to use while templating - tera_templates.register_filter("format_datetime", format_datetime); - let state = AppState { config: config.clone(), - templates: tera_templates, + templates: utils::tera::templates()?, db: Db::connect(&config.app.db).await?, mail: Email::connect(config.email).await?, }; - let router = Router::new() - .route("/", get(home)) - .route("/login", post(login_form)) - .route("/login", get(login)) - .route("/register", get(register)) - .route("/register", post(register_form)) - .route("/event/create", get(event_create)) - .route("/event/create", post(create_event_form)) - .route("/events", get(event_list)) - .route("/event/:event_id", get(event_update)) - .route("/event/:event_id/update", post(update_event_form)) - .route("/event/:event_id/delete", post(deleting_event)) - .nest_service("/assets", ServeDir::new("assets")) - .layer( - TraceLayer::new_for_http() - .make_span_with(|req: &Request<_>| { - let path = match req.extensions().get::() { - Some(path) => path.as_str(), - None => req.uri().path(), - }; - tracing::info_span!("request", method = ?req.method(), path, status = tracing::field::Empty) - }) - .on_request(|_req: &Request<_>, _span: &Span| {}) - .on_response(|res: &Response, latency: Duration, span: &Span| { - span.record("status", res.status().as_u16()); - tracing::info!("handled in {latency:?}"); - }), - ) - .with_state(Arc::new(state)); - Ok(router) -} - -async fn home(State(state): State>, cookies: CookieJar) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("message", "Hello, world!"); + let r = Router::new(); + let r = home::register_routes(r); + let r = auth::register_routes(r); + let r = posts::register_routes(r); + let r = events::register_routes(r); - if let Some(session_token) = cookies.get("session") { - let Some(user) = state.db.lookup_user_from_session_token(session_token.value()).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - ctx.insert("user", &user); - } + let r = r.nest_service("/assets", ServeDir::new("assets")); + let r = utils::tracing::register(r); - let html = state.templates.render("home.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -#[derive(serde::Deserialize)] -struct LoginForm { - email: Mailbox, -} -async fn login_form( - State(state): State>, - Form(form): Form, -) -> AppResult { - let login_token = state.db.create_login_token(&form.email).await?; - - let url = &state.config.app.url; - let url = match state.db.lookup_user_by_email(&form.email).await? { - Some(_) => format!("{url}/login?token={login_token}"), - None => format!("{url}/register?token={login_token}"), - }; - - let msg = state.mail.builder().to(form.email).body(url)?; - state.mail.send(msg).await?; - - Ok("Check your email!") -} - -#[derive(serde::Deserialize)] -struct LoginQuery { - token: String, -} -async fn login(State(state): State>, Query(login): Query) -> AppResult { - let Some(user) = state.db.lookup_user_by_login_token(&login.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - - let session_token = state.db.create_session_token(user.id).await?; - let headers = ( - // TODO: expiration date - [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), - ); - Ok(headers.into_response()) -} - -#[derive(serde::Deserialize)] -struct RegisterQuery { - token: String, -} -async fn register( - State(state): State>, - Query(register): Query, -) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("token", ®ister.token); - let html = state.templates.render("register.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} + let r = r.with_state(Arc::new(state)); -#[derive(serde::Deserialize)] -struct RegisterForm { - token: String, - first_name: String, - last_name: String, -} -async fn register_form( - State(state): State>, - Form(form): Form, -) -> AppResult { - let Some(email) = state.db.lookup_email_by_login_token(&form.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - - let user_id = state.db.create_user(&form.first_name, &form.last_name, &email).await?; - let session_token = state.db.create_session_token(user_id).await?; - - // TODO: expiration date on the cookie - let headers = ( - [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), - ); - Ok(headers.into_response()) -} - -#[derive(Deserialize)] -struct EventsParam { - #[serde(default = "default_to_false")] - past: bool, -} -fn default_to_false() -> bool { - false -} -async fn event_list( - State(state): State>, - Query(param): Query, -) -> AppResult { - let mut ctx = tera::Context::new(); - - let events = state - .db - .get_all_events(Local::now().format("fmt").to_string(), param.past) - .await?; - ctx.insert("events", &events); - let html = state.templates.render("event-list.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -async fn event_create(State(state): State>) -> AppResult { - let ctx = tera::Context::new(); - let html = state.templates.render("event-create.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -#[derive(serde::Deserialize)] -struct EventCreateForm { - title: String, - artist: String, - description: String, - start_date: String, -} -async fn create_event_form( - State(state): State>, - Form(form): Form, -) -> AppResult { - let _event_id = state - .db - .create_event(&form.title, &form.artist, &form.description, &form.start_date) - .await?; - Ok("Event created.") -} - -async fn event_update( - State(state): State>, - Path(event_id): Path, -) -> AppResult { - let mut ctx = tera::Context::new(); - let Some(event) = state.db.lookup_event_by_event_id(&event_id.parse().unwrap()).await? else { - return Ok(StatusCode::NOT_FOUND.into_response()); - }; - ctx.insert("event", &event); - - let html = state.templates.render("event.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -#[derive(serde::Deserialize)] -struct EventUpdateForm { - title: String, - artist: String, - description: String, - start_date: String, -} -async fn update_event_form( - State(state): State>, - Path(event_id): Path, - Form(form): Form, -) -> AppResult { - state - .db - .update_event( - event_id.parse().unwrap(), - &form.title, - &form.artist, - &form.description, - &form.start_date, - ) - .await?; - Ok("Event updated.") -} - -async fn deleting_event( - State(state): State>, - Path(event_id): Path, -) -> AppResult { - state.db.delete_event(event_id.parse().unwrap()).await?; - Ok("Event deleted.") -} - -struct AppError(anyhow::Error); -type AppResult = Result; -impl IntoResponse for AppError { - fn into_response(self) -> Response { - // TODO: add a `dev` mode to `config.app`, and: - // * when enabled, respond with a stack trace - // * when disabled, respond with a generic error message that doesn't leak any details - (StatusCode::INTERNAL_SERVER_ERROR, format!("Error: {}", self.0)).into_response() - } -} -impl> From for AppError { - fn from(e: E) -> Self { - Self(e.into()) - } + Ok(r) } diff --git a/src/app/posts.rs b/src/app/posts.rs new file mode 100644 index 00000000..54f63178 --- /dev/null +++ b/src/app/posts.rs @@ -0,0 +1,55 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{Html, IntoResponse, Redirect, Response}, + routing::get, + Form, +}; + +use crate::utils::types::{AppResult, AppRouter, SharedAppState}; + +/// Add all `post` routes to the router. +pub fn register_routes(router: AppRouter) -> AppRouter { + router + .route("/p/new", get(create_post_page).post(create_post_form)) + .route("/p/:post", get(view_post_page)) +} + +/// Display a single post. +async fn view_post_page( + State(state): State, + Path(post): Path, +) -> AppResult { + let Some(post) = state.db.lookup_post_by_slug(&post).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let html = state.templates.render("post.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Display the form to create a new post. +async fn create_post_page(State(state): State) -> AppResult { + let ctx = tera::Context::new(); + let html = state.templates.render("post-create.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Process the form and create a new post. +async fn create_post_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let _event_id = state.db.create_post(&form.title, &form.slug, &form.author, &form.body).await?; + Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, form.slug))) +} +#[derive(serde::Deserialize)] +struct CreatePost { + title: String, + slug: String, + author: String, + body: String, +} diff --git a/src/utils/config.rs b/src/utils/config.rs index ad2ae2a3..17ba538c 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -3,6 +3,7 @@ use lettre::message::Mailbox; use std::{net::SocketAddr, path::PathBuf}; impl Config { + /// Load a `.toml` file from disk and parse it as a [`Config`]. pub async fn load(file: &str) -> Result { async fn load_inner(file: &str) -> Result { let contents = tokio::fs::read_to_string(file).await?; @@ -12,6 +13,7 @@ impl Config { } } +/// Bag of configuration values, parsed from a TOML file with serde. #[derive(Clone, Debug, serde::Deserialize)] pub struct Config { pub app: AppConfig, @@ -20,6 +22,7 @@ pub struct Config { pub email: EmailConfig, } +/// Webapp configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AppConfig { pub url: String, diff --git a/src/utils/db.rs b/src/utils/db.rs index 9f004a72..8f4695cb 100644 --- a/src/utils/db.rs +++ b/src/utils/db.rs @@ -1,16 +1,22 @@ use std::path::Path; use anyhow::Result; +use chrono::{DateTime, Local}; use lettre::message::Mailbox; use rand::{rngs::OsRng, Rng as _}; use sqlx::{migrate::MigrateDatabase, sqlite::SqliteQueryResult, Error, Sqlite, SqlitePool}; +// +--------------------------------------------------------------------------------+ +// | TODO: Separate the individual types into a `models/` module to reduce clutter. | +// +--------------------------------------------------------------------------------+ + +/// Database client. #[derive(Clone)] pub struct Db { pool: SqlitePool, } -#[derive(sqlx::FromRow, serde::Serialize)] +#[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct User { pub id: i64, pub first_name: String, @@ -19,15 +25,26 @@ pub struct User { pub created_at: String, } -#[derive(sqlx::FromRow, serde::Serialize)] +#[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct Event { pub id: i64, pub title: String, pub artist: String, pub description: String, - pub start_date: String, - pub created_at: String, - pub updated_at: String, + pub start_date: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Post { + pub id: i64, + pub title: String, + pub slug: String, + pub author: String, + pub body: String, + pub created_at: DateTime, + pub updated_at: DateTime, } impl Db { @@ -93,6 +110,20 @@ impl Db { .execute(&self.pool) .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS posts ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + title TEXT NOT NULL, \ + slug TEXT NOT NULL, \ + author TEXT NOT NULL, \ + body TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ + )", + ) + .execute(&self.pool) + .await?; + Ok(()) } @@ -179,7 +210,7 @@ impl Db { Ok(event) } // Get all Events - pub async fn get_all_events(&self, date: String, past: bool) -> Result, Error> { + pub async fn get_all_events(&self, date: DateTime, past: bool) -> Result, Error> { let events = if !past { sqlx::query_as::<_, Event>("SELECT e.* FROM events e WHERE start_date >= ?") .bind(date) @@ -240,4 +271,22 @@ impl Db { .execute(&self.pool) .await } + + pub async fn create_post(&self, title: &str, slug: &str, author: &str, body: &str) -> Result { + let row = sqlx::query("INSERT INTO posts (title, slug, author, body) VALUES (?, ?, ?, ?)") + .bind(title) + .bind(slug) + .bind(author) + .bind(body) + .execute(&self.pool) + .await?; + Ok(row.last_insert_rowid()) + } + pub async fn lookup_post_by_slug(&self, slug: &str) -> Result> { + let row = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE slug = ?") + .bind(slug) + .fetch_optional(&self.pool) + .await?; + Ok(row) + } } diff --git a/src/utils/email.rs b/src/utils/email.rs index a47428e6..b7120883 100644 --- a/src/utils/email.rs +++ b/src/utils/email.rs @@ -6,17 +6,23 @@ use lettre::{ use crate::EmailConfig; +/// Email client. #[derive(Clone)] pub struct Email { - addr: String, + /// Mailbox to send email from. from: Mailbox, + /// Underlying SMTPS transport. + transport: SmtpTransport, } impl Email { pub async fn connect(config: EmailConfig) -> Result { - // we need this for smtps + // `lettre` requires a default provider to be installed to use SMTPS. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - Ok(Self { addr: config.smtp_addr, from: config.from }) + + let transport = SmtpTransport::from_url(&config.smtp_addr)?.build(); + + Ok(Self { transport, from: config.from }) } pub fn builder(&self) -> MessageBuilder { @@ -24,8 +30,7 @@ impl Email { } pub async fn send(&self, message: Message) -> Result<()> { - let transport = SmtpTransport::from_url(&self.addr)?.build(); - transport.send(&message)?; + self.transport.send(&message)?; Ok(()) } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1badade9..47db58b1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,6 @@ pub mod config; pub mod db; pub mod email; +pub mod tera; +pub mod tracing; +pub mod types; diff --git a/src/utils/tera.rs b/src/utils/tera.rs new file mode 100644 index 00000000..3b61a3bd --- /dev/null +++ b/src/utils/tera.rs @@ -0,0 +1,47 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Local}; +use std::collections::HashMap; +use tera::{Tera, Value}; + +/// Initialize the [`Tera`] template engine, including our custom filter functions. +pub fn templates() -> Result { + let mut tera = Tera::new("templates/*")?; + register_filter(&mut tera, "format_datetime", format_datetime); + Ok(tera) +} + +/// Format a datetime with a [`strftime`] format string. +/// +/// Usage: `{{ date | format_datetime(format="%m.%d.%Y") }}` +/// +/// [`strftime`]: https://devhints.io/strftime +fn format_datetime(date: &Value, args: &HashMap) -> Result { + let format = args.get("format").context("missing arg=`format`")?; + let format = format.as_str().context("arg=`format` must be a string")?; + + let date: &str = date.as_str().with_context(|| format!("value={date:?} must be a string"))?; + let date: DateTime = date.parse().context("parsing date")?; + + let formatted = date.format(format).to_string(); + Ok(Value::String(formatted)) +} + +/// Register a tera filter function. +/// +/// On top of the regular `register_filter`, this function adds the filter name +/// as context to any errors, and handles conversion from `anyhow::Error` to +/// `tera::Error`. +fn register_filter(tera: &mut Tera, name: &str, func: F) +where + F: Fn(&Value, &HashMap) -> Result + Send + Sync + 'static, +{ + let name_ = name.to_string(); + tera.register_filter( + name, + move |value: &Value, args: &HashMap| -> tera::Result { + func(value, args) + .with_context(|| format!("{}()", &name_)) + .map_err(|err| tera::Error::msg(err.to_string())) + }, + ); +} diff --git a/src/utils/tracing.rs b/src/utils/tracing.rs new file mode 100644 index 00000000..2ee57ab0 --- /dev/null +++ b/src/utils/tracing.rs @@ -0,0 +1,29 @@ +use std::time::Duration; + +use axum::{extract::MatchedPath, http::Request, response::Response}; +use tower_http::trace::TraceLayer; +use tracing::Span; + +use crate::utils::types::AppRouter; + +/// Register debug tracing middleware. +pub fn register(router: AppRouter) -> AppRouter { + // Add a middleware that logs all incoming requests and responses, including latency and status. + router.layer( + TraceLayer::new_for_http() + // Start a `tracing::span` for each request. + .make_span_with(|req: &Request<_>| { + let path = match req.extensions().get::() { + Some(path) => path.as_str(), + None => req.uri().path(), + }; + // Fields populated later must be initialized as `tracing::field::Empty`. + tracing::info_span!("request", method = ?req.method(), path, status = tracing::field::Empty) + }) + // Add some extra fields once the response is generated. + .on_response(|res: &Response, latency: Duration, span: &Span| { + span.record("status", res.status().as_u16()); + tracing::info!("handled in {latency:?}"); + }), + ) +} diff --git a/src/utils/types.rs b/src/utils/types.rs new file mode 100644 index 00000000..9f88db85 --- /dev/null +++ b/src/utils/types.rs @@ -0,0 +1,34 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use std::sync::Arc; + +/// The global shared application state. +pub use crate::app::AppState; +pub type SharedAppState = Arc; + +/// The global router type, with our shared application state. +pub type AppRouter = axum::Router; + +/// App-wide result type which automatically handles conversion to an HTTP response. +pub struct AppError(anyhow::Error); +pub type AppResult = Result; + +/// Convert an [`AppError`] into an HTTP response. +/// +/// This allows us to return `AppResult from `axum::Handler` functions, and +/// tells the framework how to deal with errors. +impl IntoResponse for AppError { + fn into_response(self) -> Response { + // TODO: add a `dev` mode to `config.app`, and: + // * when enabled, respond with a stack trace + // * when disabled, respond with a generic error message that doesn't leak any details + (StatusCode::INTERNAL_SERVER_ERROR, format!("Error: {}", self.0)).into_response() + } +} +/// Allow converting anything that can be converted to an `anyhow::Result` +/// into an `AppResult` with the `?` operator. +impl> From for AppError { + fn from(e: E) -> Self { + Self(e.into()) + } +} diff --git a/templates/event-list.tera.html b/templates/event-list.tera.html index a1982e50..639f543a 100644 --- a/templates/event-list.tera.html +++ b/templates/event-list.tera.html @@ -28,8 +28,8 @@

Upcoming Events:

{% for event in events %} {% endfor %} - \ No newline at end of file + diff --git a/templates/post-create.tera.html b/templates/post-create.tera.html new file mode 100644 index 00000000..ce752a68 --- /dev/null +++ b/templates/post-create.tera.html @@ -0,0 +1,42 @@ + + + + + + + WLSD + + + +
+

Let's Create a Post

+
+ + + + + + + + + + + + + +
+
+ + diff --git a/templates/post.tera.html b/templates/post.tera.html new file mode 100644 index 00000000..d68d67b1 --- /dev/null +++ b/templates/post.tera.html @@ -0,0 +1,29 @@ + + + + + + {{ post.title }} + + + +

{{ post.title }}

+

By: {{ post.author }}

+

Date: {{ post.created_at }}

+

Updated: {{ post.updated_at }}

+ +
{{ post.body | safe }}
+ + From ae8506cde1f277a4860fac66c80d7a9ea875579c Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 00:06:46 -0500 Subject: [PATCH 05/89] markdown post editor --- assets/markdown.js | 55 +++++++++++ src/app/mod.rs | 8 +- src/app/posts.rs | 74 +++++++++++--- src/utils/db.rs | 48 ++++++--- templates/page.tera.html | 167 ++++++++++++++++++++++++++++++++ templates/post-create.tera.html | 42 -------- templates/post-edit.tera.html | 160 ++++++++++++++++++++++++++++++ templates/post.tera.html | 42 +++----- templates/temp.html | 35 +++++++ templates/wlsd.tera copy.html | 23 ----- 10 files changed, 534 insertions(+), 120 deletions(-) create mode 100644 assets/markdown.js create mode 100644 templates/page.tera.html delete mode 100644 templates/post-create.tera.html create mode 100644 templates/post-edit.tera.html create mode 100644 templates/temp.html delete mode 100644 templates/wlsd.tera copy.html diff --git a/assets/markdown.js b/assets/markdown.js new file mode 100644 index 00000000..914c6190 --- /dev/null +++ b/assets/markdown.js @@ -0,0 +1,55 @@ +function markdownToHtml(md) { + // Step 1: Apply markdown formatting rules + let html = md + // Block quotes: > text + .replace(/^(?:> ?.*(?:\r?\n|$))+$/gm, (quote) => { + const text = quote.replace(/^> ?/gm, ""); + return `
${text.trim()}
`; + }) + // Unordered list: * text + .replace(/^(?:\* .*(?:\r?\n|$))+$/gm, (block) => { + // Split the block into individual lines + const lines = block.trim().split(/\r?\n/); + let listItems = ""; + for (const line of lines) { + if (line.startsWith("* ")) { + // Remove the leading '* ' and wrap in
  • + const itemText = line.replace(/^\* +/, "").trim(); + if (itemText) { + listItems += `
  • ${itemText}
  • `; + } + } + } + return `
      ${listItems}
    `; + }) + // Bold: **text** + .replace(/\*\*(.+?)\*\*/g, "$1") + // Italic: *text* + .replace(/\*(.+?)\*/g, "$1") + // Headings: #, ##, ###, etc. starting at h2 + .replace(/^# (.*)$/gm, "

    $1

    ") + .replace(/^## (.*)$/gm, "

    $1

    ") + .replace(/^### (.*)$/gm, "

    $1

    ") + .replace(/^#### (.*)$/gm, "
    $1
    ") + .replace(/^##### (.*)$/gm, "
    $1
    ") + // Images: ![alt](url) + .replace(/\!\[(.+)\]\((.*)\)/g, '$1') + // Links: [text](url) + .replace(/\[(.+)\]\((.*)\)/g, '$1'); + + // Step 2: Split the text into paragraphs and wrap in

    + const paragraphs = html.trim().split(/\n\s*\n/); + return paragraphs + .map((p) => p.trim()) + .map((p) => { + // If it's already a block-level element, ignore + if (/^(, then wrap in

    + const withBreaks = p.replace(/\n/g, "
    "); + return `

    ${withBreaks}

    `; + }) + .join("\n"); +} diff --git a/src/app/mod.rs b/src/app/mod.rs index be6b702a..b075b736 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use axum::Router; +use axum::{response::Redirect, routing::get, Router}; use std::sync::Arc; use tera::Tera; use tower_http::services::ServeDir; @@ -34,7 +34,11 @@ pub async fn build(config: Config) -> Result { let r = posts::register_routes(r); let r = events::register_routes(r); - let r = r.nest_service("/assets", ServeDir::new("assets")); + let r = r + .nest_service("/assets", ServeDir::new("assets")) + // For non-HTML pages without a , this is where the browser looks + .route("/favicon.ico", get(|| async { Redirect::to("/assets/favicon.ico") })); + let r = utils::tracing::register(r); let r = r.with_state(Arc::new(state)); diff --git a/src/app/posts.rs b/src/app/posts.rs index 54f63178..dd2f41c2 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -5,14 +5,19 @@ use axum::{ routing::get, Form, }; +use chrono::Local; -use crate::utils::types::{AppResult, AppRouter, SharedAppState}; +use crate::utils::{ + db::Post, + types::{AppResult, AppRouter, SharedAppState}, +}; /// Add all `post` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { router - .route("/p/new", get(create_post_page).post(create_post_form)) + .route("/p/new", get(create_post_page)) .route("/p/:post", get(view_post_page)) + .route("/p/:post/edit", get(edit_post_page).post(edit_post_form)) } /// Display a single post. @@ -20,7 +25,7 @@ async fn view_post_page( State(state): State, Path(post): Path, ) -> AppResult { - let Some(post) = state.db.lookup_post_by_slug(&post).await? else { + let Some(post) = state.db.lookup_post_by_url(&post).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; @@ -33,23 +38,66 @@ async fn view_post_page( /// Display the form to create a new post. async fn create_post_page(State(state): State) -> AppResult { - let ctx = tera::Context::new(); - let html = state.templates.render("post-create.tera.html", &ctx).unwrap(); + let mut ctx = tera::Context::new(); + ctx.insert( + "post", + &Post { + id: 0, + title: "".into(), + url: "".into(), + author: "".into(), + content: "".into(), + created_at: Local::now(), + updated_at: Local::now(), + }, + ); + + let html = state.templates.render("post-edit.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Display the form to create a new post. +async fn edit_post_page( + State(state): State, + Path(post): Path, +) -> AppResult { + let Some(post) = state.db.lookup_post_by_url(&post).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let html = state.templates.render("post-edit.tera.html", &ctx).unwrap(); Ok(Html(html).into_response()) } -/// Process the form and create a new post. -async fn create_post_form( +/// Process the form and create or edit a post. +async fn edit_post_form( State(state): State, - Form(form): Form, + Form(form): Form, ) -> AppResult { - let _event_id = state.db.create_post(&form.title, &form.slug, &form.author, &form.body).await?; - Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, form.slug))) + match form.id { + None => { + state + .db + .create_post(&form.title, &form.url, &form.author, &form.content) + .await?; + } + Some(id) => { + state + .db + .update_post(&id, &form.title, &form.url, &form.author, &form.content) + .await?; + } + } + Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, &form.url))) } #[derive(serde::Deserialize)] -struct CreatePost { +struct EditPost { + id: Option, title: String, - slug: String, + url: String, author: String, - body: String, + content: String, } diff --git a/src/utils/db.rs b/src/utils/db.rs index 8f4695cb..95301a03 100644 --- a/src/utils/db.rs +++ b/src/utils/db.rs @@ -40,9 +40,9 @@ pub struct Event { pub struct Post { pub id: i64, pub title: String, - pub slug: String, + pub url: String, pub author: String, - pub body: String, + pub content: String, pub created_at: DateTime, pub updated_at: DateTime, } @@ -114,9 +114,9 @@ impl Db { "CREATE TABLE IF NOT EXISTS posts ( \ id INTEGER PRIMARY KEY NOT NULL, \ title TEXT NOT NULL, \ - slug TEXT NOT NULL, \ + url TEXT NOT NULL, \ author TEXT NOT NULL, \ - body TEXT NOT NULL, \ + content TEXT NOT NULL, \ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ )", @@ -272,21 +272,47 @@ impl Db { .await } - pub async fn create_post(&self, title: &str, slug: &str, author: &str, body: &str) -> Result { - let row = sqlx::query("INSERT INTO posts (title, slug, author, body) VALUES (?, ?, ?, ?)") + pub async fn create_post(&self, title: &str, url: &str, author: &str, content: &str) -> Result { + let row = sqlx::query("INSERT INTO posts (title, url, author, content) VALUES (?, ?, ?, ?)") .bind(title) - .bind(slug) + .bind(url) .bind(author) - .bind(body) + .bind(content) .execute(&self.pool) .await?; Ok(row.last_insert_rowid()) } - pub async fn lookup_post_by_slug(&self, slug: &str) -> Result> { - let row = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE slug = ?") - .bind(slug) + pub async fn lookup_post_by_url(&self, url: &str) -> Result> { + let row = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE url = ?") + .bind(url) .fetch_optional(&self.pool) .await?; Ok(row) } + + pub async fn update_post( + &self, + id: &str, + title: &str, + url: &str, + author: &str, + content: &str, + ) -> Result { + sqlx::query( + "UPDATE posts + SET title = ?, + url = ?, + author = ?, + content = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + ) + .bind(title) + .bind(url) + .bind(author) + .bind(content) + .bind(id) + .execute(&self.pool) + .await + } } diff --git a/templates/page.tera.html b/templates/page.tera.html new file mode 100644 index 00000000..e6d6c7aa --- /dev/null +++ b/templates/page.tera.html @@ -0,0 +1,167 @@ +{% macro start(title) %} + + + + + + {{ title }} + + + +
    + +
    +
    +{% endmacro start %} + +{% macro end() %} +
    +
    +
    + + +{% endmacro end %} diff --git a/templates/post-create.tera.html b/templates/post-create.tera.html deleted file mode 100644 index ce752a68..00000000 --- a/templates/post-create.tera.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - WLSD - - - -
    -

    Let's Create a Post

    -
    - - - - - - - - - - - - - -
    -
    - - diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html new file mode 100644 index 00000000..7de46e37 --- /dev/null +++ b/templates/post-edit.tera.html @@ -0,0 +1,160 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Edit post - " ~ post.title) }} + + +
    +
    + {% if post.id != 0 %} + + {% endif %} +
    + + +
    +
    + +
    + https://lightandsound.design/p/ + +
    +
    +
    + + +
    +
    + + +
    +
    + + + + +
    +
    +
    +
    +

    {{ post.title }}

    + +
    +
    + + +{{ page::end() }} diff --git a/templates/post.tera.html b/templates/post.tera.html index d68d67b1..f59e194f 100644 --- a/templates/post.tera.html +++ b/templates/post.tera.html @@ -1,29 +1,13 @@ - - - - - - {{ post.title }} - - - -

    {{ post.title }}

    -

    By: {{ post.author }}

    -

    Date: {{ post.created_at }}

    -

    Updated: {{ post.updated_at }}

    - -
    {{ post.body | safe }}
    - - +{% import "page.tera.html" as page %} +{{ page::start(title=post.title) }} +
    +

    {{ post.title }}

    + +
    + + +{{ page::end() }} diff --git a/templates/temp.html b/templates/temp.html new file mode 100644 index 00000000..fcf76ea3 --- /dev/null +++ b/templates/temp.html @@ -0,0 +1,35 @@ +Hello! + +I was so excited to get Zoë’s first newsletter out earlier this week that we forgot to list a few things, and have since announced another Dleepover! Hope you don’t mind getting two emails from us this week. I do my best to be mindful of your attention spans.... + +![poster](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcaeb3c03-0c33-4b2d-ba98-13e00050bdbc_2232x2790.jpeg) + +[12.02.2024 Deep Creep and Ando will Present Sounds. Food by Live Canteen.](https://www.eventcreate.com/e/deepcreepandops) + +Sasha (aka Deep Creep) and Andrew (aka Ando) have both presented sounds for you all before- excited to have them both back in the booth this week for some sonic explorations. Eli (aka Live Canteen) is continuing to raise the bar with her culinary excellence. Check out this week’s menu: + +> minestrone soup, parsley/pine nut/meyer lemon pesto, Cacio e Pepe sourdough + +![poster2](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe378ddde-4c73-44fc-ba41-db5a354922a8_2160x2700.jpeg) + +[12.14.2024 Pique-nique Presents: Persian Empire, live. Food by Live Canteen.](https://dice.fm/partner/dice/event/av2vvv-pique-nique-presents-14th-dec-tba-location-new-york-new-york-tickets) + +When Sam (aka Loum) or Jared hit us up about doing anything, we do our best to make space for them in the calendar. To say that I trust their curitorial vision is an understatement. The Pique-nique approach to presenting music is woven into the fabric of the Light and Sound Design studio. This Saturday, Sam is hosting German producer Persian Empire for a live hardware set. This will be his first ever show in the US, and judging by the amount of messages I’ve gotten since we’ve announced I expect this one will a full house, and a memorable one at that. Tickets are limited, and going quickly. Come hungry, Eli is cooking again: + +> mulligatawny soup, potato and cheese borekas, Korean style carrots, tahina and mango lime pickle + +![poster3](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58ed2eb4-cf66-447c-8112-21917f7a79d9_3348x4329.jpeg) + +[12.20.2024 Solstice Dsleepover](https://www.eventcreate.com/e/dsleepover1221) + +On the other end of the energetic spectrum is the next edition of our Dsleepover series to celebrate the longest night of the year. This one is a gentle collaboration with Dan and Serena of Testu Collective. They will be providing visuals and sonics from the night along with myself, Vin (aka fieldtalk), Annie (aka UCC Harlo), MA, and Dominka Mazurová. If you’ve yet to take part in one of these, the idea is simple. We make sound for you to sleep to. Come and go as you please. Mattresses, toothbrushes and blankets are available, but you are encouraged to bring your own. The studio can be a bit drafty this time of year. There will be food too. More on that later… + +Something Nice to Listen to: + +[ambient flo](https://www.ambientflo.com/) + +Hope to see you soon. + +Love, + +KG diff --git a/templates/wlsd.tera copy.html b/templates/wlsd.tera copy.html deleted file mode 100644 index b5fa688b..00000000 --- a/templates/wlsd.tera copy.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - WLSD - - - -
    -

    {{ message }}

    -
    - - - From c6008bd725f18400337da66aae7ea5b025436e40 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 11:17:14 -0500 Subject: [PATCH 06/89] fix not logging full url --- Cargo.toml | 2 +- src/utils/tracing.rs | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 63547454..1664a2ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] -axum = { version = "0.7", default-features = false, features = ["query", "form", "matched-path"] } +axum = { version = "0.7", default-features = false, features = ["query", "form"] } axum-server = { version = "0.7", features = ["tls-rustls"] } axum-extra = { version = "0.9", features = ["cookie"] } tower-http = { version = "0.6", features = ["fs", "trace"] } diff --git a/src/utils/tracing.rs b/src/utils/tracing.rs index 2ee57ab0..62e5cafb 100644 --- a/src/utils/tracing.rs +++ b/src/utils/tracing.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use axum::{extract::MatchedPath, http::Request, response::Response}; +use axum::{http::Request, response::Response}; use tower_http::trace::TraceLayer; use tracing::Span; @@ -13,12 +13,13 @@ pub fn register(router: AppRouter) -> AppRouter { TraceLayer::new_for_http() // Start a `tracing::span` for each request. .make_span_with(|req: &Request<_>| { - let path = match req.extensions().get::() { - Some(path) => path.as_str(), - None => req.uri().path(), - }; // Fields populated later must be initialized as `tracing::field::Empty`. - tracing::info_span!("request", method = ?req.method(), path, status = tracing::field::Empty) + tracing::info_span!( + "request", + method = ?req.method(), + path = req.uri().path(), + status = tracing::field::Empty + ) }) // Add some extra fields once the response is generated. .on_response(|res: &Response, latency: Duration, span: &Span| { From e626e599bec978665e891f32d290694d1457b830 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 12:50:40 -0500 Subject: [PATCH 07/89] refactor data layer --- src/app/auth.rs | 91 +++++++------ src/app/events.rs | 73 +++++------ src/app/home.rs | 3 +- src/app/mod.rs | 5 +- src/app/posts.rs | 42 ++---- src/db/event.rs | 105 +++++++++++++++ src/db/mod.rs | 27 ++++ src/db/post.rs | 85 +++++++++++++ src/db/token.rs | 93 ++++++++++++++ src/db/user.rs | 88 +++++++++++++ src/main.rs | 1 + src/utils/db.rs | 318 ---------------------------------------------- src/utils/mod.rs | 1 - 13 files changed, 497 insertions(+), 435 deletions(-) create mode 100644 src/db/event.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/post.rs create mode 100644 src/db/token.rs create mode 100644 src/db/user.rs delete mode 100644 src/utils/db.rs diff --git a/src/app/auth.rs b/src/app/auth.rs index d84d3272..b8099f74 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -5,62 +5,46 @@ //! //! # High-level flow //! -//! 1. **Email input**: User enters their email and submits the login form. -//! 2. **Token generated**: Server creates a short-lived login token and emails it to the user. +//! 1. **Email input**: User enters their email and submits a login form. +//! 2. **Token generated**: Server creates a short-lived link with a login token and emails it to the user. +//! - If the user is already registered, the link points to `/login?token=...`. +//! - If the user is not registered, the link points to `/register?token=...`. //! 3. **Link clicked**: User clicks the link, passing the token back to the server. -//! - **Login**: If the user is already registered, they get a new session cookie. -//! - **Registration**: Otherwise, they're prompted to enter their first/last name. -//! Upon submission, the user is registered and they get a new session cookie. +//! - `/login`: The user gets a new session cookie and is redirected home. +//! - `/register`: The user is prompted to enter their first/last name. +//! Upon submission, the user gets a new session cookie and is redirected home. use axum::{ extract::{Query, State}, http::{header, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, - routing::get, + routing::post, Form, }; use lettre::message::Mailbox; +use crate::db::token::{LoginToken, SessionToken}; +use crate::db::user::{UpdateUser, User}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; -/// Add all `auth` routes to the router. +/// Add all auth routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { router - .route("/login", get(login_page).post(login_form)) - .route("/register", get(register_page).post(register_form)) + .route("/login", post(login_form).get(login_link)) + .route("/register", post(register_form).get(register_link)) } -/// Display the login page. -async fn login_page( - State(state): State, - Query(login): Query, -) -> AppResult { - let Some(user) = state.db.lookup_user_by_login_token(&login.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - - let session_token = state.db.create_session_token(user.id).await?; - let headers = ( - // TODO: expiration date - [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), - ); - Ok(headers.into_response()) -} -#[derive(serde::Deserialize)] -struct LoginQuery { - token: String, -} - -/// Process the login form. +/// Process a login form and send either a login or registration link via email. async fn login_form( State(state): State, Form(form): Form, ) -> AppResult { - let login_token = state.db.create_login_token(&form.email).await?; + let email = form.email.email.to_string(); + + let login_token = LoginToken::create(&state.db, &email).await?; let url = &state.config.app.url; - let url = match state.db.lookup_user_by_email(&form.email).await? { + let url = match User::lookup_by_email(&state.db, &email).await? { Some(_) => format!("{url}/login?token={login_token}"), None => format!("{url}/register?token={login_token}"), }; @@ -75,13 +59,36 @@ struct LoginForm { email: Mailbox, } +/// Login from a link containing a token, creating a new sesssion. +async fn login_link( + State(state): State, + Query(query): Query, +) -> AppResult { + let Some(user) = User::lookup_by_login_token(&state.db, &query.token).await? else { + return Ok(StatusCode::FORBIDDEN.into_response()); + }; + + let token = SessionToken::create(&state.db, user.id).await?; + let headers = ( + // TODO: expiration date + [(header::SET_COOKIE, format!("session={token}; Secure; Secure"))], + Redirect::to(&state.config.app.url), + ); + Ok(headers.into_response()) +} +#[derive(serde::Deserialize)] +struct LoginQuery { + token: String, +} + /// Display the registration page. -async fn register_page( +async fn register_link( State(state): State, - Query(register): Query, + Query(query): Query, ) -> AppResult { let mut ctx = tera::Context::new(); - ctx.insert("token", ®ister.token); + ctx.insert("token", &query.token); + let html = state.templates.render("register.tera.html", &ctx).unwrap(); Ok(Html(html).into_response()) } @@ -95,14 +102,18 @@ async fn register_form( State(state): State, Form(form): Form, ) -> AppResult { - let Some(email) = state.db.lookup_email_by_login_token(&form.token).await? else { + let Some(email) = LoginToken::lookup_email(&state.db, &form.token).await? else { return Ok(StatusCode::FORBIDDEN.into_response()); }; - let user_id = state.db.create_user(&form.first_name, &form.last_name, &email).await?; - let session_token = state.db.create_session_token(user_id).await?; + let user_id = User::create( + &state.db, + &UpdateUser { first_name: form.first_name, last_name: form.last_name, email }, + ) + .await?; // TODO: Expiration date on the cookie + let session_token = SessionToken::create(&state.db, user_id).await?; let headers = ( [(header::SET_COOKIE, format!("session={session_token}; Secure"))], Redirect::to(&state.config.app.url), diff --git a/src/app/events.rs b/src/app/events.rs index 06614705..28d06c83 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -1,12 +1,13 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, - response::{Html, IntoResponse, Response}, + response::{Html, IntoResponse, Redirect, Response}, routing::get, Form, }; use chrono::Local; +use crate::db::event::{Event, UpdateEvent}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; /// Add all `events` routes to the router. @@ -24,9 +25,21 @@ pub fn register_routes(router: AppRouter) -> AppRouter { /// Display a list of all events. async fn list_events_page( State(state): State, - Query(param): Query, + Query(query): Query, ) -> AppResult { - let events = state.db.get_all_events(Local::now(), param.past.unwrap_or(false)).await?; + let now = Local::now(); + let past = query.past.unwrap_or(false); + + let events = Event::list(&state.db) + .await? + .into_iter() + .filter(|e| match past { + true => e.start_date < now, + // TODO: Don't filter out in-progress events until they're over. + // Need to add an `end_date` field. + false => e.start_date >= now, + }) + .collect::>(); let mut ctx = tera::Context::new(); ctx.insert("events", &events); @@ -35,7 +48,7 @@ async fn list_events_page( Ok(Html(html).into_response()) } #[derive(serde::Deserialize)] -struct ListEvents { +struct ListEventsQuery { past: Option, } @@ -49,31 +62,20 @@ async fn create_event_page(State(state): State) -> AppResult, - Form(form): Form, + Form(form): Form, ) -> AppResult { - let _event_id = state - .db - .create_event(&form.title, &form.artist, &form.description, &form.start_date) - .await?; + let _ = Event::create(&state.db, &form).await?; + // TODO: Redirect to event page. Ok("Event created.") } -#[derive(serde::Deserialize)] -struct CreateEvent { - title: String, - artist: String, - description: String, - start_date: String, -} /// Display the form to update an event. -async fn update_event_page( - State(state): State, - Path(event_id): Path, -) -> AppResult { - let mut ctx = tera::Context::new(); - let Some(event) = state.db.lookup_event_by_event_id(&event_id.parse().unwrap()).await? else { +async fn update_event_page(State(state): State, Path(id): Path) -> AppResult { + let Some(event) = Event::lookup_by_id(&state.db, id).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; + + let mut ctx = tera::Context::new(); ctx.insert("event", &event); let html = state.templates.render("event.tera.html", &ctx).unwrap(); @@ -83,34 +85,19 @@ async fn update_event_page( /// Process the form and update an event. async fn update_event_form( State(state): State, - Path(event_id): Path, + Path(id): Path, Form(form): Form, ) -> AppResult { - state - .db - .update_event( - event_id.parse().unwrap(), - &form.title, - &form.artist, - &form.description, - &form.start_date, - ) - .await?; + Event::update(&state.db, id, &form).await?; + // TODO: Redirect to event page. Ok("Event updated.") } -#[derive(serde::Deserialize)] -struct UpdateEvent { - title: String, - artist: String, - description: String, - start_date: String, -} /// Delete an event. async fn delete_event( State(state): State, - Path(event_id): Path, + Path(id): Path, ) -> AppResult { - state.db.delete_event(event_id.parse().unwrap()).await?; - Ok("Event deleted.") + Event::delete(&state.db, id).await?; + Ok(Redirect::to("/events")) } diff --git a/src/app/home.rs b/src/app/home.rs index fa0ac95d..a7c95034 100644 --- a/src/app/home.rs +++ b/src/app/home.rs @@ -6,6 +6,7 @@ use axum::{ }; use axum_extra::extract::CookieJar; +use crate::db::user::User; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; /// Add all `home` routes to the router. @@ -19,7 +20,7 @@ async fn home_page(State(state): State, cookies: CookieJar) -> A ctx.insert("message", "Hello, world!"); if let Some(session_token) = cookies.get("session") { - let Some(user) = state.db.lookup_user_from_session_token(session_token.value()).await? else { + let Some(user) = User::lookup_by_session_token(&state.db, session_token.value()).await? else { return Ok(StatusCode::FORBIDDEN.into_response()); }; ctx.insert("user", &user); diff --git a/src/app/mod.rs b/src/app/mod.rs index b075b736..02d6e137 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use tera::Tera; use tower_http::services::ServeDir; -use crate::utils::{self, config::*, db::Db, email::Email}; +use crate::db::Db; +use crate::utils::{self, config::*, email::Email}; mod auth; mod events; @@ -24,7 +25,7 @@ pub async fn build(config: Config) -> Result { let state = AppState { config: config.clone(), templates: utils::tera::templates()?, - db: Db::connect(&config.app.db).await?, + db: crate::db::init(&config.app.db).await?, mail: Email::connect(config.email).await?, }; diff --git a/src/app/posts.rs b/src/app/posts.rs index dd2f41c2..0ec5d1b1 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -7,10 +7,8 @@ use axum::{ }; use chrono::Local; -use crate::utils::{ - db::Post, - types::{AppResult, AppRouter, SharedAppState}, -}; +use crate::db::post::{Post, UpdatePost}; +use crate::utils::types::{AppResult, AppRouter, SharedAppState}; /// Add all `post` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { @@ -21,11 +19,8 @@ pub fn register_routes(router: AppRouter) -> AppRouter { } /// Display a single post. -async fn view_post_page( - State(state): State, - Path(post): Path, -) -> AppResult { - let Some(post) = state.db.lookup_post_by_url(&post).await? else { +async fn view_post_page(State(state): State, Path(url): Path) -> AppResult { + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; @@ -57,11 +52,8 @@ async fn create_post_page(State(state): State) -> AppResult, - Path(post): Path, -) -> AppResult { - let Some(post) = state.db.lookup_post_by_url(&post).await? else { +async fn edit_post_page(State(state): State, Path(url): Path) -> AppResult { + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; @@ -78,26 +70,16 @@ async fn edit_post_form( Form(form): Form, ) -> AppResult { match form.id { + Some(id) => Post::update(&state.db, id, &form.post).await?, None => { - state - .db - .create_post(&form.title, &form.url, &form.author, &form.content) - .await?; - } - Some(id) => { - state - .db - .update_post(&id, &form.title, &form.url, &form.author, &form.content) - .await?; + Post::create(&state.db, &form.post).await?; } } - Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, &form.url))) + Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, &form.post.url))) } #[derive(serde::Deserialize)] struct EditPost { - id: Option, - title: String, - url: String, - author: String, - content: String, + id: Option, + #[serde(flatten)] + post: UpdatePost, } diff --git a/src/db/event.rs b/src/db/event.rs new file mode 100644 index 00000000..0cf91842 --- /dev/null +++ b/src/db/event.rs @@ -0,0 +1,105 @@ +use anyhow::Result; +use chrono::{DateTime, Local}; + +use super::Db; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Event { + pub id: i64, + // TODO: Add a pretty url field, for `https://site/e/:url`. + // pub url: String, + pub title: String, + pub artist: String, + pub description: String, + pub start_date: DateTime, + // TODO: Add an end. Maybe rename to just `start` and `end`. + // pub end_date: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(serde::Deserialize)] +pub struct UpdateEvent { + pub title: String, + pub artist: String, + pub description: String, + pub start_date: DateTime, +} + +impl Event { + /// Create the `events` table. + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS events ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + title TEXT NOT NULL, \ + artist TEXT NOT NULL, \ + description TEXT NOT NULL, \ + start_date TIMESTAMP NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ + )", + ) + .execute(db) + .await?; + Ok(()) + } + + // List all events. + pub async fn list(db: &Db) -> Result> { + let events = sqlx::query_as::<_, Event>("SELECT * FROM events").fetch_all(db).await?; + Ok(events) + } + + // Create a new event. + pub async fn create(db: &Db, event: &UpdateEvent) -> Result { + let row = sqlx::query( + "INSERT INTO events \ + (title, artist, description, start_date) \ + VALUES (?, ?, ?, ?)", + ) + .bind(&event.title) + .bind(&event.artist) + .bind(&event.description) + .bind(event.start_date) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + // Update an event. + pub async fn update(db: &Db, id: i64, event: &UpdateEvent) -> Result<()> { + sqlx::query( + "UPDATE events \ + SET title = ?, artist = ?, description = ?, start_date = ? \ + WHERE id = ?", + ) + .bind(&event.title) + .bind(&event.artist) + .bind(&event.description) + .bind(event.start_date) + .bind(id) + .execute(db) + .await?; + Ok(()) + } + + // Delete an event. + pub async fn delete(db: &Db, id: i64) -> Result<()> { + sqlx::query("DELETE FROM events WHERE id = ?").bind(id).execute(db).await?; + Ok(()) + } + + // Lookup an event by id, if one exists. + pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + let event = sqlx::query_as::<_, Event>( + "SELECT e.* \ + FROM events e \ + WHERE id = ?", + ) + .bind(id) + .fetch_optional(db) + .await?; + Ok(event) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 00000000..8b98c56a --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool}; +use std::path::Path; + +pub type Db = SqlitePool; + +pub mod event; +pub mod post; +pub mod token; +pub mod user; + +/// Create a new db connection pool, initializing and running migrations if necessary. +pub async fn init(file: &Path) -> Result { + let url = format!("sqlite://{}", file.display()); + if !Sqlite::database_exists(&url).await? { + Sqlite::create_database(&url).await?; + } + let db = SqlitePool::connect(&url).await?; + + user::User::migrate(&db).await?; + token::SessionToken::migrate(&db).await?; + token::LoginToken::migrate(&db).await?; + post::Post::migrate(&db).await?; + event::Event::migrate(&db).await?; + + Ok(db) +} diff --git a/src/db/post.rs b/src/db/post.rs new file mode 100644 index 00000000..8d1ad74b --- /dev/null +++ b/src/db/post.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use chrono::{DateTime, Local}; + +use super::Db; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Post { + pub id: i64, + pub title: String, + pub url: String, + pub author: String, + pub content: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(serde::Deserialize)] +pub struct UpdatePost { + pub title: String, + pub url: String, + pub author: String, + pub content: String, +} + +impl Post { + /// Create the `posts` table. + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS posts ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + title TEXT NOT NULL, \ + url TEXT NOT NULL, \ + author TEXT NOT NULL, \ + content TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ + )", + ) + .execute(db) + .await?; + Ok(()) + } + + /// Create a new post. + pub async fn create(db: &Db, post: &UpdatePost) -> Result { + let row = sqlx::query("INSERT INTO posts (title, url, author, content) VALUES (?, ?, ?, ?)") + .bind(&post.title) + .bind(&post.url) + .bind(&post.author) + .bind(&post.content) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + /// Update an existing post. + pub async fn update(db: &Db, id: i64, post: &UpdatePost) -> Result<()> { + sqlx::query( + "UPDATE posts + SET title = ?, + url = ?, + author = ?, + content = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + ) + .bind(&post.title) + .bind(&post.url) + .bind(&post.author) + .bind(&post.content) + .bind(id) + .execute(db) + .await?; + Ok(()) + } + + /// Lookup a post by URL, if one exists. + pub async fn lookup_by_url(db: &Db, url: &str) -> Result> { + let row = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE url = ?") + .bind(url) + .fetch_optional(db) + .await?; + Ok(row) + } +} diff --git a/src/db/token.rs b/src/db/token.rs new file mode 100644 index 00000000..03a5b653 --- /dev/null +++ b/src/db/token.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use chrono::{DateTime, Local}; +use rand::{rngs::OsRng, Rng}; + +use super::Db; + +/// A token which can be used to authenticate as a user. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct SessionToken { + pub id: i64, + pub user_id: i64, + pub token: String, + pub created_at: DateTime, +} + +/// A token which can be used to login or register. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct LoginToken { + pub id: i64, + pub email: String, + pub token: String, + pub created_at: DateTime, +} + +impl SessionToken { + /// Create the `session_tokens` table. + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS session_tokens ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + user_id INTEGER NOT NULL, \ + token TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + FOREIGN KEY (user_id) REFERENCES users(id) \ + )", + ) + .execute(db) + .await?; + Ok(()) + } + + /// Create a new session token for a user. + pub async fn create(db: &Db, user_id: i64) -> Result { + let token = format!("{:08x}", OsRng.gen::()); + + sqlx::query("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)") + .bind(user_id) + .bind(&token) + .execute(db) + .await?; + + Ok(token) + } +} + +impl LoginToken { + /// Create the `login_tokens` table. + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS login_tokens ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + email TEXT NOT NULL, \ + token TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ + )", + ) + .execute(db) + .await?; + Ok(()) + } + + /// Create a new login token for an email address. + pub async fn create(db: &Db, email: &str) -> Result { + let token = format!("{:08x}", OsRng.gen::()); + + sqlx::query("INSERT INTO login_tokens (email, token) VALUES (?, ?)") + .bind(email) + .bind(&token) + .execute(db) + .await?; + + Ok(token) + } + + /// Lookup the email address for the given login token, if it's valid. + pub async fn lookup_email(db: &Db, token: &str) -> Result> { + let row = sqlx::query_as::<_, (String,)>("SELECT email FROM login_tokens WHERE token = ?") + .bind(token) + .fetch_optional(db) + .await?; + Ok(row.map(|r| r.0)) + } +} diff --git a/src/db/user.rs b/src/db/user.rs new file mode 100644 index 00000000..bdfb8470 --- /dev/null +++ b/src/db/user.rs @@ -0,0 +1,88 @@ +use anyhow::Result; +use chrono::{DateTime, Local}; + +use super::Db; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct User { + pub id: i64, + pub first_name: String, + pub last_name: String, + pub email: String, + pub created_at: DateTime, +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpdateUser { + pub first_name: String, + pub last_name: String, + pub email: String, +} + +impl User { + /// Create the `users` table. + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + first_name TEXT NOT NULL, \ + last_name TEXT NOT NULL, \ + email TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ + )", + ) + .execute(db) + .await?; + Ok(()) + } + + /// Create a new user. + pub async fn create(db: &Db, user: &UpdateUser) -> Result { + let row = sqlx::query( + "INSERT INTO users \ + (first_name, last_name, email) \ + VALUES (?, ?, ?)", + ) + .bind(&user.first_name) + .bind(&user.last_name) + .bind(&user.email) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + /// Lookup a user by email address, if one exists. + pub async fn lookup_by_email(db: &Db, email: &str) -> Result> { + let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?") + .bind(email) + .fetch_optional(db) + .await?; + Ok(row) + } + /// Lookup a user by a login token, if it's valid. + pub async fn lookup_by_login_token(db: &Db, token: &str) -> Result> { + let row = sqlx::query_as::<_, User>( + "SELECT u.* \ + FROM login_tokens t \ + LEFT JOIN users u on u.email = t.email \ + WHERE t.token = ?", + ) + .bind(token) + .fetch_optional(db) + .await?; + Ok(row) + } + /// Lookup a user by a session token, if it's valid. + pub async fn lookup_by_session_token(db: &Db, token: &str) -> Result> { + let user = sqlx::query_as::<_, User>( + "SELECT u.* \ + FROM session_tokens t \ + JOIN users u on u.id = t.user_id \ + WHERE token = ?", + ) + .bind(token) + .fetch_optional(db) + .await?; + Ok(user) + } +} diff --git a/src/main.rs b/src/main.rs index 1eb1575c..675fe839 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; mod app; +mod db; mod utils; use axum::{handler::HandlerWithoutStateExt, response::Redirect}; diff --git a/src/utils/db.rs b/src/utils/db.rs deleted file mode 100644 index 95301a03..00000000 --- a/src/utils/db.rs +++ /dev/null @@ -1,318 +0,0 @@ -use std::path::Path; - -use anyhow::Result; -use chrono::{DateTime, Local}; -use lettre::message::Mailbox; -use rand::{rngs::OsRng, Rng as _}; -use sqlx::{migrate::MigrateDatabase, sqlite::SqliteQueryResult, Error, Sqlite, SqlitePool}; - -// +--------------------------------------------------------------------------------+ -// | TODO: Separate the individual types into a `models/` module to reduce clutter. | -// +--------------------------------------------------------------------------------+ - -/// Database client. -#[derive(Clone)] -pub struct Db { - pool: SqlitePool, -} - -#[derive(Debug, sqlx::FromRow, serde::Serialize)] -pub struct User { - pub id: i64, - pub first_name: String, - pub last_name: String, - pub email: String, - pub created_at: String, -} - -#[derive(Debug, sqlx::FromRow, serde::Serialize)] -pub struct Event { - pub id: i64, - pub title: String, - pub artist: String, - pub description: String, - pub start_date: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, sqlx::FromRow, serde::Serialize)] -pub struct Post { - pub id: i64, - pub title: String, - pub url: String, - pub author: String, - pub content: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl Db { - pub async fn connect(file: &Path) -> Result { - let url = format!("sqlite://{}", file.display()); - if !Sqlite::database_exists(&url).await? { - Sqlite::create_database(&url).await?; - } - let pool = SqlitePool::connect(&url).await?; - - let db = Self { pool }; - db.migrate().await?; - Ok(db) - } - - async fn migrate(&self) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - first_name TEXT NOT NULL, \ - last_name TEXT NOT NULL, \ - email TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS login_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - email TEXT NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS session_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - user_id INTEGER NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - FOREIGN KEY (user_id) REFERENCES users(id) \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS events ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - title TEXT NOT NULL, \ - artist TEXT NOT NULL, \ - description TEXT NOT NULL, \ - start_date TIMESTAMP NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS posts ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - title TEXT NOT NULL, \ - url TEXT NOT NULL, \ - author TEXT NOT NULL, \ - content TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - Ok(()) - } - - pub async fn create_user(&self, first_name: &str, last_name: &str, email: &str) -> Result { - let row = sqlx::query("INSERT INTO users (first_name, last_name, email) VALUES (?, ?, ?)") - .bind(first_name) - .bind(last_name) - .bind(email) - .execute(&self.pool) - .await?; - Ok(row.last_insert_rowid()) - } - pub async fn lookup_user_by_email(&self, email: &Mailbox) -> Result> { - let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?") - .bind(email.email.to_string()) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - pub async fn lookup_user_by_login_token(&self, token: &str) -> Result> { - let row = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM login_tokens t \ - LEFT JOIN users u on u.email = t.email \ - WHERE t.token = ?", - ) - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - pub async fn lookup_user_from_session_token(&self, token: &str) -> Result> { - let user = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM session_tokens t \ - JOIN users u on u.id = t.user_id \ - WHERE token = ?", - ) - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn create_session_token(&self, user_id: i64) -> Result { - let token = format!("{:08x}", OsRng.gen::()); - - sqlx::query("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)") - .bind(user_id) - .bind(&token) - .execute(&self.pool) - .await?; - - Ok(token) - } - pub async fn create_login_token(&self, email: &Mailbox) -> Result { - let token = format!("{:08x}", OsRng.gen::()); - - sqlx::query("INSERT INTO login_tokens (email, token) VALUES (?, ?)") - .bind(email.email.to_string()) - .bind(&token) - .execute(&self.pool) - .await?; - - Ok(token) - } - pub async fn lookup_email_by_login_token(&self, token: &str) -> Result> { - let row = sqlx::query_as::<_, (String,)>("SELECT email FROM login_tokens WHERE token = ?") - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| r.0)) - } - // Lookup Event by id - pub async fn lookup_event_by_event_id(&self, id: &i64) -> Result> { - let event = sqlx::query_as::<_, Event>( - "SELECT e.* \ - FROM events e \ - WHERE id = ?", - ) - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(event) - } - // Get all Events - pub async fn get_all_events(&self, date: DateTime, past: bool) -> Result, Error> { - let events = if !past { - sqlx::query_as::<_, Event>("SELECT e.* FROM events e WHERE start_date >= ?") - .bind(date) - .fetch_all(&self.pool) - .await? - } else { - sqlx::query_as::<_, Event>("SELECT e.* FROM events e WHERE start_date < ?") - .bind(date) - .fetch_all(&self.pool) - .await? - }; - Ok(events) - } - // Create Event - pub async fn create_event( - &self, - title: &str, - artist: &str, - description: &str, - start_date: &str, - ) -> Result { - let row = - sqlx::query("INSERT INTO events (title, artist, description, start_date) VALUES (?, ?, ?, ?)") - .bind(title) - .bind(artist) - .bind(description) - .bind(start_date) - .execute(&self.pool) - .await?; - Ok(row.last_insert_rowid()) - } - // Update Event - pub async fn update_event( - &self, - id: i64, - title: &str, - artist: &str, - description: &str, - start_date: &str, - ) -> Result { - sqlx::query( - "UPDATE events - SET title = ?, artist = ?, description = ?, start_date = ? - WHERE id = ?", - ) - .bind(title) - .bind(artist) - .bind(description) - .bind(start_date.to_string()) - .bind(id) - .execute(&self.pool) - .await - } - // Remove Event - pub async fn delete_event(&self, id: i64) -> Result { - sqlx::query("DELETE FROM events WHERE id = ?") - .bind(id) - .execute(&self.pool) - .await - } - - pub async fn create_post(&self, title: &str, url: &str, author: &str, content: &str) -> Result { - let row = sqlx::query("INSERT INTO posts (title, url, author, content) VALUES (?, ?, ?, ?)") - .bind(title) - .bind(url) - .bind(author) - .bind(content) - .execute(&self.pool) - .await?; - Ok(row.last_insert_rowid()) - } - pub async fn lookup_post_by_url(&self, url: &str) -> Result> { - let row = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE url = ?") - .bind(url) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn update_post( - &self, - id: &str, - title: &str, - url: &str, - author: &str, - content: &str, - ) -> Result { - sqlx::query( - "UPDATE posts - SET title = ?, - url = ?, - author = ?, - content = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ?", - ) - .bind(title) - .bind(url) - .bind(author) - .bind(content) - .bind(id) - .execute(&self.pool) - .await - } -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 47db58b1..7fb29907 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,4 @@ pub mod config; -pub mod db; pub mod email; pub mod tera; pub mod tracing; From f8caac2fdb6f054abb52711268a4baebfb18c932 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 12:58:59 -0500 Subject: [PATCH 08/89] set server timezone to America/New_York --- scripts/bootstrap.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 0b50fdfd..a77ce5c6 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -8,6 +8,7 @@ fi ssh $1 <<'EOS' # update sudo yum update -y +sudo timedatectl set-timezone America/New_York # create a user if ! id lsd &>/dev/null; then From 92b5b44d7e7114bc25be688a095d804fc74c9cb1 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 13:30:18 -0500 Subject: [PATCH 09/89] use utc datetimes, add local tz config --- Cargo.toml | 1 + config/dev.toml | 5 ++++- config/prod.toml | 1 + src/app/events.rs | 4 ++-- src/app/mod.rs | 4 ++-- src/app/posts.rs | 6 +++--- src/db/event.rs | 12 ++++++------ src/db/post.rs | 6 +++--- src/db/token.rs | 6 +++--- src/db/user.rs | 4 ++-- src/utils/config.rs | 15 +++++++++++++-- src/utils/tera.rs | 43 ++++++++++++++++++++++++++----------------- 12 files changed, 66 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1664a2ed..046c4020 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ serde = { version = "1", features = ["derive"] } toml = "0.8" rand = "0.8" chrono = { version = "0.4", features = ["serde"] } +chrono-tz = { version = "0.10", features = ["serde"] } # Add a little optimization to debug builds [profile.dev] diff --git a/config/dev.toml b/config/dev.toml index a275b481..89647684 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -1,6 +1,9 @@ [app] url = "https://localhost:4433" -db = "db.sqlite" +tz = "America/New_York" + +[db] +file = "db.sqlite" [net] http_addr = "[::]:8080" diff --git a/config/prod.toml b/config/prod.toml index 9106d327..16930205 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -1,5 +1,6 @@ [app] url = "https://beta.lightandsound.design" +tz = "America/New_York" db = "db.sqlite" [net] diff --git a/src/app/events.rs b/src/app/events.rs index 28d06c83..df9cc9a3 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -5,7 +5,7 @@ use axum::{ routing::get, Form, }; -use chrono::Local; +use chrono::Utc; use crate::db::event::{Event, UpdateEvent}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; @@ -27,7 +27,7 @@ async fn list_events_page( State(state): State, Query(query): Query, ) -> AppResult { - let now = Local::now(); + let now = Utc::now(); let past = query.past.unwrap_or(false); let events = Event::list(&state.db) diff --git a/src/app/mod.rs b/src/app/mod.rs index 02d6e137..601f1d72 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -24,8 +24,8 @@ pub struct AppState { pub async fn build(config: Config) -> Result { let state = AppState { config: config.clone(), - templates: utils::tera::templates()?, - db: crate::db::init(&config.app.db).await?, + templates: utils::tera::templates(&config)?, + db: crate::db::init(&config.db.file).await?, mail: Email::connect(config.email).await?, }; diff --git a/src/app/posts.rs b/src/app/posts.rs index 0ec5d1b1..9ca6e151 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -5,7 +5,7 @@ use axum::{ routing::get, Form, }; -use chrono::Local; +use chrono::Utc; use crate::db::post::{Post, UpdatePost}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; @@ -42,8 +42,8 @@ async fn create_post_page(State(state): State) -> AppResult, + pub start_date: DateTime, // TODO: Add an end. Maybe rename to just `start` and `end`. - // pub end_date: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, + // pub end_date: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(serde::Deserialize)] @@ -23,7 +23,7 @@ pub struct UpdateEvent { pub title: String, pub artist: String, pub description: String, - pub start_date: DateTime, + pub start_date: DateTime, } impl Event { diff --git a/src/db/post.rs b/src/db/post.rs index 8d1ad74b..3b13e3ee 100644 --- a/src/db/post.rs +++ b/src/db/post.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Local}; +use chrono::{DateTime, Utc}; use super::Db; @@ -10,8 +10,8 @@ pub struct Post { pub url: String, pub author: String, pub content: String, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(serde::Deserialize)] diff --git a/src/db/token.rs b/src/db/token.rs index 03a5b653..d3005083 100644 --- a/src/db/token.rs +++ b/src/db/token.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Local}; +use chrono::{DateTime, Utc}; use rand::{rngs::OsRng, Rng}; use super::Db; @@ -10,7 +10,7 @@ pub struct SessionToken { pub id: i64, pub user_id: i64, pub token: String, - pub created_at: DateTime, + pub created_at: DateTime, } /// A token which can be used to login or register. @@ -19,7 +19,7 @@ pub struct LoginToken { pub id: i64, pub email: String, pub token: String, - pub created_at: DateTime, + pub created_at: DateTime, } impl SessionToken { diff --git a/src/db/user.rs b/src/db/user.rs index bdfb8470..8fdae813 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Local}; +use chrono::{DateTime, Utc}; use super::Db; @@ -9,7 +9,7 @@ pub struct User { pub first_name: String, pub last_name: String, pub email: String, - pub created_at: DateTime, + pub created_at: DateTime, } #[derive(Debug, serde::Deserialize)] diff --git a/src/utils/config.rs b/src/utils/config.rs index 17ba538c..96c8a6af 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use chrono_tz::Tz; use lettre::message::Mailbox; use std::{net::SocketAddr, path::PathBuf}; @@ -13,10 +14,11 @@ impl Config { } } -/// Bag of configuration values, parsed from a TOML file with serde. +/// Bag of app configuration values, parsed from a TOML file with serde. #[derive(Clone, Debug, serde::Deserialize)] pub struct Config { pub app: AppConfig, + pub db: DbConfig, pub net: NetConfig, pub acme: Option, pub email: EmailConfig, @@ -25,8 +27,17 @@ pub struct Config { /// Webapp configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AppConfig { + /// Public facing URL, e.g. `https://site.com`. pub url: String, - pub db: PathBuf, + /// Local timezone. + pub tz: Tz, +} + +/// Database configuration. +#[derive(Clone, Debug, serde::Deserialize)] +pub struct DbConfig { + /// Path to sqlite3 database file. + pub file: PathBuf, } /// Networking configuration. diff --git a/src/utils/tera.rs b/src/utils/tera.rs index 3b61a3bd..8438d526 100644 --- a/src/utils/tera.rs +++ b/src/utils/tera.rs @@ -1,29 +1,38 @@ use anyhow::{Context, Result}; -use chrono::{DateTime, Local}; +use chrono::{DateTime, Utc}; use std::collections::HashMap; use tera::{Tera, Value}; +use crate::Config; + /// Initialize the [`Tera`] template engine, including our custom filter functions. -pub fn templates() -> Result { +pub fn templates(config: &Config) -> Result { let mut tera = Tera::new("templates/*")?; - register_filter(&mut tera, "format_datetime", format_datetime); - Ok(tera) -} -/// Format a datetime with a [`strftime`] format string. -/// -/// Usage: `{{ date | format_datetime(format="%m.%d.%Y") }}` -/// -/// [`strftime`]: https://devhints.io/strftime -fn format_datetime(date: &Value, args: &HashMap) -> Result { - let format = args.get("format").context("missing arg=`format`")?; - let format = format.as_str().context("arg=`format` must be a string")?; + // Format a datetime with a [`strftime`] format string. + // Also converts from UTC to the app's local timezone. + // + // Usage: `{{ date | format_datetime(format="%m.%d.%Y") }}` + // + // [`strftime`]: https://devhints.io/strftime + let tz = config.app.tz; + register_filter( + &mut tera, + "format_datetime", + move |date: &Value, args: &HashMap| { + let format = args.get("format").context("missing arg=`format`")?; + let format = format.as_str().context("arg=`format` must be a string")?; - let date: &str = date.as_str().with_context(|| format!("value={date:?} must be a string"))?; - let date: DateTime = date.parse().context("parsing date")?; + let date: &str = date.as_str().with_context(|| format!("value={date:?} must be a string"))?; + let date: DateTime = date.parse().context("parsing date")?; + let local = date.with_timezone(&tz); - let formatted = date.format(format).to_string(); - Ok(Value::String(formatted)) + let formatted = local.format(format).to_string(); + Ok(Value::String(formatted)) + }, + ); + + Ok(tera) } /// Register a tera filter function. From 8452021b1c2468e7cb109fa87603fab7682dbc10 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 13:33:32 -0500 Subject: [PATCH 10/89] fix prod config.toml --- config/prod.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/prod.toml b/config/prod.toml index 16930205..65529b22 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -1,7 +1,9 @@ [app] url = "https://beta.lightandsound.design" tz = "America/New_York" -db = "db.sqlite" + +[db] +file = "db.sqlite" [net] http_addr = "[::]:80" From 90419a3f1f95678a9f13afa23da82bcaa8ae93ad Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 22:32:02 -0500 Subject: [PATCH 11/89] update to axum 0.8 --- Cargo.toml | 4 ++-- src/app/events.rs | 4 ++-- src/app/posts.rs | 4 ++-- src/db/event.rs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 046c4020..9b28c98b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,9 @@ edition = "2021" [dependencies] -axum = { version = "0.7", default-features = false, features = ["query", "form"] } +axum = { version = "0.8", default-features = false, features = ["query", "form"] } axum-server = { version = "0.7", features = ["tls-rustls"] } -axum-extra = { version = "0.9", features = ["cookie"] } +axum-extra = { version = "0.10", features = ["cookie"] } tower-http = { version = "0.6", features = ["fs", "trace"] } tera = "1" sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } diff --git a/src/app/events.rs b/src/app/events.rs index df9cc9a3..9403e78f 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -16,8 +16,8 @@ pub fn register_routes(router: AppRouter) -> AppRouter { .route("/events", get(list_events_page)) .route("/e/new", get(create_event_page).post(create_event_form)) .route( - "/e/:event_id", - // TODO: Move to a separate `/e/:event_id/edit` route, and add a `/e/:event_id` to just view the event. + "/e/{id}", + // TODO: Move to a separate `/e/{id}/edit` route, and add a `/e/{id}` to just view the event. get(update_event_page).post(update_event_form).delete(delete_event), ) } diff --git a/src/app/posts.rs b/src/app/posts.rs index 9ca6e151..e9d7da2c 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -14,8 +14,8 @@ use crate::utils::types::{AppResult, AppRouter, SharedAppState}; pub fn register_routes(router: AppRouter) -> AppRouter { router .route("/p/new", get(create_post_page)) - .route("/p/:post", get(view_post_page)) - .route("/p/:post/edit", get(edit_post_page).post(edit_post_form)) + .route("/p/{url}", get(view_post_page)) + .route("/p/{url}/edit", get(edit_post_page).post(edit_post_form)) } /// Display a single post. diff --git a/src/db/event.rs b/src/db/event.rs index bb2b9fa2..8b0c9f01 100644 --- a/src/db/event.rs +++ b/src/db/event.rs @@ -6,7 +6,7 @@ use super::Db; #[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct Event { pub id: i64, - // TODO: Add a pretty url field, for `https://site/e/:url`. + // TODO: Add a pretty url field, for `https://site/e/{url}`. // pub url: String, pub title: String, pub artist: String, From e48a5aed5e9b09e494a283ba094eb1854e1f6245 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 22:34:13 -0500 Subject: [PATCH 12/89] add smtp auth --- config/dev.toml | 3 ++- config/prod.toml | 9 ++++++--- scripts/deploy.sh | 2 ++ src/utils/config.rs | 6 ++++++ src/utils/email.rs | 7 ++++++- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/config/dev.toml b/config/dev.toml index 89647684..576f9d81 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -1,4 +1,5 @@ [app] +domain = "localhost" url = "https://localhost:4433" tz = "America/New_York" @@ -11,4 +12,4 @@ https_addr = "[::]:4433" [email] smtp_addr = "smtp://localhost:1025" -from = "WLSD " +from = "Light and Sound Design " diff --git a/config/prod.toml b/config/prod.toml index 65529b22..4f1dc4cb 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -1,4 +1,5 @@ [app] +domain = "beta.lightandsound.design" url = "https://beta.lightandsound.design" tz = "America/New_York" @@ -11,10 +12,12 @@ https_addr = "[::]:443" [acme] domain = "beta.lightandsound.design" -email = "studio249@foltz.io" +email = "studio@beta.lightandsound.design" dir = "acme" prod = true [email] -smtp_addr = "smtp://localhost:1025" -from = "WLSD " +smtp_addr = "smtp://email-smtp.us-east-1.amazonaws.com?tls=required" +smtp_username = "$SMTP_USERNAME" +smtp_password = "$SMTP_PASSWORD" +from = "Light and Sound Design " diff --git a/scripts/deploy.sh b/scripts/deploy.sh index a87696ab..77ed5d65 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -9,6 +9,8 @@ cargo build --release --target aarch64-unknown-linux-gnu ls -l target/aarch64-unknown-linux-gnu/ ls -l target/aarch64-unknown-linux-gnu/* +envsubst < config/prod.toml > config/prod.toml.subst +mv config/prod.toml.subst config/prod.toml rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/release/lsd $1:/home/lsd/ ssh $1 <<'EOS' sudo setcap 'cap_net_bind_service=+ep' /home/lsd/lsd diff --git a/src/utils/config.rs b/src/utils/config.rs index 96c8a6af..2fa805f8 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -27,6 +27,8 @@ pub struct Config { /// Webapp configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AppConfig { + /// Public facing domain, e.g. `site.com`. + pub domain: String, /// Public facing URL, e.g. `https://site.com`. pub url: String, /// Local timezone. @@ -67,6 +69,10 @@ pub struct AcmeConfig { pub struct EmailConfig { /// SMTP address, starting with `smtp://`. pub smtp_addr: String, + /// SMTP username. + pub smtp_username: Option, + /// SMTP password. + pub smtp_password: Option, /// Mailbox to send email from. pub from: Mailbox, } diff --git a/src/utils/email.rs b/src/utils/email.rs index b7120883..a0ccec4b 100644 --- a/src/utils/email.rs +++ b/src/utils/email.rs @@ -1,6 +1,7 @@ use anyhow::Result; use lettre::{ message::{header::ContentType, Mailbox, MessageBuilder}, + transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport, }; @@ -20,7 +21,11 @@ impl Email { // `lettre` requires a default provider to be installed to use SMTPS. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let transport = SmtpTransport::from_url(&config.smtp_addr)?.build(); + let mut transport = SmtpTransport::from_url(&config.smtp_addr)?; + if let (Some(username), Some(password)) = (config.smtp_username, config.smtp_password) { + transport = transport.credentials(Credentials::new(username, password)); + } + let transport = transport.build(); Ok(Self { transport, from: config.from }) } From 20cd8cd7eb3b058b1de77a31d19dc10c087d5a3a Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 23:13:56 -0500 Subject: [PATCH 13/89] add auth extractor --- src/app/auth.rs | 59 ++++++++++++++++++++++++++++++++++++++++--------- src/app/home.rs | 10 ++------- src/app/mod.rs | 19 ++++++++-------- src/db/user.rs | 2 +- 4 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/app/auth.rs b/src/app/auth.rs index b8099f74..66dd12c7 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -15,43 +15,82 @@ //! Upon submission, the user gets a new session cookie and is redirected home. use axum::{ - extract::{Query, State}, - http::{header, StatusCode}, + extract::{OptionalFromRequestParts, Query, Request, State}, + http::{header, request::Parts, StatusCode}, + middleware::Next, response::{Html, IntoResponse, Redirect, Response}, routing::post, Form, }; +use axum_extra::extract::CookieJar; use lettre::message::Mailbox; +use std::convert::Infallible; use crate::db::token::{LoginToken, SessionToken}; use crate::db::user::{UpdateUser, User}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; /// Add all auth routes to the router. -pub fn register_routes(router: AppRouter) -> AppRouter { +pub fn register(router: AppRouter, state: SharedAppState) -> AppRouter { router + .layer(axum::middleware::from_fn_with_state(state, auth_middleware)) .route("/login", post(login_form).get(login_link)) .route("/register", post(register_form).get(register_link)) } +/// Middleware to lookup add a `User` to the request if a session token is present. +pub async fn auth_middleware( + State(state): State, + mut cookies: CookieJar, + mut request: Request, + next: Next, +) -> AppResult<(CookieJar, Response)> { + if let Some(token) = cookies.get("session") { + match User::lookup_by_session_token(&state.db, token.value()).await? { + Some(user) => { + request.extensions_mut().insert(user); + } + None => cookies = cookies.remove("session"), + } + } + let response = next.run(request).await; + Ok((cookies, response)) +} + +/// Enable extracting an `Option` in a handler. +impl OptionalFromRequestParts for User { + type Rejection = Infallible; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} + /// Process a login form and send either a login or registration link via email. async fn login_form( State(state): State, Form(form): Form, ) -> AppResult { let email = form.email.email.to_string(); - let login_token = LoginToken::create(&state.db, &email).await?; - let url = &state.config.app.url; - let url = match User::lookup_by_email(&state.db, &email).await? { - Some(_) => format!("{url}/login?token={login_token}"), - None => format!("{url}/register?token={login_token}"), + let domain = &state.config.app.domain; + let base_url = &state.config.app.url; + let msg = state.mail.builder().to(form.email); + + let msg = match User::lookup_by_email(&state.db, &email).await? { + Some(_) => { + let url = format!("{base_url}/login?token={login_token}"); + msg.subject(format!("Login to {domain}")) + .body(format!("Click here to login: {url}"))? + } + None => { + let url = format!("{base_url}/register?token={login_token}"); + msg.subject(format!("Register at {domain}")) + .body(format!("Click here to complete your registration: {url}"))? + } }; - let msg = state.mail.builder().to(form.email).body(url)?; state.mail.send(msg).await?; - Ok("Check your email!") } #[derive(serde::Deserialize)] diff --git a/src/app/home.rs b/src/app/home.rs index a7c95034..82fa8396 100644 --- a/src/app/home.rs +++ b/src/app/home.rs @@ -1,10 +1,8 @@ use axum::{ extract::State, - http::StatusCode, response::{Html, IntoResponse, Response}, routing::get, }; -use axum_extra::extract::CookieJar; use crate::db::user::User; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; @@ -15,14 +13,10 @@ pub fn register_routes(router: AppRouter) -> AppRouter { } /// Display the front page. -async fn home_page(State(state): State, cookies: CookieJar) -> AppResult { +async fn home_page(State(state): State, user: Option) -> AppResult { let mut ctx = tera::Context::new(); ctx.insert("message", "Hello, world!"); - - if let Some(session_token) = cookies.get("session") { - let Some(user) = User::lookup_by_session_token(&state.db, session_token.value()).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; + if let Some(user) = user { ctx.insert("user", &user); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 601f1d72..e04a8aea 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -22,27 +22,26 @@ pub struct AppState { } pub async fn build(config: Config) -> Result { - let state = AppState { + let state = Arc::new(AppState { config: config.clone(), templates: utils::tera::templates(&config)?, db: crate::db::init(&config.db.file).await?, mail: Email::connect(config.email).await?, - }; + }); + + let r = Router::new() + .nest_service("/assets", ServeDir::new("assets")) + // For non-HTML pages without a , this is where the browser looks + .route("/favicon.ico", get(|| async { Redirect::to("/assets/favicon.ico") })); - let r = Router::new(); let r = home::register_routes(r); - let r = auth::register_routes(r); let r = posts::register_routes(r); let r = events::register_routes(r); - let r = r - .nest_service("/assets", ServeDir::new("assets")) - // For non-HTML pages without a , this is where the browser looks - .route("/favicon.ico", get(|| async { Redirect::to("/assets/favicon.ico") })); + let r = auth::register(r, Arc::clone(&state)); let r = utils::tracing::register(r); - - let r = r.with_state(Arc::new(state)); + let r = r.with_state(state); Ok(r) } diff --git a/src/db/user.rs b/src/db/user.rs index 8fdae813..d4b653c4 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use super::Db; -#[derive(Debug, sqlx::FromRow, serde::Serialize)] +#[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] pub struct User { pub id: i64, pub first_name: String, From bcd1c5e2142296c555ccdde7b0daf2e56b6e257f Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 5 Jan 2025 23:26:09 -0500 Subject: [PATCH 14/89] add smtp credentials to deploy gha --- .github/workflows/deploy.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index eeb0db49..ee5b5928 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -29,3 +29,6 @@ jobs: - name: deploy run: scripts/deploy.sh ec2-user@beta.lightandsound.design + env: + SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} From cb2afe66e288b67bab4be52cb5eddcae0010d182 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Mon, 6 Jan 2025 08:55:57 -0500 Subject: [PATCH 15/89] add migrations table --- src/db/migration.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/db/mod.rs | 2 ++ 2 files changed, 45 insertions(+) create mode 100644 src/db/migration.rs diff --git a/src/db/migration.rs b/src/db/migration.rs new file mode 100644 index 00000000..5bce5ec0 --- /dev/null +++ b/src/db/migration.rs @@ -0,0 +1,43 @@ +use anyhow::Result; +use std::future::Future; + +use super::Db; + +/// A record of a database migration +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Migration { + id: i64, + name: String, +} + +impl Migration { + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS migrations ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + name TEXT NOT NULL \ + )", + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn run(db: &Db, name: &str, func: impl Future>) -> Result<()> { + let id = sqlx::query("SELECT id FROM migrations WHERE name = ?") + .bind(name) + .fetch_optional(db) + .await?; + + if id.is_none() { + tracing::info!("Running migration {name:?}"); + func.await?; + sqlx::query("INSERT INTO migrations (name) VALUES (?)") + .bind(name) + .execute(db) + .await?; + } + + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 8b98c56a..3977a647 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -5,6 +5,7 @@ use std::path::Path; pub type Db = SqlitePool; pub mod event; +pub mod migration; pub mod post; pub mod token; pub mod user; @@ -17,6 +18,7 @@ pub async fn init(file: &Path) -> Result { } let db = SqlitePool::connect(&url).await?; + migration::Migration::migrate(&db).await?; user::User::migrate(&db).await?; token::SessionToken::migrate(&db).await?; token::LoginToken::migrate(&db).await?; From 9ea78bb1bf962f7bf39c26e530a37d6c876ef2ad Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Mon, 6 Jan 2025 08:56:05 -0500 Subject: [PATCH 16/89] add content_rendered to posts --- src/app/posts.rs | 1 + src/db/post.rs | 35 +++++++++++++++++++++++++++-------- templates/post-edit.tera.html | 6 +++++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/app/posts.rs b/src/app/posts.rs index e9d7da2c..763eb98b 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -42,6 +42,7 @@ async fn create_post_page(State(state): State) -> AppResult, pub updated_at: DateTime, } @@ -20,6 +21,7 @@ pub struct UpdatePost { pub url: String, pub author: String, pub content: String, + pub content_rendered: String, } impl Post { @@ -32,24 +34,39 @@ impl Post { url TEXT NOT NULL, \ author TEXT NOT NULL, \ content TEXT NOT NULL, \ + content_rendered TEXT NOT NULL, \ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ )", ) .execute(db) .await?; + + Migration::run(db, "posts: add content_rendered", async { + sqlx::query("ALTER TABLE posts ADD COLUMN content_rendered TEXT NOT NULL DEFAULT ''") + .execute(db) + .await?; + Ok(()) + }) + .await?; + Ok(()) } /// Create a new post. pub async fn create(db: &Db, post: &UpdatePost) -> Result { - let row = sqlx::query("INSERT INTO posts (title, url, author, content) VALUES (?, ?, ?, ?)") - .bind(&post.title) - .bind(&post.url) - .bind(&post.author) - .bind(&post.content) - .execute(db) - .await?; + let row = sqlx::query( + "INSERT INTO posts \ + (title, url, author, content, content_rendered) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(&post.title) + .bind(&post.url) + .bind(&post.author) + .bind(&post.content) + .bind(&post.content_rendered) + .execute(db) + .await?; Ok(row.last_insert_rowid()) } @@ -61,6 +78,7 @@ impl Post { url = ?, author = ?, content = ?, + content_rendered = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", ) @@ -68,6 +86,7 @@ impl Post { .bind(&post.url) .bind(&post.author) .bind(&post.content) + .bind(&post.content_rendered) .bind(id) .execute(db) .await?; diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html index 7de46e37..08931f04 100644 --- a/templates/post-edit.tera.html +++ b/templates/post-edit.tera.html @@ -87,6 +87,7 @@ +
    @@ -114,12 +115,15 @@

    {{ post.title }}

    const dateEl = document.querySelector('.date'); document.querySelector('title').textContent = `Edit post - ${title}`; + document.getElement titleEl.textContent = document.getElementById('title').value; while (dateEl.nextSibling) { dateEl.nextSibling.remove(); } // markdownToHtml is defined in assets/markdown.js - dateEl.insertAdjacentHTML('afterend', markdownToHtml(content)); + let content_rendered = markdownToHtml(content); + dateEl.insertAdjacentHTML('afterend', content_rendered); + document.getElementById('content_rendered').value = content_rendered; } updatePreview(); From c39dbd1eef18bf9355dfeae4c907670995af0344 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sat, 18 Jan 2025 00:59:44 -0500 Subject: [PATCH 17/89] add lists --- src/app/lists.rs | 103 ++++++++++++++++++++++++ src/app/mod.rs | 2 + src/db/list.rs | 147 ++++++++++++++++++++++++++++++++++ src/db/mod.rs | 2 + src/utils/types.rs | 2 +- templates/list-edit.tera.html | 46 +++++++++++ templates/lists.tera.html | 10 +++ templates/page.tera.html | 12 +++ templates/post-edit.tera.html | 33 +++----- 9 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 src/app/lists.rs create mode 100644 src/db/list.rs create mode 100644 templates/list-edit.tera.html create mode 100644 templates/lists.tera.html diff --git a/src/app/lists.rs b/src/app/lists.rs new file mode 100644 index 00000000..7d4cbe97 --- /dev/null +++ b/src/app/lists.rs @@ -0,0 +1,103 @@ +use anyhow::anyhow; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{Html, IntoResponse, Redirect, Response}, + routing::{delete, get}, + Form, +}; +use chrono::Utc; + +use crate::utils::types::{AppResult, AppRouter, SharedAppState}; +use crate::{ + db::list::{List, UpdateList}, + utils::types::AppError, +}; + +/// Add all `lists` routes to the router. +pub fn register_routes(router: AppRouter) -> AppRouter { + router + .route("/lists", get(list_lists_page)) + .route("/lists/new", get(create_list_page)) + .route("/lists/{id}", get(edit_list_page).post(edit_list_form)) + .route("/lists/{id}/{email}", delete(remove_list_member)) +} + +/// Display a list of all lists +async fn list_lists_page(State(state): State) -> AppResult { + let lists = List::list(&state.db).await?; + + let mut ctx = tera::Context::new(); + ctx.insert("lists", &lists); + + let html = state.templates.render("lists.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Display the form to view and edit a list. +async fn edit_list_page(State(state): State, Path(id): Path) -> AppResult { + let Some(list) = List::lookup_by_id(&state.db, id).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let members = List::list_members(&state.db, id).await?; + + let mut ctx = tera::Context::new(); + ctx.insert("list", &list); + ctx.insert("members", &members); + + let html = state.templates.render("list-edit.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Display the form to create a new list. +async fn create_list_page(State(state): State) -> AppResult { + let mut ctx = tera::Context::new(); + ctx.insert( + "list", + &List { + id: 0, + name: "".into(), + description: "".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + }, + ); + ctx.insert::<[String], _>("members", &[]); + + let html = state.templates.render("list-edit.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Process the form and create or edit a list. +async fn edit_list_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let id = match form.id { + Some(id) => { + List::update(&state.db, id, &form).await?; + id + } + None => List::create(&state.db, &form).await?, + }; + + let emails = form.emails.split_whitespace().collect::>(); + for email in &emails { + if !email.contains('@') && !email.contains('.') { + return Err(AppError(anyhow!("email {email:?} is not in the format \"mailbox@domain.tld\""))); + } + } + if !emails.is_empty() { + List::add_members(&state.db, id, &emails).await?; + } + + Ok(Redirect::to(&format!("{}/lists/{}", state.config.app.url, id))) +} + +async fn remove_list_member( + State(state): State, + Path((id, email)): Path<(i64, String)>, +) -> AppResult { + List::remove_member(&state.db, id, &email).await?; + Ok(StatusCode::OK) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index e04a8aea..1fb96726 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,6 +10,7 @@ use crate::utils::{self, config::*, email::Email}; mod auth; mod events; mod home; +mod lists; mod posts; #[derive(Clone)] @@ -37,6 +38,7 @@ pub async fn build(config: Config) -> Result { let r = home::register_routes(r); let r = posts::register_routes(r); let r = events::register_routes(r); + let r = lists::register_routes(r); let r = auth::register(r, Arc::clone(&state)); diff --git a/src/db/list.rs b/src/db/list.rs new file mode 100644 index 00000000..3ca1296e --- /dev/null +++ b/src/db/list.rs @@ -0,0 +1,147 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use sqlx::QueryBuilder; + +use super::Db; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct List { + pub id: i64, + pub name: String, + pub description: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct ListMember { + pub email: String, + pub first_name: Option, + pub last_name: Option, +} + +#[derive(serde::Deserialize)] +pub struct UpdateList { + pub id: Option, + pub name: String, + pub description: String, + pub emails: String, +} + +impl List { + /// Create the `events` table. + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS lists ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + name TEXT NOT NULL, \ + description TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ + )", + ) + .execute(db) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS list_members ( \ + list_id INTEGER NOT NULL, \ + email TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + PRIMARY KEY (list_id, email) + )", + ) + .execute(db) + .await?; + + Ok(()) + } + + /// List all lists. + pub async fn list(db: &Db) -> Result> { + let events = sqlx::query_as::<_, List>("SELECT * FROM lists").fetch_all(db).await?; + Ok(events) + } + + /// Create a list. + pub async fn create(db: &Db, event: &UpdateList) -> Result { + let row = sqlx::query( + "INSERT INTO lists \ + (name, description) \ + VALUES (?, ?)", + ) + .bind(&event.name) + .bind(&event.description) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + /// Update a list. + pub async fn update(db: &Db, id: i64, event: &UpdateList) -> Result<()> { + sqlx::query( + "UPDATE lists \ + SET name = ?, description = ? \ + WHERE id = ?", + ) + .bind(&event.name) + .bind(&event.description) + .bind(id) + .execute(db) + .await?; + Ok(()) + } + + /// Lookup a list by id, if one exists. + pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { + let event = sqlx::query_as::<_, List>( + "SELECT * \ + FROM lists \ + WHERE id = ?", + ) + .bind(id) + .fetch_optional(db) + .await?; + Ok(event) + } + + /// Lookup the members of a list. + pub async fn list_members(db: &Db, list_id: i64) -> Result> { + let members = sqlx::query_as::<_, ListMember>( + "SELECT e.email, u.first_name, u.last_name + FROM list_members e \ + LEFT JOIN users u ON u.email = e.email \ + WHERE e.list_id = ? + ORDER BY e.created_at", + ) + .bind(list_id) + .fetch_all(db) + .await?; + Ok(members) + } + + /// Add members to a guest list. + pub async fn add_members(db: &Db, list_id: i64, emails: &[&str]) -> Result<()> { + QueryBuilder::new("INSERT INTO list_members (list_id, email) ") + .push_values(emails, |mut b, email| { + b.push_bind(list_id).push_bind(email); + }) + .push("ON CONFLICT DO NOTHING") + .build() + .execute(db) + .await?; + Ok(()) + } + + pub async fn remove_member(db: &Db, list_id: i64, email: &str) -> Result<()> { + sqlx::query( + "DELETE FROM list_members \ + WHERE list_id = ? AND email = ?", + ) + .bind(list_id) + .bind(email) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 3977a647..0642c39e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -5,6 +5,7 @@ use std::path::Path; pub type Db = SqlitePool; pub mod event; +pub mod list; pub mod migration; pub mod post; pub mod token; @@ -24,6 +25,7 @@ pub async fn init(file: &Path) -> Result { token::LoginToken::migrate(&db).await?; post::Post::migrate(&db).await?; event::Event::migrate(&db).await?; + list::List::migrate(&db).await?; Ok(db) } diff --git a/src/utils/types.rs b/src/utils/types.rs index 9f88db85..6e6b79bf 100644 --- a/src/utils/types.rs +++ b/src/utils/types.rs @@ -10,7 +10,7 @@ pub type SharedAppState = Arc; pub type AppRouter = axum::Router; /// App-wide result type which automatically handles conversion to an HTTP response. -pub struct AppError(anyhow::Error); +pub struct AppError(pub anyhow::Error); pub type AppResult = Result; /// Convert an [`AppError`] into an HTTP response. diff --git a/templates/list-edit.tera.html b/templates/list-edit.tera.html new file mode 100644 index 00000000..e689abb3 --- /dev/null +++ b/templates/list-edit.tera.html @@ -0,0 +1,46 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Edit list - " ~ list.name) }} + +
    + {% if list.id != 0 %} + + {% endif %} +
    + + +
    +
    + + +
    + {% if list.id != 0 %} +
    + +
      + {% for member in members %} +
    • + + {{ member.email }} {% if member.first_name %}({{ member.first_name }} {{ member.last_name }}){% endif %} +
    • + {% endfor %} +
    +
    + {% endif %} +
    + + +
    + +
    +{{ page::end() }} diff --git a/templates/lists.tera.html b/templates/lists.tera.html new file mode 100644 index 00000000..bb0c29de --- /dev/null +++ b/templates/lists.tera.html @@ -0,0 +1,10 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Guestlists") }} + +{{ page::end() }} diff --git a/templates/page.tera.html b/templates/page.tera.html index e6d6c7aa..30211dc6 100644 --- a/templates/page.tera.html +++ b/templates/page.tera.html @@ -146,6 +146,18 @@ color: var(--color-text); border: 1px solid var(--color-border); } + + .form { + padding: 2rem; + display: flex; + flex-direction: column; + + .form-field { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + } + } diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html index 08931f04..e127b2c3 100644 --- a/templates/post-edit.tera.html +++ b/templates/post-edit.tera.html @@ -9,20 +9,12 @@ } .editor { flex: 1; - padding: 2rem; - display: flex; - flex-direction: column; border-right: 1px solid var(--color-border); - .field { - display: flex; - flex-direction: column; - margin-bottom: 1rem; - textarea { - flex-grow: 1; - } + textarea { + flex-grow: 1; } - .field.content { + .content { flex-grow: 1; } .url { @@ -64,26 +56,26 @@ }
    -
    + {% if post.id != 0 %} {% endif %} -
    +
    -
    +
    https://lightandsound.design/p/
    -
    +
    -
    +
    @@ -92,7 +84,7 @@ - +
    @@ -108,14 +100,13 @@

    {{ post.title }}

    // Re-render markdown to HTML when the content changes function updatePreview() { - const title = document.getElementById('title').value; - const content = document.getElementById('content').value; + const title = document.querySelector('#title').value; + const content = document.querySelector('#content').value; const titleEl = document.querySelector('.title'); const dateEl = document.querySelector('.date'); - document.querySelector('title').textContent = `Edit post - ${title}`; - document.getElement + document.title = `Edit post - ${title}`; titleEl.textContent = document.getElementById('title').value; while (dateEl.nextSibling) { dateEl.nextSibling.remove(); From eec0dce348c5cfb5d50d8cb77575df5c8c2c4511 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 21 Jan 2025 03:38:04 -0500 Subject: [PATCH 18/89] email tracking and post sending --- assets/markdown.js | 5 ++ src/app/auth.rs | 37 ++++++-- src/app/emails.rs | 58 +++++++++++++ src/app/lists.rs | 48 +++++++++-- src/app/mod.rs | 8 +- src/app/posts.rs | 134 +++++++++++++++++++++++++++-- src/db/email.rs | 114 ++++++++++++++++++++++++ src/db/mod.rs | 2 + src/db/user.rs | 28 ++++++ src/utils/{email.rs => emailer.rs} | 8 +- src/utils/mod.rs | 2 +- templates/list-edit.tera.html | 12 +-- templates/lists.tera.html | 5 ++ templates/page.tera.html | 11 ++- templates/post-edit.tera.html | 8 +- templates/post-email.tera.html | 91 ++++++++++++++++++++ templates/post-send.tera.html | 20 +++++ templates/post-sent.tera.html | 33 +++++++ templates/post.tera.html | 7 +- 19 files changed, 585 insertions(+), 46 deletions(-) create mode 100644 src/app/emails.rs create mode 100644 src/db/email.rs rename src/utils/{email.rs => emailer.rs} (85%) create mode 100644 templates/post-email.tera.html create mode 100644 templates/post-send.tera.html create mode 100644 templates/post-sent.tera.html diff --git a/assets/markdown.js b/assets/markdown.js index 914c6190..7e3cb96e 100644 --- a/assets/markdown.js +++ b/assets/markdown.js @@ -46,6 +46,11 @@ function markdownToHtml(md) { if (/^(${p}

    `; + } // Replace single newlines with
    , then wrap in

    const withBreaks = p.replace(/\n/g, "
    "); diff --git a/src/app/auth.rs b/src/app/auth.rs index 66dd12c7..12a3651c 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -1,5 +1,9 @@ //! A simple passwordless authentication flow using one-time links sent via email. //! +//! TODO: Switch to one-time codes (123-456) instead of links: +//! * More robust against clients and intermediaries that auto-open URLs +//! * Easier to transfer across devices than a magic link +//! //! We choose this scheme instead of one with usernames/passwords to reduce //! friction and simplify onboarding. //! @@ -23,11 +27,14 @@ use axum::{ Form, }; use axum_extra::extract::CookieJar; -use lettre::message::Mailbox; +use lettre::message::{header::ContentType, Mailbox}; use std::convert::Infallible; -use crate::db::token::{LoginToken, SessionToken}; use crate::db::user::{UpdateUser, User}; +use crate::db::{ + email::Email, + token::{LoginToken, SessionToken}, +}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; /// Add all auth routes to the router. @@ -64,6 +71,16 @@ impl OptionalFromRequestParts for User { Ok(parts.extensions.get::().cloned()) } } +/// Enable extracting a `User` in a handler, returning UNAUTHORIZED if not logged in. +impl axum::extract::FromRequestParts for User { + type Rejection = StatusCode; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let Some(user) = parts.extensions.get::().cloned() else { + return Err(StatusCode::UNAUTHORIZED); + }; + Ok(user) + } +} /// Process a login form and send either a login or registration link via email. async fn login_form( @@ -73,9 +90,11 @@ async fn login_form( let email = form.email.email.to_string(); let login_token = LoginToken::create(&state.db, &email).await?; + let email_id = Email::create(&state.db, Email::LOGIN, &email).await?; + let domain = &state.config.app.domain; let base_url = &state.config.app.url; - let msg = state.mail.builder().to(form.email); + let msg = state.mailer.builder().header(ContentType::TEXT_PLAIN).to(form.email); let msg = match User::lookup_by_email(&state.db, &email).await? { Some(_) => { @@ -90,8 +109,16 @@ async fn login_form( } }; - state.mail.send(msg).await?; - Ok("Check your email!") + match state.mailer.send(msg).await { + Ok(_) => { + Email::mark_sent(&state.db, email_id).await?; + Ok("Check your email!") + } + Err(e) => { + Email::mark_error(&state.db, email_id, &e.to_string()).await?; + Err(e.into()) + } + } } #[derive(serde::Deserialize)] struct LoginForm { diff --git a/src/app/emails.rs b/src/app/emails.rs new file mode 100644 index 00000000..4449148e --- /dev/null +++ b/src/app/emails.rs @@ -0,0 +1,58 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Response, + routing::get, +}; + +use crate::{ + db::email::Email, + utils::types::{AppResult, AppRouter, SharedAppState}, +}; + +/// Add all `email` routes to the router. +pub fn register_routes(router: AppRouter) -> AppRouter { + router.route("/emails/{id}/opened.gif", get(email_opened)) +} + +async fn email_opened(Path(id): Path, State(state): State) -> AppResult { + Email::mark_opened(&state.db, id).await?; + let pixel = Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "image/gif") + .body(PIXEL.into()) + .unwrap(); + Ok(pixel) +} + +/// A 1x1 transparent GIF. +#[rustfmt::skip] +const PIXEL: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // Header: "GIF89a" + 0x01, 0x00, // Logical Screen Width: 1 + 0x01, 0x00, // Logical Screen Height: 1 + 0x80, // GCT flag = 1, Color Resolution = 0, Sort = 0, GCT Size = 2^(0+1)=2 colors + 0x00, // Background Color Index = 0 + 0x00, // Pixel Aspect Ratio = 0 (no aspect ratio given) + // Global Color Table (2 entries, each 3 bytes: RGB) + 0x00, 0x00, 0x00, // Index #0: black (will be set as transparent) + 0x00, 0x00, 0x00, // Index #1: black + // Graphic Control Extension + 0x21, 0xF9, 0x04, // Extension Introducer (0x21), GCE Label (0xF9), Block Size (4) + 0x01, // Packed Fields: bit 0 = 1 => Transparent Color Flag + 0x00, 0x00, // Delay Time = 0 + 0x00, // Transparent Color Index = 0 + 0x00, // Block Terminator + // Image Descriptor + 0x2C, // Image separator: ',' + 0x00, 0x00, 0x00, 0x00, // Image Position: (0,0) + 0x01, 0x00, // Image Width: 1 + 0x01, 0x00, // Image Height: 1 + 0x00, // No Local Color Table, no interlace, etc. + // Image Data + 0x02, // LZW Minimum Code Size + 0x02, // Block Size (number of bytes of LZW data in this sub-block) + 0x4C, 0x01, // LZW-compressed data + 0x00, // Block Terminator (end of image data) + 0x3B, // Trailer: ';' +]; diff --git a/src/app/lists.rs b/src/app/lists.rs index 7d4cbe97..b28a2da4 100644 --- a/src/app/lists.rs +++ b/src/app/lists.rs @@ -7,12 +7,16 @@ use axum::{ Form, }; use chrono::Utc; +use lettre::message::Mailbox; -use crate::utils::types::{AppResult, AppRouter, SharedAppState}; use crate::{ db::list::{List, UpdateList}, utils::types::AppError, }; +use crate::{ + db::user::User, + utils::types::{AppResult, AppRouter, SharedAppState}, +}; /// Add all `lists` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { @@ -24,7 +28,11 @@ pub fn register_routes(router: AppRouter) -> AppRouter { } /// Display a list of all lists -async fn list_lists_page(State(state): State) -> AppResult { +async fn list_lists_page(State(state): State, user: User) -> AppResult { + if !user.has_role(&state.db, User::ADMIN).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let lists = List::list(&state.db).await?; let mut ctx = tera::Context::new(); @@ -35,7 +43,15 @@ async fn list_lists_page(State(state): State) -> AppResult, Path(id): Path) -> AppResult { +async fn edit_list_page( + State(state): State, + user: User, + Path(id): Path, +) -> AppResult { + if !user.has_role(&state.db, User::ADMIN).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let Some(list) = List::lookup_by_id(&state.db, id).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; @@ -50,7 +66,11 @@ async fn edit_list_page(State(state): State, Path(id): Path } /// Display the form to create a new list. -async fn create_list_page(State(state): State) -> AppResult { +async fn create_list_page(State(state): State, user: User) -> AppResult { + if !user.has_role(&state.db, User::ADMIN).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let mut ctx = tera::Context::new(); ctx.insert( "list", @@ -71,8 +91,13 @@ async fn create_list_page(State(state): State) -> AppResult, + user: User, Form(form): Form, -) -> AppResult { +) -> AppResult { + if !user.has_role(&state.db, User::ADMIN).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let id = match form.id { Some(id) => { List::update(&state.db, id, &form).await?; @@ -83,21 +108,26 @@ async fn edit_list_form( let emails = form.emails.split_whitespace().collect::>(); for email in &emails { - if !email.contains('@') && !email.contains('.') { - return Err(AppError(anyhow!("email {email:?} is not in the format \"mailbox@domain.tld\""))); + if let Err(e) = email.parse::() { + return Err(AppError(anyhow!("email {email:?} is invalid: {e}"))); } } if !emails.is_empty() { List::add_members(&state.db, id, &emails).await?; } - Ok(Redirect::to(&format!("{}/lists/{}", state.config.app.url, id))) + Ok(Redirect::to(&format!("{}/lists/{}", state.config.app.url, id)).into_response()) } async fn remove_list_member( State(state): State, + user: User, Path((id, email)): Path<(i64, String)>, -) -> AppResult { +) -> AppResult { + if !user.has_role(&state.db, User::ADMIN).await? { + return Ok(StatusCode::FORBIDDEN); + } + List::remove_member(&state.db, id, &email).await?; Ok(StatusCode::OK) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1fb96726..9fdd6f9d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,9 +5,10 @@ use tera::Tera; use tower_http::services::ServeDir; use crate::db::Db; -use crate::utils::{self, config::*, email::Email}; +use crate::utils::{self, config::*, emailer::Emailer}; mod auth; +mod emails; mod events; mod home; mod lists; @@ -19,7 +20,7 @@ pub struct AppState { config: Config, templates: Tera, db: Db, - mail: Email, + mailer: Emailer, } pub async fn build(config: Config) -> Result { @@ -27,7 +28,7 @@ pub async fn build(config: Config) -> Result { config: config.clone(), templates: utils::tera::templates(&config)?, db: crate::db::init(&config.db.file).await?, - mail: Email::connect(config.email).await?, + mailer: Emailer::connect(config.email).await?, }); let r = Router::new() @@ -39,6 +40,7 @@ pub async fn build(config: Config) -> Result { let r = posts::register_routes(r); let r = events::register_routes(r); let r = lists::register_routes(r); + let r = emails::register_routes(r); let r = auth::register(r, Arc::clone(&state)); diff --git a/src/app/posts.rs b/src/app/posts.rs index 763eb98b..8612f201 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use axum::{ extract::{Path, State}, http::StatusCode, @@ -6,8 +8,14 @@ use axum::{ Form, }; use chrono::Utc; +use lettre::message::header::ContentType; -use crate::db::post::{Post, UpdatePost}; +use crate::db::{ + email::Email, + list::{List, ListMember}, + post::{Post, UpdatePost}, + user::User, +}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; /// Add all `post` routes to the router. @@ -16,6 +24,7 @@ pub fn register_routes(router: AppRouter) -> AppRouter { .route("/p/new", get(create_post_page)) .route("/p/{url}", get(view_post_page)) .route("/p/{url}/edit", get(edit_post_page).post(edit_post_form)) + .route("/p/{url}/send", get(send_post_page).post(send_post_form)) } /// Display a single post. @@ -32,7 +41,11 @@ async fn view_post_page(State(state): State, Path(url): Path) -> AppResult { +async fn create_post_page(State(state): State, user: User) -> AppResult { + if !user.has_role(&state.db, User::WRITER).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let mut ctx = tera::Context::new(); ctx.insert( "post", @@ -53,7 +66,14 @@ async fn create_post_page(State(state): State) -> AppResult, Path(url): Path) -> AppResult { +async fn edit_post_page( + State(state): State, + user: User, + Path(url): Path, +) -> AppResult { + if !user.has_role(&state.db, User::WRITER).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { return Ok(StatusCode::NOT_FOUND.into_response()); }; @@ -68,15 +88,20 @@ async fn edit_post_page(State(state): State, Path(url): Path, + user: User, Form(form): Form, -) -> AppResult { +) -> AppResult { + if !user.has_role(&state.db, User::WRITER).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + match form.id { Some(id) => Post::update(&state.db, id, &form.post).await?, None => { Post::create(&state.db, &form.post).await?; } } - Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, &form.post.url))) + Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, &form.post.url)).into_response()) } #[derive(serde::Deserialize)] struct EditPost { @@ -84,3 +109,102 @@ struct EditPost { #[serde(flatten)] post: UpdatePost, } + +/// Display the form to send a post. +async fn send_post_page( + State(state): State, + user: User, + Path(url): Path, +) -> AppResult { + if !user.has_role(&state.db, User::WRITER).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let lists = List::list(&state.db).await?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + ctx.insert("lists", &lists); + + let html = state.templates.render("post-send.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} + +/// Process the form and create or edit a post. +async fn send_post_form( + State(state): State, + user: User, + Path(url): Path, + Form(form): Form, +) -> AppResult { + if !user.has_role(&state.db, User::WRITER).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let Some(list) = List::lookup_by_id(&state.db, form.list_id).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + let members = List::list_members(&state.db, form.list_id).await?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let mut num_sent = 0; + let mut num_skipped = 0; + let mut errors = HashMap::new(); + for ListMember { email, .. } in &members { + // If the post was already sent to this user, skip them. + if Email::lookup_ref(&state.db, Email::POST, post.id, email).await?.is_some() { + num_skipped += 1; + continue; + } + // Otherwise, create a new email record. + let email_id = Email::create_ref(&state.db, Email::POST, post.id, email).await?; + + ctx.insert("opened_url", &format!("{}/emails/{email_id}/opened.gif", &state.config.app.url)); + let html = state.templates.render("post-email.tera.html", &ctx).unwrap(); + + let msg = state + .mailer + .builder() + .to(email.parse().unwrap()) + .subject(&post.title) + .header(ContentType::TEXT_HTML) + .body(html) + .unwrap(); + + match state.mailer.send(msg).await { + Ok(_) => { + Email::mark_sent(&state.db, email_id).await?; + num_sent += 1; + } + Err(e) => { + let e = e.to_string(); + Email::mark_error(&state.db, email_id, &e).await?; + errors.insert(email.clone(), e); + } + } + } + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + ctx.insert("list", &list); + ctx.insert("stats", &Stats { num_sent, num_skipped, errors }); + + let html = state.templates.render("post-sent.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) +} +#[derive(serde::Deserialize)] +struct SendPost { + list_id: i64, +} +#[derive(serde::Serialize)] +struct Stats { + pub num_sent: usize, + pub num_skipped: usize, + pub errors: HashMap, +} diff --git a/src/db/email.rs b/src/db/email.rs new file mode 100644 index 00000000..bb8adaf8 --- /dev/null +++ b/src/db/email.rs @@ -0,0 +1,114 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; + +use super::Db; + +/// A record of a an email which has been sent. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Email { + pub id: i64, + pub reference_id: Option, + pub kind: String, + pub address: String, + pub error: Option, + pub created_at: DateTime, + pub sent_at: Option>, + pub opened_at: Option>, +} + +impl Email { + /// A login email. + pub const LOGIN: &'static str = "login"; + /// An email containing a post. + pub const POST: &'static str = "post"; + + /// Create the `emails` table. + pub async fn migrate(db: &Db) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS emails ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + reference_id INTEGER, \ + kind TEXT NOT NULL, \ + address TEXT NOT NULL, \ + error TEXT, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + sent_at TIMESTAMP, \ + opened_at TIMESTAMP \ + )", + ) + .execute(db) + .await?; + Ok(()) + } + + /// Lookup an email referencing another database entry. + pub async fn lookup_ref(db: &Db, kind: &str, reference_id: i64, address: &str) -> Result> { + let res = sqlx::query_as::<_, Email>( + "SELECT * FROM emails WHERE kind = ? AND reference_id = ? AND address = ?", + ) + .bind(kind) + .bind(reference_id) + .bind(address) + .fetch_optional(db) + .await?; + Ok(res) + } + + /// Create a new email record. + pub async fn create(db: &Db, kind: &str, address: &str) -> Result { + let res = sqlx::query("INSERT INTO emails (kind, address) VALUES (?, ?)") + .bind(kind) + .bind(address) + .execute(db) + .await?; + Ok(res.last_insert_rowid()) + } + + /// Create a new email record referencing another database entry. + pub async fn create_ref(db: &Db, kind: &str, reference_id: i64, address: &str) -> Result { + let res = sqlx::query("INSERT INTO emails (kind, reference_id, address) VALUES (?, ?, ?)") + .bind(kind) + .bind(reference_id) + .bind(address) + .execute(db) + .await?; + Ok(res.last_insert_rowid()) + } + + /// Mark an email as sent. + pub async fn mark_sent(db: &Db, id: i64) -> Result<()> { + sqlx::query( + "UPDATE emails SET sent_at = CURRENT_TIMESTAMP \ + WHERE id = ?", + ) + .bind(id) + .execute(db) + .await?; + Ok(()) + } + + /// Mark an email as sent. + pub async fn mark_error(db: &Db, id: i64, error: &str) -> Result<()> { + sqlx::query( + "UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ? \ + WHERE id = ?", + ) + .bind(error) + .bind(id) + .execute(db) + .await?; + Ok(()) + } + + /// Mark an email as opened. + pub async fn mark_opened(db: &Db, id: i64) -> Result<()> { + sqlx::query( + "UPDATE emails SET opened_at = CURRENT_TIMESTAMP \ + WHERE id = ? AND opened_at IS NULL", + ) + .bind(id) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 0642c39e..6c8e1dd9 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -4,6 +4,7 @@ use std::path::Path; pub type Db = SqlitePool; +pub mod email; pub mod event; pub mod list; pub mod migration; @@ -26,6 +27,7 @@ pub async fn init(file: &Path) -> Result { post::Post::migrate(&db).await?; event::Event::migrate(&db).await?; list::List::migrate(&db).await?; + email::Email::migrate(&db).await?; Ok(db) } diff --git a/src/db/user.rs b/src/db/user.rs index d4b653c4..c1cbbbdb 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -12,6 +12,8 @@ pub struct User { pub created_at: DateTime, } +impl User {} + #[derive(Debug, serde::Deserialize)] pub struct UpdateUser { pub first_name: String, @@ -20,6 +22,11 @@ pub struct UpdateUser { } impl User { + /// Full access to everything. + pub const ADMIN: &'static str = "admin"; + /// Can manage posts. + pub const WRITER: &'static str = "writer"; + /// Create the `users` table. pub async fn migrate(db: &Db) -> Result<()> { sqlx::query( @@ -33,6 +40,18 @@ impl User { ) .execute(db) .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS user_roles ( \ + user_id INTEGER NOT NULL, \ + role TEXT NOT NULL, \ + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ + PRIMARY KEY (user_id, role) \ + )", + ) + .execute(db) + .await?; + Ok(()) } @@ -85,4 +104,13 @@ impl User { .await?; Ok(user) } + + pub async fn has_role(&self, db: &Db, role: &str) -> Result { + let row = sqlx::query("SELECT 1 FROM user_roles WHERE user_id = ? AND role = ?") + .bind(self.id) + .bind(role) + .fetch_optional(db) + .await?; + Ok(row.is_some()) + } } diff --git a/src/utils/email.rs b/src/utils/emailer.rs similarity index 85% rename from src/utils/email.rs rename to src/utils/emailer.rs index a0ccec4b..3649d345 100644 --- a/src/utils/email.rs +++ b/src/utils/emailer.rs @@ -1,6 +1,6 @@ use anyhow::Result; use lettre::{ - message::{header::ContentType, Mailbox, MessageBuilder}, + message::{Mailbox, MessageBuilder}, transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport, }; @@ -9,14 +9,14 @@ use crate::EmailConfig; /// Email client. #[derive(Clone)] -pub struct Email { +pub struct Emailer { /// Mailbox to send email from. from: Mailbox, /// Underlying SMTPS transport. transport: SmtpTransport, } -impl Email { +impl Emailer { pub async fn connect(config: EmailConfig) -> Result { // `lettre` requires a default provider to be installed to use SMTPS. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -31,7 +31,7 @@ impl Email { } pub fn builder(&self) -> MessageBuilder { - Message::builder().from(self.from.clone()).header(ContentType::TEXT_PLAIN) + Message::builder().from(self.from.clone()) } pub async fn send(&self, message: Message) -> Result<()> { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 7fb29907..bdf583ba 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,5 @@ pub mod config; -pub mod email; +pub mod emailer; pub mod tera; pub mod tracing; pub mod types; diff --git a/templates/list-edit.tera.html b/templates/list-edit.tera.html index e689abb3..1f88dd55 100644 --- a/templates/list-edit.tera.html +++ b/templates/list-edit.tera.html @@ -15,29 +15,29 @@ {% if list.id != 0 %} {% endif %} -

    +
    -
    +
    {% if list.id != 0 %} -
    +
      {% for member in members %} -
    • +
    • + onclick="fetch(`/lists/{{ list.id }}/{{ member.email }}`, {method: 'DELETE'}).then(res => res.ok && document.querySelector(`#member-{{ loop.index }}`).remove())">Remove {{ member.email }} {% if member.first_name %}({{ member.first_name }} {{ member.last_name }}){% endif %}
    • {% endfor %}
    {% endif %} -
    +
    diff --git a/templates/lists.tera.html b/templates/lists.tera.html index bb0c29de..e4595206 100644 --- a/templates/lists.tera.html +++ b/templates/lists.tera.html @@ -1,5 +1,10 @@ {% import "page.tera.html" as page %} {{ page::start(title="Guestlists") }} +
      {% for list in lists %}
    • diff --git a/templates/page.tera.html b/templates/page.tera.html index 30211dc6..882daf25 100644 --- a/templates/page.tera.html +++ b/templates/page.tera.html @@ -1,5 +1,5 @@ {% macro start(title) %} - + @@ -146,13 +146,18 @@ color: var(--color-text); border: 1px solid var(--color-border); } + select { + color: var(--color-text); + background: var(--color-bg-alt); + border: 1px solid var(--color-border); + } - .form { + form { padding: 2rem; display: flex; flex-direction: column; - .form-field { + .field { display: flex; flex-direction: column; margin-bottom: 1rem; diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html index e127b2c3..ad58cbce 100644 --- a/templates/post-edit.tera.html +++ b/templates/post-edit.tera.html @@ -60,22 +60,22 @@ {% if post.id != 0 %} {% endif %} -
      +
      -
      +
      https://lightandsound.design/p/
      -
      +
      -
      +
      diff --git a/templates/post-email.tera.html b/templates/post-email.tera.html new file mode 100644 index 00000000..d6b2ffe5 --- /dev/null +++ b/templates/post-email.tera.html @@ -0,0 +1,91 @@ + + + + + + + {{ post.title }} + + + +
      +
      +

      {{ post.title }}

      + + {{ post.content_rendered | safe }} + +
      +
      + + diff --git a/templates/post-send.tera.html b/templates/post-send.tera.html new file mode 100644 index 00000000..7f2cbe3c --- /dev/null +++ b/templates/post-send.tera.html @@ -0,0 +1,20 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Send post - " ~ post.title) }} + +
      +

      {{ post.title }}

      +
      + + +
      + +
      +{{ page::end() }} diff --git a/templates/post-sent.tera.html b/templates/post-sent.tera.html new file mode 100644 index 00000000..6ba69087 --- /dev/null +++ b/templates/post-sent.tera.html @@ -0,0 +1,33 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Sent post - " ~ post.title) }} + +
      +

      {{ post.title }}

      +
        +
      • Sent {{ stats.num_sent }} emails to list "{{ list.name }}"
      • + + {% if stats.num_skipped > 0 %} +
      • Skipped {{ stats.num_skipped }} emails which were already sent
      • + {% endif %} + + {%if stats.errors | length > 0 %} +
      • + Failed to send {{ stats.errors | length }} emails +
          + {% for email, error in stats.errors %} +
        • {{ email }}: {{ error }}
        • + {% endfor %} +
        • foo@foo.com: test
        • +
        +
      • + {% endif %} +
      + {{ page::end() }} +
      diff --git a/templates/post.tera.html b/templates/post.tera.html index f59e194f..08e08a55 100644 --- a/templates/post.tera.html +++ b/templates/post.tera.html @@ -3,11 +3,6 @@

      {{ post.title }}

      + {{ post.content_rendered | safe }}
      - - {{ page::end() }} From 0b7f5629f91fe31ab070b15c6685e965eee0e377 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 21 Jan 2025 03:52:42 -0500 Subject: [PATCH 19/89] downgrade gha to ubuntu-22.04 --- .github/workflows/deploy.yaml | 2 +- .github/workflows/test.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index ee5b5928..e1e86675 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -6,7 +6,7 @@ on: jobs: deploy: name: Deploy - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read steps: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 63324dd5..d9c1b0f0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,7 +5,7 @@ on: jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -16,7 +16,7 @@ jobs: rustfmt: name: Format - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -26,7 +26,7 @@ jobs: clippy: name: Lint - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -37,7 +37,7 @@ jobs: cross: name: Cross-compile - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read steps: From 94a1e666c2984e0ad0adde52a452be8a3cdcf36e Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 21 Jan 2025 12:17:17 -0500 Subject: [PATCH 20/89] email unsubscribe links, markdown images with links, style tweaks --- assets/markdown.js | 23 ++++++++++-------- src/app/auth.rs | 2 +- src/app/emails.rs | 18 ++++++++++++-- src/app/posts.rs | 14 ++++------- src/db/email.rs | 35 +++++++++++++-------------- templates/page.tera.html | 6 +++-- templates/post-edit.tera.html | 4 ++-- templates/post-email.tera.html | 44 +++++++++++++++++++++++++++++++++- templates/post-sent.tera.html | 4 ---- 9 files changed, 100 insertions(+), 50 deletions(-) diff --git a/assets/markdown.js b/assets/markdown.js index 7e3cb96e..9b2b153c 100644 --- a/assets/markdown.js +++ b/assets/markdown.js @@ -32,10 +32,18 @@ function markdownToHtml(md) { .replace(/^### (.*)$/gm, "

      $1

      ") .replace(/^#### (.*)$/gm, "
      $1
      ") .replace(/^##### (.*)$/gm, "
      $1
      ") - // Images: ![alt](url) - .replace(/\!\[(.+)\]\((.*)\)/g, '$1') - // Links: [text](url) - .replace(/\[(.+)\]\((.*)\)/g, '$1'); + // Images with a link: ![alt](image_url)(link_url) + .replace( + /\!\[(.+?)\]\((.*?)\)\((.*?)\)/g, + '$1', + ) + // Images: ![alt](image_url) + .replace( + /\!\[(.+?)\]\((.*?)\)/g, + '
      $1
      ', + ) + // Links: [text](link_url) + .replace(/\[(.+?)\]\((.*?)\)/g, '$1'); // Step 2: Split the text into paragraphs and wrap in

      const paragraphs = html.trim().split(/\n\s*\n/); @@ -43,14 +51,9 @@ function markdownToHtml(md) { .map((p) => p.trim()) .map((p) => { // If it's already a block-level element, ignore - if (/^(${p}

      `; - } // Replace single newlines with
      , then wrap in

      const withBreaks = p.replace(/\n/g, "
      "); diff --git a/src/app/auth.rs b/src/app/auth.rs index 12a3651c..065d0758 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -90,7 +90,7 @@ async fn login_form( let email = form.email.email.to_string(); let login_token = LoginToken::create(&state.db, &email).await?; - let email_id = Email::create(&state.db, Email::LOGIN, &email).await?; + let email_id = Email::create_login(&state.db, &email).await?; let domain = &state.config.app.domain; let base_url = &state.config.app.url; diff --git a/src/app/emails.rs b/src/app/emails.rs index 4449148e..aec6b2fc 100644 --- a/src/app/emails.rs +++ b/src/app/emails.rs @@ -6,13 +6,15 @@ use axum::{ }; use crate::{ - db::email::Email, + db::{email::Email, list::List}, utils::types::{AppResult, AppRouter, SharedAppState}, }; /// Add all `email` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { - router.route("/emails/{id}/opened.gif", get(email_opened)) + router + .route("/emails/{id}/opened.gif", get(email_opened)) + .route("/emails/{id}/unsubscribe", get(email_unsubscribed)) } async fn email_opened(Path(id): Path, State(state): State) -> AppResult { @@ -25,6 +27,18 @@ async fn email_opened(Path(id): Path, State(state): State) Ok(pixel) } +async fn email_unsubscribed( + Path(id): Path, + State(state): State, +) -> AppResult { + if let Some(email) = Email::lookup(&state.db, id).await? { + if let Some(list_id) = email.list_id { + List::remove_member(&state.db, list_id, &email.address).await?; + } + } + Ok(StatusCode::OK) +} + /// A 1x1 transparent GIF. #[rustfmt::skip] const PIXEL: &[u8] = &[ diff --git a/src/app/posts.rs b/src/app/posts.rs index 8612f201..45b2aab0 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -152,20 +152,15 @@ async fn send_post_form( let mut ctx = tera::Context::new(); ctx.insert("post", &post); + ctx.insert("post_url", &format!("{}/p/{}", &state.config.app.url, &post.url)); let mut num_sent = 0; - let mut num_skipped = 0; let mut errors = HashMap::new(); for ListMember { email, .. } in &members { - // If the post was already sent to this user, skip them. - if Email::lookup_ref(&state.db, Email::POST, post.id, email).await?.is_some() { - num_skipped += 1; - continue; - } - // Otherwise, create a new email record. - let email_id = Email::create_ref(&state.db, Email::POST, post.id, email).await?; + let email_id = Email::create_post(&state.db, email, post.id, list.id).await?; ctx.insert("opened_url", &format!("{}/emails/{email_id}/opened.gif", &state.config.app.url)); + ctx.insert("unsub_url", &format!("{}/emails/{email_id}/unsubscribe", &state.config.app.url)); let html = state.templates.render("post-email.tera.html", &ctx).unwrap(); let msg = state @@ -193,7 +188,7 @@ async fn send_post_form( let mut ctx = tera::Context::new(); ctx.insert("post", &post); ctx.insert("list", &list); - ctx.insert("stats", &Stats { num_sent, num_skipped, errors }); + ctx.insert("stats", &Stats { num_sent, errors }); let html = state.templates.render("post-sent.tera.html", &ctx).unwrap(); Ok(Html(html).into_response()) @@ -205,6 +200,5 @@ struct SendPost { #[derive(serde::Serialize)] struct Stats { pub num_sent: usize, - pub num_skipped: usize, pub errors: HashMap, } diff --git a/src/db/email.rs b/src/db/email.rs index bb8adaf8..3154bb92 100644 --- a/src/db/email.rs +++ b/src/db/email.rs @@ -7,9 +7,10 @@ use super::Db; #[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct Email { pub id: i64, - pub reference_id: Option, pub kind: String, pub address: String, + pub post_id: Option, + pub list_id: Option, pub error: Option, pub created_at: DateTime, pub sent_at: Option>, @@ -27,9 +28,10 @@ impl Email { sqlx::query( "CREATE TABLE IF NOT EXISTS emails ( \ id INTEGER PRIMARY KEY NOT NULL, \ - reference_id INTEGER, \ kind TEXT NOT NULL, \ address TEXT NOT NULL, \ + post_id INTEGER, \ + list_id INTEGER, \ error TEXT, \ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ sent_at TIMESTAMP, \ @@ -41,23 +43,19 @@ impl Email { Ok(()) } - /// Lookup an email referencing another database entry. - pub async fn lookup_ref(db: &Db, kind: &str, reference_id: i64, address: &str) -> Result> { - let res = sqlx::query_as::<_, Email>( - "SELECT * FROM emails WHERE kind = ? AND reference_id = ? AND address = ?", - ) - .bind(kind) - .bind(reference_id) - .bind(address) - .fetch_optional(db) - .await?; + /// Lookup an email by id. + pub async fn lookup(db: &Db, id: i64) -> Result> { + let res = sqlx::query_as::<_, Email>("SELECT * FROM emails WHERE id = ?") + .bind(id) + .fetch_optional(db) + .await?; Ok(res) } /// Create a new email record. - pub async fn create(db: &Db, kind: &str, address: &str) -> Result { + pub async fn create_login(db: &Db, address: &str) -> Result { let res = sqlx::query("INSERT INTO emails (kind, address) VALUES (?, ?)") - .bind(kind) + .bind(Email::LOGIN) .bind(address) .execute(db) .await?; @@ -65,11 +63,12 @@ impl Email { } /// Create a new email record referencing another database entry. - pub async fn create_ref(db: &Db, kind: &str, reference_id: i64, address: &str) -> Result { - let res = sqlx::query("INSERT INTO emails (kind, reference_id, address) VALUES (?, ?, ?)") - .bind(kind) - .bind(reference_id) + pub async fn create_post(db: &Db, address: &str, post_id: i64, list_id: i64) -> Result { + let res = sqlx::query("INSERT INTO emails (kind, address, post_id, list_id) VALUES (?, ?, ?, ?)") + .bind(Email::POST) .bind(address) + .bind(post_id) + .bind(list_id) .execute(db) .await?; Ok(res.last_insert_rowid()) diff --git a/templates/page.tera.html b/templates/page.tera.html index 882daf25..e2309f76 100644 --- a/templates/page.tera.html +++ b/templates/page.tera.html @@ -94,9 +94,11 @@ footer { width: 0; height: 0; } /* footer { - padding: 2rem 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 1rem 0; border-top: 1px solid var(--color-border); - margin-top: 2rem; color: var(--color-text-muted); } */ diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html index ad58cbce..9b7cc266 100644 --- a/templates/post-edit.tera.html +++ b/templates/post-edit.tera.html @@ -126,7 +126,7 @@

      {{ post.title }}

      insert = '[link text](https://link-url)'; break; case 'image': - insert = '\n![alt text](https://image-url)\n'; + insert = '\n![alt text](https://image-url)(https://link-url)\n'; break; case 'quote': insert = '\n> Quote\n'; @@ -143,7 +143,7 @@

      {{ post.title }}

      let modified = false; document.querySelectorAll('input, textarea') .forEach(e => e.addEventListener('input', () => modified = true)); - document.getElementById('post-form') + document.querySelector('form') .addEventListener('submit', () => modified = false); window.addEventListener('beforeunload', (e) => { if (modified) { diff --git a/templates/post-email.tera.html b/templates/post-email.tera.html index d6b2ffe5..a49746b0 100644 --- a/templates/post-email.tera.html +++ b/templates/post-email.tera.html @@ -15,6 +15,33 @@ line-height: 1.6; } + header { + padding: 1rem; + border-bottom: 1px solid #333; + } + header a { + text-decoration: none; + } + header a.home { + font-weight: bold; + letter-spacing: 1.05px; + } + + nav { + max-width: 768px; + margin: 0 auto; + padding: 0 1rem; + } + + footer { + display: flex; + flex-direction: column; + justify-content: center; + padding: 1rem 0; + border-top: 1px solid #333; + color: #aaa; + } + article { max-width: 768px; margin: 0 auto; @@ -65,6 +92,9 @@ max-width: 500px; } } + img.pixel { + margin: 0; + } a { color: #f2f2f2 !important; /* Fix gmail overriding link colors with !important */ @@ -79,13 +109,25 @@ +
      + +

      {{ post.title }}

      {{ post.content_rendered | safe }} -
      + diff --git a/templates/post-sent.tera.html b/templates/post-sent.tera.html index 6ba69087..ccea27e8 100644 --- a/templates/post-sent.tera.html +++ b/templates/post-sent.tera.html @@ -13,10 +13,6 @@

      {{ post.title }}

      • Sent {{ stats.num_sent }} emails to list "{{ list.name }}"
      • - {% if stats.num_skipped > 0 %} -
      • Skipped {{ stats.num_skipped }} emails which were already sent
      • - {% endif %} - {%if stats.errors | length > 0 %}
      • Failed to send {{ stats.errors | length }} emails From f43cb739ff3f4ba1d6b4f408a47863bac0053c0c Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 21 Jan 2025 12:34:23 -0500 Subject: [PATCH 21/89] style tweaks --- src/app/emails.rs | 9 +++------ templates/post-email.tera.html | 17 +++++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/app/emails.rs b/src/app/emails.rs index aec6b2fc..946c9b7c 100644 --- a/src/app/emails.rs +++ b/src/app/emails.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, - response::Response, + response::{IntoResponse, Response}, routing::get, }; @@ -27,16 +27,13 @@ async fn email_opened(Path(id): Path, State(state): State) Ok(pixel) } -async fn email_unsubscribed( - Path(id): Path, - State(state): State, -) -> AppResult { +async fn email_unsubscribed(Path(id): Path, State(state): State) -> AppResult { if let Some(email) = Email::lookup(&state.db, id).await? { if let Some(list_id) = email.list_id { List::remove_member(&state.db, list_id, &email.address).await?; } } - Ok(StatusCode::OK) + Ok("You have been unsubscribed.".into_response()) } /// A 1x1 transparent GIF. diff --git a/templates/post-email.tera.html b/templates/post-email.tera.html index a49746b0..f2d3c75c 100644 --- a/templates/post-email.tera.html +++ b/templates/post-email.tera.html @@ -27,7 +27,7 @@ letter-spacing: 1.05px; } - nav { + div.nav { max-width: 768px; margin: 0 auto; padding: 0 1rem; @@ -92,7 +92,7 @@ max-width: 500px; } } - img.pixel { + img.opened { margin: 0; } @@ -110,24 +110,25 @@
        - +

      {{ post.title }}

      {{ post.content_rendered | safe }} +
      From a3c5d8c3b495f6c6b331b332f5fdb03ccd722e4a Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 21 Jan 2025 13:19:24 -0500 Subject: [PATCH 22/89] homepage tweaks --- src/app/auth.rs | 33 ++++++++----- templates/home.tera.html | 99 ++++++++++++++++++++++++--------------- templates/login.tera.html | 18 +++++++ 3 files changed, 101 insertions(+), 49 deletions(-) create mode 100644 templates/login.tera.html diff --git a/src/app/auth.rs b/src/app/auth.rs index 065d0758..3849f1a9 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -130,21 +130,30 @@ async fn login_link( State(state): State, Query(query): Query, ) -> AppResult { - let Some(user) = User::lookup_by_login_token(&state.db, &query.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - - let token = SessionToken::create(&state.db, user.id).await?; - let headers = ( - // TODO: expiration date - [(header::SET_COOKIE, format!("session={token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), - ); - Ok(headers.into_response()) + match query.token { + Some(token) => { + let Some(user) = User::lookup_by_login_token(&state.db, &token).await? else { + return Ok(StatusCode::FORBIDDEN.into_response()); + }; + + let token = SessionToken::create(&state.db, user.id).await?; + let headers = ( + // TODO: expiration date + [(header::SET_COOKIE, format!("session={token}; Secure; Secure"))], + Redirect::to(&state.config.app.url), + ); + Ok(headers.into_response()) + } + None => { + let ctx = tera::Context::new(); + let html = state.templates.render("login.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) + } + } } #[derive(serde::Deserialize)] struct LoginQuery { - token: String, + token: Option, } /// Display the registration page. diff --git a/templates/home.tera.html b/templates/home.tera.html index cb9616c0..d6250948 100644 --- a/templates/home.tera.html +++ b/templates/home.tera.html @@ -1,37 +1,62 @@ - - - - - - - WLSD - - - -
      -

      {{ message }}

      - {% if user %} -

      Welcome, {{ user.first_name }} {{ user.last_name }}

      - {% else %} -
      - - - -
      - {% endif %} -
      - - - +{% import "page.tera.html" as page %} +{{ page::start(title="light and sound") }} + +

      Coming Soon...

      +

      Coming Soon...

      +{{ page::end() }} diff --git a/templates/login.tera.html b/templates/login.tera.html new file mode 100644 index 00000000..f29a4e69 --- /dev/null +++ b/templates/login.tera.html @@ -0,0 +1,18 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="light and sound - login") }} + +
      +
      + + +
      +
      + +
      +
      +{{ page::end() }} From 2ec7166489cb564682c57c0122259483cdfdf039 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 21 Jan 2025 13:34:36 -0500 Subject: [PATCH 23/89] tracking tweaks --- src/app/emails.rs | 2 +- src/app/posts.rs | 2 +- templates/post-email.tera.html | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/app/emails.rs b/src/app/emails.rs index 946c9b7c..228bae59 100644 --- a/src/app/emails.rs +++ b/src/app/emails.rs @@ -13,7 +13,7 @@ use crate::{ /// Add all `email` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { router - .route("/emails/{id}/opened.gif", get(email_opened)) + .route("/emails/{id}/footer.gif", get(email_opened)) .route("/emails/{id}/unsubscribe", get(email_unsubscribed)) } diff --git a/src/app/posts.rs b/src/app/posts.rs index 45b2aab0..3042ca71 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -159,7 +159,7 @@ async fn send_post_form( for ListMember { email, .. } in &members { let email_id = Email::create_post(&state.db, email, post.id, list.id).await?; - ctx.insert("opened_url", &format!("{}/emails/{email_id}/opened.gif", &state.config.app.url)); + ctx.insert("opened_url", &format!("{}/emails/{email_id}/footer.gif", &state.config.app.url)); ctx.insert("unsub_url", &format!("{}/emails/{email_id}/unsubscribe", &state.config.app.url)); let html = state.templates.render("post-email.tera.html", &ctx).unwrap(); diff --git a/templates/post-email.tera.html b/templates/post-email.tera.html index f2d3c75c..bbaedd09 100644 --- a/templates/post-email.tera.html +++ b/templates/post-email.tera.html @@ -92,9 +92,6 @@ max-width: 500px; } } - img.opened { - margin: 0; - } a { color: #f2f2f2 !important; /* Fix gmail overriding link colors with !important */ @@ -119,7 +116,7 @@

      {{ post.title }}

      {{ post.content_rendered | safe }} - + footer
      From a222ab05fea173016a12fefb47ccd37fd7b1a656 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 21 Jan 2025 14:00:13 -0500 Subject: [PATCH 24/89] email ratelimit, skipping --- config/prod.toml | 1 + src/app/posts.rs | 64 +++++++++++++++++++++-------------- src/db/email.rs | 14 ++++++++ src/utils/config.rs | 2 ++ templates/post-sent.tera.html | 6 +++- 5 files changed, 60 insertions(+), 27 deletions(-) diff --git a/config/prod.toml b/config/prod.toml index 4f1dc4cb..2916f3e5 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -21,3 +21,4 @@ smtp_addr = "smtp://email-smtp.us-east-1.amazonaws.com?tls=required" smtp_username = "$SMTP_USERNAME" smtp_password = "$SMTP_PASSWORD" from = "Light and Sound Design " +ratelimit = 10 diff --git a/src/app/posts.rs b/src/app/posts.rs index 3042ca71..f95da073 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration}; use axum::{ extract::{Path, State}, @@ -9,6 +9,7 @@ use axum::{ }; use chrono::Utc; use lettre::message::header::ContentType; +use tokio::time::sleep; use crate::db::{ email::Email, @@ -155,40 +156,50 @@ async fn send_post_form( ctx.insert("post_url", &format!("{}/p/{}", &state.config.app.url, &post.url)); let mut num_sent = 0; + let mut num_skipped = 0; let mut errors = HashMap::new(); - for ListMember { email, .. } in &members { - let email_id = Email::create_post(&state.db, email, post.id, list.id).await?; - - ctx.insert("opened_url", &format!("{}/emails/{email_id}/footer.gif", &state.config.app.url)); - ctx.insert("unsub_url", &format!("{}/emails/{email_id}/unsubscribe", &state.config.app.url)); - let html = state.templates.render("post-email.tera.html", &ctx).unwrap(); - - let msg = state - .mailer - .builder() - .to(email.parse().unwrap()) - .subject(&post.title) - .header(ContentType::TEXT_HTML) - .body(html) - .unwrap(); - - match state.mailer.send(msg).await { - Ok(_) => { - Email::mark_sent(&state.db, email_id).await?; - num_sent += 1; + let batch_size = state.config.email.ratelimit.unwrap_or(members.len()); + for members in members.chunks(batch_size) { + for ListMember { email, .. } in members { + // If this post was already sent to this address in this list, skip sending it again. + if Email::lookup_post(&state.db, email, post.id, list.id).await?.is_some() { + num_skipped += 1; + continue; } - Err(e) => { - let e = e.to_string(); - Email::mark_error(&state.db, email_id, &e).await?; - errors.insert(email.clone(), e); + let email_id = Email::create_post(&state.db, email, post.id, list.id).await?; + + ctx.insert("opened_url", &format!("{}/emails/{email_id}/footer.gif", &state.config.app.url)); + ctx.insert("unsub_url", &format!("{}/emails/{email_id}/unsubscribe", &state.config.app.url)); + let html = state.templates.render("post-email.tera.html", &ctx).unwrap(); + + let msg = state + .mailer + .builder() + .to(email.parse().unwrap()) + .subject(&post.title) + .header(ContentType::TEXT_HTML) + .body(html) + .unwrap(); + + match state.mailer.send(msg).await { + Ok(_) => { + Email::mark_sent(&state.db, email_id).await?; + num_sent += 1; + } + Err(e) => { + let e = e.to_string(); + Email::mark_error(&state.db, email_id, &e).await?; + errors.insert(email.clone(), e); + } } } + sleep(Duration::from_secs(1)).await; } let mut ctx = tera::Context::new(); ctx.insert("post", &post); ctx.insert("list", &list); - ctx.insert("stats", &Stats { num_sent, errors }); + ctx.insert("stats", &Stats { num_sent, num_skipped, errors }); let html = state.templates.render("post-sent.tera.html", &ctx).unwrap(); Ok(Html(html).into_response()) @@ -200,5 +211,6 @@ struct SendPost { #[derive(serde::Serialize)] struct Stats { pub num_sent: usize, + pub num_skipped: usize, pub errors: HashMap, } diff --git a/src/db/email.rs b/src/db/email.rs index 3154bb92..e1f31109 100644 --- a/src/db/email.rs +++ b/src/db/email.rs @@ -52,6 +52,20 @@ impl Email { Ok(res) } + /// Lookup an email by address, post, and list. + pub async fn lookup_post(db: &Db, address: &str, post_id: i64, list_id: i64) -> Result> { + let res = sqlx::query_as::<_, Email>( + "SELECT * FROM emails \ + WHERE address = ? AND post_id = ? AND list_id = ?", + ) + .bind(address) + .bind(post_id) + .bind(list_id) + .fetch_optional(db) + .await?; + Ok(res) + } + /// Create a new email record. pub async fn create_login(db: &Db, address: &str) -> Result { let res = sqlx::query("INSERT INTO emails (kind, address) VALUES (?, ?)") diff --git a/src/utils/config.rs b/src/utils/config.rs index 2fa805f8..b1a91d94 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -75,4 +75,6 @@ pub struct EmailConfig { pub smtp_password: Option, /// Mailbox to send email from. pub from: Mailbox, + /// Maximum number of emails to send per second. + pub ratelimit: Option, } diff --git a/templates/post-sent.tera.html b/templates/post-sent.tera.html index ccea27e8..c219e1a3 100644 --- a/templates/post-sent.tera.html +++ b/templates/post-sent.tera.html @@ -13,7 +13,11 @@

      {{ post.title }}

      • Sent {{ stats.num_sent }} emails to list "{{ list.name }}"
      • - {%if stats.errors | length > 0 %} + {% if stats.num_skipped > 0 %} +
      • Skipped sending {{ stats.num_skipped }} emails which were already delivered
      • + {% endif %} + + {% if stats.errors | length > 0 %}
      • Failed to send {{ stats.errors | length }} emails
          From 172641a8e5bcb0fd6e4c5dc2a852f222f2bf475b Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Mon, 3 Mar 2025 19:52:40 -0500 Subject: [PATCH 25/89] post: remove content_rendered --- src/db/migration.rs | 1 + src/db/post.rs | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/db/migration.rs b/src/db/migration.rs index 5bce5ec0..315bcda2 100644 --- a/src/db/migration.rs +++ b/src/db/migration.rs @@ -23,6 +23,7 @@ impl Migration { Ok(()) } + #[allow(unused)] pub async fn run(db: &Db, name: &str, func: impl Future>) -> Result<()> { let id = sqlx::query("SELECT id FROM migrations WHERE name = ?") .bind(name) diff --git a/src/db/post.rs b/src/db/post.rs index 6614a01b..2c539434 100644 --- a/src/db/post.rs +++ b/src/db/post.rs @@ -42,14 +42,6 @@ impl Post { .execute(db) .await?; - Migration::run(db, "posts: add content_rendered", async { - sqlx::query("ALTER TABLE posts ADD COLUMN content_rendered TEXT NOT NULL DEFAULT ''") - .execute(db) - .await?; - Ok(()) - }) - .await?; - Ok(()) } From 1df5ab4d963510a7b61e511491f2f581fad15741 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Mon, 3 Mar 2025 22:08:45 -0500 Subject: [PATCH 26/89] post-edit: validate title, url, author --- templates/post-edit.tera.html | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html index 9b7cc266..7da6f079 100644 --- a/templates/post-edit.tera.html +++ b/templates/post-edit.tera.html @@ -144,7 +144,15 @@

          {{ post.title }}

          document.querySelectorAll('input, textarea') .forEach(e => e.addEventListener('input', () => modified = true)); document.querySelector('form') - .addEventListener('submit', () => modified = false); + .addEventListener('submit', e => { + for (const field of ['title', 'url', 'author']) { + if (!document.getElementById(field).value) { + alert(`Missing ${field}.`); + return e.preventDefault(); + } + } + modified = false; + }); window.addEventListener('beforeunload', (e) => { if (modified) { e.preventDefault(); From 850b76b5fc340cdbc75a6ba8b79a8feac7321ae9 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 9 Mar 2025 23:52:53 -0400 Subject: [PATCH 27/89] add post list page --- src/app/posts.rs | 34 ++++++++++++++++- src/db/post.rs | 14 +++++++ templates/page.tera.html | 26 ++++++++----- templates/post-list.tera.html | 70 +++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 templates/post-list.tera.html diff --git a/src/app/posts.rs b/src/app/posts.rs index f95da073..677ac3e4 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -4,7 +4,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, - routing::get, + routing::{get, post}, Form, }; use chrono::Utc; @@ -22,10 +22,23 @@ use crate::utils::types::{AppResult, AppRouter, SharedAppState}; /// Add all `post` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { router + .route("/posts", get(list_posts_page)) .route("/p/new", get(create_post_page)) .route("/p/{url}", get(view_post_page)) .route("/p/{url}/edit", get(edit_post_page).post(edit_post_form)) .route("/p/{url}/send", get(send_post_page).post(send_post_form)) + .route("/p/{url}/delete", post(delete_post_form)) +} + +/// Display a list of posts. +async fn list_posts_page(State(state): State) -> AppResult { + let posts = Post::list(&state.db).await?; + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + + let html = state.templates.render("post-list.tera.html", &ctx).unwrap(); + Ok(Html(html).into_response()) } /// Display a single post. @@ -214,3 +227,22 @@ struct Stats { pub num_skipped: usize, pub errors: HashMap, } + +/// Process the form and create or edit a post. +async fn delete_post_form( + State(state): State, + user: User, + Path(url): Path, +) -> AppResult { + if !user.has_role(&state.db, User::WRITER).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Ok(StatusCode::NOT_FOUND.into_response()); + }; + + Post::delete(&state.db, post.id).await?; + + // Redirect to the list page. + Ok(Redirect::to("/posts").into_response()) +} diff --git a/src/db/post.rs b/src/db/post.rs index 2c539434..c2edf64f 100644 --- a/src/db/post.rs +++ b/src/db/post.rs @@ -45,6 +45,14 @@ impl Post { Ok(()) } + // List all posts. + pub async fn list(db: &Db) -> Result> { + let posts = sqlx::query_as::<_, Post>("SELECT * FROM posts ORDER BY updated_at DESC") + .fetch_all(db) + .await?; + Ok(posts) + } + /// Create a new post. pub async fn create(db: &Db, post: &UpdatePost) -> Result { let row = sqlx::query( @@ -85,6 +93,12 @@ impl Post { Ok(()) } + /// Delete a post. + pub async fn delete(db: &Db, id: i64) -> Result<()> { + sqlx::query("DELETE FROM posts WHERE id = ?").bind(id).execute(db).await?; + Ok(()) + } + /// Lookup a post by URL, if one exists. pub async fn lookup_by_url(db: &Db, url: &str) -> Result> { let row = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE url = ?") diff --git a/templates/page.tera.html b/templates/page.tera.html index e2309f76..084b1bc5 100644 --- a/templates/page.tera.html +++ b/templates/page.tera.html @@ -51,13 +51,6 @@ margin: 0 auto; padding: 1rem; - h1 { - margin-bottom: 0.5rem; - font-size: 1.9rem; - font-weight: 900; - line-height: 2.2rem; - } - time { display: block; margin-bottom: 2rem; @@ -102,22 +95,37 @@ color: var(--color-text-muted); } */ + h1 { + margin-bottom: 0.5rem; + font-size: 1.9rem; + font-weight: 900; + line-height: 2.2rem; + } + h2 { + margin-bottom: 0.3rem; + font-size: 1.2rem; + font-weight: 700; + } a { color: var(--color-text); text-decoration: underline; + line-height: normal; } a:hover { color: var(--color-text-muted); } - button { + button, .button { + font-size: 1rem; + display: block; padding: 0.5rem 1rem; cursor: pointer; background: var(--color-button); color: var(--color-text); border: none; border-radius: 4px; + text-decoration: none; } - button:hover { + button:hover, .button:hover { background: var(--color-button-hover); } ul { diff --git a/templates/post-list.tera.html b/templates/post-list.tera.html new file mode 100644 index 00000000..dd89b5eb --- /dev/null +++ b/templates/post-list.tera.html @@ -0,0 +1,70 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Posts") }} + +
          +
          +

          Posts

          + New Post +
          + {% for post in posts %} +
          +

          {{ post.title }}

          +
          + By {{ post.author }} • Updated + +
          +
          + Edit + +
          + +
          +
          +
          + {% endfor %} +
          +{{ page::end() }} From 908b0573042ea3356e222f7471422bd8c002dbc8 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Mon, 10 Mar 2025 00:32:53 -0400 Subject: [PATCH 28/89] post-edit: toggleable preview --- templates/post-edit.tera.html | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html index 7da6f079..b948fd9d 100644 --- a/templates/post-edit.tera.html +++ b/templates/post-edit.tera.html @@ -26,7 +26,7 @@ background-color: var(--color-bg-alt); border: 1px solid var(--color-border); - span { + .origin { color: var(--color-text-muted); white-space: nowrap; font-family: monospace; @@ -54,6 +54,10 @@ flex: 1; overflow-y: auto; } + .preview.hidden { + flex: 0; + width: 0; + }
          @@ -67,7 +71,7 @@
          - https://lightandsound.design/p/ +
          @@ -84,6 +88,7 @@ +
          @@ -95,8 +100,18 @@

          {{ post.title }}

      + -
      -
      - {% if post.id != 0 %} - - {% endif %} -
      - - -
      -
      - -
      - - -
      -
      -
      - - -
      -
      - - -
      - -
      - - - - - - No changes - - -
      -
      -
      -
      -

      {{ post.title }}

      - +
      + {% if post.id != 0 %} + + {% endif %} +
      + + +
      +
      + +
      + +
      -
      - {{ page::end() }} diff --git a/templates/post-email.tera.html b/templates/post-email.tera.html index bbaedd09..1528bf9e 100644 --- a/templates/post-email.tera.html +++ b/templates/post-email.tera.html @@ -115,7 +115,7 @@

      {{ post.title }}

      - {{ post.content_rendered | safe }} + {{ post.content | safe }} footer
      diff --git a/templates/post.tera.html b/templates/post.tera.html index 08e08a55..945e73ec 100644 --- a/templates/post.tera.html +++ b/templates/post.tera.html @@ -3,6 +3,6 @@

      {{ post.title }}

      - {{ post.content_rendered | safe }} + {{ post.content | safe }}
      {{ page::end() }} diff --git a/templates/test.css b/templates/test.css new file mode 100644 index 00000000..c700cf5e --- /dev/null +++ b/templates/test.css @@ -0,0 +1,29 @@ +.pell { + border: 1px solid hsla(0, 0%, 4%, 0.1); +} +.pell, +.pell-content { + box-sizing: border-box; +} +.pell-content { + height: 300px; + outline: 0; + overflow-y: auto; + padding: 10px; +} +.pell-actionbar { + background-color: #fff; + border-bottom: 1px solid hsla(0, 0%, 4%, 0.1); +} +.pell-button { + background-color: transparent; + border: none; + cursor: pointer; + height: 30px; + outline: 0; + width: 30px; + vertical-align: bottom; +} +.pell-button-selected { + background-color: #f0f0f0; +} From 2e1777ceb51632be7107107dce9096789f74e16c Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sat, 29 Mar 2025 17:49:47 -0400 Subject: [PATCH 33/89] posts: errata --- src/app/posts.rs | 6 +++++- templates/post-edit.tera.html | 9 +++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/posts.rs b/src/app/posts.rs index 4917b3b8..99d19bc6 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -31,7 +31,11 @@ pub fn register_routes(router: AppRouter) -> AppRouter { } /// Display a list of posts. -async fn list_posts_page(State(state): State) -> AppResult { +async fn list_posts_page(State(state): State, user: User) -> AppResult { + if !user.has_role(&state.db, User::WRITER).await? { + return Ok(StatusCode::FORBIDDEN.into_response()); + } + let posts = Post::list(&state.db).await?; let mut ctx = tera::Context::new(); diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html index f9b6e93d..eb6304b5 100644 --- a/templates/post-edit.tera.html +++ b/templates/post-edit.tera.html @@ -166,7 +166,7 @@ async function setSaved() { modified = false; - status.textContent = 'Changes unsaved'; + status.textContent = 'Changes saved'; status.classList.remove('unsaved'); status.classList.remove('error'); } @@ -194,12 +194,13 @@ } try { - let form = new FormData(document.querySelector('form')); + const formEl = document.querySelector('form'); + let form = new FormData(formEl); const content = document.querySelector('.pell-content').innerHTML; form.append('content', content); - const response = await fetch(form.getAttribute('action'), { + const response = await fetch(formEl.getAttribute('action'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams(form), @@ -213,7 +214,7 @@ const currentUrl = window.location.pathname.split('/')[2]; if (newUrl !== currentUrl) { history.pushState({}, '', `/p/${newUrl}/edit`); - form.action = `/p/${newUrl}/edit`; + formEl.action = `/p/${newUrl}/edit`; } } else { setError(); From edb48a48a4180a3959415a1374d19915fb1c3ab3 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Mon, 31 Mar 2025 15:28:10 -0400 Subject: [PATCH 34/89] Use built-in sqlx migration support instead of custom solution --- .gitignore | 1 + migrations/0001_create_emails.down.sql | 1 + migrations/0001_create_emails.up.sql | 11 +++++ migrations/0002_create_events.down.sql | 1 + migrations/0002_create_events.up.sql | 9 ++++ migrations/0003_create_lists.down.sql | 1 + migrations/0003_create_lists.up.sql | 7 +++ migrations/0004_create_list_members.down.sql | 1 + migrations/0004_create_list_members.up.sql | 6 +++ migrations/0005_create_posts.down.sql | 1 + migrations/0005_create_posts.up.sql | 10 +++++ migrations/0006_create_login_tokens.down.sql | 1 + migrations/0006_create_login_tokens.up.sql | 6 +++ .../0007_create_session_tokens.down.sql | 1 + migrations/0007_create_session_tokens.up.sql | 7 +++ migrations/0008_create_users.down.sql | 1 + migrations/0008_create_users.up.sql | 7 +++ migrations/0009_create_user_roles.down.sql | 1 + migrations/0009_create_user_roles.up.sql | 6 +++ ...ove_column_content_rendered_posts.down.sql | 2 + ...emove_column_content_rendered_posts.up.sql | 2 + src/db/email.rs | 20 --------- src/db/event.rs | 18 -------- src/db/list.rs | 28 ------------ src/db/migration.rs | 44 ------------------- src/db/mod.rs | 10 +---- src/db/post.rs | 31 +------------ src/db/token.rs | 31 ------------- src/db/user.rs | 28 ------------ 29 files changed, 85 insertions(+), 208 deletions(-) create mode 100644 migrations/0001_create_emails.down.sql create mode 100644 migrations/0001_create_emails.up.sql create mode 100644 migrations/0002_create_events.down.sql create mode 100644 migrations/0002_create_events.up.sql create mode 100644 migrations/0003_create_lists.down.sql create mode 100644 migrations/0003_create_lists.up.sql create mode 100644 migrations/0004_create_list_members.down.sql create mode 100644 migrations/0004_create_list_members.up.sql create mode 100644 migrations/0005_create_posts.down.sql create mode 100644 migrations/0005_create_posts.up.sql create mode 100644 migrations/0006_create_login_tokens.down.sql create mode 100644 migrations/0006_create_login_tokens.up.sql create mode 100644 migrations/0007_create_session_tokens.down.sql create mode 100644 migrations/0007_create_session_tokens.up.sql create mode 100644 migrations/0008_create_users.down.sql create mode 100644 migrations/0008_create_users.up.sql create mode 100644 migrations/0009_create_user_roles.down.sql create mode 100644 migrations/0009_create_user_roles.up.sql create mode 100644 migrations/0010_remove_column_content_rendered_posts.down.sql create mode 100644 migrations/0010_remove_column_content_rendered_posts.up.sql delete mode 100644 src/db/migration.rs diff --git a/.gitignore b/.gitignore index 579ab3ae..2291ecb2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ Cargo.lock *.sqlite *.sqlite-* +.DS_Store \ No newline at end of file diff --git a/migrations/0001_create_emails.down.sql b/migrations/0001_create_emails.down.sql new file mode 100644 index 00000000..bb5088d5 --- /dev/null +++ b/migrations/0001_create_emails.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS emails; \ No newline at end of file diff --git a/migrations/0001_create_emails.up.sql b/migrations/0001_create_emails.up.sql new file mode 100644 index 00000000..ee0d4d5d --- /dev/null +++ b/migrations/0001_create_emails.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY NOT NULL, + kind TEXT NOT NULL, + address TEXT NOT NULL, + post_id INTEGER, + list_id INTEGER, + error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP, + opened_at TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0002_create_events.down.sql b/migrations/0002_create_events.down.sql new file mode 100644 index 00000000..bfd996dc --- /dev/null +++ b/migrations/0002_create_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS events; \ No newline at end of file diff --git a/migrations/0002_create_events.up.sql b/migrations/0002_create_events.up.sql new file mode 100644 index 00000000..6382652e --- /dev/null +++ b/migrations/0002_create_events.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + description TEXT NOT NULL, + start_date TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0003_create_lists.down.sql b/migrations/0003_create_lists.down.sql new file mode 100644 index 00000000..d9b740f0 --- /dev/null +++ b/migrations/0003_create_lists.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS lists; \ No newline at end of file diff --git a/migrations/0003_create_lists.up.sql b/migrations/0003_create_lists.up.sql new file mode 100644 index 00000000..61b7b341 --- /dev/null +++ b/migrations/0003_create_lists.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0004_create_list_members.down.sql b/migrations/0004_create_list_members.down.sql new file mode 100644 index 00000000..478b7b2c --- /dev/null +++ b/migrations/0004_create_list_members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS list_members; \ No newline at end of file diff --git a/migrations/0004_create_list_members.up.sql b/migrations/0004_create_list_members.up.sql new file mode 100644 index 00000000..b6dc42cc --- /dev/null +++ b/migrations/0004_create_list_members.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS list_members ( + list_id INTEGER NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, email) +); \ No newline at end of file diff --git a/migrations/0005_create_posts.down.sql b/migrations/0005_create_posts.down.sql new file mode 100644 index 00000000..52ac968d --- /dev/null +++ b/migrations/0005_create_posts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS posts; \ No newline at end of file diff --git a/migrations/0005_create_posts.up.sql b/migrations/0005_create_posts.up.sql new file mode 100644 index 00000000..d1a1b7e4 --- /dev/null +++ b/migrations/0005_create_posts.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + author TEXT NOT NULL, + content TEXT NOT NULL, + content_rendered TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0006_create_login_tokens.down.sql b/migrations/0006_create_login_tokens.down.sql new file mode 100644 index 00000000..d2f607c5 --- /dev/null +++ b/migrations/0006_create_login_tokens.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/migrations/0006_create_login_tokens.up.sql b/migrations/0006_create_login_tokens.up.sql new file mode 100644 index 00000000..db2ad200 --- /dev/null +++ b/migrations/0006_create_login_tokens.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS login_tokens ( + id INTEGER PRIMARY KEY NOT NULL, + email TEXT NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0007_create_session_tokens.down.sql b/migrations/0007_create_session_tokens.down.sql new file mode 100644 index 00000000..918799d3 --- /dev/null +++ b/migrations/0007_create_session_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS session_tokens; \ No newline at end of file diff --git a/migrations/0007_create_session_tokens.up.sql b/migrations/0007_create_session_tokens.up.sql new file mode 100644 index 00000000..46f48fbb --- /dev/null +++ b/migrations/0007_create_session_tokens.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS session_tokens ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/migrations/0008_create_users.down.sql b/migrations/0008_create_users.down.sql new file mode 100644 index 00000000..365a2107 --- /dev/null +++ b/migrations/0008_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/migrations/0008_create_users.up.sql b/migrations/0008_create_users.up.sql new file mode 100644 index 00000000..12b8b1e1 --- /dev/null +++ b/migrations/0008_create_users.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0009_create_user_roles.down.sql b/migrations/0009_create_user_roles.down.sql new file mode 100644 index 00000000..a78eae2a --- /dev/null +++ b/migrations/0009_create_user_roles.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_roles; \ No newline at end of file diff --git a/migrations/0009_create_user_roles.up.sql b/migrations/0009_create_user_roles.up.sql new file mode 100644 index 00000000..5d87fdf9 --- /dev/null +++ b/migrations/0009_create_user_roles.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role) +); \ No newline at end of file diff --git a/migrations/0010_remove_column_content_rendered_posts.down.sql b/migrations/0010_remove_column_content_rendered_posts.down.sql new file mode 100644 index 00000000..4b12b055 --- /dev/null +++ b/migrations/0010_remove_column_content_rendered_posts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts ADD content_rendered TEXT NOT NULL; +UPDATE posts SET content_rendered = content; \ No newline at end of file diff --git a/migrations/0010_remove_column_content_rendered_posts.up.sql b/migrations/0010_remove_column_content_rendered_posts.up.sql new file mode 100644 index 00000000..5e6804b4 --- /dev/null +++ b/migrations/0010_remove_column_content_rendered_posts.up.sql @@ -0,0 +1,2 @@ +UPDATE posts SET content = content_rendered; +ALTER TABLE posts DROP COLUMN content_rendered; \ No newline at end of file diff --git a/src/db/email.rs b/src/db/email.rs index e1f31109..efe211d0 100644 --- a/src/db/email.rs +++ b/src/db/email.rs @@ -23,26 +23,6 @@ impl Email { /// An email containing a post. pub const POST: &'static str = "post"; - /// Create the `emails` table. - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS emails ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - kind TEXT NOT NULL, \ - address TEXT NOT NULL, \ - post_id INTEGER, \ - list_id INTEGER, \ - error TEXT, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - sent_at TIMESTAMP, \ - opened_at TIMESTAMP \ - )", - ) - .execute(db) - .await?; - Ok(()) - } - /// Lookup an email by id. pub async fn lookup(db: &Db, id: i64) -> Result> { let res = sqlx::query_as::<_, Email>("SELECT * FROM emails WHERE id = ?") diff --git a/src/db/event.rs b/src/db/event.rs index 8b0c9f01..5665963e 100644 --- a/src/db/event.rs +++ b/src/db/event.rs @@ -27,24 +27,6 @@ pub struct UpdateEvent { } impl Event { - /// Create the `events` table. - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS events ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - title TEXT NOT NULL, \ - artist TEXT NOT NULL, \ - description TEXT NOT NULL, \ - start_date TIMESTAMP NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(db) - .await?; - Ok(()) - } - // List all events. pub async fn list(db: &Db) -> Result> { let events = sqlx::query_as::<_, Event>("SELECT * FROM events").fetch_all(db).await?; diff --git a/src/db/list.rs b/src/db/list.rs index 3ca1296e..9b68b558 100644 --- a/src/db/list.rs +++ b/src/db/list.rs @@ -29,34 +29,6 @@ pub struct UpdateList { } impl List { - /// Create the `events` table. - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS lists ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - name TEXT NOT NULL, \ - description TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(db) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS list_members ( \ - list_id INTEGER NOT NULL, \ - email TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - PRIMARY KEY (list_id, email) - )", - ) - .execute(db) - .await?; - - Ok(()) - } - /// List all lists. pub async fn list(db: &Db) -> Result> { let events = sqlx::query_as::<_, List>("SELECT * FROM lists").fetch_all(db).await?; diff --git a/src/db/migration.rs b/src/db/migration.rs deleted file mode 100644 index 315bcda2..00000000 --- a/src/db/migration.rs +++ /dev/null @@ -1,44 +0,0 @@ -use anyhow::Result; -use std::future::Future; - -use super::Db; - -/// A record of a database migration -#[derive(Debug, sqlx::FromRow, serde::Serialize)] -pub struct Migration { - id: i64, - name: String, -} - -impl Migration { - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS migrations ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - name TEXT NOT NULL \ - )", - ) - .execute(db) - .await?; - Ok(()) - } - - #[allow(unused)] - pub async fn run(db: &Db, name: &str, func: impl Future>) -> Result<()> { - let id = sqlx::query("SELECT id FROM migrations WHERE name = ?") - .bind(name) - .fetch_optional(db) - .await?; - - if id.is_none() { - tracing::info!("Running migration {name:?}"); - func.await?; - sqlx::query("INSERT INTO migrations (name) VALUES (?)") - .bind(name) - .execute(db) - .await?; - } - - Ok(()) - } -} diff --git a/src/db/mod.rs b/src/db/mod.rs index 6c8e1dd9..9e6c781c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -7,7 +7,6 @@ pub type Db = SqlitePool; pub mod email; pub mod event; pub mod list; -pub mod migration; pub mod post; pub mod token; pub mod user; @@ -20,14 +19,7 @@ pub async fn init(file: &Path) -> Result { } let db = SqlitePool::connect(&url).await?; - migration::Migration::migrate(&db).await?; - user::User::migrate(&db).await?; - token::SessionToken::migrate(&db).await?; - token::LoginToken::migrate(&db).await?; - post::Post::migrate(&db).await?; - event::Event::migrate(&db).await?; - list::List::migrate(&db).await?; - email::Email::migrate(&db).await?; + sqlx::migrate!("./migrations"); Ok(db) } diff --git a/src/db/post.rs b/src/db/post.rs index 03ea1a8a..6815e321 100644 --- a/src/db/post.rs +++ b/src/db/post.rs @@ -1,7 +1,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; -use super::{migration::Migration, Db}; +use super::Db; #[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct Post { @@ -23,35 +23,6 @@ pub struct UpdatePost { } impl Post { - /// Create the `posts` table. - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS posts ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - title TEXT NOT NULL, \ - url TEXT NOT NULL, \ - author TEXT NOT NULL, \ - content TEXT NOT NULL, \ - content_rendered TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(db) - .await?; - - Migration::run(db, "posts: remove content_rendered", async { - sqlx::query("UPDATE posts SET content = content_rendered").execute(db).await?; - sqlx::query("ALTER TABLE posts DROP COLUMN content_rendered") - .execute(db) - .await?; - Ok(()) - }) - .await?; - - Ok(()) - } - // List all posts. pub async fn list(db: &Db) -> Result> { let posts = sqlx::query_as::<_, Post>("SELECT * FROM posts ORDER BY updated_at DESC") diff --git a/src/db/token.rs b/src/db/token.rs index d3005083..895d78d4 100644 --- a/src/db/token.rs +++ b/src/db/token.rs @@ -23,22 +23,6 @@ pub struct LoginToken { } impl SessionToken { - /// Create the `session_tokens` table. - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS session_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - user_id INTEGER NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - FOREIGN KEY (user_id) REFERENCES users(id) \ - )", - ) - .execute(db) - .await?; - Ok(()) - } - /// Create a new session token for a user. pub async fn create(db: &Db, user_id: i64) -> Result { let token = format!("{:08x}", OsRng.gen::()); @@ -54,21 +38,6 @@ impl SessionToken { } impl LoginToken { - /// Create the `login_tokens` table. - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS login_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - email TEXT NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(db) - .await?; - Ok(()) - } - /// Create a new login token for an email address. pub async fn create(db: &Db, email: &str) -> Result { let token = format!("{:08x}", OsRng.gen::()); diff --git a/src/db/user.rs b/src/db/user.rs index c1cbbbdb..abfff3c3 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -27,34 +27,6 @@ impl User { /// Can manage posts. pub const WRITER: &'static str = "writer"; - /// Create the `users` table. - pub async fn migrate(db: &Db) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - first_name TEXT NOT NULL, \ - last_name TEXT NOT NULL, \ - email TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(db) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS user_roles ( \ - user_id INTEGER NOT NULL, \ - role TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - PRIMARY KEY (user_id, role) \ - )", - ) - .execute(db) - .await?; - - Ok(()) - } - /// Create a new user. pub async fn create(db: &Db, user: &UpdateUser) -> Result { let row = sqlx::query( From 3a5d637038257e348bdb6844f66af7a679d093cb Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Sat, 29 Mar 2025 16:40:01 -0400 Subject: [PATCH 35/89] use sqlx macros for compile-time validity checks --- .env | 1 + migrations/0001_create_emails.up.sql | 2 +- migrations/0002_create_events.up.sql | 4 +- migrations/0003_create_lists.up.sql | 4 +- migrations/0004_create_list_members.up.sql | 2 +- migrations/0005_create_posts.up.sql | 4 +- migrations/0006_create_login_tokens.up.sql | 2 +- migrations/0007_create_session_tokens.up.sql | 2 +- migrations/0008_create_users.up.sql | 2 +- migrations/0009_create_user_roles.up.sql | 2 +- src/app/events.rs | 2 +- src/app/lists.rs | 4 +- src/app/posts.rs | 4 +- src/db/email.rs | 70 ++++++++++---------- src/db/event.rs | 61 ++++++++--------- src/db/list.rs | 70 ++++++++++---------- src/db/post.rs | 47 +++++++------ src/db/token.rs | 13 ++-- src/db/user.rs | 55 +++++++-------- 19 files changed, 176 insertions(+), 175 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 00000000..01727481 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL=sqlite://lsd.sqlite \ No newline at end of file diff --git a/migrations/0001_create_emails.up.sql b/migrations/0001_create_emails.up.sql index ee0d4d5d..195740f2 100644 --- a/migrations/0001_create_emails.up.sql +++ b/migrations/0001_create_emails.up.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS emails ( post_id INTEGER, list_id INTEGER, error TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, sent_at TIMESTAMP, opened_at TIMESTAMP ); \ No newline at end of file diff --git a/migrations/0002_create_events.up.sql b/migrations/0002_create_events.up.sql index 6382652e..9ff71f95 100644 --- a/migrations/0002_create_events.up.sql +++ b/migrations/0002_create_events.up.sql @@ -4,6 +4,6 @@ CREATE TABLE IF NOT EXISTS events ( artist TEXT NOT NULL, description TEXT NOT NULL, start_date TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/migrations/0003_create_lists.up.sql b/migrations/0003_create_lists.up.sql index 61b7b341..d5278b91 100644 --- a/migrations/0003_create_lists.up.sql +++ b/migrations/0003_create_lists.up.sql @@ -2,6 +2,6 @@ CREATE TABLE IF NOT EXISTS lists ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/migrations/0004_create_list_members.up.sql b/migrations/0004_create_list_members.up.sql index b6dc42cc..fe47caa3 100644 --- a/migrations/0004_create_list_members.up.sql +++ b/migrations/0004_create_list_members.up.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS list_members ( list_id INTEGER NOT NULL, email TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (list_id, email) ); \ No newline at end of file diff --git a/migrations/0005_create_posts.up.sql b/migrations/0005_create_posts.up.sql index d1a1b7e4..fa18fb7e 100644 --- a/migrations/0005_create_posts.up.sql +++ b/migrations/0005_create_posts.up.sql @@ -5,6 +5,6 @@ CREATE TABLE IF NOT EXISTS posts ( author TEXT NOT NULL, content TEXT NOT NULL, content_rendered TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/migrations/0006_create_login_tokens.up.sql b/migrations/0006_create_login_tokens.up.sql index db2ad200..ba078b25 100644 --- a/migrations/0006_create_login_tokens.up.sql +++ b/migrations/0006_create_login_tokens.up.sql @@ -2,5 +2,5 @@ CREATE TABLE IF NOT EXISTS login_tokens ( id INTEGER PRIMARY KEY NOT NULL, email TEXT NOT NULL, token TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/migrations/0007_create_session_tokens.up.sql b/migrations/0007_create_session_tokens.up.sql index 46f48fbb..796f0b4f 100644 --- a/migrations/0007_create_session_tokens.up.sql +++ b/migrations/0007_create_session_tokens.up.sql @@ -2,6 +2,6 @@ CREATE TABLE IF NOT EXISTS session_tokens ( id INTEGER PRIMARY KEY NOT NULL, user_id INTEGER NOT NULL, token TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); \ No newline at end of file diff --git a/migrations/0008_create_users.up.sql b/migrations/0008_create_users.up.sql index 12b8b1e1..f6d4b9ab 100644 --- a/migrations/0008_create_users.up.sql +++ b/migrations/0008_create_users.up.sql @@ -3,5 +3,5 @@ CREATE TABLE IF NOT EXISTS users ( first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/migrations/0009_create_user_roles.up.sql b/migrations/0009_create_user_roles.up.sql index 5d87fdf9..ad6a07cc 100644 --- a/migrations/0009_create_user_roles.up.sql +++ b/migrations/0009_create_user_roles.up.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL, role TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, role) ); \ No newline at end of file diff --git a/src/app/events.rs b/src/app/events.rs index 9403e78f..c5417249 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -27,7 +27,7 @@ async fn list_events_page( State(state): State, Query(query): Query, ) -> AppResult { - let now = Utc::now(); + let now = Utc::now().naive_utc(); let past = query.past.unwrap_or(false); let events = Event::list(&state.db) diff --git a/src/app/lists.rs b/src/app/lists.rs index b28a2da4..54f0ccad 100644 --- a/src/app/lists.rs +++ b/src/app/lists.rs @@ -78,8 +78,8 @@ async fn create_list_page(State(state): State, user: User) -> Ap id: 0, name: "".into(), description: "".into(), - created_at: Utc::now(), - updated_at: Utc::now(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), }, ); ctx.insert::<[String], _>("members", &[]); diff --git a/src/app/posts.rs b/src/app/posts.rs index 99d19bc6..f9241ad7 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -73,8 +73,8 @@ async fn create_post_page(State(state): State, user: User) -> Ap url: "".into(), author: "".into(), content: "".into(), - created_at: Utc::now(), - updated_at: Utc::now(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), }, ); diff --git a/src/db/email.rs b/src/db/email.rs index efe211d0..bcfabd14 100644 --- a/src/db/email.rs +++ b/src/db/email.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::NaiveDateTime; use super::Db; @@ -12,9 +12,9 @@ pub struct Email { pub post_id: Option, pub list_id: Option, pub error: Option, - pub created_at: DateTime, - pub sent_at: Option>, - pub opened_at: Option>, + pub created_at: NaiveDateTime, + pub sent_at: Option, + pub opened_at: Option, } impl Email { @@ -25,8 +25,7 @@ impl Email { /// Lookup an email by id. pub async fn lookup(db: &Db, id: i64) -> Result> { - let res = sqlx::query_as::<_, Email>("SELECT * FROM emails WHERE id = ?") - .bind(id) + let res = sqlx::query_as!(Self, r#"SELECT * FROM emails WHERE id = ?"#, id) .fetch_optional(db) .await?; Ok(res) @@ -34,13 +33,14 @@ impl Email { /// Lookup an email by address, post, and list. pub async fn lookup_post(db: &Db, address: &str, post_id: i64, list_id: i64) -> Result> { - let res = sqlx::query_as::<_, Email>( - "SELECT * FROM emails \ - WHERE address = ? AND post_id = ? AND list_id = ?", + let res = sqlx::query_as!( + Self, + r#"SELECT * FROM emails + WHERE address = ? AND post_id = ? AND list_id = ?"#, + address, + post_id, + list_id ) - .bind(address) - .bind(post_id) - .bind(list_id) .fetch_optional(db) .await?; Ok(res) @@ -48,9 +48,7 @@ impl Email { /// Create a new email record. pub async fn create_login(db: &Db, address: &str) -> Result { - let res = sqlx::query("INSERT INTO emails (kind, address) VALUES (?, ?)") - .bind(Email::LOGIN) - .bind(address) + let res = sqlx::query!("INSERT INTO emails (kind, address) VALUES (?, ?)", Email::LOGIN, address) .execute(db) .await?; Ok(res.last_insert_rowid()) @@ -58,23 +56,25 @@ impl Email { /// Create a new email record referencing another database entry. pub async fn create_post(db: &Db, address: &str, post_id: i64, list_id: i64) -> Result { - let res = sqlx::query("INSERT INTO emails (kind, address, post_id, list_id) VALUES (?, ?, ?, ?)") - .bind(Email::POST) - .bind(address) - .bind(post_id) - .bind(list_id) - .execute(db) - .await?; + let res = sqlx::query!( + r#"INSERT INTO emails (kind, address, post_id, list_id) VALUES (?, ?, ?, ?)"#, + Email::POST, + address, + post_id, + list_id + ) + .execute(db) + .await?; Ok(res.last_insert_rowid()) } /// Mark an email as sent. pub async fn mark_sent(db: &Db, id: i64) -> Result<()> { - sqlx::query( - "UPDATE emails SET sent_at = CURRENT_TIMESTAMP \ - WHERE id = ?", + sqlx::query!( + r#"UPDATE emails SET sent_at = CURRENT_TIMESTAMP + WHERE id = ?"#, + id ) - .bind(id) .execute(db) .await?; Ok(()) @@ -82,12 +82,12 @@ impl Email { /// Mark an email as sent. pub async fn mark_error(db: &Db, id: i64, error: &str) -> Result<()> { - sqlx::query( - "UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ? \ - WHERE id = ?", + sqlx::query!( + r#"UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ? + WHERE id = ?"#, + error, + id ) - .bind(error) - .bind(id) .execute(db) .await?; Ok(()) @@ -95,11 +95,11 @@ impl Email { /// Mark an email as opened. pub async fn mark_opened(db: &Db, id: i64) -> Result<()> { - sqlx::query( - "UPDATE emails SET opened_at = CURRENT_TIMESTAMP \ - WHERE id = ? AND opened_at IS NULL", + sqlx::query!( + r#"UPDATE emails SET opened_at = CURRENT_TIMESTAMP + WHERE id = ? AND opened_at IS NULL"#, + id ) - .bind(id) .execute(db) .await?; Ok(()) diff --git a/src/db/event.rs b/src/db/event.rs index 5665963e..825cf28a 100644 --- a/src/db/event.rs +++ b/src/db/event.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::NaiveDateTime; use super::Db; @@ -11,11 +11,11 @@ pub struct Event { pub title: String, pub artist: String, pub description: String, - pub start_date: DateTime, + pub start_date: NaiveDateTime, // TODO: Add an end. Maybe rename to just `start` and `end`. - // pub end_date: DateTime, - pub created_at: DateTime, - pub updated_at: DateTime, + // pub end_date: NaiveDateTime, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, } #[derive(serde::Deserialize)] @@ -23,27 +23,27 @@ pub struct UpdateEvent { pub title: String, pub artist: String, pub description: String, - pub start_date: DateTime, + pub start_date: NaiveDateTime, } impl Event { // List all events. pub async fn list(db: &Db) -> Result> { - let events = sqlx::query_as::<_, Event>("SELECT * FROM events").fetch_all(db).await?; + let events = sqlx::query_as!(Self, "SELECT * FROM events").fetch_all(db).await?; Ok(events) } // Create a new event. pub async fn create(db: &Db, event: &UpdateEvent) -> Result { - let row = sqlx::query( - "INSERT INTO events \ - (title, artist, description, start_date) \ - VALUES (?, ?, ?, ?)", + let row = sqlx::query!( + r#"INSERT INTO events + (title, artist, description, start_date) + VALUES (?, ?, ?, ?)"#, + event.title, + event.artist, + event.description, + event.start_date ) - .bind(&event.title) - .bind(&event.artist) - .bind(&event.description) - .bind(event.start_date) .execute(db) .await?; Ok(row.last_insert_rowid()) @@ -51,16 +51,16 @@ impl Event { // Update an event. pub async fn update(db: &Db, id: i64, event: &UpdateEvent) -> Result<()> { - sqlx::query( - "UPDATE events \ - SET title = ?, artist = ?, description = ?, start_date = ? \ - WHERE id = ?", + sqlx::query!( + r#"UPDATE events + SET title = ?, artist = ?, description = ?, start_date = ? + WHERE id = ?"#, + event.title, + event.artist, + event.description, + event.start_date, + id ) - .bind(&event.title) - .bind(&event.artist) - .bind(&event.description) - .bind(event.start_date) - .bind(id) .execute(db) .await?; Ok(()) @@ -68,18 +68,19 @@ impl Event { // Delete an event. pub async fn delete(db: &Db, id: i64) -> Result<()> { - sqlx::query("DELETE FROM events WHERE id = ?").bind(id).execute(db).await?; + sqlx::query!("DELETE FROM events WHERE id = ?", id).execute(db).await?; Ok(()) } // Lookup an event by id, if one exists. pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { - let event = sqlx::query_as::<_, Event>( - "SELECT e.* \ - FROM events e \ - WHERE id = ?", + let event = sqlx::query_as!( + Self, + r#"SELECT e.* + FROM events e + WHERE id = ?"#, + id, ) - .bind(id) .fetch_optional(db) .await?; Ok(event) diff --git a/src/db/list.rs b/src/db/list.rs index 9b68b558..64bc1bb8 100644 --- a/src/db/list.rs +++ b/src/db/list.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::NaiveDateTime; use sqlx::QueryBuilder; use super::Db; @@ -9,8 +9,8 @@ pub struct List { pub id: i64, pub name: String, pub description: String, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, } #[derive(Debug, sqlx::FromRow, serde::Serialize)] @@ -31,19 +31,19 @@ pub struct UpdateList { impl List { /// List all lists. pub async fn list(db: &Db) -> Result> { - let events = sqlx::query_as::<_, List>("SELECT * FROM lists").fetch_all(db).await?; + let events = sqlx::query_as!(Self, "SELECT * FROM lists").fetch_all(db).await?; Ok(events) } /// Create a list. pub async fn create(db: &Db, event: &UpdateList) -> Result { - let row = sqlx::query( - "INSERT INTO lists \ - (name, description) \ - VALUES (?, ?)", + let row = sqlx::query!( + r#"INSERT INTO lists + (name, description) + VALUES (?, ?)"#, + event.name, + event.description ) - .bind(&event.name) - .bind(&event.description) .execute(db) .await?; Ok(row.last_insert_rowid()) @@ -51,14 +51,14 @@ impl List { /// Update a list. pub async fn update(db: &Db, id: i64, event: &UpdateList) -> Result<()> { - sqlx::query( - "UPDATE lists \ - SET name = ?, description = ? \ - WHERE id = ?", + sqlx::query!( + r#"UPDATE lists + SET name = ?, description = ? + WHERE id = ?"#, + event.name, + event.description, + id ) - .bind(&event.name) - .bind(&event.description) - .bind(id) .execute(db) .await?; Ok(()) @@ -66,12 +66,13 @@ impl List { /// Lookup a list by id, if one exists. pub async fn lookup_by_id(db: &Db, id: i64) -> Result> { - let event = sqlx::query_as::<_, List>( - "SELECT * \ - FROM lists \ - WHERE id = ?", + let event = sqlx::query_as!( + Self, + r#"SELECT * + FROM lists + WHERE id = ?"#, + id ) - .bind(id) .fetch_optional(db) .await?; Ok(event) @@ -79,14 +80,15 @@ impl List { /// Lookup the members of a list. pub async fn list_members(db: &Db, list_id: i64) -> Result> { - let members = sqlx::query_as::<_, ListMember>( - "SELECT e.email, u.first_name, u.last_name - FROM list_members e \ - LEFT JOIN users u ON u.email = e.email \ - WHERE e.list_id = ? - ORDER BY e.created_at", + let members = sqlx::query_as!( + ListMember, + r#"SELECT e.email, u.first_name, u.last_name + FROM list_members e + JOIN users u ON u.email = e.email + WHERE e.list_id = ? + ORDER BY e.created_at"#, + list_id ) - .bind(list_id) .fetch_all(db) .await?; Ok(members) @@ -106,12 +108,12 @@ impl List { } pub async fn remove_member(db: &Db, list_id: i64, email: &str) -> Result<()> { - sqlx::query( - "DELETE FROM list_members \ - WHERE list_id = ? AND email = ?", + sqlx::query!( + r#"DELETE FROM list_members + WHERE list_id = ? AND email = ?"#, + list_id, + email ) - .bind(list_id) - .bind(email) .execute(db) .await?; Ok(()) diff --git a/src/db/post.rs b/src/db/post.rs index 6815e321..0b846b07 100644 --- a/src/db/post.rs +++ b/src/db/post.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::NaiveDateTime; use super::Db; @@ -10,8 +10,8 @@ pub struct Post { pub url: String, pub author: String, pub content: String, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, } #[derive(serde::Deserialize)] @@ -25,7 +25,7 @@ pub struct UpdatePost { impl Post { // List all posts. pub async fn list(db: &Db) -> Result> { - let posts = sqlx::query_as::<_, Post>("SELECT * FROM posts ORDER BY updated_at DESC") + let posts = sqlx::query_as!(Self, "SELECT * FROM posts ORDER BY updated_at DESC") .fetch_all(db) .await?; Ok(posts) @@ -33,15 +33,15 @@ impl Post { /// Create a new post. pub async fn create(db: &Db, post: &UpdatePost) -> Result { - let row = sqlx::query( - "INSERT INTO posts \ - (title, url, author, content) \ - VALUES (?, ?, ?, ?, ?)", + let row = sqlx::query!( + r#"INSERT INTO posts + (title, url, author, content) + VALUES (?, ?, ?, ?)"#, + post.title, + post.url, + post.author, + post.content, ) - .bind(&post.title) - .bind(&post.url) - .bind(&post.author) - .bind(&post.content) .execute(db) .await?; Ok(row.last_insert_rowid()) @@ -49,20 +49,20 @@ impl Post { /// Update an existing post. pub async fn update(db: &Db, id: i64, post: &UpdatePost) -> Result<()> { - sqlx::query( - "UPDATE posts - SET title = ?, + sqlx::query!( + r#"UPDATE posts + SET title = ?, url = ?, author = ?, content = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ?", + WHERE id = ?"#, + post.title, + post.url, + post.author, + post.content, + id ) - .bind(&post.title) - .bind(&post.url) - .bind(&post.author) - .bind(&post.content) - .bind(id) .execute(db) .await?; Ok(()) @@ -70,14 +70,13 @@ impl Post { /// Delete a post. pub async fn delete(db: &Db, id: i64) -> Result<()> { - sqlx::query("DELETE FROM posts WHERE id = ?").bind(id).execute(db).await?; + sqlx::query!("DELETE FROM posts WHERE id = ?", id).execute(db).await?; Ok(()) } /// Lookup a post by URL, if one exists. pub async fn lookup_by_url(db: &Db, url: &str) -> Result> { - let row = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE url = ?") - .bind(url) + let row = sqlx::query_as!(Self, "SELECT * FROM posts WHERE url = ?", url) .fetch_optional(db) .await?; Ok(row) diff --git a/src/db/token.rs b/src/db/token.rs index 895d78d4..478329dd 100644 --- a/src/db/token.rs +++ b/src/db/token.rs @@ -27,9 +27,7 @@ impl SessionToken { pub async fn create(db: &Db, user_id: i64) -> Result { let token = format!("{:08x}", OsRng.gen::()); - sqlx::query("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)") - .bind(user_id) - .bind(&token) + sqlx::query!("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)", user_id, token) .execute(db) .await?; @@ -42,9 +40,7 @@ impl LoginToken { pub async fn create(db: &Db, email: &str) -> Result { let token = format!("{:08x}", OsRng.gen::()); - sqlx::query("INSERT INTO login_tokens (email, token) VALUES (?, ?)") - .bind(email) - .bind(&token) + sqlx::query!("INSERT INTO login_tokens (email, token) VALUES (?, ?)", email, token) .execute(db) .await?; @@ -53,10 +49,9 @@ impl LoginToken { /// Lookup the email address for the given login token, if it's valid. pub async fn lookup_email(db: &Db, token: &str) -> Result> { - let row = sqlx::query_as::<_, (String,)>("SELECT email FROM login_tokens WHERE token = ?") - .bind(token) + let row = sqlx::query_scalar!("SELECT email FROM login_tokens WHERE token = ?", token) .fetch_optional(db) .await?; - Ok(row.map(|r| r.0)) + Ok(row) } } diff --git a/src/db/user.rs b/src/db/user.rs index abfff3c3..57a53db0 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Utc}; +use chrono::NaiveDateTime; use super::Db; @@ -9,7 +9,7 @@ pub struct User { pub first_name: String, pub last_name: String, pub email: String, - pub created_at: DateTime, + pub created_at: NaiveDateTime, } impl User {} @@ -29,14 +29,14 @@ impl User { /// Create a new user. pub async fn create(db: &Db, user: &UpdateUser) -> Result { - let row = sqlx::query( - "INSERT INTO users \ - (first_name, last_name, email) \ - VALUES (?, ?, ?)", + let row = sqlx::query!( + r#"INSERT INTO users + (first_name, last_name, email) + VALUES (?, ?, ?)"#, + user.first_name, + user.last_name, + user.email ) - .bind(&user.first_name) - .bind(&user.last_name) - .bind(&user.email) .execute(db) .await?; Ok(row.last_insert_rowid()) @@ -44,43 +44,46 @@ impl User { /// Lookup a user by email address, if one exists. pub async fn lookup_by_email(db: &Db, email: &str) -> Result> { - let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?") - .bind(email) + let row = sqlx::query_as!(Self, "SELECT * FROM users WHERE email = ?", email) .fetch_optional(db) .await?; Ok(row) } /// Lookup a user by a login token, if it's valid. pub async fn lookup_by_login_token(db: &Db, token: &str) -> Result> { - let row = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM login_tokens t \ - LEFT JOIN users u on u.email = t.email \ - WHERE t.token = ?", + // Weird workaround for sqlx incorrectly inferring nullability for joins + // not sure why this is needed here and not below + // use the "!" syntax to force the column to be interpreted as non-null + // https://github.com/launchbadge/sqlx/issues/2127 + let row = sqlx::query_as!( + User, + r#"SELECT u.id as "id!", u.first_name as "first_name!", u.last_name as "last_name!", u.email as "email!", u.created_at as "created_at!" + FROM login_tokens t + JOIN users u on u.email = t.email + WHERE token = ?"#, + token ) - .bind(token) .fetch_optional(db) .await?; Ok(row) } /// Lookup a user by a session token, if it's valid. pub async fn lookup_by_session_token(db: &Db, token: &str) -> Result> { - let user = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM session_tokens t \ - JOIN users u on u.id = t.user_id \ - WHERE token = ?", + let user = sqlx::query_as!( + Self, + r#"SELECT u.* + FROM session_tokens t + JOIN users u on u.id = t.user_id + WHERE token = ?"#, + token ) - .bind(token) .fetch_optional(db) .await?; Ok(user) } pub async fn has_role(&self, db: &Db, role: &str) -> Result { - let row = sqlx::query("SELECT 1 FROM user_roles WHERE user_id = ? AND role = ?") - .bind(self.id) - .bind(role) + let row = sqlx::query!("SELECT * FROM user_roles WHERE user_id = ? AND role = ?", self.id, role) .fetch_optional(db) .await?; Ok(row.is_some()) From 807586da9d89676c6cde0de72a2ab975c882f478 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Mon, 31 Mar 2025 15:30:15 -0400 Subject: [PATCH 36/89] update readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index ffb5b6b4..394acc83 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ git clone https://github.com/foltik/lsd cd lsd ``` +Initialize dev database: +```sh +cargo sqlx database create +``` + To automatically recompile and rerun when you make changes, use `cargo-watch`: ```sh cargo install cargo-watch From 7e5ff5861d3d38537e284c1139d0d6f75da1900e Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Mon, 31 Mar 2025 15:34:46 -0400 Subject: [PATCH 37/89] automate transition to new migrations method --- migrations/0000_drop_migrations.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 migrations/0000_drop_migrations.sql diff --git a/migrations/0000_drop_migrations.sql b/migrations/0000_drop_migrations.sql new file mode 100644 index 00000000..3f95094b --- /dev/null +++ b/migrations/0000_drop_migrations.sql @@ -0,0 +1,2 @@ +-- Drop the old custom migrations table in favor of the SQLx managed table +DROP TABLE IF EXISTS migrations; \ No newline at end of file From 2cc4ad44e19b2e538edb428783d633c345e14d20 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Mon, 31 Mar 2025 15:44:16 -0400 Subject: [PATCH 38/89] prepared SQLx to fix CI --- ...bd1b8881297a06d0e0d333b065aec5d9da24e.json | 56 +++++++++++++++ ...8099d674224915857ad5ff1e73c5f3aad1522.json | 12 ++++ ...c695548575d341037b64a7f010d47f7d7fb1a.json | 56 +++++++++++++++ ...511b0700ef9c11eac0204c2c0aa5c04929270.json | 12 ++++ ...6abd83f637c2feb205c3f1f957f410ca6b1e4.json | 12 ++++ ...29c7f47a77bd4d1d21ad3895da6edb83e469a.json | 56 +++++++++++++++ ...bdf3e8d3d3f61e17713aa10f9912522cd84ca.json | 44 ++++++++++++ ...7d1f08b6acfbfc0d3fc6ad54a779e37bc8481.json | 12 ++++ ...72a5ea03360acc445bbc9889a30500802da8a.json | 12 ++++ ...12b69b79de2bbb75428543f0170d31f54226c.json | 12 ++++ ...ad9cb7aec768dd47520f012a7efea6ddd0092.json | 12 ++++ ...3e653987d7722c3bdc139d26641004e361755.json | 12 ++++ ...e1e110d9509ca4686ab431edaa36e5cd5266f.json | 68 +++++++++++++++++++ ...9adac27b27a3cf7bf853af3a9f130b1684d91.json | 12 ++++ ...8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json | 32 +++++++++ ...17f8527d7bca1554989625aee8fba037fc032.json | 44 ++++++++++++ ...717c30574e0f533c11529aa87ee9722c90dc2.json | 12 ++++ ...d3fc34ee65fb399813097facc87082c89b26e.json | 12 ++++ ...b963b7dc2d5cf7a033c33f7d1587858387809.json | 32 +++++++++ ...4d60777cf278c40214dcb62b6e9c1bb016675.json | 12 ++++ ...08ddb227c6aeb37b58e228bb5f382c82d36c2.json | 44 ++++++++++++ ...e6f85f32411793dd3e618dd830a8365159a94.json | 12 ++++ ...52f2b897888afe7efc321913db83752287e5d.json | 44 ++++++++++++ ...ba6c41b7110b8ca8bc046f1cf84a364aa4607.json | 12 ++++ ...06b0d244cda9060eb13f32f2aba048af0e3c2.json | 20 ++++++ ...dba6203017212c67c7f0ec98b8ed7227f84df.json | 12 ++++ ...b3175f5dea2d8b7c407b17a306e5166aab3e4.json | 44 ++++++++++++ ...3c1bba2925c2bf3eececa5159caac1ff36bf2.json | 68 +++++++++++++++++++ ...584d3ddc0c6b432b7c518cb2a8140e9a08613.json | 56 +++++++++++++++ ...e24456e16e5a12e033a0f6ce315c9ce838a4e.json | 12 ++++ ...cbc5f6fd57c905dd9530573713dc9ca96c783.json | 12 ++++ 31 files changed, 868 insertions(+) create mode 100644 .sqlx/query-0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e.json create mode 100644 .sqlx/query-1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522.json create mode 100644 .sqlx/query-20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a.json create mode 100644 .sqlx/query-290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270.json create mode 100644 .sqlx/query-298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4.json create mode 100644 .sqlx/query-2a0e6ef2b145f006461a9395e0329c7f47a77bd4d1d21ad3895da6edb83e469a.json create mode 100644 .sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json create mode 100644 .sqlx/query-3ff9dd9bd93f3853f64b28128c27d1f08b6acfbfc0d3fc6ad54a779e37bc8481.json create mode 100644 .sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json create mode 100644 .sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json create mode 100644 .sqlx/query-5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092.json create mode 100644 .sqlx/query-68a8883680f928667ea8fa65a423e653987d7722c3bdc139d26641004e361755.json create mode 100644 .sqlx/query-78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f.json create mode 100644 .sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json create mode 100644 .sqlx/query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json create mode 100644 .sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json create mode 100644 .sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json create mode 100644 .sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json create mode 100644 .sqlx/query-92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809.json create mode 100644 .sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json create mode 100644 .sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json create mode 100644 .sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json create mode 100644 .sqlx/query-9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d.json create mode 100644 .sqlx/query-b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607.json create mode 100644 .sqlx/query-bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2.json create mode 100644 .sqlx/query-cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df.json create mode 100644 .sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json create mode 100644 .sqlx/query-d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2.json create mode 100644 .sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json create mode 100644 .sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json create mode 100644 .sqlx/query-e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783.json diff --git a/.sqlx/query-0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e.json b/.sqlx/query-0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e.json new file mode 100644 index 00000000..336d133c --- /dev/null +++ b/.sqlx/query-0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM events", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "artist", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "start_date", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e" +} diff --git a/.sqlx/query-1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522.json b/.sqlx/query-1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522.json new file mode 100644 index 00000000..85e3b92c --- /dev/null +++ b/.sqlx/query-1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM list_members\n WHERE list_id = ? AND email = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522" +} diff --git a/.sqlx/query-20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a.json b/.sqlx/query-20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a.json new file mode 100644 index 00000000..2c185e54 --- /dev/null +++ b/.sqlx/query-20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM posts WHERE url = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "author", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a" +} diff --git a/.sqlx/query-290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270.json b/.sqlx/query-290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270.json new file mode 100644 index 00000000..891c848c --- /dev/null +++ b/.sqlx/query-290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO users\n (first_name, last_name, email)\n VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270" +} diff --git a/.sqlx/query-298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4.json b/.sqlx/query-298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4.json new file mode 100644 index 00000000..a6d64ce2 --- /dev/null +++ b/.sqlx/query-298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO posts\n (title, url, author, content)\n VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4" +} diff --git a/.sqlx/query-2a0e6ef2b145f006461a9395e0329c7f47a77bd4d1d21ad3895da6edb83e469a.json b/.sqlx/query-2a0e6ef2b145f006461a9395e0329c7f47a77bd4d1d21ad3895da6edb83e469a.json new file mode 100644 index 00000000..058f0470 --- /dev/null +++ b/.sqlx/query-2a0e6ef2b145f006461a9395e0329c7f47a77bd4d1d21ad3895da6edb83e469a.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.*\n FROM events e\n WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "artist", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "start_date", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2a0e6ef2b145f006461a9395e0329c7f47a77bd4d1d21ad3895da6edb83e469a" +} diff --git a/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json b/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json new file mode 100644 index 00000000..b1d39aa9 --- /dev/null +++ b/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM users WHERE email = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca" +} diff --git a/.sqlx/query-3ff9dd9bd93f3853f64b28128c27d1f08b6acfbfc0d3fc6ad54a779e37bc8481.json b/.sqlx/query-3ff9dd9bd93f3853f64b28128c27d1f08b6acfbfc0d3fc6ad54a779e37bc8481.json new file mode 100644 index 00000000..51f48f40 --- /dev/null +++ b/.sqlx/query-3ff9dd9bd93f3853f64b28128c27d1f08b6acfbfc0d3fc6ad54a779e37bc8481.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO events\n (title, artist, description, start_date)\n VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "3ff9dd9bd93f3853f64b28128c27d1f08b6acfbfc0d3fc6ad54a779e37bc8481" +} diff --git a/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json b/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json new file mode 100644 index 00000000..08996f60 --- /dev/null +++ b/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a" +} diff --git a/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json b/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json new file mode 100644 index 00000000..2298a135 --- /dev/null +++ b/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO session_tokens (user_id, token) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c" +} diff --git a/.sqlx/query-5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092.json b/.sqlx/query-5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092.json new file mode 100644 index 00000000..fd52d0ae --- /dev/null +++ b/.sqlx/query-5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO login_tokens (email, token) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092" +} diff --git a/.sqlx/query-68a8883680f928667ea8fa65a423e653987d7722c3bdc139d26641004e361755.json b/.sqlx/query-68a8883680f928667ea8fa65a423e653987d7722c3bdc139d26641004e361755.json new file mode 100644 index 00000000..ed34f645 --- /dev/null +++ b/.sqlx/query-68a8883680f928667ea8fa65a423e653987d7722c3bdc139d26641004e361755.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE events\n SET title = ?, artist = ?, description = ?, start_date = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "68a8883680f928667ea8fa65a423e653987d7722c3bdc139d26641004e361755" +} diff --git a/.sqlx/query-78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f.json b/.sqlx/query-78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f.json new file mode 100644 index 00000000..96377599 --- /dev/null +++ b/.sqlx/query-78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM emails WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "post_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 8, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f" +} diff --git a/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json b/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json new file mode 100644 index 00000000..6aa292f3 --- /dev/null +++ b/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM posts WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91" +} diff --git a/.sqlx/query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json b/.sqlx/query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json new file mode 100644 index 00000000..bb27cc66 --- /dev/null +++ b/.sqlx/query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.email, u.first_name, u.last_name\n FROM list_members e\n JOIN users u ON u.email = e.email\n WHERE e.list_id = ?\n ORDER BY e.created_at", + "describe": { + "columns": [ + { + "name": "email", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0" +} diff --git a/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json b/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json new file mode 100644 index 00000000..19adbd4b --- /dev/null +++ b/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT *\n FROM lists\n WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032" +} diff --git a/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json b/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json new file mode 100644 index 00000000..912ac4bd --- /dev/null +++ b/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET opened_at = CURRENT_TIMESTAMP\n WHERE id = ? AND opened_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2" +} diff --git a/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json b/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json new file mode 100644 index 00000000..00b43b65 --- /dev/null +++ b/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO lists\n (name, description)\n VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e" +} diff --git a/.sqlx/query-92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809.json b/.sqlx/query-92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809.json new file mode 100644 index 00000000..579c49ee --- /dev/null +++ b/.sqlx/query-92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM user_roles WHERE user_id = ? AND role = ?", + "describe": { + "columns": [ + { + "name": "user_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "role", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 2, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809" +} diff --git a/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json b/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json new file mode 100644 index 00000000..74694359 --- /dev/null +++ b/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE lists\n SET name = ?, description = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675" +} diff --git a/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json b/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json new file mode 100644 index 00000000..3469975b --- /dev/null +++ b/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT u.*\n FROM session_tokens t\n JOIN users u on u.id = t.user_id\n WHERE token = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2" +} diff --git a/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json b/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json new file mode 100644 index 00000000..0e6e7132 --- /dev/null +++ b/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET sent_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94" +} diff --git a/.sqlx/query-9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d.json b/.sqlx/query-9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d.json new file mode 100644 index 00000000..19d38a55 --- /dev/null +++ b/.sqlx/query-9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT u.id as \"id!\", u.first_name as \"first_name!\", u.last_name as \"last_name!\", u.email as \"email!\", u.created_at as \"created_at!\"\n FROM login_tokens t\n JOIN users u on u.email = t.email\n WHERE token = ?", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name!", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name!", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email!", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at!", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + true, + true + ] + }, + "hash": "9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d" +} diff --git a/.sqlx/query-b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607.json b/.sqlx/query-b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607.json new file mode 100644 index 00000000..d96d336d --- /dev/null +++ b/.sqlx/query-b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO emails (kind, address) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607" +} diff --git a/.sqlx/query-bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2.json b/.sqlx/query-bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2.json new file mode 100644 index 00000000..76e99ccd --- /dev/null +++ b/.sqlx/query-bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT email FROM login_tokens WHERE token = ?", + "describe": { + "columns": [ + { + "name": "email", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2" +} diff --git a/.sqlx/query-cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df.json b/.sqlx/query-cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df.json new file mode 100644 index 00000000..1d67a0fe --- /dev/null +++ b/.sqlx/query-cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE posts\n SET title = ?,\n url = ?,\n author = ?,\n content = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df" +} diff --git a/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json b/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json new file mode 100644 index 00000000..6437e012 --- /dev/null +++ b/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM lists", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4" +} diff --git a/.sqlx/query-d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2.json b/.sqlx/query-d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2.json new file mode 100644 index 00000000..94d73cac --- /dev/null +++ b/.sqlx/query-d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM emails\n WHERE address = ? AND post_id = ? AND list_id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "post_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 8, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2" +} diff --git a/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json b/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json new file mode 100644 index 00000000..a2dc24dd --- /dev/null +++ b/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM posts ORDER BY updated_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "author", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613" +} diff --git a/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json b/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json new file mode 100644 index 00000000..10c970d6 --- /dev/null +++ b/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM events WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e" +} diff --git a/.sqlx/query-e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783.json b/.sqlx/query-e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783.json new file mode 100644 index 00000000..743d9a4b --- /dev/null +++ b/.sqlx/query-e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO emails (kind, address, post_id, list_id) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783" +} From ecae65504d0009bbb4d41fd6ad1d91edbf41ea3e Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Mon, 31 Mar 2025 15:54:49 -0400 Subject: [PATCH 39/89] Remove .env file from VC to force SQLx offline compilation --- .env | 1 - .gitignore | 3 ++- README.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 01727481..00000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL=sqlite://lsd.sqlite \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2291ecb2..1e4f7a39 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ Cargo.lock *.sqlite *.sqlite-* -.DS_Store \ No newline at end of file +.DS_Store +.env \ No newline at end of file diff --git a/README.md b/README.md index 394acc83..b6d1da24 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Initialize dev database: cargo sqlx database create ``` +Create a .env file with `DATABASE_URL=sqlite://lsd.sqlite` + To automatically recompile and rerun when you make changes, use `cargo-watch`: ```sh cargo install cargo-watch From e8a2dd9688756285e019a973984953b18c9c9d99 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Tue, 1 Apr 2025 16:31:42 -0400 Subject: [PATCH 40/89] fix date formatting (#14) --- src/utils/tera.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/utils/tera.rs b/src/utils/tera.rs index 8438d526..6bd1079f 100644 --- a/src/utils/tera.rs +++ b/src/utils/tera.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; +use chrono::NaiveDateTime; use std::collections::HashMap; use tera::{Tera, Value}; @@ -24,8 +24,9 @@ pub fn templates(config: &Config) -> Result { let format = format.as_str().context("arg=`format` must be a string")?; let date: &str = date.as_str().with_context(|| format!("value={date:?} must be a string"))?; - let date: DateTime = date.parse().context("parsing date")?; - let local = date.with_timezone(&tz); + let date: NaiveDateTime = date.parse().context("parsing date")?; + let utc = date.and_utc(); + let local = utc.with_timezone(&tz); let formatted = local.format(format).to_string(); Ok(Value::String(formatted)) From 3e6196ca186cde0877e8ebf347b8dfd42aaa1318 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Thu, 3 Apr 2025 02:10:04 -0400 Subject: [PATCH 41/89] more detailed logs (#16) --- Cargo.toml | 4 +++- src/main.rs | 14 +++++++++++++- src/utils/tracing.rs | 45 +++++++++++++++++++++++--------------------- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9b28c98b..3ddafb20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" axum = { version = "0.8", default-features = false, features = ["query", "form"] } axum-server = { version = "0.7", features = ["tls-rustls"] } axum-extra = { version = "0.10", features = ["cookie"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } +tower-http = { version = "0.6", features = ["fs", "request-id", "trace", "util"] } tera = "1" sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "tokio1", "tokio1-rustls-tls", "serde"] } @@ -25,6 +25,8 @@ toml = "0.8" rand = "0.8" chrono = { version = "0.4", features = ["serde"] } chrono-tz = { version = "0.10", features = ["serde"] } +uuid = { version = "1.16.0", features = ["v7"] } +tower = "0.5.2" # Add a little optimization to debug builds [profile.dev] diff --git a/src/main.rs b/src/main.rs index 675fe839..6801c817 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,23 @@ mod utils; use axum::{handler::HandlerWithoutStateExt, response::Redirect}; use axum_server::tls_rustls::RustlsConfig; use futures::StreamExt; +use tracing::{level_filters::LevelFilter, Level}; +use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _}; use utils::config::*; #[tokio::main] async fn main() -> Result<()> { - tracing_subscriber::fmt().init(); + let log_filter = tracing_subscriber::filter::Targets::default() + .with_target("h2", LevelFilter::OFF) + .with_default(Level::DEBUG); + + tracing_subscriber::fmt() + .with_target(true) + .with_line_number(true) + .with_max_level(Level::DEBUG) + .finish() + .with(log_filter) + .try_init()?; // Load the server config let file = std::env::args().nth(1).context("usage: lsd ")?; diff --git a/src/utils/tracing.rs b/src/utils/tracing.rs index 62e5cafb..1ffc2033 100644 --- a/src/utils/tracing.rs +++ b/src/utils/tracing.rs @@ -1,30 +1,33 @@ -use std::time::Duration; - -use axum::{http::Request, response::Response}; -use tower_http::trace::TraceLayer; -use tracing::Span; +use axum::http::Request; +use tower::ServiceBuilder; +use tower_http::request_id::{MakeRequestId, RequestId}; +use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; +use tower_http::ServiceBuilderExt as _; +use uuid::Uuid; use crate::utils::types::AppRouter; +#[derive(Clone, Copy)] +pub struct MakeRequestUuidV7; +impl MakeRequestId for MakeRequestUuidV7 { + fn make_request_id(&mut self, _request: &Request) -> Option { + // Use UUIDv7 so that request ID can be sorted by time + let request_id = Uuid::now_v7(); + Some(RequestId::new(request_id.to_string().parse().unwrap())) + } +} + /// Register debug tracing middleware. pub fn register(router: AppRouter) -> AppRouter { // Add a middleware that logs all incoming requests and responses, including latency and status. router.layer( - TraceLayer::new_for_http() - // Start a `tracing::span` for each request. - .make_span_with(|req: &Request<_>| { - // Fields populated later must be initialized as `tracing::field::Empty`. - tracing::info_span!( - "request", - method = ?req.method(), - path = req.uri().path(), - status = tracing::field::Empty - ) - }) - // Add some extra fields once the response is generated. - .on_response(|res: &Response, latency: Duration, span: &Span| { - span.record("status", res.status().as_u16()); - tracing::info!("handled in {latency:?}"); - }), + ServiceBuilder::new() + .set_x_request_id(MakeRequestUuidV7) + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().include_headers(true)) + .on_response(DefaultOnResponse::new().include_headers(true)), + ) + .propagate_x_request_id(), ) } From ce8e7f899601debb790e2fa72d084bb72c14f9c1 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Thu, 3 Apr 2025 02:23:21 -0400 Subject: [PATCH 42/89] basic database seeding functionality (#15) --- ...e0416c5dd63be2150bf80387552313e9d087a.json | 12 +++++ config/dev.toml | 1 + config/seed_data.toml | 12 +++++ src/app/mod.rs | 2 +- src/db/mod.rs | 53 +++++++++++++++++-- src/db/user.rs | 17 +++++- src/utils/config.rs | 1 + 7 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 .sqlx/query-f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a.json create mode 100644 config/seed_data.toml diff --git a/.sqlx/query-f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a.json b/.sqlx/query-f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a.json new file mode 100644 index 00000000..52e7ca21 --- /dev/null +++ b/.sqlx/query-f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO user_roles (user_id, role) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a" +} diff --git a/config/dev.toml b/config/dev.toml index 576f9d81..39a20058 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -5,6 +5,7 @@ tz = "America/New_York" [db] file = "db.sqlite" +seed_data = "config/seed_data.json" [net] http_addr = "[::]:8080" diff --git a/config/seed_data.toml b/config/seed_data.toml new file mode 100644 index 00000000..42b8bf04 --- /dev/null +++ b/config/seed_data.toml @@ -0,0 +1,12 @@ +[[users]] +email = "test@test.com" +first_name = "Test" +last_name = "Testington" + +[[user_roles]] +user_id = 1 +role = "admin" + +[[user_roles]] +user_id = 1 +role = "writer" diff --git a/src/app/mod.rs b/src/app/mod.rs index 9fdd6f9d..4bd8776b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -27,7 +27,7 @@ pub async fn build(config: Config) -> Result { let state = Arc::new(AppState { config: config.clone(), templates: utils::tera::templates(&config)?, - db: crate::db::init(&config.db.file).await?, + db: crate::db::init(&config.db).await?, mailer: Emailer::connect(config.email).await?, }); diff --git a/src/db/mod.rs b/src/db/mod.rs index 9e6c781c..7c863533 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,6 +1,10 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; +use serde::Deserialize; use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool}; use std::path::Path; +use user::User; + +use crate::utils::config::DbConfig; pub type Db = SqlitePool; @@ -12,8 +16,8 @@ pub mod token; pub mod user; /// Create a new db connection pool, initializing and running migrations if necessary. -pub async fn init(file: &Path) -> Result { - let url = format!("sqlite://{}", file.display()); +pub async fn init(db_config: &DbConfig) -> Result { + let url = format!("sqlite://{}", db_config.file.display()); if !Sqlite::database_exists(&url).await? { Sqlite::create_database(&url).await?; } @@ -21,5 +25,48 @@ pub async fn init(file: &Path) -> Result { sqlx::migrate!("./migrations"); + if let Some(seed_data) = &db_config.seed_data { + seed_db(&db, seed_data).await?; + } + Ok(db) } + +#[derive(Deserialize)] +struct SeedData { + users: Vec, + user_roles: Vec, +} + +impl SeedData { + pub async fn load(file: &Path) -> Result { + let contents = tokio::fs::read_to_string(file).await?; + toml::from_str(&contents).with_context(|| format!("loading config={file:#?}")) + } +} + +async fn seed_db(db: &Db, seed_data_path: &Path) -> Result<()> { + let seed_data = SeedData::load(seed_data_path).await?; + + for user in seed_data.users { + if User::lookup_by_email(db, &user.email).await?.is_none() { + User::create(db, &user).await?; + } + } + + for user_role in seed_data.user_roles { + if sqlx::query!( + "SELECT * FROM user_roles WHERE user_id = ? AND role = ?", + user_role.user_id, + user_role.role + ) + .fetch_optional(db) + .await? + .is_none() + { + User::add_role(db, user_role.user_id, &user_role.role).await?; + } + } + + Ok(()) +} diff --git a/src/db/user.rs b/src/db/user.rs index 57a53db0..74b4dfab 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -1,9 +1,10 @@ use anyhow::Result; use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; use super::Db; -#[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct User { pub id: i64, pub first_name: String, @@ -14,6 +15,13 @@ pub struct User { impl User {} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UserRole { + pub user_id: i64, + pub role: String, + pub created_at: NaiveDateTime, +} + #[derive(Debug, serde::Deserialize)] pub struct UpdateUser { pub first_name: String, @@ -42,6 +50,13 @@ impl User { Ok(row.last_insert_rowid()) } + pub async fn add_role(db: &Db, user_id: i64, role: &str) -> Result<()> { + sqlx::query!(r#"INSERT INTO user_roles (user_id, role) VALUES (?, ?)"#, user_id, role) + .execute(db) + .await?; + Ok(()) + } + /// Lookup a user by email address, if one exists. pub async fn lookup_by_email(db: &Db, email: &str) -> Result> { let row = sqlx::query_as!(Self, "SELECT * FROM users WHERE email = ?", email) diff --git a/src/utils/config.rs b/src/utils/config.rs index b1a91d94..de187c70 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -40,6 +40,7 @@ pub struct AppConfig { pub struct DbConfig { /// Path to sqlite3 database file. pub file: PathBuf, + pub seed_data: Option, } /// Networking configuration. From ad1a7ece3530ce5fea40c7e5814f0bec96b97cc4 Mon Sep 17 00:00:00 2001 From: amani <163478167+hiamani@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:36:40 -0500 Subject: [PATCH 43/89] doc: Update README with sqlx instructions (#17) * doc: Update README with sqlx instructions * fix: sqlx database setup * Remove redundant command --------- Co-authored-by: Sam Wlody --- README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b6d1da24..fa6d6428 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Coming to you live. ## Setup Install a rust toolchain with [rustup.rs](https://rustup.rs): + ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo --version @@ -12,25 +13,30 @@ rustc --version ``` Clone the repo: + ```sh git clone https://github.com/foltik/lsd cd lsd ``` +Create a .env file with `DATABASE_URL=sqlite://db.sqlite` + Initialize dev database: + ```sh -cargo sqlx database create +cargo install sqlx-cli --no-default-features --features sqlite +cargo sqlx database setup ``` -Create a .env file with `DATABASE_URL=sqlite://lsd.sqlite` - To automatically recompile and rerun when you make changes, use `cargo-watch`: + ```sh cargo install cargo-watch cargo watch -x 'run config/dev.toml' ``` Use [mailtutan](https://github.com/mailtutan/mailtutan) for local testing of email functionality: + ```sh cargo install mailtutan mailtutan @@ -38,5 +44,5 @@ mailtutan ## Workflow -* Make commits in a separate branch, and open a PR against `main` -* When new commits land in `main`, a github action will automatically deploy the app to https://beta.lightandsound.design +- Make commits in a separate branch, and open a PR against `main` +- When new commits land in `main`, a github action will automatically deploy the app to https://beta.lightandsound.design From 3fe48297d7d706cb013b19e7bffcb03a42aeeae5 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Thu, 3 Apr 2025 16:37:29 -0400 Subject: [PATCH 44/89] checkin .env and enable SQLX_OFFLINE in CI (#18) --- .env | 1 + .github/workflows/deploy.yaml | 1 + .github/workflows/test.yaml | 8 ++++++++ .gitignore | 3 +-- 4 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 00000000..2dc9251f --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL=sqlite://db.sqlite \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index e1e86675..306e7c10 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -32,3 +32,4 @@ jobs: env: SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + SQLX_OFFLINE: true diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d9c1b0f0..a4006c96 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,6 +8,8 @@ jobs: runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -19,6 +21,8 @@ jobs: runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -29,6 +33,8 @@ jobs: runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -40,6 +46,8 @@ jobs: runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable diff --git a/.gitignore b/.gitignore index 1e4f7a39..2291ecb2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ Cargo.lock *.sqlite *.sqlite-* -.DS_Store -.env \ No newline at end of file +.DS_Store \ No newline at end of file From df35e8ba5394c9a3aab27e41af16499f7aed3aca Mon Sep 17 00:00:00 2001 From: amani <163478167+hiamani@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:13:01 -0500 Subject: [PATCH 45/89] Add TailwindCSS (#19) * feat: Install tailwindcss * feat: layout.tera.html with blocks * feat: Add header to layout.tera.html * revert: Revert README to main, let other PR take care of it * feat: head -> styles block * feat: Move styles to web/, setup livereload, shore up dev workflow * revert: Local dev.toml * revert: page.tera.html * revert: post-list, event-create templates * feat: page.tera.html styles -> main.css * Update .gitignore Co-authored-by: Sam Wlody * feat: Ignore web/ in cargo watch, build:styles.min script --------- Co-authored-by: Sam Wlody --- .gitignore | 3 +- README.md | 13 +- assets/dist.css | 473 ++++++ mise.toml | 3 + package-lock.json | 3161 ++++++++++++++++++++++++++++++++++++ package.json | 14 + templates/home.tera.html | 109 +- templates/layout.tera.html | 27 + web/styles/main.css | 78 + 9 files changed, 3821 insertions(+), 60 deletions(-) create mode 100644 assets/dist.css create mode 100644 mise.toml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 templates/layout.tera.html create mode 100644 web/styles/main.css diff --git a/.gitignore b/.gitignore index 2291ecb2..1fc44349 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ Cargo.lock *.sqlite *.sqlite-* -.DS_Store \ No newline at end of file +.DS_Store +node_modules diff --git a/README.md b/README.md index fa6d6428..7696f17b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,18 @@ To automatically recompile and rerun when you make changes, use `cargo-watch`: ```sh cargo install cargo-watch -cargo watch -x 'run config/dev.toml' +cargo watch -x 'run config/dev.toml' --ignore "web/*" +``` + +Run Tailwind CLI to compile styles via `npm`. +You can optionally install the [livereload][https://www.npmjs.com/package/livereload] extension to reload the page on style changes. + +```sh +npm install +# Watch for changes to ./web/styles/main.css, start livereload server +npm run watch +# Build styles minified +npm run build:styles.min ``` Use [mailtutan](https://github.com/mailtutan/mailtutan) for local testing of email functionality: diff --git a/assets/dist.css b/assets/dist.css new file mode 100644 index 00000000..c64d8b66 --- /dev/null +++ b/assets/dist.css @@ -0,0 +1,473 @@ +/*! tailwindcss v4.0.17 | MIT License | https://tailwindcss.com */ +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --color-neutral-600: oklch(0.439 0 0); + --color-neutral-700: oklch(0.371 0 0); + --color-neutral-800: oklch(0.269 0 0); + --color-neutral-900: oklch(0.205 0 0); + --color-neutral-950: oklch(0.145 0 0); + --color-black: #000; + --color-white: #fff; + --spacing: 0.25rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-9xl: 8rem; + --text-9xl--line-height: 1; + --font-weight-bold: 700; + --font-weight-black: 900; + --radius-sm: 0.25rem; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: color-mix(in oklab, currentColor 50%, transparent); + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .absolute { + position: absolute; + } + .static { + position: static; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .block { + display: block; + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .hidden { + display: none; + } + .table { + display: table; + } + .w-full { + width: 100%; + } + .max-w-4xl { + max-width: var(--container-4xl); + } + .flex-grow { + flex-grow: 1; + } + .resize { + resize: both; + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-neutral-800 { + border-color: var(--color-neutral-800); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + .font-black { + --tw-font-weight: var(--font-weight-black); + font-weight: var(--font-weight-black); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .italic { + font-style: italic; + } + .ordinal { + --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .underline { + text-decoration-line: underline; + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .lg\:text-9xl { + @media (width >= 64rem) { + font-size: var(--text-9xl); + line-height: var(--tw-leading, var(--text-9xl--line-height)); + } + } +} +html, body { + height: 100%; + width: 100%; + background-color: var(--color-black); + color: var(--color-white); +} +header { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + border-color: var(--color-neutral-700); + padding: calc(var(--spacing) * 4); +} +article { + margin-inline: auto; + max-width: var(--container-3xl); + padding: calc(var(--spacing) * 4); + time { + margin-bottom: calc(var(--spacing) * 8); + display: block; + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + text-transform: uppercase; + } + p { + margin-bottom: calc(var(--spacing) * 5); + &:last-child { + margin-bottom: calc(var(--spacing) * 0); + } + } + blockquote { + margin-block: calc(var(--spacing) * 4); + border-left-style: var(--tw-border-style); + border-left-width: 4px; + padding-inline: calc(var(--spacing) * 8); + padding-block: calc(var(--spacing) * 6); + font-style: italic; + } + img { + margin-block: calc(var(--spacing) * 8); + display: block; + height: auto; + max-width: 100%; + } +} +h2 { + margin-bottom: calc(var(--spacing) * 1); + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); +} +a { + text-decoration-line: underline; +} +button, .button { + display: block; + cursor: pointer; + background-color: var(--color-neutral-900); + padding-inline: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 2); + &:hover { + @media (hover: hover) { + background-color: var(--color-neutral-800); + } + } + border-radius: var(--radius-sm); +} +ul { + list-style-position: inside; +} +label { + margin-bottom: calc(var(--spacing) * 2); + display: block; +} +input[type="text"] { + width: 100%; + border-style: var(--tw-border-style); + border-width: 1px; + border-color: var(--color-neutral-700); + background-color: var(--color-neutral-950); + padding: calc(var(--spacing) * 2); +} +textarea { + height: 50vh; + width: 100%; + flex: 1; + resize: vertical; + background-color: var(--color-neutral-900); + padding: calc(var(--spacing) * 2); + border-style: var(--tw-border-style); + border-width: 1px; + border-color: var(--color-neutral-700); + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); +} +select { + border-style: var(--tw-border-style); + border-width: 1px; + border-color: var(--color-neutral-600); + background-color: var(--color-neutral-950); +} +form { + display: flex; + flex-direction: column; + padding: calc(var(--spacing) * 8); + .field { + margin-bottom: calc(var(--spacing) * 4); + display: flex; + flex-direction: column; + padding-bottom: calc(var(--spacing) * 4); + } +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-ordinal { + syntax: "*"; + inherits: false; +} +@property --tw-slashed-zero { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-figure { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-spacing { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-fraction { + syntax: "*"; + inherits: false; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..7b931342 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "latest" +rust = "latest" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..05aa5283 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3161 @@ +{ + "name": "lsd", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/cli": "^4.0.17", + "livereload": "^0.9.3", + "npm-run-all": "^4.1.5" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.0.17.tgz", + "integrity": "sha512-Jygu5jjf64vzNXeTr00OhlMzRq+/KwNxJS6eZlgcBpEbXTEmmlr/PSjv1Q9Lk3aTnQc4yNlXkHdWPnlpF+ILUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.0.17", + "@tailwindcss/oxide": "4.0.17", + "enhanced-resolve": "^5.18.1", + "lightningcss": "1.29.2", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.0.17" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.17.tgz", + "integrity": "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "tailwindcss": "4.0.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.17.tgz", + "integrity": "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.0.17", + "@tailwindcss/oxide-darwin-arm64": "4.0.17", + "@tailwindcss/oxide-darwin-x64": "4.0.17", + "@tailwindcss/oxide-freebsd-x64": "4.0.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.17", + "@tailwindcss/oxide-linux-x64-musl": "4.0.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.17.tgz", + "integrity": "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.17.tgz", + "integrity": "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.17.tgz", + "integrity": "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.17.tgz", + "integrity": "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.17.tgz", + "integrity": "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.17.tgz", + "integrity": "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.17.tgz", + "integrity": "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.17.tgz", + "integrity": "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.17.tgz", + "integrity": "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.17.tgz", + "integrity": "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.17.tgz", + "integrity": "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/livereload": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", + "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.0", + "livereload-js": "^3.3.1", + "opts": ">= 1.2.0", + "ws": "^7.4.3" + }, + "bin": { + "livereload": "bin/livereload.js" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/livereload-js": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", + "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opts": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", + "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.17.tgz", + "integrity": "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..b8aec3e8 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "scripts": { + "watch": "npm-run-all --parallel watch:styles reload:assets", + "reload:assets": "npx livereload assets", + "watch:styles": "npx @tailwindcss/cli -i ./web/styles/main.css -o ./assets/dist.css --watch", + "build:styles": "npx @tailwindcss/cli -i ./web/styles/main.css -o ./assets/dist.css", + "build:styles.min": "npx @tailwindcss/cli -i ./web/styles/main.css -o ./assets/dist.css --minify" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.17", + "livereload": "^0.9.3", + "npm-run-all": "^4.1.5" + } +} diff --git a/templates/home.tera.html b/templates/home.tera.html index d6250948..b490bb9b 100644 --- a/templates/home.tera.html +++ b/templates/home.tera.html @@ -1,62 +1,55 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="light and sound") }} +{% extends "layout.tera.html" %} + +{% block title %}light and sound{% endblock title %} + +{% block styles %} + +{{ super() }} + -

      Coming Soon...

      -

      Coming Soon...

      -{{ page::end() }} +{% endblock styles %} + +{% block content %} +
      +

      Coming Soon...

      +

      Coming Soon...

      +
      +{% endblock content %} diff --git a/templates/layout.tera.html b/templates/layout.tera.html new file mode 100644 index 00000000..c90c5790 --- /dev/null +++ b/templates/layout.tera.html @@ -0,0 +1,27 @@ + + + + + + {% block title %}light and sound design{% endblock title %} + + + {% block styles %} + {% endblock styles %} + + + {% block header %} +
      + +
      + {% endblock header %} +
      + {% block content %}{% endblock content %} +
      + + diff --git a/web/styles/main.css b/web/styles/main.css new file mode 100644 index 00000000..2d19ebe4 --- /dev/null +++ b/web/styles/main.css @@ -0,0 +1,78 @@ +@import "tailwindcss"; + +/* + * TODO: Refactor styles into Tailwind utility classes + * Below styles are from page.tera.html + */ + +html, +body { + @apply w-full h-full text-white bg-black; +} + +header { + @apply p-4 border-b border-neutral-700; +} + +article { + @apply max-w-3xl mx-auto p-4; + + time { + @apply block mb-8 text-sm uppercase; + } + + p { + @apply mb-5 last:mb-0; + } + + blockquote { + @apply italic my-4 py-6 px-8 border-l-[4px]; + } + + img { + @apply max-w-full h-auto block my-8; + } +} + +h2 { + @apply mb-1 text-xl font-bold; +} + +a { + @apply underline; +} + +button, +.button { + @apply block px-4 py-2 cursor-pointer bg-neutral-900 hover:bg-neutral-800; + @apply rounded-sm; +} + +ul { + @apply list-inside; +} + +label { + @apply block mb-2; +} + +input[type="text"] { + @apply w-full p-2 bg-neutral-950 border border-neutral-700; +} + +textarea { + @apply flex-1 resize-y w-full h-[50vh] p-2 bg-neutral-900; + @apply text-sm font-mono border border-neutral-700; +} + +select { + @apply bg-neutral-950 border border-neutral-600; +} + +form { + @apply p-8 flex flex-col; + + .field { + @apply mb-4 pb-4 flex flex-col; + } +} From b072566b6088a6f5eb380295367672ba99cb23a5 Mon Sep 17 00:00:00 2001 From: Riley Champion <47626030+RileyChampion@users.noreply.github.com> Date: Fri, 4 Apr 2025 10:09:09 -0700 Subject: [PATCH 46/89] Found a bug resetting up project with the dev.toml using seed_data.json instead of seed_data.toml and encountered error while trying to enter seed data for created_at date (#20) --- config/dev.toml | 2 +- config/seed_data.toml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/config/dev.toml b/config/dev.toml index 39a20058..deff937c 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -5,7 +5,7 @@ tz = "America/New_York" [db] file = "db.sqlite" -seed_data = "config/seed_data.json" +seed_data = "config/seed_data.toml" [net] http_addr = "[::]:8080" diff --git a/config/seed_data.toml b/config/seed_data.toml index 42b8bf04..066ff876 100644 --- a/config/seed_data.toml +++ b/config/seed_data.toml @@ -6,7 +6,9 @@ last_name = "Testington" [[user_roles]] user_id = 1 role = "admin" +created_at="2025-04-04T16:03:35" [[user_roles]] user_id = 1 role = "writer" +created_at="2025-04-04T16:03:35" \ No newline at end of file From f0ba6fc7242b1e539590581450c496c0b8af4607 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Sat, 5 Apr 2025 14:35:15 -0400 Subject: [PATCH 47/89] add prettier formatting and consolidate frontend resources (#21) --- .github/workflows/deploy.yaml | 6 + .github/workflows/test.yaml | 13 + .gitignore | 1 + .vscode/settings.json | 15 + README.md | 2 +- assets/dist.css | 473 ------ deno.lock | 1275 +++++++++++++++++ frontend/.prettierignore | 2 + frontend/prettier.config.cjs | 16 + {assets => frontend/static}/favicon.ico | Bin {assets => frontend/static}/markdown.js | 0 frontend/styles/main.css | 78 + frontend/templates/event-create.tera.html | 45 + frontend/templates/event-list.tera.html | 38 + frontend/templates/event.tera.html | 55 + frontend/templates/home.tera.html | 59 + .../templates}/layout.tera.html | 20 +- frontend/templates/list-edit.tera.html | 65 + .../templates}/lists.tera.html | 10 +- frontend/templates/login.tera.html | 18 + frontend/templates/page.tera.html | 205 +++ frontend/templates/post-edit.tera.html | 284 ++++ frontend/templates/post-email.tera.html | 139 ++ frontend/templates/post-list.tera.html | 75 + frontend/templates/post-send.tera.html | 20 + frontend/templates/post-sent.tera.html | 36 + frontend/templates/post.tera.html | 10 + frontend/templates/register.tera.html | 33 + frontend/templates/temp.html | 37 + frontend/templates/test.css | 29 + package-lock.json | 234 ++- package.json | 18 +- scripts/deploy.sh | 2 +- src/app/mod.rs | 4 +- src/utils/tera.rs | 2 +- templates/event-create.tera.html | 45 - templates/event-list.tera.html | 35 - templates/event.tera.html | 49 - templates/home.tera.html | 55 - templates/list-edit.tera.html | 46 - templates/login.tera.html | 18 - templates/page.tera.html | 199 --- templates/post-edit.tera.html | 273 ---- templates/post-email.tera.html | 131 -- templates/post-list.tera.html | 70 - templates/post-send.tera.html | 20 - templates/post-sent.tera.html | 33 - templates/post.tera.html | 8 - templates/register.tera.html | 33 - templates/temp.html | 35 - templates/test.css | 29 - web/styles/main.css | 78 - 52 files changed, 2757 insertions(+), 1719 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 assets/dist.css create mode 100644 deno.lock create mode 100644 frontend/.prettierignore create mode 100644 frontend/prettier.config.cjs rename {assets => frontend/static}/favicon.ico (100%) rename {assets => frontend/static}/markdown.js (100%) create mode 100644 frontend/styles/main.css create mode 100644 frontend/templates/event-create.tera.html create mode 100644 frontend/templates/event-list.tera.html create mode 100644 frontend/templates/event.tera.html create mode 100644 frontend/templates/home.tera.html rename {templates => frontend/templates}/layout.tera.html (53%) create mode 100644 frontend/templates/list-edit.tera.html rename {templates => frontend/templates}/lists.tera.html (59%) create mode 100644 frontend/templates/login.tera.html create mode 100644 frontend/templates/page.tera.html create mode 100644 frontend/templates/post-edit.tera.html create mode 100644 frontend/templates/post-email.tera.html create mode 100644 frontend/templates/post-list.tera.html create mode 100644 frontend/templates/post-send.tera.html create mode 100644 frontend/templates/post-sent.tera.html create mode 100644 frontend/templates/post.tera.html create mode 100644 frontend/templates/register.tera.html create mode 100644 frontend/templates/temp.html create mode 100644 frontend/templates/test.css delete mode 100644 templates/event-create.tera.html delete mode 100644 templates/event-list.tera.html delete mode 100644 templates/event.tera.html delete mode 100644 templates/home.tera.html delete mode 100644 templates/list-edit.tera.html delete mode 100644 templates/login.tera.html delete mode 100644 templates/page.tera.html delete mode 100644 templates/post-edit.tera.html delete mode 100644 templates/post-email.tera.html delete mode 100644 templates/post-list.tera.html delete mode 100644 templates/post-send.tera.html delete mode 100644 templates/post-sent.tera.html delete mode 100644 templates/post.tera.html delete mode 100644 templates/register.tera.html delete mode 100644 templates/temp.html delete mode 100644 templates/test.css delete mode 100644 web/styles/main.css diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 306e7c10..17ddc9f2 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -17,6 +17,12 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: install gcc-aarch64-linux-gnu run: sudo apt install -y gcc-aarch64-linux-gnu + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - run: + deno install --allow-scripts + deno task build:styles.min - name: Setup SSH key run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a4006c96..b83f5b20 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -28,6 +28,19 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cargo fmt --check + prettier: + name: Format frontend + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - run: + deno install --global npm:prettier + deno run -A npm:prettier templates styles --check + working-directory: ./frontend + clippy: name: Lint runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index 1fc44349..7093f6b4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ Cargo.lock *.sqlite-* .DS_Store node_modules +frontend/static/dist diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..13e94334 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jinja-html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.associations": { + "*.css": "tailwindcss", + "*.tera.html": "jinja-html" + }, +} \ No newline at end of file diff --git a/README.md b/README.md index 7696f17b..19c6b8cc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ You can optionally install the [livereload][https://www.npmjs.com/package/livere ```sh npm install -# Watch for changes to ./web/styles/main.css, start livereload server +# Watch for changes to ./frontend/styles/main.css, start livereload server npm run watch # Build styles minified npm run build:styles.min diff --git a/assets/dist.css b/assets/dist.css deleted file mode 100644 index c64d8b66..00000000 --- a/assets/dist.css +++ /dev/null @@ -1,473 +0,0 @@ -/*! tailwindcss v4.0.17 | MIT License | https://tailwindcss.com */ -@layer theme, base, components, utilities; -@layer theme { - :root, :host { - --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", - "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - --color-neutral-600: oklch(0.439 0 0); - --color-neutral-700: oklch(0.371 0 0); - --color-neutral-800: oklch(0.269 0 0); - --color-neutral-900: oklch(0.205 0 0); - --color-neutral-950: oklch(0.145 0 0); - --color-black: #000; - --color-white: #fff; - --spacing: 0.25rem; - --container-3xl: 48rem; - --container-4xl: 56rem; - --text-sm: 0.875rem; - --text-sm--line-height: calc(1.25 / 0.875); - --text-xl: 1.25rem; - --text-xl--line-height: calc(1.75 / 1.25); - --text-5xl: 3rem; - --text-5xl--line-height: 1; - --text-9xl: 8rem; - --text-9xl--line-height: 1; - --font-weight-bold: 700; - --font-weight-black: 900; - --radius-sm: 0.25rem; - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --default-font-family: var(--font-sans); - --default-mono-font-family: var(--font-mono); - } -} -@layer base { - *, ::after, ::before, ::backdrop, ::file-selector-button { - box-sizing: border-box; - margin: 0; - padding: 0; - border: 0 solid; - } - html, :host { - line-height: 1.5; - -webkit-text-size-adjust: 100%; - tab-size: 4; - font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); - font-feature-settings: var(--default-font-feature-settings, normal); - font-variation-settings: var(--default-font-variation-settings, normal); - -webkit-tap-highlight-color: transparent; - } - hr { - height: 0; - color: inherit; - border-top-width: 1px; - } - abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; - } - h1, h2, h3, h4, h5, h6 { - font-size: inherit; - font-weight: inherit; - } - a { - color: inherit; - -webkit-text-decoration: inherit; - text-decoration: inherit; - } - b, strong { - font-weight: bolder; - } - code, kbd, samp, pre { - font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); - font-feature-settings: var(--default-mono-font-feature-settings, normal); - font-variation-settings: var(--default-mono-font-variation-settings, normal); - font-size: 1em; - } - small { - font-size: 80%; - } - sub, sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; - } - sub { - bottom: -0.25em; - } - sup { - top: -0.5em; - } - table { - text-indent: 0; - border-color: inherit; - border-collapse: collapse; - } - :-moz-focusring { - outline: auto; - } - progress { - vertical-align: baseline; - } - summary { - display: list-item; - } - ol, ul, menu { - list-style: none; - } - img, svg, video, canvas, audio, iframe, embed, object { - display: block; - vertical-align: middle; - } - img, video { - max-width: 100%; - height: auto; - } - button, input, select, optgroup, textarea, ::file-selector-button { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - border-radius: 0; - background-color: transparent; - opacity: 1; - } - :where(select:is([multiple], [size])) optgroup { - font-weight: bolder; - } - :where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; - } - ::file-selector-button { - margin-inline-end: 4px; - } - ::placeholder { - opacity: 1; - } - @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { - ::placeholder { - color: color-mix(in oklab, currentColor 50%, transparent); - } - } - textarea { - resize: vertical; - } - ::-webkit-search-decoration { - -webkit-appearance: none; - } - ::-webkit-date-and-time-value { - min-height: 1lh; - text-align: inherit; - } - ::-webkit-datetime-edit { - display: inline-flex; - } - ::-webkit-datetime-edit-fields-wrapper { - padding: 0; - } - ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { - padding-block: 0; - } - :-moz-ui-invalid { - box-shadow: none; - } - button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { - appearance: button; - } - ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { - height: auto; - } - [hidden]:where(:not([hidden="until-found"])) { - display: none !important; - } -} -@layer utilities { - .absolute { - position: absolute; - } - .static { - position: static; - } - .container { - width: 100%; - @media (width >= 40rem) { - max-width: 40rem; - } - @media (width >= 48rem) { - max-width: 48rem; - } - @media (width >= 64rem) { - max-width: 64rem; - } - @media (width >= 80rem) { - max-width: 80rem; - } - @media (width >= 96rem) { - max-width: 96rem; - } - } - .block { - display: block; - } - .contents { - display: contents; - } - .flex { - display: flex; - } - .hidden { - display: none; - } - .table { - display: table; - } - .w-full { - width: 100%; - } - .max-w-4xl { - max-width: var(--container-4xl); - } - .flex-grow { - flex-grow: 1; - } - .resize { - resize: both; - } - .border { - border-style: var(--tw-border-style); - border-width: 1px; - } - .border-b { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 1px; - } - .border-neutral-800 { - border-color: var(--color-neutral-800); - } - .p-4 { - padding: calc(var(--spacing) * 4); - } - .text-5xl { - font-size: var(--text-5xl); - line-height: var(--tw-leading, var(--text-5xl--line-height)); - } - .font-black { - --tw-font-weight: var(--font-weight-black); - font-weight: var(--font-weight-black); - } - .font-bold { - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); - } - .italic { - font-style: italic; - } - .ordinal { - --tw-ordinal: ordinal; - font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); - } - .underline { - text-decoration-line: underline; - } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } - .filter { - filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); - } - .ease-in-out { - --tw-ease: var(--ease-in-out); - transition-timing-function: var(--ease-in-out); - } - .lg\:text-9xl { - @media (width >= 64rem) { - font-size: var(--text-9xl); - line-height: var(--tw-leading, var(--text-9xl--line-height)); - } - } -} -html, body { - height: 100%; - width: 100%; - background-color: var(--color-black); - color: var(--color-white); -} -header { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 1px; - border-color: var(--color-neutral-700); - padding: calc(var(--spacing) * 4); -} -article { - margin-inline: auto; - max-width: var(--container-3xl); - padding: calc(var(--spacing) * 4); - time { - margin-bottom: calc(var(--spacing) * 8); - display: block; - font-size: var(--text-sm); - line-height: var(--tw-leading, var(--text-sm--line-height)); - text-transform: uppercase; - } - p { - margin-bottom: calc(var(--spacing) * 5); - &:last-child { - margin-bottom: calc(var(--spacing) * 0); - } - } - blockquote { - margin-block: calc(var(--spacing) * 4); - border-left-style: var(--tw-border-style); - border-left-width: 4px; - padding-inline: calc(var(--spacing) * 8); - padding-block: calc(var(--spacing) * 6); - font-style: italic; - } - img { - margin-block: calc(var(--spacing) * 8); - display: block; - height: auto; - max-width: 100%; - } -} -h2 { - margin-bottom: calc(var(--spacing) * 1); - font-size: var(--text-xl); - line-height: var(--tw-leading, var(--text-xl--line-height)); - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); -} -a { - text-decoration-line: underline; -} -button, .button { - display: block; - cursor: pointer; - background-color: var(--color-neutral-900); - padding-inline: calc(var(--spacing) * 4); - padding-block: calc(var(--spacing) * 2); - &:hover { - @media (hover: hover) { - background-color: var(--color-neutral-800); - } - } - border-radius: var(--radius-sm); -} -ul { - list-style-position: inside; -} -label { - margin-bottom: calc(var(--spacing) * 2); - display: block; -} -input[type="text"] { - width: 100%; - border-style: var(--tw-border-style); - border-width: 1px; - border-color: var(--color-neutral-700); - background-color: var(--color-neutral-950); - padding: calc(var(--spacing) * 2); -} -textarea { - height: 50vh; - width: 100%; - flex: 1; - resize: vertical; - background-color: var(--color-neutral-900); - padding: calc(var(--spacing) * 2); - border-style: var(--tw-border-style); - border-width: 1px; - border-color: var(--color-neutral-700); - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: var(--tw-leading, var(--text-sm--line-height)); -} -select { - border-style: var(--tw-border-style); - border-width: 1px; - border-color: var(--color-neutral-600); - background-color: var(--color-neutral-950); -} -form { - display: flex; - flex-direction: column; - padding: calc(var(--spacing) * 8); - .field { - margin-bottom: calc(var(--spacing) * 4); - display: flex; - flex-direction: column; - padding-bottom: calc(var(--spacing) * 4); - } -} -@property --tw-border-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} -@property --tw-font-weight { - syntax: "*"; - inherits: false; -} -@property --tw-ordinal { - syntax: "*"; - inherits: false; -} -@property --tw-slashed-zero { - syntax: "*"; - inherits: false; -} -@property --tw-numeric-figure { - syntax: "*"; - inherits: false; -} -@property --tw-numeric-spacing { - syntax: "*"; - inherits: false; -} -@property --tw-numeric-fraction { - syntax: "*"; - inherits: false; -} -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} -@property --tw-blur { - syntax: "*"; - inherits: false; -} -@property --tw-brightness { - syntax: "*"; - inherits: false; -} -@property --tw-contrast { - syntax: "*"; - inherits: false; -} -@property --tw-grayscale { - syntax: "*"; - inherits: false; -} -@property --tw-hue-rotate { - syntax: "*"; - inherits: false; -} -@property --tw-invert { - syntax: "*"; - inherits: false; -} -@property --tw-opacity { - syntax: "*"; - inherits: false; -} -@property --tw-saturate { - syntax: "*"; - inherits: false; -} -@property --tw-sepia { - syntax: "*"; - inherits: false; -} -@property --tw-drop-shadow { - syntax: "*"; - inherits: false; -} -@property --tw-ease { - syntax: "*"; - inherits: false; -} diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000..0ebd1a33 --- /dev/null +++ b/deno.lock @@ -0,0 +1,1275 @@ +{ + "version": "4", + "specifiers": { + "npm:@tailwindcss/cli@^4.1.2": "4.1.2", + "npm:livereload@~0.9.3": "0.9.3", + "npm:npm-run-all@^4.1.5": "4.1.5", + "npm:prettier-plugin-jinja-template@2": "2.0.0_prettier@3.5.3", + "npm:prettier-plugin-tailwindcss@~0.6.11": "0.6.11_prettier@3.5.3", + "npm:prettier@^3.5.3": "3.5.3" + }, + "npm": { + "@parcel/watcher-android-arm64@2.5.1": { + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==" + }, + "@parcel/watcher-darwin-arm64@2.5.1": { + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==" + }, + "@parcel/watcher-darwin-x64@2.5.1": { + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==" + }, + "@parcel/watcher-freebsd-x64@2.5.1": { + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==" + }, + "@parcel/watcher-linux-arm-glibc@2.5.1": { + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==" + }, + "@parcel/watcher-linux-arm-musl@2.5.1": { + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==" + }, + "@parcel/watcher-linux-arm64-glibc@2.5.1": { + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==" + }, + "@parcel/watcher-linux-arm64-musl@2.5.1": { + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==" + }, + "@parcel/watcher-linux-x64-glibc@2.5.1": { + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==" + }, + "@parcel/watcher-linux-x64-musl@2.5.1": { + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==" + }, + "@parcel/watcher-win32-arm64@2.5.1": { + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==" + }, + "@parcel/watcher-win32-ia32@2.5.1": { + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==" + }, + "@parcel/watcher-win32-x64@2.5.1": { + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==" + }, + "@parcel/watcher@2.5.1": { + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dependencies": [ + "@parcel/watcher-android-arm64", + "@parcel/watcher-darwin-arm64", + "@parcel/watcher-darwin-x64", + "@parcel/watcher-freebsd-x64", + "@parcel/watcher-linux-arm-glibc", + "@parcel/watcher-linux-arm-musl", + "@parcel/watcher-linux-arm64-glibc", + "@parcel/watcher-linux-arm64-musl", + "@parcel/watcher-linux-x64-glibc", + "@parcel/watcher-linux-x64-musl", + "@parcel/watcher-win32-arm64", + "@parcel/watcher-win32-ia32", + "@parcel/watcher-win32-x64", + "detect-libc@1.0.3", + "is-glob", + "micromatch", + "node-addon-api" + ] + }, + "@tailwindcss/cli@4.1.2": { + "integrity": "sha512-HaPFz9GNbBLgV9vsSD818HCvf598D24ZOZlCdth/Y3jk1BZY69UD99e4pcfifT8msFg4xYI+uxEv5N1MYao1Mg==", + "dependencies": [ + "@parcel/watcher", + "@tailwindcss/node", + "@tailwindcss/oxide", + "enhanced-resolve", + "mri", + "picocolors", + "tailwindcss" + ] + }, + "@tailwindcss/node@4.1.2": { + "integrity": "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ==", + "dependencies": [ + "enhanced-resolve", + "jiti", + "lightningcss", + "tailwindcss" + ] + }, + "@tailwindcss/oxide-android-arm64@4.1.2": { + "integrity": "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w==" + }, + "@tailwindcss/oxide-darwin-arm64@4.1.2": { + "integrity": "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA==" + }, + "@tailwindcss/oxide-darwin-x64@4.1.2": { + "integrity": "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw==" + }, + "@tailwindcss/oxide-freebsd-x64@4.1.2": { + "integrity": "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ==" + }, + "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.2": { + "integrity": "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow==" + }, + "@tailwindcss/oxide-linux-arm64-gnu@4.1.2": { + "integrity": "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw==" + }, + "@tailwindcss/oxide-linux-arm64-musl@4.1.2": { + "integrity": "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg==" + }, + "@tailwindcss/oxide-linux-x64-gnu@4.1.2": { + "integrity": "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ==" + }, + "@tailwindcss/oxide-linux-x64-musl@4.1.2": { + "integrity": "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A==" + }, + "@tailwindcss/oxide-win32-arm64-msvc@4.1.2": { + "integrity": "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg==" + }, + "@tailwindcss/oxide-win32-x64-msvc@4.1.2": { + "integrity": "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw==" + }, + "@tailwindcss/oxide@4.1.2": { + "integrity": "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ==", + "dependencies": [ + "@tailwindcss/oxide-android-arm64", + "@tailwindcss/oxide-darwin-arm64", + "@tailwindcss/oxide-darwin-x64", + "@tailwindcss/oxide-freebsd-x64", + "@tailwindcss/oxide-linux-arm-gnueabihf", + "@tailwindcss/oxide-linux-arm64-gnu", + "@tailwindcss/oxide-linux-arm64-musl", + "@tailwindcss/oxide-linux-x64-gnu", + "@tailwindcss/oxide-linux-x64-musl", + "@tailwindcss/oxide-win32-arm64-msvc", + "@tailwindcss/oxide-win32-x64-msvc" + ] + }, + "ansi-styles@3.2.1": { + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": [ + "color-convert" + ] + }, + "anymatch@3.1.3": { + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": [ + "normalize-path", + "picomatch" + ] + }, + "array-buffer-byte-length@1.0.2": { + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dependencies": [ + "call-bound", + "is-array-buffer" + ] + }, + "arraybuffer.prototype.slice@1.0.4": { + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dependencies": [ + "array-buffer-byte-length", + "call-bind", + "define-properties", + "es-abstract", + "es-errors", + "get-intrinsic", + "is-array-buffer" + ] + }, + "async-function@1.0.0": { + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==" + }, + "available-typed-arrays@1.0.7": { + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": [ + "possible-typed-array-names" + ] + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "binary-extensions@2.3.0": { + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==" + }, + "brace-expansion@1.1.11": { + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": [ + "balanced-match", + "concat-map" + ] + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bind@1.0.8": { + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "get-intrinsic", + "set-function-length" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, + "chalk@2.4.2": { + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": [ + "ansi-styles", + "escape-string-regexp", + "supports-color" + ] + }, + "chokidar@3.6.0": { + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": [ + "anymatch", + "braces", + "fsevents", + "glob-parent", + "is-binary-path", + "is-glob", + "normalize-path", + "readdirp" + ] + }, + "color-convert@1.9.3": { + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.3": { + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "concat-map@0.0.1": { + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "cross-spawn@6.0.6": { + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dependencies": [ + "nice-try", + "path-key", + "semver", + "shebang-command", + "which" + ] + }, + "data-view-buffer@1.0.2": { + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dependencies": [ + "call-bound", + "es-errors", + "is-data-view" + ] + }, + "data-view-byte-length@1.0.2": { + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dependencies": [ + "call-bound", + "es-errors", + "is-data-view" + ] + }, + "data-view-byte-offset@1.0.1": { + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dependencies": [ + "call-bound", + "es-errors", + "is-data-view" + ] + }, + "define-data-property@1.1.4": { + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": [ + "es-define-property", + "es-errors", + "gopd" + ] + }, + "define-properties@1.2.1": { + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dependencies": [ + "define-data-property", + "has-property-descriptors", + "object-keys" + ] + }, + "detect-libc@1.0.3": { + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==" + }, + "detect-libc@2.0.3": { + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "enhanced-resolve@5.18.1": { + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dependencies": [ + "graceful-fs", + "tapable" + ] + }, + "error-ex@1.3.2": { + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": [ + "is-arrayish" + ] + }, + "es-abstract@1.23.9": { + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dependencies": [ + "array-buffer-byte-length", + "arraybuffer.prototype.slice", + "available-typed-arrays", + "call-bind", + "call-bound", + "data-view-buffer", + "data-view-byte-length", + "data-view-byte-offset", + "es-define-property", + "es-errors", + "es-object-atoms", + "es-set-tostringtag", + "es-to-primitive", + "function.prototype.name", + "get-intrinsic", + "get-proto", + "get-symbol-description", + "globalthis", + "gopd", + "has-property-descriptors", + "has-proto", + "has-symbols", + "hasown", + "internal-slot", + "is-array-buffer", + "is-callable", + "is-data-view", + "is-regex", + "is-shared-array-buffer", + "is-string", + "is-typed-array", + "is-weakref", + "math-intrinsics", + "object-inspect", + "object-keys", + "object.assign", + "own-keys", + "regexp.prototype.flags", + "safe-array-concat", + "safe-push-apply", + "safe-regex-test", + "set-proto", + "string.prototype.trim", + "string.prototype.trimend", + "string.prototype.trimstart", + "typed-array-buffer", + "typed-array-byte-length", + "typed-array-byte-offset", + "typed-array-length", + "unbox-primitive", + "which-typed-array" + ] + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.1": { + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": [ + "es-errors" + ] + }, + "es-set-tostringtag@2.1.0": { + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": [ + "es-errors", + "get-intrinsic", + "has-tostringtag", + "hasown" + ] + }, + "es-to-primitive@1.3.0": { + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dependencies": [ + "is-callable", + "is-date-object", + "is-symbol" + ] + }, + "escape-string-regexp@1.0.5": { + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, + "for-each@0.3.5": { + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dependencies": [ + "is-callable" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==" + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "function.prototype.name@1.1.8": { + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dependencies": [ + "call-bind", + "call-bound", + "define-properties", + "functions-have-names", + "hasown", + "is-callable" + ] + }, + "functions-have-names@1.2.3": { + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "get-symbol-description@1.1.0": { + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic" + ] + }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, + "globalthis@1.0.4": { + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dependencies": [ + "define-properties", + "gopd" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "graceful-fs@4.2.11": { + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "has-bigints@1.1.0": { + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==" + }, + "has-flag@3.0.0": { + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + }, + "has-property-descriptors@1.0.2": { + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": [ + "es-define-property" + ] + }, + "has-proto@1.2.0": { + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dependencies": [ + "dunder-proto" + ] + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag@1.0.2": { + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": [ + "has-symbols" + ] + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": [ + "function-bind" + ] + }, + "hosted-git-info@2.8.9": { + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" + }, + "internal-slot@1.1.0": { + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dependencies": [ + "es-errors", + "hasown", + "side-channel" + ] + }, + "is-array-buffer@3.0.5": { + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dependencies": [ + "call-bind", + "call-bound", + "get-intrinsic" + ] + }, + "is-arrayish@0.2.1": { + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "is-async-function@2.1.1": { + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dependencies": [ + "async-function", + "call-bound", + "get-proto", + "has-tostringtag", + "safe-regex-test" + ] + }, + "is-bigint@1.1.0": { + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dependencies": [ + "has-bigints" + ] + }, + "is-binary-path@2.1.0": { + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": [ + "binary-extensions" + ] + }, + "is-boolean-object@1.2.2": { + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dependencies": [ + "call-bound", + "has-tostringtag" + ] + }, + "is-callable@1.2.7": { + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" + }, + "is-core-module@2.16.1": { + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dependencies": [ + "hasown" + ] + }, + "is-data-view@1.0.2": { + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dependencies": [ + "call-bound", + "get-intrinsic", + "is-typed-array" + ] + }, + "is-date-object@1.1.0": { + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dependencies": [ + "call-bound", + "has-tostringtag" + ] + }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-finalizationregistry@1.1.1": { + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dependencies": [ + "call-bound" + ] + }, + "is-generator-function@1.1.0": { + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dependencies": [ + "call-bound", + "get-proto", + "has-tostringtag", + "safe-regex-test" + ] + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "is-map@2.0.3": { + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==" + }, + "is-number-object@1.1.1": { + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dependencies": [ + "call-bound", + "has-tostringtag" + ] + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-regex@1.2.1": { + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dependencies": [ + "call-bound", + "gopd", + "has-tostringtag", + "hasown" + ] + }, + "is-set@2.0.3": { + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==" + }, + "is-shared-array-buffer@1.0.4": { + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dependencies": [ + "call-bound" + ] + }, + "is-string@1.1.1": { + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dependencies": [ + "call-bound", + "has-tostringtag" + ] + }, + "is-symbol@1.1.1": { + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dependencies": [ + "call-bound", + "has-symbols", + "safe-regex-test" + ] + }, + "is-typed-array@1.1.15": { + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dependencies": [ + "which-typed-array" + ] + }, + "is-weakmap@2.0.2": { + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==" + }, + "is-weakref@1.1.1": { + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dependencies": [ + "call-bound" + ] + }, + "is-weakset@2.0.4": { + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dependencies": [ + "call-bound", + "get-intrinsic" + ] + }, + "isarray@2.0.5": { + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "jiti@2.4.2": { + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==" + }, + "json-parse-better-errors@1.0.2": { + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" + }, + "lightningcss-darwin-arm64@1.29.2": { + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==" + }, + "lightningcss-darwin-x64@1.29.2": { + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==" + }, + "lightningcss-freebsd-x64@1.29.2": { + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==" + }, + "lightningcss-linux-arm-gnueabihf@1.29.2": { + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==" + }, + "lightningcss-linux-arm64-gnu@1.29.2": { + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==" + }, + "lightningcss-linux-arm64-musl@1.29.2": { + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==" + }, + "lightningcss-linux-x64-gnu@1.29.2": { + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==" + }, + "lightningcss-linux-x64-musl@1.29.2": { + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==" + }, + "lightningcss-win32-arm64-msvc@1.29.2": { + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==" + }, + "lightningcss-win32-x64-msvc@1.29.2": { + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==" + }, + "lightningcss@1.29.2": { + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dependencies": [ + "detect-libc@2.0.3", + "lightningcss-darwin-arm64", + "lightningcss-darwin-x64", + "lightningcss-freebsd-x64", + "lightningcss-linux-arm-gnueabihf", + "lightningcss-linux-arm64-gnu", + "lightningcss-linux-arm64-musl", + "lightningcss-linux-x64-gnu", + "lightningcss-linux-x64-musl", + "lightningcss-win32-arm64-msvc", + "lightningcss-win32-x64-msvc" + ] + }, + "livereload-js@3.4.1": { + "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==" + }, + "livereload@0.9.3": { + "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", + "dependencies": [ + "chokidar", + "livereload-js", + "opts", + "ws" + ] + }, + "load-json-file@4.0.0": { + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dependencies": [ + "graceful-fs", + "parse-json", + "pify", + "strip-bom" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "memorystream@0.3.1": { + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==" + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch" + ] + }, + "minimatch@3.1.2": { + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": [ + "brace-expansion" + ] + }, + "mri@1.2.0": { + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" + }, + "nice-try@1.0.5": { + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" + }, + "node-addon-api@7.1.1": { + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" + }, + "normalize-package-data@2.5.0": { + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dependencies": [ + "hosted-git-info", + "resolve", + "semver", + "validate-npm-package-license" + ] + }, + "normalize-path@3.0.0": { + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "npm-run-all@4.1.5": { + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dependencies": [ + "ansi-styles", + "chalk", + "cross-spawn", + "memorystream", + "minimatch", + "pidtree", + "read-pkg", + "shell-quote", + "string.prototype.padend" + ] + }, + "object-inspect@1.13.4": { + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "object-keys@1.1.1": { + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, + "object.assign@4.1.7": { + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dependencies": [ + "call-bind", + "call-bound", + "define-properties", + "es-object-atoms", + "has-symbols", + "object-keys" + ] + }, + "opts@2.0.2": { + "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==" + }, + "own-keys@1.0.1": { + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dependencies": [ + "get-intrinsic", + "object-keys", + "safe-push-apply" + ] + }, + "parse-json@4.0.0": { + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dependencies": [ + "error-ex", + "json-parse-better-errors" + ] + }, + "path-key@2.0.1": { + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==" + }, + "path-parse@1.0.7": { + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "path-type@3.0.0": { + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dependencies": [ + "pify" + ] + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "pidtree@0.3.1": { + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==" + }, + "pify@3.0.0": { + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==" + }, + "possible-typed-array-names@1.1.0": { + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" + }, + "prettier-plugin-jinja-template@2.0.0_prettier@3.5.3": { + "integrity": "sha512-REZDAcZuOUvMDaPS47/GNRLKvbxh9DO9euXhWA7gJGqTLGzHPK2Z841F8I4bxsR7e2lqnHezkQ8GcWaKekKBVQ==", + "dependencies": [ + "prettier" + ] + }, + "prettier-plugin-tailwindcss@0.6.11_prettier@3.5.3": { + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "dependencies": [ + "prettier" + ] + }, + "prettier@3.5.3": { + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==" + }, + "read-pkg@3.0.0": { + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dependencies": [ + "load-json-file", + "normalize-package-data", + "path-type" + ] + }, + "readdirp@3.6.0": { + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": [ + "picomatch" + ] + }, + "reflect.getprototypeof@1.0.10": { + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dependencies": [ + "call-bind", + "define-properties", + "es-abstract", + "es-errors", + "es-object-atoms", + "get-intrinsic", + "get-proto", + "which-builtin-type" + ] + }, + "regexp.prototype.flags@1.5.4": { + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dependencies": [ + "call-bind", + "define-properties", + "es-errors", + "get-proto", + "gopd", + "set-function-name" + ] + }, + "resolve@1.22.10": { + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": [ + "is-core-module", + "path-parse", + "supports-preserve-symlinks-flag" + ] + }, + "safe-array-concat@1.1.3": { + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dependencies": [ + "call-bind", + "call-bound", + "get-intrinsic", + "has-symbols", + "isarray" + ] + }, + "safe-push-apply@1.0.0": { + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dependencies": [ + "es-errors", + "isarray" + ] + }, + "safe-regex-test@1.1.0": { + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dependencies": [ + "call-bound", + "es-errors", + "is-regex" + ] + }, + "semver@5.7.2": { + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==" + }, + "set-function-length@1.2.2": { + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": [ + "define-data-property", + "es-errors", + "function-bind", + "get-intrinsic", + "gopd", + "has-property-descriptors" + ] + }, + "set-function-name@2.0.2": { + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dependencies": [ + "define-data-property", + "es-errors", + "functions-have-names", + "has-property-descriptors" + ] + }, + "set-proto@1.0.0": { + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dependencies": [ + "dunder-proto", + "es-errors", + "es-object-atoms" + ] + }, + "shebang-command@1.2.0": { + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@1.0.0": { + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==" + }, + "shell-quote@1.8.2": { + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==" + }, + "side-channel-list@1.0.0": { + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": [ + "es-errors", + "object-inspect" + ] + }, + "side-channel-map@1.0.1": { + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect" + ] + }, + "side-channel-weakmap@1.0.2": { + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect", + "side-channel-map" + ] + }, + "side-channel@1.1.0": { + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": [ + "es-errors", + "object-inspect", + "side-channel-list", + "side-channel-map", + "side-channel-weakmap" + ] + }, + "spdx-correct@3.2.0": { + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dependencies": [ + "spdx-expression-parse", + "spdx-license-ids" + ] + }, + "spdx-exceptions@2.5.0": { + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==" + }, + "spdx-expression-parse@3.0.1": { + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dependencies": [ + "spdx-exceptions", + "spdx-license-ids" + ] + }, + "spdx-license-ids@3.0.21": { + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==" + }, + "string.prototype.padend@3.1.6": { + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dependencies": [ + "call-bind", + "define-properties", + "es-abstract", + "es-object-atoms" + ] + }, + "string.prototype.trim@1.2.10": { + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dependencies": [ + "call-bind", + "call-bound", + "define-data-property", + "define-properties", + "es-abstract", + "es-object-atoms", + "has-property-descriptors" + ] + }, + "string.prototype.trimend@1.0.9": { + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dependencies": [ + "call-bind", + "call-bound", + "define-properties", + "es-object-atoms" + ] + }, + "string.prototype.trimstart@1.0.8": { + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dependencies": [ + "call-bind", + "define-properties", + "es-object-atoms" + ] + }, + "strip-bom@3.0.0": { + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==" + }, + "supports-color@5.5.0": { + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": [ + "has-flag" + ] + }, + "supports-preserve-symlinks-flag@1.0.0": { + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "tailwindcss@4.1.2": { + "integrity": "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw==" + }, + "tapable@2.2.1": { + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" + }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, + "typed-array-buffer@1.0.3": { + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dependencies": [ + "call-bound", + "es-errors", + "is-typed-array" + ] + }, + "typed-array-byte-length@1.0.3": { + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dependencies": [ + "call-bind", + "for-each", + "gopd", + "has-proto", + "is-typed-array" + ] + }, + "typed-array-byte-offset@1.0.4": { + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dependencies": [ + "available-typed-arrays", + "call-bind", + "for-each", + "gopd", + "has-proto", + "is-typed-array", + "reflect.getprototypeof" + ] + }, + "typed-array-length@1.0.7": { + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dependencies": [ + "call-bind", + "for-each", + "gopd", + "is-typed-array", + "possible-typed-array-names", + "reflect.getprototypeof" + ] + }, + "unbox-primitive@1.1.0": { + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dependencies": [ + "call-bound", + "has-bigints", + "has-symbols", + "which-boxed-primitive" + ] + }, + "validate-npm-package-license@3.0.4": { + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dependencies": [ + "spdx-correct", + "spdx-expression-parse" + ] + }, + "which-boxed-primitive@1.1.1": { + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dependencies": [ + "is-bigint", + "is-boolean-object", + "is-number-object", + "is-string", + "is-symbol" + ] + }, + "which-builtin-type@1.2.1": { + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dependencies": [ + "call-bound", + "function.prototype.name", + "has-tostringtag", + "is-async-function", + "is-date-object", + "is-finalizationregistry", + "is-generator-function", + "is-regex", + "is-weakref", + "isarray", + "which-boxed-primitive", + "which-collection", + "which-typed-array" + ] + }, + "which-collection@1.0.2": { + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dependencies": [ + "is-map", + "is-set", + "is-weakmap", + "is-weakset" + ] + }, + "which-typed-array@1.1.19": { + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dependencies": [ + "available-typed-arrays", + "call-bind", + "call-bound", + "for-each", + "get-proto", + "gopd", + "has-tostringtag" + ] + }, + "which@1.3.1": { + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": [ + "isexe" + ] + }, + "ws@7.5.10": { + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==" + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@tailwindcss/cli@^4.1.2", + "npm:livereload@~0.9.3", + "npm:npm-run-all@^4.1.5", + "npm:prettier-plugin-jinja-template@2", + "npm:prettier-plugin-tailwindcss@~0.6.11", + "npm:prettier@^3.5.3" + ] + } + } +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000..735057d5 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,2 @@ +# This file is currently unparsable because of the tera macros +frontend/templates/page.tera.html \ No newline at end of file diff --git a/frontend/prettier.config.cjs b/frontend/prettier.config.cjs new file mode 100644 index 00000000..88b918ea --- /dev/null +++ b/frontend/prettier.config.cjs @@ -0,0 +1,16 @@ +const config = { + plugins: [ + require.resolve("prettier-plugin-jinja-template"), + require.resolve("prettier-plugin-tailwindcss"), + ], + overrides: [ + { + files: ["*.tera.html"], + options: { + parser: "jinja-template", + }, + }, + ], +}; + +module.exports = config; diff --git a/assets/favicon.ico b/frontend/static/favicon.ico similarity index 100% rename from assets/favicon.ico rename to frontend/static/favicon.ico diff --git a/assets/markdown.js b/frontend/static/markdown.js similarity index 100% rename from assets/markdown.js rename to frontend/static/markdown.js diff --git a/frontend/styles/main.css b/frontend/styles/main.css new file mode 100644 index 00000000..7c80c0b8 --- /dev/null +++ b/frontend/styles/main.css @@ -0,0 +1,78 @@ +@import "tailwindcss"; + +/* + * TODO: Refactor styles into Tailwind utility classes + * Below styles are from page.tera.html + */ + +html, +body { + @apply h-full w-full bg-black text-white; +} + +header { + @apply border-b border-neutral-700 p-4; +} + +article { + @apply mx-auto max-w-3xl p-4; + + time { + @apply mb-8 block text-sm uppercase; + } + + p { + @apply mb-5 last:mb-0; + } + + blockquote { + @apply my-4 border-l-[4px] px-8 py-6 italic; + } + + img { + @apply my-8 block h-auto max-w-full; + } +} + +h2 { + @apply mb-1 text-xl font-bold; +} + +a { + @apply underline; +} + +button, +.button { + @apply block cursor-pointer bg-neutral-900 px-4 py-2 hover:bg-neutral-800; + @apply rounded-sm; +} + +ul { + @apply list-inside; +} + +label { + @apply mb-2 block; +} + +input[type="text"] { + @apply w-full border border-neutral-700 bg-neutral-950 p-2; +} + +textarea { + @apply h-[50vh] w-full flex-1 resize-y bg-neutral-900 p-2; + @apply border border-neutral-700 font-mono text-sm; +} + +select { + @apply border border-neutral-600 bg-neutral-950; +} + +form { + @apply flex flex-col p-8; + + .field { + @apply mb-4 flex flex-col pb-4; + } +} diff --git a/frontend/templates/event-create.tera.html b/frontend/templates/event-create.tera.html new file mode 100644 index 00000000..a0962057 --- /dev/null +++ b/frontend/templates/event-create.tera.html @@ -0,0 +1,45 @@ + + + + + + + WLSD + + + +
      +

      Let's Create an Event

      + + + + + + + + + + + + + + + + + + +
      + + diff --git a/frontend/templates/event-list.tera.html b/frontend/templates/event-list.tera.html new file mode 100644 index 00000000..a13c2a91 --- /dev/null +++ b/frontend/templates/event-list.tera.html @@ -0,0 +1,38 @@ + + + + + + WLSD + + + +

      Upcoming Events:

      + {% for event in events %} + + {% endfor %} + + diff --git a/frontend/templates/event.tera.html b/frontend/templates/event.tera.html new file mode 100644 index 00000000..c903df37 --- /dev/null +++ b/frontend/templates/event.tera.html @@ -0,0 +1,55 @@ + + + + + + WLSD + + + + {% if event %} +

      Update Event: {{ event.title }}

      +
      + + + + + + + + + + + + + + + +
      +
      + +
      + {% else %} +

      Event does not exist...

      + {% endif %} + + diff --git a/frontend/templates/home.tera.html b/frontend/templates/home.tera.html new file mode 100644 index 00000000..881fb921 --- /dev/null +++ b/frontend/templates/home.tera.html @@ -0,0 +1,59 @@ +{% extends "layout.tera.html" %} + +{% block title %}light and sound{% endblock title %} + +{% block styles %} + + {{ super() }} + + +{% endblock styles %} + +{% block content %} +
      +

      + Coming Soon... +

      +

      + Coming Soon... +

      +
      +{% endblock content %} diff --git a/templates/layout.tera.html b/frontend/templates/layout.tera.html similarity index 53% rename from templates/layout.tera.html rename to frontend/templates/layout.tera.html index c90c5790..5e0e3115 100644 --- a/templates/layout.tera.html +++ b/frontend/templates/layout.tera.html @@ -6,22 +6,24 @@ {% block title %}light and sound design{% endblock title %} - - + + {% block styles %} {% endblock styles %} {% block header %} -
      - -
      +
      + +
      {% endblock header %}
      {% block content %}{% endblock content %} -
      + diff --git a/frontend/templates/list-edit.tera.html b/frontend/templates/list-edit.tera.html new file mode 100644 index 00000000..829d0de3 --- /dev/null +++ b/frontend/templates/list-edit.tera.html @@ -0,0 +1,65 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Edit list - " ~ list.name) }} + +
      + {% if list.id != 0 %} + + {% endif %} +
      + + +
      +
      + + +
      + {% if list.id != 0 %} +
      + +
        + {% for member in members %} +
      • + + {{ member.email }} + {% if member.first_name %} + ({{ member.first_name }} + {{ member.last_name }}) + {% endif %} +
      • + {% endfor %} +
      +
      + {% endif %} +
      + + +
      + +
      +{{ page::end() }} diff --git a/templates/lists.tera.html b/frontend/templates/lists.tera.html similarity index 59% rename from templates/lists.tera.html rename to frontend/templates/lists.tera.html index e4595206..445c2464 100644 --- a/templates/lists.tera.html +++ b/frontend/templates/lists.tera.html @@ -1,15 +1,15 @@ {% import "page.tera.html" as page %} {{ page::start(title="Guestlists") }} {{ page::end() }} diff --git a/frontend/templates/login.tera.html b/frontend/templates/login.tera.html new file mode 100644 index 00000000..d609c247 --- /dev/null +++ b/frontend/templates/login.tera.html @@ -0,0 +1,18 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="light and sound - login") }} + +
      +
      + + +
      +
      + +
      +
      +{{ page::end() }} diff --git a/frontend/templates/page.tera.html b/frontend/templates/page.tera.html new file mode 100644 index 00000000..47b263f9 --- /dev/null +++ b/frontend/templates/page.tera.html @@ -0,0 +1,205 @@ +{% macro start(title) %} + + + + + + {{ title }} + + + +
      + +
      +
      +{% endmacro start %} +{% macro end() %} +
      +
      + + +{% endmacro end %} diff --git a/frontend/templates/post-edit.tera.html b/frontend/templates/post-edit.tera.html new file mode 100644 index 00000000..ccd8fe38 --- /dev/null +++ b/frontend/templates/post-edit.tera.html @@ -0,0 +1,284 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Edit post - " ~ post.title) }} + + + +
      + {% if post.id != 0 %} + + {% endif %} +
      + + +
      +
      + +
      + + +
      +
      +
      + + +
      +
      + +
      +
      +
      +
      +
      +
      + +{{ page::end() }} diff --git a/frontend/templates/post-email.tera.html b/frontend/templates/post-email.tera.html new file mode 100644 index 00000000..b69075f9 --- /dev/null +++ b/frontend/templates/post-email.tera.html @@ -0,0 +1,139 @@ + + + + + + + + {{ post.title }} + + + + +
      + +
      +
      +
      +

      {{ post.title }}

      + + {{ post.content | safe }} + footer +
      +
      + + + diff --git a/frontend/templates/post-list.tera.html b/frontend/templates/post-list.tera.html new file mode 100644 index 00000000..24e7d18a --- /dev/null +++ b/frontend/templates/post-list.tera.html @@ -0,0 +1,75 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Posts") }} + +
      +
      +

      Posts

      + New Post +
      + {% for post in posts %} +
      +

      {{ post.title }}

      +
      + By {{ post.author }} • Updated + +
      +
      + Edit + +
      + +
      +
      +
      + {% endfor %} +
      +{{ page::end() }} diff --git a/frontend/templates/post-send.tera.html b/frontend/templates/post-send.tera.html new file mode 100644 index 00000000..cc2584cd --- /dev/null +++ b/frontend/templates/post-send.tera.html @@ -0,0 +1,20 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Send post - " ~ post.title) }} + +
      +

      {{ post.title }}

      +
      + + +
      + +
      +{{ page::end() }} diff --git a/frontend/templates/post-sent.tera.html b/frontend/templates/post-sent.tera.html new file mode 100644 index 00000000..35e62033 --- /dev/null +++ b/frontend/templates/post-sent.tera.html @@ -0,0 +1,36 @@ +{% import "page.tera.html" as page %} +{{ page::start(title="Sent post - " ~ post.title) }} + +
      +

      {{ post.title }}

      +
        +
      • Sent {{ stats.num_sent }} emails to list "{{ list.name }}"
      • + + {% if stats.num_skipped > 0 %} +
      • + Skipped sending {{ stats.num_skipped }} emails which were already + delivered +
      • + {% endif %} + + {% if stats.errors | length > 0 %} +
      • + Failed to send {{ stats.errors | length }} emails +
          + {% for email, error in stats.errors %} +
        • {{ email }}: {{ error }}
        • + {% endfor %} +
        • foo@foo.com: test
        • +
        +
      • + {% endif %} +
      + {{ page::end() }} +
      diff --git a/frontend/templates/post.tera.html b/frontend/templates/post.tera.html new file mode 100644 index 00000000..6c55ad42 --- /dev/null +++ b/frontend/templates/post.tera.html @@ -0,0 +1,10 @@ +{% import "page.tera.html" as page %} +{{ page::start(title=post.title) }} +
      +

      {{ post.title }}

      + + {{ post.content | safe }} +
      +{{ page::end() }} diff --git a/frontend/templates/register.tera.html b/frontend/templates/register.tera.html new file mode 100644 index 00000000..faee7810 --- /dev/null +++ b/frontend/templates/register.tera.html @@ -0,0 +1,33 @@ + + + + + + + WLSD + + + +
      +

      Register

      +
      + + + + + + + + +
      +
      + + diff --git a/frontend/templates/temp.html b/frontend/templates/temp.html new file mode 100644 index 00000000..b85bd127 --- /dev/null +++ b/frontend/templates/temp.html @@ -0,0 +1,37 @@ +Hello! I was so excited to get Zoë’s first newsletter out earlier this week that +we forgot to list a few things, and have since announced another Dleepover! Hope +you don’t mind getting two emails from us this week. I do my best to be mindful +of your attention spans.... +![poster](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcaeb3c03-0c33-4b2d-ba98-13e00050bdbc_2232x2790.jpeg) +[12.02.2024 Deep Creep and Ando will Present Sounds. Food by Live +Canteen.](https://www.eventcreate.com/e/deepcreepandops) Sasha (aka Deep Creep) +and Andrew (aka Ando) have both presented sounds for you all before- excited to +have them both back in the booth this week for some sonic explorations. Eli (aka +Live Canteen) is continuing to raise the bar with her culinary excellence. Check +out this week’s menu: > minestrone soup, parsley/pine nut/meyer lemon pesto, +Cacio e Pepe sourdough +![poster2](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe378ddde-4c73-44fc-ba41-db5a354922a8_2160x2700.jpeg) +[12.14.2024 Pique-nique Presents: Persian Empire, live. Food by Live +Canteen.](https://dice.fm/partner/dice/event/av2vvv-pique-nique-presents-14th-dec-tba-location-new-york-new-york-tickets) +When Sam (aka Loum) or Jared hit us up about doing anything, we do our best to +make space for them in the calendar. To say that I trust their curitorial vision +is an understatement. The Pique-nique approach to presenting music is woven into +the fabric of the Light and Sound Design studio. This Saturday, Sam is hosting +German producer Persian Empire for a live hardware set. This will be his first +ever show in the US, and judging by the amount of messages I’ve gotten since +we’ve announced I expect this one will a full house, and a memorable one at +that. Tickets are limited, and going quickly. Come hungry, Eli is cooking again: +> mulligatawny soup, potato and cheese borekas, Korean style carrots, tahina and +mango lime pickle +![poster3](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58ed2eb4-cf66-447c-8112-21917f7a79d9_3348x4329.jpeg) +[12.20.2024 Solstice Dsleepover](https://www.eventcreate.com/e/dsleepover1221) +On the other end of the energetic spectrum is the next edition of our Dsleepover +series to celebrate the longest night of the year. This one is a gentle +collaboration with Dan and Serena of Testu Collective. They will be providing +visuals and sonics from the night along with myself, Vin (aka fieldtalk), Annie +(aka UCC Harlo), MA, and Dominka Mazurová. If you’ve yet to take part in one of +these, the idea is simple. We make sound for you to sleep to. Come and go as you +please. Mattresses, toothbrushes and blankets are available, but you are +encouraged to bring your own. The studio can be a bit drafty this time of year. +There will be food too. More on that later… Something Nice to Listen to: +[ambient flo](https://www.ambientflo.com/) Hope to see you soon. Love, KG diff --git a/frontend/templates/test.css b/frontend/templates/test.css new file mode 100644 index 00000000..65801147 --- /dev/null +++ b/frontend/templates/test.css @@ -0,0 +1,29 @@ +.pell { + border: 1px solid hsla(0, 0%, 4%, 0.1); +} +.pell, +.pell-content { + box-sizing: border-box; +} +.pell-content { + height: 300px; + outline: 0; + overflow-y: auto; + padding: 10px; +} +.pell-actionbar { + background-color: #fff; + border-bottom: 1px solid hsla(0, 0%, 4%, 0.1); +} +.pell-button { + background-color: transparent; + border: none; + cursor: pointer; + height: 30px; + outline: 0; + width: 30px; + vertical-align: bottom; +} +.pell-button-selected { + background-color: #f0f0f0; +} diff --git a/package-lock.json b/package-lock.json index 05aa5283..afb49358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,12 @@ "packages": { "": { "devDependencies": { - "@tailwindcss/cli": "^4.0.17", + "@tailwindcss/cli": "^4.1.2", "livereload": "^0.9.3", - "npm-run-all": "^4.1.5" + "npm-run-all": "^4.1.5", + "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.0.0", + "prettier-plugin-tailwindcss": "^0.6.11" } }, "node_modules/@parcel/watcher": { @@ -333,64 +336,64 @@ } }, "node_modules/@tailwindcss/cli": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.0.17.tgz", - "integrity": "sha512-Jygu5jjf64vzNXeTr00OhlMzRq+/KwNxJS6eZlgcBpEbXTEmmlr/PSjv1Q9Lk3aTnQc4yNlXkHdWPnlpF+ILUg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.3.tgz", + "integrity": "sha512-irQW1LhBCi8O7OPrDVTyo6IZFqUDukGkcqOIxoU9d7zSOxU5LZQ1EB1KA981xmZpPIIfaowgdia8FSxaQrBonQ==", "dev": true, "license": "MIT", "dependencies": { "@parcel/watcher": "^2.5.1", - "@tailwindcss/node": "4.0.17", - "@tailwindcss/oxide": "4.0.17", + "@tailwindcss/node": "4.1.3", + "@tailwindcss/oxide": "4.1.3", "enhanced-resolve": "^5.18.1", - "lightningcss": "1.29.2", "mri": "^1.2.0", "picocolors": "^1.1.1", - "tailwindcss": "4.0.17" + "tailwindcss": "4.1.3" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "node_modules/@tailwindcss/node": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.17.tgz", - "integrity": "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz", + "integrity": "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==", "dev": true, "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "tailwindcss": "4.0.17" + "lightningcss": "1.29.2", + "tailwindcss": "4.1.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.17.tgz", - "integrity": "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz", + "integrity": "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.17", - "@tailwindcss/oxide-darwin-arm64": "4.0.17", - "@tailwindcss/oxide-darwin-x64": "4.0.17", - "@tailwindcss/oxide-freebsd-x64": "4.0.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.17", - "@tailwindcss/oxide-linux-x64-musl": "4.0.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.17" + "@tailwindcss/oxide-android-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-x64": "4.1.3", + "@tailwindcss/oxide-freebsd-x64": "4.1.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-x64-musl": "4.1.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.17.tgz", - "integrity": "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.3.tgz", + "integrity": "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==", "cpu": [ "arm64" ], @@ -405,9 +408,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.17.tgz", - "integrity": "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.3.tgz", + "integrity": "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==", "cpu": [ "arm64" ], @@ -422,9 +425,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.17.tgz", - "integrity": "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.3.tgz", + "integrity": "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==", "cpu": [ "x64" ], @@ -439,9 +442,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.17.tgz", - "integrity": "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.3.tgz", + "integrity": "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==", "cpu": [ "x64" ], @@ -456,9 +459,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.17.tgz", - "integrity": "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.3.tgz", + "integrity": "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==", "cpu": [ "arm" ], @@ -473,9 +476,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.17.tgz", - "integrity": "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.3.tgz", + "integrity": "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==", "cpu": [ "arm64" ], @@ -490,9 +493,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.17.tgz", - "integrity": "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.3.tgz", + "integrity": "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==", "cpu": [ "arm64" ], @@ -507,9 +510,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.17.tgz", - "integrity": "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.3.tgz", + "integrity": "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==", "cpu": [ "x64" ], @@ -524,9 +527,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.17.tgz", - "integrity": "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.3.tgz", + "integrity": "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==", "cpu": [ "x64" ], @@ -541,9 +544,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.17.tgz", - "integrity": "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.3.tgz", + "integrity": "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==", "cpu": [ "arm64" ], @@ -558,9 +561,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.17.tgz", - "integrity": "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.3.tgz", + "integrity": "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==", "cpu": [ "x64" ], @@ -2426,6 +2429,111 @@ "node": ">= 0.4" } }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-jinja-template": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-2.0.0.tgz", + "integrity": "sha512-REZDAcZuOUvMDaPS47/GNRLKvbxh9DO9euXhWA7gJGqTLGzHPK2Z841F8I4bxsR7e2lqnHezkQ8GcWaKekKBVQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -2896,9 +3004,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.17.tgz", - "integrity": "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", + "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index b8aec3e8..1a31fbb7 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { "scripts": { - "watch": "npm-run-all --parallel watch:styles reload:assets", - "reload:assets": "npx livereload assets", - "watch:styles": "npx @tailwindcss/cli -i ./web/styles/main.css -o ./assets/dist.css --watch", - "build:styles": "npx @tailwindcss/cli -i ./web/styles/main.css -o ./assets/dist.css", - "build:styles.min": "npx @tailwindcss/cli -i ./web/styles/main.css -o ./assets/dist.css --minify" + "watch": "npm-run-all --parallel watch:styles reload:static", + "reload:static": "npx livereload ./frontend/static", + "watch:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --watch", + "build:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css", + "build:styles.min": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --minify", + "format": "npx prettier ./frontend/templates ./frontend/styles --write" }, "devDependencies": { - "@tailwindcss/cli": "^4.0.17", "livereload": "^0.9.3", - "npm-run-all": "^4.1.5" + "npm-run-all": "^4.1.5", + "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.0.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "@tailwindcss/cli": "^4.1.2" } } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 77ed5d65..ec76ed17 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -11,7 +11,7 @@ ls -l target/aarch64-unknown-linux-gnu/ ls -l target/aarch64-unknown-linux-gnu/* envsubst < config/prod.toml > config/prod.toml.subst mv config/prod.toml.subst config/prod.toml -rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/release/lsd $1:/home/lsd/ +rsync --rsync-path="sudo rsync" -Pavzr --delete frontend config target/aarch64-unknown-linux-gnu/release/lsd $1:/home/lsd/ ssh $1 <<'EOS' sudo setcap 'cap_net_bind_service=+ep' /home/lsd/lsd sudo systemctl restart lsd diff --git a/src/app/mod.rs b/src/app/mod.rs index 4bd8776b..e8fea08e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -32,9 +32,9 @@ pub async fn build(config: Config) -> Result { }); let r = Router::new() - .nest_service("/assets", ServeDir::new("assets")) + .nest_service("/static", ServeDir::new("frontend/static")) // For non-HTML pages without a , this is where the browser looks - .route("/favicon.ico", get(|| async { Redirect::to("/assets/favicon.ico") })); + .route("/favicon.ico", get(|| async { Redirect::to("/static/favicon.ico") })); let r = home::register_routes(r); let r = posts::register_routes(r); diff --git a/src/utils/tera.rs b/src/utils/tera.rs index 6bd1079f..d52444c3 100644 --- a/src/utils/tera.rs +++ b/src/utils/tera.rs @@ -7,7 +7,7 @@ use crate::Config; /// Initialize the [`Tera`] template engine, including our custom filter functions. pub fn templates(config: &Config) -> Result { - let mut tera = Tera::new("templates/*")?; + let mut tera = Tera::new("frontend/templates/*")?; // Format a datetime with a [`strftime`] format string. // Also converts from UTC to the app's local timezone. diff --git a/templates/event-create.tera.html b/templates/event-create.tera.html deleted file mode 100644 index 9bcad06c..00000000 --- a/templates/event-create.tera.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - WLSD - - - -
      -

      Let's Create an Event

      -
      - - - - - - - - - - - - - - - - -
      -
      - - \ No newline at end of file diff --git a/templates/event-list.tera.html b/templates/event-list.tera.html deleted file mode 100644 index 639f543a..00000000 --- a/templates/event-list.tera.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - WLSD - - - -

      Upcoming Events:

      - {% for event in events %} - - {% endfor %} - - diff --git a/templates/event.tera.html b/templates/event.tera.html deleted file mode 100644 index 8fe51f26..00000000 --- a/templates/event.tera.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - WLSD - - - - {% if event %} -

      Update Event: {{ event.title }}

      -
      - - - - - - - - - - - - - - - -
      -
      - -
      - {% else %} -

      Event does not exist...

      - {% endif %} - - \ No newline at end of file diff --git a/templates/home.tera.html b/templates/home.tera.html deleted file mode 100644 index b490bb9b..00000000 --- a/templates/home.tera.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "layout.tera.html" %} - -{% block title %}light and sound{% endblock title %} - -{% block styles %} - -{{ super() }} - - -{% endblock styles %} - -{% block content %} -
      -

      Coming Soon...

      -

      Coming Soon...

      -
      -{% endblock content %} diff --git a/templates/list-edit.tera.html b/templates/list-edit.tera.html deleted file mode 100644 index 1f88dd55..00000000 --- a/templates/list-edit.tera.html +++ /dev/null @@ -1,46 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Edit list - " ~ list.name) }} - -
      - {% if list.id != 0 %} - - {% endif %} -
      - - -
      -
      - - -
      - {% if list.id != 0 %} -
      - -
        - {% for member in members %} -
      • - - {{ member.email }} {% if member.first_name %}({{ member.first_name }} {{ member.last_name }}){% endif %} -
      • - {% endfor %} -
      -
      - {% endif %} -
      - - -
      - -
      -{{ page::end() }} diff --git a/templates/login.tera.html b/templates/login.tera.html deleted file mode 100644 index f29a4e69..00000000 --- a/templates/login.tera.html +++ /dev/null @@ -1,18 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="light and sound - login") }} - -
      -
      - - -
      -
      - -
      -
      -{{ page::end() }} diff --git a/templates/page.tera.html b/templates/page.tera.html deleted file mode 100644 index 0667c62f..00000000 --- a/templates/page.tera.html +++ /dev/null @@ -1,199 +0,0 @@ -{% macro start(title) %} - - - - - - {{ title }} - - - -
      - -
      -
      -{% endmacro start %} - -{% macro end() %} -
      -
      -
      - - -{% endmacro end %} diff --git a/templates/post-edit.tera.html b/templates/post-edit.tera.html deleted file mode 100644 index eb6304b5..00000000 --- a/templates/post-edit.tera.html +++ /dev/null @@ -1,273 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Edit post - " ~ post.title) }} - - - -
      - {% if post.id != 0 %} - - {% endif %} -
      - - -
      -
      - -
      - - -
      -
      -
      - - -
      -
      - -
      -
      -
      -
      -
      - -
      - -{{ page::end() }} diff --git a/templates/post-email.tera.html b/templates/post-email.tera.html deleted file mode 100644 index 1528bf9e..00000000 --- a/templates/post-email.tera.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - {{ post.title }} - - - -
      - -
      -
      -
      -

      {{ post.title }}

      - - {{ post.content | safe }} - footer -
      -
      - - - diff --git a/templates/post-list.tera.html b/templates/post-list.tera.html deleted file mode 100644 index dd89b5eb..00000000 --- a/templates/post-list.tera.html +++ /dev/null @@ -1,70 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Posts") }} - -
      -
      -

      Posts

      - New Post -
      - {% for post in posts %} -
      -

      {{ post.title }}

      -
      - By {{ post.author }} • Updated - -
      -
      - Edit - -
      - -
      -
      -
      - {% endfor %} -
      -{{ page::end() }} diff --git a/templates/post-send.tera.html b/templates/post-send.tera.html deleted file mode 100644 index 7f2cbe3c..00000000 --- a/templates/post-send.tera.html +++ /dev/null @@ -1,20 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Send post - " ~ post.title) }} - -
      -

      {{ post.title }}

      -
      - - -
      - -
      -{{ page::end() }} diff --git a/templates/post-sent.tera.html b/templates/post-sent.tera.html deleted file mode 100644 index c219e1a3..00000000 --- a/templates/post-sent.tera.html +++ /dev/null @@ -1,33 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Sent post - " ~ post.title) }} - -
      -

      {{ post.title }}

      -
        -
      • Sent {{ stats.num_sent }} emails to list "{{ list.name }}"
      • - - {% if stats.num_skipped > 0 %} -
      • Skipped sending {{ stats.num_skipped }} emails which were already delivered
      • - {% endif %} - - {% if stats.errors | length > 0 %} -
      • - Failed to send {{ stats.errors | length }} emails -
          - {% for email, error in stats.errors %} -
        • {{ email }}: {{ error }}
        • - {% endfor %} -
        • foo@foo.com: test
        • -
        -
      • - {% endif %} -
      - {{ page::end() }} -
      diff --git a/templates/post.tera.html b/templates/post.tera.html deleted file mode 100644 index 945e73ec..00000000 --- a/templates/post.tera.html +++ /dev/null @@ -1,8 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title=post.title) }} -
      -

      {{ post.title }}

      - - {{ post.content | safe }} -
      -{{ page::end() }} diff --git a/templates/register.tera.html b/templates/register.tera.html deleted file mode 100644 index c9661e31..00000000 --- a/templates/register.tera.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - WLSD - - - -
      -

      Register

      -
      - - - - - - - - -
      -
      - - diff --git a/templates/temp.html b/templates/temp.html deleted file mode 100644 index fcf76ea3..00000000 --- a/templates/temp.html +++ /dev/null @@ -1,35 +0,0 @@ -Hello! - -I was so excited to get Zoë’s first newsletter out earlier this week that we forgot to list a few things, and have since announced another Dleepover! Hope you don’t mind getting two emails from us this week. I do my best to be mindful of your attention spans.... - -![poster](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcaeb3c03-0c33-4b2d-ba98-13e00050bdbc_2232x2790.jpeg) - -[12.02.2024 Deep Creep and Ando will Present Sounds. Food by Live Canteen.](https://www.eventcreate.com/e/deepcreepandops) - -Sasha (aka Deep Creep) and Andrew (aka Ando) have both presented sounds for you all before- excited to have them both back in the booth this week for some sonic explorations. Eli (aka Live Canteen) is continuing to raise the bar with her culinary excellence. Check out this week’s menu: - -> minestrone soup, parsley/pine nut/meyer lemon pesto, Cacio e Pepe sourdough - -![poster2](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe378ddde-4c73-44fc-ba41-db5a354922a8_2160x2700.jpeg) - -[12.14.2024 Pique-nique Presents: Persian Empire, live. Food by Live Canteen.](https://dice.fm/partner/dice/event/av2vvv-pique-nique-presents-14th-dec-tba-location-new-york-new-york-tickets) - -When Sam (aka Loum) or Jared hit us up about doing anything, we do our best to make space for them in the calendar. To say that I trust their curitorial vision is an understatement. The Pique-nique approach to presenting music is woven into the fabric of the Light and Sound Design studio. This Saturday, Sam is hosting German producer Persian Empire for a live hardware set. This will be his first ever show in the US, and judging by the amount of messages I’ve gotten since we’ve announced I expect this one will a full house, and a memorable one at that. Tickets are limited, and going quickly. Come hungry, Eli is cooking again: - -> mulligatawny soup, potato and cheese borekas, Korean style carrots, tahina and mango lime pickle - -![poster3](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58ed2eb4-cf66-447c-8112-21917f7a79d9_3348x4329.jpeg) - -[12.20.2024 Solstice Dsleepover](https://www.eventcreate.com/e/dsleepover1221) - -On the other end of the energetic spectrum is the next edition of our Dsleepover series to celebrate the longest night of the year. This one is a gentle collaboration with Dan and Serena of Testu Collective. They will be providing visuals and sonics from the night along with myself, Vin (aka fieldtalk), Annie (aka UCC Harlo), MA, and Dominka Mazurová. If you’ve yet to take part in one of these, the idea is simple. We make sound for you to sleep to. Come and go as you please. Mattresses, toothbrushes and blankets are available, but you are encouraged to bring your own. The studio can be a bit drafty this time of year. There will be food too. More on that later… - -Something Nice to Listen to: - -[ambient flo](https://www.ambientflo.com/) - -Hope to see you soon. - -Love, - -KG diff --git a/templates/test.css b/templates/test.css deleted file mode 100644 index c700cf5e..00000000 --- a/templates/test.css +++ /dev/null @@ -1,29 +0,0 @@ -.pell { - border: 1px solid hsla(0, 0%, 4%, 0.1); -} -.pell, -.pell-content { - box-sizing: border-box; -} -.pell-content { - height: 300px; - outline: 0; - overflow-y: auto; - padding: 10px; -} -.pell-actionbar { - background-color: #fff; - border-bottom: 1px solid hsla(0, 0%, 4%, 0.1); -} -.pell-button { - background-color: transparent; - border: none; - cursor: pointer; - height: 30px; - outline: 0; - width: 30px; - vertical-align: bottom; -} -.pell-button-selected { - background-color: #f0f0f0; -} diff --git a/web/styles/main.css b/web/styles/main.css deleted file mode 100644 index 2d19ebe4..00000000 --- a/web/styles/main.css +++ /dev/null @@ -1,78 +0,0 @@ -@import "tailwindcss"; - -/* - * TODO: Refactor styles into Tailwind utility classes - * Below styles are from page.tera.html - */ - -html, -body { - @apply w-full h-full text-white bg-black; -} - -header { - @apply p-4 border-b border-neutral-700; -} - -article { - @apply max-w-3xl mx-auto p-4; - - time { - @apply block mb-8 text-sm uppercase; - } - - p { - @apply mb-5 last:mb-0; - } - - blockquote { - @apply italic my-4 py-6 px-8 border-l-[4px]; - } - - img { - @apply max-w-full h-auto block my-8; - } -} - -h2 { - @apply mb-1 text-xl font-bold; -} - -a { - @apply underline; -} - -button, -.button { - @apply block px-4 py-2 cursor-pointer bg-neutral-900 hover:bg-neutral-800; - @apply rounded-sm; -} - -ul { - @apply list-inside; -} - -label { - @apply block mb-2; -} - -input[type="text"] { - @apply w-full p-2 bg-neutral-950 border border-neutral-700; -} - -textarea { - @apply flex-1 resize-y w-full h-[50vh] p-2 bg-neutral-900; - @apply text-sm font-mono border border-neutral-700; -} - -select { - @apply bg-neutral-950 border border-neutral-600; -} - -form { - @apply p-8 flex flex-col; - - .field { - @apply mb-4 pb-4 flex flex-col; - } -} From e032a595c5f1f468aa4d613126ae9eebfaff2a69 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sat, 5 Apr 2025 14:40:58 -0400 Subject: [PATCH 48/89] random tweaks --- src/db/user.rs | 2 -- src/main.rs | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/db/user.rs b/src/db/user.rs index 74b4dfab..ee37efd9 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -13,8 +13,6 @@ pub struct User { pub created_at: NaiveDateTime, } -impl User {} - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UserRole { pub user_id: i64, diff --git a/src/main.rs b/src/main.rs index 6801c817..bf28bbf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use utils::config::*; async fn main() -> Result<()> { let log_filter = tracing_subscriber::filter::Targets::default() .with_target("h2", LevelFilter::OFF) + .with_target("globset", LevelFilter::OFF) .with_default(Level::DEBUG); tracing_subscriber::fmt() From e883361340b9d0d1025cfed5aa48feaf95f125a5 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sat, 5 Apr 2025 14:41:35 -0400 Subject: [PATCH 49/89] fix prettier actions --- .github/workflows/deploy.yaml | 5 ++--- .github/workflows/test.yaml | 6 ++---- frontend/.prettierignore => .prettierignore | 2 +- package.json | 3 ++- frontend/prettier.config.cjs => prettier.config.cjs | 0 5 files changed, 7 insertions(+), 9 deletions(-) rename frontend/.prettierignore => .prettierignore (64%) rename frontend/prettier.config.cjs => prettier.config.cjs (100%) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 17ddc9f2..74955439 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -20,9 +20,8 @@ jobs: - uses: denoland/setup-deno@v2 with: deno-version: v2.x - - run: - deno install --allow-scripts - deno task build:styles.min + - run: deno install --allow-scripts + - run: deno task build:styles.min - name: Setup SSH key run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b83f5b20..79809a2a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,10 +36,8 @@ jobs: - uses: denoland/setup-deno@v2 with: deno-version: v2.x - - run: - deno install --global npm:prettier - deno run -A npm:prettier templates styles --check - working-directory: ./frontend + - run: deno install --allow-scripts + - run: deno task format:check clippy: name: Lint diff --git a/frontend/.prettierignore b/.prettierignore similarity index 64% rename from frontend/.prettierignore rename to .prettierignore index 735057d5..f9acb734 100644 --- a/frontend/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ # This file is currently unparsable because of the tera macros -frontend/templates/page.tera.html \ No newline at end of file +frontend/templates/page.tera.html diff --git a/package.json b/package.json index 1a31fbb7..670f6478 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "watch:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --watch", "build:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css", "build:styles.min": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --minify", - "format": "npx prettier ./frontend/templates ./frontend/styles --write" + "format": "npx prettier ./frontend/templates ./frontend/styles --write", + "format:check": "npx prettier ./frontend/templates ./frontend/styles --check" }, "devDependencies": { "livereload": "^0.9.3", diff --git a/frontend/prettier.config.cjs b/prettier.config.cjs similarity index 100% rename from frontend/prettier.config.cjs rename to prettier.config.cjs From eb969bb74aa1a4d9fc9889c4415719d8c82c1821 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sat, 5 Apr 2025 15:10:15 -0400 Subject: [PATCH 50/89] Use node in GHA --- .github/workflows/deploy.yaml | 9 +++++---- .github/workflows/test.yaml | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 74955439..a0352828 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -17,11 +17,12 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: install gcc-aarch64-linux-gnu run: sudo apt install -y gcc-aarch64-linux-gnu - - uses: denoland/setup-deno@v2 + + - uses: actions/setup-node@v4 with: - deno-version: v2.x - - run: deno install --allow-scripts - - run: deno task build:styles.min + node-version: 23 + - run: npm ci + - run: npm run build:styles.min - name: Setup SSH key run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 79809a2a..db6a0f82 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -33,11 +33,11 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - uses: denoland/setup-deno@v2 + - uses: actions/setup-node@v4 with: - deno-version: v2.x - - run: deno install --allow-scripts - - run: deno task format:check + node-version: 23 + - run: npm ci + - run: npm run format:check clippy: name: Lint From ed50a9a489c7972f254e24ccb86bf2b2b91e5b7c Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 6 Apr 2025 22:14:11 -0400 Subject: [PATCH 51/89] editor: sticky toolbar --- frontend/templates/post-edit.tera.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/templates/post-edit.tera.html b/frontend/templates/post-edit.tera.html index ccd8fe38..1f6ec586 100644 --- a/frontend/templates/post-edit.tera.html +++ b/frontend/templates/post-edit.tera.html @@ -39,12 +39,20 @@ .pell { flex-grow: 1; + overflow: visible; display: flex; flex-direction: column; border: 1px solid var(--color-border); + padding: 0 16px 16px 16px; .pell-actionbar { + position: sticky; + top: 0; + background-color: var(--color-bg); + border-bottom: 1px solid var(--color-border); + padding: 10px 0 10px 0; + display: flex; flex-direction: row; align-items: center; @@ -84,6 +92,9 @@ overflow-y: auto; padding: 10px; } + .pell-content:focus { + outline: none; + } } } From 53d2319de168d6570dc3b3417e6821dec8ae8df6 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 6 Apr 2025 22:21:06 -0400 Subject: [PATCH 52/89] editor: disable formatting on paste --- frontend/templates/post-edit.tera.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/templates/post-edit.tera.html b/frontend/templates/post-edit.tera.html index 1f6ec586..5449dbc5 100644 --- a/frontend/templates/post-edit.tera.html +++ b/frontend/templates/post-edit.tera.html @@ -291,5 +291,15 @@ .querySelectorAll(".resize") .forEach((e) => e.addEventListener("mousedown", startResize)); })(); + + // Clear formatting when pasting into the editor + (() => { + const editor = document.querySelector(".pell-content"); + editor.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + document.execCommand("insertText", false, text); + }); + })(); {{ page::end() }} From e1aa1470fd642b8ccd3b2c875e2bd29f972db757 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 6 Apr 2025 23:23:04 -0400 Subject: [PATCH 53/89] editor: save local changes in localStorage --- frontend/templates/post-edit.tera.html | 90 +++++++++++++++----------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/frontend/templates/post-edit.tera.html b/frontend/templates/post-edit.tera.html index 5449dbc5..c78a3b9f 100644 --- a/frontend/templates/post-edit.tera.html +++ b/frontend/templates/post-edit.tera.html @@ -121,13 +121,7 @@ {% endif %}
      - +
      @@ -181,29 +175,53 @@ // AJAX saving with status indicator (() => { - // Track unsaved changes - let modified = false; + const editor = document.querySelector(".pell-content"); const status = document.querySelector("#status"); - async function setSaved() { - modified = false; - status.textContent = "Changes saved"; - status.classList.remove("unsaved"); - status.classList.remove("error"); - } - async function setUnsaved() { - modified = true; - status.textContent = "Changes unsaved"; - status.classList.add("unsaved"); - status.classList.remove("error"); - } - async function setError() { - modified = true; - status.textContent = "Error saving"; - status.classList.add("error"); - status.classList.remove("unsaved"); + const postKey = "posts/{{ post.id }}"; + const postUpdatedAt = new Date( + '{{ post.updated_at | format_datetime(format="%m-%d-%Y %H:%M:%S %Z") }}', + ); + let modified = false; + + const setStatus = (text, clazz, mod) => { + status.textContent = text; + status.className = clazz; + modified = mod; + }; + const setModified = () => + setStatus("Changes saved locally", "unsaved", true); + const setRestored = () => setStatus("Changes restored", "unsaved", true); + const setSaved = () => setStatus("Changes saved", "", false); + const setError = () => setStatus("Error saving changes", "error", true); + + // Load any saved local changes + let saved = postKey in localStorage && JSON.parse(localStorage[postKey]); + if (saved) { + const updatedAt = new Date(saved.updatedAt); + if (postKey == "posts/0" || updatedAt > postUpdatedAt) { + editor.innerHTML = saved.content; + setRestored(); + } } + // Save local changes + editor.addEventListener("input", (e) => { + localStorage[postKey] = JSON.stringify({ + updatedAt: Date.now(), + content: editor.innerHTML, + }); + setModified(); + }); + + // Warn before leaving if modified + window.addEventListener("beforeunload", (e) => { + if (modified) { + e.preventDefault(); + e.returnValue = ""; + } + }); + // Save post via AJAX window.savePost = async () => { // Check for missing fields @@ -237,6 +255,13 @@ history.pushState({}, "", `/p/${newUrl}/edit`); formEl.action = `/p/${newUrl}/edit`; } + + if (postKey == "posts/0") { + // Clear local changes on the "new post" page + localStorage.removeItem(postKey); + // Reload the page to pick up the new id + window.location.href = `/p/${newUrl}/edit`; + } } else { setError(); console.error(response.statusText); @@ -246,19 +271,6 @@ console.error(error); } }; - - // Update saved status when inputs change - document - .querySelectorAll("input, .pell-content") - .forEach((e) => e.addEventListener("input", setUnsaved)); - - // Warn before leaving if there are unsaved changes - window.addEventListener("beforeunload", (e) => { - if (modified) { - e.preventDefault(); - e.returnValue = ""; - } - }); })(); // Resize handles From f700c5f8c3dcf910bd20c4e0e6f57116b2a7a08f Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Sun, 6 Apr 2025 23:28:18 -0400 Subject: [PATCH 54/89] posts: rename routes --- frontend/templates/post-edit.tera.html | 8 ++++---- frontend/templates/post-list.tera.html | 8 ++++---- frontend/templates/post-send.tera.html | 2 +- src/app/posts.rs | 11 ++++++----- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/templates/post-edit.tera.html b/frontend/templates/post-edit.tera.html index c78a3b9f..beaa234d 100644 --- a/frontend/templates/post-edit.tera.html +++ b/frontend/templates/post-edit.tera.html @@ -115,7 +115,7 @@ } } -
      + {% if post.id != 0 %} {% endif %} @@ -252,15 +252,15 @@ const newUrl = document.getElementById("url").value; const currentUrl = window.location.pathname.split("/")[2]; if (newUrl !== currentUrl) { - history.pushState({}, "", `/p/${newUrl}/edit`); - formEl.action = `/p/${newUrl}/edit`; + history.pushState({}, "", `/posts/${newUrl}/edit`); + formEl.action = `/posts/${newUrl}/edit`; } if (postKey == "posts/0") { // Clear local changes on the "new post" page localStorage.removeItem(postKey); // Reload the page to pick up the new id - window.location.href = `/p/${newUrl}/edit`; + window.location.href = `/posts/${newUrl}/edit`; } } else { setError(); diff --git a/frontend/templates/post-list.tera.html b/frontend/templates/post-list.tera.html index 24e7d18a..1fe4772c 100644 --- a/frontend/templates/post-list.tera.html +++ b/frontend/templates/post-list.tera.html @@ -43,7 +43,7 @@

      Posts

      - New Post + New Post
      {% for post in posts %}
      @@ -55,15 +55,15 @@

      {{ post.title }}

      - Edit + Edit diff --git a/frontend/templates/post-send.tera.html b/frontend/templates/post-send.tera.html index cc2584cd..fa49daa7 100644 --- a/frontend/templates/post-send.tera.html +++ b/frontend/templates/post-send.tera.html @@ -5,7 +5,7 @@ padding-bottom: 1rem; } - +

      {{ post.title }}

      diff --git a/src/app/posts.rs b/src/app/posts.rs index f9241ad7..aba08fa8 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -23,11 +23,12 @@ use crate::utils::types::{AppResult, AppRouter, SharedAppState}; pub fn register_routes(router: AppRouter) -> AppRouter { router .route("/posts", get(list_posts_page)) - .route("/p/new", get(create_post_page)) + .route("/posts/new", get(create_post_page)) + .route("/posts/{url}", get(view_post_page)) + .route("/posts/{url}/edit", get(edit_post_page).post(edit_post_form)) + .route("/posts/{url}/send", get(send_post_page).post(send_post_form)) + .route("/posts/{url}/delete", post(delete_post_form)) .route("/p/{url}", get(view_post_page)) - .route("/p/{url}/edit", get(edit_post_page).post(edit_post_form)) - .route("/p/{url}/send", get(send_post_page).post(send_post_form)) - .route("/p/{url}/delete", post(delete_post_form)) } /// Display a list of posts. @@ -118,7 +119,7 @@ async fn edit_post_form( Post::create(&state.db, &form.post).await?; } } - Ok(Redirect::to(&format!("{}/p/{}", state.config.app.url, &form.post.url)).into_response()) + Ok(().into_response()) } #[derive(serde::Deserialize)] struct EditPost { From f5977dda64f826796fae23d9440ea4ee4299590c Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Tue, 8 Apr 2025 00:11:20 -0400 Subject: [PATCH 55/89] migrate templating engine to askama (#22) * introduce compile-time checking to template construction * organize template files based on routes * organize template rendering code into `src/views` directory * consistently use base layout file for all templates --- .prettierignore | 2 - .vscode/extensions.json | 10 + .vscode/settings.json | 4 +- Cargo.toml | 20 +- README.md | 2 +- askama.toml | 2 + deno.lock | 62 ++-- .../prettier.config.cjs | 2 +- frontend/templates/auth/login.html | 22 ++ frontend/templates/auth/register.html | 28 ++ frontend/templates/event-create.tera.html | 45 --- frontend/templates/event-list.tera.html | 38 -- frontend/templates/event.tera.html | 55 --- frontend/templates/events/create.html | 43 +++ frontend/templates/events/list.html | 37 ++ frontend/templates/events/view.html | 51 +++ .../templates/{home.tera.html => index.html} | 5 +- .../{layout.tera.html => layout.html} | 3 + frontend/templates/list-edit.tera.html | 65 ---- frontend/templates/lists.tera.html | 15 - frontend/templates/lists/edit.html | 68 ++++ frontend/templates/lists/view.html | 19 + frontend/templates/login.tera.html | 18 - frontend/templates/page.tera.html | 205 ----------- frontend/templates/post-edit.tera.html | 317 ----------------- frontend/templates/post-list.tera.html | 75 ---- frontend/templates/post-send.tera.html | 20 -- frontend/templates/post-sent.tera.html | 36 -- frontend/templates/post.tera.html | 10 - frontend/templates/posts/edit.html | 328 ++++++++++++++++++ .../email.html} | 11 +- frontend/templates/posts/list.html | 79 +++++ frontend/templates/posts/send.html | 24 ++ frontend/templates/posts/sent.html | 39 +++ frontend/templates/posts/view.html | 12 + frontend/templates/register.tera.html | 33 -- frontend/templates/temp.html | 37 -- frontend/templates/test.css | 29 -- package.json | 6 +- src/app/auth.rs | 23 +- src/app/events.rs | 28 +- src/app/home.rs | 14 +- src/app/lists.rs | 28 +- src/app/mod.rs | 3 - src/app/posts.rs | 85 ++--- src/db/list.rs | 4 +- src/db/post.rs | 2 +- src/main.rs | 3 + src/utils/mod.rs | 1 - src/utils/tera.rs | 57 --- src/views/auth.rs | 11 + src/views/events.rs | 19 + src/views/filters.rs | 16 + src/views/index.rs | 5 + src/views/lists.rs | 15 + src/views/mod.rs | 6 + src/views/posts.rs | 49 +++ 57 files changed, 1021 insertions(+), 1225 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 askama.toml rename prettier.config.cjs => frontend/prettier.config.cjs (89%) create mode 100644 frontend/templates/auth/login.html create mode 100644 frontend/templates/auth/register.html delete mode 100644 frontend/templates/event-create.tera.html delete mode 100644 frontend/templates/event-list.tera.html delete mode 100644 frontend/templates/event.tera.html create mode 100644 frontend/templates/events/create.html create mode 100644 frontend/templates/events/list.html create mode 100644 frontend/templates/events/view.html rename frontend/templates/{home.tera.html => index.html} (95%) rename frontend/templates/{layout.tera.html => layout.html} (94%) delete mode 100644 frontend/templates/list-edit.tera.html delete mode 100644 frontend/templates/lists.tera.html create mode 100644 frontend/templates/lists/edit.html create mode 100644 frontend/templates/lists/view.html delete mode 100644 frontend/templates/login.tera.html delete mode 100644 frontend/templates/page.tera.html delete mode 100644 frontend/templates/post-edit.tera.html delete mode 100644 frontend/templates/post-list.tera.html delete mode 100644 frontend/templates/post-send.tera.html delete mode 100644 frontend/templates/post-sent.tera.html delete mode 100644 frontend/templates/post.tera.html create mode 100644 frontend/templates/posts/edit.html rename frontend/templates/{post-email.tera.html => posts/email.html} (91%) create mode 100644 frontend/templates/posts/list.html create mode 100644 frontend/templates/posts/send.html create mode 100644 frontend/templates/posts/sent.html create mode 100644 frontend/templates/posts/view.html delete mode 100644 frontend/templates/register.tera.html delete mode 100644 frontend/templates/temp.html delete mode 100644 frontend/templates/test.css delete mode 100644 src/utils/tera.rs create mode 100644 src/views/auth.rs create mode 100644 src/views/events.rs create mode 100644 src/views/filters.rs create mode 100644 src/views/index.rs create mode 100644 src/views/lists.rs create mode 100644 src/views/mod.rs create mode 100644 src/views/posts.rs diff --git a/.prettierignore b/.prettierignore index f9acb734..e69de29b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +0,0 @@ -# This file is currently unparsable because of the tera macros -frontend/templates/page.tera.html diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..b502e78a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "rust-lang.rust-analyzer", + "esbenp.prettier-vscode", + "samuelcolvin.jinjahtml", + "dotenv.dotenv-vscode", + "tamasfe.even-better-toml" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 13e94334..b4dea7d5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,8 @@ }, "files.associations": { "*.css": "tailwindcss", - "*.tera.html": "jinja-html" + "*.html": "jinja-html" }, + "prettier.configPath": "frontend/prettier.config.cjs", + "prettier.requireConfig": true } \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 3ddafb20..bc959b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,28 +5,28 @@ edition = "2021" [dependencies] +askama = "0.13.0" axum = { version = "0.8", default-features = false, features = ["query", "form"] } axum-server = { version = "0.7", features = ["tls-rustls"] } axum-extra = { version = "0.10", features = ["cookie"] } -tower-http = { version = "0.6", features = ["fs", "request-id", "trace", "util"] } -tera = "1" -sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "tokio1", "tokio1-rustls-tls", "serde"] } -tokio = { version = "1", features = ["rt-multi-thread", "fs", "net", "sync", "macros"] } rustls = "0.23" rustls-acme = { version = "0.12", features = ["axum"] } +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } +tokio = { version = "1", features = ["rt-multi-thread", "fs", "net", "sync", "macros"] } +tower = "0.5.2" +tower-http = { version = "0.6", features = ["fs", "request-id", "trace", "util"] } anyhow = "1" -tracing = "0.1" -tracing-subscriber = "0.3" +chrono-tz = { version = "0.10", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } futures = "0.3" +rand = "0.8" serde = { version = "1", features = ["derive"] } toml = "0.8" -rand = "0.8" -chrono = { version = "0.4", features = ["serde"] } -chrono-tz = { version = "0.10", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = "0.3" uuid = { version = "1.16.0", features = ["v7"] } -tower = "0.5.2" # Add a little optimization to debug builds [profile.dev] diff --git a/README.md b/README.md index 19c6b8cc..30f89cf9 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ To automatically recompile and rerun when you make changes, use `cargo-watch`: ```sh cargo install cargo-watch -cargo watch -x 'run config/dev.toml' --ignore "web/*" +cargo watch -x 'run config/dev.toml' ``` Run Tailwind CLI to compile styles via `npm`. diff --git a/askama.toml b/askama.toml new file mode 100644 index 00000000..8275a9a6 --- /dev/null +++ b/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["frontend/templates"] diff --git a/deno.lock b/deno.lock index 0ebd1a33..516c3b8c 100644 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,7 @@ { "version": "4", "specifiers": { - "npm:@tailwindcss/cli@^4.1.2": "4.1.2", + "npm:@tailwindcss/cli@^4.1.2": "4.1.3", "npm:livereload@~0.9.3": "0.9.3", "npm:npm-run-all@^4.1.5": "4.1.5", "npm:prettier-plugin-jinja-template@2": "2.0.0_prettier@3.5.3", @@ -70,8 +70,8 @@ "node-addon-api" ] }, - "@tailwindcss/cli@4.1.2": { - "integrity": "sha512-HaPFz9GNbBLgV9vsSD818HCvf598D24ZOZlCdth/Y3jk1BZY69UD99e4pcfifT8msFg4xYI+uxEv5N1MYao1Mg==", + "@tailwindcss/cli@4.1.3": { + "integrity": "sha512-irQW1LhBCi8O7OPrDVTyo6IZFqUDukGkcqOIxoU9d7zSOxU5LZQ1EB1KA981xmZpPIIfaowgdia8FSxaQrBonQ==", "dependencies": [ "@parcel/watcher", "@tailwindcss/node", @@ -82,8 +82,8 @@ "tailwindcss" ] }, - "@tailwindcss/node@4.1.2": { - "integrity": "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ==", + "@tailwindcss/node@4.1.3": { + "integrity": "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==", "dependencies": [ "enhanced-resolve", "jiti", @@ -91,41 +91,41 @@ "tailwindcss" ] }, - "@tailwindcss/oxide-android-arm64@4.1.2": { - "integrity": "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w==" + "@tailwindcss/oxide-android-arm64@4.1.3": { + "integrity": "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==" }, - "@tailwindcss/oxide-darwin-arm64@4.1.2": { - "integrity": "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA==" + "@tailwindcss/oxide-darwin-arm64@4.1.3": { + "integrity": "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==" }, - "@tailwindcss/oxide-darwin-x64@4.1.2": { - "integrity": "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw==" + "@tailwindcss/oxide-darwin-x64@4.1.3": { + "integrity": "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==" }, - "@tailwindcss/oxide-freebsd-x64@4.1.2": { - "integrity": "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ==" + "@tailwindcss/oxide-freebsd-x64@4.1.3": { + "integrity": "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==" }, - "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.2": { - "integrity": "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow==" + "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3": { + "integrity": "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==" }, - "@tailwindcss/oxide-linux-arm64-gnu@4.1.2": { - "integrity": "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw==" + "@tailwindcss/oxide-linux-arm64-gnu@4.1.3": { + "integrity": "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==" }, - "@tailwindcss/oxide-linux-arm64-musl@4.1.2": { - "integrity": "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg==" + "@tailwindcss/oxide-linux-arm64-musl@4.1.3": { + "integrity": "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==" }, - "@tailwindcss/oxide-linux-x64-gnu@4.1.2": { - "integrity": "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ==" + "@tailwindcss/oxide-linux-x64-gnu@4.1.3": { + "integrity": "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==" }, - "@tailwindcss/oxide-linux-x64-musl@4.1.2": { - "integrity": "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A==" + "@tailwindcss/oxide-linux-x64-musl@4.1.3": { + "integrity": "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==" }, - "@tailwindcss/oxide-win32-arm64-msvc@4.1.2": { - "integrity": "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg==" + "@tailwindcss/oxide-win32-arm64-msvc@4.1.3": { + "integrity": "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==" }, - "@tailwindcss/oxide-win32-x64-msvc@4.1.2": { - "integrity": "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw==" + "@tailwindcss/oxide-win32-x64-msvc@4.1.3": { + "integrity": "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==" }, - "@tailwindcss/oxide@4.1.2": { - "integrity": "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ==", + "@tailwindcss/oxide@4.1.3": { + "integrity": "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==", "dependencies": [ "@tailwindcss/oxide-android-arm64", "@tailwindcss/oxide-darwin-arm64", @@ -1132,8 +1132,8 @@ "supports-preserve-symlinks-flag@1.0.0": { "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, - "tailwindcss@4.1.2": { - "integrity": "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw==" + "tailwindcss@4.1.3": { + "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==" }, "tapable@2.2.1": { "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" diff --git a/prettier.config.cjs b/frontend/prettier.config.cjs similarity index 89% rename from prettier.config.cjs rename to frontend/prettier.config.cjs index 88b918ea..63f9b4a8 100644 --- a/prettier.config.cjs +++ b/frontend/prettier.config.cjs @@ -5,7 +5,7 @@ const config = { ], overrides: [ { - files: ["*.tera.html"], + files: ["*.html"], options: { parser: "jinja-template", }, diff --git a/frontend/templates/auth/login.html b/frontend/templates/auth/login.html new file mode 100644 index 00000000..9c8d64ef --- /dev/null +++ b/frontend/templates/auth/login.html @@ -0,0 +1,22 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - login{% endblock title %} +{% block styles %} + +{% endblock styles %} +{% block content %} + +
      + + +
      +
      + +
      + +{% endblock content %} diff --git a/frontend/templates/auth/register.html b/frontend/templates/auth/register.html new file mode 100644 index 00000000..0ee71bd5 --- /dev/null +++ b/frontend/templates/auth/register.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - register{% endblock title %} +{% block styles %} + +{% endblock styles %} + +{% block content %} +

      Register

      +
      + + + + + + + + +
      +{% endblock content %} diff --git a/frontend/templates/event-create.tera.html b/frontend/templates/event-create.tera.html deleted file mode 100644 index a0962057..00000000 --- a/frontend/templates/event-create.tera.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - WLSD - - - -
      -

      Let's Create an Event

      -
      - - - - - - - - - - - - - - - - -
      -
      - - diff --git a/frontend/templates/event-list.tera.html b/frontend/templates/event-list.tera.html deleted file mode 100644 index a13c2a91..00000000 --- a/frontend/templates/event-list.tera.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - WLSD - - - -

      Upcoming Events:

      - {% for event in events %} - - {% endfor %} - - diff --git a/frontend/templates/event.tera.html b/frontend/templates/event.tera.html deleted file mode 100644 index c903df37..00000000 --- a/frontend/templates/event.tera.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - WLSD - - - - {% if event %} -

      Update Event: {{ event.title }}

      -
      - - - - - - - - - - - - - - - -
      -
      - -
      - {% else %} -

      Event does not exist...

      - {% endif %} - - diff --git a/frontend/templates/events/create.html b/frontend/templates/events/create.html new file mode 100644 index 00000000..89fbad24 --- /dev/null +++ b/frontend/templates/events/create.html @@ -0,0 +1,43 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - new event{% endblock title %} + +{% block styles %} + +{% endblock styles %} + +{% block content %} +
      +

      Let's Create an Event

      +
      + + + + + + + + + + + + + + + + +
      +
      +{% endblock content %} diff --git a/frontend/templates/events/list.html b/frontend/templates/events/list.html new file mode 100644 index 00000000..7667571f --- /dev/null +++ b/frontend/templates/events/list.html @@ -0,0 +1,37 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - events{% endblock title %} + +{% block styles %} + +{% endblock styles %} + +{% block content %} +

      Upcoming Events:

      + {% for event in events %} + + {% endfor %} +{% endblock content %} diff --git a/frontend/templates/events/view.html b/frontend/templates/events/view.html new file mode 100644 index 00000000..10a60aab --- /dev/null +++ b/frontend/templates/events/view.html @@ -0,0 +1,51 @@ +{% extends "layout.html" %} + +{% block title %}{{ event.title }}{% endblock title %} + +{% block styles %} + +{% endblock styles %} + +{% block content %} +

      Update Event: {{ event.title }}

      +
      + + + + + + + + + + + + + + + +
      +
      + +
      +{% endblock content %} diff --git a/frontend/templates/home.tera.html b/frontend/templates/index.html similarity index 95% rename from frontend/templates/home.tera.html rename to frontend/templates/index.html index 881fb921..9a7026fb 100644 --- a/frontend/templates/home.tera.html +++ b/frontend/templates/index.html @@ -1,11 +1,8 @@ -{% extends "layout.tera.html" %} +{% extends "layout.html" %} {% block title %}light and sound{% endblock title %} {% block styles %} - - {{ super() }} - -
      - {% if list.id != 0 %} - - {% endif %} -
      - - -
      -
      - - -
      - {% if list.id != 0 %} -
      - -
        - {% for member in members %} -
      • - - {{ member.email }} - {% if member.first_name %} - ({{ member.first_name }} - {{ member.last_name }}) - {% endif %} -
      • - {% endfor %} -
      -
      - {% endif %} -
      - - -
      - -
      -{{ page::end() }} diff --git a/frontend/templates/lists.tera.html b/frontend/templates/lists.tera.html deleted file mode 100644 index 445c2464..00000000 --- a/frontend/templates/lists.tera.html +++ /dev/null @@ -1,15 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Guestlists") }} - - -{{ page::end() }} diff --git a/frontend/templates/lists/edit.html b/frontend/templates/lists/edit.html new file mode 100644 index 00000000..48f26f43 --- /dev/null +++ b/frontend/templates/lists/edit.html @@ -0,0 +1,68 @@ +{% extends "layout.html" %} +{% block title %}Edit list - {{ list.name }}{% endblock %} +{% block styles %} + +{% endblock %} + +{% block content %} +
      + {% if list.id != 0 %} + + {% endif %} +
      + + +
      +
      + + +
      + {% if list.id != 0 %} +
      + +
        + {% for member in members %} +
      • + + {{ member.email }} ({{ member.first_name }} + {{ member.last_name }}) +
      • + {% endfor %} +
      +
      + {% endif %} +
      + + +
      + +
      +{% endblock %} diff --git a/frontend/templates/lists/view.html b/frontend/templates/lists/view.html new file mode 100644 index 00000000..a96c8170 --- /dev/null +++ b/frontend/templates/lists/view.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} +{% block title %}light and sound - Lists{% endblock title %} +{% block styles %} + +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/frontend/templates/login.tera.html b/frontend/templates/login.tera.html deleted file mode 100644 index d609c247..00000000 --- a/frontend/templates/login.tera.html +++ /dev/null @@ -1,18 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="light and sound - login") }} - -
      -
      - - -
      -
      - -
      -
      -{{ page::end() }} diff --git a/frontend/templates/page.tera.html b/frontend/templates/page.tera.html deleted file mode 100644 index 47b263f9..00000000 --- a/frontend/templates/page.tera.html +++ /dev/null @@ -1,205 +0,0 @@ -{% macro start(title) %} - - - - - - {{ title }} - - - -
      - -
      -
      -{% endmacro start %} -{% macro end() %} -
      -
      - - -{% endmacro end %} diff --git a/frontend/templates/post-edit.tera.html b/frontend/templates/post-edit.tera.html deleted file mode 100644 index beaa234d..00000000 --- a/frontend/templates/post-edit.tera.html +++ /dev/null @@ -1,317 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Edit post - " ~ post.title) }} - - - -
      - {% if post.id != 0 %} - - {% endif %} -
      - - -
      -
      - -
      - - -
      -
      -
      - - -
      -
      - -
      -
      -
      -
      -
      -
      - -{{ page::end() }} diff --git a/frontend/templates/post-list.tera.html b/frontend/templates/post-list.tera.html deleted file mode 100644 index 1fe4772c..00000000 --- a/frontend/templates/post-list.tera.html +++ /dev/null @@ -1,75 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Posts") }} - -
      -
      -

      Posts

      - New Post -
      - {% for post in posts %} -
      -

      {{ post.title }}

      -
      - By {{ post.author }} • Updated - -
      -
      - Edit - -
      - -
      -
      -
      - {% endfor %} -
      -{{ page::end() }} diff --git a/frontend/templates/post-send.tera.html b/frontend/templates/post-send.tera.html deleted file mode 100644 index fa49daa7..00000000 --- a/frontend/templates/post-send.tera.html +++ /dev/null @@ -1,20 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Send post - " ~ post.title) }} - -
      -

      {{ post.title }}

      -
      - - -
      - -
      -{{ page::end() }} diff --git a/frontend/templates/post-sent.tera.html b/frontend/templates/post-sent.tera.html deleted file mode 100644 index 35e62033..00000000 --- a/frontend/templates/post-sent.tera.html +++ /dev/null @@ -1,36 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title="Sent post - " ~ post.title) }} - -
      -

      {{ post.title }}

      -
        -
      • Sent {{ stats.num_sent }} emails to list "{{ list.name }}"
      • - - {% if stats.num_skipped > 0 %} -
      • - Skipped sending {{ stats.num_skipped }} emails which were already - delivered -
      • - {% endif %} - - {% if stats.errors | length > 0 %} -
      • - Failed to send {{ stats.errors | length }} emails -
          - {% for email, error in stats.errors %} -
        • {{ email }}: {{ error }}
        • - {% endfor %} -
        • foo@foo.com: test
        • -
        -
      • - {% endif %} -
      - {{ page::end() }} -
      diff --git a/frontend/templates/post.tera.html b/frontend/templates/post.tera.html deleted file mode 100644 index 6c55ad42..00000000 --- a/frontend/templates/post.tera.html +++ /dev/null @@ -1,10 +0,0 @@ -{% import "page.tera.html" as page %} -{{ page::start(title=post.title) }} -
      -

      {{ post.title }}

      - - {{ post.content | safe }} -
      -{{ page::end() }} diff --git a/frontend/templates/posts/edit.html b/frontend/templates/posts/edit.html new file mode 100644 index 00000000..68d01a0e --- /dev/null +++ b/frontend/templates/posts/edit.html @@ -0,0 +1,328 @@ +{% extends "layout.html" %} +{% block title %}Edit post - {{ post.title }}{% endblock title %} +{% block styles %} + +{% endblock styles %} +{% block scripts %} + + +{% endblock scripts %} +{% block content %} +
      + {% if post.id != 0 %} + + {% endif %} +
      + + +
      +
      + +
      + + +
      +
      +
      + + +
      +
      + +
      +
      +
      +
      +
      +
      + +{% endblock content %} diff --git a/frontend/templates/post-email.tera.html b/frontend/templates/posts/email.html similarity index 91% rename from frontend/templates/post-email.tera.html rename to frontend/templates/posts/email.html index b69075f9..3545bea9 100644 --- a/frontend/templates/post-email.tera.html +++ b/frontend/templates/posts/email.html @@ -1,3 +1,4 @@ +{# TODO email template file? #} @@ -111,16 +112,16 @@

      {{ post.title }}

      {{ post.created_at | format_datetime("%b %d, %Y") }} {{ post.content | safe }} footer @@ -130,7 +131,7 @@

      {{ post.title }}

      diff --git a/frontend/templates/posts/list.html b/frontend/templates/posts/list.html new file mode 100644 index 00000000..c26bfcc8 --- /dev/null +++ b/frontend/templates/posts/list.html @@ -0,0 +1,79 @@ +{% extends "layout.html" %} +{% block title %}light and sound{% endblock %} +{% block styles %} + +{% endblock styles %} + +{% block content %} +
      +
      +

      Posts

      + New Post +
      + {% for post in posts %} +
      +

      {{ post.title }}

      +
      + By {{ post.author }} • Updated + +
      +
      + Edit + +
      + +
      +
      +
      + {% endfor %} +
      +{% endblock content %} diff --git a/frontend/templates/posts/send.html b/frontend/templates/posts/send.html new file mode 100644 index 00000000..a12acabb --- /dev/null +++ b/frontend/templates/posts/send.html @@ -0,0 +1,24 @@ +{% extends "layout.html" %} +{% block title %}Send post - {{ post.title }}{% endblock %} +{% block styles %} + +{% endblock styles %} + +{% block content %} +
      +

      {{ post.title }}

      +
      + + +
      + +
      +{% endblock content %} diff --git a/frontend/templates/posts/sent.html b/frontend/templates/posts/sent.html new file mode 100644 index 00000000..c7ea7a2b --- /dev/null +++ b/frontend/templates/posts/sent.html @@ -0,0 +1,39 @@ +{% extends "layout.html" %} +{% block title %}Sent post - {{ post_title }}{% endblock title %} +{% block styles %} + +{% endblock styles %} + +{% block content %} +
      +

      {{ post_title }}

      +
        +
      • Sent {{ num_sent }} emails to list "{{ list_name }}"
      • + + {% if num_skipped > 0 %} +
      • + Skipped sending {{ num_skipped }} emails which were already delivered +
      • + {% endif %} + + {% if !errors.is_empty() %} +
      • + Failed to send {{ errors.len() }} emails +
          + {% for (email, message) in errors.iter() %} +
        • {{ email }}: {{ message }}
        • + {% endfor %} +
        • foo@foo.com: test
        • +
        +
      • + {% endif %} +
      +
      +{% endblock content %} diff --git a/frontend/templates/posts/view.html b/frontend/templates/posts/view.html new file mode 100644 index 00000000..95d089cf --- /dev/null +++ b/frontend/templates/posts/view.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} +{% block title %}{{ post.title }}{% endblock title %} + +{% block content %} +
      +

      {{ post.title }}

      + + {{ post.content | safe }} +
      +{% endblock %} diff --git a/frontend/templates/register.tera.html b/frontend/templates/register.tera.html deleted file mode 100644 index faee7810..00000000 --- a/frontend/templates/register.tera.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - WLSD - - - -
      -

      Register

      -
      - - - - - - - - -
      -
      - - diff --git a/frontend/templates/temp.html b/frontend/templates/temp.html deleted file mode 100644 index b85bd127..00000000 --- a/frontend/templates/temp.html +++ /dev/null @@ -1,37 +0,0 @@ -Hello! I was so excited to get Zoë’s first newsletter out earlier this week that -we forgot to list a few things, and have since announced another Dleepover! Hope -you don’t mind getting two emails from us this week. I do my best to be mindful -of your attention spans.... -![poster](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcaeb3c03-0c33-4b2d-ba98-13e00050bdbc_2232x2790.jpeg) -[12.02.2024 Deep Creep and Ando will Present Sounds. Food by Live -Canteen.](https://www.eventcreate.com/e/deepcreepandops) Sasha (aka Deep Creep) -and Andrew (aka Ando) have both presented sounds for you all before- excited to -have them both back in the booth this week for some sonic explorations. Eli (aka -Live Canteen) is continuing to raise the bar with her culinary excellence. Check -out this week’s menu: > minestrone soup, parsley/pine nut/meyer lemon pesto, -Cacio e Pepe sourdough -![poster2](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe378ddde-4c73-44fc-ba41-db5a354922a8_2160x2700.jpeg) -[12.14.2024 Pique-nique Presents: Persian Empire, live. Food by Live -Canteen.](https://dice.fm/partner/dice/event/av2vvv-pique-nique-presents-14th-dec-tba-location-new-york-new-york-tickets) -When Sam (aka Loum) or Jared hit us up about doing anything, we do our best to -make space for them in the calendar. To say that I trust their curitorial vision -is an understatement. The Pique-nique approach to presenting music is woven into -the fabric of the Light and Sound Design studio. This Saturday, Sam is hosting -German producer Persian Empire for a live hardware set. This will be his first -ever show in the US, and judging by the amount of messages I’ve gotten since -we’ve announced I expect this one will a full house, and a memorable one at -that. Tickets are limited, and going quickly. Come hungry, Eli is cooking again: -> mulligatawny soup, potato and cheese borekas, Korean style carrots, tahina and -mango lime pickle -![poster3](https://substackcdn.com/image/fetch/w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F58ed2eb4-cf66-447c-8112-21917f7a79d9_3348x4329.jpeg) -[12.20.2024 Solstice Dsleepover](https://www.eventcreate.com/e/dsleepover1221) -On the other end of the energetic spectrum is the next edition of our Dsleepover -series to celebrate the longest night of the year. This one is a gentle -collaboration with Dan and Serena of Testu Collective. They will be providing -visuals and sonics from the night along with myself, Vin (aka fieldtalk), Annie -(aka UCC Harlo), MA, and Dominka Mazurová. If you’ve yet to take part in one of -these, the idea is simple. We make sound for you to sleep to. Come and go as you -please. Mattresses, toothbrushes and blankets are available, but you are -encouraged to bring your own. The studio can be a bit drafty this time of year. -There will be food too. More on that later… Something Nice to Listen to: -[ambient flo](https://www.ambientflo.com/) Hope to see you soon. Love, KG diff --git a/frontend/templates/test.css b/frontend/templates/test.css deleted file mode 100644 index 65801147..00000000 --- a/frontend/templates/test.css +++ /dev/null @@ -1,29 +0,0 @@ -.pell { - border: 1px solid hsla(0, 0%, 4%, 0.1); -} -.pell, -.pell-content { - box-sizing: border-box; -} -.pell-content { - height: 300px; - outline: 0; - overflow-y: auto; - padding: 10px; -} -.pell-actionbar { - background-color: #fff; - border-bottom: 1px solid hsla(0, 0%, 4%, 0.1); -} -.pell-button { - background-color: transparent; - border: none; - cursor: pointer; - height: 30px; - outline: 0; - width: 30px; - vertical-align: bottom; -} -.pell-button-selected { - background-color: #f0f0f0; -} diff --git a/package.json b/package.json index 670f6478..8ffc4f64 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "watch:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --watch", "build:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css", "build:styles.min": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --minify", - "format": "npx prettier ./frontend/templates ./frontend/styles --write", - "format:check": "npx prettier ./frontend/templates ./frontend/styles --check" + "format": "npx prettier ./frontend/templates ./frontend/styles --config frontend/prettier.config.cjs --write", + "format:check": "npx prettier ./frontend/templates ./frontend/styles --config frontend/prettier.config.cjs --check" }, "devDependencies": { "livereload": "^0.9.3", @@ -16,4 +16,4 @@ "prettier-plugin-tailwindcss": "^0.6.11", "@tailwindcss/cli": "^4.1.2" } -} +} \ No newline at end of file diff --git a/src/app/auth.rs b/src/app/auth.rs index 3849f1a9..d0b642fb 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -18,6 +18,7 @@ //! - `/register`: The user is prompted to enter their first/last name. //! Upon submission, the user gets a new session cookie and is redirected home. +use askama::Template; use axum::{ extract::{OptionalFromRequestParts, Query, Request, State}, http::{header, request::Parts, StatusCode}, @@ -30,12 +31,15 @@ use axum_extra::extract::CookieJar; use lettre::message::{header::ContentType, Mailbox}; use std::convert::Infallible; -use crate::db::user::{UpdateUser, User}; use crate::db::{ email::Email, token::{LoginToken, SessionToken}, }; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; +use crate::{ + db::user::{UpdateUser, User}, + views, +}; /// Add all auth routes to the router. pub fn register(router: AppRouter, state: SharedAppState) -> AppRouter { @@ -144,11 +148,7 @@ async fn login_link( ); Ok(headers.into_response()) } - None => { - let ctx = tera::Context::new(); - let html = state.templates.render("login.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) - } + None => Ok(Html(views::auth::Login.render()?).into_response()), } } #[derive(serde::Deserialize)] @@ -157,15 +157,10 @@ struct LoginQuery { } /// Display the registration page. -async fn register_link( - State(state): State, - Query(query): Query, -) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("token", &query.token); +async fn register_link(Query(query): Query) -> AppResult { + let register_template = views::auth::Register { token: query.token.clone() }; - let html = state.templates.render("register.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(register_template.render()?).into_response()) } #[derive(serde::Deserialize)] struct RegisterQuery { diff --git a/src/app/events.rs b/src/app/events.rs index c5417249..a5fc8f93 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -1,3 +1,4 @@ +use askama::Template; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -7,16 +8,19 @@ use axum::{ }; use chrono::Utc; -use crate::db::event::{Event, UpdateEvent}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; +use crate::{ + db::event::{Event, UpdateEvent}, + views, +}; /// Add all `events` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { router .route("/events", get(list_events_page)) - .route("/e/new", get(create_event_page).post(create_event_form)) + .route("/events/new", get(create_event_page).post(create_event_form)) .route( - "/e/{id}", + "/events/{id}", // TODO: Move to a separate `/e/{id}/edit` route, and add a `/e/{id}` to just view the event. get(update_event_page).post(update_event_form).delete(delete_event), ) @@ -41,11 +45,9 @@ async fn list_events_page( }) .collect::>(); - let mut ctx = tera::Context::new(); - ctx.insert("events", &events); + let list_template = views::events::EventList { events }; - let html = state.templates.render("event-list.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(list_template.render()?).into_response()) } #[derive(serde::Deserialize)] struct ListEventsQuery { @@ -53,10 +55,8 @@ struct ListEventsQuery { } /// Display the form to create a new event. -async fn create_event_page(State(state): State) -> AppResult { - let ctx = tera::Context::new(); - let html = state.templates.render("event-create.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) +async fn create_event_page() -> AppResult { + Ok(Html(views::events::EventCreate.render()?).into_response()) } /// Process the form and create a new event. @@ -75,11 +75,9 @@ async fn update_event_page(State(state): State, Path(id): Path AppRouter { @@ -13,13 +14,8 @@ pub fn register_routes(router: AppRouter) -> AppRouter { } /// Display the front page. -async fn home_page(State(state): State, user: Option) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("message", "Hello, world!"); - if let Some(user) = user { - ctx.insert("user", &user); - } +async fn home_page(State(_state): State, _user: Option) -> AppResult { + let index_template = views::index::IndexTemplate {}; - let html = state.templates.render("home.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(index_template.render()?).into_response()) } diff --git a/src/app/lists.rs b/src/app/lists.rs index 54f0ccad..dcb5d328 100644 --- a/src/app/lists.rs +++ b/src/app/lists.rs @@ -1,4 +1,5 @@ use anyhow::anyhow; +use askama::Template; use axum::{ extract::{Path, State}, http::StatusCode, @@ -12,6 +13,7 @@ use lettre::message::Mailbox; use crate::{ db::list::{List, UpdateList}, utils::types::AppError, + views, }; use crate::{ db::user::User, @@ -35,11 +37,9 @@ async fn list_lists_page(State(state): State, user: User) -> App let lists = List::list(&state.db).await?; - let mut ctx = tera::Context::new(); - ctx.insert("lists", &lists); + let list_template = views::lists::Lists { lists }; - let html = state.templates.render("lists.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(list_template.render()?).into_response()) } /// Display the form to view and edit a list. @@ -57,12 +57,9 @@ async fn edit_list_page( }; let members = List::list_members(&state.db, id).await?; - let mut ctx = tera::Context::new(); - ctx.insert("list", &list); - ctx.insert("members", &members); + let edit_template = views::lists::ListEdit { list, members }; - let html = state.templates.render("list-edit.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(edit_template.render()?).into_response()) } /// Display the form to create a new list. @@ -71,21 +68,18 @@ async fn create_list_page(State(state): State, user: User) -> Ap return Ok(StatusCode::FORBIDDEN.into_response()); } - let mut ctx = tera::Context::new(); - ctx.insert( - "list", - &List { + let create_template = views::lists::ListEdit { + list: List { id: 0, name: "".into(), description: "".into(), created_at: Utc::now().naive_utc(), updated_at: Utc::now().naive_utc(), }, - ); - ctx.insert::<[String], _>("members", &[]); + members: Vec::new(), + }; - let html = state.templates.render("list-edit.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(create_template.render()?).into_response()) } /// Process the form and create or edit a list. diff --git a/src/app/mod.rs b/src/app/mod.rs index e8fea08e..03e4b355 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,6 @@ use anyhow::Result; use axum::{response::Redirect, routing::get, Router}; use std::sync::Arc; -use tera::Tera; use tower_http::services::ServeDir; use crate::db::Db; @@ -18,7 +17,6 @@ mod posts; #[allow(unused)] pub struct AppState { config: Config, - templates: Tera, db: Db, mailer: Emailer, } @@ -26,7 +24,6 @@ pub struct AppState { pub async fn build(config: Config) -> Result { let state = Arc::new(AppState { config: config.clone(), - templates: utils::tera::templates(&config)?, db: crate::db::init(&config.db).await?, mailer: Emailer::connect(config.email).await?, }); diff --git a/src/app/posts.rs b/src/app/posts.rs index aba08fa8..e5a55e45 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, time::Duration}; +use askama::Template; use axum::{ extract::{Path, State}, http::StatusCode, @@ -11,13 +12,16 @@ use chrono::Utc; use lettre::message::header::ContentType; use tokio::time::sleep; -use crate::db::{ - email::Email, - list::{List, ListMember}, - post::{Post, UpdatePost}, - user::User, -}; use crate::utils::types::{AppResult, AppRouter, SharedAppState}; +use crate::{ + db::{ + email::Email, + list::{List, ListMember}, + post::{Post, UpdatePost}, + user::User, + }, + views, +}; /// Add all `post` routes to the router. pub fn register_routes(router: AppRouter) -> AppRouter { @@ -39,11 +43,9 @@ async fn list_posts_page(State(state): State, user: User) -> App let posts = Post::list(&state.db).await?; - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); + let list_template = views::posts::PostList { posts }; - let html = state.templates.render("post-list.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(list_template.render()?).into_response()) } /// Display a single post. @@ -52,11 +54,8 @@ async fn view_post_page(State(state): State, Path(url): Path, user: User) -> Ap return Ok(StatusCode::FORBIDDEN.into_response()); } - let mut ctx = tera::Context::new(); - ctx.insert( - "post", - &Post { + let create_template = views::posts::PostEdit { + post: Post { id: 0, title: "".into(), url: "".into(), @@ -77,10 +74,9 @@ async fn create_post_page(State(state): State, user: User) -> Ap created_at: Utc::now().naive_utc(), updated_at: Utc::now().naive_utc(), }, - ); + }; - let html = state.templates.render("post-edit.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(create_template.render()?).into_response()) } /// Display the form to create a new post. @@ -96,11 +92,9 @@ async fn edit_post_page( return Ok(StatusCode::NOT_FOUND.into_response()); }; - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); + let edit_template = views::posts::PostEdit { post }; - let html = state.templates.render("post-edit.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(edit_template.render()?).into_response()) } /// Process the form and create or edit a post. @@ -142,12 +136,9 @@ async fn send_post_page( }; let lists = List::list(&state.db).await?; - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - ctx.insert("lists", &lists); + let send_template = views::posts::PostSend { post, lists }; - let html = state.templates.render("post-send.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(send_template.render()?).into_response()) } /// Process the form and create or edit a post. @@ -168,9 +159,8 @@ async fn send_post_form( }; let members = List::list_members(&state.db, form.list_id).await?; - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - ctx.insert("post_url", &format!("{}/p/{}", &state.config.app.url, &post.url)); + let mut email_template = + views::posts::PostEmail { post: post.clone(), opened_url: "".into(), unsub_url: "".into() }; let mut num_sent = 0; let mut num_skipped = 0; @@ -185,9 +175,8 @@ async fn send_post_form( } let email_id = Email::create_post(&state.db, email, post.id, list.id).await?; - ctx.insert("opened_url", &format!("{}/emails/{email_id}/footer.gif", &state.config.app.url)); - ctx.insert("unsub_url", &format!("{}/emails/{email_id}/unsubscribe", &state.config.app.url)); - let html = state.templates.render("post-email.tera.html", &ctx).unwrap(); + email_template.opened_url = format!("{}/emails/{email_id}/footer.gif", &state.config.app.url); + email_template.unsub_url = format!("{}/emails/{email_id}/unsubscribe", &state.config.app.url); let msg = state .mailer @@ -195,7 +184,7 @@ async fn send_post_form( .to(email.parse().unwrap()) .subject(&post.title) .header(ContentType::TEXT_HTML) - .body(html) + .body(email_template.render()?) .unwrap(); match state.mailer.send(msg).await { @@ -213,24 +202,20 @@ async fn send_post_form( sleep(Duration::from_secs(1)).await; } - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - ctx.insert("list", &list); - ctx.insert("stats", &Stats { num_sent, num_skipped, errors }); + let sent_template = views::posts::PostSent { + post_title: post.title, + list_name: list.name, + num_sent, + num_skipped, + errors, + }; - let html = state.templates.render("post-sent.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) + Ok(Html(sent_template.render()?).into_response()) } #[derive(serde::Deserialize)] struct SendPost { list_id: i64, } -#[derive(serde::Serialize)] -struct Stats { - pub num_sent: usize, - pub num_skipped: usize, - pub errors: HashMap, -} /// Process the form and create or edit a post. async fn delete_post_form( diff --git a/src/db/list.rs b/src/db/list.rs index 64bc1bb8..5c43823e 100644 --- a/src/db/list.rs +++ b/src/db/list.rs @@ -16,8 +16,8 @@ pub struct List { #[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct ListMember { pub email: String, - pub first_name: Option, - pub last_name: Option, + pub first_name: String, + pub last_name: String, } #[derive(serde::Deserialize)] diff --git a/src/db/post.rs b/src/db/post.rs index 0b846b07..efbb10b0 100644 --- a/src/db/post.rs +++ b/src/db/post.rs @@ -3,7 +3,7 @@ use chrono::NaiveDateTime; use super::Db; -#[derive(Debug, sqlx::FromRow, serde::Serialize)] +#[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] pub struct Post { pub id: i64, pub title: String, diff --git a/src/main.rs b/src/main.rs index bf28bbf9..33c3e8e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; mod app; mod db; mod utils; +mod views; use axum::{handler::HandlerWithoutStateExt, response::Redirect}; use axum_server::tls_rustls::RustlsConfig; @@ -13,6 +14,7 @@ use utils::config::*; #[tokio::main] async fn main() -> Result<()> { + // TODO(sam) is it possible to filter the logs from ServeDir? let log_filter = tracing_subscriber::filter::Targets::default() .with_target("h2", LevelFilter::OFF) .with_target("globset", LevelFilter::OFF) @@ -29,6 +31,7 @@ async fn main() -> Result<()> { // Load the server config let file = std::env::args().nth(1).context("usage: lsd ")?; let config = Config::load(&file).await?; + views::filters::set_timezone(config.app.tz); let app = app::build(config.clone()).await?.into_make_service(); tracing::info!("Live at {}", &config.app.url); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index bdf583ba..119c03ba 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,4 @@ pub mod config; pub mod emailer; -pub mod tera; pub mod tracing; pub mod types; diff --git a/src/utils/tera.rs b/src/utils/tera.rs deleted file mode 100644 index d52444c3..00000000 --- a/src/utils/tera.rs +++ /dev/null @@ -1,57 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::NaiveDateTime; -use std::collections::HashMap; -use tera::{Tera, Value}; - -use crate::Config; - -/// Initialize the [`Tera`] template engine, including our custom filter functions. -pub fn templates(config: &Config) -> Result { - let mut tera = Tera::new("frontend/templates/*")?; - - // Format a datetime with a [`strftime`] format string. - // Also converts from UTC to the app's local timezone. - // - // Usage: `{{ date | format_datetime(format="%m.%d.%Y") }}` - // - // [`strftime`]: https://devhints.io/strftime - let tz = config.app.tz; - register_filter( - &mut tera, - "format_datetime", - move |date: &Value, args: &HashMap| { - let format = args.get("format").context("missing arg=`format`")?; - let format = format.as_str().context("arg=`format` must be a string")?; - - let date: &str = date.as_str().with_context(|| format!("value={date:?} must be a string"))?; - let date: NaiveDateTime = date.parse().context("parsing date")?; - let utc = date.and_utc(); - let local = utc.with_timezone(&tz); - - let formatted = local.format(format).to_string(); - Ok(Value::String(formatted)) - }, - ); - - Ok(tera) -} - -/// Register a tera filter function. -/// -/// On top of the regular `register_filter`, this function adds the filter name -/// as context to any errors, and handles conversion from `anyhow::Error` to -/// `tera::Error`. -fn register_filter(tera: &mut Tera, name: &str, func: F) -where - F: Fn(&Value, &HashMap) -> Result + Send + Sync + 'static, -{ - let name_ = name.to_string(); - tera.register_filter( - name, - move |value: &Value, args: &HashMap| -> tera::Result { - func(value, args) - .with_context(|| format!("{}()", &name_)) - .map_err(|err| tera::Error::msg(err.to_string())) - }, - ); -} diff --git a/src/views/auth.rs b/src/views/auth.rs new file mode 100644 index 00000000..24c56acb --- /dev/null +++ b/src/views/auth.rs @@ -0,0 +1,11 @@ +use askama::Template; + +#[derive(Template)] +#[template(path = "auth/login.html")] +pub struct Login; + +#[derive(Template)] +#[template(path = "auth/register.html")] +pub struct Register { + pub token: String, +} diff --git a/src/views/events.rs b/src/views/events.rs new file mode 100644 index 00000000..c176e89c --- /dev/null +++ b/src/views/events.rs @@ -0,0 +1,19 @@ +use crate::db::event::Event; +use crate::views::filters; +use askama::Template; + +#[derive(Template)] +#[template(path = "events/create.html")] +pub struct EventCreate; + +#[derive(Template)] +#[template(path = "events/list.html")] +pub struct EventList { + pub events: Vec, +} + +#[derive(Template)] +#[template(path = "events/view.html")] +pub struct EventView { + pub event: Event, +} diff --git a/src/views/filters.rs b/src/views/filters.rs new file mode 100644 index 00000000..f551f67c --- /dev/null +++ b/src/views/filters.rs @@ -0,0 +1,16 @@ +use std::sync::OnceLock; + +use chrono::NaiveDateTime; + +static TZ: OnceLock = OnceLock::new(); + +pub fn set_timezone(tz: chrono_tz::Tz) { + TZ.set(tz).unwrap(); +} + +pub fn format_datetime(date: &NaiveDateTime, format: &str) -> Result { + // For some reason `and_local_timezone` is fallible whereas `and_utc -> `with_timezone` is not + let local = date.and_utc().with_timezone(TZ.get().expect("Uninitialized timezone value")); + let formatted = local.format(format).to_string(); + Ok(formatted) +} diff --git a/src/views/index.rs b/src/views/index.rs new file mode 100644 index 00000000..0a362ce8 --- /dev/null +++ b/src/views/index.rs @@ -0,0 +1,5 @@ +use askama::Template; + +#[derive(Template)] +#[template(path = "index.html")] +pub struct IndexTemplate; diff --git a/src/views/lists.rs b/src/views/lists.rs new file mode 100644 index 00000000..8e61a442 --- /dev/null +++ b/src/views/lists.rs @@ -0,0 +1,15 @@ +use crate::db::list; +use askama::Template; + +#[derive(Template)] +#[template(path = "lists/view.html")] +pub struct Lists { + pub lists: Vec, +} + +#[derive(Template)] +#[template(path = "lists/edit.html")] +pub struct ListEdit { + pub list: list::List, + pub members: Vec, +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 00000000..f704d8bf --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod events; +pub mod filters; +pub mod index; +pub mod lists; +pub mod posts; diff --git a/src/views/posts.rs b/src/views/posts.rs new file mode 100644 index 00000000..2ef4dcf0 --- /dev/null +++ b/src/views/posts.rs @@ -0,0 +1,49 @@ +use crate::views::filters; +use std::collections::HashMap; + +use askama::Template; + +use crate::db::{list::List, post::Post}; + +#[derive(Template)] +#[template(path = "posts/edit.html")] +pub struct PostEdit { + pub post: Post, +} + +#[derive(Template, Clone)] +#[template(path = "posts/email.html")] +pub struct PostEmail { + pub post: Post, + pub opened_url: String, + pub unsub_url: String, +} + +#[derive(Template)] +#[template(path = "posts/list.html")] +pub struct PostList { + pub posts: Vec, +} + +#[derive(Template)] +#[template(path = "posts/send.html")] +pub struct PostSend { + pub post: Post, + pub lists: Vec, +} + +#[derive(Template)] +#[template(path = "posts/sent.html")] +pub struct PostSent { + pub post_title: String, + pub list_name: String, + pub num_sent: i64, + pub num_skipped: i64, + pub errors: HashMap, +} + +#[derive(Template)] +#[template(path = "posts/view.html")] +pub struct PostView { + pub post: Post, +} From b7f3e1cc2e84257fb5b7e3b8039f2aa2fe37a693 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Tue, 8 Apr 2025 09:40:28 -0400 Subject: [PATCH 56/89] enable deploy on workflow dispatch --- .github/workflows/deploy.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index a0352828..32de596a 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -2,6 +2,7 @@ name: Deploy on: push: branches: [main] + workflow_dispatch: {} jobs: deploy: From aa8df983713b2a6ec8762ea58a8c3653d4da5dd4 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Tue, 8 Apr 2025 14:23:29 -0400 Subject: [PATCH 57/89] convert templating engine to askama take 2 (#26) Second take on https://github.com/foltik/lsd/pull/22 correctly inherit from base template to include base styles --- frontend/templates/auth/login.html | 2 + frontend/templates/auth/register.html | 2 + frontend/templates/events/create.html | 2 + frontend/templates/events/list.html | 2 + frontend/templates/events/view.html | 2 + frontend/templates/layout.html | 188 +++++++++++++++++++++++++- frontend/templates/lists/edit.html | 2 + frontend/templates/lists/view.html | 2 + frontend/templates/posts/edit.html | 2 + frontend/templates/posts/list.html | 2 + frontend/templates/posts/send.html | 2 + frontend/templates/posts/sent.html | 2 + 12 files changed, 203 insertions(+), 7 deletions(-) diff --git a/frontend/templates/auth/login.html b/frontend/templates/auth/login.html index 9c8d64ef..1e4c0acf 100644 --- a/frontend/templates/auth/login.html +++ b/frontend/templates/auth/login.html @@ -2,6 +2,8 @@ {% block title %}light and sound - login{% endblock title %} {% block styles %} + {% call super() %} + {% endblock styles %} @@ -25,8 +201,6 @@ {% endblock header %} -
      - {% block content %}{% endblock content %} -
      +
      {% block content %}{% endblock content %}
      diff --git a/frontend/templates/lists/edit.html b/frontend/templates/lists/edit.html index 48f26f43..cfc0419e 100644 --- a/frontend/templates/lists/edit.html +++ b/frontend/templates/lists/edit.html @@ -1,6 +1,8 @@ {% extends "layout.html" %} {% block title %}Edit list - {{ list.name }}{% endblock %} {% block styles %} + {% call super() %} + +{% endblock styles %} + +{% block content %} +
      +

      Are you sure you want to unsubscribe from our mailing list?

      +
      + +
      +
      +{% endblock %} diff --git a/src/app/emails.rs b/src/app/emails.rs index 857b9d90..ff62be5c 100644 --- a/src/app/emails.rs +++ b/src/app/emails.rs @@ -11,17 +11,18 @@ use crate::{ error::AppResult, types::{AppRouter, SharedAppState}, }, + views, }; /// Add all `email` routes to the router. pub fn routes() -> AppRouter { AppRouter::new() .route("/{id}/footer.gif", get(email_opened)) - .route("/{id}/unsubscribe", get(email_unsubscribed)) + .route("/{id}/unsubscribe", get(email_unsubscribe_view).post(email_unsubscribe_form)) } -async fn email_opened(Path(id): Path, State(state): State) -> AppResult { - Email::mark_opened(&state.db, id).await?; +async fn email_opened(Path(email_id): Path, State(state): State) -> AppResult { + Email::mark_opened(&state.db, email_id).await?; let pixel = Response::builder() .status(StatusCode::OK) .header("Content-Type", "image/gif") @@ -30,8 +31,23 @@ async fn email_opened(Path(id): Path, State(state): State) Ok(pixel) } -async fn email_unsubscribed(Path(id): Path, State(state): State) -> AppResult { - if let Some(email) = Email::lookup(&state.db, id).await? { +async fn email_unsubscribe_view( + Path(email_id): Path, + State(state): State, +) -> AppResult { + // TODO: Better error handling rather than silently eating + if let Some(email) = Email::lookup(&state.db, email_id).await? { + return Ok(views::emails::Unsubscribe { email_id, email_address: email.address }.into_response()); + } + Ok("You have been unsubscribed.".into_response()) +} + +async fn email_unsubscribe_form( + Path(email_id): Path, + State(state): State, +) -> AppResult { + // TODO: Better error handling rather than silently eating + if let Some(email) = Email::lookup(&state.db, email_id).await? { if let Some(list_id) = email.list_id { List::remove_member(&state.db, list_id, &email.address).await?; } diff --git a/src/views/emails.rs b/src/views/emails.rs new file mode 100644 index 00000000..96c63202 --- /dev/null +++ b/src/views/emails.rs @@ -0,0 +1,9 @@ +use askama::Template; +use askama_web::WebTemplate; + +#[derive(Template, WebTemplate)] +#[template(path = "emails/unsubscribe.html")] +pub struct Unsubscribe { + pub email_id: i64, + pub email_address: String, +} diff --git a/src/views/mod.rs b/src/views/mod.rs index f704d8bf..fbba6ed6 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod emails; pub mod events; pub mod filters; pub mod index; From a127b5adeba0f3c0e9456ece326e64231580ec8a Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Wed, 16 Apr 2025 19:16:52 -0400 Subject: [PATCH 65/89] Fix email.html post URL --- src/app/posts.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/posts.rs b/src/app/posts.rs index 0b1b7c64..6d8c2615 100644 --- a/src/app/posts.rs +++ b/src/app/posts.rs @@ -159,13 +159,12 @@ async fn send_post_form( }; let members = List::list_members(&state.db, form.list_id).await?; - let mut email_template = - views::posts::PostEmail { post: post.clone(), opened_url: "".into(), unsub_url: "".into() }; - // XXX: The `url` field is just a slug, not an absolute URL. // We can't yet access `config.app.url` within templates, so we just mutate // the URL here and rely on that behavior in the `email.html` template. post.url = format!("{}/p/{}", &state.config.app.url, &post.url); + let mut email_template = + views::posts::PostEmail { post: post.clone(), opened_url: "".into(), unsub_url: "".into() }; let mut num_sent = 0; let mut num_skipped = 0; From b7e1a3f979046dd83050d624506caef0f105a24e Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Wed, 16 Apr 2025 19:44:54 -0400 Subject: [PATCH 66/89] Make User first_name and last_name nullable --- frontend/templates/lists/edit.html | 12 ++++++++++-- .../0011_remove_users_name_nullability.down.sql | 12 ++++++++++++ migrations/0011_remove_users_name_nullability.up.sql | 10 ++++++++++ src/db/list.rs | 4 ++-- src/db/user.rs | 4 ++-- 5 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 migrations/0011_remove_users_name_nullability.down.sql create mode 100644 migrations/0011_remove_users_name_nullability.up.sql diff --git a/frontend/templates/lists/edit.html b/frontend/templates/lists/edit.html index cfc0419e..c79e9059 100644 --- a/frontend/templates/lists/edit.html +++ b/frontend/templates/lists/edit.html @@ -50,8 +50,16 @@ > Remove - {{ member.email }} ({{ member.first_name }} - {{ member.last_name }}) + {% if let Some(first_name) = member.first_name %} + {% if let Some(last_name) = member.last_name %} + {{ member.email }} + ({{ first_name }} {{ last_name }}) + {% else %} + {{ member.email }} + {% endif %} + {% else %} + {{ member.email }} + {% endif %}
    • {% endfor %}
    diff --git a/migrations/0011_remove_users_name_nullability.down.sql b/migrations/0011_remove_users_name_nullability.down.sql new file mode 100644 index 00000000..553d34ae --- /dev/null +++ b/migrations/0011_remove_users_name_nullability.down.sql @@ -0,0 +1,12 @@ +PRAGMA writable_schema = ON; + +DELETE FROM users WHERE first_name IS NULL OR last_name IS NULL; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT', 'first_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT', 'last_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/migrations/0011_remove_users_name_nullability.up.sql b/migrations/0011_remove_users_name_nullability.up.sql new file mode 100644 index 00000000..5529226d --- /dev/null +++ b/migrations/0011_remove_users_name_nullability.up.sql @@ -0,0 +1,10 @@ +PRAGMA writable_schema = ON; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT NOT NULL', 'first_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT NOT NULL', 'last_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/src/db/list.rs b/src/db/list.rs index 2ab58a73..b5946e68 100644 --- a/src/db/list.rs +++ b/src/db/list.rs @@ -16,8 +16,8 @@ pub struct List { #[derive(Debug, sqlx::FromRow, serde::Serialize)] pub struct ListMember { pub email: String, - pub first_name: String, - pub last_name: String, + pub first_name: Option, + pub last_name: Option, } #[derive(serde::Deserialize)] diff --git a/src/db/user.rs b/src/db/user.rs index 1eb9b75c..1e3d0156 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -7,8 +7,8 @@ use crate::utils::error::AppResult; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct User { pub id: i64, - pub first_name: String, - pub last_name: String, + pub first_name: Option, + pub last_name: Option, pub email: String, pub created_at: NaiveDateTime, } From 2b0bf04d80646c4b1c2d7e65c41098e43ccf4529 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Thu, 17 Apr 2025 01:55:40 -0400 Subject: [PATCH 67/89] Add list signup form --- frontend/styles/main.css | 3 +- frontend/templates/lists/signup.html | 28 +++++++++++++++ src/app/lists.rs | 51 ++++++++++++++++++++++++++++ src/app/mod.rs | 1 + src/views/lists.rs | 6 ++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 frontend/templates/lists/signup.html diff --git a/frontend/styles/main.css b/frontend/styles/main.css index 7c80c0b8..40db8f00 100644 --- a/frontend/styles/main.css +++ b/frontend/styles/main.css @@ -56,7 +56,8 @@ label { @apply mb-2 block; } -input[type="text"] { +input[type="text"], +input[type="email"] { @apply w-full border border-neutral-700 bg-neutral-950 p-2; } diff --git a/frontend/templates/lists/signup.html b/frontend/templates/lists/signup.html new file mode 100644 index 00000000..d94363a0 --- /dev/null +++ b/frontend/templates/lists/signup.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - Sign Up{% endblock title %} +{% block styles %} + {% call super() %} + + +{% endblock styles %} +{% block content %} +
    +

    Sign up for {{ list.description }}

    +
    +
    + + +
    + +
    + +
    +
    +
    +{% endblock content %} diff --git a/src/app/lists.rs b/src/app/lists.rs index 96942b03..1238b3dd 100644 --- a/src/app/lists.rs +++ b/src/app/lists.rs @@ -26,6 +26,7 @@ pub fn routes() -> AppRouter { .route("/", get(list_lists_page)) .route("/new", get(create_list_page)) .route("/{id}", get(edit_list_page).post(edit_list_form)) + .route("/{id}/signup", get(signup_page).post(signup_form)) .route("/{id}/{email}", delete(remove_list_member)) } @@ -121,3 +122,53 @@ async fn remove_list_member( List::remove_member(&state.db, id, &email).await?; Ok(()) } + +/// Display the newsletter signup page. +// XXX: Hard coded to list with id=1. +pub async fn newsletter_signup_page(State(state): State) -> AppResult { + signup_page(State(state), Path(1)).await +} + +/// Display the list signup page. +async fn signup_page( + State(state): State, + Path(list_id): Path, +) -> AppResult { + // XXX: Hard code only allow id 1 to be signed up to. + // A flag should be added to List whether it's public or not, and what the signup page looks like. + if list_id != 1 { + return Err(AppError::NotAuthorized); + } + + let Some(list) = List::lookup_by_id(&state.db, list_id).await? else { + return Err(AppError::NotFound); + }; + + Ok(views::lists::Signup { list }) +} + +/// Process the list signup form. +// +// XXX: We really should rate limit this. +async fn signup_form( + State(state): State, + Form(form): Form, +) -> AppResult { + // XXX: Hard code only allow id 1 to be signed up to. + // A flag should be added to List whether it's public or not, and what the signup page looks like. + if form.list_id != 1 { + return Err(AppError::NotAuthorized); + } + + let Some(list) = List::lookup_by_id(&state.db, form.list_id).await? else { + return Err(AppError::NotFound); + }; + List::add_members(&state.db, list.id, &[form.email.email.as_ref()]).await?; + + Ok("You have succesfully signed up!") +} +#[derive(serde::Deserialize)] +struct NewsletterForm { + list_id: i64, + email: Mailbox, +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 41a28175..d249bc39 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -37,6 +37,7 @@ pub async fn build(config: Config) -> anyhow::Result { .route("/p/{url}", get(posts::view_post_page)) .nest("/events", events::routes()) .nest("/lists", lists::routes()) + .route("/newsletter", get(lists::newsletter_signup_page)) .nest("/emails", emails::routes()) .nest_service("/static", ServeDir::new("frontend/static")) // For non-HTML pages without a , this is where the browser looks diff --git a/src/views/lists.rs b/src/views/lists.rs index 5ecbf536..ddb14072 100644 --- a/src/views/lists.rs +++ b/src/views/lists.rs @@ -15,3 +15,9 @@ pub struct ListEdit { pub list: list::List, pub members: Vec, } + +#[derive(Template, WebTemplate)] +#[template(path = "lists/signup.html")] +pub struct Signup { + pub list: list::List, +} From 4742062b25369318b7ea80d4cb9db4b1fadec299 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Thu, 17 Apr 2025 02:08:45 -0400 Subject: [PATCH 68/89] Fix list edit JOIN --- src/db/list.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/list.rs b/src/db/list.rs index b5946e68..daa3e9b7 100644 --- a/src/db/list.rs +++ b/src/db/list.rs @@ -84,7 +84,7 @@ impl List { ListMember, r#"SELECT e.email, u.first_name, u.last_name FROM list_members e - JOIN users u ON u.email = e.email + LEFT JOIN users u ON u.email = e.email WHERE e.list_id = ? ORDER BY e.created_at"#, list_id From 2643847171f5342e0a56d9537905373400378971 Mon Sep 17 00:00:00 2001 From: Jack Foltz Date: Thu, 17 Apr 2025 02:13:48 -0400 Subject: [PATCH 69/89] Fix sqlx --- ...0a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json | 4 ++-- ...42e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json | 4 ++-- ...980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json} | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) rename .sqlx/{query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json => query-b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json} (66%) diff --git a/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json b/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json index b1d39aa9..7395c683 100644 --- a/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json +++ b/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json @@ -34,8 +34,8 @@ }, "nullable": [ false, - false, - false, + true, + true, false, false ] diff --git a/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json b/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json index 3469975b..922e50a6 100644 --- a/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json +++ b/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json @@ -34,8 +34,8 @@ }, "nullable": [ false, - false, - false, + true, + true, false, false ] diff --git a/.sqlx/query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json b/.sqlx/query-b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json similarity index 66% rename from .sqlx/query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json rename to .sqlx/query-b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json index bb27cc66..b2692b07 100644 --- a/.sqlx/query-7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0.json +++ b/.sqlx/query-b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT e.email, u.first_name, u.last_name\n FROM list_members e\n JOIN users u ON u.email = e.email\n WHERE e.list_id = ?\n ORDER BY e.created_at", + "query": "SELECT e.email, u.first_name, u.last_name\n FROM list_members e\n LEFT JOIN users u ON u.email = e.email\n WHERE e.list_id = ?\n ORDER BY e.created_at", "describe": { "columns": [ { @@ -24,9 +24,9 @@ }, "nullable": [ false, - false, - false + true, + true ] }, - "hash": "7ca407a30cccec2dab26603e1bc8cd1f5ecf3177db672bc6fd96ff91e17af3d0" + "hash": "b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7" } From c3dbd188d095b0141318f0c13d5b4e8b18f276e1 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Thu, 17 Apr 2025 01:22:28 -0400 Subject: [PATCH 70/89] Actually run migrations --- src/db/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 04fffe3b..9c32f5f7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -24,7 +24,7 @@ pub async fn init(db_config: &DbConfig) -> anyhow::Result { } let db = SqlitePool::connect(&url).await?; - sqlx::migrate!("./migrations"); + sqlx::migrate!("./migrations").run(&db); if let Some(seed_data) = &db_config.seed_data { seed_db(&db, seed_data).await?; From a4ba2df075c0645e084c848275348bbf22c2a1a8 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Thu, 17 Apr 2025 01:23:11 -0400 Subject: [PATCH 71/89] ? --- src/db/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 9c32f5f7..421d9d53 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -24,7 +24,7 @@ pub async fn init(db_config: &DbConfig) -> anyhow::Result { } let db = SqlitePool::connect(&url).await?; - sqlx::migrate!("./migrations").run(&db); + sqlx::migrate!("./migrations").run(&db)?; if let Some(seed_data) = &db_config.seed_data { seed_db(&db, seed_data).await?; From 1f211e9d12dc8eab22e6cd85ab8ce25155b697c8 Mon Sep 17 00:00:00 2001 From: Sam Wlody Date: Thu, 17 Apr 2025 01:30:53 -0400 Subject: [PATCH 72/89] await --- src/db/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 421d9d53..bd1620ab 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -24,7 +24,7 @@ pub async fn init(db_config: &DbConfig) -> anyhow::Result { } let db = SqlitePool::connect(&url).await?; - sqlx::migrate!("./migrations").run(&db)?; + sqlx::migrate!("./migrations").run(&db).await?; if let Some(seed_data) = &db_config.seed_data { seed_db(&db, seed_data).await?; From e2239976057ba1a6057b69347d041e3aadfc994a Mon Sep 17 00:00:00 2001 From: amani <163478167+hiamani@users.noreply.github.com> Date: Sat, 26 Apr 2025 11:41:35 -0500 Subject: [PATCH 73/89] Refactor Styles (#30) * feat: Refactor posts/edit styles * refactor: posts/edit resize classes * feat: posts/list refactor * wip: posts/send refactor * feat: posts pages migration * feat: login/register refactor * feat: lists styles refactor * feat: events styles refactor * refactor: Remove styles blocks * fix: Normalize datetime-local for NaiveDatetime * style: Remove LSD header text colors * feat: Use DM Sans, style bugs, initial deep brown color scheme * feat: Add Poppins display font * feat: lists/signup, display p/br tags correctly in posts/view * feat: Colorscheme, style posts pages * feat: Use HTMX to handle post deletion * fix: Prettier format fonts.css * style: Correct posts/edit link underline color * feat: htmx -> hyperscript * Post email preview page, css tweaks --------- Co-authored-by: Jack Foltz --- frontend/styles/auth/login.css | 9 + frontend/styles/auth/register.css | 9 + frontend/styles/events/create.css | 3 + frontend/styles/events/list.css | 9 + frontend/styles/events/view.css | 3 + frontend/styles/extentions/form.css | 49 +++++ frontend/styles/fonts.css | 28 +++ frontend/styles/lists/edit.css | 9 + frontend/styles/lists/signup.css | 9 + frontend/styles/lists/view.css | 15 ++ frontend/styles/main.css | 98 ++++----- frontend/styles/posts/edit.css | 87 ++++++++ frontend/styles/posts/list.css | 37 ++++ frontend/styles/posts/send.css | 9 + frontend/styles/posts/view.css | 34 +++ frontend/templates/auth/login.html | 30 ++- frontend/templates/auth/register.html | 39 ++-- frontend/templates/events/create.html | 79 +++---- frontend/templates/events/list.html | 44 +--- frontend/templates/events/view.html | 92 ++++---- frontend/templates/layout.html | 193 +---------------- frontend/templates/lists/edit.html | 121 +++++------ frontend/templates/lists/signup.html | 31 +-- frontend/templates/lists/view.html | 28 ++- frontend/templates/posts/edit.html | 288 +++++++++----------------- frontend/templates/posts/email.html | 9 +- frontend/templates/posts/list.html | 85 ++------ frontend/templates/posts/send.html | 35 ++-- frontend/templates/posts/sent.html | 4 +- frontend/templates/posts/view.html | 18 +- src/app/posts.rs | 13 ++ src/views/posts.rs | 2 +- 32 files changed, 736 insertions(+), 783 deletions(-) create mode 100644 frontend/styles/auth/login.css create mode 100644 frontend/styles/auth/register.css create mode 100644 frontend/styles/events/create.css create mode 100644 frontend/styles/events/list.css create mode 100644 frontend/styles/events/view.css create mode 100644 frontend/styles/extentions/form.css create mode 100644 frontend/styles/fonts.css create mode 100644 frontend/styles/lists/edit.css create mode 100644 frontend/styles/lists/signup.css create mode 100644 frontend/styles/lists/view.css create mode 100644 frontend/styles/posts/edit.css create mode 100644 frontend/styles/posts/list.css create mode 100644 frontend/styles/posts/send.css create mode 100644 frontend/styles/posts/view.css diff --git a/frontend/styles/auth/login.css b/frontend/styles/auth/login.css new file mode 100644 index 00000000..6c7c2706 --- /dev/null +++ b/frontend/styles/auth/login.css @@ -0,0 +1,9 @@ +#auth\/login { + @apply flex flex-col items-center p-6; + > .container { + @apply mt-12 max-w-sm border border-neutral-900 bg-neutral-900/20 p-6; + h1 { + @apply mb-4 border-b border-neutral-800 pb-4 text-2xl; + } + } +} diff --git a/frontend/styles/auth/register.css b/frontend/styles/auth/register.css new file mode 100644 index 00000000..590faab4 --- /dev/null +++ b/frontend/styles/auth/register.css @@ -0,0 +1,9 @@ +#auth\/register { + @apply flex flex-col items-center p-6; + > .container { + @apply mt-12 max-w-sm border border-neutral-900 bg-neutral-900/20 p-6; + h1 { + @apply mb-4 border-b border-neutral-800 pb-4 text-2xl; + } + } +} diff --git a/frontend/styles/events/create.css b/frontend/styles/events/create.css new file mode 100644 index 00000000..d1fb8cca --- /dev/null +++ b/frontend/styles/events/create.css @@ -0,0 +1,3 @@ +#events\/create { + @apply mx-auto flex w-full max-w-2xl flex-col; +} diff --git a/frontend/styles/events/list.css b/frontend/styles/events/list.css new file mode 100644 index 00000000..d30b51f0 --- /dev/null +++ b/frontend/styles/events/list.css @@ -0,0 +1,9 @@ +#events\/list { + @apply mx-auto mt-8 flex max-w-4xl flex-col; + .title { + @apply mb-4 text-2xl; + } + .event a { + @apply hover:underline; + } +} diff --git a/frontend/styles/events/view.css b/frontend/styles/events/view.css new file mode 100644 index 00000000..846299a3 --- /dev/null +++ b/frontend/styles/events/view.css @@ -0,0 +1,3 @@ +#events\/view { + @apply mx-auto flex w-full max-w-2xl flex-col; +} diff --git a/frontend/styles/extentions/form.css b/frontend/styles/extentions/form.css new file mode 100644 index 00000000..47917552 --- /dev/null +++ b/frontend/styles/extentions/form.css @@ -0,0 +1,49 @@ +.ext\/form { + @apply flex flex-col; +} + +.ext\/form input, +.ext\/input { + @apply border-lsd-white/30 bg-lsd-black w-full rounded-md border px-3 py-2; + @apply focus:border-lsd-blue focus:outline-none; +} + +.ext\/form label, +.ext\/label { + @apply mb-2 block; +} + +.ext\/form textarea, +.ext\/textarea { + @apply bg-lsd-black h-[50vh] w-full flex-1 resize-y p-2; + @apply border-lsd-white/30 border; +} + +.ext\/form select, +.ext\/select { + @apply border-lsd-white/40 bg-lsd-black border px-2 py-1; +} + +.ext\/form .field, +.ext\/field { + @apply mb-4 flex flex-col pb-4; +} + +.ext\/form button, +.ext\/button { + @apply bg-lsd-white/10 hover:bg-lsd-white/15 block cursor-pointer px-4 py-2; + @apply border-lsd-white/10 border; + @apply rounded-sm; + + &.\:icon { + @apply px-3; + } + + &.\:green { + @apply border-lsd-green/30 bg-lsd-green/20 hover:bg-lsd-green/30; + } + + &.\:red { + @apply border-lsd-red/30 bg-lsd-red/20 hover:bg-lsd-red/30; + } +} diff --git a/frontend/styles/fonts.css b/frontend/styles/fonts.css new file mode 100644 index 00000000..c32bdc32 --- /dev/null +++ b/frontend/styles/fonts.css @@ -0,0 +1,28 @@ +@font-face { + font-family: "DM Sans Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 1000; + src: url(https://cdn.jsdelivr.net/fontsource/fonts/dm-sans:vf@latest/latin-wght-normal.woff2) + format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Poppins"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: + url(https://cdn.jsdelivr.net/fontsource/fonts/poppins@latest/latin-700-normal.woff2) + format("woff2"), + url(https://cdn.jsdelivr.net/fontsource/fonts/poppins@latest/latin-700-normal.woff) + format("woff"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} diff --git a/frontend/styles/lists/edit.css b/frontend/styles/lists/edit.css new file mode 100644 index 00000000..50c61168 --- /dev/null +++ b/frontend/styles/lists/edit.css @@ -0,0 +1,9 @@ +#lists\/edit { + @apply flex flex-col items-center; + form { + @apply mt-8 mb-12 w-full max-w-xl; + textarea { + @apply min-h-52; + } + } +} diff --git a/frontend/styles/lists/signup.css b/frontend/styles/lists/signup.css new file mode 100644 index 00000000..01bc0e8d --- /dev/null +++ b/frontend/styles/lists/signup.css @@ -0,0 +1,9 @@ +#lists\/signup { + @apply flex w-full flex-col items-center; + .container { + @apply mt-12 w-full max-w-sm; + } + h2 { + @apply mb-2 text-xl font-bold; + } +} diff --git a/frontend/styles/lists/view.css b/frontend/styles/lists/view.css new file mode 100644 index 00000000..d67b3724 --- /dev/null +++ b/frontend/styles/lists/view.css @@ -0,0 +1,15 @@ +#lists\/view { + @apply mx-auto mt-8 flex max-w-4xl flex-col px-6; +} + +#lists\/view header { + @apply mb-8 flex items-end justify-between px-0 pb-4; + @apply border-b border-neutral-800; + .title { + @apply text-lg uppercase; + } +} + +#lists\/view .lists { + @apply text-lg underline hover:decoration-blue-500; +} diff --git a/frontend/styles/main.css b/frontend/styles/main.css index 40db8f00..31bad1a2 100644 --- a/frontend/styles/main.css +++ b/frontend/styles/main.css @@ -1,79 +1,71 @@ @import "tailwindcss"; +@import "./fonts.css"; + +@theme { + --font-sans: DM Sans Variable, sans-serif; + --font-display: Poppins, sans-serif; + /* CSS HEX */ + --color-lsd-black: #080504; + --color-lsd-white: #ebe3de; + --color-lsd-bright: #fcf3ee; + --color-lsd-blue: #8ec6ff; + --color-lsd-green: #6fd08c; + --color-lsd-red: #c36346; + --color-lsd-yellow: #eec643; +} + /* * TODO: Refactor styles into Tailwind utility classes * Below styles are from page.tera.html */ -html, -body { - @apply h-full w-full bg-black text-white; +:root { + @apply bg-lsd-black text-lsd-white h-full w-full font-sans; } -header { - @apply border-b border-neutral-700 p-4; +body { + @apply min-h-full; } -article { - @apply mx-auto max-w-3xl p-4; +body > header { + @apply border-lsd-white/10 flex flex-col items-center border-b px-6 py-4; - time { - @apply mb-8 block text-sm uppercase; + nav { + @apply flex w-full max-w-6xl items-center gap-x-8; } - - p { - @apply mb-5 last:mb-0; + nav .lsd { + @apply font-display text-lsd-bright font-bold; } - - blockquote { - @apply my-4 border-l-[4px] px-8 py-6 italic; - } - - img { - @apply my-8 block h-auto max-w-full; + nav .sep { + @apply h-4 border-r border-neutral-700; } } -h2 { - @apply mb-1 text-xl font-bold; -} +/** Extensions **/ -a { - @apply underline; -} +@import "./extentions/form.css"; -button, -.button { - @apply block cursor-pointer bg-neutral-900 px-4 py-2 hover:bg-neutral-800; - @apply rounded-sm; -} +/** Modules **/ -ul { - @apply list-inside; -} +/* Auth */ -label { - @apply mb-2 block; -} +@import "./auth/login.css"; +@import "./auth/register.css"; -input[type="text"], -input[type="email"] { - @apply w-full border border-neutral-700 bg-neutral-950 p-2; -} +@import "./events/create.css"; +@import "./events/list.css"; +@import "./events/view.css"; -textarea { - @apply h-[50vh] w-full flex-1 resize-y bg-neutral-900 p-2; - @apply border border-neutral-700 font-mono text-sm; -} +/* Lists */ -select { - @apply border border-neutral-600 bg-neutral-950; -} +@import "./lists/edit.css"; +@import "./lists/signup.css"; +@import "./lists/view.css"; -form { - @apply flex flex-col p-8; +/* Posts */ - .field { - @apply mb-4 flex flex-col pb-4; - } -} +@import "./posts/edit.css"; +@import "./posts/list.css"; +@import "./posts/send.css"; +@import "./posts/view.css"; diff --git a/frontend/styles/posts/edit.css b/frontend/styles/posts/edit.css new file mode 100644 index 00000000..5b8209aa --- /dev/null +++ b/frontend/styles/posts/edit.css @@ -0,0 +1,87 @@ +body:has(#posts\/edit) > header { + @apply hidden; +} + +#posts\/edit { + @apply w-full; +} + +#posts\/edit .editor { + @apply relative grid h-screen w-full; + grid-template-columns: 1fr 1fr 1fr auto; + + .navbar { + @apply sticky top-0 col-span-4 flex items-center py-2; + @apply border-lsd-white/10 gap-x-4 border-b px-8; + } + + .navbar .save { + @apply ml-auto flex items-center gap-x-4; + } + + .details { + @apply sticky col-span-1 h-full w-full min-w-sm px-4 py-4; + @apply border-lsd-white/10 border-l; + } + + .content { + @apply col-span-3 w-full overflow-y-auto px-8; + } + + .content .pell-wrapper { + @apply min-h-screen w-full; + @apply flex flex-col items-center; + } +} + +#posts\/edit .editor .content .pell { + @apply relative w-full max-w-3xl overflow-visible; + padding-bottom: 50%; + + .pell-actionbar { + @apply bg-lsd-black sticky top-0 mt-4 py-2.5; + @apply flex items-center gap-2.5; + + .pell-button { + @apply bg-lsd-white/10 hover:bg-lsd-white/20 h-8 w-10 p-1; + } + + .pell-button-selected { + @apply bg-lsd-white/20; + } + + #status { + @apply ml-auto text-sm; + @apply before:mr-1.5 before:content-["•"]; + } + + #status.unsaved { + @apply text-amber-400; + } + + #status.error { + @apply text-red-500; + } + } + + .pell-content { + @apply text-lg leading-relaxed; + @apply bg-lsd-white/5 grow overflow-y-auto p-8 focus:outline-none; + @apply mt-4; + a { + @apply decoration-lsd-blue underline; + } + } +} + +#posts\/edit .editor .content .resize { + @apply absolute top-0 bottom-0 z-50 w-4 cursor-ew-resize bg-transparent; + + &.left { + @apply left-0; + } + + &.right { + @apply right-0; + } +} diff --git a/frontend/styles/posts/list.css b/frontend/styles/posts/list.css new file mode 100644 index 00000000..74c57a78 --- /dev/null +++ b/frontend/styles/posts/list.css @@ -0,0 +1,37 @@ +#posts\/list { + @apply mx-auto mt-8 flex max-w-4xl flex-col px-6; +} + +#posts\/list header { + @apply mb-8 flex items-end justify-between px-0 pb-4; + @apply border-lsd-white/20 border-b; + .title { + @apply text-2xl font-bold; + } +} + +#posts\/list .post { + @apply mb-6 flex items-start justify-between pb-6; + + .title { + @apply mb-1 text-2xl font-bold; + a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + } + + .info { + @apply mb-4; + } + + .actions { + @apply flex; + a, + form { + @apply mr-2; + } + form { + @apply p-0; + } + } +} diff --git a/frontend/styles/posts/send.css b/frontend/styles/posts/send.css new file mode 100644 index 00000000..d17afa16 --- /dev/null +++ b/frontend/styles/posts/send.css @@ -0,0 +1,9 @@ +#posts\/send { + @apply flex flex-col items-center; + .title { + @apply mb-4 text-lg; + } + form { + @apply w-full max-w-2xl; + } +} diff --git a/frontend/styles/posts/view.css b/frontend/styles/posts/view.css new file mode 100644 index 00000000..ae081b02 --- /dev/null +++ b/frontend/styles/posts/view.css @@ -0,0 +1,34 @@ +#posts\/view { + @apply flex flex-col items-center px-6; + + > article { + @apply mt-6 lg:mt-12; + } + + .details { + @apply border-lsd-white/20 mb-6 border-b pb-6; + + .title { + @apply mb-1 text-4xl; + font-weight: 1000; + } + + .date { + @apply text-lsd-red; + } + } + + article { + @apply w-full max-w-2xl; + } + + .content { + @apply mb-16 text-lg leading-relaxed; + p a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + p img { + @apply mx-auto max-w-xl; + } + } +} diff --git a/frontend/templates/auth/login.html b/frontend/templates/auth/login.html index 1e4c0acf..1722048a 100644 --- a/frontend/templates/auth/login.html +++ b/frontend/templates/auth/login.html @@ -1,24 +1,20 @@ {% extends "layout.html" %} {% block title %}light and sound - login{% endblock title %} -{% block styles %} - {% call super() %} - -{% endblock styles %} {% block content %} -
    -
    - - +
    +
    +

    Login

    + +
    + + +
    +
    + +
    +
    -
    - -
    - +
    {% endblock content %} diff --git a/frontend/templates/auth/register.html b/frontend/templates/auth/register.html index 82ee6116..12269900 100644 --- a/frontend/templates/auth/register.html +++ b/frontend/templates/auth/register.html @@ -1,30 +1,25 @@ {% extends "layout.html" %} {% block title %}light and sound - register{% endblock title %} -{% block styles %} - {% call super() %} - - -{% endblock styles %} {% block content %} -

    Register

    -
    - - +
    +
    +

    Register

    + +
    + + +
    - - +
    + + +
    - - - + + + +
    +
    {% endblock content %} diff --git a/frontend/templates/events/create.html b/frontend/templates/events/create.html index afba8cbe..f195d67b 100644 --- a/frontend/templates/events/create.html +++ b/frontend/templates/events/create.html @@ -2,44 +2,53 @@ {% block title %}light and sound - new event{% endblock title %} -{% block styles %} - {% call super() %} - - -{% endblock styles %} - {% block content %} -
    +

    Let's Create an Event

    -
    - - - - - - - - - - - - - - + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + + +
    + + +
    -
    +
    + {% endblock content %} diff --git a/frontend/templates/events/list.html b/frontend/templates/events/list.html index 8855a415..69b81db3 100644 --- a/frontend/templates/events/list.html +++ b/frontend/templates/events/list.html @@ -2,38 +2,16 @@ {% block title %}light and sound - events{% endblock title %} -{% block styles %} - {% call super() %} - - -{% endblock styles %} - {% block content %} -

    Upcoming Events:

    - {% for event in events %} - - {% endfor %} +
    +

    Upcoming Events

    + {% for event in events %} + + {% endfor %} +
    {% endblock content %} diff --git a/frontend/templates/events/view.html b/frontend/templates/events/view.html index d9c00c7d..a4305492 100644 --- a/frontend/templates/events/view.html +++ b/frontend/templates/events/view.html @@ -2,52 +2,60 @@ {% block title %}{{ event.title }}{% endblock title %} -{% block styles %} - {% call super() %} - - -{% endblock styles %} - {% block content %} -

    Update Event: {{ event.title }}

    -
    - - - - - - - - +

    Update Event: {{ event.title }}

    + +
    + + +
    + +
    + + +
    + +
    + + +
    - - +
    + + +
    - - -
    -
    - -
    + + +
    + +
    +
    + {% endblock content %} diff --git a/frontend/templates/layout.html b/frontend/templates/layout.html index 36056b1e..407d19ba 100644 --- a/frontend/templates/layout.html +++ b/frontend/templates/layout.html @@ -5,202 +5,27 @@ {% block title %}light and sound design{% endblock title %} - {% block scripts %} - {% endblock scripts %} {% block styles %} - {% endblock styles %} {% block header %} -
    +
    {% endblock header %}
    {% block content %}{% endblock content %}
    + + + {% block scripts %} + {% endblock scripts %} diff --git a/frontend/templates/lists/edit.html b/frontend/templates/lists/edit.html index c79e9059..1e0c94c9 100644 --- a/frontend/templates/lists/edit.html +++ b/frontend/templates/lists/edit.html @@ -1,78 +1,67 @@ {% extends "layout.html" %} {% block title %}Edit list - {{ list.name }}{% endblock %} {% block styles %} - {% call super() %} - - {% endblock %} {% block content %} -
    - {% if list.id != 0 %} - - {% endif %} -
    - - -
    -
    - - -
    - {% if list.id != 0 %} +
    + + {% if list.id != 0 %} + + {% endif %}
    - -
      - {% for member in members %} -
    • - - {% if let Some(first_name) = member.first_name %} - {% if let Some(last_name) = member.last_name %} - {{ member.email }} - ({{ first_name }} {{ last_name }}) + + +
    +
    + + +
    + {% if list.id != 0 %} +
    + +
      + {% for member in members %} +
    • + {% if let Some(first_name) = member.first_name %} + {% if let Some(last_name) = member.last_name %} + {{ member.email }} + ({{ first_name }} {{ last_name }}) + {% else %} + {{ member.email }} + {% endif %} {% else %} {{ member.email }} {% endif %} - {% else %} - {{ member.email }} - {% endif %} -
    • - {% endfor %} -
    + + + {% endfor %} + +
    + {% endif %} +
    + +
    - {% endif %} -
    - - -
    - - + + +
    {% endblock %} diff --git a/frontend/templates/lists/signup.html b/frontend/templates/lists/signup.html index d94363a0..19e88c2c 100644 --- a/frontend/templates/lists/signup.html +++ b/frontend/templates/lists/signup.html @@ -1,28 +1,19 @@ {% extends "layout.html" %} {% block title %}light and sound - Sign Up{% endblock title %} -{% block styles %} - {% call super() %} - -{% endblock styles %} {% block content %} -
    -

    Sign up for {{ list.description }}

    -
    -
    - - -
    - -
    +
    +
    +

    Sign up for {{ list.description }}

    + +
    + + +
    + -
    - + +
    {% endblock content %} diff --git a/frontend/templates/lists/view.html b/frontend/templates/lists/view.html index 9af1abb1..a12dd392 100644 --- a/frontend/templates/lists/view.html +++ b/frontend/templates/lists/view.html @@ -1,21 +1,17 @@ {% extends "layout.html" %} {% block title %}light and sound - Lists{% endblock title %} -{% block styles %} - {% call super() %} - - -{% endblock %} {% block content %} - +
    +
    +

    Lists

    +
    + +
    {% endblock %} diff --git a/frontend/templates/posts/edit.html b/frontend/templates/posts/edit.html index 97a2cd39..15f1eb1d 100644 --- a/frontend/templates/posts/edit.html +++ b/frontend/templates/posts/edit.html @@ -1,168 +1,73 @@ {% extends "layout.html" %} {% block title %}Edit post - {{ post.title }}{% endblock title %} -{% block styles %} - {% call super() %} - -{% endblock styles %} {% block scripts %} -{% endblock scripts %} -{% block content %} -
    - {% if post.id != 0 %} - - {% endif %} -
    - - -
    -
    - -
    - - -
    -
    -
    - - -
    -
    - -
    -
    -
    -
    -
    -
    -{% endblock content %} +{% endblock scripts %} diff --git a/frontend/templates/posts/email.html b/frontend/templates/posts/email.html index 989003bf..4d7b0e5b 100644 --- a/frontend/templates/posts/email.html +++ b/frontend/templates/posts/email.html @@ -76,10 +76,7 @@ } p { - margin-bottom: 1.2rem; - } - p:last-child { - margin-bottom: 0; + margin: 0; } blockquote { @@ -94,7 +91,7 @@ max-width: 100%; display: block; height: auto; - margin: 2rem auto; + margin: 0 auto; } @media (min-width: 550px) { img { @@ -123,7 +120,7 @@
    -
    +

    {{ post.title }}