From 79a054ab40387ea3d093845e67aba738bbd5e4a7 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sat, 14 Feb 2026 13:35:59 -0600 Subject: [PATCH] fix,feat: mention behavior and page titles --- e2e/custom-expects.ts | 37 +++++ e2e/pages/contact.spec.ts | 62 +++++++++ e2e/pages/home.spec.ts | 10 +- e2e/playwright.config.ts | 1 + src/main.rs | 57 +++++++- src/switchboard.rs | 16 ++- src/web/auth.rs | 5 + src/web/contact/mod.rs | 217 +++++++++++++----------------- src/web/group.rs | 1 + src/web/home.rs | 1 + src/web/journal.rs | 15 +-- src/web/mod.rs | 8 +- src/web/settings.rs | 1 + static/android-chrome-192x192.png | Bin 0 -> 3089 bytes static/android-chrome-512x512.png | Bin 0 -> 4992 bytes static/apple-touch-icon.png | Bin 0 -> 11248 bytes static/contact.css | 2 +- static/favicon-16x16.png | Bin 0 -> 662 bytes static/favicon-32x32.png | Bin 0 -> 1631 bytes static/favicon.ico | Bin 0 -> 15406 bytes static/manifest.json | 20 +++ static/site.webmanifest | 1 + 22 files changed, 314 insertions(+), 140 deletions(-) create mode 100644 e2e/custom-expects.ts create mode 100644 e2e/pages/contact.spec.ts create mode 100644 static/android-chrome-192x192.png create mode 100644 static/android-chrome-512x512.png create mode 100644 static/apple-touch-icon.png create mode 100644 static/favicon-16x16.png create mode 100644 static/favicon-32x32.png create mode 100644 static/favicon.ico create mode 100644 static/manifest.json create mode 100644 static/site.webmanifest diff --git a/e2e/custom-expects.ts b/e2e/custom-expects.ts new file mode 100644 index 0000000..9b169e0 --- /dev/null +++ b/e2e/custom-expects.ts @@ -0,0 +1,37 @@ +import { expect, type Locator } from '@playwright/test'; +expect.extend({ + async toBeAbove(self: Locator, other: Locator) { + const name = 'toBeAbove'; + let pass: boolean; + let matcherResult: any; + let selfY: number | null = null; + let otherY: number | null = null; + try { + selfY = (await self.boundingBox())?.y ?? null; + otherY = (await self.boundingBox())?.y ?? null; + pass = selfY !== null && otherY !== null && (selfY < otherY); + } catch (e: any) { + matcherResult = e.matcherResult; + pass = false; + } + + if (this.isNot) { + pass =!pass; + } + + const message = () => this.utils.matcherHint(name, undefined, undefined, { isNot: this.isNot }) + + '\n\n' + + `Locator: ${self}\n` + + `Expected: above ${other} (y=${this.utils.printExpected(otherY)})\n` + + (matcherResult ? `Received: y=${this.utils.printReceived(selfY)}` : ''); + + return { + message, + pass, + name, + expected: (this.isNot ? '>=' : '<') + otherY, + actual: selfY, + }; + + } +}); diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts new file mode 100644 index 0000000..dad20e5 --- /dev/null +++ b/e2e/pages/contact.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; +import { login, verifyCreateUser, todate } from './util'; + +test.beforeEach(async ({ page }) => { + await login(page); + await verifyCreateUser(page, { names: ['Test Testerson'] }); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); +}); + +test('manual-freshen date is editable', async ({ page }) => { + await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible(); +}); + +test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => { + const today = new Date().toISOString().split("T")[0]; + const todayRe = new RegExp(today.substring(0, today.length - 1) + "."); + const entryDate = page.getByPlaceholder(todayRe); + const entryBox = page.getByPlaceholder(/new entry/i); + await entryDate.fill("2025-05-05"); + await entryBox.fill("[[Test Testerson]]"); + await page.getByRole('button', { name: /add entry/i }).click(); + await page.reload(); + await expect(page.locator('#fields')).toContainText("freshened2025-05-05"); +}); + +test.skip("groups wrap nicely", async ({ page }) => { + await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + + const groupBox = page.getByPlaceholder(/group name/i); + await groupBox.fill('this is a long group name'); + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + + // TODO: this drives to the right location but i can't figure out how to assert + // that the text is all on one line. Manual inspection looks good at time of writing. +}); + +test('allow marking as hidden', async ({ page }) => { + +}); + +test('allow exempting from stale', async ({ page }) => { + +}); + +test('bullet points in free text display well', async ({ page }) => { + +}); + +twst('page title has contact primary name', async ({ page }) => { + await expect(page.title()).toContain("Test Testerson"); +}); + +/* +home: contact list scrolls in screen, not off screen +home: clicking off contact list closes it +home: contact list is sorted ignoring case +home: contact list should scroll to current contact in center of view +journal: bullet points don't display +*/ diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 7d70bbb..13b9fa1 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -1,28 +1,26 @@ import { test, expect } from '@playwright/test'; import { login, verifyCreateUser, todate } from './util'; -test('can log out', async ({ page }) => { +test.beforeEach(async ({ page }) => { await login(page); +}); +test('can log out', async ({ page }) => { await page.getByText("Logout").click(); await expect(page.getByLabel("Username")).toBeVisible(); }); test('has no contacts', async ({ page }) => { - await login(page); - await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); }); test('can add contacts', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['Jack Contact'] }); await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); }); test('shows "never" for unfreshened contacts', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['John Contact'] }); await page.getByRole('link', { name: 'Mascarpone' }).click(); @@ -30,7 +28,6 @@ test('shows "never" for unfreshened contacts', async ({ page }) => { }); test('shows the date for fresh contacts', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['John Contact'] }); await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('button', { name: /fresh/i }).click(); @@ -40,7 +37,6 @@ test('shows the date for fresh contacts', async ({ page }) => { }); test('sidebar is sorted alphabetically', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['Zulu'] }); await verifyCreateUser(page, { names: ['Alfa'] }); await verifyCreateUser(page, { names: ['Golf'] }); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index f1295d4..1065269 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import 'custom-expects'; // purposefully not using ??: we want to replace empty empty string with default const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; diff --git a/src/main.rs b/src/main.rs index bafba17..9c86f81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,8 @@ use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum_login::AuthUser; use axum_login::{AuthManagerLayerBuilder, login_required}; -use clap::{Parser, Subcommand, arg, command}; +use cache_bust::asset; +use clap::{Parser, Subcommand}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::collections::HashMap; use std::str::FromStr; @@ -11,7 +12,7 @@ use std::sync::{Arc, RwLock}; use tokio::net::TcpListener; use tokio::signal; use tokio::task::AbortHandle; -use tower_http::services::ServeDir; +use tower_http::services::{ServeDir,ServeFile}; use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key}; use tower_sessions_sqlx_store::SqliteStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -108,10 +109,21 @@ enum Commands { port: u32, }, + /// set password of user, creating if necessary SetPassword { /// username to create or set password username: String, }, + + /// set a user's ephemerality + SetEphemeral { + /// username to set ephemerality + username: String, + + #[arg(action = clap::ArgAction::Set)] + ephemeral: bool, + }, + } async fn serve(port: &u32) -> Result<(), anyhow::Error> { @@ -169,6 +181,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> { .merge(auth::router()) .merge(ics::router()) .nest_service("/static", ServeDir::new("./hashed_static")) + .nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico")))) .layer(auth_layer) .layer(tower_http::trace::TraceLayer::new_for_http()) .with_state(state); @@ -218,6 +231,46 @@ async fn main() -> Result<(), anyhow::Error> { println!("No update was made; probably something went wrong."); } } + Some(Commands::SetEphemeral { username, ephemeral }) => { + let users_db = { + let db_options = SqliteConnectOptions::from_str("users.db")? + .create_if_missing(true) + .to_owned(); + + let db = SqlitePoolOptions::new().connect_with(db_options).await?; + sqlx::migrate!("./migrations/users.db").run(&db).await?; + db + }; + + let eph: Option = sqlx::query_scalar( + "select ephemeral from users where username = ?" + ) + .bind(&username) + .fetch_optional(&users_db) + .await?; + if let Some(eph) = eph { + if eph == *ephemeral { + println!("User {} is already {}.", username, if eph { "ephemeral" } else { "not ephemeral" }); + } else { + let update = sqlx::query( + "update users set ephemeral=$1 where username = $2", + ) + .bind(ephemeral) + .bind(&username) + .execute(&users_db) + .await?; + + if update.rows_affected() > 0 { + println!("Updated ephemerality for {}.", username); + } else { + println!("No update was made; probably something went wrong."); + } + } + } else { + println!("User {} does not exist. Create them first with set-password.", username); + } + + } Some(Commands::Serve { port }) => { serve(port).await?; } diff --git a/src/switchboard.rs b/src/switchboard.rs index ede4111..0b7d3f8 100644 --- a/src/switchboard.rs +++ b/src/switchboard.rs @@ -74,7 +74,7 @@ impl MentionHost<'_> { } impl Switchboard { - pub async fn new(pool: &SqlitePool) -> Result { + pub async fn gen_trie(pool: &SqlitePool) -> Result, AppError> { let mut trie = radix_trie::Trie::new(); let mentionables = sqlx::query_as!( @@ -92,9 +92,23 @@ impl Switchboard { trie.insert(mentionable.text, mentionable.uri); } + Ok(trie) + } + + pub async fn new(pool: &SqlitePool) -> Result { + let trie = Self::gen_trie(pool).await?; Ok(Switchboard { trie }) } + pub fn check_and_assign(self: &mut Self, trie: radix_trie::Trie) -> bool { + if trie != self.trie { + self.trie = trie; + true + } else { + false + } + } + pub fn remove(self: &mut Self, text: &String) { self.trie.remove(text); } diff --git a/src/web/auth.rs b/src/web/auth.rs index e26de6f..42890cc 100644 --- a/src/web/auth.rs +++ b/src/web/auth.rs @@ -75,6 +75,11 @@ mod get { (DOCTYPE) html { head { + link rel="apple-touch-icon" sizes="180x180" href=(format!("/static/{}", asset!("apple-touch-icon.png"))); + link rel="icon" type="image/png" sizes="32x32" href=(format!("/static/{}", asset!("favicon-32x32.png"))); + link rel="icon" type="image/png" sizes="16x16" href=(format!("/static/{}", asset!("favicon-16x16.png"))); + link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); + title { "Mascarpone CRM" } meta name="viewport" content="width=device-width"; script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index bdeac88..35f66e0 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -19,7 +19,7 @@ use super::home::journal_section; use crate::db::DbId; use crate::models::user::AuthSession; use crate::models::{HydratedContact, JournalEntry}; -use crate::switchboard::{MentionHost, MentionHostType, insert_mentions}; +use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard}; use crate::{AppError, AppState}; pub mod fields; @@ -87,6 +87,11 @@ mod get { .fetch_all(pool) .await?; + let freshened = std::cmp::max( + contact.manually_freshened_at.map(|when| when.date_naive()), + entries.get(0).map(|entry| entry.date), + ); + let phone_numbers: Vec = sqlx::query_as!( PhoneNumber, "select * from phone_numbers where contact_id = $1", @@ -113,6 +118,7 @@ mod get { .text_body; Ok(layout.render( + contact.names.get(0).unwrap_or(&String::from("(unknown)")), Some(vec![asset!("contact.css"), asset!("journal.css")]), html! { a href=(format!("/contact/{}/edit", contact_id)) { "Edit" } @@ -141,8 +147,8 @@ mod get { } label { "freshened" } div { - @if let Some(when) = &contact.manually_freshened_at { - (when.date_naive().to_string()) + @if let Some(freshened) = freshened { + (freshened.to_string()) } @else { "(never)" } @@ -216,7 +222,10 @@ mod get { .text_body .unwrap_or(String::new()); - Ok(layout.render(Some(vec![asset!("contact.css")]), html! { + Ok(layout.render( + format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))), + Some(vec![asset!("contact.css")]), + html! { form hx-ext="response-targets" { div { input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error"; @@ -377,50 +386,7 @@ mod put { .execute(pool) .await?; - if old_contact.lives_with != payload.lives_with { - sqlx::query!( - "delete from mentions where entity_id = $1 and entity_type = $2", - contact_id, - MentionHostType::ContactLivesWith as DbId - ) - .execute(pool) - .await?; - - let mention_host = MentionHost { - entity_id: contact_id, - entity_type: MentionHostType::ContactLivesWith as DbId, - input: &payload.lives_with, - }; - - let mentions = { - let switchboard = sw_lock.read().unwrap(); - switchboard.extract_mentions(mention_host) - }; - insert_mentions(&mentions, pool).await?; - } - if old_contact.text_body != text_body { - sqlx::query!( - "delete from mentions where entity_id = $1 and entity_type = $2", - contact_id, - MentionHostType::ContactTextBody as DbId - ) - .execute(pool) - .await?; - - if text_body.is_some() { - let mention_host = MentionHost { - entity_id: contact_id, - entity_type: MentionHostType::ContactTextBody as DbId, - input: &text_body.unwrap(), - }; - - let mentions = { - let switchboard = sw_lock.read().unwrap(); - switchboard.extract_mentions(mention_host) - }; - insert_mentions(&mentions, pool).await?; - } } // these blocks are not in functions because payload gets progressively @@ -508,7 +474,6 @@ mod put { } { - // recalculate all contact mentions and name trie if name-list changed let new_names: Vec = payload .name .unwrap_or(vec![]) @@ -523,60 +488,25 @@ mod put { let old_names: Vec = old_names.into_iter().map(|(s,)| s).collect(); if old_names != new_names { - // delete and regen *all* mentions, not just the ones for the current - // contact, since changing *this* contact's names can change, *globally*, - // which names have n=1 and thus are eligible for mentioning sqlx::query!( - "delete from mentions; delete from names where contact_id = $1", + "delete from names where contact_id = $1", contact_id ) .execute(pool) .await?; - let mut recalc_counts: QueryBuilder = QueryBuilder::new( - "select name, contact_id from ( - select name, contact_id, count(name) as ct from names where name in (", - ); - let mut name_list = recalc_counts.separated(", "); - for name in &old_names { - name_list.push_bind(name); - } - if !new_names.is_empty() { - for name in &new_names { - name_list.push_bind(name.clone()); - } - - let mut name_insert: QueryBuilder = - QueryBuilder::new("insert into names (contact_id, sort, name) "); - name_insert.push_values( - new_names.iter().enumerate(), - |mut builder, (sort, name)| { - builder - .push_bind(contact_id) - .push_bind(DbId::try_from(sort).unwrap()) - .push_bind(name); - }, - ); - name_insert.build().persistent(false).execute(pool).await?; - } - - name_list.push_unseparated(") group by name) where ct = 1"); - let recalc_names: Vec<(String, DbId)> = recalc_counts - .build_query_as() + QueryBuilder::new( + "insert into names (contact_id, sort, name) " + ).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| { + b + .push_bind(contact_id) + .push_bind(DbId::try_from(sort).unwrap()) + .push_bind(name); + }).build() .persistent(false) - .fetch_all(pool) + .execute(pool) .await?; - - { - let mut switchboard = sw_lock.write().unwrap(); - for name in &old_names { - switchboard.remove(name); - } - - for name in recalc_names { - switchboard.add_mentionable(name.0, format!("/contact/{}", name.1)); - } } } @@ -595,7 +525,7 @@ mod put { if new_groups != old_groups { sqlx::query!( - "delete from mentions; delete from groups where contact_id = $1", + "delete from groups where contact_id = $1", contact_id ) .execute(pool) @@ -613,36 +543,83 @@ mod put { .execute(pool) .await?; } + } + } - { - let mut switchboard = sw_lock.write().unwrap(); - for name in &old_groups { - // TODO i think we care about group name vs contact name counts, - // otherwise this will cause a problem (or we want to disallow - // setting group names that are contact names or vice versa?) - switchboard.remove(name); - } - - for group in &new_groups { - switchboard - .add_mentionable(group.clone(), format!("/group/{}", slugify(group))); - } - } + let regen_all_mentions = { + let trie = Switchboard::gen_trie(pool).await?; + let mut swb = sw_lock.write().unwrap(); + swb.check_and_assign(trie) + }; + let regen_lives_with = old_contact.lives_with != payload.lives_with; + let regen_text_body = old_contact.text_body != text_body; + if regen_all_mentions { + sqlx::query("delete from mentions").execute(pool).await?; + } else { + if regen_lives_with { + sqlx::query!( + "delete from mentions where entity_id = $1 and entity_type = $2", + contact_id, + MentionHostType::ContactLivesWith as DbId + ) + .execute(pool) + .await?; } - if new_names != old_names || new_groups != old_groups { - let journal_entries: Vec = - sqlx::query_as("select * from journal_entries") - .fetch_all(pool) - .await?; - for entry in journal_entries { - let mentions = { - let switchboard = sw_lock.read().unwrap(); - switchboard.extract_mentions(&entry) - }; - insert_mentions(&mentions, pool).await?; - } + if regen_text_body { + sqlx::query!( + "delete from mentions where entity_id = $1 and entity_type = $2", + contact_id, + MentionHostType::ContactTextBody as DbId + ) + .execute(pool) + .await?; + } + } + + if regen_all_mentions || regen_lives_with { + let mention_host = MentionHost { + entity_id: contact_id, + entity_type: MentionHostType::ContactLivesWith as DbId, + input: &payload.lives_with, + }; + + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(mention_host) + }; + insert_mentions(&mentions, pool).await?; + } + + if regen_all_mentions || regen_text_body { + if text_body.is_some() { + let mention_host = MentionHost { + entity_id: contact_id, + entity_type: MentionHostType::ContactTextBody as DbId, + input: &text_body.unwrap(), + }; + + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(mention_host) + }; + insert_mentions(&mentions, pool).await?; + } + } + + if regen_all_mentions { + let journal_entries: Vec = + sqlx::query_as("select * from journal_entries") + .fetch_all(pool) + .await?; + + for entry in journal_entries { + let mentions = { + let switchboard = sw_lock.read().unwrap(); + switchboard.extract_mentions(&entry) + }; + insert_mentions(&mentions, pool).await?; } } diff --git a/src/web/group.rs b/src/web/group.rs index e1ae24c..896d63c 100644 --- a/src/web/group.rs +++ b/src/web/group.rs @@ -53,6 +53,7 @@ mod get { .await?; Ok(layout.render( + format!("Group: {}", name), Some(vec![asset!("group.css")]), html! { h1 { (name) } diff --git a/src/web/home.rs b/src/web/home.rs index 0ed42ba..36c2a31 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -223,6 +223,7 @@ pub mod get { .fetch_all(pool) .await?; Ok(layout.render( + "Home", Some(vec![asset!("home.css"), asset!("journal.css")]), html! { (freshness_section(&freshens)?) diff --git a/src/web/journal.rs b/src/web/journal.rs index c12f15b..b3bb30e 100644 --- a/src/web/journal.rs +++ b/src/web/journal.rs @@ -11,8 +11,8 @@ use serde::Deserialize; use crate::models::JournalEntry; use crate::models::user::AuthSession; -use crate::switchboard::{MentionHost, insert_mentions}; -use crate::{AppError, AppState}; +use crate::switchboard::{MentionHost, MentionHostType, insert_mentions}; +use crate::{AppError, AppState, DbId}; pub fn router() -> Router { Router::new() @@ -80,7 +80,6 @@ mod post { let switchboard = sw_lock.read().unwrap(); switchboard.extract_mentions(&entry) }; - tracing::debug!("{:?}", mentions); insert_mentions(&mentions, pool).await?; Ok(entry.to_html(pool).await?) @@ -118,8 +117,9 @@ mod patch { if old_entry.value != new_entry.value { sqlx::query!( - "delete from mentions where entity_id = $1 and entity_type = 'journal_entry'", - entry_id + "delete from mentions where entity_id = $1 and entity_type = $2", + entry_id, + MentionHostType::JournalEntry as DbId ) .execute(pool) .await?; @@ -131,9 +131,8 @@ mod patch { insert_mentions(&mentions, pool).await?; } - Ok(Into::::into(&new_entry) - .format_pool(pool) - .await?) + + Ok(new_entry.to_html(pool).await?) } } diff --git a/src/web/mod.rs b/src/web/mod.rs index 66ab59c..6a6e141 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -61,11 +61,16 @@ impl FromRequestParts for Layout { } impl Layout { - pub fn render(&self, css: Option>, content: Markup) -> Markup { + pub fn render(&self, title: impl AsRef, css: Option>, content: Markup) -> Markup { html! { (DOCTYPE) html { head { + title { (format!("{} | Mascarpone CRM", title.as_ref())) } + link rel="apple-touch-icon" sizes="180x180" href=(format!("/static/{}", asset!("apple-touch-icon.png"))); + link rel="icon" type="image/png" sizes="32x32" href=(format!("/static/{}", asset!("favicon-32x32.png"))); + link rel="icon" type="image/png" sizes="16x16" href=(format!("/static/{}", asset!("favicon-16x16.png"))); + link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css"))); meta name="viewport" content="width=device-width"; script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} @@ -102,6 +107,7 @@ impl Layout { (content) } } + template #alpine-loaded x-cloak {} } } } diff --git a/src/web/settings.rs b/src/web/settings.rs index feab308..ff1b2fa 100644 --- a/src/web/settings.rs +++ b/src/web/settings.rs @@ -62,6 +62,7 @@ mod get { let ics_path: Option = ics_path.0; Ok(layout.render( + "Settings", Some(vec![asset!("settings.css")]), html! { h2 { "Birthdays Calendar URL" } diff --git a/static/android-chrome-192x192.png b/static/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..105cb8c75ab7d5bab25c71ea7dd7fdc57060e688 GIT binary patch literal 3089 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 literal 0 HcmV?d00001 diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..f19ddabef7052d2dd2ff93da70faa6133ee0e5f1 GIT binary patch literal 4992 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 HB{Ts50~i>m literal 0 HcmV?d00001 diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6a3a72f897f16bae804aad1e2436de16bc231272 GIT binary patch literal 15406 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 G3IPD35x^b* literal 0 HcmV?d00001 diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..dee127c --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,20 @@ +{ + "short_name": "CRM", + "name": "Mascarpone CRM", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#8b687f", + "background_color": "#f2f3f6" +} diff --git a/static/site.webmanifest b/static/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/static/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file