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(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) => NonNullable, request: () => Promise, 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 (
setManagingLists((active) => !active)} /> {state.loading && !state.board ?
Loading your Haven board…
: null} {state.error ? (

Havenllo could not reach its board

{state.error}

) : null} {state.board ? ( ) : null} {selectedCard && selectedList ? ( setSelectedCardID(null)} onEdit={(patch) => editCard(selectedCard.id, patch)} onDelete={() => deleteCard(selectedCard.id)} /> ) : null} dispatch({ type: "dismiss", id })} />
); }