From 240f73b229b2f138f11e80ad4ab654316ff81f3a Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 14 Jul 2026 10:37:10 -0300 Subject: [PATCH] feat(ui): Jira-style card detail, edit-mode column management, design polish --- project-context.md | 18 +- web/src/App.tsx | 32 +- web/src/components/BoardCanvas.tsx | 57 ++- web/src/components/CardDetailModal.tsx | 237 +++++++++++ web/src/components/CardEditor.tsx | 51 --- web/src/components/CardItem.tsx | 138 +++--- web/src/components/Header.tsx | 35 +- web/src/components/ListColumn.tsx | 34 +- web/src/styles.css | 556 ++++++++++++++++--------- 9 files changed, 769 insertions(+), 389 deletions(-) create mode 100644 web/src/components/CardDetailModal.tsx delete mode 100644 web/src/components/CardEditor.tsx diff --git a/project-context.md b/project-context.md index b69952d..a98e2ec 100644 --- a/project-context.md +++ b/project-context.md @@ -45,15 +45,15 @@ The product is intentionally small: one Go binary serves both the JSON API and t - `web/src/api.ts`: typed fetch client (`APIError` class, `request` helper). All calls are same-origin under `/api`. - `web/src/board-state.ts`: `useReducer` board state — owns the `Board` snapshot, `loading`, `error`, and `toasts`. Pure action creators/reducers for add/update/remove list & card and optimistic transforms, plus rollback support. - `web/src/drag.ts`: native HTML5 drag-and-drop helpers — `cardDragType` MIME, `beginCardDrag`/`readCardDrag` payloads, and `movePosition` (computes the new fractional position between drop neighbours). -- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer, wires optimistic mutations with rollback + toasts, and renders `Header` + `BoardCanvas` + `Toasts`. -- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, and reload button. -- `web/src/components/BoardCanvas.tsx`: horizontally-scrolling list rail, inline "add list" affordance, and the empty/loading states. Maps lists to `ListColumn`. -- `web/src/components/ListColumn.tsx`: one column — inline list-name edit, card count, drop zones (one before each card + one at the end), inline add-card input, and per-list delete confirmation. -- `web/src/components/CardItem.tsx`: one draggable card — click-to-edit title, done toggle, expand to `CardEditor`, and delete confirmation. -- `web/src/components/CardEditor.tsx`: description editor and destructive (delete) action for a card. +- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer and selected-card/list-management UI state, wires optimistic mutations with rollback + toasts, and renders `Header`, `BoardCanvas`, the selected card's `CardDetailModal`, and `Toasts`. +- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, reload button, and the active/inactive Edit toggle for list management. +- `web/src/components/BoardCanvas.tsx`: responsive horizontally-scrolling list rail, management-mode-only add-list affordance, and board empty state. Maps lists to `ListColumn`. +- `web/src/components/ListColumn.tsx`: one column — card count, native drop zones (one before each card + one at the end), inline add-card input, empty-column state, and management-mode-only list rename/delete controls with delete confirmation. +- `web/src/components/CardItem.tsx`: one draggable card tile. A genuine click opens the board-level detail dialog; drag events are suppressed from opening it. It retains the done toggle, title/description preview, and a visible drag grip. +- `web/src/components/CardDetailModal.tsx`: accessible Jira-style card dialog with inline title and description editing, list/status properties, timestamps, delete confirmation, outside/Escape/X close controls, and modal focus restoration. - `web/src/components/ConfirmDialog.tsx`: accessible confirmation dialog used before any delete. - `web/src/components/Toast.tsx`: non-blocking toast notifications (auto-dismiss ~4.4s). -- `web/src/styles.css`: all styling. Dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), CSS custom properties, rounded surfaces, soft shadows, focus rings, and `prefers-reduced-motion` support. 44px touch targets for mobile. +- `web/src/styles.css`: all styling. Elevated dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), responsive horizontal board rail, polished card/list/button states, edit-mode treatment, detail-dialog layout, empty states, focus rings, 44px mobile touch targets, and `prefers-reduced-motion` support. ### Embedding bridge @@ -109,7 +109,9 @@ Error shape: `{ "error": { "code": "validation_error"|"not_found"|..., "message" - Loads `/api/board` on mount; no board picker (single fixed board). - Native HTML5 drag-and-drop reorders cards within and across lists; drop computes a fractional position, updates optimistically, and reconciles/rolls back on failure. -- Inline add/edit for lists and cards, done toggle, descriptions, delete-with-confirm. +- Clicking a card opens a centered, accessible Jira-style detail dialog. Its title and description patch independently, while list, done status, created/updated timestamps, and delete-with-confirm are visible in the dialog. It closes via X, Escape, or outside click, focuses on open, and restores the prior trigger focus when closed. +- The Header's Edit toggle enters list-management mode. Only in that mode are list rename/delete controls and the end-of-rail add-list control visible; card add, done, detail editing, delete, and drag-and-drop remain available in either mode. +- Inline add for cards, done toggle, descriptions, and delete-with-confirm. There is no inline card expansion/editor; card editing is centralized in the detail dialog. - Optimistic UI: every mutation updates state immediately, then reconciles with the server response; failures restore the prior snapshot and show an error toast. Only the pending resource's controls disable. - Responsive horizontally-scrolling board; usable on phones. diff --git a/web/src/App.tsx b/web/src/App.tsx index c68fa10..a3d568a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useReducer, useRef } from "preact/hooks"; +import { useCallback, useEffect, useReducer, useRef, useState } from "preact/hooks"; import { api, APIError } from "./api"; import { addCard, @@ -14,6 +14,7 @@ import { import { movePosition } from "./drag"; import type { CardPatch, ListPatch } from "./types"; import { BoardCanvas } from "./components/BoardCanvas"; +import { CardDetailModal } from "./components/CardDetailModal"; import { Header } from "./components/Header"; import { Toasts } from "./components/Toast"; @@ -27,9 +28,18 @@ function friendlyError(error: unknown): string { export function App() { const [state, dispatch] = useReducer(boardReducer, initialBoardState); + const [managingLists, setManagingLists] = useState(false); + const [selectedCardID, setSelectedCardID] = useState(null); const boardRef = useRef(state.board); boardRef.current = state.board; + const selectedList = state.board?.lists.find((list) => list.cards.some((card) => card.id === selectedCardID)); + const selectedCard = selectedList?.cards.find((card) => card.id === selectedCardID); + + useEffect(() => { + if (selectedCardID !== null && !selectedCard) setSelectedCardID(null); + }, [selectedCardID, selectedCard]); + const toast = useCallback((message: string, tone: "success" | "error" | "info" = "info") => { const id = ++toastID; dispatch({ type: "toast", toast: { id, message, tone } }); @@ -141,7 +151,12 @@ export function App() { return (
-
+
setManagingLists((active) => !active)} + /> {state.loading && !state.board ?
Loading your Haven board…
: null} {state.error ? (
@@ -158,12 +173,21 @@ export function App() { onDeleteList={deleteList} onCreateCard={createCard} onEditCard={editCard} - onDeleteCard={deleteCard} onMoveCard={moveCard} + onOpenCard={setSelectedCardID} + managingLists={managingLists} + /> + ) : null} + {selectedCard && selectedList ? ( + setSelectedCardID(null)} + onEdit={(patch) => editCard(selectedCard.id, patch)} + onDelete={() => deleteCard(selectedCard.id)} /> ) : null} dispatch({ type: "dismiss", id })} />
); } - diff --git a/web/src/components/BoardCanvas.tsx b/web/src/components/BoardCanvas.tsx index c3930c5..8b82052 100644 --- a/web/src/components/BoardCanvas.tsx +++ b/web/src/components/BoardCanvas.tsx @@ -1,4 +1,4 @@ -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import type { ListWithCards } from "../types"; import { ListColumn } from "./ListColumn"; @@ -9,8 +9,9 @@ interface BoardCanvasProps { onDeleteList: (id: number) => Promise; onCreateCard: (listID: number, title: string) => Promise; onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise; - onDeleteCard: (id: number) => Promise; onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise; + onOpenCard: (id: number) => void; + managingLists: boolean; } export function BoardCanvas(props: BoardCanvasProps) { @@ -18,6 +19,13 @@ export function BoardCanvas(props: BoardCanvasProps) { const [name, setName] = useState(""); const [saving, setSaving] = useState(false); + useEffect(() => { + if (!props.managingLists) { + setAdding(false); + setName(""); + } + }, [props.managingLists]); + const addList = async () => { if (!name.trim()) return; setSaving(true); @@ -39,22 +47,26 @@ export function BoardCanvas(props: BoardCanvasProps) {

One calm place for your homelab work

Haven board

-

Drag tasks between lists, or click a title to edit.

+

+ {props.managingLists ? "Manage your workflow: rename, remove, or add lists." : "Drag tasks between lists, or open one for the full story."} +

-
- {props.lists.map((list) => ( - props.onEditList(list.id, patch)} - onDeleteList={() => props.onDeleteList(list.id)} - onCreateCard={(title) => props.onCreateCard(list.id, title)} - onEditCard={props.onEditCard} - onDeleteCard={props.onDeleteCard} - onMoveCard={props.onMoveCard} - /> - ))} -
+ {props.lists.length ? ( +
+ {props.lists.map((list) => ( + props.onEditList(list.id, patch)} + onDeleteList={() => props.onDeleteList(list.id)} + onCreateCard={(title) => props.onCreateCard(list.id, title)} + onEditCard={props.onEditCard} + onMoveCard={props.onMoveCard} + onOpenCard={props.onOpenCard} + /> + ))} + {props.managingLists ?
{adding ? (
) : ( )} +
: null} +
+ ) : ( +
+ +

Your board is ready for a first list

+

Turn on Edit to create the first stage in your workflow.

-
+ )} ); } diff --git a/web/src/components/CardDetailModal.tsx b/web/src/components/CardDetailModal.tsx new file mode 100644 index 0000000..b49b38e --- /dev/null +++ b/web/src/components/CardDetailModal.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import type { Card } from "../types"; +import { ConfirmDialog } from "./ConfirmDialog"; + +interface CardDetailModalProps { + card: Card; + listName: string; + onClose: () => void; + onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise; + onDelete: () => Promise; +} + +function formatTimestamp(value: string): string { + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) return "Unavailable"; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(timestamp); +} + +export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: CardDetailModalProps) { + const [title, setTitle] = useState(card.title); + const [description, setDescription] = useState(card.description); + const [savingTitle, setSavingTitle] = useState(false); + const [savingDescription, setSavingDescription] = useState(false); + const [savingStatus, setSavingStatus] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const dialogRef = useRef(null); + const returnFocusRef = useRef(null); + const confirmingDeleteRef = useRef(false); + const discardTitleSaveRef = useRef(false); + const titleSavingRef = useRef(false); + const closeRef = useRef(onClose); + confirmingDeleteRef.current = confirmingDelete; + closeRef.current = onClose; + + useEffect(() => { + setTitle(card.title); + setDescription(card.description); + }, [card.id]); + + useEffect(() => { + returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusDialog = window.requestAnimationFrame(() => dialogRef.current?.focus()); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape" && !confirmingDeleteRef.current) closeRef.current(); + }; + window.addEventListener("keydown", closeOnEscape); + return () => { + window.cancelAnimationFrame(focusDialog); + window.removeEventListener("keydown", closeOnEscape); + returnFocusRef.current?.focus(); + }; + }, []); + + const saveTitle = async () => { + const nextTitle = title.trim(); + if (!nextTitle) { + setTitle(card.title); + return; + } + if (nextTitle === card.title || titleSavingRef.current) return; + titleSavingRef.current = true; + setSavingTitle(true); + try { + await onEdit({ title: nextTitle }); + setTitle(nextTitle); + } catch { + setTitle(card.title); + } finally { + titleSavingRef.current = false; + setSavingTitle(false); + } + }; + + const saveDescription = async () => { + if (description === card.description) return; + setSavingDescription(true); + try { + await onEdit({ description }); + } catch { + setDescription(card.description); + } finally { + setSavingDescription(false); + } + }; + + const close = () => { + const nextTitle = title.trim(); + if (!discardTitleSaveRef.current && nextTitle && nextTitle !== card.title) void saveTitle(); + onClose(); + }; + closeRef.current = close; + + const toggleDone = async () => { + setSavingStatus(true); + try { + await onEdit({ done: !card.done }); + } catch { + // The app restores the card and shows the request error as a toast. + } finally { + setSavingStatus(false); + } + }; + + const deleteCard = async () => { + setConfirmingDelete(false); + try { + await onDelete(); + onClose(); + } catch { + // The app restores the card and shows the request error as a toast. + } + }; + + const descriptionChanged = description !== card.description; + + return ( +