feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
Some checks failed
Build and deploy Havenllo / Verify Go and frontend (push) Failing after 7s
Build and deploy Havenllo / Build and push image (push) Has been skipped
Build and deploy Havenllo / Apply and restart Havenllo (push) Has been skipped

- 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:
Hermes
2026-07-14 10:13:51 -03:00
commit 041c3fab4b
44 changed files with 5931 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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()}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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()}
/>
</>
);
}

View 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>;
}