Files
havenllo/web/src/components/ListColumn.tsx
Hermes 240f73b229
All checks were successful
Build and deploy Havenllo / Verify Go and frontend (push) Successful in 32s
Build and deploy Havenllo / Build and push image (push) Successful in 33s
Build and deploy Havenllo / Apply and restart Havenllo (push) Successful in 7s
feat(ui): Jira-style card detail, edit-mode column management, design polish
2026-07-14 10:37:10 -03:00

209 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, 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;
managingLists: boolean;
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>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => 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,
onMoveCard,
onOpenCard,
managingLists
}: 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);
useEffect(() => {
if (!managingLists && editingName) {
setName(list.name);
setEditingName(false);
} else if (!editingName) {
setName(list.name);
}
}, [editingName, list.name, managingLists]);
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 " + (managingLists ? "is-managing" : "")}>
<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()}
/>
) : managingLists ? (
<button type="button" class="list-name" onClick={() => setEditingName(true)}>{list.name}</button>
) : (
<h2 class="list-name-text">{list.name}</h2>
)}
<span class="count-badge">{list.cards.length}</span>
</div>
{managingLists ? <button
type="button"
class="icon-button list-delete"
aria-label={"Delete " + list.name}
title="Delete list"
onClick={() => setConfirming(true)}
>
×
</button> : null}
</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)}
onOpen={() => onOpenCard(card.id)}
/>
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
</div>
))}
{!list.cards.length ? <p class="list-empty">No cards yet. Drop one 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()}
/>
</>
);
}