209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
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()}
|
||
/>
|
||
</>
|
||
);
|
||
}
|