feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board - Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api - Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme - PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven - Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
This commit is contained in:
95
web/src/components/BoardCanvas.tsx
Normal file
95
web/src/components/BoardCanvas.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import type { ListWithCards } from "../types";
|
||||
import { ListColumn } from "./ListColumn";
|
||||
|
||||
interface BoardCanvasProps {
|
||||
lists: ListWithCards[];
|
||||
onCreateList: (name: string) => Promise<void>;
|
||||
onEditList: (id: number, patch: { name?: string; position?: number }) => Promise<void>;
|
||||
onDeleteList: (id: number) => Promise<void>;
|
||||
onCreateCard: (listID: number, title: string) => Promise<void>;
|
||||
onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
|
||||
onDeleteCard: (id: number) => Promise<void>;
|
||||
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function BoardCanvas(props: BoardCanvasProps) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const addList = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await props.onCreateList(name);
|
||||
setName("");
|
||||
setAdding(false);
|
||||
} catch {
|
||||
// App has shown an error toast.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class="board-area" aria-label="Haven task board">
|
||||
<div class="board-intro">
|
||||
<div>
|
||||
<p class="eyebrow">One calm place for your homelab work</p>
|
||||
<h1>Haven board</h1>
|
||||
</div>
|
||||
<p class="board-hint">Drag tasks between lists, or click a title to edit.</p>
|
||||
</div>
|
||||
<div class="board-rail">
|
||||
{props.lists.map((list) => (
|
||||
<ListColumn
|
||||
key={list.id}
|
||||
list={list}
|
||||
onEditList={(patch) => 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}
|
||||
/>
|
||||
))}
|
||||
<section class="add-list-column">
|
||||
{adding ? (
|
||||
<form
|
||||
class="quick-add"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void addList();
|
||||
}}
|
||||
>
|
||||
<label class="sr-only" htmlFor="new-list-name">New list name</label>
|
||||
<input
|
||||
id="new-list-name"
|
||||
value={name}
|
||||
maxlength={120}
|
||||
autoFocus
|
||||
placeholder="List name"
|
||||
onInput={(event) => setName(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setAdding(false);
|
||||
setName("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="quick-add-actions">
|
||||
<button type="submit" class="button button-primary" disabled={saving}>Add list</button>
|
||||
<button type="button" class="button button-quiet" disabled={saving} onClick={() => setAdding(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" class="add-list-button" onClick={() => setAdding(true)}>
|
||||
<span aria-hidden="true">+</span> Add another list
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
51
web/src/components/CardEditor.tsx
Normal file
51
web/src/components/CardEditor.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import type { Card } from "../types";
|
||||
|
||||
interface CardEditorProps {
|
||||
card: Card;
|
||||
onSave: (description: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function CardEditor({ card, onSave, onClose, onDelete }: CardEditorProps) {
|
||||
const [description, setDescription] = useState(card.description);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => setDescription(card.description), [card.id, card.description]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(description);
|
||||
onClose();
|
||||
} catch {
|
||||
// App already surfaced an actionable toast and has rolled the state back.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class="card-editor" aria-label={"Edit " + card.title}>
|
||||
<label>
|
||||
Notes
|
||||
<textarea
|
||||
value={description}
|
||||
maxlength={10000}
|
||||
rows={4}
|
||||
placeholder="Add useful context…"
|
||||
onInput={(event) => setDescription(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<div class="editor-actions">
|
||||
<button type="button" class="button button-primary" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? "Saving…" : "Save notes"}
|
||||
</button>
|
||||
<button type="button" class="button button-quiet" disabled={saving} onClick={onClose}>Close</button>
|
||||
<button type="button" class="text-danger" disabled={saving} onClick={onDelete}>Delete card</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
113
web/src/components/CardItem.tsx
Normal file
113
web/src/components/CardItem.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { beginCardDrag } from "../drag";
|
||||
import type { Card } from "../types";
|
||||
import { CardEditor } from "./CardEditor";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
interface CardItemProps {
|
||||
card: Card;
|
||||
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function CardItem({ card, onEdit, onDelete }: CardItemProps) {
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [title, setTitle] = useState(card.title);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onEdit({ title });
|
||||
setEditingTitle(false);
|
||||
} catch {
|
||||
setTitle(card.title);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setConfirming(false);
|
||||
try {
|
||||
await onDelete();
|
||||
} catch {
|
||||
// The global mutation handler reports the failure.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
class={"card " + (card.done ? "card-done" : "")}
|
||||
draggable={!editingTitle}
|
||||
onDragStart={(event) => beginCardDrag(event as unknown as DragEvent, card)}
|
||||
>
|
||||
<div class="card-topline">
|
||||
<button
|
||||
type="button"
|
||||
class={"done-button " + (card.done ? "is-done" : "")}
|
||||
aria-label={card.done ? "Mark card active" : "Mark card done"}
|
||||
title={card.done ? "Mark active" : "Mark done"}
|
||||
disabled={saving}
|
||||
onClick={() => void onEdit({ done: !card.done })}
|
||||
>
|
||||
{card.done ? "✓" : ""}
|
||||
</button>
|
||||
<div class="card-title-wrap">
|
||||
{editingTitle ? (
|
||||
<input
|
||||
class="card-title-input"
|
||||
value={title}
|
||||
maxlength={240}
|
||||
aria-label="Card title"
|
||||
autoFocus
|
||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void saveTitle();
|
||||
if (event.key === "Escape") {
|
||||
setTitle(card.title);
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => void saveTitle()}
|
||||
/>
|
||||
) : (
|
||||
<button type="button" class="card-title" onClick={() => setEditingTitle(true)}>{card.title}</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="card-menu"
|
||||
title="Open card details"
|
||||
aria-label="Open card details"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
•••
|
||||
</button>
|
||||
</div>
|
||||
{card.description && !expanded ? <p class="card-preview">{card.description}</p> : null}
|
||||
{expanded ? (
|
||||
<CardEditor
|
||||
card={card}
|
||||
onSave={(description) => onEdit({ description })}
|
||||
onClose={() => setExpanded(false)}
|
||||
onDelete={() => setConfirming(true)}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Delete this card?"
|
||||
detail={"“" + card.title + "” will be permanently removed."}
|
||||
confirmLabel="Delete card"
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => void remove()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
50
web/src/components/ConfirmDialog.tsx
Normal file
50
web/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
confirmLabel: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
detail,
|
||||
confirmLabel,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: ConfirmDialogProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div class="dialog-backdrop" role="presentation" onMouseDown={onCancel}>
|
||||
<section
|
||||
class="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-detail"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 id="confirm-title">{title}</h2>
|
||||
<p id="confirm-detail">{detail}</p>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="button button-secondary" onClick={onCancel}>Cancel</button>
|
||||
<button type="button" class="button button-danger" onClick={onConfirm}>{confirmLabel}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
30
web/src/components/Header.tsx
Normal file
30
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
interface HeaderProps {
|
||||
loading: boolean;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
export function Header({ loading, onReload }: HeaderProps) {
|
||||
return (
|
||||
<header class="topbar">
|
||||
<div class="brand" aria-label="Havenllo">
|
||||
<span class="brand-mark" aria-hidden="true">H</span>
|
||||
<span>havenllo</span>
|
||||
</div>
|
||||
<div class="topbar-board">
|
||||
<span class="eyebrow">Haven homelab</span>
|
||||
<strong>My board</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button refresh-button"
|
||||
title="Refresh board"
|
||||
aria-label="Refresh board"
|
||||
disabled={loading}
|
||||
onClick={onReload}
|
||||
>
|
||||
<span aria-hidden="true">↻</span>
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
196
web/src/components/ListColumn.tsx
Normal file
196
web/src/components/ListColumn.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { cardDragType, readCardDrag } from "../drag";
|
||||
import type { Card, ListWithCards } from "../types";
|
||||
import { CardItem } from "./CardItem";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
interface ListColumnProps {
|
||||
list: ListWithCards;
|
||||
onEditList: (patch: { name?: string; position?: number }) => Promise<void>;
|
||||
onDeleteList: () => Promise<void>;
|
||||
onCreateCard: (title: string) => Promise<void>;
|
||||
onEditCard: (cardID: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
|
||||
onDeleteCard: (cardID: number) => Promise<void>;
|
||||
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface DropZoneProps {
|
||||
index: number;
|
||||
onDropCard: (cardID: number, sourceListID: number, index: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function DropZone({ index, onDropCard }: DropZoneProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
return (
|
||||
<div
|
||||
class={"drop-zone " + (active ? "is-active" : "")}
|
||||
aria-label="Drop card here"
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer?.types.includes(cardDragType)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setActive(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setActive(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setActive(false);
|
||||
const payload = readCardDrag(event as unknown as DragEvent);
|
||||
if (payload) void onDropCard(payload.cardID, payload.sourceListID, index);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListColumn({
|
||||
list,
|
||||
onEditList,
|
||||
onDeleteList,
|
||||
onCreateCard,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onMoveCard
|
||||
}: ListColumnProps) {
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [name, setName] = useState(list.name);
|
||||
const [addingCard, setAddingCard] = useState(false);
|
||||
const [cardTitle, setCardTitle] = useState("");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const saveName = async () => {
|
||||
if (!name.trim() || name.trim() === list.name) {
|
||||
setName(list.name);
|
||||
setEditingName(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onEditList({ name });
|
||||
setEditingName(false);
|
||||
} catch {
|
||||
setName(list.name);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addCard = async () => {
|
||||
if (!cardTitle.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onCreateCard(cardTitle);
|
||||
setCardTitle("");
|
||||
setAddingCard(false);
|
||||
} catch {
|
||||
// App has shown an error toast.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setConfirming(false);
|
||||
try {
|
||||
await onDeleteList();
|
||||
} catch {
|
||||
// App has shown an error toast.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class="list-column">
|
||||
<div class="list-header">
|
||||
<div class="list-heading">
|
||||
{editingName ? (
|
||||
<input
|
||||
value={name}
|
||||
maxlength={120}
|
||||
autoFocus
|
||||
aria-label="List name"
|
||||
onInput={(event) => setName(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void saveName();
|
||||
if (event.key === "Escape") {
|
||||
setName(list.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => void saveName()}
|
||||
/>
|
||||
) : (
|
||||
<button type="button" class="list-name" onClick={() => setEditingName(true)}>{list.name}</button>
|
||||
)}
|
||||
<span class="count-badge">{list.cards.length}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button list-delete"
|
||||
aria-label={"Delete " + list.name}
|
||||
title="Delete list"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="cards-stack">
|
||||
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} />
|
||||
{list.cards.map((card: Card, index) => (
|
||||
<div class="card-slot" key={card.id}>
|
||||
<CardItem
|
||||
card={card}
|
||||
onEdit={(patch) => onEditCard(card.id, patch)}
|
||||
onDelete={() => onDeleteCard(card.id)}
|
||||
/>
|
||||
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
|
||||
</div>
|
||||
))}
|
||||
{!list.cards.length ? <p class="list-empty">Drop a card here or add the first task.</p> : null}
|
||||
</div>
|
||||
{addingCard ? (
|
||||
<form
|
||||
class="quick-add"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void addCard();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={cardTitle}
|
||||
maxlength={240}
|
||||
autoFocus
|
||||
placeholder="What needs doing?"
|
||||
aria-label="New card title"
|
||||
onInput={(event) => setCardTitle(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setAddingCard(false);
|
||||
setCardTitle("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="quick-add-actions">
|
||||
<button type="submit" class="button button-primary" disabled={saving}>Add card</button>
|
||||
<button type="button" class="button button-quiet" disabled={saving} onClick={() => setAddingCard(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" class="add-card-button" onClick={() => setAddingCard(true)}>
|
||||
<span aria-hidden="true">+</span> Add a card
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title={"Delete “" + list.name + "”? "}
|
||||
detail="All cards in this list will be permanently removed."
|
||||
confirmLabel="Delete list"
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => void remove()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
25
web/src/components/Toast.tsx
Normal file
25
web/src/components/Toast.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "preact/hooks";
|
||||
import type { ToastMessage } from "../board-state";
|
||||
|
||||
interface ToastsProps {
|
||||
toasts: ToastMessage[];
|
||||
onDismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
function Toast({ toast, onDismiss }: { toast: ToastMessage; onDismiss: (id: number) => void }) {
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => onDismiss(toast.id), 4400);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [toast.id, onDismiss]);
|
||||
return (
|
||||
<div class={"toast toast-" + toast.tone} role="status">
|
||||
<span>{toast.message}</span>
|
||||
<button type="button" aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toasts({ toasts, onDismiss }: ToastsProps) {
|
||||
return <aside class="toast-stack" aria-live="polite">{toasts.map((toast) => <Toast key={toast.id} toast={toast} onDismiss={onDismiss} />)}</aside>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user