From eb79ab671e31b64eaa6df5435da133dd0ad754eb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 16:36:51 +0100 Subject: [PATCH] feat(04-02): add CreateThreadModal and uiStore modal state - Add createThreadModalOpen state to uiStore with open/close actions - Create CreateThreadModal with name input and category dropdown - Modal submits via useCreateThread with name + categoryId - Fix pre-existing formatting in uiStore.ts (spaces to tabs) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/CreateThreadModal.tsx | 143 ++++++++++++++++++++ src/client/stores/uiStore.ts | 142 ++++++++++--------- 2 files changed, 218 insertions(+), 67 deletions(-) create mode 100644 src/client/components/CreateThreadModal.tsx diff --git a/src/client/components/CreateThreadModal.tsx b/src/client/components/CreateThreadModal.tsx new file mode 100644 index 0000000..73d3404 --- /dev/null +++ b/src/client/components/CreateThreadModal.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { useCategories } from "../hooks/useCategories"; +import { useCreateThread } from "../hooks/useThreads"; +import { useUIStore } from "../stores/uiStore"; + +export function CreateThreadModal() { + const isOpen = useUIStore((s) => s.createThreadModalOpen); + const closeModal = useUIStore((s) => s.closeCreateThreadModal); + + const { data: categories } = useCategories(); + const createThread = useCreateThread(); + + const [name, setName] = useState(""); + const [categoryId, setCategoryId] = useState(null); + const [error, setError] = useState(null); + + // Pre-select first category when categories load + useEffect(() => { + if (categories && categories.length > 0 && categoryId === null) { + setCategoryId(categories[0].id); + } + }, [categories, categoryId]); + + if (!isOpen) return null; + + function resetForm() { + setName(""); + setCategoryId(categories?.[0]?.id ?? null); + setError(null); + } + + function handleClose() { + resetForm(); + closeModal(); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) { + setError("Thread name is required"); + return; + } + if (categoryId === null) { + setError("Please select a category"); + return; + } + setError(null); + createThread.mutate( + { name: trimmed, categoryId }, + { + onSuccess: () => { + resetForm(); + closeModal(); + }, + onError: (err) => { + setError( + err instanceof Error ? err.message : "Failed to create thread", + ); + }, + }, + ); + } + + return ( +
{ + if (e.key === "Escape") handleClose(); + }} + > +
e.stopPropagation()} + onKeyDown={() => {}} + > +

New Thread

+ +
+
+ + setName(e.target.value)} + placeholder="e.g. Lightweight sleeping bag" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ + +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts index ec52332..842ef25 100644 --- a/src/client/stores/uiStore.ts +++ b/src/client/stores/uiStore.ts @@ -1,88 +1,96 @@ import { create } from "zustand"; interface UIState { - // Item panel state - panelMode: "closed" | "add" | "edit"; - editingItemId: number | null; - confirmDeleteItemId: number | null; + // Item panel state + panelMode: "closed" | "add" | "edit"; + editingItemId: number | null; + confirmDeleteItemId: number | null; - openAddPanel: () => void; - openEditPanel: (itemId: number) => void; - closePanel: () => void; - openConfirmDelete: (itemId: number) => void; - closeConfirmDelete: () => void; + openAddPanel: () => void; + openEditPanel: (itemId: number) => void; + closePanel: () => void; + openConfirmDelete: (itemId: number) => void; + closeConfirmDelete: () => void; - // Candidate panel state - candidatePanelMode: "closed" | "add" | "edit"; - editingCandidateId: number | null; - confirmDeleteCandidateId: number | null; + // Candidate panel state + candidatePanelMode: "closed" | "add" | "edit"; + editingCandidateId: number | null; + confirmDeleteCandidateId: number | null; - openCandidateAddPanel: () => void; - openCandidateEditPanel: (id: number) => void; - closeCandidatePanel: () => void; - openConfirmDeleteCandidate: (id: number) => void; - closeConfirmDeleteCandidate: () => void; + openCandidateAddPanel: () => void; + openCandidateEditPanel: (id: number) => void; + closeCandidatePanel: () => void; + openConfirmDeleteCandidate: (id: number) => void; + closeConfirmDeleteCandidate: () => void; - // Resolution dialog state - resolveThreadId: number | null; - resolveCandidateId: number | null; + // Resolution dialog state + resolveThreadId: number | null; + resolveCandidateId: number | null; - openResolveDialog: (threadId: number, candidateId: number) => void; - closeResolveDialog: () => void; + openResolveDialog: (threadId: number, candidateId: number) => void; + closeResolveDialog: () => void; - // Setup-related UI state - itemPickerOpen: boolean; - openItemPicker: () => void; - closeItemPicker: () => void; + // Setup-related UI state + itemPickerOpen: boolean; + openItemPicker: () => void; + closeItemPicker: () => void; - confirmDeleteSetupId: number | null; - openConfirmDeleteSetup: (id: number) => void; - closeConfirmDeleteSetup: () => void; + confirmDeleteSetupId: number | null; + openConfirmDeleteSetup: (id: number) => void; + closeConfirmDeleteSetup: () => void; + + // Create thread modal + createThreadModalOpen: boolean; + openCreateThreadModal: () => void; + closeCreateThreadModal: () => void; } export const useUIStore = create((set) => ({ - // Item panel - panelMode: "closed", - editingItemId: null, - confirmDeleteItemId: null, + // Item panel + panelMode: "closed", + editingItemId: null, + confirmDeleteItemId: null, - openAddPanel: () => set({ panelMode: "add", editingItemId: null }), - openEditPanel: (itemId) => set({ panelMode: "edit", editingItemId: itemId }), - closePanel: () => set({ panelMode: "closed", editingItemId: null }), - openConfirmDelete: (itemId) => set({ confirmDeleteItemId: itemId }), - closeConfirmDelete: () => set({ confirmDeleteItemId: null }), + openAddPanel: () => set({ panelMode: "add", editingItemId: null }), + openEditPanel: (itemId) => set({ panelMode: "edit", editingItemId: itemId }), + closePanel: () => set({ panelMode: "closed", editingItemId: null }), + openConfirmDelete: (itemId) => set({ confirmDeleteItemId: itemId }), + closeConfirmDelete: () => set({ confirmDeleteItemId: null }), - // Candidate panel - candidatePanelMode: "closed", - editingCandidateId: null, - confirmDeleteCandidateId: null, + // Candidate panel + candidatePanelMode: "closed", + editingCandidateId: null, + confirmDeleteCandidateId: null, - openCandidateAddPanel: () => - set({ candidatePanelMode: "add", editingCandidateId: null }), - openCandidateEditPanel: (id) => - set({ candidatePanelMode: "edit", editingCandidateId: id }), - closeCandidatePanel: () => - set({ candidatePanelMode: "closed", editingCandidateId: null }), - openConfirmDeleteCandidate: (id) => - set({ confirmDeleteCandidateId: id }), - closeConfirmDeleteCandidate: () => - set({ confirmDeleteCandidateId: null }), + openCandidateAddPanel: () => + set({ candidatePanelMode: "add", editingCandidateId: null }), + openCandidateEditPanel: (id) => + set({ candidatePanelMode: "edit", editingCandidateId: id }), + closeCandidatePanel: () => + set({ candidatePanelMode: "closed", editingCandidateId: null }), + openConfirmDeleteCandidate: (id) => set({ confirmDeleteCandidateId: id }), + closeConfirmDeleteCandidate: () => set({ confirmDeleteCandidateId: null }), - // Resolution dialog - resolveThreadId: null, - resolveCandidateId: null, + // Resolution dialog + resolveThreadId: null, + resolveCandidateId: null, - openResolveDialog: (threadId, candidateId) => - set({ resolveThreadId: threadId, resolveCandidateId: candidateId }), - closeResolveDialog: () => - set({ resolveThreadId: null, resolveCandidateId: null }), + openResolveDialog: (threadId, candidateId) => + set({ resolveThreadId: threadId, resolveCandidateId: candidateId }), + closeResolveDialog: () => + set({ resolveThreadId: null, resolveCandidateId: null }), - // Setup-related UI state - itemPickerOpen: false, - openItemPicker: () => set({ itemPickerOpen: true }), - closeItemPicker: () => set({ itemPickerOpen: false }), + // Setup-related UI state + itemPickerOpen: false, + openItemPicker: () => set({ itemPickerOpen: true }), + closeItemPicker: () => set({ itemPickerOpen: false }), - confirmDeleteSetupId: null, - openConfirmDeleteSetup: (id) => set({ confirmDeleteSetupId: id }), - closeConfirmDeleteSetup: () => set({ confirmDeleteSetupId: null }), + confirmDeleteSetupId: null, + openConfirmDeleteSetup: (id) => set({ confirmDeleteSetupId: id }), + closeConfirmDeleteSetup: () => set({ confirmDeleteSetupId: null }), + + // Create thread modal + createThreadModalOpen: false, + openCreateThreadModal: () => set({ createThreadModalOpen: true }), + closeCreateThreadModal: () => set({ createThreadModalOpen: false }), }));