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