From b099a47eb4ebe530d2086758383497c53a1ff65c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 14 Mar 2026 22:44:48 +0100 Subject: [PATCH] feat(01-03): add data hooks, utilities, UI store, and foundational components - API fetch wrapper with error handling and multipart upload - Weight/price formatters for display - TanStack Query hooks for items, categories, and totals with cache invalidation - Zustand UI store for panel and confirm dialog state - TotalsBar, CategoryHeader, ItemCard, ConfirmDialog, ImageUpload components Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/CategoryHeader.tsx | 143 +++++++++++++++++++++++ src/client/components/ConfirmDialog.tsx | 61 ++++++++++ src/client/components/ImageUpload.tsx | 95 +++++++++++++++ src/client/components/ItemCard.tsx | 62 ++++++++++ src/client/components/TotalsBar.tsx | 38 ++++++ src/client/hooks/useCategories.ts | 53 +++++++++ src/client/hooks/useItems.ts | 69 +++++++++++ src/client/hooks/useTotals.ts | 31 +++++ src/client/lib/api.ts | 61 ++++++++++ src/client/lib/formatters.ts | 9 ++ src/client/stores/uiStore.ts | 25 ++++ 11 files changed, 647 insertions(+) create mode 100644 src/client/components/CategoryHeader.tsx create mode 100644 src/client/components/ConfirmDialog.tsx create mode 100644 src/client/components/ImageUpload.tsx create mode 100644 src/client/components/ItemCard.tsx create mode 100644 src/client/components/TotalsBar.tsx create mode 100644 src/client/hooks/useCategories.ts create mode 100644 src/client/hooks/useItems.ts create mode 100644 src/client/hooks/useTotals.ts create mode 100644 src/client/lib/api.ts create mode 100644 src/client/lib/formatters.ts create mode 100644 src/client/stores/uiStore.ts diff --git a/src/client/components/CategoryHeader.tsx b/src/client/components/CategoryHeader.tsx new file mode 100644 index 0000000..726999e --- /dev/null +++ b/src/client/components/CategoryHeader.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { formatWeight, formatPrice } from "../lib/formatters"; +import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories"; + +interface CategoryHeaderProps { + categoryId: number; + name: string; + emoji: string; + totalWeight: number; + totalCost: number; + itemCount: number; +} + +export function CategoryHeader({ + categoryId, + name, + emoji, + totalWeight, + totalCost, + itemCount, +}: CategoryHeaderProps) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(name); + const [editEmoji, setEditEmoji] = useState(emoji); + const updateCategory = useUpdateCategory(); + const deleteCategory = useDeleteCategory(); + + const isUncategorized = categoryId === 1; + + function handleSave() { + if (!editName.trim()) return; + updateCategory.mutate( + { id: categoryId, name: editName.trim(), emoji: editEmoji }, + { onSuccess: () => setIsEditing(false) }, + ); + } + + function handleDelete() { + if ( + confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`) + ) { + deleteCategory.mutate(categoryId); + } + } + + if (isEditing) { + return ( +
+ setEditEmoji(e.target.value)} + className="w-12 text-center text-xl border border-gray-200 rounded-md px-1 py-1" + maxLength={4} + /> + setEditName(e.target.value)} + className="text-lg font-semibold border border-gray-200 rounded-md px-2 py-1" + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") setIsEditing(false); + }} + autoFocus + /> + + +
+ ); + } + + return ( +
+ {emoji} +

{name}

+ + {itemCount} {itemCount === 1 ? "item" : "items"} ·{" "} + {formatWeight(totalWeight)} · {formatPrice(totalCost)} + + {!isUncategorized && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/client/components/ConfirmDialog.tsx b/src/client/components/ConfirmDialog.tsx new file mode 100644 index 0000000..3ca6446 --- /dev/null +++ b/src/client/components/ConfirmDialog.tsx @@ -0,0 +1,61 @@ +import { useUIStore } from "../stores/uiStore"; +import { useDeleteItem } from "../hooks/useItems"; +import { useItems } from "../hooks/useItems"; + +export function ConfirmDialog() { + const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId); + const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete); + const deleteItem = useDeleteItem(); + const { data: items } = useItems(); + + if (confirmDeleteItemId == null) return null; + + const item = items?.find((i) => i.id === confirmDeleteItemId); + const itemName = item?.name ?? "this item"; + + function handleDelete() { + if (confirmDeleteItemId == null) return; + deleteItem.mutate(confirmDeleteItemId, { + onSuccess: () => closeConfirmDelete(), + }); + } + + return ( +
+
{ + if (e.key === "Escape") closeConfirmDelete(); + }} + /> +
+

+ Delete Item +

+

+ Are you sure you want to delete{" "} + {itemName}? This action cannot be + undone. +

+
+ + +
+
+
+ ); +} diff --git a/src/client/components/ImageUpload.tsx b/src/client/components/ImageUpload.tsx new file mode 100644 index 0000000..f898fb4 --- /dev/null +++ b/src/client/components/ImageUpload.tsx @@ -0,0 +1,95 @@ +import { useState, useRef } from "react"; +import { apiUpload } from "../lib/api"; + +interface ImageUploadProps { + value: string | null; + onChange: (filename: string | null) => void; +} + +const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB +const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"]; + +export function ImageUpload({ value, onChange }: ImageUploadProps) { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setError(null); + + if (!ACCEPTED_TYPES.includes(file.type)) { + setError("Please select a JPG, PNG, or WebP image."); + return; + } + + if (file.size > MAX_SIZE_BYTES) { + setError("Image must be under 5MB."); + return; + } + + setUploading(true); + try { + const result = await apiUpload<{ filename: string }>( + "/api/images", + file, + ); + onChange(result.filename); + } catch { + setError("Upload failed. Please try again."); + } finally { + setUploading(false); + } + } + + return ( +
+ {value && ( +
+ Item + +
+ )} + + + {error &&

{error}

} +
+ ); +} diff --git a/src/client/components/ItemCard.tsx b/src/client/components/ItemCard.tsx new file mode 100644 index 0000000..6fe4d2b --- /dev/null +++ b/src/client/components/ItemCard.tsx @@ -0,0 +1,62 @@ +import { formatWeight, formatPrice } from "../lib/formatters"; +import { useUIStore } from "../stores/uiStore"; + +interface ItemCardProps { + id: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + categoryName: string; + categoryEmoji: string; + imageFilename: string | null; +} + +export function ItemCard({ + id, + name, + weightGrams, + priceCents, + categoryName, + categoryEmoji, + imageFilename, +}: ItemCardProps) { + const openEditPanel = useUIStore((s) => s.openEditPanel); + + return ( + + ); +} diff --git a/src/client/components/TotalsBar.tsx b/src/client/components/TotalsBar.tsx new file mode 100644 index 0000000..3277050 --- /dev/null +++ b/src/client/components/TotalsBar.tsx @@ -0,0 +1,38 @@ +import { useTotals } from "../hooks/useTotals"; +import { formatWeight, formatPrice } from "../lib/formatters"; + +export function TotalsBar() { + const { data } = useTotals(); + + const global = data?.global; + + return ( +
+
+
+

GearBox

+
+ + + {global?.itemCount ?? 0} + {" "} + items + + + + {formatWeight(global?.totalWeight ?? null)} + {" "} + total + + + + {formatPrice(global?.totalCost ?? null)} + {" "} + spent + +
+
+
+
+ ); +} diff --git a/src/client/hooks/useCategories.ts b/src/client/hooks/useCategories.ts new file mode 100644 index 0000000..62552b0 --- /dev/null +++ b/src/client/hooks/useCategories.ts @@ -0,0 +1,53 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; +import type { Category, CreateCategory } from "../../shared/types"; + +export function useCategories() { + return useQuery({ + queryKey: ["categories"], + queryFn: () => apiGet("/api/categories"), + }); +} + +export function useCreateCategory() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateCategory) => + apiPost("/api/categories", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categories"] }); + }, + }); +} + +export function useUpdateCategory() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + ...data + }: { + id: number; + name?: string; + emoji?: string; + }) => apiPut(`/api/categories/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categories"] }); + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} + +export function useDeleteCategory() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiDelete<{ success: boolean }>(`/api/categories/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categories"] }); + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} diff --git a/src/client/hooks/useItems.ts b/src/client/hooks/useItems.ts new file mode 100644 index 0000000..eddc52c --- /dev/null +++ b/src/client/hooks/useItems.ts @@ -0,0 +1,69 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; +import type { CreateItem } from "../../shared/types"; + +interface ItemWithCategory { + id: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + categoryId: number; + notes: string | null; + productUrl: string | null; + imageFilename: string | null; + createdAt: string; + updatedAt: string; + categoryName: string; + categoryEmoji: string; +} + +export function useItems() { + return useQuery({ + queryKey: ["items"], + queryFn: () => apiGet("/api/items"), + }); +} + +export function useItem(id: number | null) { + return useQuery({ + queryKey: ["items", id], + queryFn: () => apiGet(`/api/items/${id}`), + enabled: id != null, + }); +} + +export function useCreateItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateItem) => + apiPost("/api/items", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} + +export function useUpdateItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: number } & Partial) => + apiPut(`/api/items/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} + +export function useDeleteItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiDelete<{ success: boolean }>(`/api/items/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }); + queryClient.invalidateQueries({ queryKey: ["totals"] }); + }, + }); +} diff --git a/src/client/hooks/useTotals.ts b/src/client/hooks/useTotals.ts new file mode 100644 index 0000000..482b05c --- /dev/null +++ b/src/client/hooks/useTotals.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "../lib/api"; + +interface CategoryTotals { + categoryId: number; + categoryName: string; + categoryEmoji: string; + totalWeight: number; + totalCost: number; + itemCount: number; +} + +interface GlobalTotals { + totalWeight: number; + totalCost: number; + itemCount: number; +} + +interface TotalsResponse { + categories: CategoryTotals[]; + global: GlobalTotals; +} + +export type { CategoryTotals, GlobalTotals, TotalsResponse }; + +export function useTotals() { + return useQuery({ + queryKey: ["totals"], + queryFn: () => apiGet("/api/totals"), + }); +} diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts new file mode 100644 index 0000000..f96caaf --- /dev/null +++ b/src/client/lib/api.ts @@ -0,0 +1,61 @@ +class ApiError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + +async function handleResponse(res: Response): Promise { + if (!res.ok) { + let message = `Request failed with status ${res.status}`; + try { + const body = await res.json(); + if (body.error) message = body.error; + } catch { + // Use default message + } + throw new ApiError(message, res.status); + } + return res.json() as Promise; +} + +export async function apiGet(url: string): Promise { + const res = await fetch(url); + return handleResponse(res); +} + +export async function apiPost(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return handleResponse(res); +} + +export async function apiPut(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return handleResponse(res); +} + +export async function apiDelete(url: string): Promise { + const res = await fetch(url, { method: "DELETE" }); + return handleResponse(res); +} + +export async function apiUpload(url: string, file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch(url, { + method: "POST", + body: formData, + }); + return handleResponse(res); +} diff --git a/src/client/lib/formatters.ts b/src/client/lib/formatters.ts new file mode 100644 index 0000000..7167d83 --- /dev/null +++ b/src/client/lib/formatters.ts @@ -0,0 +1,9 @@ +export function formatWeight(grams: number | null | undefined): string { + if (grams == null) return "--"; + return `${Math.round(grams)}g`; +} + +export function formatPrice(cents: number | null | undefined): string { + if (cents == null) return "--"; + return `$${(cents / 100).toFixed(2)}`; +} diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts new file mode 100644 index 0000000..1588038 --- /dev/null +++ b/src/client/stores/uiStore.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; + +interface UIState { + panelMode: "closed" | "add" | "edit"; + editingItemId: number | null; + confirmDeleteItemId: number | null; + + openAddPanel: () => void; + openEditPanel: (itemId: number) => void; + closePanel: () => void; + openConfirmDelete: (itemId: number) => void; + closeConfirmDelete: () => void; +} + +export const useUIStore = create((set) => ({ + 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 }), +}));