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/Sheets.tsx
// The list's sheets: Share (link, people, assistants, admin link) and Settings (name, aisles, clear,
// leave or delete). People and link changes need a connection; the rest work offline.
import { useState, type FormEvent } from "react";
import { navigate } from "./lib/router";
import { SERVED_APP_ID, explain } from "./lib/bounded";
import {
  removeMember, changeLink, leaveList, deleteList, updateList, setMyName,
  type ListDoc, type Member,
} from "./lib/data";
import { Modal, copy, share, toast, Icon } from "./ui";
import { SECTIONS, listKey, sectionLabel, sectionOrder, LIMITS, type Item, type Known, type SectionId } from "../functions/lib/lists";

const API_BASE = `https://functions.openapps.xyz/apps/${SERVED_APP_ID}/api`;
const joined = (at: number) => new Date(at * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });

export function ShareSheet({ id, uid, list, secret, members, owners, isOwner, ownerKey, onClose }: {
  id: string; uid: string | null; list: ListDoc; secret: string | null; members: Member[]; owners: Set<string>;
  isOwner: boolean; ownerKey?: string; onClose: () => void;
}) {
  const url = secret ? `${location.origin}/l/${id}#${secret}` : null;
  const key = secret ? listKey(id, secret) : null;
  const mine = members.find((m) => m.id === uid);
  const [name, setName] = useState(mine?.name || "");
  const [removing, setRemoving] = useState<Member | null>(null);
  const [newLink, setNewLink] = useState(true);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");

  const run = async (work: () => Promise<unknown>, done: string) => {
    setBusy(true);
    setError("");
    try {
      await work();
      toast(done);
      setRemoving(null);
    } catch (err) {
      setError(explain(err));
    } finally {
      setBusy(false);
    }
  };
  const saveName = (e: FormEvent) => {
    e.preventDefault();
    if (uid) { setMyName(id, uid, name); toast("Saved your name."); }
  };

  return (
    <Modal title="Share this list" onClose={onClose}>
      <div className="sheet">
        <section>
          <p>Anyone with this link can see {list.name} and change it, live. No sign-up and nothing to install.</p>
          {url ? (
            <>
              <div className="link-row"><code className="link-code">{url}</code></div>
              <div className="row">
                <button className="btn" type="button" onClick={() => share(url, list.name, `Our ${list.name} list`)}><Icon name="share" size={16} /> Send the link</button>
                <button className="btn btn-line" type="button" onClick={() => copy(url, "Link copied.")}>Copy</button>
              </div>
            </>
          ) : <p className="notice">The link appears once this list has synced. Connect to the internet for a moment.</p>}
        </section>

        <section>
          <h3 className="h3">On this list</h3>
          <ul className="people">
            {members.map((m) => (
              <li key={m.id}>
                <span className="grow">
                  <strong>{m.name || (m.id === uid ? "You" : "Someone")}</strong>
                  {m.id === uid && m.name ? <span className="muted"> (you)</span> : null}
                  {owners.has(m.id) ? <span className="chip">manages</span> : null}
                  <small className="muted"> joined {joined(m.at)}</small>
                </span>
                {isOwner && m.id !== uid && (
                  <button className="btn btn-sm btn-quiet btn-danger" type="button" onClick={() => setRemoving(m)}>Remove</button>
                )}
              </li>
            ))}
          </ul>
          {removing && (
            <div className="confirm">
              <p>Remove {removing.name || "this person"} from {list.name}?</p>
              <label className="check"><input type="checkbox" checked={newLink} onChange={(e) => setNewLink(e.target.checked)} /> Also change the link, so they cannot open the list again with the old one</label>
              {owners.has(removing.id) && <p className="muted">They manage this list, so they can still come back with the admin link. The admin link cannot be changed.</p>}
              <div className="row">
                <button className="btn btn-sm" type="button" disabled={busy} onClick={() => run(() => removeMember(id, removing.id, newLink), newLink ? "Removed. The link changed: send the new one to the others if they need it." : "Removed.")}>Remove</button>
                <button className="btn btn-sm btn-quiet" type="button" onClick={() => setRemoving(null)}>Cancel</button>
              </div>
            </div>
          )}
          <form className="name-row" onSubmit={saveName}>
            <label className="field grow"><span>Your name, so the others know who is on the list</span>
              <input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={LIMITS.memberName} placeholder="Sam" />
            </label>
            <button className="btn btn-line" type="submit">Save</button>
          </form>
        </section>

        {key && (
          <section>
            <h3 className="h3">For assistants</h3>
            <p className="muted">Give this key to an AI or voice assistant so it can read the list and add to it ("add milk and eggs"). It works like the link: anyone with it can change the list.</p>
            <div className="link-row"><code className="link-code">{key}</code><button className="btn btn-sm btn-line" type="button" onClick={() => copy(key, "Key copied.")}>Copy</button></div>
            <pre className="code small-code">{`curl -X POST ${API_BASE}/list/items \\
  -H 'x-list-key: ${key}' \\
  -H 'content-type: application/json' \\
  -d '{"text": "milk and eggs"}'`}</pre>
            <p className="hint">Every route is in <a href="/llms.txt" target="_blank" rel="noopener">llms.txt</a> and <a href="/openapi.json" target="_blank" rel="noopener">openapi.json</a>.</p>
          </section>
        )}

        {isOwner && (
          <section>
            <h3 className="h3">You manage this list</h3>
            {ownerKey ? (
              <>
                <p className="muted">The admin link lets you remove people and delete the list from any device. Anyone with it can do that, so keep it to yourself, for example in your notes.</p>
                <div className="link-row"><code className="link-code">{`${location.origin}/l/${id}/admin#${ownerKey}`}</code><button className="btn btn-sm btn-line" type="button" onClick={() => copy(`${location.origin}/l/${id}/admin#${ownerKey}`, "Admin link copied.")}>Copy</button></div>
              </>
            ) : <p className="muted">The admin link is on the device that made this list.</p>}
            <p className="muted">Changing the link turns off the old link and the old assistant key. Everyone already on the list stays.</p>
            <button className="btn btn-line" type="button" disabled={busy} onClick={() => run(() => changeLink(id), "New link made. Send it to anyone who still needs to join.")}>Change the link</button>
          </section>
        )}
        {error && <p className="notice notice-error" role="alert">{error}</p>}
      </div>
    </Modal>
  );
}

export function SettingsSheet({ id, uid, list, items, known, isOwner, onClose, onClear }: {
  id: string; uid: string | null; list: ListDoc; items: Item[]; known: Record<string, Known>; isOwner: boolean; onClose: () => void; onClear: () => void;
}) {
  const [view, setView] = useState<"main" | "aisles" | "delete">("main");
  const [name, setName] = useState(list.name);
  const [order, setOrder] = useState<SectionId[]>(() => sectionOrder(list.order));
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const grocery = list.kind !== "todo";
  const checked = items.filter((i) => i.checked).length;

  const saveName = (e: FormEvent) => {
    e.preventDefault();
    const n = name.trim().slice(0, LIMITS.listName);
    if (n && n !== list.name) { updateList(id, { name: n }); toast("Renamed."); }
  };
  const move = (i: number, by: number) => setOrder((o) => {
    const next = [...o];
    [next[i], next[i + by]] = [next[i + by], next[i]];
    return next;
  });
  const online = async (work: () => Promise<unknown>) => {
    setBusy(true);
    setError("");
    try {
      await work();
      onClose();
      navigate("/lists", true);
    } catch (err) {
      setError(explain(err));
      setBusy(false);
    }
  };

  if (view === "aisles") {
    return (
      <Modal title="Arrange aisles" onClose={onClose} footer={<>
        <button className="btn btn-quiet" type="button" onClick={() => setOrder(SECTIONS.map((s) => s.id))}>Reset</button>
        <span className="grow" />
        <button className="btn" type="button" onClick={() => { updateList(id, { order: JSON.stringify(order) }); toast("Aisles saved."); onClose(); }}>Save</button>
      </>}>
        <p className="muted">Put the sections in the order you walk {list.name}. Each list keeps its own order.</p>
        <ol className="aisles">
          {order.map((s, i) => (
            <li key={s}>
              <span className="grow">{sectionLabel(s)}</span>
              <button className="icon-btn" type="button" disabled={i === 0} onClick={() => move(i, -1)} aria-label={`Move ${sectionLabel(s)} up`}><Icon name="up" /></button>
              <button className="icon-btn" type="button" disabled={i === order.length - 1} onClick={() => move(i, 1)} aria-label={`Move ${sectionLabel(s)} down`}><Icon name="down" /></button>
            </li>
          ))}
        </ol>
      </Modal>
    );
  }

  if (view === "delete") {
    return (
      <Modal title={`Delete ${list.name}?`} onClose={onClose}>
        <div className="sheet">
          <p>This deletes the list and its items for everyone on it. It cannot be undone.</p>
          {error && <p className="notice notice-error" role="alert">{error}</p>}
          <div className="row">
            <button className="btn btn-danger-solid" type="button" disabled={busy} onClick={() => online(() => deleteList(id, items.map((i) => i.id), Object.keys(known)))}>{busy ? "Deleting..." : "Delete for everyone"}</button>
            <button className="btn btn-quiet" type="button" onClick={() => setView("main")}>Cancel</button>
          </div>
        </div>
      </Modal>
    );
  }

  return (
    <Modal title="List settings" onClose={onClose}>
      <div className="sheet">
        <form className="name-row" onSubmit={saveName}>
          <label className="field grow"><span>Name</span><input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={LIMITS.listName} /></label>
          <button className="btn btn-line" type="submit">Save</button>
        </form>
        <label className="check">
          <input type="checkbox" checked={grocery} onChange={(e) => updateList(id, { kind: e.target.checked ? "grocery" : "todo" })} />
          Sort items by store aisle
        </label>
        <div className="menu">
          {grocery && <button type="button" onClick={() => setView("aisles")}>Arrange aisles<small>Match the order of your store</small></button>}
          <button type="button" disabled={!checked} onClick={onClear}>Clear checked items<small>{checked ? `${checked} checked off` : "Nothing checked off"}</small></button>
          {isOwner
            ? <button type="button" className="danger" onClick={() => setView("delete")}>Delete this list<small>For everyone on it</small></button>
            : <button type="button" className="danger" disabled={busy || !uid} onClick={() => uid && online(() => leaveList(id, uid))}>Leave this list<small>It disappears from this device. You can rejoin with the link.</small></button>}
        </div>
        {error && <p className="notice notice-error" role="alert">{error}</p>}
      </div>
    </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