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

169
web/src/App.tsx Normal file
View File

@@ -0,0 +1,169 @@
import { useCallback, useEffect, useReducer, useRef } from "preact/hooks";
import { api, APIError } from "./api";
import {
addCard,
addList,
boardReducer,
cloneBoard,
initialBoardState,
removeCard,
removeList,
updateCard,
updateList
} from "./board-state";
import { movePosition } from "./drag";
import type { CardPatch, ListPatch } from "./types";
import { BoardCanvas } from "./components/BoardCanvas";
import { Header } from "./components/Header";
import { Toasts } from "./components/Toast";
let toastID = 0;
function friendlyError(error: unknown): string {
if (error instanceof APIError) return error.message;
if (error instanceof Error) return error.message;
return "Could not save that change. Please try again.";
}
export function App() {
const [state, dispatch] = useReducer(boardReducer, initialBoardState);
const boardRef = useRef(state.board);
boardRef.current = state.board;
const toast = useCallback((message: string, tone: "success" | "error" | "info" = "info") => {
const id = ++toastID;
dispatch({ type: "toast", toast: { id, message, tone } });
window.setTimeout(() => dispatch({ type: "dismiss", id }), 4200);
}, []);
const load = useCallback(async () => {
dispatch({ type: "loading" });
try {
dispatch({ type: "loaded", board: await api.board() });
} catch (error) {
dispatch({ type: "error", error: friendlyError(error) });
}
}, []);
useEffect(() => { void load(); }, [load]);
const mutate = useCallback(async (
makeOptimistic: (current: NonNullable<typeof boardRef.current>) => NonNullable<typeof boardRef.current>,
request: () => Promise<void>,
success?: string
) => {
const before = boardRef.current;
if (!before) return;
dispatch({ type: "replace", board: makeOptimistic(cloneBoard(before)) });
try {
await request();
if (success) toast(success, "success");
} catch (error) {
dispatch({ type: "replace", board: before });
toast(friendlyError(error), "error");
throw error;
}
}, [toast]);
const createList = async (name: string) => {
const current = boardRef.current;
if (!current) return;
const position = Math.max(0, ...current.lists.map((list) => list.position)) + 1024;
try {
const { list } = await api.createList(name, position);
const board = boardRef.current;
if (board) dispatch({ type: "replace", board: addList(board, list) });
toast("List added", "success");
} catch (error) {
toast(friendlyError(error), "error");
throw error;
}
};
const editList = async (id: number, patch: ListPatch) => {
const old = boardRef.current?.lists.find((list) => list.id === id);
if (!old) return;
await mutate(
(board) => updateList(board, { ...old, ...patch }),
async () => {
const { list } = await api.patchList(id, patch);
const current = boardRef.current;
if (current) dispatch({ type: "replace", board: updateList(current, list) });
}
);
};
const deleteList = async (id: number) => {
await mutate((board) => removeList(board, id), () => api.deleteList(id), "List deleted");
};
const createCard = async (listID: number, title: string) => {
const current = boardRef.current;
const list = current?.lists.find((item) => item.id === listID);
if (!list) return;
const position = Math.max(0, ...list.cards.map((card) => card.position)) + 1024;
try {
const { card } = await api.createCard(listID, title, "", position);
const board = boardRef.current;
if (board) dispatch({ type: "replace", board: addCard(board, card) });
toast("Card added", "success");
} catch (error) {
toast(friendlyError(error), "error");
throw error;
}
};
const editCard = async (id: number, patch: CardPatch) => {
const old = boardRef.current?.lists.flatMap((list) => list.cards).find((card) => card.id === id);
if (!old) return;
const optimistic = { ...old, ...patch, list_id: patch.list_id ?? old.list_id };
await mutate(
(board) => updateCard(board, optimistic),
async () => {
const { card } = await api.patchCard(id, patch);
const current = boardRef.current;
if (current) dispatch({ type: "replace", board: updateCard(current, card) });
}
);
};
const deleteCard = async (id: number) => {
await mutate((board) => removeCard(board, id), () => api.deleteCard(id), "Card deleted");
};
const moveCard = async (cardID: number, sourceListID: number, targetListID: number, dropIndex: number) => {
const current = boardRef.current;
const card = current?.lists.flatMap((list) => list.cards).find((item) => item.id === cardID);
if (!current || !card) return;
const position = movePosition(current.lists, cardID, sourceListID, targetListID, dropIndex);
await editCard(cardID, { list_id: targetListID, position });
};
return (
<main class="app-shell">
<Header onReload={load} loading={state.loading} />
{state.loading && !state.board ? <section class="loading-card" aria-live="polite">Loading your Haven board</section> : null}
{state.error ? (
<section class="fatal-state">
<h1>Havenllo could not reach its board</h1>
<p>{state.error}</p>
<button type="button" class="button button-primary" onClick={() => void load()}>Try again</button>
</section>
) : null}
{state.board ? (
<BoardCanvas
lists={state.board.lists}
onCreateList={createList}
onEditList={editList}
onDeleteList={deleteList}
onCreateCard={createCard}
onEditCard={editCard}
onDeleteCard={deleteCard}
onMoveCard={moveCard}
/>
) : null}
<Toasts toasts={state.toasts} onDismiss={(id) => dispatch({ type: "dismiss", id })} />
</main>
);
}

63
web/src/api.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { Board, Card, CardPatch, List, ListPatch } from "./types";
export class APIError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string
) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch("/api" + path, {
...init,
headers: {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers
}
});
if (!response.ok) {
let message = "Something went wrong";
let code = "request_failed";
try {
const payload = (await response.json()) as { error?: { message?: string; code?: string } };
message = payload.error?.message || message;
code = payload.error?.code || code;
} catch {
// The server's JSON error body is preferred, but network intermediaries may not have one.
}
throw new APIError(message, response.status, code);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
const json = (body: object): RequestInit => ({
method: "POST",
body: JSON.stringify(body)
});
export const api = {
board: () => request<Board>("/board"),
lists: () => request<{ lists: List[] }>("/lists"),
createList: (name: string, position?: number) =>
request<{ list: List }>("/lists", json({ name, ...(position === undefined ? {} : { position }) })),
patchList: (id: number, patch: ListPatch) =>
request<{ list: List }>("/lists/" + id, { method: "PATCH", body: JSON.stringify(patch) }),
deleteList: (id: number) => request<void>("/lists/" + id, { method: "DELETE" }),
listCards: (id: number) => request<{ cards: Card[] }>("/lists/" + id + "/cards"),
createCard: (listID: number, title: string, description = "", position?: number) =>
request<{ card: Card }>("/lists/" + listID + "/cards", json({
title,
description,
...(position === undefined ? {} : { position })
})),
patchCard: (id: number, patch: CardPatch) =>
request<{ card: Card }>("/cards/" + id, { method: "PATCH", body: JSON.stringify(patch) }),
deleteCard: (id: number) => request<void>("/cards/" + id, { method: "DELETE" })
};

108
web/src/board-state.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { Board, Card, List, ListWithCards } from "./types";
export type ToastTone = "success" | "error" | "info";
export interface ToastMessage {
id: number;
tone: ToastTone;
message: string;
}
export interface BoardState {
board: Board | null;
loading: boolean;
error: string | null;
toasts: ToastMessage[];
}
export type BoardAction =
| { type: "loading" }
| { type: "loaded"; board: Board }
| { type: "replace"; board: Board }
| { type: "error"; error: string }
| { type: "toast"; toast: ToastMessage }
| { type: "dismiss"; id: number };
export const initialBoardState: BoardState = {
board: null,
loading: true,
error: null,
toasts: []
};
export function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case "loading":
return { ...state, loading: true, error: null };
case "loaded":
return { ...state, board: action.board, loading: false, error: null };
case "replace":
return { ...state, board: action.board };
case "error":
return { ...state, loading: false, error: action.error };
case "toast":
return { ...state, toasts: [...state.toasts, action.toast] };
case "dismiss":
return { ...state, toasts: state.toasts.filter((toast) => toast.id !== action.id) };
}
}
export function cloneBoard(board: Board): Board {
return {
lists: board.lists.map((list) => ({ ...list, cards: list.cards.map((card) => ({ ...card })) }))
};
}
export function orderedLists(lists: ListWithCards[]): ListWithCards[] {
return [...lists].sort((a, b) => a.position - b.position || a.id - b.id);
}
export function orderedCards(cards: Card[]): Card[] {
return [...cards].sort((a, b) => a.position - b.position || a.id - b.id);
}
export function addList(board: Board, list: List): Board {
return { lists: orderedLists([...board.lists, { ...list, cards: [] }]) };
}
export function updateList(board: Board, list: List): Board {
return {
lists: orderedLists(board.lists.map((current) => current.id === list.id
? { ...current, ...list }
: current))
};
}
export function removeList(board: Board, listID: number): Board {
return { lists: board.lists.filter((list) => list.id !== listID) };
}
export function addCard(board: Board, card: Card): Board {
return {
lists: board.lists.map((list) => list.id === card.list_id
? { ...list, cards: orderedCards([...list.cards, card]) }
: list)
};
}
export function updateCard(board: Board, card: Card): Board {
const without = board.lists.map((list) => ({
...list,
cards: list.cards.filter((current) => current.id !== card.id)
}));
return {
lists: without.map((list) => list.id === card.list_id
? { ...list, cards: orderedCards([...list.cards, card]) }
: list)
};
}
export function removeCard(board: Board, cardID: number): Board {
return {
lists: board.lists.map((list) => ({
...list,
cards: list.cards.filter((card) => card.id !== cardID)
}))
};
}

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

50
web/src/drag.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Card, ListWithCards } from "./types";
export const cardDragType = "application/x-havenllo-card";
export interface CardDragPayload {
cardID: number;
sourceListID: number;
}
export function beginCardDrag(event: DragEvent, card: Card): void {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(cardDragType, JSON.stringify({ cardID: card.id, sourceListID: card.list_id }));
event.dataTransfer.setData("text/plain", String(card.id));
}
export function readCardDrag(event: DragEvent): CardDragPayload | null {
try {
const raw = event.dataTransfer?.getData(cardDragType);
if (!raw) return null;
const payload = JSON.parse(raw) as CardDragPayload;
if (!Number.isInteger(payload.cardID) || !Number.isInteger(payload.sourceListID)) return null;
return payload;
} catch {
return null;
}
}
export function movePosition(
lists: ListWithCards[],
cardID: number,
sourceListID: number,
targetListID: number,
dropIndex: number
): number {
const target = lists.find((list) => list.id === targetListID);
if (!target) return 1024;
const sourceIndex = target.cards.findIndex((card) => card.id === cardID);
const cards = target.cards.filter((card) => card.id !== cardID);
let index = dropIndex;
if (sourceListID === targetListID && sourceIndex !== -1 && sourceIndex < dropIndex) index -= 1;
index = Math.max(0, Math.min(index, cards.length));
const previous = cards[index - 1];
const next = cards[index];
if (!previous && !next) return 1024;
if (!previous) return next.position - 1024;
if (!next) return previous.position + 1024;
return (previous.position + next.position) / 2;
}

12
web/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { render } from "preact";
import { App } from "./App";
import "./styles.css";
const root = document.getElementById("app");
if (!root) {
throw new Error("Havenllo root element is missing");
}
render(<App />, root);

425
web/src/styles.css Normal file
View File

@@ -0,0 +1,425 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
background: #0a1020;
color: #e9effc;
--ink: #e9effc;
--muted: #97a6c1;
--faint: #63728d;
--canvas: #0a1020;
--surface: #111b2d;
--surface-raised: #17243a;
--surface-hover: #1c2c46;
--border: rgba(151, 174, 211, 0.16);
--border-strong: rgba(109, 211, 200, 0.42);
--teal: #62d5c7;
--teal-deep: #168c86;
--indigo: #8097ff;
--danger: #ff7888;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
}
* { box-sizing: border-box; }
html, body, #app { min-height: 100%; }
body {
min-width: 320px;
margin: 0;
background:
radial-gradient(circle at 85% -10%, rgba(72, 115, 195, 0.24), transparent 32rem),
radial-gradient(circle at 0% 100%, rgba(24, 119, 116, 0.15), transparent 26rem),
var(--canvas);
}
button, input, textarea { font: inherit; }
button { color: inherit; }
button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; }
button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 3px solid rgba(98, 213, 199, 0.65);
outline-offset: 2px;
}
button:disabled { cursor: not-allowed; opacity: 0.58; }
.app-shell { min-height: 100vh; }
.topbar {
min-height: 72px;
display: flex;
align-items: center;
gap: 18px;
padding: 12px clamp(18px, 4vw, 52px);
border-bottom: 1px solid var(--border);
background: rgba(10, 16, 32, 0.78);
backdrop-filter: blur(14px);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 1.28rem;
font-weight: 760;
letter-spacing: -0.045em;
}
.brand-mark {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 11px;
color: #09111e;
font-size: 1.06rem;
font-weight: 900;
background: linear-gradient(135deg, var(--teal), var(--indigo));
box-shadow: 0 6px 20px rgba(98, 213, 199, 0.24);
}
.topbar-board {
display: grid;
gap: 1px;
padding-left: 18px;
border-left: 1px solid var(--border);
}
.topbar-board strong { font-size: 0.92rem; }
.eyebrow {
margin: 0;
color: var(--teal);
font-size: 0.69rem;
font-weight: 760;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.refresh-button { margin-left: auto; }
.icon-button {
width: 40px;
height: 40px;
padding: 0;
display: inline-grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
color: var(--muted);
background: transparent;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.icon-button:hover { color: var(--ink); border-color: var(--border-strong); background: var(--surface-hover); }
.board-area { padding: clamp(24px, 4vw, 50px); }
.board-intro {
max-width: 1500px;
margin: 0 auto 24px;
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.board-intro h1 { margin: 4px 0 0; font-size: clamp(1.65rem, 3vw, 2.2rem); letter-spacing: -0.045em; }
.board-hint { max-width: 315px; margin: 0; color: var(--muted); font-size: 0.9rem; text-align: right; }
.board-rail {
width: max-content;
min-width: 100%;
max-width: 1500px;
margin: 0 auto;
display: flex;
align-items: flex-start;
gap: 16px;
padding-bottom: 34px;
}
.list-column, .add-list-column {
width: min(310px, calc(100vw - 48px));
flex: 0 0 310px;
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(17, 27, 45, 0.84);
box-shadow: var(--shadow);
}
.list-column { padding: 14px; }
.list-header, .list-heading, .card-topline, .editor-actions, .quick-add-actions, .dialog-actions {
display: flex;
align-items: center;
}
.list-header { justify-content: space-between; gap: 8px; padding: 0 1px 9px; }
.list-heading { min-width: 0; gap: 8px; }
.list-name, .card-title {
min-width: 0;
padding: 0;
overflow: hidden;
border: 0;
color: var(--ink);
font-weight: 740;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
background: transparent;
}
.list-name { font-size: 1rem; }
.list-heading input { width: 190px; }
.count-badge {
min-width: 23px;
padding: 2px 7px;
border-radius: 999px;
color: var(--muted);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
text-align: center;
background: rgba(126, 151, 190, 0.12);
}
.list-delete { width: 30px; height: 30px; border-color: transparent; font-size: 1.25rem; }
.list-delete:hover { color: var(--danger); border-color: rgba(255, 120, 136, 0.25); }
.cards-stack { min-height: 40px; }
.card-slot { position: relative; }
.drop-zone {
height: 8px;
margin: 1px 0;
border-radius: 6px;
transition: height 120ms ease, background 120ms ease;
}
.drop-zone.is-active { height: 22px; background: rgba(98, 213, 199, 0.2); outline: 1px dashed var(--teal); }
.card {
padding: 11px;
border: 1px solid rgba(161, 185, 220, 0.16);
border-radius: 12px;
background: linear-gradient(145deg, rgba(28, 44, 70, 0.94), rgba(18, 30, 49, 0.94));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
cursor: grab;
}
.card:active { cursor: grabbing; }
.card:hover { border-color: rgba(151, 174, 211, 0.32); }
.card-done { opacity: 0.7; }
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
.card-topline { align-items: flex-start; gap: 8px; }
.card-title-wrap { min-width: 0; flex: 1; }
.card-title { width: 100%; line-height: 1.35; white-space: normal; }
.card-title-input { width: 100%; padding: 2px 4px; }
.done-button {
flex: 0 0 auto;
width: 21px;
height: 21px;
margin-top: 1px;
padding: 0;
border: 1.5px solid var(--faint);
border-radius: 7px;
color: #081320;
cursor: pointer;
background: transparent;
}
.done-button.is-done { border-color: var(--teal); background: var(--teal); }
.card-menu { padding: 0 2px 4px; border: 0; color: var(--faint); font-weight: 800; letter-spacing: 0.08em; cursor: pointer; background: transparent; }
.card-menu:hover { color: var(--ink); }
.card-preview {
display: -webkit-box;
margin: 8px 1px 0 29px;
overflow: hidden;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.38;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-editor {
margin-top: 11px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.76rem; font-weight: 650; }
input, textarea {
width: 100%;
padding: 9px 10px;
border: 1px solid rgba(151, 174, 211, 0.27);
border-radius: 9px;
color: var(--ink);
line-height: 1.35;
background: rgba(7, 14, 27, 0.6);
}
textarea { min-height: 82px; resize: vertical; }
.editor-actions { flex-wrap: wrap; gap: 7px; margin-top: 9px; }
.button {
min-height: 36px;
padding: 7px 12px;
border: 1px solid transparent;
border-radius: 9px;
font-size: 0.82rem;
font-weight: 720;
cursor: pointer;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
}
.button:hover:not(:disabled) { transform: translateY(-1px); }
.button-primary { color: #08131e; background: linear-gradient(135deg, var(--teal), #75c9d8); }
.button-secondary { border-color: var(--border); color: var(--ink); background: var(--surface-hover); }
.button-danger { color: #fff1f3; background: #b84155; }
.button-quiet { color: var(--muted); background: transparent; }
.button-quiet:hover:not(:disabled) { color: var(--ink); background: rgba(151, 174, 211, 0.09); }
.text-danger { margin-left: auto; padding: 5px; border: 0; color: var(--danger); font-size: 0.77rem; cursor: pointer; background: transparent; }
.text-danger:hover { text-decoration: underline; }
.quick-add { display: grid; gap: 8px; padding-top: 10px; }
.quick-add-actions { gap: 5px; }
.add-card-button, .add-list-button {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
gap: 5px;
padding: 8px;
border: 1px dashed transparent;
border-radius: 9px;
color: var(--muted);
text-align: left;
cursor: pointer;
background: transparent;
}
.add-card-button { margin-top: 8px; }
.add-card-button:hover, .add-list-button:hover { border-color: var(--border-strong); color: var(--teal); background: rgba(98, 213, 199, 0.06); }
.add-list-column { padding: 8px; border-style: dashed; box-shadow: none; background: rgba(17, 27, 45, 0.42); }
.add-list-button { min-height: 46px; }
.list-empty { margin: 12px 5px; color: var(--faint); font-size: 0.78rem; line-height: 1.45; text-align: center; }
.loading-card, .fatal-state {
max-width: 520px;
margin: 80px auto;
padding: 28px;
border: 1px solid var(--border);
border-radius: 16px;
color: var(--muted);
text-align: center;
background: var(--surface);
box-shadow: var(--shadow);
}
.loading-card::after { content: ""; display: block; width: 52%; height: 6px; margin: 17px auto 0; border-radius: 99px; background: linear-gradient(90deg, transparent, var(--teal), transparent); animation: shimmer 1.25s infinite; }
.fatal-state h1 { margin-top: 0; color: var(--ink); font-size: 1.25rem; }
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 18px;
background: rgba(2, 7, 15, 0.7);
backdrop-filter: blur(3px);
}
.confirm-dialog {
width: min(100%, 410px);
padding: 24px;
border: 1px solid rgba(151, 174, 211, 0.28);
border-radius: 16px;
background: #14213a;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5);
}
.confirm-dialog h2 { margin: 0; font-size: 1.15rem; }
.confirm-dialog p { margin: 10px 0 22px; color: var(--muted); line-height: 1.45; }
.dialog-actions { justify-content: flex-end; gap: 8px; }
.toast-stack {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 40;
display: grid;
gap: 9px;
width: min(350px, calc(100vw - 36px));
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px 11px 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--teal);
border-radius: 11px;
color: var(--ink);
background: #17243a;
box-shadow: var(--shadow);
animation: toast-in 180ms ease-out;
}
.toast-error { border-left-color: var(--danger); }
.toast-info { border-left-color: var(--indigo); }
.toast span { flex: 1; font-size: 0.86rem; }
.toast button { width: 26px; height: 26px; border: 0; border-radius: 6px; color: var(--muted); font-size: 1.2rem; cursor: pointer; background: transparent; }
.toast button:hover { color: var(--ink); background: rgba(255,255,255,0.07); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
@media (max-width: 640px) {
.topbar { min-height: 64px; padding: 10px 16px; }
.topbar-board { display: none; }
.board-area { padding: 24px 16px; overflow: hidden; }
.board-intro { align-items: flex-start; margin-bottom: 18px; }
.board-hint { display: none; }
.board-rail { overflow-x: auto; margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
.list-column, .add-list-column { scroll-snap-align: start; }
.button, .icon-button, .add-card-button { min-height: 44px; }
.icon-button { width: 44px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}

46
web/src/types.ts Normal file
View File

@@ -0,0 +1,46 @@
export interface Card {
id: number;
list_id: number;
title: string;
description: string;
position: number;
done: boolean;
created_at: string;
updated_at: string;
}
export interface List {
id: number;
name: string;
position: number;
card_count?: number;
}
export interface ListWithCards extends List {
cards: Card[];
}
export interface Board {
lists: ListWithCards[];
}
export interface APIErrorBody {
error: {
code: string;
message: string;
};
}
export interface ListPatch {
name?: string;
position?: number;
}
export interface CardPatch {
title?: string;
description?: string;
done?: boolean;
list_id?: number;
position?: number;
}