- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.1.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import {
BOARD_NAME,
MEMBERS,
NOTICE,
WORKSPACE,
buildInitialState,
getLabel,
moveCardInBoard,
nextCardId,
} from "./data";
import type { BoardState, Card } from "./types";
import { focusElement } from "./focus";
import { Wordmark } from "./components/Wordmark";
import { FilterBar, type FilterState } from "./components/FilterBar";
import { ColumnView, type AddResult, type DropPlaceholder } from "./components/ColumnView";
import { CardDialog } from "./components/CardDialog";
import { ConfirmDialog } from "./components/ConfirmDialog";
import { CardMenu, type MenuPosition } from "./components/CardMenu";
import { Toasts, type Toast } from "./components/Toasts";
import { AvatarStack } from "./components/Avatar";
import { IconReset, IconUndo } from "./icons";
const STORAGE_KEY = "boardly.board.v1";
const MENU_WIDTH = 210;
interface AppState {
board: BoardState;
past: BoardState[];
}
interface DragInfo {
cardId: string;
fromColId: string;
width: number;
height: number;
grabX: number;
grabY: number;
x: number;
y: number;
target: { colId: string; visIndex: number } | null;
blocked: boolean;
}
interface PendingDrag {
cardId: string;
fromColId: string;
startX: number;
startY: number;
rect: DOMRect;
}
interface KbdInfo {
cardId: string;
fromColId: string;
fromFullIndex: number;
}
interface MenuInfo {
cardId: string;
button: HTMLButtonElement;
pos: MenuPosition;
}
interface ColRects {
rect: DOMRect;
cards: { id: string; rect: DOMRect }[];
}
function loadAppState(): AppState {
const initial = buildInitialState();
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { board: initial, past: [] };
const saved = JSON.parse(raw) as BoardState | null;
if (!saved || !Array.isArray(saved.columns) || !saved.cards) return { board: initial, past: [] };
const ok =
initial.columns.every((c) => saved.columns.some((s) => s.id === c.id)) &&
Object.values(saved.cards).every((c) => typeof c?.title === "string" && typeof c?.id === "string");
if (!ok) return { board: initial, past: [] };
const columns = saved.columns.map((c) => ({
...c,
card_ids: (c.card_ids ?? []).filter((id) => !!saved.cards[id]),
}));
return { board: { columns, cards: saved.cards }, past: [] };
} catch {
return { board: initial, past: [] };
}
}
function getMenuPos(btn: HTMLButtonElement): MenuPosition {
const r = btn.getBoundingClientRect();
const left = Math.max(8, Math.min(r.right - MENU_WIDTH, window.innerWidth - MENU_WIDTH - 8));
let top = r.bottom + 6;
if (top + 300 > window.innerHeight) top = Math.max(8, r.top - 306);
return { left, top };
}
export default function App() {
const [app, setApp] = useState<AppState>(loadAppState);
const [filters, setFilters] = useState<FilterState>({ assignees: [], labels: [], query: "" });
const [openCardId, setOpenCardId] = useState<string | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [menu, setMenu] = useState<MenuInfo | null>(null);
const [drag, setDrag] = useState<DragInfo | null>(null);
const [kbd, setKbd] = useState<KbdInfo | null>(null);
const [toasts, setToasts] = useState<Toast[]>([]);
const [live, setLive] = useState<{ text: string; flip: boolean }>({ text: "", flip: false });
const [focusSel, setFocusSel] = useState<{ selector: string; n: number } | null>(null);
const board = app.board;
const boardRef = useRef(board);
const menuRef = useRef<MenuInfo | null>(null);
const boardElRef = useRef<HTMLElement | null>(null);
const visibleRef = useRef<Map<string, string[]>>(new Map());
const dragRef = useRef<DragInfo | null>(null);
const rectsRef = useRef<Map<string, ColRects> | null>(null);
const pendingRef = useRef<PendingDrag | null>(null);
const suppressClickRef = useRef(false);
const ghostRef = useRef<HTMLDivElement | null>(null);
const toastSeq = useRef(0);
useEffect(() => {
boardRef.current = board;
}, [board]);
useEffect(() => {
menuRef.current = menu;
}, [menu]);
/* ---------------- derived data ---------------- */
const filtersActive =
filters.assignees.length + filters.labels.length + (filters.query.trim() ? 1 : 0);
const visibleIdsByCol = useMemo(() => {
const q = filters.query.trim().toLowerCase();
const map = new Map<string, string[]>();
for (const col of board.columns) {
map.set(
col.id,
col.card_ids.filter((id) => {
const card = board.cards[id];
if (!card) return false;
if (filters.assignees.length > 0 && !(card.assignee && filters.assignees.includes(card.assignee)))
return false;
if (filters.labels.length > 0 && !card.labels.some((l) => filters.labels.includes(l))) return false;
if (q && !card.title.toLowerCase().includes(q) && !card.description.toLowerCase().includes(q))
return false;
return true;
}),
);
}
return map;
}, [board, filters]);
useEffect(() => {
visibleRef.current = visibleIdsByCol;
}, [visibleIdsByCol]);
const shownCount = useMemo(() => {
let n = 0;
for (const ids of visibleIdsByCol.values()) n += ids.length;
return n;
}, [visibleIdsByCol]);
const totalCount = useMemo(
() => board.columns.reduce((n, c) => n + c.card_ids.length, 0),
[board],
);
const zeroMatches = filtersActive > 0 && shownCount === 0;
/* ---------------- stable utilities ---------------- */
const endDrag = useCallback(() => {
dragRef.current = null;
pendingRef.current = null;
rectsRef.current = null;
setDrag(null);
document.body.classList.remove("is-dragging");
}, []);
const announce = useCallback((text: string) => {
setLive((prev) => ({ text, flip: !prev.flip }));
}, []);
const showToast = useCallback((message: string, tone: Toast["tone"] = "info", action?: Toast["action"]) => {
const id = ++toastSeq.current;
setToasts((t) => [...t.slice(-2), { id, message, tone, action }]);
window.setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 6500);
}, []);
const requestFocus = useCallback((selector: string) => {
setFocusSel({ selector, n: Date.now() });
}, []);
const requestCardFocus = useCallback(
(cardId: string) => requestFocus(`[data-card-id="${CSS.escape(cardId)}"]`),
[requestFocus],
);
useEffect(() => {
if (!focusSel) return;
let el: HTMLElement | null = null;
try {
el = document.querySelector<HTMLElement>(focusSel.selector);
} catch {
el = null;
}
focusElement(el);
}, [focusSel]);
/* ---------------- undo (declared early, stable) ---------------- */
const undo = useCallback(() => {
setApp((prev) => {
if (prev.past.length === 0) return prev;
return { board: prev.past[prev.past.length - 1], past: prev.past.slice(0, -1) };
});
announce("Change undone.");
}, [announce]);
/* ---------------- board mutations ---------------- */
const wipBlocked = useCallback((b: BoardState, cardId: string, toColId: string): string | null => {
const from = b.columns.find((c) => c.card_ids.includes(cardId));
const to = b.columns.find((c) => c.id === toColId);
if (!from || !to || from.id === to.id) return null;
if (to.wip_limit != null && to.card_ids.length + 1 > to.wip_limit) {
return `“${to.title}” is limited to ${to.wip_limit} cards and is already full. Finish or move a card out first.`;
}
return null;
}, []);
const attemptMove = useCallback(
(cardId: string, toColId: string, toFullIndex: number, refocus = true) => {
const b = boardRef.current;
const card = b.cards[cardId];
if (!card) return;
const blocked = wipBlocked(b, cardId, toColId);
const toCol = b.columns.find((c) => c.id === toColId);
if (blocked) {
showToast(blocked, "limit");
announce(`Blocked. ${blocked}`);
if (refocus) requestCardFocus(cardId);
return;
}
setApp((prev) => {
const next = moveCardInBoard(prev.board, cardId, toColId, toFullIndex);
if (next === prev.board) return prev;
return { board: next, past: [...prev.past.slice(-49), prev.board] };
});
if (refocus) requestCardFocus(cardId);
announce(`“${card.title}” moved to ${toCol?.title ?? toColId}.`);
},
[announce, requestCardFocus, showToast, wipBlocked],
);
const createCard = useCallback(
(colId: string, title: string): AddResult => {
const b = boardRef.current;
const col = b.columns.find((c) => c.id === colId);
if (!col) return { ok: false, error: "That column no longer exists." };
const trimmed = title.trim();
if (!trimmed) return { ok: false, error: "Add a title first — every card needs one." };
if (col.wip_limit != null && col.card_ids.length + 1 > col.wip_limit) {
return {
ok: false,
error: `“${col.title}” is limited to ${col.wip_limit} cards and is already full. Finish or move one before adding another.`,
};
}
const id = nextCardId(b.cards);
setApp((prev) => {
const nextCard: Card = {
id,
title: trimmed,
description: "",
assignee: null,
labels: [],
priority: "medium",
due: null,
checklist: [],
};
const columns = prev.board.columns.map((c) =>
c.id === colId ? { ...c, card_ids: [...c.card_ids, id] } : c,
);
return {
board: { columns, cards: { ...prev.board.cards, [id]: nextCard } },
past: [...prev.past.slice(-49), prev.board],
};
});
showToast(`Added “${trimmed}” to ${col.title}.`, "info", { label: "Undo", run: () => undo() });
announce(`Added “${trimmed}” to ${col.title}.`);
return { ok: true };
},
[announce, showToast, undo],
);
const deleteCard = useCallback(
(cardId: string) => {
const b = boardRef.current;
const card = b.cards[cardId];
const col = b.columns.find((c) => c.card_ids.includes(cardId));
if (!card || !col) {
setConfirmId(null);
return;
}
setApp((prev) => {
const columns = prev.board.columns.map((c) => ({
...c,
card_ids: c.card_ids.filter((x) => x !== cardId),
}));
const cards = { ...prev.board.cards };
delete cards[cardId];
return { board: { columns, cards }, past: [...prev.past.slice(-49), prev.board] };
});
setConfirmId(null);
setOpenCardId((prev) => (prev === cardId ? null : prev));
showToast(`Deleted “${card.title}” from ${col.title}.`, "danger", {
label: "Undo",
run: () => undo(),
});
requestFocus(`[data-addbtn="${col.id}"]`);
},
[requestFocus, showToast, undo],
);
const patchCard = useCallback((cardId: string, patch: Partial<Card>) => {
setApp((prev) => {
const card = prev.board.cards[cardId];
if (!card) return prev;
return {
...prev,
board: { ...prev.board, cards: { ...prev.board.cards, [cardId]: { ...card, ...patch } } },
};
});
}, []);
const resetDemo = useCallback(() => {
setMenu(null);
setOpenCardId(null);
setConfirmId(null);
setKbd(null);
endDrag();
setApp((prev) => ({ board: buildInitialState(), past: [...prev.past.slice(-49), prev.board] }));
showToast("Demo reset — the board is back to its original state.");
}, [endDrag, showToast]);
/* ---------------- persistence + global shortcuts ---------------- */
useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(board));
} catch {
/* storage unavailable — the demo continues in memory */
}
}, [board]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
if (e.key.toLowerCase() !== "z") return;
const el = document.activeElement as HTMLElement | null;
const tag = el?.tagName ?? "";
const inText = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || !!el?.isContentEditable;
if (inText || openCardId !== null || confirmId !== null) return;
e.preventDefault();
undo();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [confirmId, openCardId, undo]);
useEffect(() => {
const clear = (e: Event) => {
if (!suppressClickRef.current) return;
e.stopPropagation();
e.preventDefault();
suppressClickRef.current = false;
};
window.addEventListener("click", clear, { capture: true });
return () => window.removeEventListener("click", clear, { capture: true });
}, []);
/* ---------------- pointer drag ---------------- */
const measureRects = useCallback((): Map<string, ColRects> | null => {
const root = boardElRef.current;
if (!root) return null;
const map = new Map<string, ColRects>();
const draggedId = dragRef.current?.cardId ?? null;
root.querySelectorAll<HTMLElement>("[data-column-id]").forEach((colEl) => {
const colId = colEl.dataset.columnId;
if (!colId) return;
const cards: { id: string; rect: DOMRect }[] = [];
colEl.querySelectorAll<HTMLElement>("[data-card-id]").forEach((cardEl) => {
const id = cardEl.dataset.cardId;
if (!id || (draggedId && id === draggedId)) return;
cards.push({ id, rect: cardEl.getBoundingClientRect() });
});
map.set(colId, { rect: colEl.getBoundingClientRect(), cards });
});
return map;
}, []);
const currentVisIndex = useCallback((cardId: string, colId: string): number => {
const vis = visibleRef.current.get(colId) ?? [];
return Math.max(0, vis.indexOf(cardId));
}, []);
const fullIndexIn = useCallback(
(col: { id: string; card_ids: string[] }, list: string[], visIndex: number, mode: "before" | "after"): number => {
const full = col.card_ids;
if (list.length === 0) return full.length;
if (visIndex >= list.length) {
const lastFull = full.indexOf(list[list.length - 1]);
return Math.min(lastFull + 1, full.length);
}
const at = full.indexOf(list[Math.max(0, visIndex)]);
if (at === -1) return full.length;
return mode === "after" ? Math.min(at + 1, full.length) : at;
},
[],
);
const beginPointerDrag = useCallback(
(e: ReactPointerEvent<HTMLElement>, cardId: string, colId: string) => {
if (e.pointerType === "touch") return;
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest("button, a, input, textarea, select, label")) return;
const cardEl = e.currentTarget;
const rect = cardEl.getBoundingClientRect();
pendingRef.current = { cardId, fromColId: colId, startX: e.clientX, startY: e.clientY, rect };
const cleanup = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
window.removeEventListener("keydown", onKey);
};
const start = (x: number, y: number) => {
const p = pendingRef.current;
if (!p) return;
suppressClickRef.current = true;
const info: DragInfo = {
cardId,
fromColId: colId,
width: p.rect.width,
height: p.rect.height,
grabX: x - p.rect.left,
grabY: y - p.rect.top,
x,
y,
target: { colId, visIndex: currentVisIndex(cardId, colId) },
blocked: false,
};
dragRef.current = info;
rectsRef.current = null;
setDrag(info);
document.body.classList.add("is-dragging");
requestAnimationFrame(() => {
if (dragRef.current) rectsRef.current = measureRects();
});
};
const updatePos = (x: number, y: number) => {
const info = dragRef.current;
if (!info) return;
info.x = x;
info.y = y;
if (ghostRef.current) {
ghostRef.current.style.transform = `translate3d(${x - info.grabX}px, ${y - info.grabY}px, 0)`;
}
const rects = rectsRef.current;
if (!rects) return;
let found: { colId: string; visIndex: number } | null = null;
for (const [colId, entry] of rects) {
const r = entry.rect;
if (x >= r.left && x <= r.right && y >= r.top - 12 && y <= r.bottom + 12) {
let visIndex = 0;
for (const c of entry.cards) {
if (y > c.rect.top + c.rect.height / 2) visIndex++;
}
found = { colId, visIndex };
break;
}
}
if (found) {
const blockedNow = wipBlocked(boardRef.current, info.cardId, found.colId) !== null;
if (
info.target?.colId !== found.colId ||
info.target?.visIndex !== found.visIndex ||
blockedNow !== info.blocked
) {
const next = { ...info, target: found, blocked: blockedNow };
dragRef.current = next;
setDrag(next);
}
}
};
const onMove = (e: PointerEvent) => {
if (!dragRef.current) {
const p = pendingRef.current;
if (!p) return;
if (Math.hypot(e.clientX - p.startX, e.clientY - p.startY) < 6) return;
start(e.clientX, e.clientY);
return;
}
if (e.cancelable) e.preventDefault();
updatePos(e.clientX, e.clientY);
};
const onUp = () => {
cleanup();
const info = dragRef.current;
endDrag();
const target = info?.target;
if (info && target) {
const col = boardRef.current.columns.find((c) => c.id === target.colId);
const vis = visibleRef.current.get(target.colId) ?? [];
const toFull = col ? fullIndexIn(col, vis, target.visIndex, "before") : 0;
attemptMove(info.cardId, target.colId, toFull);
}
};
const onCancel = () => {
cleanup();
const info = dragRef.current;
endDrag();
if (info) announce("Drag cancelled. The card stayed where it was.");
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
cleanup();
const info = dragRef.current;
endDrag();
if (info) {
const col = boardRef.current.columns.find((c) => c.id === info.fromColId);
announce(`Drag cancelled. The card stayed in ${col?.title ?? "its column"}.`);
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("keydown", onKey);
},
[announce, attemptMove, currentVisIndex, endDrag, fullIndexIn, measureRects, wipBlocked],
);
/* ---------------- keyboard moves ---------------- */
const pickupCard = useCallback(
(card: Card, colId: string) => {
const col = boardRef.current.columns.find((c) => c.id === colId);
setKbd({
cardId: card.id,
fromColId: colId,
fromFullIndex: col?.card_ids.indexOf(card.id) ?? 0,
});
announce(
`Picked up “${card.title}” in ${col?.title ?? colId}. Use the up and down arrow keys to reorder within this column, or left and right to move it to the next or previous column. Press Enter or Space to drop, or Escape to cancel.`,
);
},
[announce],
);
const kbdMoveWithin = useCallback(
(dir: -1 | 1) => {
if (!kbd) return;
const b = boardRef.current;
const col = b.columns.find((c) => c.card_ids.includes(kbd.cardId));
if (!col) return;
const vis = visibleRef.current.get(col.id) ?? [];
const others = vis.filter((id) => id !== kbd.cardId);
const cur = vis.indexOf(kbd.cardId);
if (cur < 0) return;
const next = cur + dir;
const card = b.cards[kbd.cardId];
if (next < 0) {
announce(`“${card?.title ?? "Card"}” is already the first card in ${col.title}.`);
return;
}
if (next > others.length - 1) {
announce(`“${card?.title ?? "Card"}” is already the last card in ${col.title}.`);
return;
}
// `others` is the visible order without the dragged card; land at visible index `next`.
const toFull =
next === 0
? fullIndexIn(col, others, 0, "before")
: fullIndexIn(col, others, next - 1, "after");
attemptMove(kbd.cardId, col.id, toFull);
},
[announce, attemptMove, fullIndexIn, kbd],
);
const kbdMoveColumn = useCallback(
(dir: -1 | 1) => {
if (!kbd) return;
const b = boardRef.current;
const colIdx = b.columns.findIndex((c) => c.card_ids.includes(kbd.cardId));
if (colIdx < 0) return;
const target = b.columns[colIdx + dir];
if (!target) {
announce(
`There is no column ${dir < 0 ? "to the left" : "to the right"} of ${b.columns[colIdx].title}.`,
);
return;
}
const blocked = wipBlocked(b, kbd.cardId, target.id);
if (blocked) {
showToast(blocked, "limit");
announce(`Can’t move to ${target.title}. ${blocked}`);
return;
}
attemptMove(kbd.cardId, target.id, target.card_ids.length);
},
[announce, attemptMove, kbd, showToast, wipBlocked],
);
const dropKbd = useCallback(
(card: Card) => {
const col = boardRef.current.columns.find((c) => c.card_ids.includes(card.id));
setKbd(null);
requestCardFocus(card.id);
announce(`Dropped “${card.title}” in ${col?.title ?? "its column"}.`);
},
[announce, requestCardFocus],
);
const cancelKbd = useCallback(() => {
if (!kbd) return;
const b = boardRef.current;
const card = b.cards[kbd.cardId];
const col = b.columns.find((c) => c.id === kbd.fromColId);
setApp((prev) => {
const next = moveCardInBoard(prev.board, kbd.cardId, kbd.fromColId, kbd.fromFullIndex);
if (next === prev.board) return prev;
return { ...prev, board: next };
});
setKbd(null);
requestCardFocus(kbd.cardId);
announce(`Move cancelled. “${card?.title ?? "The card"}” is back in ${col?.title ?? "its original column"}.`);
}, [announce, kbd, requestCardFocus]);
const onCardKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLElement>, card: Card, colId: string) => {
if (kbd && kbd.cardId !== card.id) return;
if (kbd) {
switch (e.key) {
case "ArrowUp":
e.preventDefault();
kbdMoveWithin(-1);
return;
case "ArrowDown":
e.preventDefault();
kbdMoveWithin(1);
return;
case "ArrowLeft":
e.preventDefault();
kbdMoveColumn(-1);
return;
case "ArrowRight":
e.preventDefault();
kbdMoveColumn(1);
return;
case " ":
case "Enter":
case "m":
case "M":
e.preventDefault();
dropKbd(card);
return;
case "Escape":
e.preventDefault();
cancelKbd();
return;
}
return;
}
if (e.target !== e.currentTarget) return;
if (e.key === "Enter") {
e.preventDefault();
setOpenCardId(card.id);
} else if (e.key === " " || e.key === "m" || e.key === "M") {
e.preventDefault();
pickupCard(card, colId);
}
},
[cancelKbd, dropKbd, kbd, kbdMoveColumn, kbdMoveWithin, pickupCard],
);
/* ---------------- menus & dialogs ---------------- */
const toggleMenu = useCallback((btn: HTMLButtonElement, cardId: string) => {
setMenu((prev) => {
if (prev && prev.cardId === cardId) return null;
return { cardId, button: btn, pos: getMenuPos(btn) };
});
}, []);
const closeMenu = useCallback((refocus: boolean) => {
const m = menuRef.current;
setMenu(null);
if (refocus && m) m.button.focus();
}, []);
const closeCardDialog = useCallback(
(cardId: string) => {
setOpenCardId(null);
requestCardFocus(cardId);
},
[requestCardFocus],
);
const askDelete = useCallback((cardId: string) => {
setMenu(null);
setOpenCardId(null);
setConfirmId(cardId);
}, []);
const openCard = useCallback(
(cardId: string) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
setOpenCardId(cardId);
},
[],
);
const menuMove = useCallback(
(colId: string) => {
const m = menuRef.current;
if (!m) return;
const target = boardRef.current.columns.find((c) => c.id === colId);
setMenu(null);
attemptMove(m.cardId, colId, target?.card_ids.length ?? 0);
},
[attemptMove],
);
const menuDelete = useCallback(() => {
const m = menuRef.current;
if (!m) return;
setMenu(null);
setConfirmId(m.cardId);
}, []);
const dialogMove = useCallback(
(cardId: string, colId: string) => {
const target = boardRef.current.columns.find((c) => c.id === colId);
// Keep focus inside the open dialog instead of jumping back to the card.
attemptMove(cardId, colId, target?.card_ids.length ?? 0, false);
},
[attemptMove],
);
/* ---------------- render helpers ---------------- */
const openedCard = openCardId ? board.cards[openCardId] : null;
const openedCardCol = openCardId ? board.columns.find((c) => c.card_ids.includes(openCardId)) : null;
const confirmCard = confirmId ? board.cards[confirmId] : null;
const confirmCol = confirmId ? board.columns.find((c) => c.card_ids.includes(confirmId)) : null;
const menuCard = menu ? board.cards[menu.cardId] : null;
const menuCol = menu ? board.columns.find((c) => c.card_ids.includes(menu.cardId)) : null;
const dragCard = drag ? board.cards[drag.cardId] : null;
const placeholderFor = (colId: string): DropPlaceholder | null => {
if (!drag || !drag.target || drag.target.colId !== colId) return null;
return { index: drag.target.visIndex, height: drag.height, blocked: drag.blocked };
};
const clearFilters = useCallback(() => setFilters({ assignees: [], labels: [], query: "" }), []);
return (
<div className="app">
<header className="topbar">
<Wordmark />
<span className="topbar__sep" aria-hidden="true" />
<div className="topbar__ids">
<span className="topbar__workspace">{WORKSPACE}</span>
<h1 className="topbar__board">{BOARD_NAME}</h1>
</div>
<div className="topbar__actions">
<AvatarStack members={MEMBERS} />
<span className="topbar__vr" aria-hidden="true" />
<button
type="button"
className="btn btn--ghost"
onClick={() => undo()}
disabled={app.past.length === 0}
aria-keyshortcuts="Control+Z Meta+Z"
title="Undo last change (Ctrl+Z / ⌘Z)"
>
<IconUndo width={15} height={15} />
Undo
</button>
<button
type="button"
className="btn btn--ghost"
onClick={resetDemo}
title="Restore the original demo board"
>
<IconReset width={15} height={15} />
Reset demo
</button>
</div>
</header>
<FilterBar
filters={filters}
activeCount={filtersActive}
shown={shownCount}
total={totalCount}
onToggleAssignee={(id) =>
setFilters((f) => ({
...f,
assignees: f.assignees.includes(id)
? f.assignees.filter((x) => x !== id)
: [...f.assignees, id],
}))
}
onToggleLabel={(id) =>
setFilters((f) => ({
...f,
labels: f.labels.includes(id) ? f.labels.filter((x) => x !== id) : [...f.labels, id],
}))
}
onQuery={(q) => setFilters((f) => ({ ...f, query: q }))}
onClear={clearFilters}
/>
{zeroMatches && (
<div className="empty-banner" role="status">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="6.5" />
<path d="m20 20-3.7-3.7" />
<path d="M8.5 8.5l5 5M13.5 8.5l-5 5" />
</svg>
<p className="empty-banner__text">
<strong>No cards match your filters.</strong> Try a different search, assignee, or label — or
clear the filters to see the whole board again.
</p>
<button type="button" className="btn btn--primary btn--sm" onClick={clearFilters}>
Clear all filters
</button>
</div>
)}
<main className="board" ref={boardElRef} aria-label={`${BOARD_NAME} board`}>
{board.columns.map((col) => (
<ColumnView
key={col.id}
column={col}
cards={(visibleIdsByCol.get(col.id) ?? [])
.map((id) => board.cards[id])
.filter((c): c is Card => Boolean(c))}
totalCount={col.card_ids.length}
filtered={filtersActive > 0}
dragOver={drag?.target?.colId === col.id ? (drag.blocked ? "blocked" : "ok") : null}
hiddenCardId={drag?.cardId ?? null}
kbdLiftedId={kbd?.cardId ?? null}
openMenuCardId={menu?.cardId ?? null}
placeholder={placeholderFor(col.id)}
onCardOpen={openCard}
onCardKeyDown={onCardKeyDown}
onCardPointerDown={beginPointerDrag}
onMenuButton={toggleMenu}
onAdd={createCard}
/>
))}
</main>
<footer className="statusbar">
<p className="statusbar__notice">{NOTICE}</p>
<span className="statusbar__meta">
{filtersActive > 0 ? `${shownCount} of ${totalCount} cards visible` : `${totalCount} cards`}
</span>
</footer>
{drag && dragCard && (
<div
className="ghost"
ref={ghostRef}
aria-hidden="true"
style={{
width: drag.width,
height: drag.height,
transform: `translate3d(${drag.x - drag.grabX}px, ${drag.y - drag.grabY}px, 0)`,
}}
>
<div className="card card--ghost" data-priority={dragCard.priority}>
{dragCard.labels.length > 0 && (
<div className="card__labels">
{dragCard.labels.slice(0, 3).map((lid) => (
<span key={lid} className="tag" style={{ background: "#f1f4f8", color: "#5c6f80" }}>
{getLabel(lid)?.name ?? lid}
</span>
))}
</div>
)}
<h3 className="card__title">{dragCard.title}</h3>
</div>
</div>
)}
{menu && menuCard && menuCol && (
<CardMenu
cardTitle={menuCard.title}
currentColumnTitle={menuCol.title}
others={board.columns.filter((c) => c.id !== menuCol.id)}
pos={menu.pos}
button={menu.button}
onMove={menuMove}
onDelete={() => {
const m = menuRef.current;
setMenu(null);
if (m) setConfirmId(m.cardId);
}}
onClose={closeMenu}
/>
)}
{openedCard && openedCardCol && (
<CardDialog
card={openedCard}
columnId={openedCardCol.id}
columnTitle={openedCardCol.title}
columns={board.columns}
onPatch={patchCard}
onClose={closeCardDialog}
onRequestDelete={askDelete}
onMove={dialogMove}
/>
)}
{confirmCard && confirmCol && (
<ConfirmDialog
title="Delete this card?"
body={`“${confirmCard.title}” will be removed from ${confirmCol.title}. You can undo this right after.`}
confirmLabel="Delete card"
cancelLabel="Cancel"
onConfirm={() => deleteCard(confirmCard.id)}
onCancel={() => {
setConfirmId(null);
requestCardFocus(confirmCard.id);
}}
/>
)}
<Toasts toasts={toasts} onDismiss={(id) => setToasts((t) => t.filter((x) => x.id !== id))} />
<div className="sr-only" aria-live="polite">
{live.flip ? live.text : ""}
</div>
<div className="sr-only" aria-live="polite">
{live.flip ? "" : live.text}
</div>
<p id="card-kbd-hint" className="sr-only">
Press Enter to open card details. Press Space to pick up the card, move it with the arrow keys, then
press Space to drop or Escape to cancel.
</p>
</div>
);
}