Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone ee59501e91bae6d2c23a8370
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/ListPage.tsx
// A list: add items (with suggestions), check them off with one thumb, edit, and clear what is done.
// Everything here works offline; changes sync when there is a connection.
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
import { navigate } from "./lib/router";
import { useStored, write } from "./lib/local";
import { useSyncStatus, clearNotice } from "./lib/sync";
import {
useList, useMine, forget, addItems, toggleItem, updateItem, removeItem, clearChecked, restoreItems,
} from "./lib/data";
import { Modal, toast, Icon } from "./ui";
import { ShareSheet, SettingsSheet } from "./Sheets";
import {
SECTIONS, groupItems, dictionaryMatches, itemKey, normal, LIMITS, type Item, type Known, type Kind,
} from "../functions/lib/lists";
const go = (to: string) => (e: React.MouseEvent) => { e.preventDefault(); navigate(to); };
function useOnline() {
return useSyncExternalStore(
(l) => { window.addEventListener("online", l); window.addEventListener("offline", l); return () => { window.removeEventListener("online", l); window.removeEventListener("offline", l); }; },
() => navigator.onLine,
);
}
export function ListPage({ id }: { id: string }) {
const L = useList(id);
const ownerKey = useMine()[id]?.ownerKey;
const [editing, setEditing] = useState<Item | null>(null);
const [sheet, setSheet] = useState<"share" | "settings" | null>(null);
const hintDismissed = useStored<boolean>(`lists:hint:${id}`, false);
useEffect(() => { if (L.list?.name) document.title = `${L.list.name} | Lists`; }, [L.list?.name]);
if (L.state === "gone" || L.state === "deleted") {
return (
<div className="page narrow">
<h1 className="page-title">{L.state === "deleted" ? "This list was deleted" : "You are not on this list any more"}</h1>
<p className="lede">{L.state === "deleted" ? "Its owner deleted it for everyone." : "Someone who manages the list removed you, or the list no longer exists. Ask for a new link if you should be on it."}</p>
<button className="btn" type="button" onClick={() => { forget(id); navigate("/lists", true); }}>Remove it from this device</button>
</div>
);
}
if (!L.list) return <div className="page narrow"><div className="loading"><span className="spinner" /> Loading the list...</div></div>;
const list = L.list;
const kind: Kind = list.kind === "todo" ? "todo" : "grocery";
const { groups, checked } = groupItems(L.items, kind, list.order);
const open = groups.reduce((n, g) => n + g.items.length, 0);
const isOwner = Boolean(L.uid && L.owners.has(L.uid));
const onClear = () => {
const done = checked;
clearChecked(id, done);
toast(`Cleared ${done.length} checked ${done.length === 1 ? "item" : "items"}.`, { label: "Undo", run: () => restoreItems(id, done) });
};
return (
<div className="page narrow list-page">
<header className="list-head">
<a className="back" href="/lists" onClick={go("/lists")}><Icon name="back" /> Lists</a>
<div className="list-title-row">
<div className="grow">
<h1 className="list-title">{list.name}</h1>
<p className="list-sub">{open ? `${open} ${kind === "todo" ? "left" : "to get"}` : L.items.length ? "All done" : "Empty"}{L.members.length > 1 ? ` · shared with ${L.members.length - 1}` : ""}</p>
</div>
<button className="btn btn-line btn-sm" type="button" onClick={() => setSheet("share")}><Icon name="share" size={16} /> Share</button>
<button className="icon-btn" type="button" onClick={() => setSheet("settings")} aria-label="List settings"><Icon name="more" /></button>
</div>
<SyncLine pending={L.pending} live={L.live} />
</header>
{!hintDismissed && L.members.length <= 1 && (
<div className="notice notice-ok hint-box">
<span>Share the link with everyone in the house. They see every change as it happens.</span>
<span className="row">
<button className="btn btn-sm" type="button" onClick={() => setSheet("share")}>Share</button>
<button className="btn btn-sm btn-quiet" type="button" onClick={() => write(`lists:hint:${id}`, true)}>Not now</button>
</span>
</div>
)}
<AddBar id={id} kind={kind} items={L.items} known={L.known} />
{!L.items.length ? (
<div className="empty">
<img className="art" src="/art/card-lists.webp" alt="" width="200" height="150" />
<p className="h3">Nothing on the list yet</p>
<p className="muted">Type an item and press Enter. Add several at once with commas: milk, eggs, 2 lb chicken.</p>
</div>
) : (
<>
{groups.map((g) => (
<section className="group" key={g.section ?? "all"} aria-label={g.label || "Items"}>
{g.label && <h2 className="group-label">{g.label}</h2>}
<ul className="items">{g.items.map((it) => <Row key={it.id} it={it} onToggle={() => toggleItem(id, it)} onEdit={() => setEditing(it)} />)}</ul>
</section>
))}
{!open && <p className="all-done">Everything is checked off.</p>}
{checked.length > 0 && (
<section className="group checked" aria-label="Checked off">
<div className="group-label-row">
<h2 className="group-label">{kind === "todo" ? "Done" : "Checked off"} ({checked.length})</h2>
<button className="btn btn-sm btn-quiet" type="button" onClick={onClear}>Clear</button>
</div>
<ul className="items">{checked.map((it) => <Row key={it.id} it={it} onToggle={() => toggleItem(id, it)} onEdit={() => setEditing(it)} />)}</ul>
</section>
)}
</>
)}
{editing && <ItemSheet id={id} kind={kind} it={editing} items={L.items} onClose={() => setEditing(null)} />}
{sheet === "share" && <ShareSheet id={id} uid={L.uid} list={list} secret={L.secret} members={L.members} owners={L.owners} isOwner={isOwner} ownerKey={ownerKey} onClose={() => setSheet(null)} />}
{sheet === "settings" && <SettingsSheet id={id} uid={L.uid} list={list} items={L.items} known={L.known} isOwner={isOwner} onClose={() => setSheet(null)} onClear={() => { setSheet(null); onClear(); }} />}
</div>
);
}
function SyncLine({ pending, live }: { pending: number; live: boolean }) {
const online = useOnline();
const s = useSyncStatus();
if (s.notice) {
return <p className="sync sync-error" role="alert">{s.notice} <button type="button" onClick={clearNotice}>OK</button></p>;
}
if (pending && (!online || s.stuck)) {
return <p className="sync"><Icon name="offline" size={16} /> Your changes are saved on this phone. They sync when you are back online.</p>;
}
if (pending) return <p className="sync"><Icon name="cloud" size={16} /> Saving...</p>;
if (!online || !live) return <p className="sync"><Icon name="offline" size={16} /> {online ? "Connecting..." : "Offline. You can still check off and add items."}</p>;
return <p className="sync sync-live"><span className="dot" aria-hidden="true" /> Live: changes show for everyone right away</p>;
}
function Row({ it, onToggle, onEdit }: { it: Item; onToggle: () => void; onEdit: () => void }) {
const meta = [it.qty, it.note].filter(Boolean).join(" · ");
return (
<li className={`item${it.checked ? " done" : ""}`}>
<button type="button" className="item-main" aria-pressed={it.checked} onClick={onToggle}>
<span className="box" aria-hidden="true">{it.checked && <Icon name="check" size={16} />}</span>
<span className="item-text">
<span className="item-name">{it.name}</span>
{meta && <span className="item-meta">{meta}</span>}
</span>
</button>
<button type="button" className="item-edit" onClick={onEdit} aria-label={`Edit ${it.name}`}><Icon name="edit" /></button>
</li>
);
}
function AddBar({ id, kind, items, known }: { id: string; kind: Kind; items: Item[]; known: Record<string, Known> }) {
const [text, setText] = useState("");
const [focused, setFocused] = useState(false);
const blurTimer = useRef<number>();
const input = useRef<HTMLInputElement>(null);
const onList = useMemo(() => new Set(items.filter((i) => !i.checked).map((i) => i.id)), [items]);
const typed = text.trim();
const suggestions = useMemo(() => {
const knownList = Object.values(known).filter((k) => k.name && !onList.has(k.id));
if (!typed) return knownList.sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, 12).map((k) => k.name);
if (/[,\n]/.test(typed)) return [];
const t = normal(typed);
const fromKnown = knownList
.filter((k) => { const n = normal(k.name); return n.startsWith(t) || n.includes(` ${t}`); })
.sort((a, b) => (b.uses ?? 0) - (a.uses ?? 0))
.map((k) => k.name);
const seen = new Set(fromKnown.map(itemKey));
const fromDictionary = kind === "grocery"
? dictionaryMatches(typed, 8).map((n) => n.charAt(0).toUpperCase() + n.slice(1)).filter((n) => !onList.has(itemKey(n)) && !seen.has(itemKey(n)))
: [];
return [...fromKnown, ...fromDictionary].filter((n) => normal(n) !== t).slice(0, 6);
}, [typed, known, onList, kind]);
const add = (value: string) => {
if (!value.trim()) return;
const { added, already } = addItems(id, kind, value, items, known);
if (already.length && !added.length) toast(`${already.join(", ")} ${already.length === 1 ? "is" : "are"} already on the list.`);
setText("");
input.current?.focus();
};
const submit = (e: FormEvent) => { e.preventDefault(); add(text); };
const showRecent = !typed && (focused || !onList.size) && suggestions.length > 0;
const showMatches = typed && focused && suggestions.length > 0;
return (
<div className="add">
<form className="add-bar" onSubmit={submit}>
<label className="sr-only" htmlFor="add-input">Add an item</label>
<input
id="add-input" ref={input} className="input add-input" value={text} onChange={(e) => setText(e.target.value)}
// Suggestions hide a moment after leaving the box: hiding them at once moves the list up under
// the finger, and the tap that left the box misses the item it was aimed at.
onFocus={() => { window.clearTimeout(blurTimer.current); setFocused(true); }}
onBlur={() => { blurTimer.current = window.setTimeout(() => setFocused(false), 300); }}
placeholder={kind === "todo" ? "Add a task" : "Add an item, like 2 lb chicken"} autoComplete="off" autoCapitalize="sentences" enterKeyHint="done" maxLength={500}
/>
<button className="btn add-btn" type="submit" aria-label="Add"><Icon name="plus" /> <span className="add-label">Add</span></button>
</form>
{(showRecent || showMatches) && (
<div className="suggest" aria-label={showRecent ? "Add again" : "Suggestions"}>
{showRecent && <span className="suggest-label">Add again</span>}
{suggestions.map((s) => (
<button key={s} type="button" className="pick" onPointerDown={(e) => e.preventDefault()} onClick={() => add(s)}>{s}</button>
))}
</div>
)}
</div>
);
}
function ItemSheet({ id, kind, it, items, onClose }: { id: string; kind: Kind; it: Item; items: Item[]; onClose: () => void }) {
const [name, setName] = useState(it.name);
const [qty, setQty] = useState(it.qty || "");
const [note, setNote] = useState(it.note || "");
const [section, setSection] = useState(it.section || "other");
const [error, setError] = useState<string | null>(null);
const save = (e?: FormEvent) => {
e?.preventDefault();
const err = updateItem(id, it, { name, qty, note, section }, items);
if (err) return setError(err);
onClose();
};
return (
<Modal
title="Edit item"
onClose={onClose}
footer={<>
<button className="btn btn-quiet btn-danger" type="button" onClick={() => { removeItem(id, it); onClose(); toast(`Removed ${it.name}.`); }}><Icon name="trash" size={16} /> Remove</button>
<span className="grow" />
<button className="btn" type="submit" form="item-form">Save</button>
</>}
>
<form id="item-form" className="stack" onSubmit={save}>
<label className="field"><span>Name</span><input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={LIMITS.itemName} /></label>
<div className="two">
<label className="field"><span>Quantity</span><input className="input" value={qty} onChange={(e) => setQty(e.target.value)} maxLength={LIMITS.qty} placeholder="2, 1 lb, a dozen" /></label>
{kind === "grocery" && (
<label className="field"><span>Aisle</span>
<select className="select" value={section} onChange={(e) => setSection(e.target.value)}>
{SECTIONS.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</label>
)}
</div>
<label className="field"><span>Note</span><input className="input" value={note} onChange={(e) => setNote(e.target.value)} maxLength={LIMITS.note} placeholder="Brand, size, anything" /></label>
{kind === "grocery" && <p className="hint">Change the aisle and Lists remembers it for this item next time.</p>}
{error && <p className="notice notice-error" role="alert">{error}</p>}
</form>
</Modal>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy constants
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync