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