Compare commits
4
Commits
f480407927
..
0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24d8aa062 | ||
|
|
5233b1e6fd | ||
|
|
ff9f679fcb | ||
|
|
a13bc17629 |
+5
-19
@@ -1,13 +1,12 @@
|
|||||||
import { test as base, expect, Page } from "@playwright/test";
|
import { test as base, expect, Page } from "@playwright/test";
|
||||||
import { spawn, ChildProcess } from "child_process";
|
import { spawn, ChildProcess } from "child_process";
|
||||||
import * as fs from "fs";
|
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a fresh Sustenance server against a unique, throwaway database on a
|
* Starts a fresh Sustenance server against an in-memory SQLite database on a
|
||||||
* unique port for each test, and tears it down afterwards. This gives every
|
* unique port for each test, and tears it down afterwards. Every test gets a
|
||||||
* test a clean DB with no shared state between tests.
|
* clean DB with no shared state between tests, and nothing is written to disk.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
||||||
server: [
|
server: [
|
||||||
@@ -15,10 +14,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
|||||||
const server = await startServer();
|
const server = await startServer();
|
||||||
await use({ baseURL: server.baseURL });
|
await use({ baseURL: server.baseURL });
|
||||||
await killTree(server.child);
|
await killTree(server.child);
|
||||||
// Clean up the DB files (including -wal / -shm).
|
|
||||||
for (const suffix of ["", "-wal", "-shm"]) {
|
|
||||||
fs.rmSync(server.dbPath + suffix, { force: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ scope: "test", auto: true },
|
{ scope: "test", auto: true },
|
||||||
],
|
],
|
||||||
@@ -35,12 +30,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({
|
|||||||
/** Starts a server, retrying on a fresh port if the first attempt fails to bind. */
|
/** Starts a server, retrying on a fresh port if the first attempt fails to bind. */
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
const dbPath = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`sustenance-e2e-${process.pid}-${Date.now()}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2)}.db`,
|
|
||||||
);
|
|
||||||
const port = 20000 + Math.floor(Math.random() * 30000);
|
const port = 20000 + Math.floor(Math.random() * 30000);
|
||||||
const baseURL = `http://localhost:${port}`;
|
const baseURL = `http://localhost:${port}`;
|
||||||
|
|
||||||
@@ -50,7 +39,7 @@ async function startServer() {
|
|||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
DATABASE_PATH: dbPath,
|
DATABASE_IN_MEMORY: "1",
|
||||||
REGISTRATION_MODE: "open",
|
REGISTRATION_MODE: "open",
|
||||||
BIND_ADDRESS: `127.0.0.1:${port}`,
|
BIND_ADDRESS: `127.0.0.1:${port}`,
|
||||||
PUBLIC_BASE_URL: baseURL,
|
PUBLIC_BASE_URL: baseURL,
|
||||||
@@ -72,13 +61,10 @@ async function startServer() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForServer(baseURL, child);
|
await waitForServer(baseURL, child);
|
||||||
return { baseURL, child, dbPath };
|
return { baseURL, child };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The server may have failed to bind (port collision). Clean up and retry.
|
// The server may have failed to bind (port collision). Clean up and retry.
|
||||||
await killTree(child);
|
await killTree(child);
|
||||||
for (const suffix of ["", "-wal", "-shm"]) {
|
|
||||||
fs.rmSync(dbPath + suffix, { force: true });
|
|
||||||
}
|
|
||||||
if (attempt === 4) {
|
if (attempt === 4) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`server failed to start after retries; last stderr:\n${stderr}\n${error}`,
|
`server failed to start after retries; last stderr:\n${stderr}\n${error}`,
|
||||||
|
|||||||
@@ -20,12 +20,25 @@ test("a list updates live for another user via websocket", async ({ page, browse
|
|||||||
// Give the websocket connections a moment to establish.
|
// Give the websocket connections a moment to establish.
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Both start with an empty list.
|
||||||
|
await expect(pageB.locator("#list-meta")).toHaveText("0 items left out of 0");
|
||||||
|
|
||||||
// User A adds an item.
|
// User A adds an item.
|
||||||
await addItem(page, "Apple", "2");
|
await addItem(page, "Apple", "2");
|
||||||
|
|
||||||
// It should appear on User B's page without any reload.
|
// It should appear on User B's page without any reload.
|
||||||
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" })).toBeVisible();
|
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" })).toBeVisible();
|
||||||
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" }).locator(".item-qty")).toHaveText("(2)");
|
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" }).locator(".item-qty")).toHaveText("(2)");
|
||||||
|
// The left/total count should also update live for User B.
|
||||||
|
await expect(pageB.locator("#list-meta")).toHaveText("1 items left out of 1");
|
||||||
|
|
||||||
|
// User A removes the item.
|
||||||
|
await page.locator(".item-actions-button").first().click();
|
||||||
|
await page.locator("[id^='item-edit-delete']").click();
|
||||||
|
|
||||||
|
// The item and the count should update live on User B's page.
|
||||||
|
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" })).toHaveCount(0);
|
||||||
|
await expect(pageB.locator("#list-meta")).toHaveText("0 items left out of 0");
|
||||||
|
|
||||||
await contextB.close();
|
await contextB.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// A single static asset: its embedded bytes and a content hash used to
|
||||||
|
/// version its URL.
|
||||||
|
pub struct StaticAsset {
|
||||||
|
pub data: &'static [u8],
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registry of the app's static assets, keyed by filename. Each asset's
|
||||||
|
/// content hash is computed once on first use and cached, so the versioned URL
|
||||||
|
/// changes automatically whenever the underlying file changes.
|
||||||
|
pub struct StaticAssetStore {
|
||||||
|
assets: HashMap<&'static str, StaticAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticAssetStore {
|
||||||
|
fn new(entries: &[(&'static str, &'static [u8])]) -> Self {
|
||||||
|
let assets = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(name, data)| {
|
||||||
|
let hash = content_hash(data);
|
||||||
|
(*name, StaticAsset { data, hash })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self { assets }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks up an asset by its filename (e.g. `"style.css"`).
|
||||||
|
pub fn get(&self, name: &str) -> Option<&StaticAsset> {
|
||||||
|
self.assets.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the versioned URL for an asset, e.g. `/static/style.css?v=<hash>`.
|
||||||
|
pub fn url(&self, name: &str) -> Option<String> {
|
||||||
|
self.assets
|
||||||
|
.get(name)
|
||||||
|
.map(|asset| format!("/static/{name}?v={}", asset.hash))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_hash(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs a `StaticAssetStore` from `name => path` pairs, embedding each
|
||||||
|
/// file's bytes at compile time via `include_bytes!`.
|
||||||
|
macro_rules! static_assets {
|
||||||
|
($($name:literal => $path:literal),* $(,)?) => {
|
||||||
|
StaticAssetStore::new(&[
|
||||||
|
$(($name, include_bytes!($path))),*
|
||||||
|
])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's static assets, loaded once on first use.
|
||||||
|
pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
|
||||||
|
static_assets! {
|
||||||
|
"style.css" => "../static/style.css",
|
||||||
|
"passkey-login.js" => "../static/passkey-login.js",
|
||||||
|
"passkey-register.js" => "../static/passkey-register.js",
|
||||||
|
"password-toggle.js" => "../static/password-toggle.js",
|
||||||
|
"htmx.min.js" => "../static/htmx.min.js",
|
||||||
|
"htmx-ws.min.js" => "../static/htmx-ws.min.js",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns the versioned URL for a known asset, panicking if the name isn't
|
||||||
|
/// registered (a programmer error, since these are compile-time constants).
|
||||||
|
pub fn url(name: &str) -> String {
|
||||||
|
STORE
|
||||||
|
.url(name)
|
||||||
|
.unwrap_or_else(|| panic!("unknown static asset: {name}"))
|
||||||
|
}
|
||||||
+17
-14
@@ -20,19 +20,13 @@ use thiserror::Error;
|
|||||||
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
|
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
|
||||||
use tracing::{Level, error, warn};
|
use tracing::{Level, error, warn};
|
||||||
|
|
||||||
|
use crate::assets;
|
||||||
use crate::domain::{DomainError, SessionUser};
|
use crate::domain::{DomainError, SessionUser};
|
||||||
use crate::ports::{HubEvent, RealtimeNotifier};
|
use crate::ports::{HubEvent, RealtimeNotifier};
|
||||||
use crate::services::{AuthService, InvitationService, ListService, MealService};
|
use crate::services::{AuthService, InvitationService, ListService, MealService};
|
||||||
use crate::views;
|
use crate::views;
|
||||||
use crate::webauthn::WebAuthnService;
|
use crate::webauthn::WebAuthnService;
|
||||||
|
|
||||||
/// The contents of `static/`, embedded into the binary at compile time so the
|
|
||||||
/// app can be shipped as a single executable without a separate static directory.
|
|
||||||
const STYLE_CSS: &[u8] = include_bytes!("../static/style.css");
|
|
||||||
const PASSKEY_LOGIN_JS: &[u8] = include_bytes!("../static/passkey-login.js");
|
|
||||||
const PASSKEY_REGISTER_JS: &[u8] = include_bytes!("../static/passkey-register.js");
|
|
||||||
const PASSWORD_TOGGLE_JS: &[u8] = include_bytes!("../static/password-toggle.js");
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub auth: Arc<AuthService>,
|
pub auth: Arc<AuthService>,
|
||||||
@@ -340,15 +334,24 @@ async fn home() -> Redirect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serves a file embedded in the binary from the `static/` folder.
|
/// Serves a file embedded in the binary from the `static/` folder.
|
||||||
|
///
|
||||||
|
/// Every asset is served with an immutable, long-lived cache header. Because
|
||||||
|
/// the HTML references each asset under a URL that is versioned by its content
|
||||||
|
/// hash, a changed file gets a new URL and the cache is never stale.
|
||||||
async fn static_asset(Path(path): Path<String>) -> Response {
|
async fn static_asset(Path(path): Path<String>) -> Response {
|
||||||
let (mime, data) = match path.as_str() {
|
let Some(asset) = assets::STORE.get(&path) else {
|
||||||
"style.css" => ("text/css", STYLE_CSS),
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
"passkey-login.js" => ("application/javascript", PASSKEY_LOGIN_JS),
|
|
||||||
"passkey-register.js" => ("application/javascript", PASSKEY_REGISTER_JS),
|
|
||||||
"password-toggle.js" => ("application/javascript", PASSWORD_TOGGLE_JS),
|
|
||||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
|
||||||
};
|
};
|
||||||
([(header::CONTENT_TYPE, mime)], data).into_response()
|
let mime = match path.as_str() {
|
||||||
|
"style.css" => "text/css",
|
||||||
|
_ => "application/javascript",
|
||||||
|
};
|
||||||
|
let mut response = ([(header::CONTENT_TYPE, mime)], asset.data).into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
|
);
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn log_response_status(request: Request, next: Next) -> Response {
|
async fn log_response_status(request: Request, next: Next) -> Response {
|
||||||
|
|||||||
+13
-1
@@ -1,3 +1,4 @@
|
|||||||
|
mod assets;
|
||||||
mod domain;
|
mod domain;
|
||||||
mod http;
|
mod http;
|
||||||
mod hub;
|
mod hub;
|
||||||
@@ -42,6 +43,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into());
|
let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into());
|
||||||
|
let database_in_memory = env::var("DATABASE_IN_MEMORY")
|
||||||
|
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false);
|
||||||
let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into());
|
let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into());
|
||||||
// For loopback hosts, advertise `localhost` so WebAuthn works locally (browsers
|
// For loopback hosts, advertise `localhost` so WebAuthn works locally (browsers
|
||||||
// reject IP addresses as RP IDs). Access the app via http://localhost:PORT.
|
// reject IP addresses as RP IDs). Access the app via http://localhost:PORT.
|
||||||
@@ -69,7 +73,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build the adapters (ports) and wire them into application services.
|
// Build the adapters (ports) and wire them into application services.
|
||||||
let db = SqliteDatabase::open(&database_path).await?;
|
let db = if database_in_memory {
|
||||||
|
SqliteDatabase::open_in_memory().await?
|
||||||
|
} else {
|
||||||
|
SqliteDatabase::open(&database_path).await?
|
||||||
|
};
|
||||||
let users: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository);
|
let users: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository);
|
||||||
let sessions: Arc<dyn SessionRepository> = Arc::new(SqliteSessionRepository);
|
let sessions: Arc<dyn SessionRepository> = Arc::new(SqliteSessionRepository);
|
||||||
let lists: Arc<dyn ListRepository> = Arc::new(SqliteListRepository);
|
let lists: Arc<dyn ListRepository> = Arc::new(SqliteListRepository);
|
||||||
@@ -163,6 +171,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
info!("shutdown complete; closing database");
|
info!("shutdown complete; closing database");
|
||||||
|
// Explicitly close the pool so SQLite can checkpoint and remove the
|
||||||
|
// WAL/SHM sidecar files. Without this, the pool's background close task
|
||||||
|
// races with process exit and the sidecars can be left behind.
|
||||||
|
db.close().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-14
@@ -39,26 +39,32 @@ impl SqliteDatabase {
|
|||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
/// Opens an in-memory database. Every connection in the pool shares the same
|
||||||
|
/// in-memory database via a named shared-cache database, so the schema and
|
||||||
|
/// data are visible across the whole pool. Nothing is persisted to disk.
|
||||||
pub async fn open_in_memory() -> DomainResult<Self> {
|
pub async fn open_in_memory() -> DomainResult<Self> {
|
||||||
// A pool of `:memory:` connections would each get a separate database,
|
// A plain `:memory:` database is private to each connection, so a pool
|
||||||
// so use a unique temporary file that shares the schema across the pool.
|
// would get a separate database per connection. Use a uniquely-named
|
||||||
|
// shared-cache in-memory database instead so the whole pool shares one.
|
||||||
|
//
|
||||||
|
// The name must be unique per call: shared-cache in-memory databases are
|
||||||
|
// keyed by name, so two calls with the same name within a process would
|
||||||
|
// resolve to the same database and share state. This matters for the
|
||||||
|
// unit tests, which run many `open_in_memory()` calls in parallel within
|
||||||
|
// a single process and need isolation from each other. The counter makes
|
||||||
|
// each call unique. (Shared-cache and in-memory databases are per-process,
|
||||||
|
// so there is no cross-process collision to guard against.)
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
let path = std::env::temp_dir().join(format!(
|
let filename = format!("file:sustenance-in-memory-{}", unique);
|
||||||
"sustenance-test-{}-{}-{}.db",
|
|
||||||
std::process::id(),
|
|
||||||
now(),
|
|
||||||
unique
|
|
||||||
));
|
|
||||||
let path = path.to_str().unwrap();
|
|
||||||
let options = SqliteConnectOptions::new()
|
let options = SqliteConnectOptions::new()
|
||||||
.filename(path)
|
.filename(filename)
|
||||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
.in_memory(true)
|
||||||
|
.shared_cache(true)
|
||||||
|
.journal_mode(sqlx::sqlite::SqliteJournalMode::Memory)
|
||||||
.foreign_keys(true)
|
.foreign_keys(true)
|
||||||
.busy_timeout(std::time::Duration::from_secs(5))
|
.busy_timeout(std::time::Duration::from_secs(5));
|
||||||
.create_if_missing(true);
|
|
||||||
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
|
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
|
||||||
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
|
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
|
||||||
seed_default_categories(&pool).await?;
|
seed_default_categories(&pool).await?;
|
||||||
@@ -68,6 +74,14 @@ impl SqliteDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteDatabase {
|
impl SqliteDatabase {
|
||||||
|
/// Closes the connection pool, waiting for all connections to be released
|
||||||
|
/// and closed. This lets SQLite checkpoint and remove the WAL/SHM sidecar
|
||||||
|
/// files on a clean shutdown; without it, the pool's background close task
|
||||||
|
/// races with process exit and the sidecars may be left behind.
|
||||||
|
pub async fn close(&self) {
|
||||||
|
self.pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs `operation` inside a single transaction, committing on success and
|
/// Runs `operation` inside a single transaction, committing on success and
|
||||||
/// rolling back on error. Multiple repositories can participate in the same
|
/// rolling back on error. Multiple repositories can participate in the same
|
||||||
/// transaction so their writes commit together atomically.
|
/// transaction so their writes commit together atomically.
|
||||||
|
|||||||
+25
-9
@@ -37,8 +37,8 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
|
|||||||
button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" }
|
button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" }
|
||||||
p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } }
|
p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } }
|
||||||
}
|
}
|
||||||
script src="/static/password-toggle.js" {}
|
script src=(crate::assets::url("password-toggle.js")) {}
|
||||||
script src="/static/passkey-login.js" {}
|
script src=(crate::assets::url("passkey-login.js")) {}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup {
|
|||||||
}
|
}
|
||||||
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
|
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
|
||||||
}
|
}
|
||||||
script src="/static/password-toggle.js" {}
|
script src=(crate::assets::url("password-toggle.js")) {}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,8 +164,8 @@ pub fn account_page(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
script src="/static/password-toggle.js" {}
|
script src=(crate::assets::url("password-toggle.js")) {}
|
||||||
script src="/static/passkey-register.js" {}
|
script src=(crate::assets::url("passkey-register.js")) {}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -795,7 +795,7 @@ pub fn list_page(
|
|||||||
div {
|
div {
|
||||||
p class="eyebrow" { "SHARED LIST" }
|
p class="eyebrow" { "SHARED LIST" }
|
||||||
h1 { (list.name) }
|
h1 { (list.name) }
|
||||||
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
|
(list_meta_fragment(items, false))
|
||||||
}
|
}
|
||||||
div class="list-topbar-actions" {
|
div class="list-topbar-actions" {
|
||||||
@if list.archived_at.is_some() {
|
@if list.archived_at.is_some() {
|
||||||
@@ -1095,6 +1095,21 @@ pub fn categories_panel(categories: &[Category], csrf_token: &str, out_of_band:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_meta_fragment(items: &[Item], out_of_band: bool) -> Markup {
|
||||||
|
let left = items.iter().filter(|item| !item.checked).count();
|
||||||
|
let total = items.len();
|
||||||
|
let meta = html! {
|
||||||
|
p id="list-meta" class="list-meta" { (left) " items left out of " (total) }
|
||||||
|
};
|
||||||
|
if out_of_band {
|
||||||
|
html! {
|
||||||
|
p id="list-meta" class="list-meta" hx-swap-oob="outerHTML" { (left) " items left out of " (total) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn live_list_fragments(
|
pub fn live_list_fragments(
|
||||||
list: &GroceryList,
|
list: &GroceryList,
|
||||||
items: &[Item],
|
items: &[Item],
|
||||||
@@ -1104,6 +1119,7 @@ pub fn live_list_fragments(
|
|||||||
) -> Markup {
|
) -> Markup {
|
||||||
let editable = list.archived_at.is_none();
|
let editable = list.archived_at.is_none();
|
||||||
html! {
|
html! {
|
||||||
|
(list_meta_fragment(items, true))
|
||||||
(list_items_fragment(list, items, categories, csrf_token, true, editable))
|
(list_items_fragment(list, items, categories, csrf_token, true, editable))
|
||||||
(list_meals_panel(list_meals, list.id, csrf_token, true, editable))
|
(list_meals_panel(list_meals, list.id, csrf_token, true, editable))
|
||||||
}
|
}
|
||||||
@@ -1292,9 +1308,9 @@ fn page(title: &str, user: Option<&User>, content: Markup) -> Markup {
|
|||||||
meta charset="utf-8";
|
meta charset="utf-8";
|
||||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||||
title { (title) " · Sustenance" }
|
title { (title) " · Sustenance" }
|
||||||
link rel="stylesheet" href="/static/style.css";
|
link rel="stylesheet" href=(crate::assets::url("style.css"));
|
||||||
script src="https://unpkg.com/htmx.org@2.0.10" {}
|
script src=(crate::assets::url("htmx.min.js")) {}
|
||||||
script src="https://unpkg.com/htmx-ext-ws@2.0.4/ws.js" {}
|
script src=(crate::assets::url("htmx-ws.min.js")) {}
|
||||||
}
|
}
|
||||||
body hx-boost="true" {
|
body hx-boost="true" {
|
||||||
header class="site-header" {
|
header class="site-header" {
|
||||||
|
|||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user