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/CardModal.tsx
// A card's details: title, list, labels, people, due date, cover, description (Markdown), checklist and
// comments. Every change saves right away and shows up for everyone on the board.
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useQuery } from "@bounded-sh/client";
import { useBoard } from "./lib/board";
import { renderMarkdown } from "./lib/markdown";
import { Modal, Popover, Icon, copy } from "./ui";
import { DueBadge, initials } from "./CardTile";
import { SITE_URL } from "../functions/lib/config";
import { COLORS, COLOR_IDS, LIMITS, parseIds, parseChecklist, randomId, type Card, type Comment } from "../functions/lib/tasks";
type Row<T> = T & { id: string };
const gap = (n: number) => ({ "--gap": `${n}px` }) as CSSProperties;
export function CardModal({ cardId, onClose }: { cardId: string; onClose: () => void }) {
const b = useBoard();
const { data: stored } = useQuery(`${b.base}/cards/${cardId}`) as { data: Row<Card> | null | undefined };
const live = b.cards.find((c) => c.id === cardId);
const card = live || (stored ? { ...stored, id: cardId } : stored);
const readOnly = Boolean(b.board?.archived);
if (card === undefined) return <Modal title="Loading card" onClose={onClose}><div className="loading"><span className="spinner" /> Opening the card...</div></Modal>;
if (!card) return (
<Modal title="Card not found" onClose={onClose}>
<p>This card was deleted, or the link is not complete.</p>
</Modal>
);
return <CardDetails card={card} readOnly={readOnly} onClose={onClose} />;
}
function CardDetails({ card, readOnly, onClose }: { card: Row<Card>; readOnly: boolean; onClose: () => void }) {
const b = useBoard();
const list = b.lists.find((l) => l.id === card.listId);
const update = (fields: Partial<Card>) => b.actions.updateCard(card.id, fields);
const url = `${SITE_URL}/b/${b.boardId}/c/${card.id}`;
const locked = readOnly || card.archived;
return (
<Modal wide onClose={onClose} label={card.title} title={<TitleEditor card={card} disabled={locked} onSave={(title) => update({ title })} />}>
{card.archived && <p className="notice" style={{ marginBottom: 16 }}>This card is archived. Restore it to edit it or put it back on the board.</p>}
<div className="card-detail">
<div className="card-main">
<p className="muted small card-where">
In list <strong>{list?.title ?? "a removed list"}</strong>{list?.archived ? " (archived)" : ""}
{!locked && <MoveCard card={card} />}
</p>
<div className="props">
<Prop label="Labels">
<span className="prop-chips">
{b.cardLabels(card).map((l) => <span key={l.id} className="lbl lbl-lg" style={{ background: COLORS[l.color]?.bg, color: COLORS[l.color]?.fg }}>{l.name || COLORS[l.color]?.name}</span>)}
{!locked && <LabelPicker card={card} />}
</span>
</Prop>
<Prop label="Assigned to">
<span className="prop-chips">
{b.cardPeople(card).map((p) => <span key={p.id} className="person"><span className="avatar">{initials(p.name)}</span>{p.name}</span>)}
{!locked && <PeoplePicker card={card} />}
</span>
</Prop>
<Prop label="Due">
<DueEditor card={card} disabled={locked} onChange={update} />
</Prop>
{!locked && (
<Prop label="Cover">
<span className="swatches" role="radiogroup" aria-label="Cover color">
<button type="button" role="radio" aria-checked={!card.cover} className="swatch swatch-none" onClick={() => update({ cover: "" })} aria-label="No cover" />
{COLOR_IDS.map((c) => <button key={c} type="button" role="radio" aria-checked={card.cover === c} className="swatch" style={{ background: COLORS[c].solid }} onClick={() => update({ cover: c })} aria-label={COLORS[c].name} />)}
</span>
</Prop>
)}
</div>
<Description card={card} disabled={locked} onSave={(description) => update({ description })} />
<Checklist card={card} disabled={locked} onSave={(checklist) => update({ checklist })} />
<Comments card={card} disabled={readOnly} />
</div>
</div>
<div className="card-actions">
<button type="button" className="btn btn-line btn-sm" onClick={() => copy(url, "Card link copied.")}><Icon name="link" size={16} /> Copy link</button>
{!readOnly && (card.archived ? (
<>
<button type="button" className="btn btn-line btn-sm" onClick={() => update({ archived: false })}>Restore card</button>
<button type="button" className="btn btn-line btn-sm btn-danger" onClick={() => { if (confirm("Delete this card and its comments for everyone? This cannot be undone.")) { void b.actions.deleteCard(card.id); onClose(); } }}><Icon name="trash" size={16} /> Delete</button>
</>
) : (
<button type="button" className="btn btn-line btn-sm" onClick={() => { void update({ archived: true }); onClose(); }}><Icon name="archive" size={16} /> Archive</button>
))}
</div>
</Modal>
);
}
function Prop({ label, children }: { label: string; children: ReactNode }) {
return <div className="prop"><span className="prop-label">{label}</span><div className="prop-value">{children}</div></div>;
}
function TitleEditor({ card, disabled, onSave }: { card: Card; disabled: boolean; onSave: (t: string) => void }) {
const [value, setValue] = useState(card.title);
const focused = useRef(false);
useEffect(() => { if (!focused.current) setValue(card.title); }, [card.title]);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (el) { el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }
}, [value]);
const save = () => {
focused.current = false;
const t = value.trim().slice(0, LIMITS.cardTitle);
if (!t) setValue(card.title);
else if (t !== card.title) onSave(t);
};
return (
<textarea ref={ref} className="title-edit" rows={1} value={value} readOnly={disabled} aria-label="Card title" maxLength={LIMITS.cardTitle}
onFocus={() => { focused.current = true; }}
onChange={(e) => setValue(e.target.value.replace(/\n/g, " "))}
onBlur={save}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLTextAreaElement).blur(); } }} />
);
}
function MoveCard({ card }: { card: Card }) {
const b = useBoard();
const [listId, setListId] = useState(card.listId);
const others = b.cards.filter((c) => c.listId === listId && c.id !== card.id);
const current = b.cards.filter((c) => c.listId === card.listId && c.id !== card.id).length;
const here = b.cards.filter((c) => c.listId === card.listId).findIndex((c) => c.id === card.id);
const [index, setIndex] = useState(listId === card.listId ? Math.max(0, here) : others.length);
return (
<Popover className="btn btn-quiet btn-sm move-btn" label="Move card" button="Move" align="start">
{(close) => (
<form className="menu-form" onSubmit={(e) => { e.preventDefault(); void b.actions.moveCard(card.id, listId, index); close(); }}>
<p className="menu-title">Move card</p>
<label className="field"><span>List</span>
<select className="select" value={listId} onChange={(e) => {
setListId(e.target.value);
setIndex(b.cards.filter((c) => c.listId === e.target.value && c.id !== card.id).length);
}}>
{b.openLists.map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
</select>
</label>
<label className="field"><span>Position</span>
<select className="select" value={index} onChange={(e) => setIndex(Number(e.target.value))}>
{Array.from({ length: (listId === card.listId ? current : others.length) + 1 }, (_, i) => <option key={i} value={i}>{i + 1}</option>)}
</select>
</label>
<button className="btn btn-sm" type="submit">Move</button>
</form>
)}
</Popover>
);
}
function LabelPicker({ card }: { card: Card }) {
const b = useBoard();
const ids = parseIds(card.labels);
const [name, setName] = useState("");
const [color, setColor] = useState(COLOR_IDS[b.labels.length % COLOR_IDS.length]);
const [editing, setEditing] = useState<string | null>(null);
const toggle = (id: string) => b.actions.updateCard(card.id, { labels: JSON.stringify(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]) });
return (
<Popover className="chip-btn" label="Labels" button={<><Icon name="tag" size={15} /> {ids.length ? "Edit" : "Add"}</>} align="start">
{() => (
<div className="menu-form picker">
<p className="menu-title">Labels</p>
{b.labels.length > 0 && (
<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)} />
) : (
<>
<label className="check pick-check">
<input type="checkbox" checked={ids.includes(l.id)} onChange={() => toggle(l.id)} />
<span className="lbl lbl-lg" style={{ background: COLORS[l.color]?.bg, color: COLORS[l.color]?.fg }}>{l.name || COLORS[l.color]?.name}</span>
</label>
<button type="button" className="icon-btn icon-btn-sm" aria-label={`Edit label ${l.name || COLORS[l.color]?.name}`} onClick={() => setEditing(l.id)}><Icon name="edit" size={15} /></button>
</>
)}
</li>
))}
</ul>
)}
<form className="stack" style={gap(8)} onSubmit={async (e) => {
e.preventDefault();
const n = name.trim().slice(0, LIMITS.labelName);
const id = await b.actions.addLabel(n, color);
if (id) { toggle(id); setName(""); setColor(COLOR_IDS[(b.labels.length + 1) % COLOR_IDS.length]); }
}}>
<input className="input input-sm" value={name} maxLength={LIMITS.labelName} placeholder="New label name" aria-label="New label name" onChange={(e) => setName(e.target.value)} />
<ColorPick value={color} onChange={setColor} />
<button className="btn btn-sm" type="submit">Create label</button>
</form>
</div>
)}
</Popover>
);
}
export function LabelEdit({ id, name, color, done }: { id: string; name: string; color: string; done: () => void }) {
const b = useBoard();
const [n, setN] = useState(name);
const [c, setC] = useState(color);
return (
<div className="stack label-edit" style={gap(8)}>
<input className="input input-sm" value={n} maxLength={LIMITS.labelName} aria-label="Label name" onChange={(e) => setN(e.target.value)} autoFocus />
<ColorPick value={c} onChange={setC} />
<div className="row" style={gap(6)}>
<button type="button" className="btn btn-sm" onClick={() => { void b.actions.updateLabel(id, { name: n.trim(), color: c }); done(); }}>Save</button>
<button type="button" className="btn btn-sm btn-quiet" onClick={done}>Cancel</button>
<button type="button" className="btn btn-sm btn-quiet btn-danger" onClick={() => { if (confirm("Delete this label from the board? Cards lose it.")) { void b.actions.deleteLabel(id); done(); } }}>Delete</button>
</div>
</div>
);
}
export function ColorPick({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return (
<span className="swatches" role="radiogroup" aria-label="Color">
{COLOR_IDS.map((c) => <button key={c} type="button" role="radio" aria-checked={value === c} className="swatch" style={{ background: COLORS[c].solid }} onClick={() => onChange(c)} aria-label={COLORS[c].name} />)}
</span>
);
}
function PeoplePicker({ card }: { card: Card }) {
const b = useBoard();
const ids = parseIds(card.assignees);
const [name, setName] = useState("");
const set = (next: string[]) => b.actions.updateCard(card.id, { assignees: JSON.stringify(next) });
const active = b.members.filter((m) => !m.removed);
return (
<Popover className="chip-btn" label="Assign people" button={<><Icon name="user" size={15} /> {ids.length ? "Edit" : "Assign"}</>} align="start">
{() => (
<div className="menu-form picker">
<p className="menu-title">Assign people</p>
{active.length > 0 ? (
<ul className="pick-list">
{active.map((m) => (
<li key={m.id}>
<label className="check pick-check">
<input type="checkbox" checked={ids.includes(m.id)} onChange={() => set(ids.includes(m.id) ? ids.filter((x) => x !== m.id) : [...ids, m.id])} />
<span className="avatar">{initials(m.name)}</span> {m.name}{m.id === b.meId ? <span className="muted"> (you)</span> : null}
</label>
</li>
))}
</ul>
) : <p className="muted small">No people yet. Add the names of the people on this board.</p>}
<form className="row" style={gap(6)} onSubmit={async (e) => {
e.preventDefault();
const n = name.trim().slice(0, LIMITS.memberName);
if (!n) return;
const id = await b.actions.addMember(n);
if (id) { void set([...ids.filter((x) => x !== id), id]); setName(""); }
}}>
<input className="input input-sm grow" value={name} maxLength={LIMITS.memberName} placeholder="Add a name" aria-label="Add a person by name" onChange={(e) => setName(e.target.value)} />
<button className="btn btn-sm" type="submit">Add</button>
</form>
</div>
)}
</Popover>
);
}
function DueEditor({ card, disabled, onChange }: { card: Card; disabled: boolean; onChange: (f: Partial<Card>) => void }) {
const day = card.due.slice(0, 10);
const time = card.due.length === 16 ? card.due.slice(11) : "";
if (disabled) return card.due ? <DueBadge card={card} /> : <span className="muted">None</span>;
return (
<span className="due-edit">
<input type="date" className="input input-sm" value={day} aria-label="Due date" onChange={(e) => onChange({ due: e.target.value ? (time ? `${e.target.value}T${time}` : e.target.value) : "" })} />
{day && <input type="time" className="input input-sm" value={time} aria-label="Due time (optional)" onChange={(e) => onChange({ due: e.target.value ? `${day}T${e.target.value}` : day })} />}
{day && (
<label className="check">
<input type="checkbox" checked={card.done} onChange={(e) => onChange({ done: e.target.checked })} /> Done
</label>
)}
{day && <button type="button" className="btn btn-quiet btn-sm" onClick={() => onChange({ due: "", done: false })}>Clear</button>}
{day && <DueBadge card={card} />}
</span>
);
}
function Description({ card, disabled, onSave }: { card: Card; disabled: boolean; onSave: (d: string) => void }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(card.description);
const html = useMemo(() => (card.description ? renderMarkdown(card.description) : ""), [card.description]);
useEffect(() => { if (!editing) setValue(card.description); }, [card.description, editing]);
return (
<section className="detail-section">
<div className="spread"><h3 className="detail-h"><Icon name="text" size={17} /> Description</h3>
{!disabled && !editing && card.description && <button type="button" className="btn btn-quiet btn-sm" onClick={() => setEditing(true)}>Edit</button>}</div>
{editing ? (
<div className="stack" style={gap(8)}>
<textarea className="textarea desc-input" value={value} autoFocus maxLength={LIMITS.description} rows={8} aria-label="Description" onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { onSave(value); setEditing(false); } }} />
<div className="spread">
<span className="hint">Markdown works: **bold**, lists, [links](https://example.com).</span>
<span className="row" style={gap(6)}>
<button type="button" className="btn btn-sm" onClick={() => { onSave(value); setEditing(false); }}>Save</button>
<button type="button" className="btn btn-sm btn-quiet" onClick={() => { setValue(card.description); setEditing(false); }}>Cancel</button>
</span>
</div>
</div>
) : card.description ? (
<div className="md prose" dangerouslySetInnerHTML={{ __html: html }} />
) : disabled ? <p className="muted small">No description.</p> : (
<button type="button" className="desc-empty" onClick={() => setEditing(true)}>Add a description: notes, links, steps...</button>
)}
</section>
);
}
function Checklist({ card, disabled, onSave }: { card: Card; disabled: boolean; onSave: (c: string) => void }) {
const items = parseChecklist(card.checklist);
const [text, setText] = useState("");
const [editing, setEditing] = useState<string | null>(null);
const [hideDone, setHideDone] = useState(false);
const save = (next: typeof items) => onSave(JSON.stringify(next));
const done = items.filter((x) => x.done).length;
const pct = items.length ? Math.round((done / items.length) * 100) : 0;
const shown = hideDone ? items.filter((x) => !x.done) : items;
return (
<section className="detail-section">
<div className="spread">
<h3 className="detail-h"><Icon name="check" size={17} /> Checklist</h3>
{done > 0 && <button type="button" className="btn btn-quiet btn-sm" onClick={() => setHideDone((h) => !h)}>{hideDone ? `Show checked (${done})` : "Hide checked"}</button>}
</div>
{items.length > 0 && (
<div className="progress" aria-label={`${done} of ${items.length} done`}>
<span className="progress-num">{pct}%</span>
<span className="progress-bar"><span style={{ width: `${pct}%` }} /></span>
</div>
)}
<ul className="checklist">
{shown.map((it) => (
<li key={it.id} className={it.done ? "is-done" : ""}>
<input type="checkbox" checked={it.done} disabled={disabled} aria-label={it.text} onChange={(e) => save(items.map((x) => (x.id === it.id ? { ...x, done: e.target.checked } : x)))} />
{editing === it.id ? (
<input className="input input-sm grow" defaultValue={it.text} autoFocus maxLength={LIMITS.checkText} aria-label="Edit item"
onBlur={(e) => { const t = e.target.value.trim(); setEditing(null); if (t && t !== it.text) save(items.map((x) => (x.id === it.id ? { ...x, text: t } : x))); }}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { e.stopPropagation(); setEditing(null); } }} />
) : (
<button type="button" className="check-text" disabled={disabled} onClick={() => setEditing(it.id)}>{it.text}</button>
)}
{!disabled && <button type="button" className="icon-btn icon-btn-sm" aria-label={`Delete ${it.text}`} onClick={() => save(items.filter((x) => x.id !== it.id))}><Icon name="close" size={14} /></button>}
</li>
))}
</ul>
{!disabled && items.length < LIMITS.checkItems && (
<form className="row check-add" style={gap(6)} onSubmit={(e) => {
e.preventDefault();
const t = text.trim().slice(0, LIMITS.checkText);
if (!t) return;
save([...items, { id: randomId(8), text: t, done: false }]);
setText("");
}}>
<input className="input input-sm grow" value={text} maxLength={LIMITS.checkText} placeholder="Add an item" aria-label="Add a checklist item" onChange={(e) => setText(e.target.value)} />
<button className="btn btn-sm btn-line" type="submit">Add</button>
</form>
)}
</section>
);
}
function timeAgo(sec: number): string {
const s = Math.max(0, Date.now() / 1000 - sec);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
return new Date(sec * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: s > 300 * 86400 ? "numeric" : undefined });
}
function Comments({ card, disabled }: { card: Card; disabled: boolean }) {
const b = useBoard();
const { data } = useQuery(`${b.base}/comments`, { limit: 1000, filter: { cardId: card.id } }) as { data: Row<Comment>[] | undefined };
const comments = useMemo(() => [...(data || [])].sort((a, x) => a.createdAt - x.createdAt), [data]);
const [text, setText] = useState("");
const [name, setName] = useState("");
const [editing, setEditing] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
const t = text.trim().slice(0, LIMITS.comment);
if (!t) return;
let who = b.myMember?.name;
if (!who) {
const n = name.trim().slice(0, LIMITS.memberName);
if (!n) return;
const id = await b.actions.addMember(n);
if (!id) return;
await b.actions.setMe(id);
who = n;
}
setBusy(true);
if (await b.actions.addComment(card.id, t, who)) setText("");
setBusy(false);
};
return (
<section className="detail-section">
<h3 className="detail-h"><Icon name="comment" size={17} /> Comments</h3>
{data === undefined ? <p className="muted small">Loading comments...</p> : (
<ul className="comments">
{comments.map((c) => (
<li key={c.id}>
<span className="avatar">{initials(c.name)}</span>
<div className="comment-body">
<p className="comment-meta"><strong>{c.name}</strong> <span className="muted">{timeAgo(c.createdAt)}{c.editedAt ? ", edited" : ""}</span></p>
{editing === c.id ? (
<CommentEdit text={c.text} onDone={(t) => { setEditing(null); if (t && t !== c.text) void b.actions.editComment(c.id, t); }} />
) : <p className="comment-text">{c.text}</p>}
{c.by === b.uid && !disabled && editing !== c.id && (
<p className="comment-tools">
<button type="button" className="link-btn" onClick={() => setEditing(c.id)}>Edit</button>
<button type="button" className="link-btn" onClick={() => { if (confirm("Delete this comment?")) void b.actions.deleteComment(c.id); }}>Delete</button>
</p>
)}
{c.by !== b.uid && b.isOwner && !disabled && (
<p className="comment-tools"><button type="button" className="link-btn" onClick={() => { if (confirm("Delete this comment?")) void b.actions.deleteComment(c.id); }}>Delete</button></p>
)}
</div>
</li>
))}
</ul>
)}
{!disabled && (
<form className="stack comment-form" style={gap(8)} onSubmit={(e) => { e.preventDefault(); void submit(); }}>
{!b.myMember && <input className="input input-sm" value={name} maxLength={LIMITS.memberName} placeholder="Your name" aria-label="Your name" onChange={(e) => setName(e.target.value)} />}
<textarea className="textarea comment-input" rows={2} value={text} maxLength={LIMITS.comment} placeholder="Write a comment" aria-label="Write a comment" onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void submit(); }} />
<div><button className="btn btn-sm" type="submit" disabled={busy || !text.trim() || (!b.myMember && !name.trim())}>Comment</button></div>
</form>
)}
</section>
);
}
function CommentEdit({ text, onDone }: { text: string; onDone: (t: string) => void }) {
const [value, setValue] = useState(text);
return (
<div className="stack" style={gap(6)}>
<textarea className="textarea comment-input" value={value} autoFocus maxLength={LIMITS.comment} aria-label="Edit comment" onChange={(e) => setValue(e.target.value)} />
<span className="row" style={gap(6)}>
<button type="button" className="btn btn-sm" onClick={() => onDone(value.trim())}>Save</button>
<button type="button" className="btn btn-sm btn-quiet" onClick={() => onDone(text)}>Cancel</button>
</span>
</div>
);
}
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