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:
2026-03-15 16:36:51 +01:00
parent 4a31a16e0e
commit eb79ab671e
2 changed files with 218 additions and 67 deletions

View 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>
);
}

View File

@@ -38,6 +38,11 @@ interface UIState {
confirmDeleteSetupId: number | null;
openConfirmDeleteSetup: (id: number) => void;
closeConfirmDeleteSetup: () => void;
// Create thread modal
createThreadModalOpen: boolean;
openCreateThreadModal: () => void;
closeCreateThreadModal: () => void;
}
export const useUIStore = create<UIState>((set) => ({
@@ -63,10 +68,8 @@ export const useUIStore = create<UIState>((set) => ({
set({ candidatePanelMode: "edit", editingCandidateId: id }),
closeCandidatePanel: () =>
set({ candidatePanelMode: "closed", editingCandidateId: null }),
openConfirmDeleteCandidate: (id) =>
set({ confirmDeleteCandidateId: id }),
closeConfirmDeleteCandidate: () =>
set({ confirmDeleteCandidateId: null }),
openConfirmDeleteCandidate: (id) => set({ confirmDeleteCandidateId: id }),
closeConfirmDeleteCandidate: () => set({ confirmDeleteCandidateId: null }),
// Resolution dialog
resolveThreadId: null,
@@ -85,4 +88,9 @@ export const useUIStore = create<UIState>((set) => ({
confirmDeleteSetupId: null,
openConfirmDeleteSetup: (id) => set({ confirmDeleteSetupId: id }),
closeConfirmDeleteSetup: () => set({ confirmDeleteSetupId: null }),
// Create thread modal
createThreadModalOpen: false,
openCreateThreadModal: () => set({ createThreadModalOpen: true }),
closeCreateThreadModal: () => set({ createThreadModalOpen: false }),
}));