Files
havenllo/web/src/components/CardItem.tsx
Hermes 041c3fab4b
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
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
2026-07-14 10:13:51 -03:00

114 lines
3.5 KiB
TypeScript

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