- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Boardly — Website Relaunch 1.0.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 { createRoot } from "react-dom/client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DragEvent, KeyboardEvent } from "react";
import "./styles.css";
type ChecklistItem = { id: string; text: string; done: boolean };
type Card = {
id: string;
title: string;
description: string;
assignee: string | null;
labels: string[];
priority: string;
due: string | null;
checklist: ChecklistItem[];
};
type Column = { id: string; title: string; wip_limit: number | null; card_ids: string[] };
type Label = { id: string; name: string };
type Member = { id: string; name: string; role: string };
type BoardState = {
product: string;
workspace: string;
board: string;
today: string;
fictional_notice: string;
labels: Label[];
priorities: string[];
columns: Column[];
cards: Card[];
};
type UndoEntry = { snapshot: BoardState; label: string };
type DialogState = { type: "card"; cardId: string } | { type: "create"; columnId: string } | null;
type CardDraft = Omit<Card, "assignee" | "due"> & { assignee: string; due: string };
type KeyboardMove = { cardId: string; original: BoardState };
const STORAGE_KEY = "boardly.website-relaunch.v1";
const labelColors: Record<string, string> = {
design: "lavender",
frontend: "blue",
backend: "amber",
content: "green",
research: "teal",
bug: "rose",
};
const memberColors: Record<string, string> = {
maya: "#bc7f55",
tomas: "#537fa9",
aiko: "#9b75a6",
jonah: "#56877c",
priya: "#bd7180",
};
function copy<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function formatDate(date: string): string {
const parsed = new Date(`${date}T12:00:00`);
return new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(parsed);
}
function initials(name: string): string {
return name.split(/\s+/).slice(0, 2).map((part) => part[0]).join("").toUpperCase();
}
function Icon({ name, size = 16 }: { name: string; size?: number }) {
const common = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true as const };
if (name === "search") return <svg {...common}><circle cx="10.8" cy="10.8" r="6.8" /><path d="m16 16 4.5 4.5" /></svg>;
if (name === "plus") return <svg {...common}><path d="M12 5v14M5 12h14" /></svg>;
if (name === "chevron") return <svg {...common}><path d="m8 10 4 4 4-4" /></svg>;
if (name === "calendar") return <svg {...common}><rect x="3.5" y="5" width="17" height="16" rx="2.5" /><path d="M7.5 3v4M16.5 3v4M4 9.5h16" /></svg>;
if (name === "check") return <svg {...common}><path d="m5 12.5 4.2 4.2L19 7" /></svg>;
if (name === "grip") return <svg {...common}><circle cx="8" cy="6" r=".8" fill="currentColor" /><circle cx="16" cy="6" r=".8" fill="currentColor" /><circle cx="8" cy="12" r=".8" fill="currentColor" /><circle cx="16" cy="12" r=".8" fill="currentColor" /><circle cx="8" cy="18" r=".8" fill="currentColor" /><circle cx="16" cy="18" r=".8" fill="currentColor" /></svg>;
if (name === "undo") return <svg {...common}><path d="M9 14 4.5 9.5 9 5" /><path d="M5 9.5h8a6.5 6.5 0 1 1 0 13h-1" /></svg>;
if (name === "arrow") return <svg {...common}><path d="M5 12h14M13 6l6 6-6 6" /></svg>;
if (name === "close") return <svg {...common}><path d="m6 6 12 12M18 6 6 18" /></svg>;
if (name === "board") return <svg {...common}><rect x="3.5" y="4" width="17" height="16" rx="2.5" /><path d="M9 4v16M15 4v16" /></svg>;
if (name === "spark") return <svg {...common}><path d="m12 2 1.8 7.2L21 11l-7.2 1.8L12 20l-1.8-7.2L3 11l7.2-1.8L12 2Z" /></svg>;
return <svg {...common}><circle cx="12" cy="12" r="8" /></svg>;
}
function Logo() {
return <div className="brand" aria-label="Boardly home">
<span className="brand-mark"><span /><span /><span /><span /></span>
<span className="brand-name">boardly</span>
</div>;
}
function MemberAvatar({ member, compact = false }: { member: Member; compact?: boolean }) {
return <span className={`member-avatar${compact ? " compact" : ""}`} style={{ "--avatar-color": memberColors[member.id] || "#71827d" } as React.CSSProperties} aria-hidden="true">{initials(member.name)}</span>;
}
function moveInState(state: BoardState, cardId: string, targetColumnId: string, targetIndex: number): { state: BoardState; blocked?: string; changed: boolean } {
const source = state.columns.find((column) => column.card_ids.includes(cardId));
const target = state.columns.find((column) => column.id === targetColumnId);
if (!source || !target) return { state, changed: false };
const sourceIndex = source.card_ids.indexOf(cardId);
if (source.id !== target.id && target.wip_limit !== null && target.card_ids.length >= target.wip_limit) {
return { state, blocked: `${target.title} is at its limit of ${target.wip_limit} cards. Move or finish a card before adding another.`, changed: false };
}
const columns = state.columns.map((column) => ({ ...column, card_ids: [...column.card_ids] }));
const nextSource = columns.find((column) => column.id === source.id)!;
const nextTarget = columns.find((column) => column.id === target.id)!;
nextSource.card_ids.splice(sourceIndex, 1);
const adjustedIndex = source.id === target.id && sourceIndex < targetIndex ? targetIndex - 1 : targetIndex;
const insertIndex = Math.max(0, Math.min(adjustedIndex, nextTarget.card_ids.length));
nextTarget.card_ids.splice(insertIndex, 0, cardId);
const changed = columns.some((column, index) => column.card_ids.some((id, idIndex) => id !== state.columns[index].card_ids[idIndex]) || column.card_ids.length !== state.columns[index].card_ids.length);
return { state: changed ? { ...state, columns } : state, changed };
}
function isTextEntry(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}
function App() {
const [board, setBoard] = useState<BoardState | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [ready, setReady] = useState(false);
const initialBoard = useRef<BoardState | null>(null);
const [dialog, setDialog] = useState<DialogState>(null);
const [cardDraft, setCardDraft] = useState<CardDraft | null>(null);
const [createTitle, setCreateTitle] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createAssignee, setCreateAssignee] = useState("");
const [validation, setValidation] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false);
const [query, setQuery] = useState("");
const [assigneeFilter, setAssigneeFilter] = useState("all");
const [labelFilter, setLabelFilter] = useState("all");
const [undoEntry, setUndoEntry] = useState<UndoEntry | null>(null);
const [notice, setNotice] = useState("");
const [announcement, setAnnouncement] = useState("");
const [keyboardMove, setKeyboardMove] = useState<KeyboardMove | null>(null);
const [draggingCard, setDraggingCard] = useState<string | null>(null);
const [dropMarker, setDropMarker] = useState<{ columnId: string; index: number } | null>(null);
const cardRefs = useRef(new Map<string, HTMLElement>());
const addButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const titleInputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const lastFocusedCard = useRef<string | null>(null);
useEffect(() => {
let active = true;
async function load() {
try {
const [boardResponse, membersResponse] = await Promise.all([fetch("/data/board.json"), fetch("/data/members.json")]);
if (!boardResponse.ok || !membersResponse.ok) throw new Error("Board data could not be loaded.");
const supplied = await boardResponse.json() as BoardState;
const memberData = await membersResponse.json() as { members: Member[] };
if (!active) return;
initialBoard.current = copy(supplied);
setMembers(memberData.members);
let starting = supplied;
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as BoardState;
if (Array.isArray(parsed.columns) && Array.isArray(parsed.cards) && parsed.columns.length === supplied.columns.length) starting = parsed;
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
setBoard(copy(starting));
setReady(true);
} catch {
if (!active) return;
setNotice("The board data could not be loaded. Refresh the page to try again.");
}
}
void load();
return () => { active = false; };
}, []);
useEffect(() => {
if (!board || !ready || keyboardMove) return;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(board)); } catch { /* Storage can be unavailable in private browsing. */ }
}, [board, ready, keyboardMove]);
useEffect(() => {
if (!dialog) return;
window.requestAnimationFrame(() => titleInputRef.current?.focus());
}, [dialog]);
const cardById = useMemo(() => new Map((board?.cards ?? []).map((card) => [card.id, card])), [board]);
const memberById = useMemo(() => new Map(members.map((member) => [member.id, member])), [members]);
const activeFilters = Boolean(query.trim() || assigneeFilter !== "all" || labelFilter !== "all");
const visibleIdsFor = useCallback((column: Column) => {
if (!board) return [];
return column.card_ids.filter((id) => {
const card = cardById.get(id);
if (!card) return false;
const normalizedQuery = query.trim().toLocaleLowerCase();
if (normalizedQuery && !`${card.title} ${card.description}`.toLocaleLowerCase().includes(normalizedQuery)) return false;
if (assigneeFilter === "unassigned" && card.assignee !== null) return false;
if (assigneeFilter !== "all" && assigneeFilter !== "unassigned" && card.assignee !== assigneeFilter) return false;
if (labelFilter !== "all" && !card.labels.includes(labelFilter)) return false;
return true;
});
}, [board, cardById, query, assigneeFilter, labelFilter]);
const tell = (message: string, announce = false) => {
setNotice(message);
if (announce) setAnnouncement(message);
};
const closeDialog = () => {
const returnId = lastFocusedCard.current;
setDialog(null);
setValidation("");
setConfirmDelete(false);
window.requestAnimationFrame(() => {
if (returnId && cardRefs.current.has(returnId)) cardRefs.current.get(returnId)?.focus();
else if (dialog?.type === "create") addButtonRefs.current.get(dialog.columnId)?.focus();
});
};
const openCard = (cardId: string) => {
if (!board) return;
const card = cardById.get(cardId);
if (!card) return;
lastFocusedCard.current = cardId;
setCardDraft({ ...copy(card), assignee: card.assignee ?? "", due: card.due ?? "" });
setValidation("");
setConfirmDelete(false);
setDialog({ type: "card", cardId });
};
const openCreate = (columnId: string) => {
lastFocusedCard.current = null;
setCreateTitle("");
setCreateDescription("");
setCreateAssignee("");
setValidation("");
setDialog({ type: "create", columnId });
};
const commitMove = (cardId: string, targetColumnId: string, targetIndex: number) => {
if (!board) return;
const result = moveInState(board, cardId, targetColumnId, targetIndex);
if (result.blocked) { tell(result.blocked, true); return; }
if (!result.changed) return;
const card = cardById.get(cardId);
setUndoEntry({ snapshot: copy(board), label: "move" });
setBoard(result.state);
const target = board.columns.find((column) => column.id === targetColumnId);
tell(`${card?.title ?? "Card"} moved to ${target?.title ?? "the new column"}.`, true);
};
const undo = useCallback(() => {
if (!undoEntry) return;
setBoard(copy(undoEntry.snapshot));
tell(`Undid the most recent ${undoEntry.label}.`, true);
setUndoEntry(null);
}, [undoEntry]);
useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z" && !event.shiftKey && !isTextEntry(event.target) && undoEntry) {
event.preventDefault();
undo();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [undoEntry, undo]);
const saveCard = () => {
if (!board || !dialog || dialog.type !== "card" || !cardDraft) return;
if (!cardDraft.title.trim()) { setValidation("Give this card a title before saving."); titleInputRef.current?.focus(); return; }
const updated: Card = { ...cardDraft, title: cardDraft.title.trim(), assignee: cardDraft.assignee || null, due: cardDraft.due || null, description: cardDraft.description.trim() };
setBoard({ ...board, cards: board.cards.map((card) => card.id === updated.id ? updated : card) });
setUndoEntry(null);
tell("Card details saved.", true);
closeDialog();
};
const createCard = () => {
if (!board || !dialog || dialog.type !== "create") return;
const title = createTitle.trim();
if (!title) { setValidation("A card needs a title. Add one before creating it."); titleInputRef.current?.focus(); return; }
const column = board.columns.find((item) => item.id === dialog.columnId);
if (!column) return;
if (column.wip_limit !== null && column.card_ids.length >= column.wip_limit) {
const message = `${column.title} is at its limit of ${column.wip_limit} cards. Move or finish a card before adding another.`;
setValidation(message);
tell(message, true);
return;
}
const newCard: Card = {
id: `c-${Date.now()}`,
title,
description: createDescription.trim(),
assignee: createAssignee || null,
labels: [],
priority: "medium",
due: null,
checklist: [],
};
const next = { ...board, cards: [...board.cards, newCard], columns: board.columns.map((item) => item.id === column.id ? { ...item, card_ids: [...item.card_ids, newCard.id] } : item) };
setUndoEntry({ snapshot: copy(board), label: "creation" });
setBoard(next);
lastFocusedCard.current = newCard.id;
tell(`${newCard.title} added to ${column.title}.`, true);
setDialog(null);
setValidation("");
window.requestAnimationFrame(() => cardRefs.current.get(newCard.id)?.focus());
};
const deleteCard = () => {
if (!board || !dialog || dialog.type !== "card") return;
const card = cardById.get(dialog.cardId);
const next: BoardState = {
...board,
cards: board.cards.filter((item) => item.id !== dialog.cardId),
columns: board.columns.map((column) => ({ ...column, card_ids: column.card_ids.filter((id) => id !== dialog.cardId) })),
};
setUndoEntry({ snapshot: copy(board), label: "deletion" });
setBoard(next);
setDialog(null);
setConfirmDelete(false);
tell(`${card?.title ?? "Card"} deleted. Undo is available.`, true);
const parent = board.columns.find((column) => column.card_ids.includes(dialog.cardId));
window.requestAnimationFrame(() => { if (parent) addButtonRefs.current.get(parent.id)?.focus(); });
};
const beginKeyboardMove = (cardId: string) => {
if (!board) return;
const card = cardById.get(cardId);
const column = board.columns.find((item) => item.card_ids.includes(cardId));
if (!card || !column) return;
setKeyboardMove({ cardId, original: copy(board) });
tell(`Picked up ${card.title} in ${column.title}. Use arrow keys to move, Enter to drop, or Escape to cancel.`, true);
};
const keyboardStep = (cardId: string, direction: "up" | "down" | "left" | "right") => {
if (!board) return;
const sourceIndex = board.columns.findIndex((column) => column.card_ids.includes(cardId));
const source = board.columns[sourceIndex];
if (!source) return;
let target = source;
let targetIndex = source.card_ids.indexOf(cardId);
if (direction === "up") targetIndex -= 1;
if (direction === "down") targetIndex += 2;
if (direction === "left" && sourceIndex > 0) {
target = board.columns[sourceIndex - 1];
targetIndex = target.card_ids.length;
}
if (direction === "right" && sourceIndex < board.columns.length - 1) {
target = board.columns[sourceIndex + 1];
targetIndex = target.card_ids.length;
}
if (target.id === source.id && (targetIndex < 0 || targetIndex > source.card_ids.length)) return;
if (target.id === source.id && direction === "up" && source.card_ids.indexOf(cardId) === 0) return;
if (target.id === source.id && direction === "down" && source.card_ids.indexOf(cardId) === source.card_ids.length - 1) return;
const result = moveInState(board, cardId, target.id, targetIndex);
if (result.blocked) { tell(result.blocked, true); return; }
if (!result.changed) return;
setBoard(result.state);
const card = cardById.get(cardId);
const finalColumn = result.state.columns.find((column) => column.card_ids.includes(cardId))!;
tell(`Moved ${card?.title ?? "card"} to ${finalColumn.title}, position ${finalColumn.card_ids.indexOf(cardId) + 1} of ${finalColumn.card_ids.length}.`, true);
window.requestAnimationFrame(() => cardRefs.current.get(cardId)?.focus());
};
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>, cardId: string) => {
if (event.currentTarget !== event.target) return;
if (keyboardMove?.cardId === cardId) {
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
keyboardStep(cardId, ({ ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" } as const)[event.key as "ArrowUp"]);
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const column = board?.columns.find((item) => item.card_ids.includes(cardId));
const card = cardById.get(cardId);
setUndoEntry({ snapshot: keyboardMove.original, label: "move" });
setKeyboardMove(null);
tell(`Dropped ${card?.title ?? "card"} in ${column?.title ?? "its column"}.`, true);
} else if (event.key === "Escape") {
event.preventDefault();
const originalColumn = keyboardMove.original.columns.find((column) => column.card_ids.includes(cardId));
setBoard(copy(keyboardMove.original));
setKeyboardMove(null);
tell(`Move cancelled. ${cardById.get(cardId)?.title ?? "Card"} returned to ${originalColumn?.title ?? "its original position"}.`, true);
window.requestAnimationFrame(() => cardRefs.current.get(cardId)?.focus());
}
return;
}
if (event.key === "Enter") { event.preventDefault(); openCard(cardId); }
if (event.key === " ") { event.preventDefault(); beginKeyboardMove(cardId); }
};
const onCardDragStart = (event: DragEvent<HTMLElement>, cardId: string) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", cardId);
setDraggingCard(cardId);
};
const onCardDragOver = (event: DragEvent<HTMLElement>, columnId: string, cardId: string) => {
event.preventDefault();
event.stopPropagation();
const column = board?.columns.find((item) => item.id === columnId);
if (!column) return;
const rect = event.currentTarget.getBoundingClientRect();
const index = column.card_ids.indexOf(cardId) + (event.clientY > rect.top + rect.height / 2 ? 1 : 0);
setDropMarker({ columnId, index });
};
const onCardDrop = (event: DragEvent<HTMLElement>, columnId: string, cardId: string) => {
event.preventDefault();
event.stopPropagation();
if (!board || !draggingCard) return;
const column = board.columns.find((item) => item.id === columnId);
if (!column) return;
const rect = event.currentTarget.getBoundingClientRect();
const index = column.card_ids.indexOf(cardId) + (event.clientY > rect.top + rect.height / 2 ? 1 : 0);
commitMove(draggingCard, columnId, index);
setDraggingCard(null);
setDropMarker(null);
};
const onColumnDrop = (event: DragEvent<HTMLElement>, columnId: string) => {
event.preventDefault();
if (!board || !draggingCard) return;
const column = board.columns.find((item) => item.id === columnId);
if (column) commitMove(draggingCard, columnId, column.card_ids.length);
setDraggingCard(null);
setDropMarker(null);
};
if (!ready || !board) return <main className="loading-screen"><Logo /><span>Opening your board…</span></main>;
const visibleTotal = board.columns.reduce((sum, column) => sum + visibleIdsFor(column).length, 0);
const clearFilters = () => { setQuery(""); setAssigneeFilter("all"); setLabelFilter("all"); };
const activeCard = dialog?.type === "card" ? cardById.get(dialog.cardId) : undefined;
const activeColumn = dialog?.type === "create" ? board.columns.find((column) => column.id === dialog.columnId) : undefined;
return <div className="app-shell">
<header className="topbar">
<div className="topbar-inner">
<Logo />
<div className="topbar-divider" />
<button className="workspace-button" type="button" aria-label="Harbor Studio workspace">
<span className="workspace-monogram">H</span>
<span className="workspace-copy"><span className="workspace-label">WORKSPACE</span><strong>{board.workspace}</strong></span>
<Icon name="chevron" size={14} />
</button>
<div className="topbar-spacer" />
<div className="team-presence" aria-label="Harbor Studio team">
{members.slice(0, 4).map((member) => <MemberAvatar key={member.id} member={member} compact />)}
<span className="team-count">+1</span>
</div>
<button className="avatar-self" type="button" aria-label="Your profile">EL</button>
</div>
</header>
<main className="main-content">
<section className="project-heading">
<div className="heading-copy">
<div className="eyebrow"><span className="eyebrow-dot" /> PROJECT BOARD <span className="eyebrow-slash">/</span> HARBOR STUDIO</div>
<div className="title-row"><span className="board-title-icon"><Icon name="board" size={20} /></span><h1>{board.board}</h1><span className="private-pill"><span /> Private</span></div>
<p className="project-subtitle">A clear path from first idea to launch day.</p>
</div>
<div className="heading-actions">
<button className="quiet-action undo-action" type="button" onClick={undo} disabled={!undoEntry} title={undoEntry ? `Undo ${undoEntry.label}` : "Nothing to undo yet"}><Icon name="undo" size={15} /><span>Undo</span><kbd>⌘ Z</kbd></button>
<button className="quiet-action reset-action" type="button" onClick={() => {
if (!initialBoard.current) return;
const restored = copy(initialBoard.current);
setBoard(restored);
setUndoEntry(null);
setKeyboardMove(null);
setDialog(null);
setQuery("");
setAssigneeFilter("all");
setLabelFilter("all");
setDraggingCard(null);
setDropMarker(null);
localStorage.removeItem(STORAGE_KEY);
tell("Demo board reset to its original state.", true);
}}>Reset demo</button>
</div>
</section>
<section className="filter-panel" aria-label="Filter cards">
<label className="search-field"><Icon name="search" size={17} /><input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search cards or descriptions…" aria-label="Search card titles and descriptions" /></label>
<div className="filter-divider" />
<label className="filter-select"><span>Assignee</span><select value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} aria-label="Filter by assignee"><option value="all">Anyone</option><option value="unassigned">Unassigned</option>{members.map((member) => <option key={member.id} value={member.id}>{member.name.split(" ")[0]}</option>)}</select><Icon name="chevron" size={13} /></label>
<label className="filter-select label-select"><span>Label</span><select value={labelFilter} onChange={(event) => setLabelFilter(event.target.value)} aria-label="Filter by label"><option value="all">Any label</option>{board.labels.map((label) => <option key={label.id} value={label.id}>{label.name}</option>)}</select><Icon name="chevron" size={13} /></label>
{activeFilters && <button className="clear-filters" type="button" onClick={clearFilters}><Icon name="close" size={13} /> Clear</button>}
{!activeFilters && <span className="filter-hint">{board.cards.length} cards</span>}
</section>
{notice && <div className="notice-banner" role="status"><span className="notice-icon"><Icon name="spark" size={15} /></span><span>{notice}</span><button type="button" aria-label="Dismiss message" onClick={() => setNotice("")}><Icon name="close" size={14} /></button></div>}
<div className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
{visibleTotal === 0 && activeFilters && <div className="no-results"><span className="no-results-icon"><Icon name="search" size={19} /></span><div><strong>No cards found</strong><p>Try another search or clear your filters to see the full board.</p></div><button type="button" onClick={clearFilters}>Clear filters</button></div>}
<section className="board-region" aria-label={`${board.board} kanban board`}>
<div className="board-grid">
{board.columns.map((column, columnIndex) => {
const visibleIds = visibleIdsFor(column);
return <section className={`board-column column-${column.id}${dropMarker?.columnId === column.id ? " column-drop-active" : ""}`} key={column.id} onDragOver={(event) => { event.preventDefault(); if (draggingCard) setDropMarker({ columnId: column.id, index: column.card_ids.length }); }} onDrop={(event) => onColumnDrop(event, column.id)} aria-label={`${column.title}, ${visibleIds.length} cards${column.wip_limit ? `, limit ${column.wip_limit}` : ""}`}>
<header className="column-header">
<div className="column-title-group"><span className={`column-status-dot dot-${column.id}`} /><h2>{column.title}</h2><span className="card-count">{visibleIds.length}{column.wip_limit !== null && <span className="wip-total"> / {column.wip_limit}</span>}</span></div>
<button className="column-menu-button" type="button" onClick={() => openCreate(column.id)} aria-label={`Add a card to ${column.title}`}><Icon name="plus" size={17} /></button>
</header>
<div className="column-cards">
{dropMarker?.columnId === column.id && dropMarker.index === 0 && draggingCard !== visibleIds[0] && <div className="drop-placeholder" aria-hidden="true" />}
{visibleIds.map((cardId, cardIndex) => {
const card = cardById.get(cardId);
if (!card) return null;
const member = card.assignee ? memberById.get(card.assignee) : undefined;
const isOverdue = Boolean(card.due && card.due < board.today && column.id !== "done");
const showMarker = dropMarker?.columnId === column.id && dropMarker.index === column.card_ids.indexOf(cardId);
return <div className="card-slot" key={card.id}>
{showMarker && draggingCard !== card.id && <div className="drop-placeholder" aria-hidden="true" />}
<article id={`card-${card.id}`} className={`task-card${draggingCard === card.id ? " is-dragging" : ""}${keyboardMove?.cardId === card.id ? " is-keyboard-moving" : ""}`} tabIndex={0} aria-label={`${card.title}. ${column.title}. Press Enter for details or Space to move.`} aria-grabbed={keyboardMove?.cardId === card.id ? true : undefined} draggable onClick={(event) => { if ((event.target as HTMLElement).closest("button, select, a")) return; openCard(card.id); }} onKeyDown={(event) => handleCardKeyDown(event, card.id)} onDragStart={(event) => onCardDragStart(event, card.id)} onDragEnd={() => { setDraggingCard(null); setDropMarker(null); }} onDragOver={(event) => onCardDragOver(event, column.id, card.id)} onDrop={(event) => onCardDrop(event, column.id, card.id)} ref={(node) => { if (node) cardRefs.current.set(card.id, node); else cardRefs.current.delete(card.id); }}>
<div className="card-topline"><span className={`priority-mark priority-${card.priority}`} title={`${card.priority} priority`} /><span className="priority-label">{card.priority}</span><button className="drag-grip" type="button" aria-label={`Drag ${card.title}; or focus the card and press Space to move`} tabIndex={-1}><Icon name="grip" size={14} /></button></div>
<h3>{card.title}</h3>
<div className="card-labels">{card.labels.map((labelId) => {
const label = board.labels.find((item) => item.id === labelId);
return label ? <span className={`label-chip label-${labelColors[labelId] || "neutral"}`} key={labelId}>{label.name}</span> : null;
})}</div>
<div className="card-meta">
<div className="card-owner">{member ? <><MemberAvatar member={member} /><span title={member.name}>{member.name.split(" ")[0]}</span></> : <><span className="unassigned-avatar"><span>+</span></span><span className="unassigned-text">Unassigned</span></>}</div>
<div className={`due-date${isOverdue ? " overdue" : ""}`} title={card.due ? `Due ${formatDate(card.due)}${isOverdue ? ", overdue" : ""}` : "No due date"}>{card.due ? <><Icon name="calendar" size={12} /><span>{formatDate(card.due)}</span>{isOverdue && <span className="overdue-word">Overdue</span>}</> : <span className="no-due-date">No due date</span>}</div>
</div>
{(card.checklist.length > 0 || card.due) && <div className="card-bottomline">
{card.checklist.length > 0 && <span className={`checklist-progress${card.checklist.every((item) => item.done) ? " checklist-complete" : ""}`}><Icon name="check" size={12} />{card.checklist.filter((item) => item.done).length}/{card.checklist.length}</span>}
<select className="move-select" value="" aria-label={`Move ${card.title} to another column`} onChange={(event) => { const target = board.columns.find((item) => item.id === event.target.value); if (target) commitMove(card.id, target.id, target.card_ids.length); event.currentTarget.value = ""; }} onClick={(event) => event.stopPropagation()}><option value="" disabled>Move to…</option>{board.columns.filter((item) => item.id !== column.id).map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}</select>
</div>}
{!card.checklist.length && !card.due && <div className="card-bottomline no-checklist"><span className="card-spacer" /><select className="move-select" value="" aria-label={`Move ${card.title} to another column`} onChange={(event) => { const target = board.columns.find((item) => item.id === event.target.value); if (target) commitMove(card.id, target.id, target.card_ids.length); event.currentTarget.value = ""; }} onClick={(event) => event.stopPropagation()}><option value="" disabled>Move to…</option>{board.columns.filter((item) => item.id !== column.id).map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}</select></div>}
</article>
{dropMarker?.columnId === column.id && dropMarker.index === column.card_ids.indexOf(cardId) + 1 && cardIndex === visibleIds.length - 1 && draggingCard !== card.id && <div className="drop-placeholder" aria-hidden="true" />}
</div>;
})}
{dropMarker?.columnId === column.id && dropMarker.index === column.card_ids.length && (visibleIds.length === 0 || column.card_ids.length === 0) && <div className="drop-placeholder" aria-hidden="true" />}
{visibleIds.length === 0 && <div className="column-empty">{activeFilters ? "No matching cards" : "A little room to think."}</div>}
</div>
<button className="add-card-button" type="button" onClick={() => openCreate(column.id)} ref={(node) => { if (node) addButtonRefs.current.set(column.id, node); else addButtonRefs.current.delete(column.id); }}><Icon name="plus" size={15} /><span>Add card</span></button>
</section>;
})}
</div>
</section>
<footer className="page-footer"><span className="footer-mark"><span /></span><span>{board.fictional_notice}</span><span className="footer-date">Today · {formatDate(board.today)}</span></footer>
</main>
{dialog && <div className="dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) closeDialog(); }}>
<div className={`dialog-card${dialog.type === "create" ? " create-dialog" : ""}`} role="dialog" aria-modal="true" aria-labelledby="dialog-title" aria-describedby="dialog-description" ref={dialogRef} onKeyDown={(event) => {
if (event.key === "Escape") { event.preventDefault(); closeDialog(); return; }
if (event.key === "Tab" && dialogRef.current) {
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((element) => element.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0]; const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
}}>
<div className="dialog-header">
<div><div className="dialog-eyebrow">{dialog.type === "create" ? "NEW CARD" : "CARD DETAILS"}</div><h2 id="dialog-title">{dialog.type === "create" ? "Add a card" : "Edit card"}</h2></div>
<button className="dialog-close" type="button" onClick={closeDialog} aria-label="Close dialog"><Icon name="close" size={18} /></button>
</div>
<p id="dialog-description" className="dialog-intro">{dialog.type === "create" ? <>Add a task to <strong>{activeColumn?.title}</strong> and keep the project moving.</> : "Keep the details clear so the next step is easy to see."}</p>
{dialog.type === "create" ? <form className="detail-form" onSubmit={(event) => { event.preventDefault(); createCard(); }}>
<label className="form-field"><span>Title <em>Required</em></span><input ref={titleInputRef} value={createTitle} onChange={(event) => { setCreateTitle(event.target.value); setValidation(""); }} placeholder="What needs to get done?" aria-invalid={Boolean(validation && !createTitle.trim())} /></label>
<label className="form-field"><span>Description</span><textarea value={createDescription} onChange={(event) => setCreateDescription(event.target.value)} rows={3} placeholder="Add a little more context…" /></label>
<label className="form-field"><span>Assignee</span><select value={createAssignee} onChange={(event) => setCreateAssignee(event.target.value)}><option value="">Unassigned</option>{members.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}</select></label>
{validation && <p className="validation-message" role="alert">{validation}</p>}
<div className="dialog-actions"><button className="button-secondary" type="button" onClick={closeDialog}>Cancel</button><button className="button-primary" type="submit"><Icon name="plus" size={15} /> Create card</button></div>
</form> : cardDraft && activeCard && <form className="detail-form" onSubmit={(event) => { event.preventDefault(); saveCard(); }}>
<label className="form-field"><span>Title <em>Required</em></span><input ref={titleInputRef} value={cardDraft.title} onChange={(event) => { setCardDraft({ ...cardDraft, title: event.target.value }); setValidation(""); }} aria-invalid={Boolean(validation && !cardDraft.title.trim())} /></label>
<label className="form-field"><span>Description</span><textarea value={cardDraft.description} onChange={(event) => setCardDraft({ ...cardDraft, description: event.target.value })} rows={3} /></label>
<div className="form-row">
<label className="form-field"><span>Assignee</span><select value={cardDraft.assignee} onChange={(event) => setCardDraft({ ...cardDraft, assignee: event.target.value })}><option value="">Unassigned</option>{members.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}</select></label>
<label className="form-field"><span>Due date</span><input type="date" value={cardDraft.due} onChange={(event) => setCardDraft({ ...cardDraft, due: event.target.value })} /></label>
</div>
<div className="form-row">
<label className="form-field"><span>Priority</span><select value={cardDraft.priority} onChange={(event) => setCardDraft({ ...cardDraft, priority: event.target.value })}>{board.priorities.map((priority) => <option key={priority} value={priority}>{priority[0].toUpperCase() + priority.slice(1)}</option>)}</select></label>
<div className="form-field"><span>Labels</span><div className="label-options">{board.labels.map((label) => <label className="label-option" key={label.id}><input type="checkbox" checked={cardDraft.labels.includes(label.id)} onChange={(event) => setCardDraft({ ...cardDraft, labels: event.target.checked ? [...cardDraft.labels, label.id] : cardDraft.labels.filter((item) => item !== label.id) })} /><span className={`label-chip label-${labelColors[label.id] || "neutral"}`}>{label.name}</span></label>)}</div></div>
</div>
<div className="form-field checklist-editor"><span>Checklist <small>{cardDraft.checklist.filter((item) => item.done).length} of {cardDraft.checklist.length} complete</small></span>
{cardDraft.checklist.map((item, index) => <div className="checklist-edit-row" key={item.id}><input type="checkbox" checked={item.done} aria-label={`Mark ${item.text} ${item.done ? "incomplete" : "complete"}`} onChange={(event) => setCardDraft({ ...cardDraft, checklist: cardDraft.checklist.map((entry) => entry.id === item.id ? { ...entry, done: event.target.checked } : entry) })} /><input value={item.text} aria-label={`Checklist item ${index + 1}`} onChange={(event) => setCardDraft({ ...cardDraft, checklist: cardDraft.checklist.map((entry) => entry.id === item.id ? { ...entry, text: event.target.value } : entry) })} /><button type="button" className="remove-check-item" aria-label={`Remove checklist item ${index + 1}`} onClick={() => setCardDraft({ ...cardDraft, checklist: cardDraft.checklist.filter((entry) => entry.id !== item.id) })}><Icon name="close" size={14} /></button></div>)}
<button type="button" className="add-check-item" onClick={() => setCardDraft({ ...cardDraft, checklist: [...cardDraft.checklist, { id: `${activeCard.id}-new-${Date.now()}`, text: "", done: false }] })}><Icon name="plus" size={13} /> Add checklist item</button>
</div>
{validation && <p className="validation-message" role="alert">{validation}</p>}
{confirmDelete && <div className="delete-confirm" role="alert"><div><strong>Delete this card?</strong><p>This will remove it from the board. You can undo the deletion afterward.</p></div><button type="button" className="button-danger" onClick={deleteCard}>Delete card</button><button type="button" className="text-button" onClick={() => setConfirmDelete(false)}>Keep card</button></div>}
<div className="dialog-actions split-actions"><button type="button" className="delete-link" onClick={() => setConfirmDelete(true)}>Delete card</button><div><button className="button-secondary" type="button" onClick={closeDialog}>Cancel</button><button className="button-primary" type="submit">Save changes</button></div></div>
</form>}
</div>
</div>}
</div>;
}
createRoot(document.getElementById("root")!).render(<App />);