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