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/BoardPage.tsx
// A board: joins it on this device (the link is the permission), claims it when opened with the admin
// link, then shows the board, list or calendar view with search and filters, and a card when one is open.
import { useEffect, useState } from "react";
import { BoardContext, joinBoard, claimBoard, useBoardState, useBoard } from "./lib/board";
import { explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { rememberBoard } from "./lib/mine";
import { NO_FILTER, NOBODY, filterCount, type DueFilter, type Filter } from "./lib/filter";
import { Icon, Popover, share, toast, download } from "./ui";
import { Kanban } from "./Kanban";
import { TableView } from "./TableView";
import { CalendarView } from "./CalendarView";
import { CardModal } from "./CardModal";
import { SettingsPanel, LabelsPanel, PeoplePanel, ArchivePanel } from "./Panels";
import { initials } from "./CardTile";
import { SITE_URL } from "../functions/lib/config";
import { COLORS, boardCsv, exportBoard, fileName } from "../functions/lib/tasks";
type View = "board" | "list" | "calendar";
type Panel = "settings" | "labels" | "people" | "archive" | null;
let cardPushed = false;
export function BoardPage({ boardId, cardId, admin }: { boardId: string; cardId: string | null; admin: boolean }) {
const [state, setState] = useState<{ status: "joining" | "missing" | "removed" | "ready" | "error"; uid?: string; error?: string; claim?: "ok" | "bad" }>({ status: "joining" });
useEffect(() => {
let off = false;
(async () => {
try {
const { uid, ok, removed } = await joinBoard(boardId);
if (!ok) { if (!off) setState({ status: removed ? "removed" : "missing" }); return; }
let claim: "ok" | "bad" | undefined;
const key = new URLSearchParams(location.hash.slice(1)).get("k");
if (key) {
claim = (await claimBoard(boardId, uid, key)) ? "ok" : "bad";
if (claim === "ok") rememberBoard({ id: boardId, key });
// Drop the key from the address bar so it is not shared by accident.
history.replaceState(null, "", `/b/${boardId}${admin ? "/admin" : ""}`);
}
if (!off) setState({ status: "ready", uid, claim });
} catch (err) {
if (!off) setState({ status: "error", error: explain(err) });
}
})();
return () => { off = true; };
}, [boardId]); // eslint-disable-line react-hooks/exhaustive-deps
if (state.status === "joining") return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Opening the board...</div></div>;
if (state.status === "missing" || state.status === "removed") return (
<div className="wrap page-pad narrow">
<h1 className="h1">{state.status === "removed" ? "You were removed from this board." : "This board does not exist."}</h1>
<p className="lede" style={{ marginTop: 14 }}>{state.status === "removed" ? "The board's owner removed this device. Ask them if you think that was a mistake." : "Check the link you were sent: board links are long and every character counts. The board may also have been deleted."}</p>
<a className="btn" style={{ marginTop: 24 }} href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a new board</a>
</div>
);
if (state.status === "error") return <div className="wrap page-pad narrow"><p className="notice notice-error">{state.error}</p></div>;
return <Loaded boardId={boardId} uid={state.uid!} cardId={cardId} admin={admin} claim={state.claim} />;
}
function Loaded({ boardId, uid, cardId, admin, claim }: { boardId: string; uid: string; cardId: string | null; admin: boolean; claim?: "ok" | "bad" }) {
const b = useBoardState(boardId, uid);
return (
<BoardContext.Provider value={b}>
<BoardScreen cardId={cardId} admin={admin} claim={claim} />
</BoardContext.Provider>
);
}
function readView(boardId: string): View {
try {
const v = localStorage.getItem(`tasks:view:${boardId}`);
return v === "list" || v === "calendar" ? v : "board";
} catch { return "board"; }
}
function BoardScreen({ cardId, admin, claim }: { cardId: string | null; admin: boolean; claim?: "ok" | "bad" }) {
const b = useBoard();
const [view, setViewState] = useState<View>(() => readView(b.boardId));
const [filter, setFilter] = useState<Filter>(NO_FILTER);
const [panel, setPanel] = useState<Panel>(admin ? "settings" : null);
const [isNew, setIsNew] = useState(() => { try { return localStorage.getItem(`tasks:new:${b.boardId}`) === "1"; } catch { return false; } });
const setView = (v: View) => { setViewState(v); try { localStorage.setItem(`tasks:view:${b.boardId}`, v); } catch { /* fine */ } };
useEffect(() => {
if (!b.board) return;
document.title = `${b.board.title} | Tasks`;
rememberBoard({ id: b.boardId, title: b.board.title });
}, [b.board?.title]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (claim === "ok") toast("This device can now manage the board.");
if (claim === "bad") toast("That admin link did not work. Check that you copied all of it.");
}, [claim]);
if (!b.ready) return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Loading the board...</div></div>;
if (!b.board) return (
<div className="wrap page-pad narrow">
<h1 className="h1">This board was deleted.</h1>
<a className="btn" style={{ marginTop: 24 }} href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a new board</a>
</div>
);
const url = `${SITE_URL}/b/${b.boardId}`;
const openCard = (id: string) => { cardPushed = true; navigate(`/b/${b.boardId}/c/${id}`, { keepScroll: true }); };
// Closing goes back when this page opened the card, so the back button does not reopen it.
const closeCard = () => {
if (cardPushed) { cardPushed = false; history.back(); } else navigate(`/b/${b.boardId}`, { keepScroll: true, replace: true });
};
const dismissNew = () => { try { localStorage.removeItem(`tasks:new:${b.boardId}`); } catch { /* fine */ } setIsNew(false); };
const exportData = () => ({ board: { ...b.board!, id: b.boardId }, lists: b.lists, cards: b.cards, labels: b.labels, members: b.members });
return (
<div className={`board-page view-${view}`}>
<header className="board-head">
<div className="board-head-inner">
<div className="board-title-block">
<h1 className="board-title">{b.board.title}</h1>
{b.board.archived && <span className="chip">Archived, read only</span>}
</div>
<div className="board-actions">
<YouPicker />
<button className="btn btn-sm" type="button" onClick={() => share(url, b.board!.title)}><Icon name="link" size={16} /> Share</button>
<Popover className="icon-btn" label="Board menu" button={<Icon name="more" />}>
{(close) => (
<div className="menu">
<button type="button" onClick={() => { setPanel("settings"); close(); }}><Icon name="lock" /> Board settings{b.isOwner ? "" : "..."}</button>
<button type="button" onClick={() => { setPanel("labels"); close(); }}><Icon name="tag" /> Labels</button>
<button type="button" onClick={() => { setPanel("people"); close(); }}><Icon name="user" /> People</button>
<button type="button" onClick={() => { setPanel("archive"); close(); }}><Icon name="archive" /> Archived items</button>
<hr />
<button type="button" onClick={() => { download(fileName(b.board!.title, "json"), JSON.stringify(exportBoard(exportData()), null, 2), "application/json"); close(); }}><Icon name="download" /> Export JSON</button>
<button type="button" onClick={() => { download(fileName(b.board!.title, "csv"), boardCsv(exportData()), "text/csv"); close(); }}><Icon name="download" /> Export CSV</button>
</div>
)}
</Popover>
</div>
</div>
{isNew && (
<div className="notice notice-ok invite">
<span>Board ready. Send the link to your team: anyone with it can edit, no account needed. Keep your admin link private.</span>
<span className="row" style={{ gap: 8 }}>
<button className="btn btn-sm" type="button" onClick={() => share(url, b.board!.title)}>Share link</button>
<button className="btn btn-sm btn-line" type="button" onClick={() => setPanel("settings")}>Admin link</button>
<button className="btn btn-sm btn-quiet" type="button" onClick={dismissNew}>Dismiss</button>
</span>
</div>
)}
{b.board.archived && (
<p className="notice archived-note">This board is archived, so nobody can change it. {b.isOwner ? <button type="button" className="link-btn" onClick={() => void b.actions.setBoardArchived(false)}>Restore the board</button> : "Its owner can restore it."}</p>
)}
<Toolbar view={view} setView={setView} filter={filter} setFilter={setFilter} />
</header>
<main className="board-main">
{view === "board" && <Kanban filter={filter} onOpen={openCard} />}
{view === "list" && <TableView filter={filter} onOpen={openCard} />}
{view === "calendar" && <CalendarView filter={filter} onOpen={openCard} />}
</main>
{cardId && <CardModal cardId={cardId} onClose={closeCard} key={cardId} />}
{panel === "settings" && <SettingsPanel onClose={() => setPanel(null)} />}
{panel === "labels" && <LabelsPanel onClose={() => setPanel(null)} />}
{panel === "people" && <PeoplePanel onClose={() => setPanel(null)} />}
{panel === "archive" && <ArchivePanel onClose={() => setPanel(null)} onOpen={(id) => { setPanel(null); openCard(id); }} />}
</div>
);
}
function YouPicker() {
const b = useBoard();
const [name, setName] = useState("");
const active = b.members.filter((m) => !m.removed);
return (
<Popover className="btn btn-line btn-sm you-btn" label="Who are you on this board?" button={b.myMember ? <><span className="avatar">{initials(b.myMember.name)}</span><span className="you-name">{b.myMember.name}</span></> : <><Icon name="user" size={16} /> Your name</>}>
{(close) => (
<div className="menu-form picker">
<p className="menu-title">Who are you?</p>
<p className="muted small">Your name signs your comments. It is saved for this board on this device.</p>
{active.length > 0 && (
<ul className="pick-list">
{active.map((m) => (
<li key={m.id}>
<label className="check pick-check">
<input type="radio" name="me" checked={b.meId === m.id} onChange={() => { void b.actions.setMe(m.id); close(); }} />
<span className="avatar">{initials(m.name)}</span> {m.name}
</label>
</li>
))}
</ul>
)}
{!b.board?.archived && (
<form className="row" style={{ gap: 6 }} onSubmit={async (e) => {
e.preventDefault();
const n = name.trim().slice(0, 40);
if (!n) return;
const id = await b.actions.addMember(n);
if (id) { await b.actions.setMe(id); setName(""); close(); }
}}>
<input className="input input-sm grow" value={name} maxLength={40} placeholder={active.length ? "Not listed? Add your name" : "Your name"} aria-label="Your name" onChange={(e) => setName(e.target.value)} />
<button className="btn btn-sm" type="submit">Save</button>
</form>
)}
</div>
)}
</Popover>
);
}
const DUE_OPTIONS: [DueFilter, string][] = [["overdue", "Overdue"], ["today", "Due today"], ["week", "Due in the next 7 days"], ["none", "No due date"], ["done", "Marked done"]];
function Toolbar({ view, setView, filter, setFilter }: { view: View; setView: (v: View) => void; filter: Filter; setFilter: (f: Filter) => void }) {
const b = useBoard();
const n = filterCount(filter) - (filter.q.trim() ? 1 : 0);
const toggle = <K extends "labels" | "people" | "due">(k: K, v: Filter[K][number]) =>
setFilter({ ...filter, [k]: (filter[k] as string[]).includes(v as string) ? (filter[k] as string[]).filter((x) => x !== v) : [...filter[k], v] });
const active = b.members.filter((m) => !m.removed);
const shown = b.cards.filter((c) => b.openLists.some((l) => l.id === c.listId));
return (
<div className="toolbar">
<div className="segmented views" role="tablist" aria-label="View">
{([["board", "Board", "board"], ["list", "List", "list"], ["calendar", "Calendar", "calendar"]] as const).map(([v, label, icon]) => (
<button key={v} type="button" role="tab" aria-selected={view === v} onClick={() => setView(v)}><Icon name={icon} size={16} /> {label}</button>
))}
</div>
<label className="search">
<Icon name="search" size={16} />
<input type="search" value={filter.q} placeholder="Search cards" aria-label="Search cards" onChange={(e) => setFilter({ ...filter, q: e.target.value })} />
</label>
<Popover className={`btn btn-line btn-sm${n ? " is-on" : ""}`} label="Filter cards" button={<><Icon name="filter" size={16} /> Filter{n ? ` · ${n}` : ""}</>}>
{() => (
<div className="menu-form filter-panel">
<div className="spread"><p className="menu-title">Filter cards</p>{n > 0 && <button type="button" className="link-btn" onClick={() => setFilter({ ...NO_FILTER, q: filter.q })}>Clear</button>}</div>
<fieldset>
<legend>Labels</legend>
{b.labels.length ? b.labels.map((l) => (
<label key={l.id} className="check"><input type="checkbox" checked={filter.labels.includes(l.id)} onChange={() => toggle("labels", l.id)} />
<span className="lbl" style={{ background: COLORS[l.color]?.bg, color: COLORS[l.color]?.fg }}>{l.name || COLORS[l.color]?.name}</span></label>
)) : <p className="muted small">No labels yet.</p>}
</fieldset>
<fieldset>
<legend>Assigned to</legend>
{active.map((m) => (
<label key={m.id} className="check"><input type="checkbox" checked={filter.people.includes(m.id)} onChange={() => toggle("people", m.id)} /> {m.name}{m.id === b.meId ? " (you)" : ""}</label>
))}
<label className="check"><input type="checkbox" checked={filter.people.includes(NOBODY)} onChange={() => toggle("people", NOBODY)} /> No one</label>
</fieldset>
<fieldset>
<legend>Due date</legend>
{DUE_OPTIONS.map(([v, label]) => (
<label key={v} className="check"><input type="checkbox" checked={filter.due.includes(v)} onChange={() => toggle("due", v)} /> {label}</label>
))}
</fieldset>
</div>
)}
</Popover>
{filterCount(filter) > 0 && (
<p className="filter-note">Filtering. <button type="button" className="link-btn" onClick={() => setFilter(NO_FILTER)}>Show all {shown.length} cards</button></p>
)}
</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