fix,feat: mention behavior and page titles

This commit is contained in:
Robert Perce 2026-02-14 13:35:59 -06:00
parent 7e2f5d0a18
commit 79a054ab40
22 changed files with 314 additions and 140 deletions

37
e2e/custom-expects.ts Normal file
View file

@ -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,
};
}
});

62
e2e/pages/contact.spec.ts Normal file
View file

@ -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
*/

View file

@ -1,28 +1,26 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { login, verifyCreateUser, todate } from './util'; import { login, verifyCreateUser, todate } from './util';
test('can log out', async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
});
test('can log out', async ({ page }) => {
await page.getByText("Logout").click(); await page.getByText("Logout").click();
await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible();
}); });
test('has no contacts', async ({ page }) => { test('has no contacts', async ({ page }) => {
await login(page);
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0);
}); });
test('can add contacts', async ({ page }) => { test('can add contacts', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['John Contact'] });
await verifyCreateUser(page, { names: ['Jack Contact'] }); await verifyCreateUser(page, { names: ['Jack Contact'] });
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
}); });
test('shows "never" for unfreshened contacts', async ({ page }) => { test('shows "never" for unfreshened contacts', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: 'Mascarpone' }).click(); 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 }) => { test('shows the date for fresh contacts', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: /fresh/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 }) => { test('sidebar is sorted alphabetically', async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['Zulu'] }); await verifyCreateUser(page, { names: ['Zulu'] });
await verifyCreateUser(page, { names: ['Alfa'] }); await verifyCreateUser(page, { names: ['Alfa'] });
await verifyCreateUser(page, { names: ['Golf'] }); await verifyCreateUser(page, { names: ['Golf'] });

View file

@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import 'custom-expects';
// purposefully not using ??: we want to replace empty empty string with default // purposefully not using ??: we want to replace empty empty string with default
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';

View file

@ -3,7 +3,8 @@ use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::get;
use axum_login::AuthUser; use axum_login::AuthUser;
use axum_login::{AuthManagerLayerBuilder, login_required}; 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 sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
@ -11,7 +12,7 @@ use std::sync::{Arc, RwLock};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::signal; use tokio::signal;
use tokio::task::AbortHandle; 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::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
use tower_sessions_sqlx_store::SqliteStore; use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -108,10 +109,21 @@ enum Commands {
port: u32, port: u32,
}, },
/// set password of user, creating if necessary
SetPassword { SetPassword {
/// username to create or set password /// username to create or set password
username: String, 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> { async fn serve(port: &u32) -> Result<(), anyhow::Error> {
@ -169,6 +181,7 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
.merge(auth::router()) .merge(auth::router())
.merge(ics::router()) .merge(ics::router())
.nest_service("/static", ServeDir::new("./hashed_static")) .nest_service("/static", ServeDir::new("./hashed_static"))
.nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))))
.layer(auth_layer) .layer(auth_layer)
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state); .with_state(state);
@ -218,6 +231,46 @@ async fn main() -> Result<(), anyhow::Error> {
println!("No update was made; probably something went wrong."); 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<bool> = 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 }) => { Some(Commands::Serve { port }) => {
serve(port).await?; serve(port).await?;
} }

View file

@ -74,7 +74,7 @@ impl MentionHost<'_> {
} }
impl Switchboard { impl Switchboard {
pub async fn new(pool: &SqlitePool) -> Result<Self, AppError> { pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String,String>, AppError> {
let mut trie = radix_trie::Trie::new(); let mut trie = radix_trie::Trie::new();
let mentionables = sqlx::query_as!( let mentionables = sqlx::query_as!(
@ -92,9 +92,23 @@ impl Switchboard {
trie.insert(mentionable.text, mentionable.uri); trie.insert(mentionable.text, mentionable.uri);
} }
Ok(trie)
}
pub async fn new(pool: &SqlitePool) -> Result<Self, AppError> {
let trie = Self::gen_trie(pool).await?;
Ok(Switchboard { trie }) Ok(Switchboard { trie })
} }
pub fn check_and_assign(self: &mut Self, trie: radix_trie::Trie<String, String>) -> bool {
if trie != self.trie {
self.trie = trie;
true
} else {
false
}
}
pub fn remove(self: &mut Self, text: &String) { pub fn remove(self: &mut Self, text: &String) {
self.trie.remove(text); self.trie.remove(text);
} }

View file

@ -75,6 +75,11 @@ mod get {
(DOCTYPE) (DOCTYPE)
html { html {
head { 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"; 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.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" {} script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}

View file

@ -19,7 +19,7 @@ use super::home::journal_section;
use crate::db::DbId; use crate::db::DbId;
use crate::models::user::AuthSession; use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry}; use crate::models::{HydratedContact, JournalEntry};
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions}; use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard};
use crate::{AppError, AppState}; use crate::{AppError, AppState};
pub mod fields; pub mod fields;
@ -87,6 +87,11 @@ mod get {
.fetch_all(pool) .fetch_all(pool)
.await?; .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<PhoneNumber> = sqlx::query_as!( let phone_numbers: Vec<PhoneNumber> = sqlx::query_as!(
PhoneNumber, PhoneNumber,
"select * from phone_numbers where contact_id = $1", "select * from phone_numbers where contact_id = $1",
@ -113,6 +118,7 @@ mod get {
.text_body; .text_body;
Ok(layout.render( Ok(layout.render(
contact.names.get(0).unwrap_or(&String::from("(unknown)")),
Some(vec![asset!("contact.css"), asset!("journal.css")]), Some(vec![asset!("contact.css"), asset!("journal.css")]),
html! { html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" } a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
@ -141,8 +147,8 @@ mod get {
} }
label { "freshened" } label { "freshened" }
div { div {
@if let Some(when) = &contact.manually_freshened_at { @if let Some(freshened) = freshened {
(when.date_naive().to_string()) (freshened.to_string())
} @else { } @else {
"(never)" "(never)"
} }
@ -216,7 +222,10 @@ mod get {
.text_body .text_body
.unwrap_or(String::new()); .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" { form hx-ext="response-targets" {
div { div {
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error"; input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
@ -377,50 +386,7 @@ mod put {
.execute(pool) .execute(pool)
.await?; .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 { 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 // 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<String> = payload let new_names: Vec<String> = payload
.name .name
.unwrap_or(vec![]) .unwrap_or(vec![])
@ -523,60 +488,25 @@ mod put {
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect(); let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
if old_names != new_names { 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!( sqlx::query!(
"delete from mentions; delete from names where contact_id = $1", "delete from names where contact_id = $1",
contact_id contact_id
) )
.execute(pool) .execute(pool)
.await?; .await?;
let mut recalc_counts: QueryBuilder<Sqlite> = 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() { if !new_names.is_empty() {
for name in &new_names { QueryBuilder::new(
name_list.push_bind(name.clone()); "insert into names (contact_id, sort, name) "
} ).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
b
let mut name_insert: QueryBuilder<Sqlite> = .push_bind(contact_id)
QueryBuilder::new("insert into names (contact_id, sort, name) "); .push_bind(DbId::try_from(sort).unwrap())
name_insert.push_values( .push_bind(name);
new_names.iter().enumerate(), }).build()
|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()
.persistent(false) .persistent(false)
.fetch_all(pool) .execute(pool)
.await?; .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 { if new_groups != old_groups {
sqlx::query!( sqlx::query!(
"delete from mentions; delete from groups where contact_id = $1", "delete from groups where contact_id = $1",
contact_id contact_id
) )
.execute(pool) .execute(pool)
@ -613,36 +543,83 @@ mod put {
.execute(pool) .execute(pool)
.await?; .await?;
} }
}
}
{ let regen_all_mentions = {
let mut switchboard = sw_lock.write().unwrap(); let trie = Switchboard::gen_trie(pool).await?;
for name in &old_groups { let mut swb = sw_lock.write().unwrap();
// TODO i think we care about group name vs contact name counts, swb.check_and_assign(trie)
// otherwise this will cause a problem (or we want to disallow };
// setting group names that are contact names or vice versa?) let regen_lives_with = old_contact.lives_with != payload.lives_with;
switchboard.remove(name); let regen_text_body = old_contact.text_body != text_body;
} if regen_all_mentions {
sqlx::query("delete from mentions").execute(pool).await?;
for group in &new_groups { } else {
switchboard if regen_lives_with {
.add_mentionable(group.clone(), format!("/group/{}", slugify(group))); 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<JournalEntry> =
sqlx::query_as("select * from journal_entries")
.fetch_all(pool)
.await?;
for entry in journal_entries { if regen_text_body {
let mentions = { sqlx::query!(
let switchboard = sw_lock.read().unwrap(); "delete from mentions where entity_id = $1 and entity_type = $2",
switchboard.extract_mentions(&entry) contact_id,
}; MentionHostType::ContactTextBody as DbId
insert_mentions(&mentions, pool).await?; )
} .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<JournalEntry> =
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?;
} }
} }

View file

@ -53,6 +53,7 @@ mod get {
.await?; .await?;
Ok(layout.render( Ok(layout.render(
format!("Group: {}", name),
Some(vec![asset!("group.css")]), Some(vec![asset!("group.css")]),
html! { html! {
h1 { (name) } h1 { (name) }

View file

@ -223,6 +223,7 @@ pub mod get {
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(layout.render( Ok(layout.render(
"Home",
Some(vec![asset!("home.css"), asset!("journal.css")]), Some(vec![asset!("home.css"), asset!("journal.css")]),
html! { html! {
(freshness_section(&freshens)?) (freshness_section(&freshens)?)

View file

@ -11,8 +11,8 @@ use serde::Deserialize;
use crate::models::JournalEntry; use crate::models::JournalEntry;
use crate::models::user::AuthSession; use crate::models::user::AuthSession;
use crate::switchboard::{MentionHost, insert_mentions}; use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
use crate::{AppError, AppState}; use crate::{AppError, AppState, DbId};
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
@ -80,7 +80,6 @@ mod post {
let switchboard = sw_lock.read().unwrap(); let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(&entry) switchboard.extract_mentions(&entry)
}; };
tracing::debug!("{:?}", mentions);
insert_mentions(&mentions, pool).await?; insert_mentions(&mentions, pool).await?;
Ok(entry.to_html(pool).await?) Ok(entry.to_html(pool).await?)
@ -118,8 +117,9 @@ mod patch {
if old_entry.value != new_entry.value { if old_entry.value != new_entry.value {
sqlx::query!( sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = 'journal_entry'", "delete from mentions where entity_id = $1 and entity_type = $2",
entry_id entry_id,
MentionHostType::JournalEntry as DbId
) )
.execute(pool) .execute(pool)
.await?; .await?;
@ -131,9 +131,8 @@ mod patch {
insert_mentions(&mentions, pool).await?; insert_mentions(&mentions, pool).await?;
} }
Ok(Into::<MentionHost>::into(&new_entry)
.format_pool(pool) Ok(new_entry.to_html(pool).await?)
.await?)
} }
} }

View file

@ -61,11 +61,16 @@ impl FromRequestParts<AppState> for Layout {
} }
impl Layout { impl Layout {
pub fn render(&self, css: Option<Vec<&str>>, content: Markup) -> Markup { pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup {
html! { html! {
(DOCTYPE) (DOCTYPE)
html { html {
head { 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"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width"; 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.org@2.0.7/dist/htmx.min.js" {}
@ -102,6 +107,7 @@ impl Layout {
(content) (content)
} }
} }
template #alpine-loaded x-cloak {}
} }
} }
} }

View file

@ -62,6 +62,7 @@ mod get {
let ics_path: Option<String> = ics_path.0; let ics_path: Option<String> = ics_path.0;
Ok(layout.render( Ok(layout.render(
"Settings",
Some(vec![asset!("settings.css")]), Some(vec![asset!("settings.css")]),
html! { html! {
h2 { "Birthdays Calendar URL" } h2 { "Birthdays Calendar URL" }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -47,7 +47,7 @@ main {
#groups { #groups {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min-content; width: fit-content;
} }
#text_body { #text_body {

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

20
static/manifest.json Normal file
View file

@ -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"
}

1
static/site.webmanifest Normal file
View file

@ -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"}