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 f6f2112e77bc8c011e38c83a
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/Panels.tsx
// Board panels: settings (share link, and for owners the admin link, rename, archive, removing people
// and deleting), labels, people, and archived cards and lists.
import { useState, type CSSProperties } from "react";
import { useQuery } from "@bounded-sh/client";
import { useBoard, useArchivedCards } from "./lib/board";
import { explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { boardKey } from "./lib/mine";
import { Modal, Icon, copy, toast } from "./ui";
import { LabelEdit, ColorPick } from "./CardModal";
import { initials } from "./CardTile";
import { SITE_URL } from "../functions/lib/config";
import { COLORS, COLOR_IDS, LIMITS } from "../functions/lib/tasks";
const gap = (n: number) => ({ "--gap": `${n}px` }) as CSSProperties;
const when = (sec: number) => new Date(sec * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
export function SettingsPanel({ onClose }: { onClose: () => void }) {
const b = useBoard();
const url = `${SITE_URL}/b/${b.boardId}`;
const key = boardKey(b.boardId);
const adminUrl = key ? `${SITE_URL}/b/${b.boardId}/admin#k=${key}` : null;
return (
<Modal title="Board settings" onClose={onClose}>
<div className="stack" style={gap(26)}>
<section className="stack" style={gap(8)}>
<h3 className="detail-h">Board link</h3>
<p className="muted small">Anyone with this link can open the board and edit it. Nobody needs an account.</p>
<div className="row" style={gap(8)}>
<code className="link-code">{url}</code>
<button className="btn btn-sm" type="button" onClick={() => copy(url, "Board link copied.")}>Copy</button>
</div>
</section>
{b.isOwner ? <OwnerSettings adminUrl={adminUrl} onClose={onClose} /> : (
<section className="stack" style={gap(8)}>
<h3 className="detail-h">Owner tools</h3>
<p className="small">Renaming, archiving and deleting the board, and removing people, need the board's admin link. If you made this board on another device, open your admin link here. It looks like <span className="mono">{SITE_URL.replace("https://", "")}/b/.../admin#k=...</span></p>
</section>
)}
</div>
</Modal>
);
}
function OwnerSettings({ adminUrl, onClose }: { adminUrl: string | null; onClose: () => void }) {
const b = useBoard();
const [title, setTitle] = useState(b.board!.title);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const { data: joins } = useQuery(`${b.base}/joins`, { limit: 1000 }) as { data: { id: string; memberId?: string | null; via?: string | null; at: number }[] | undefined };
const { data: blocks } = useQuery(`${b.base}/blocks`, { limit: 1000 }) as { data: { id: string; at: number }[] | undefined };
const nameOf = (memberId?: string | null) => (memberId ? b.members.find((m) => m.id === memberId)?.name : null);
const run = async (fn: () => Promise<unknown>, done: string) => {
setBusy(true);
try { await fn(); toast(done); } catch (err) { toast(explain(err)); }
setBusy(false);
};
return (
<>
<section className="stack" style={gap(8)}>
<h3 className="detail-h">Admin link</h3>
{adminUrl ? (
<>
<p className="muted small">This link lets someone rename, archive or delete the board and remove people. Keep it private. Open it on another device to manage the board there.</p>
<div className="row" style={gap(8)}>
<code className="link-code">{adminUrl}</code>
<button className="btn btn-sm btn-line" type="button" onClick={() => copy(adminUrl, "Admin link copied. Keep it private.")}>Copy</button>
</div>
</>
) : <p className="muted small">This device manages the board. The admin link is saved on the device that made the board.</p>}
</section>
<form className="stack" style={gap(8)} onSubmit={(e) => { e.preventDefault(); const t = title.trim(); if (t) void run(() => b.actions.renameBoard(t.slice(0, LIMITS.boardTitle)), "Board renamed."); }}>
<h3 className="detail-h">Name</h3>
<div className="row" style={gap(8)}>
<input className="input grow" value={title} maxLength={LIMITS.boardTitle} aria-label="Board name" onChange={(e) => setTitle(e.target.value)} />
<button className="btn btn-sm" type="submit" disabled={busy || !title.trim() || title.trim() === b.board!.title}>Rename</button>
</div>
</form>
<section className="stack" style={gap(8)}>
<h3 className="detail-h">People and devices with access</h3>
<p className="muted small">Each device that opened the board link. Removing one takes away its access and stops it from opening the board again. The link itself still works, so share it only with people you trust.</p>
{joins === undefined ? <p className="muted small">Loading...</p> : (
<ul className="device-list">
{[...joins].sort((a, x) => a.at - x.at).map((j) => (
<li key={j.id}>
<span className="avatar">{j.via === "api" ? "AI" : initials(nameOf(j.memberId) || "?")}</span>
<span className="grow">
<strong>{j.via === "api" ? "AI assistants (the API)" : nameOf(j.memberId) || "No name picked"}</strong>
<span className="muted small"> · since {when(j.at)}{j.id === b.uid ? " · this device" : ""}</span>
</span>
{j.id !== b.uid && j.via !== "api" && (
<button type="button" className="btn btn-sm btn-line btn-danger" disabled={busy} onClick={() => { if (confirm(`Remove ${nameOf(j.memberId) || "this device"} from the board?`)) void run(() => b.actions.removeDevice(j.id), "Removed."); }}>Remove</button>
)}
</li>
))}
</ul>
)}
{blocks && blocks.length > 0 && (
<>
<p className="small"><strong>Removed devices</strong></p>
<ul className="device-list">
{blocks.map((x) => (
<li key={x.id}>
<span className="grow muted small">Removed {when(x.at)}</span>
<button type="button" className="btn btn-sm btn-quiet" disabled={busy} onClick={() => void run(() => b.actions.unblock(x.id), "That device can open the board again.")}>Let back in</button>
</li>
))}
</ul>
</>
)}
</section>
<section className="stack" style={gap(8)}>
<h3 className="detail-h">Archive</h3>
<p className="muted small">{b.board!.archived ? "The board is archived: everyone can still read it, nobody can change it." : "Archiving keeps the board readable for everyone but stops all changes. You can restore it."}</p>
<div>
<button type="button" className="btn btn-sm btn-line" disabled={busy} onClick={() => void run(() => b.actions.setBoardArchived(!b.board!.archived), b.board!.archived ? "Board restored." : "Board archived.")}>
{b.board!.archived ? "Restore board" : "Archive board"}
</button>
</div>
</section>
<section className="stack danger-zone" style={gap(8)}>
<h3 className="detail-h">Delete</h3>
{!confirming ? (
<div><button type="button" className="btn btn-sm btn-line btn-danger" onClick={() => setConfirming(true)}><Icon name="trash" size={16} /> Delete board</button></div>
) : (
<>
<p className="small">Delete <strong>{b.board!.title}</strong> and every list, card and comment on it, for everyone? This cannot be undone. Export it first if you might want it back.</p>
<div className="row" style={gap(8)}>
<button type="button" className="btn btn-sm btn-danger-solid" disabled={busy} onClick={() => void (async () => {
setBusy(true);
try {
await b.actions.deleteBoard();
onClose();
toast("Board deleted.");
navigate("/");
} catch (err) { toast(explain(err)); setBusy(false); }
})()}>{busy ? "Deleting..." : "Delete for everyone"}</button>
<button type="button" className="btn btn-sm btn-quiet" onClick={() => setConfirming(false)}>Cancel</button>
</div>
</>
)}
</section>
</>
);
}
export function LabelsPanel({ onClose }: { onClose: () => void }) {
const b = useBoard();
const [editing, setEditing] = useState<string | null>(null);
const [name, setName] = useState("");
const [color, setColor] = useState(COLOR_IDS[b.labels.length % COLOR_IDS.length]);
const locked = Boolean(b.board?.archived);
return (
<Modal title="Labels" onClose={onClose}>
<div className="stack" style={gap(16)}>
{b.labels.length ? (
<ul className="pick-list">
{b.labels.map((l) => (
<li key={l.id}>
{editing === l.id ? <LabelEdit id={l.id} name={l.name} color={l.color} done={() => setEditing(null)} /> : (
<>
<span className="lbl lbl-lg grow-0" style={{ background: COLORS[l.color]?.bg, color: COLORS[l.color]?.fg }}>{l.name || COLORS[l.color]?.name}</span>
<span className="muted small grow">{b.cards.filter((c) => c.labels.includes(l.id)).length} cards</span>
{!locked && <button type="button" className="btn btn-sm btn-quiet" onClick={() => setEditing(l.id)}>Edit</button>}
</>
)}
</li>
))}
</ul>
) : <p className="muted">No labels yet.</p>}
{!locked && (
<form className="stack" style={gap(8)} onSubmit={async (e) => {
e.preventDefault();
if (await b.actions.addLabel(name.trim().slice(0, LIMITS.labelName), color)) { setName(""); setColor(COLOR_IDS[(b.labels.length + 1) % COLOR_IDS.length]); }
}}>
<h3 className="detail-h">New label</h3>
<input className="input" value={name} maxLength={LIMITS.labelName} placeholder="Name, like Urgent (or leave empty for a color only)" aria-label="Label name" onChange={(e) => setName(e.target.value)} />
<ColorPick value={color} onChange={setColor} />
<div><button className="btn btn-sm" type="submit">Create label</button></div>
</form>
)}
</div>
</Modal>
);
}
export function PeoplePanel({ onClose }: { onClose: () => void }) {
const b = useBoard();
const [name, setName] = useState("");
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const { data: joins } = useQuery(b.isOwner ? `${b.base}/joins` : null, { limit: 1000 }) as { data: { id: string; memberId?: string | null }[] | undefined };
const active = b.members.filter((m) => !m.removed);
const locked = Boolean(b.board?.archived);
return (
<Modal title="People" onClose={onClose}>
<div className="stack" style={gap(16)}>
<p className="muted small">People are names on this board, for assigning cards and signing comments. They are not accounts: anyone with the board link can pick a name.</p>
{active.length ? (
<ul className="pick-list">
{active.map((m) => (
<li key={m.id}>
<span className="avatar">{initials(m.name)}</span>
{editing === m.id ? (
<form className="row grow" style={gap(6)} onSubmit={(e) => { e.preventDefault(); const t = draft.trim(); if (t) void b.actions.renameMember(m.id, t.slice(0, LIMITS.memberName)); setEditing(null); }}>
<input className="input input-sm grow" value={draft} autoFocus maxLength={LIMITS.memberName} aria-label="Name" onChange={(e) => setDraft(e.target.value)} />
<button className="btn btn-sm" type="submit">Save</button>
</form>
) : (
<>
<span className="grow">{m.name}{m.id === b.meId ? <span className="muted"> (you)</span> : null}</span>
{!locked && <button type="button" className="btn btn-sm btn-quiet" onClick={() => { setEditing(m.id); setDraft(m.name); }}>Rename</button>}
{b.isOwner && !locked && m.id !== b.meId && (
<button type="button" className="btn btn-sm btn-quiet btn-danger" onClick={() => {
if (!confirm(`Remove ${m.name}? Their cards are unassigned, and the devices that picked this name lose access to the board.`)) return;
void b.actions.removeMember(m.id, joins || []).then((n) => toast(n ? `${m.name} removed, with ${n} device${n === 1 ? "" : "s"}.` : `${m.name} removed.`), (err) => toast(explain(err)));
}}>Remove</button>
)}
</>
)}
</li>
))}
</ul>
) : <p className="muted">No people yet.</p>}
{!locked && (
<form className="row" style={gap(8)} onSubmit={async (e) => {
e.preventDefault();
const n = name.trim().slice(0, LIMITS.memberName);
if (n && (await b.actions.addMember(n))) setName("");
}}>
<input className="input grow" value={name} maxLength={LIMITS.memberName} placeholder="Add a name" aria-label="Add a person" onChange={(e) => setName(e.target.value)} />
<button className="btn btn-sm" type="submit">Add</button>
</form>
)}
</div>
</Modal>
);
}
export function ArchivePanel({ onClose, onOpen }: { onClose: () => void; onOpen: (id: string) => void }) {
const b = useBoard();
const cards = useArchivedCards(b.boardId, true);
const lists = b.lists.filter((l) => l.archived);
const locked = Boolean(b.board?.archived);
return (
<Modal title="Archived items" onClose={onClose}>
<div className="stack" style={gap(20)}>
<section className="stack" style={gap(8)}>
<h3 className="detail-h">Cards</h3>
{cards === undefined ? <p className="muted small">Loading...</p> : cards.length ? (
<ul className="pick-list">
{[...cards].sort((a, x) => a.title.localeCompare(x.title)).map((c) => (
<li key={c.id}>
<button type="button" className="link-btn grow left" onClick={() => onOpen(c.id)}>{c.title}</button>
<span className="muted small">{b.lists.find((l) => l.id === c.listId)?.title}</span>
{!locked && <button type="button" className="btn btn-sm btn-quiet" onClick={() => void b.actions.updateCard(c.id, { archived: false })}>Restore</button>}
{!locked && <button type="button" className="btn btn-sm btn-quiet btn-danger" onClick={() => { if (confirm(`Delete "${c.title}" and its comments for everyone? This cannot be undone.`)) void b.actions.deleteCard(c.id); }}>Delete</button>}
</li>
))}
</ul>
) : <p className="muted small">No archived cards.</p>}
</section>
<section className="stack" style={gap(8)}>
<h3 className="detail-h">Lists</h3>
{lists.length ? (
<ul className="pick-list">
{lists.map((l) => (
<li key={l.id}>
<span className="grow">{l.title}</span>
{!locked && <button type="button" className="btn btn-sm btn-quiet" onClick={() => void b.actions.updateList(l.id, { archived: false })}>Restore</button>}
</li>
))}
</ul>
) : <p className="muted small">No archived lists.</p>}
</section>
</div>
</Modal>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync