lists-free.openapps.xyz

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.
Owned app · rev ebccaf0b13fe
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.
← All files
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
ebccaf0b13 · 17 hours ago
Bind agent address in policy constants
519855687c · 19 hours ago
Use OpenApps service URLs and add contribution strip
2c353edfdc · 19 hours ago
Bounded source sync
58ac7e08ec · 1 day ago
Bounded source sync
150962f3d9 · 1 day ago
Bounded source sync
e52e4676b9 · 1 day ago