- 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, useReducer, useRef, useState } from "react";
import type { KeyboardEvent as RKeyboardEvent, MouseEvent as RMouseEvent, PointerEvent as RPointerEvent } from "react";
import { BOARD_NAME, FICTIONAL_NOTICE, WORKSPACE, type BoardState, type Card } from "./model";
import {
NO_FILTERS,
cardMatches,
filtersActive,
findColumn,
limitMessage,
loadBoard,
moveCard as moveCardPure,
newCardId,
reducer,
saveBoard,
wouldExceedLimit,
type AppState,
type Filters,
} from "./boardState";
import { ColumnView, type PlaceholderInfo } from "./components/ColumnView";
import type { CardHandlers, GhostInfo } from "./components/CardView";
import { CardDialog } from "./components/CardDialog";
import { MoveMenu } from "./components/MoveMenu";
import { FilterBar } from "./components/FilterBar";
import { Toasts, type Toast } from "./components/Toasts";
import { IconRefresh, IconSearch, IconUndo, Logo } from "./components/Icons";
type KbDrag = {
cardId: string;
title: string;
fromColId: string;
fromIndex: number;
fromCount: number;
colId: string;
index: number;
};
type PointerDrag = {
cardId: string;
x: number;
y: number;
offsetX: number;
offsetY: number;
width: number;
height: number;
over: { colId: string; before: string | "end" } | null;
};
type MenuState = { cardId: string; rect: { left: number; top: number; bottom: number } };
function kbAnchor(board: BoardState, k: KbDrag, filters: Filters): string | "end" {
const col = board.columns.find(c => c.id === k.colId);
if (!col) return "end";
const others = col.card_ids
.map(id => board.cards[id])
.filter(c => c && c.id !== k.cardId && cardMatches(c, filters));
return k.index >= others.length ? "end" : others[k.index].id;
}
function tentativeBoard(board: BoardState, k: KbDrag, filters: Filters): BoardState {
return moveCardPure(board, k.cardId, k.colId, kbAnchor(board, k, filters));
}
function initState(): AppState {
return { board: loadBoard(), history: [] };
}
export default function App() {
const [app, dispatch] = useReducer(reducer, undefined, initState);
const [filters, setFilters] = useState<Filters>(NO_FILTERS);
const [toasts, setToasts] = useState<Toast[]>([]);
const [liveMsg, setLiveMsg] = useState("");
const [drag, setDrag] = useState<PointerDrag | null>(null);
const [kbDrag, setKbDrag] = useState<KbDrag | null>(null);
const [dialogCardId, setDialogCardId] = useState<string | null>(null);
const [moveMenu, setMoveMenu] = useState<MenuState | null>(null);
const boardRef = useRef<HTMLDivElement>(null);
const dragPending = useRef<{ cardId: string; pointerId: number; startX: number; startY: number; rect: DOMRect; started: boolean } | null>(null);
const dragPointRef = useRef<{ x: number; y: number } | null>(null);
const suppressClick = useRef(false);
const suppressEpoch = useRef(0);
const liveTick = useRef(0);
const toastId = useRef(0);
const cancelKbRef = useRef<() => void>(() => {});
const doUndoRef = useRef<() => void>(() => {});
const board = app.board;
const filtered = filtersActive(filters);
const say = useCallback((msg: string) => {
liveTick.current += 1;
setLiveMsg(msg + (liveTick.current % 2 === 0 ? " \u200B" : ""));
}, []);
const toast = useCallback((text: string, tone: Toast["tone"] = "info") => {
const id = ++toastId.current;
setToasts(ts => [...ts.slice(-2), { id, text, tone }]);
window.setTimeout(() => setToasts(ts => ts.filter(t => t.id !== id)), 6500);
}, []);
useEffect(() => {
saveBoard(app.board);
}, [app.board]);
const displayBoard = useMemo(() => {
if (!kbDrag) return board;
return tentativeBoard(board, kbDrag, filters);
}, [board, kbDrag, filters]);
const visibleByCol = useMemo(() => {
const map = new Map<string, Card[]>();
for (const col of displayBoard.columns) {
map.set(
col.id,
col.card_ids.map(id => displayBoard.cards[id]).filter(c => c && cardMatches(c, filters)),
);
}
return map;
}, [displayBoard, filters]);
const totalVisible = useMemo(
() => Array.from(visibleByCol.values()).reduce((a, b) => a + b.length, 0),
[visibleByCol],
);
const totalCards = useMemo(() => board.columns.reduce((a, c) => a + c.card_ids.length, 0), [board]);
const ghostStyle: GhostInfo = drag
? { left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }
: null;
const placeholder: PlaceholderInfo =
drag && drag.over ? { colId: drag.over.colId, before: drag.over.before, height: drag.height } : null;
const colTitleOf = useCallback(
(id: string) => board.columns.find(c => c.id === id)?.title ?? "",
[board],
);
const computeOver = useCallback(
(x: number, y: number): { colId: string; before: string | "end" } | null => {
const hit = document.elementFromPoint(x, y);
if (!hit) return null;
const colEl = hit.closest("[data-col-id]") as HTMLElement | null;
if (!colEl) return null;
const colId = colEl.dataset.colId ?? "";
if (!colId) return null;
const cardEls = Array.from(colEl.querySelectorAll<HTMLElement>("[data-card-id]")).filter(
el => !el.classList.contains("ghost"),
);
let before: string | "end" = "end";
for (const ce of cardEls) {
const r = ce.getBoundingClientRect();
if (y < r.top + r.height / 2) {
before = ce.dataset.cardId ?? "end";
break;
}
}
return { colId, before };
},
[],
);
function openCard(cardId: string) {
if (drag) return;
setMoveMenu(null);
setDialogCardId(cardId);
}
function closeDialog() {
const id = dialogCardId;
setDialogCardId(null);
if (id) {
window.setTimeout(() => {
const el = document.getElementById(`card-${id}`);
if (el instanceof HTMLElement) el.focus();
}, 0);
}
}
function createCard(colId: string, title: string): string | null {
const col = board.columns.find(c => c.id === colId);
if (!col) return "Column not found.";
if (wouldExceedLimit(board, colId, null)) {
const msg = limitMessage(col);
toast(msg, "warn");
return msg;
}
const card: Card = {
id: newCardId(),
title,
description: "",
assignee: null,
labels: [],
priority: "medium",
due: null,
checklist: [],
};
dispatch({ type: "create", colId, card });
toast(`Added “${title}” to ${col.title}.`);
say(`Added “${title}” to ${col.title}.`);
return null;
}
function deleteCard(cardId: string) {
const card = board.cards[cardId];
if (!card) {
setDialogCardId(null);
return;
}
const colId = findColumn(board.columns, cardId)?.id ?? "";
dispatch({ type: "delete", cardId, title: card.title });
setDialogCardId(null);
toast(`Deleted “${card.title}”. Undo brings it back.`);
say(`Deleted “${card.title}”.`);
window.setTimeout(() => {
const el = document.querySelector(`[data-col-id="${colId}"] .col-title`);
if (el instanceof HTMLElement) el.focus();
}, 0);
}
function moveViaMenu(colId: string) {
if (!moveMenu) return;
const cardId = moveMenu.cardId;
setMoveMenu(null);
const card = board.cards[cardId];
const target = board.columns.find(c => c.id === colId);
if (!card || !target) return;
if (wouldExceedLimit(board, colId, cardId)) {
toast(limitMessage(target), "warn");
window.setTimeout(() => {
const el = document.getElementById(`movebtn-${cardId}`);
if (el instanceof HTMLElement) el.focus();
}, 0);
return;
}
const anchor = target.card_ids[0] ?? "end";
dispatch({ type: "move", cardId, toColId: colId, anchor, title: card.title });
toast(`Moved “${card.title}” to ${target.title}.`);
say(`Moved “${card.title}” to ${target.title}.`);
window.setTimeout(() => {
const el = document.getElementById(`card-${cardId}`);
if (el instanceof HTMLElement) el.focus();
}, 0);
}
function closeMoveMenu(focusButton = true) {
const m = moveMenu;
setMoveMenu(null);
if (m && focusButton) {
window.setTimeout(() => {
const el = document.getElementById(`movebtn-${m.cardId}`);
if (el instanceof HTMLElement) el.focus();
}, 0);
}
}
function doUndo() {
if (drag || kbDrag || moveMenu) return;
if (app.history.length === 0) {
toast("Nothing to undo right now.");
return;
}
const last = app.history[app.history.length - 1];
dispatch({ type: "undo" });
const text =
last.kind === "move"
? `Move undone — “${last.cardTitle}” is back where it was.`
: last.kind === "create"
? `Card creation undone — “${last.cardTitle}” was removed.`
: `Deletion undone — “${last.cardTitle}” is back on the board.`;
toast(text);
say(text);
}
doUndoRef.current = doUndo;
function resetDemo() {
dispatch({ type: "reset" });
setDialogCardId(null);
setMoveMenu(null);
setKbDrag(null);
setDrag(null);
setFilters(NO_FILTERS);
toast("Demo reset — the board is back to its starting state.");
say("Demo reset.");
}
function startKbDrag(cardId: string) {
if (drag || kbDrag) return;
const card = board.cards[cardId];
const fromCol = findColumn(board.columns, cardId);
if (!card || !fromCol) return;
const visible = fromCol.card_ids
.map(id => board.cards[id])
.filter(c => c && cardMatches(c, filters));
const fromIndex = visible.findIndex(c => c.id === cardId);
if (fromIndex < 0) return;
setKbDrag({
cardId,
title: card.title,
fromColId: fromCol.id,
fromIndex,
fromCount: visible.length - 1,
colId: fromCol.id,
index: fromIndex,
});
say(
`Picked up “${card.title}”. Position ${fromIndex + 1} of ${visible.length} in ${fromCol.title}. Move with the arrow keys, drop with Enter or Space, cancel with Escape.`,
);
}
function kbOthers(colId: string, cardId: string): Card[] {
const col = board.columns.find(c => c.id === colId);
if (!col) return [];
return col.card_ids.map(id => board.cards[id]).filter(c => c && c.id !== cardId && cardMatches(c, filters));
}
function dropKbDrag(k: KbDrag) {
const anchor = kbAnchor(board, k, filters);
const next = moveCardPure(board, k.cardId, k.colId, anchor);
const target = next.columns.find(c => c.id === k.colId)!;
const pos = target.card_ids.indexOf(k.cardId) + 1;
setKbDrag(null);
if (next !== board) {
dispatch({ type: "move", cardId: k.cardId, toColId: k.colId, anchor, title: k.title });
}
say(`Dropped “${k.title}” in ${target.title}, position ${pos} of ${target.card_ids.length}.`);
}
function cancelKbDrag() {
if (!kbDrag) return;
const k = kbDrag;
setKbDrag(null);
say(
`Cancelled. “${k.title}” is back in ${colTitleOf(k.fromColId)}, position ${k.fromIndex + 1} of ${k.fromCount + 1}.`,
);
}
cancelKbRef.current = cancelKbDrag;
function handleKbDragKey(e: RKeyboardEvent<HTMLDivElement>, k: KbDrag) {
if (e.key === "Escape") {
e.preventDefault();
cancelKbDrag();
return;
}
if (e.key === "Tab") {
cancelKbDrag();
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
dropKbDrag(k);
return;
}
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const others = kbOthers(k.colId, k.cardId).length;
const index =
e.key === "ArrowUp" ? Math.max(0, k.index - 1) : Math.min(others, k.index + 1);
setKbDrag({ ...k, index });
say(`“${k.title}” — position ${index + 1} of ${others + 1} in ${colTitleOf(k.colId)}.`);
return;
}
if (e.key === "Home" || e.key === "End") {
e.preventDefault();
const others = kbOthers(k.colId, k.cardId).length;
const index = e.key === "Home" ? 0 : others;
setKbDrag({ ...k, index });
say(`“${k.title}” — position ${index + 1} of ${others + 1} in ${colTitleOf(k.colId)}.`);
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const dir = e.key === "ArrowLeft" ? -1 : 1;
const cur = board.columns.findIndex(c => c.id === k.colId);
const next = board.columns[cur + dir];
if (!next) {
say(
dir < 0
? "No column to the left — this is the start of the board."
: "No column to the right — this is the end of the board.",
);
return;
}
if (wouldExceedLimit(board, next.id, k.cardId)) {
const msg = limitMessage(next);
toast(msg, "warn");
say(msg);
return;
}
const others = kbOthers(next.id, k.cardId).length;
const index = Math.min(k.index, others);
setKbDrag({ ...k, colId: next.id, index });
say(`“${k.title}” over ${next.title}, position ${index + 1} of ${others + 1}.`);
}
}
const handleCardKeyDown = (e: RKeyboardEvent<HTMLDivElement>) => {
const cardId = e.currentTarget.dataset.cardId ?? "";
if (kbDrag) {
if (kbDrag.cardId === cardId) {
handleKbDragKey(e, kbDrag);
} else if (e.key === "Escape") {
e.preventDefault();
cancelKbDrag();
}
return;
}
if (drag) return;
if (e.key === "Enter") {
e.preventDefault();
openCard(cardId);
} else if (e.key === " ") {
e.preventDefault();
startKbDrag(cardId);
}
};
const handleCardPointerDown = (e: RPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-nodrag]")) return;
if (kbDrag || drag) return;
const el = e.currentTarget;
const cardId = el.dataset.cardId ?? "";
if (!cardId) return;
dragPending.current = {
cardId,
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
rect: el.getBoundingClientRect(),
started: false,
};
try {
el.setPointerCapture(e.pointerId);
} catch {}
};
const handleCardPointerMove = (e: RPointerEvent<HTMLDivElement>) => {
const p = dragPending.current;
if (!p || p.pointerId !== e.pointerId) return;
if (!p.started) {
const dist = Math.hypot(e.clientX - p.startX, e.clientY - p.startY);
if (dist < 6) return;
p.started = true;
suppressEpoch.current += 1;
suppressClick.current = true;
setDrag({
cardId: p.cardId,
x: e.clientX,
y: e.clientY,
offsetX: p.startX - p.rect.left,
offsetY: p.startY - p.rect.top,
width: p.rect.width,
height: p.rect.height,
over: null,
});
}
dragPointRef.current = { x: e.clientX, y: e.clientY };
const over = computeOver(e.clientX, e.clientY);
setDrag(d => (d ? { ...d, x: e.clientX, y: e.clientY, over } : d));
};
function endDrag() {
dragPending.current = null;
dragPointRef.current = null;
setDrag(null);
const epoch = ++suppressEpoch.current;
window.setTimeout(() => {
if (suppressEpoch.current === epoch) suppressClick.current = false;
}, 150);
}
const handleCardPointerUp = (e: RPointerEvent<HTMLDivElement>) => {
const p = dragPending.current;
if (!p || p.pointerId !== e.pointerId) return;
if (!p.started) {
dragPending.current = null;
return;
}
const over = computeOver(e.clientX, e.clientY);
endDrag();
const card = board.cards[p.cardId];
if (!card || !over) return;
const target = board.columns.find(c => c.id === over.colId);
if (!target) return;
if (wouldExceedLimit(board, over.colId, p.cardId)) {
const msg = limitMessage(target);
toast(msg, "warn");
say(msg);
return;
}
const next = moveCardPure(board, p.cardId, over.colId, over.before);
const pos = next.columns.find(c => c.id === over.colId)!.card_ids.indexOf(p.cardId) + 1;
dispatch({ type: "move", cardId: p.cardId, toColId: over.colId, anchor: over.before, title: card.title });
say(`Dropped “${card.title}” in ${target.title}, position ${pos}.`);
};
const handleCardPointerCancel = () => {
if (dragPending.current?.started) {
endDrag();
say("Move cancelled.");
} else {
dragPending.current = null;
}
};
const handleCardClick = (e: RMouseEvent<HTMLDivElement>) => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
const cardId = e.currentTarget.dataset.cardId ?? "";
openCard(cardId);
};
const openMoveMenu = (e: RMouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const btn = e.currentTarget;
const cardEl = btn.closest("[data-card-id]") as HTMLElement | null;
const cardId = cardEl?.dataset.cardId ?? "";
if (!cardId) return;
const r = btn.getBoundingClientRect();
setMoveMenu({ cardId, rect: { left: r.left, top: r.top, bottom: r.bottom } });
};
const cardHandlers: CardHandlers = {
onPointerDown: handleCardPointerDown,
onPointerMove: handleCardPointerMove,
onPointerUp: handleCardPointerUp,
onPointerCancel: handleCardPointerCancel,
onClickCard: handleCardClick,
onKeyDownCard: handleCardKeyDown,
onOpenMoveMenu: openMoveMenu,
};
const dragging = drag !== null;
useEffect(() => {
if (!dragging) return;
document.body.classList.add("is-pointer-dragging");
return () => document.body.classList.remove("is-pointer-dragging");
}, [dragging]);
useEffect(() => {
if (!dragging) return;
let raf = 0;
const step = () => {
const pt = dragPointRef.current;
if (pt) {
const boardEl = boardRef.current;
if (boardEl) {
const br = boardEl.getBoundingClientRect();
const EX = 44;
if (pt.x < br.left + EX) boardEl.scrollLeft -= Math.min(18, (br.left + EX - pt.x) * 0.4);
else if (pt.x > br.right - EX) boardEl.scrollLeft += Math.min(18, (pt.x - (br.right - EX)) * 0.4);
}
const over = computeOver(pt.x, pt.y);
if (over) {
const list = document.querySelector(`[data-col-id="${over.colId}"] [data-col-list]`);
if (list instanceof HTMLElement) {
const lr = list.getBoundingClientRect();
const EY = 36;
if (pt.y < lr.top + EY) list.scrollTop -= Math.min(14, (lr.top + EY - pt.y) * 0.35);
else if (pt.y > lr.bottom - EY) list.scrollTop += Math.min(14, (pt.y - (lr.bottom - EY)) * 0.35);
}
}
setDrag(d => {
if (!d) return d;
if (d.over?.colId === over?.colId && d.over?.before === over?.before) return d;
return { ...d, over };
});
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [dragging, computeOver]);
useEffect(() => {
if (!dragging) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
endDrag();
say("Move cancelled.");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [dragging, say]);
useEffect(() => {
if (!kbDrag) return;
const el = document.getElementById(`card-${kbDrag.cardId}`);
if (el instanceof HTMLElement && document.activeElement !== el) el.focus();
}, [kbDrag, displayBoard]);
useEffect(() => {
if (!kbDrag) return;
const onDown = () => cancelKbRef.current();
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [kbDrag !== null]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "z") {
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
e.preventDefault();
doUndoRef.current();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (dialogCardId && !board.cards[dialogCardId]) setDialogCardId(null);
}, [board, dialogCardId]);
useEffect(() => {
if (!moveMenu) return;
const onDown = (ev: PointerEvent) => {
const t = ev.target as Element | null;
if (!t) return;
if (t.closest(".move-menu") || t.closest(`#movebtn-${moveMenu.cardId}`)) return;
setMoveMenu(null);
};
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [moveMenu]);
const dialogCard = dialogCardId ? board.cards[dialogCardId] ?? null : null;
const dialogColId = dialogCard ? findColumn(board.columns, dialogCard.id)?.id ?? null : null;
const moveMenuCard = moveMenu ? board.cards[moveMenu.cardId] ?? null : null;
const moveMenuColId = moveMenuCard ? findColumn(board.columns, moveMenuCard.id)?.id ?? null : null;
return (
<div className="app">
<header className="topbar">
<div className="brand">
<Logo size={26} />
<span className="wordmark">
Board<span className="ly">ly</span>
</span>
</div>
<div className="crumbs">
<span className="crumb-workspace">{WORKSPACE}</span>
<span className="crumb-sep" aria-hidden="true">
/
</span>
<h1 className="crumb-board">{BOARD_NAME}</h1>
</div>
<div className="topbar-actions">
<button
type="button"
className="btn-top"
onClick={doUndo}
disabled={app.history.length === 0}
title="Undo the last move, creation, or deletion (Ctrl+Z / ⌘Z)"
aria-keyshortcuts="Control+Z"
>
<IconUndo size={14} />
Undo
</button>
<button type="button" className="btn-top" onClick={resetDemo} title="Restore the original demo board">
<IconRefresh size={14} />
Reset demo
</button>
</div>
</header>
<FilterBar
filters={filters}
onChange={setFilters}
onClear={() => setFilters(NO_FILTERS)}
visibleCount={totalVisible}
totalCount={totalCards}
/>
{totalVisible === 0 && (
<div className="board-empty">
<div className="board-empty-icon">
<IconSearch size={20} />
</div>
<h2>{filtered ? "No cards match your filters" : "This board has no cards"}</h2>
<p>
{filtered
? "Try a different search term, or clear the assignee and label filters to see every card again."
: "Reset the demo to bring back the original relaunch board."}
</p>
{filtered && (
<button type="button" className="btn primary" onClick={() => setFilters(NO_FILTERS)}>
Clear all filters
</button>
)}
</div>
)}
<main className="board" ref={boardRef} aria-label="Website Relaunch board">
{displayBoard.columns.map(col => (
<ColumnView
key={col.id}
column={col}
cards={visibleByCol.get(col.id) ?? []}
totalCards={col.card_ids.length}
filtered={filtered}
ghostCardId={drag?.cardId ?? null}
ghostStyle={ghostStyle}
liftedCardId={kbDrag?.cardId ?? null}
placeholder={placeholder}
cardHandlers={cardHandlers}
onCreate={createCard}
/>
))}
</main>
<footer className="foot">
<p className="foot-notice">{FICTIONAL_NOTICE}</p>
<span className="foot-hint">Enter opens a card · Space picks it up · arrows move · Esc cancels</span>
</footer>
<Toasts toasts={toasts} onDismiss={id => setToasts(ts => ts.filter(t => t.id !== id))} />
{dialogCard && dialogColId != null && (
<CardDialog
card={dialogCard}
colId={dialogColId}
colTitle={board.columns.find(c => c.id === dialogColId)?.title ?? ""}
onClose={closeDialog}
onDelete={() => deleteCard(dialogCard.id)}
onPatch={patch => dispatch({ type: "patch", cardId: dialogCard.id, patch })}
/>
)}
{moveMenu && moveMenuCard && moveMenuColId != null && (
<MoveMenu
cardId={moveMenuCard.id}
cardTitle={moveMenuCard.title}
board={board}
currentColId={moveMenuColId}
anchorRect={moveMenu.rect}
onMove={moveViaMenu}
onClose={() => closeMoveMenu(true)}
/>
)}
<div className="sr-only" role="status" aria-live="polite">
{liveMsg}
</div>
</div>
);
}