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:
1
web/dist/.gitkeep
vendored
Normal file
1
web/dist/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
20
web/embed.go
Normal file
20
web/embed.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var embedded embed.FS
|
||||
|
||||
// Files contains the compiled Vite bundle without its dist path prefix.
|
||||
var Files = mustSub(embedded, "dist")
|
||||
|
||||
func mustSub(source fs.FS, directory string) fs.FS {
|
||||
files, err := fs.Sub(source, directory)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
15
web/index.html
Normal file
15
web/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#111b2d" />
|
||||
<meta name="description" content="Havenllo, the Haven homelab board." />
|
||||
<title>Havenllo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2120
web/package-lock.json
generated
Normal file
2120
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "havenllo-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.26.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.25.12": true
|
||||
}
|
||||
}
|
||||
169
web/src/App.tsx
Normal file
169
web/src/App.tsx
Normal 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
63
web/src/api.ts
Normal 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
108
web/src/board-state.ts
Normal 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)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
50
web/src/drag.ts
Normal file
50
web/src/drag.ts
Normal 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
12
web/src/main.tsx
Normal 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
425
web/src/styles.css
Normal 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
46
web/src/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
22
web/tsconfig.json
Normal file
22
web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
11
web/vite.config.ts
Normal file
11
web/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import preact from "@preact/preset-vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user