feat(22-01): add UIStore modal states, AddToCollectionModal, and sonner toasts
- Extend UIStore with addToCollectionModal, addToThreadModal, catalogSessionThreadId - Create AddToCollectionModal with category dropdown, notes, purchase price - Install sonner and add Toaster + AddToCollectionModal to root layout - closeCatalogSearch now resets catalogSessionThreadId Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -20,6 +20,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11",
|
"zustand": "^5.0.11",
|
||||||
@@ -964,6 +965,8 @@
|
|||||||
|
|
||||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||||
|
|
||||||
|
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
|
|||||||
178
src/client/components/AddToCollectionModal.tsx
Normal file
178
src/client/components/AddToCollectionModal.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useCreateItem } from "../hooks/useItems";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export function AddToCollectionModal() {
|
||||||
|
const { open, globalItemId, globalItemName } = useUIStore(
|
||||||
|
(s) => s.addToCollectionModal,
|
||||||
|
);
|
||||||
|
const closeAddToCollection = useUIStore((s) => s.closeAddToCollection);
|
||||||
|
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const createItem = useCreateItem();
|
||||||
|
|
||||||
|
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [purchasePrice, setPurchasePrice] = useState("");
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Reset form state when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setCategoryId(categories?.[0]?.id ?? null);
|
||||||
|
setNotes("");
|
||||||
|
setPurchasePrice("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, categories]);
|
||||||
|
|
||||||
|
if (!open || !globalItemId) return null;
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
closeAddToCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (categoryId === null) {
|
||||||
|
setError("Please select a category");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const purchasePriceCents = purchasePrice
|
||||||
|
? Math.round(Number.parseFloat(purchasePrice) * 100)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
createItem.mutate(
|
||||||
|
{
|
||||||
|
name: globalItemName ?? "Unknown Item",
|
||||||
|
categoryId,
|
||||||
|
globalItemId: globalItemId!,
|
||||||
|
notes: notes || undefined,
|
||||||
|
purchasePriceCents: purchasePriceCents || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Added to Collection");
|
||||||
|
closeAddToCollection();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to add item",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
Add to Collection
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">{globalItemName}</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="collection-category"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="collection-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-gray-400 focus:border-transparent bg-white"
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="collection-notes"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="collection-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Personal notes (optional)"
|
||||||
|
rows={3}
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="collection-price"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Purchase Price ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="collection-price"
|
||||||
|
type="number"
|
||||||
|
value={purchasePrice}
|
||||||
|
onChange={(e) => setPurchasePrice(e.target.value)}
|
||||||
|
placeholder="Purchase price (optional)"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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={createItem.isPending}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{createItem.isPending ? "Adding..." : "Add to Collection"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
useRouter,
|
useRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
|
import { AddToCollectionModal } from "../components/AddToCollectionModal";
|
||||||
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
||||||
@@ -204,6 +206,10 @@ function RootLayout() {
|
|||||||
{/* Catalog Search Overlay */}
|
{/* Catalog Search Overlay */}
|
||||||
<CatalogSearchOverlay />
|
<CatalogSearchOverlay />
|
||||||
|
|
||||||
|
{/* Add to Collection Modal */}
|
||||||
|
<AddToCollectionModal />
|
||||||
|
<Toaster position="bottom-right" richColors />
|
||||||
|
|
||||||
{/* Onboarding Wizard */}
|
{/* Onboarding Wizard */}
|
||||||
{showWizard && (
|
{showWizard && (
|
||||||
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ interface UIState {
|
|||||||
catalogSearchMode: "collection" | "thread" | null;
|
catalogSearchMode: "collection" | "thread" | null;
|
||||||
openCatalogSearch: (mode: "collection" | "thread") => void;
|
openCatalogSearch: (mode: "collection" | "thread") => void;
|
||||||
closeCatalogSearch: () => void;
|
closeCatalogSearch: () => void;
|
||||||
|
|
||||||
|
// Add-to-collection modal
|
||||||
|
addToCollectionModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
|
||||||
|
openAddToCollection: (globalItemId: number, globalItemName: string) => void;
|
||||||
|
closeAddToCollection: () => void;
|
||||||
|
|
||||||
|
// Add-to-thread modal
|
||||||
|
addToThreadModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
|
||||||
|
openAddToThread: (globalItemId: number, globalItemName: string) => void;
|
||||||
|
closeAddToThread: () => void;
|
||||||
|
|
||||||
|
// Session thread tracking
|
||||||
|
catalogSessionThreadId: number | null;
|
||||||
|
setCatalogSessionThreadId: (id: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
@@ -123,5 +137,23 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
fabMenuOpen: false,
|
fabMenuOpen: false,
|
||||||
}),
|
}),
|
||||||
closeCatalogSearch: () =>
|
closeCatalogSearch: () =>
|
||||||
set({ catalogSearchOpen: false, catalogSearchMode: null }),
|
set({ catalogSearchOpen: false, catalogSearchMode: null, catalogSessionThreadId: null }),
|
||||||
|
|
||||||
|
// Add-to-collection modal
|
||||||
|
addToCollectionModal: { open: false, globalItemId: null, globalItemName: null },
|
||||||
|
openAddToCollection: (globalItemId, globalItemName) =>
|
||||||
|
set({ addToCollectionModal: { open: true, globalItemId, globalItemName } }),
|
||||||
|
closeAddToCollection: () =>
|
||||||
|
set({ addToCollectionModal: { open: false, globalItemId: null, globalItemName: null } }),
|
||||||
|
|
||||||
|
// Add-to-thread modal
|
||||||
|
addToThreadModal: { open: false, globalItemId: null, globalItemName: null },
|
||||||
|
openAddToThread: (globalItemId, globalItemName) =>
|
||||||
|
set({ addToThreadModal: { open: true, globalItemId, globalItemName } }),
|
||||||
|
closeAddToThread: () =>
|
||||||
|
set({ addToThreadModal: { open: false, globalItemId: null, globalItemName: null } }),
|
||||||
|
|
||||||
|
// Session thread tracking
|
||||||
|
catalogSessionThreadId: null,
|
||||||
|
setCatalogSessionThreadId: (id) => set({ catalogSessionThreadId: id }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user