- 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 { useEffect, useRef, useState, type ReactNode } from "react";
import { daysFromToday, formatDue, isOverdue, members } from "./data";
import { Avatar, Icon, LabelChip } from "./ui";
import type { Card, Column } from "./types";
/* ---------- Base modal on the native <dialog> ---------- */
interface ModalProps {
open: boolean;
onClose: () => void;
labelledBy: string;
className?: string;
children: ReactNode;
}
function Modal({ open, onClose, labelledBy, className = "", children }: ModalProps) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (open && !el.open) {
el.showModal();
el.querySelector<HTMLElement>("[data-autofocus]")?.focus();
} else if (!open && el.open) el.close();
}, [open]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
el.addEventListener("cancel", onCancel);
return () => el.removeEventListener("cancel", onCancel);
}, [onClose]);
return (
<dialog
ref={ref}
className={`modal ${className}`}
aria-labelledby={labelledBy}
onClick={(e) => {
// click on the backdrop closes
if (e.target === ref.current) onClose();
}}
>
{open && <div className="modal-inner">{children}</div>}
</dialog>
);
}
/* ---------- Card detail ---------- */
interface CardDialogProps {
card: Card | null;
column: Column | null;
columns: Column[];
onClose: () => void;
onUpdate: (cardId: string, patch: Partial<Card>) => void;
onMoveTo: (cardId: string, toColumnId: string) => void;
onDelete: (cardId: string) => void;
wipReason: (toColumnId: string) => string | null;
}
export function CardDialog({ card, column, columns, onClose, onUpdate, onMoveTo, onDelete, wipReason }: CardDialogProps) {
const [title, setTitle] = useState("");
const [titleError, setTitleError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [moveMessage, setMoveMessage] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
const cardId = card?.id ?? null;
useEffect(() => {
setTitle(card?.title ?? "");
setTitleError(null);
setConfirmDelete(false);
setMoveMessage(null);
}, [cardId]);
// Keep the local title in sync if it changed elsewhere (e.g. undo) while the dialog is closed.
useEffect(() => {
if (card && title !== card.title && !titleError && document.activeElement !== titleRef.current) {
setTitle(card.title);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [card?.title]);
const open = card !== null;
const commitTitle = (value: string) => {
setTitle(value);
if (!card) return;
if (value.trim() === "") {
setTitleError("A card needs a title. The previous title is kept until you add one.");
return;
}
setTitleError(null);
if (value.trim() !== card.title) onUpdate(card.id, { title: value.trim() });
};
const done = card ? card.checklist.filter((i) => i.done).length : 0;
const total = card ? card.checklist.length : 0;
const overdue = card && column ? isOverdue(card, column.id) : false;
return (
<Modal open={open} onClose={onClose} labelledBy="card-dialog-title" className="modal-card">
{card && column && (
<div className="cd">
<header className="cd-head">
<div className="cd-crumb">
<span className="cd-column">{column.title}</span>
<span className="cd-sep" aria-hidden="true">
·
</span>
<span className={`cd-priority priority-${card.priority}`}>
<Icon.Flag size={12} />
{card.priority} priority
</span>
</div>
<button type="button" className="icon-btn" onClick={onClose} aria-label="Close card">
<Icon.Close size={16} />
</button>
</header>
<div className="cd-body">
<div className="cd-main">
<label className="field-label" htmlFor="card-dialog-title">
Title
</label>
<input
ref={titleRef}
id="card-dialog-title"
data-autofocus
className={`cd-title${titleError ? " is-invalid" : ""}`}
value={title}
maxLength={140}
aria-invalid={titleError ? true : undefined}
aria-describedby={titleError ? "card-dialog-title-error" : undefined}
onChange={(e) => commitTitle(e.target.value)}
/>
{titleError && (
<p className="field-error" id="card-dialog-title-error" role="alert">
<Icon.Alert size={13} /> {titleError}
</p>
)}
<label className="field-label" htmlFor="card-dialog-desc">
Description
</label>
<textarea
id="card-dialog-desc"
className="cd-desc"
rows={4}
placeholder="Add context, links, or notes for the team."
value={card.description}
onChange={(e) => onUpdate(card.id, { description: e.target.value })}
/>
<div className="cd-section-head">
<span className="field-label">Checklist</span>
{total > 0 && (
<span className="cd-progress-text">
{done} of {total} done
</span>
)}
</div>
{total > 0 ? (
<>
<div
className="cd-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={done}
aria-label="Checklist progress"
>
<span style={{ width: `${(done / total) * 100}%` }} />
</div>
<ul className="cd-checklist">
{card.checklist.map((item) => (
<li key={item.id}>
<label className={`check${item.done ? " is-done" : ""}`}>
<input
type="checkbox"
checked={item.done}
onChange={(e) =>
onUpdate(card.id, {
checklist: card.checklist.map((i) =>
i.id === item.id ? { ...i, done: e.target.checked } : i,
),
})
}
/>
<span className="check-box" aria-hidden="true">
<Icon.Check size={12} />
</span>
<span className="check-text">{item.text}</span>
</label>
</li>
))}
</ul>
</>
) : (
<p className="cd-empty">No checklist on this card.</p>
)}
</div>
<aside className="cd-side">
<label className="field-label" htmlFor="card-dialog-assignee">
Assignee
</label>
<div className="cd-assignee">
<Avatar memberId={card.assignee} size={28} />
<select
id="card-dialog-assignee"
className="select"
value={card.assignee ?? ""}
onChange={(e) => onUpdate(card.id, { assignee: e.target.value || null })}
>
<option value="">Unassigned</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
{card.assignee && <p className="cd-role">{members.find((m) => m.id === card.assignee)?.role}</p>}
<span className="field-label">Due date</span>
<p className={`cd-due${overdue ? " is-overdue" : ""}`}>
{card.due ? (
<>
{overdue ? <Icon.Alert size={14} /> : <Icon.Calendar size={14} />}
<span>
{formatDue(card.due, true)}
{overdue && <strong> · Overdue by {Math.abs(daysFromToday(card.due))} day{Math.abs(daysFromToday(card.due)) === 1 ? "" : "s"}</strong>}
{!overdue && column.id !== "done" && daysFromToday(card.due) === 0 && <strong> · Due today</strong>}
</span>
</>
) : (
<span className="muted">No due date</span>
)}
</p>
<span className="field-label">Labels</span>
<div className="cd-labels">
{card.labels.length > 0 ? card.labels.map((l) => <LabelChip key={l} id={l} />) : <span className="muted">None</span>}
</div>
<label className="field-label" htmlFor="card-dialog-move">
Column
</label>
<select
id="card-dialog-move"
className="select"
value={column.id}
onChange={(e) => {
const to = e.target.value;
const reason = wipReason(to);
if (reason) {
setMoveMessage(reason);
return;
}
setMoveMessage(null);
onMoveTo(card.id, to);
}}
>
{columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
{c.wip_limit !== null ? ` (${c.card_ids.length}/${c.wip_limit})` : ""}
</option>
))}
</select>
{moveMessage && (
<p className="field-error" role="alert">
<Icon.Alert size={13} /> {moveMessage}
</p>
)}
<div className="cd-danger">
{!confirmDelete ? (
<button type="button" className="btn btn-ghost btn-danger-text" onClick={() => setConfirmDelete(true)}>
<Icon.Trash size={14} /> Delete card
</button>
) : (
<div className="confirm-inline" role="group" aria-label="Confirm deletion">
<p>Delete “{card.title}”? You can undo this afterwards.</p>
<div className="confirm-actions">
<button type="button" className="btn btn-danger btn-sm" onClick={() => onDelete(card.id)} autoFocus>
Delete
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setConfirmDelete(false)}>
Keep card
</button>
</div>
</div>
)}
</div>
</aside>
</div>
</div>
)}
</Modal>
);
}
/* ---------- Confirm ---------- */
interface ConfirmDialogProps {
open: boolean;
title: string;
body: ReactNode;
confirmLabel: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({ open, title, body, confirmLabel, danger, onConfirm, onCancel }: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onCancel} labelledBy="confirm-title" className="modal-confirm">
<div className="confirm">
<h2 id="confirm-title">{title}</h2>
<div className="confirm-body">{body}</div>
<div className="confirm-actions">
<button type="button" className="btn btn-ghost" onClick={onCancel} autoFocus>
Cancel
</button>
<button type="button" className={`btn ${danger ? "btn-danger" : "btn-primary"}`} onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</Modal>
);
}