194 lines
6.8 KiB
TypeScript
194 lines
6.8 KiB
TypeScript
import { useCallback, useEffect, useReducer, useRef, useState } from "preact/hooks";
|
|
import { api, APIError } from "./api";
|
|
import {
|
|
addCard,
|
|
addList,
|
|
boardReducer,
|
|
cloneBoard,
|
|
initialBoardState,
|
|
removeCard,
|
|
removeList,
|
|
updateCard,
|
|
updateList
|
|
} from "./board-state";
|
|
import { movePosition } from "./drag";
|
|
import type { CardPatch, ListPatch } from "./types";
|
|
import { BoardCanvas } from "./components/BoardCanvas";
|
|
import { CardDetailModal } from "./components/CardDetailModal";
|
|
import { Header } from "./components/Header";
|
|
import { Toasts } from "./components/Toast";
|
|
|
|
let toastID = 0;
|
|
|
|
function friendlyError(error: unknown): string {
|
|
if (error instanceof APIError) return error.message;
|
|
if (error instanceof Error) return error.message;
|
|
return "Could not save that change. Please try again.";
|
|
}
|
|
|
|
export function App() {
|
|
const [state, dispatch] = useReducer(boardReducer, initialBoardState);
|
|
const [managingLists, setManagingLists] = useState(false);
|
|
const [selectedCardID, setSelectedCardID] = useState<number | null>(null);
|
|
const boardRef = useRef(state.board);
|
|
boardRef.current = state.board;
|
|
|
|
const selectedList = state.board?.lists.find((list) => list.cards.some((card) => card.id === selectedCardID));
|
|
const selectedCard = selectedList?.cards.find((card) => card.id === selectedCardID);
|
|
|
|
useEffect(() => {
|
|
if (selectedCardID !== null && !selectedCard) setSelectedCardID(null);
|
|
}, [selectedCardID, selectedCard]);
|
|
|
|
const toast = useCallback((message: string, tone: "success" | "error" | "info" = "info") => {
|
|
const id = ++toastID;
|
|
dispatch({ type: "toast", toast: { id, message, tone } });
|
|
window.setTimeout(() => dispatch({ type: "dismiss", id }), 4200);
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
dispatch({ type: "loading" });
|
|
try {
|
|
dispatch({ type: "loaded", board: await api.board() });
|
|
} catch (error) {
|
|
dispatch({ type: "error", error: friendlyError(error) });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
const mutate = useCallback(async (
|
|
makeOptimistic: (current: NonNullable<typeof boardRef.current>) => NonNullable<typeof boardRef.current>,
|
|
request: () => Promise<void>,
|
|
success?: string
|
|
) => {
|
|
const before = boardRef.current;
|
|
if (!before) return;
|
|
dispatch({ type: "replace", board: makeOptimistic(cloneBoard(before)) });
|
|
try {
|
|
await request();
|
|
if (success) toast(success, "success");
|
|
} catch (error) {
|
|
dispatch({ type: "replace", board: before });
|
|
toast(friendlyError(error), "error");
|
|
throw error;
|
|
}
|
|
}, [toast]);
|
|
|
|
const createList = async (name: string) => {
|
|
const current = boardRef.current;
|
|
if (!current) return;
|
|
const position = Math.max(0, ...current.lists.map((list) => list.position)) + 1024;
|
|
try {
|
|
const { list } = await api.createList(name, position);
|
|
const board = boardRef.current;
|
|
if (board) dispatch({ type: "replace", board: addList(board, list) });
|
|
toast("List added", "success");
|
|
} catch (error) {
|
|
toast(friendlyError(error), "error");
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const editList = async (id: number, patch: ListPatch) => {
|
|
const old = boardRef.current?.lists.find((list) => list.id === id);
|
|
if (!old) return;
|
|
await mutate(
|
|
(board) => updateList(board, { ...old, ...patch }),
|
|
async () => {
|
|
const { list } = await api.patchList(id, patch);
|
|
const current = boardRef.current;
|
|
if (current) dispatch({ type: "replace", board: updateList(current, list) });
|
|
}
|
|
);
|
|
};
|
|
|
|
const deleteList = async (id: number) => {
|
|
await mutate((board) => removeList(board, id), () => api.deleteList(id), "List deleted");
|
|
};
|
|
|
|
const createCard = async (listID: number, title: string) => {
|
|
const current = boardRef.current;
|
|
const list = current?.lists.find((item) => item.id === listID);
|
|
if (!list) return;
|
|
const position = Math.max(0, ...list.cards.map((card) => card.position)) + 1024;
|
|
try {
|
|
const { card } = await api.createCard(listID, title, "", position);
|
|
const board = boardRef.current;
|
|
if (board) dispatch({ type: "replace", board: addCard(board, card) });
|
|
toast("Card added", "success");
|
|
} catch (error) {
|
|
toast(friendlyError(error), "error");
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const editCard = async (id: number, patch: CardPatch) => {
|
|
const old = boardRef.current?.lists.flatMap((list) => list.cards).find((card) => card.id === id);
|
|
if (!old) return;
|
|
const optimistic = { ...old, ...patch, list_id: patch.list_id ?? old.list_id };
|
|
await mutate(
|
|
(board) => updateCard(board, optimistic),
|
|
async () => {
|
|
const { card } = await api.patchCard(id, patch);
|
|
const current = boardRef.current;
|
|
if (current) dispatch({ type: "replace", board: updateCard(current, card) });
|
|
}
|
|
);
|
|
};
|
|
|
|
const deleteCard = async (id: number) => {
|
|
await mutate((board) => removeCard(board, id), () => api.deleteCard(id), "Card deleted");
|
|
};
|
|
|
|
const moveCard = async (cardID: number, targetListID: number, dropIndex: number) => {
|
|
const current = boardRef.current;
|
|
const card = current?.lists.flatMap((list) => list.cards).find((item) => item.id === cardID);
|
|
if (!current || !card) return;
|
|
const position = movePosition(current.lists, cardID, targetListID, dropIndex);
|
|
await editCard(cardID, { list_id: targetListID, position });
|
|
};
|
|
|
|
return (
|
|
<main class="app-shell">
|
|
<Header
|
|
onReload={load}
|
|
loading={state.loading}
|
|
managingLists={managingLists}
|
|
onToggleManagingLists={() => setManagingLists((active) => !active)}
|
|
/>
|
|
{state.loading && !state.board ? <section class="loading-card" aria-live="polite">Loading your Haven board…</section> : null}
|
|
{state.error ? (
|
|
<section class="fatal-state">
|
|
<h1>Havenllo could not reach its board</h1>
|
|
<p>{state.error}</p>
|
|
<button type="button" class="button button-primary" onClick={() => void load()}>Try again</button>
|
|
</section>
|
|
) : null}
|
|
{state.board ? (
|
|
<BoardCanvas
|
|
lists={state.board?.lists ?? []}
|
|
onCreateList={createList}
|
|
onEditList={editList}
|
|
onDeleteList={deleteList}
|
|
onCreateCard={createCard}
|
|
onMoveCard={moveCard}
|
|
onOpenCard={setSelectedCardID}
|
|
managingLists={managingLists}
|
|
/>
|
|
) : null}
|
|
{selectedCard && selectedList ? (
|
|
<CardDetailModal
|
|
card={selectedCard}
|
|
listName={selectedList.name}
|
|
lists={state.board?.lists ?? []}
|
|
onClose={() => setSelectedCardID(null)}
|
|
onEdit={(patch) => editCard(selectedCard.id, patch)}
|
|
onDelete={() => deleteCard(selectedCard.id)}
|
|
/>
|
|
) : null}
|
|
<Toasts toasts={state.toasts} onDismiss={(id) => dispatch({ type: "dismiss", id })} />
|
|
</main>
|
|
);
|
|
}
|