diff --git a/Taskfile b/Taskfile index 211d1e8..9eeaa50 100755 --- a/Taskfile +++ b/Taskfile @@ -12,7 +12,8 @@ refresh_sqlx_db() { rm -f some_user.db for migration in migrations/each_user/*.sql; do echo "Applying $migration..." - sqlite3 some_user.db < "$migration" + echo "BEGIN TRANSACTION;$(cat "$migration");COMMIT TRANSACTION;"\ + | sqlite3 some_user.db done } 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/migrations/each_user/0012_contact_fresh_type.sql b/migrations/each_user/0012_contact_fresh_type.sql index a9685ef..7a5f8bb 100644 --- a/migrations/each_user/0012_contact_fresh_type.sql +++ b/migrations/each_user/0012_contact_fresh_type.sql @@ -1,5 +1,12 @@ +-- foreign_keys can only up/down outside of transactions +-- so we first pre-commit the one started by sqlx... +COMMIT TRANSACTION; +-- turn off foreign keys... PRAGMA foreign_keys=OFF; + +-- start our own transaction... +BEGIN TRANSACTION; create table if not exists new_contacts ( id integer primary key autoincrement, birthday text, @@ -16,4 +23,12 @@ insert into new_contacts ( drop table contacts; alter table new_contacts rename to contacts; PRAGMA foreign_key_check; + +-- commit our own transaction... +COMMIT TRANSACTION; + +-- put our own pragmas back... PRAGMA foreign_keys=ON; + +-- and start a dummy transaction so sqlx's COMMIT doesn't explode +BEGIN TRANSACTION; 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 8eea1b0..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; @@ -75,7 +75,7 @@ mod get { let entries: Vec = sqlx::query_as( "select distinct j.id, j.value, j.date from journal_entries j join mentions m on j.id = m.entity_id - where m.entity_type = $1 and (m.url = '/contact/'||$1 or m.url in ( + where m.entity_type = $1 and (m.url = '/contact/'||$2 or m.url in ( select '/group/'||slug from groups where contact_id = $2 )) @@ -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 0000000..105cb8c Binary files /dev/null and b/static/android-chrome-192x192.png differ diff --git a/static/android-chrome-512x512.png b/static/android-chrome-512x512.png new file mode 100644 index 0000000..f19ddab Binary files /dev/null and b/static/android-chrome-512x512.png differ diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..af1d2af Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/contact.css b/static/contact.css index d725284..765ff62 100644 --- a/static/contact.css +++ b/static/contact.css @@ -47,7 +47,7 @@ main { #groups { display: flex; flex-direction: column; - width: min-content; + width: fit-content; } #text_body { diff --git a/static/favicon-16x16.png b/static/favicon-16x16.png new file mode 100644 index 0000000..e3114e8 Binary files /dev/null and b/static/favicon-16x16.png differ diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png new file mode 100644 index 0000000..a4e51d8 Binary files /dev/null and b/static/favicon-32x32.png differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..6a3a72f Binary files /dev/null and b/static/favicon.ico differ 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