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:
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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user