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) <noreply@anthropic.com>
This commit is contained in:
143
src/client/components/CreateThreadModal.tsx
Normal file
143
src/client/components/CreateThreadModal.tsx
Normal file
@@ -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<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={handleClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="document"
|
||||||
|
className="w-full max-w-md bg-white rounded-xl shadow-xl p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={() => {}}
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">New Thread</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="thread-name"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Thread name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="thread-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="thread-category"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="thread-category"
|
||||||
|
value={categoryId ?? ""}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
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 bg-white"
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.emoji} {cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createThread.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{createThread.isPending ? "Creating..." : "Create Thread"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,88 +1,96 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
// Item panel state
|
// Item panel state
|
||||||
panelMode: "closed" | "add" | "edit";
|
panelMode: "closed" | "add" | "edit";
|
||||||
editingItemId: number | null;
|
editingItemId: number | null;
|
||||||
confirmDeleteItemId: number | null;
|
confirmDeleteItemId: number | null;
|
||||||
|
|
||||||
openAddPanel: () => void;
|
openAddPanel: () => void;
|
||||||
openEditPanel: (itemId: number) => void;
|
openEditPanel: (itemId: number) => void;
|
||||||
closePanel: () => void;
|
closePanel: () => void;
|
||||||
openConfirmDelete: (itemId: number) => void;
|
openConfirmDelete: (itemId: number) => void;
|
||||||
closeConfirmDelete: () => void;
|
closeConfirmDelete: () => void;
|
||||||
|
|
||||||
// Candidate panel state
|
// Candidate panel state
|
||||||
candidatePanelMode: "closed" | "add" | "edit";
|
candidatePanelMode: "closed" | "add" | "edit";
|
||||||
editingCandidateId: number | null;
|
editingCandidateId: number | null;
|
||||||
confirmDeleteCandidateId: number | null;
|
confirmDeleteCandidateId: number | null;
|
||||||
|
|
||||||
openCandidateAddPanel: () => void;
|
openCandidateAddPanel: () => void;
|
||||||
openCandidateEditPanel: (id: number) => void;
|
openCandidateEditPanel: (id: number) => void;
|
||||||
closeCandidatePanel: () => void;
|
closeCandidatePanel: () => void;
|
||||||
openConfirmDeleteCandidate: (id: number) => void;
|
openConfirmDeleteCandidate: (id: number) => void;
|
||||||
closeConfirmDeleteCandidate: () => void;
|
closeConfirmDeleteCandidate: () => void;
|
||||||
|
|
||||||
// Resolution dialog state
|
// Resolution dialog state
|
||||||
resolveThreadId: number | null;
|
resolveThreadId: number | null;
|
||||||
resolveCandidateId: number | null;
|
resolveCandidateId: number | null;
|
||||||
|
|
||||||
openResolveDialog: (threadId: number, candidateId: number) => void;
|
openResolveDialog: (threadId: number, candidateId: number) => void;
|
||||||
closeResolveDialog: () => void;
|
closeResolveDialog: () => void;
|
||||||
|
|
||||||
// Setup-related UI state
|
// Setup-related UI state
|
||||||
itemPickerOpen: boolean;
|
itemPickerOpen: boolean;
|
||||||
openItemPicker: () => void;
|
openItemPicker: () => void;
|
||||||
closeItemPicker: () => void;
|
closeItemPicker: () => void;
|
||||||
|
|
||||||
confirmDeleteSetupId: number | null;
|
confirmDeleteSetupId: number | null;
|
||||||
openConfirmDeleteSetup: (id: number) => void;
|
openConfirmDeleteSetup: (id: number) => void;
|
||||||
closeConfirmDeleteSetup: () => void;
|
closeConfirmDeleteSetup: () => void;
|
||||||
|
|
||||||
|
// Create thread modal
|
||||||
|
createThreadModalOpen: boolean;
|
||||||
|
openCreateThreadModal: () => void;
|
||||||
|
closeCreateThreadModal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
// Item panel
|
// Item panel
|
||||||
panelMode: "closed",
|
panelMode: "closed",
|
||||||
editingItemId: null,
|
editingItemId: null,
|
||||||
confirmDeleteItemId: null,
|
confirmDeleteItemId: null,
|
||||||
|
|
||||||
openAddPanel: () => set({ panelMode: "add", editingItemId: null }),
|
openAddPanel: () => set({ panelMode: "add", editingItemId: null }),
|
||||||
openEditPanel: (itemId) => set({ panelMode: "edit", editingItemId: itemId }),
|
openEditPanel: (itemId) => set({ panelMode: "edit", editingItemId: itemId }),
|
||||||
closePanel: () => set({ panelMode: "closed", editingItemId: null }),
|
closePanel: () => set({ panelMode: "closed", editingItemId: null }),
|
||||||
openConfirmDelete: (itemId) => set({ confirmDeleteItemId: itemId }),
|
openConfirmDelete: (itemId) => set({ confirmDeleteItemId: itemId }),
|
||||||
closeConfirmDelete: () => set({ confirmDeleteItemId: null }),
|
closeConfirmDelete: () => set({ confirmDeleteItemId: null }),
|
||||||
|
|
||||||
// Candidate panel
|
// Candidate panel
|
||||||
candidatePanelMode: "closed",
|
candidatePanelMode: "closed",
|
||||||
editingCandidateId: null,
|
editingCandidateId: null,
|
||||||
confirmDeleteCandidateId: null,
|
confirmDeleteCandidateId: null,
|
||||||
|
|
||||||
openCandidateAddPanel: () =>
|
openCandidateAddPanel: () =>
|
||||||
set({ candidatePanelMode: "add", editingCandidateId: null }),
|
set({ candidatePanelMode: "add", editingCandidateId: null }),
|
||||||
openCandidateEditPanel: (id) =>
|
openCandidateEditPanel: (id) =>
|
||||||
set({ candidatePanelMode: "edit", editingCandidateId: id }),
|
set({ candidatePanelMode: "edit", editingCandidateId: id }),
|
||||||
closeCandidatePanel: () =>
|
closeCandidatePanel: () =>
|
||||||
set({ candidatePanelMode: "closed", editingCandidateId: null }),
|
set({ candidatePanelMode: "closed", editingCandidateId: null }),
|
||||||
openConfirmDeleteCandidate: (id) =>
|
openConfirmDeleteCandidate: (id) => set({ confirmDeleteCandidateId: id }),
|
||||||
set({ confirmDeleteCandidateId: id }),
|
closeConfirmDeleteCandidate: () => set({ confirmDeleteCandidateId: null }),
|
||||||
closeConfirmDeleteCandidate: () =>
|
|
||||||
set({ confirmDeleteCandidateId: null }),
|
|
||||||
|
|
||||||
// Resolution dialog
|
// Resolution dialog
|
||||||
resolveThreadId: null,
|
resolveThreadId: null,
|
||||||
resolveCandidateId: null,
|
resolveCandidateId: null,
|
||||||
|
|
||||||
openResolveDialog: (threadId, candidateId) =>
|
openResolveDialog: (threadId, candidateId) =>
|
||||||
set({ resolveThreadId: threadId, resolveCandidateId: candidateId }),
|
set({ resolveThreadId: threadId, resolveCandidateId: candidateId }),
|
||||||
closeResolveDialog: () =>
|
closeResolveDialog: () =>
|
||||||
set({ resolveThreadId: null, resolveCandidateId: null }),
|
set({ resolveThreadId: null, resolveCandidateId: null }),
|
||||||
|
|
||||||
// Setup-related UI state
|
// Setup-related UI state
|
||||||
itemPickerOpen: false,
|
itemPickerOpen: false,
|
||||||
openItemPicker: () => set({ itemPickerOpen: true }),
|
openItemPicker: () => set({ itemPickerOpen: true }),
|
||||||
closeItemPicker: () => set({ itemPickerOpen: false }),
|
closeItemPicker: () => set({ itemPickerOpen: false }),
|
||||||
|
|
||||||
confirmDeleteSetupId: null,
|
confirmDeleteSetupId: null,
|
||||||
openConfirmDeleteSetup: (id) => set({ confirmDeleteSetupId: id }),
|
openConfirmDeleteSetup: (id) => set({ confirmDeleteSetupId: id }),
|
||||||
closeConfirmDeleteSetup: () => set({ confirmDeleteSetupId: null }),
|
closeConfirmDeleteSetup: () => set({ confirmDeleteSetupId: null }),
|
||||||
|
|
||||||
|
// Create thread modal
|
||||||
|
createThreadModalOpen: false,
|
||||||
|
openCreateThreadModal: () => set({ createThreadModalOpen: true }),
|
||||||
|
closeCreateThreadModal: () => set({ createThreadModalOpen: false }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user