feat(22-02): build AddToThreadModal with thread picker and new thread flow

- Create AddToThreadModal with pick/create modes for thread selection
- Support existing thread selection with category display
- Support new thread creation with candidate in one step
- Pre-select session thread via catalogSessionThreadId
- Auto-switch to create mode when no active threads exist
- Wire AddToThreadModal at root layout level
This commit is contained in:
2026-04-06 16:00:34 +02:00
parent e8b7907a22
commit c33b7c7bdc
2 changed files with 292 additions and 0 deletions

View File

@@ -0,0 +1,289 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { useCategories } from "../hooks/useCategories";
import { useGlobalItem } from "../hooks/useGlobalItems";
import { useCreateThread, useThreads } from "../hooks/useThreads";
import { apiPost } from "../lib/api";
import { useUIStore } from "../stores/uiStore";
export function AddToThreadModal() {
const { open, globalItemId, globalItemName } = useUIStore(
(s) => s.addToThreadModal,
);
const closeAddToThread = useUIStore((s) => s.closeAddToThread);
const catalogSessionThreadId = useUIStore(
(s) => s.catalogSessionThreadId,
);
const setCatalogSessionThreadId = useUIStore(
(s) => s.setCatalogSessionThreadId,
);
const { data: threads } = useThreads();
const { data: categories } = useCategories();
const { data: globalItem } = useGlobalItem(globalItemId);
const createThread = useCreateThread();
const queryClient = useQueryClient();
const [mode, setMode] = useState<"pick" | "create">("pick");
const [selectedThreadId, setSelectedThreadId] = useState<number | null>(
null,
);
const [newThreadName, setNewThreadName] = useState("");
const [newThreadCategoryId, setNewThreadCategoryId] = useState<
number | null
>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeThreads = threads?.filter((t) => t.status === "active") ?? [];
// Pre-select category for new thread creation
useEffect(() => {
if (categories && categories.length > 0 && newThreadCategoryId === null) {
setNewThreadCategoryId(categories[0].id);
}
}, [categories, newThreadCategoryId]);
// Initialize selection when modal opens
useEffect(() => {
if (open) {
if (activeThreads.length === 0) {
setMode("create");
} else {
setMode("pick");
if (
catalogSessionThreadId &&
activeThreads.some((t) => t.id === catalogSessionThreadId)
) {
setSelectedThreadId(catalogSessionThreadId);
} else if (activeThreads.length > 0) {
setSelectedThreadId(activeThreads[0].id);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Reset form state when modal closes
useEffect(() => {
if (!open) {
setMode("pick");
setSelectedThreadId(null);
setNewThreadName("");
setNewThreadCategoryId(categories?.[0]?.id ?? null);
setError(null);
setIsSubmitting(false);
}
}, [open, categories]);
if (!open || !globalItemId) return null;
function handleClose() {
closeAddToThread();
}
function handleSelectChange(value: string) {
if (value === "new") {
setMode("create");
} else {
setSelectedThreadId(Number(value));
}
}
async function handleAddToExistingThread() {
if (!selectedThreadId || !globalItemId) return;
setIsSubmitting(true);
setError(null);
try {
const thread = activeThreads.find((t) => t.id === selectedThreadId);
await apiPost(`/api/threads/${selectedThreadId}/candidates`, {
name: globalItemName ?? "Unknown Item",
globalItemId,
categoryId: thread?.categoryId ?? categories?.[0]?.id ?? 1,
weightGrams: globalItem?.weightGrams ?? undefined,
priceCents: globalItem?.priceCents ?? undefined,
});
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({
queryKey: ["threads", selectedThreadId],
});
setCatalogSessionThreadId(selectedThreadId);
toast.success(`Added to "${thread?.name ?? "thread"}"`);
closeAddToThread();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to add candidate",
);
} finally {
setIsSubmitting(false);
}
}
async function handleCreateThreadAndAdd() {
const trimmedName = newThreadName.trim();
if (!trimmedName || !newThreadCategoryId || !globalItemId) return;
setIsSubmitting(true);
setError(null);
try {
const thread = await createThread.mutateAsync({
name: trimmedName,
categoryId: newThreadCategoryId,
});
await apiPost(`/api/threads/${thread.id}/candidates`, {
name: globalItemName ?? "Unknown Item",
globalItemId,
categoryId: newThreadCategoryId,
weightGrams: globalItem?.weightGrams ?? undefined,
priceCents: globalItem?.priceCents ?? undefined,
});
queryClient.invalidateQueries({ queryKey: ["threads"] });
setCatalogSessionThreadId(thread.id);
toast.success(`Created "${trimmedName}" with first candidate`);
closeAddToThread();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create thread",
);
} finally {
setIsSubmitting(false);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (mode === "pick") {
handleAddToExistingThread();
} else {
handleCreateThreadAndAdd();
}
}
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-1">
{mode === "pick" ? "Add to Thread" : "New Thread + Candidate"}
</h2>
<p className="text-sm text-gray-500 mb-4">{globalItemName}</p>
<form onSubmit={handleSubmit} className="space-y-4">
{mode === "pick" ? (
<div>
<label
htmlFor="thread-select"
className="block text-sm font-medium text-gray-700 mb-1"
>
Thread
</label>
<select
id="thread-select"
value={selectedThreadId ?? ""}
onChange={(e) => handleSelectChange(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-gray-400 focus:border-transparent bg-white"
>
{activeThreads.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.categoryName})
</option>
))}
<option value="new">+ New Thread...</option>
</select>
</div>
) : (
<>
<div>
<label
htmlFor="new-thread-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Thread name
</label>
<input
id="new-thread-name"
type="text"
value={newThreadName}
onChange={(e) => setNewThreadName(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-gray-400 focus:border-transparent"
/>
</div>
<div>
<label
htmlFor="new-thread-category"
className="block text-sm font-medium text-gray-700 mb-1"
>
Category
</label>
<select
id="new-thread-category"
value={newThreadCategoryId ?? ""}
onChange={(e) =>
setNewThreadCategoryId(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-gray-400 focus:border-transparent bg-white"
>
{categories?.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{activeThreads.length > 0 && (
<button
type="button"
onClick={() => setMode("pick")}
className="text-sm text-gray-500 hover:text-gray-700 underline"
>
Back to thread picker
</button>
)}
</>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={
mode === "create" && activeThreads.length > 0
? () => setMode("pick")
: 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={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
>
{isSubmitting
? "Adding..."
: mode === "pick"
? "Add as Candidate"
: "Create & Add"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { useState } from "react";
import { Toaster } from "sonner";
import "../app.css";
import { AddToCollectionModal } from "../components/AddToCollectionModal";
import { AddToThreadModal } from "../components/AddToThreadModal";
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
@@ -208,6 +209,8 @@ function RootLayout() {
{/* Add to Collection Modal */}
<AddToCollectionModal />
{/* Add to Thread Modal */}
<AddToThreadModal />
<Toaster position="bottom-right" richColors />
{/* Onboarding Wizard */}