--- phase: 37 plan: "02" title: "Client — Admin Items List, Edit Page & Sidebar" type: execute wave: 2 depends_on: - "37-01" files_modified: - src/client/hooks/useAdminGlobalItems.ts - src/client/routes/admin.tsx - src/client/routes/admin/items.tsx - src/client/routes/admin/items.$itemId.tsx autonomous: true requirements: - ADMN-02 - ADMN-03 - ADMN-04 --- # Plan 37-02: Client — Admin Items List, Edit Page & Sidebar ## Objective Build the client side of the admin global item management feature: a `useAdminGlobalItems` hooks file, the `/admin/items` list page with infinite scroll, the `/admin/items/$itemId` edit page with all fields and impact-aware delete confirmation, and activate the Items sidebar link in the admin shell. All styling matches the UI-SPEC (Tailwind classes specified explicitly). --- Create useAdminGlobalItems hooks file execute - `src/client/hooks/useGlobalItems.ts` — read as pattern reference: `useQuery`/`useMutation` pattern, `apiGet`/`apiDelete`/`apiPut` usage, `ApiError` import, queryClient invalidation - `src/client/lib/api.ts` — confirm `apiGet`, `apiPut`, `apiDelete` signatures Create `src/client/hooks/useAdminGlobalItems.ts` with the following content: ```typescript import { useInfiniteQuery, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query"; import { ApiError, apiDelete, apiGet, apiPut } from "../lib/api"; // ── Types ────────────────────────────────────────────────────────── export interface AdminGlobalItem { id: number; manufacturerId: number; brand: string; model: string; category: string | null; weightGrams: number | null; priceCents: number | null; imageUrl: string | null; description: string | null; sourceUrl: string | null; imageCredit: string | null; imageSourceUrl: string | null; dominantColor: string | null; cropZoom: number | null; cropX: number | null; cropY: number | null; createdAt: string; tags: string[]; ownerCount: number; } export interface AdminGlobalItemPage { items: AdminGlobalItem[]; total: number; hasMore: boolean; nextOffset: number; } export interface AdminGlobalItemDetail extends Omit { ownerCount: number; } export interface UpdateGlobalItemPayload { manufacturerId?: number; model?: string; category?: string | null; weightGrams?: number | null; priceCents?: number | null; imageUrl?: string | null; description?: string | null; sourceUrl?: string | null; imageCredit?: string | null; imageSourceUrl?: string | null; tags?: string[]; } // ── Hooks ────────────────────────────────────────────────────────── export function useAdminGlobalItems(query?: string, tagNames?: string[]) { const params = new URLSearchParams(); if (query) params.set("q", query); if (tagNames && tagNames.length > 0) params.set("tags", tagNames.join(",")); params.set("limit", "50"); const qs = params.toString(); return useInfiniteQuery({ queryKey: ["admin-global-items", query ?? "", tagNames ?? []], queryFn: ({ pageParam = 0 }) => apiGet( `/api/admin/items?offset=${pageParam}&${qs}`, ), getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined, initialPageParam: 0, }); } export function useAdminGlobalItem(id: number | null) { return useQuery({ queryKey: ["admin-global-item", id], queryFn: () => apiGet(`/api/admin/items/${id}`), enabled: id != null, retry: (count, error) => error instanceof ApiError && error.status === 404 ? false : count < 3, }); } export function useUpdateAdminGlobalItem() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, data, }: { id: number; data: UpdateGlobalItemPayload; }) => apiPut(`/api/admin/items/${id}`, data), onSuccess: (_result, { id }) => { queryClient.invalidateQueries({ queryKey: ["admin-global-items"] }); queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] }); }, }); } export function useDeleteAdminGlobalItem() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: number) => apiDelete<{ success: boolean }>(`/api/admin/items/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-global-items"] }); }, }); } ``` - File `src/client/hooks/useAdminGlobalItems.ts` exists - File exports `useAdminGlobalItems` (uses `useInfiniteQuery` with `initialPageParam: 0`) - File exports `useAdminGlobalItem` (uses `useQuery`, enabled only when id is not null) - File exports `useUpdateAdminGlobalItem` (uses `useMutation` with `apiPut`) - File exports `useDeleteAdminGlobalItem` (uses `useMutation` with `apiDelete`) - `bun run build` exits 0 after this task Activate Items sidebar link in admin.tsx execute - `src/client/routes/admin.tsx` — read entire file; identify the disabled Items `
` block (lines ~32-40) that must be replaced with an active ``; understand existing imports (`createFileRoute`, `Outlet`, `useNavigate`, `useEffect`, `useAuth`, `LucideIcon`) Edit `src/client/routes/admin.tsx`: 1. Add `Link` to the `@tanstack/react-router` import line: ```typescript import { Link, createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; ``` 2. Replace the disabled Items `
` block: ```tsx {/* Items — disabled (phase 37) */}
Items Soon
``` With: ```tsx {/* Items — active (phase 37) */} Items ``` - `src/client/routes/admin.tsx` imports `Link` from `@tanstack/react-router` - File contains ``) - File contains `activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}` - File does NOT contain `cursor-not-allowed` on the Items entry - File does NOT contain the "Soon" badge span for Items - `bun run build` exits 0 after this task Create admin items list route (/admin/items) execute - `src/client/routes/admin/index.tsx` — read as reference for the file-based route pattern in the admin directory; confirm `createFileRoute` usage - `src/client/hooks/useAdminGlobalItems.ts` — the hooks file created in T1 (AdminGlobalItem type, useAdminGlobalItems, hook return shape) - `src/client/lib/iconData.ts` — confirm `LucideIcon` export - `src/client/hooks/useFormatters.ts` — confirm `useFormatters()` hook and its `formatWeight`/`formatPrice` methods - `src/client/routes/admin.tsx` — confirm the `
` wrapper uses `bg-gray-50 p-6`; the list page renders inside `` Create `src/client/routes/admin/items.tsx` with the following content: ```tsx import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { useAdminGlobalItems } from "../../hooks/useAdminGlobalItems"; import { useFormatters } from "../../hooks/useFormatters"; import { useTags } from "../../hooks/useTags"; export const Route = createFileRoute("/admin/items")({ component: AdminItemsPage, }); function AdminItemsPage() { const navigate = useNavigate(); const { weight: formatWeight, price: formatPrice } = useFormatters(); const [searchQuery, setSearchQuery] = useState(""); const [selectedTags, setSelectedTags] = useState([]); const [debouncedQuery, setDebouncedQuery] = useState(""); const sentinelRef = useRef(null); // Debounce search input useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300); return () => clearTimeout(timer); }, [searchQuery]); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, } = useAdminGlobalItems( debouncedQuery || undefined, selectedTags.length > 0 ? selectedTags : undefined, ); const { data: allTags } = useTags(); // Infinite scroll sentinel useEffect(() => { const el = sentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { threshold: 0.1 }, ); observer.observe(el); return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); const allItems = data?.pages.flatMap((p) => p.items) ?? []; const total = data?.pages[0]?.total ?? 0; function toggleTag(name: string) { setSelectedTags((prev) => prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name], ); } return (
{/* Header */}

Catalog Items

{!isLoading && (

{total.toLocaleString()} items

)}
setSearchQuery(e.target.value)} className="w-64 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300" />
{/* Tag filters */} {allTags && allTags.length > 0 && (
{allTags.map((tag) => ( ))}
)} {/* Error state */} {isError && (
Failed to load catalog items. Please try again.
)} {/* Table */} {!isError && (
{isLoading ? Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, j) => ( ))} )) : allItems.map((item) => ( navigate({ to: "/admin/items/$itemId", params: { itemId: String(item.id) } }) } > ))}
Brand / Model Category Weight Price Tags Owners
{item.brand} {item.model} {item.category ?? } {item.weightGrams != null ? formatWeight(item.weightGrams) : } {item.priceCents != null ? formatPrice(item.priceCents) : } {item.tags.length === 0 ? ( ) : item.tags.length <= 2 ? (
{item.tags.map((t) => ( {t} ))}
) : ( +{item.tags.length} )}
{item.ownerCount}
{/* Empty state (after load, no items) */} {!isLoading && allItems.length === 0 && !isError && (

No items found

Try a different search or clear your filters.

)} {/* Infinite scroll sentinel */}
{/* Loading more */} {isFetchingNextPage && (
Loading...
)} {/* All loaded message */} {!isLoading && !hasNextPage && allItems.length > 0 && (
All {total.toLocaleString()} items loaded
)}
)}
); } ``` Note: `useTags` hook — check if it exists in `src/client/hooks/useTags.ts`. If not, replace `useTags()` with a direct `useQuery` call to `/api/tags`. - File `src/client/routes/admin/items.tsx` exists - File exports route via `createFileRoute("/admin/items")(` - File imports and calls `useAdminGlobalItems` with `useInfiniteQuery` (via the hook) - File contains `sentinelRef` and `IntersectionObserver` for infinite scroll - File contains the data table with columns: Brand/Model, Category, Weight, Price, Tags, Owners - File contains skeleton loading rows (`animate-pulse`) - File contains empty state with "No items found" text - Row click calls `navigate` to `/admin/items/$itemId` - `bun run build` exits 0 after this task (routeTree.gen.ts auto-updated by Vite) Create admin item edit route (/admin/items/$itemId) execute - `src/client/hooks/useAdminGlobalItems.ts` — hooks file from T1: `useAdminGlobalItem`, `useUpdateAdminGlobalItem`, `useDeleteAdminGlobalItem`, `UpdateGlobalItemPayload`, `AdminGlobalItemDetail` types - `src/client/routes/admin/items.tsx` — the list route just created (T3) to understand navigation patterns - `src/server/routes/manufacturers.ts` — `GET /api/manufacturers` returns `{ id, name, slug }[]`; client needs to fetch this list for the brand dropdown - `src/client/lib/api.ts` — `apiGet` for fetching manufacturers inline Create `src/client/routes/admin/items.$itemId.tsx` with the following content: ```tsx import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { useAdminGlobalItem, useDeleteAdminGlobalItem, useUpdateAdminGlobalItem, } from "../../hooks/useAdminGlobalItems"; import { apiGet } from "../../lib/api"; export const Route = createFileRoute("/admin/items/$itemId")({ component: AdminItemEditPage, }); interface Manufacturer { id: number; name: string; slug: string; } // ── Tag chip input ───────────────────────────────────────────────── function TagInput({ value, onChange, }: { value: string[]; onChange: (tags: string[]) => void; }) { const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); function addTag(raw: string) { const tag = raw.trim().toLowerCase().replace(/\s+/g, "-"); if (tag && !value.includes(tag)) { onChange([...value, tag]); } setInputValue(""); } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addTag(inputValue); } else if (e.key === "Backspace" && inputValue === "" && value.length > 0) { onChange(value.slice(0, -1)); } } function removeTag(tag: string) { onChange(value.filter((t) => t !== tag)); } return (
inputRef.current?.focus()} > {value.map((tag) => ( {tag} ))} setInputValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => { if (inputValue) addTag(inputValue); }} placeholder={value.length === 0 ? "Add tags..." : ""} className="outline-none bg-transparent text-sm flex-1 min-w-[100px]" />
); } // ── Main edit page ───────────────────────────────────────────────── function AdminItemEditPage() { const { itemId } = Route.useParams(); const id = Number(itemId); const navigate = useNavigate(); const { data: item, isLoading, isError } = useAdminGlobalItem(isNaN(id) ? null : id); const updateMutation = useUpdateAdminGlobalItem(); const deleteMutation = useDeleteAdminGlobalItem(); const [manufacturers, setManufacturers] = useState([]); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // Form state const [form, setForm] = useState({ manufacturerId: 0, model: "", category: "", weightGrams: "", priceCents: "", imageUrl: "", description: "", sourceUrl: "", imageCredit: "", imageSourceUrl: "", tags: [] as string[], }); // Populate form when item loads useEffect(() => { if (item) { setForm({ manufacturerId: item.manufacturerId, model: item.model, category: item.category ?? "", weightGrams: item.weightGrams != null ? String(item.weightGrams) : "", priceCents: item.priceCents != null ? String(item.priceCents / 100) : "", imageUrl: item.imageUrl ?? "", description: item.description ?? "", sourceUrl: item.sourceUrl ?? "", imageCredit: item.imageCredit ?? "", imageSourceUrl: item.imageSourceUrl ?? "", tags: [], }); } }, [item]); // Fetch manufacturers for dropdown useEffect(() => { apiGet("/api/manufacturers").then(setManufacturers).catch(() => {}); }, []); function handleChange( field: keyof typeof form, value: string | number | string[], ) { setForm((prev) => ({ ...prev, [field]: value })); } async function handleSave(e: React.FormEvent) { e.preventDefault(); const weightGrams = form.weightGrams !== "" ? Number(form.weightGrams) : null; const priceCents = form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null; await updateMutation.mutateAsync({ id, data: { manufacturerId: form.manufacturerId || undefined, model: form.model || undefined, category: form.category || null, weightGrams: weightGrams, priceCents: priceCents, imageUrl: form.imageUrl || null, description: form.description || null, sourceUrl: form.sourceUrl || null, imageCredit: form.imageCredit || null, imageSourceUrl: form.imageSourceUrl || null, tags: form.tags, }, }); } async function handleDelete() { await deleteMutation.mutateAsync(id); navigate({ to: "/admin/items" }); } const inputClass = "w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300"; const labelClass = "block text-sm font-medium text-gray-700 mb-1"; const sectionClass = "border-t border-gray-100 pt-6 mt-6"; if (isLoading) { return (
{Array.from({ length: 8 }).map((_, i) => (
))}
); } if (isError || !item) { return (

Failed to load item. Please try again.

); } const ownerText = item.ownerCount === 0 ? "Not in any collection" : item.ownerCount === 1 ? "1 user in collection" : `${item.ownerCount} users in collection`; return (
{/* Back link */} {/* Page heading */}

{item.brand} {item.model}

{ownerText}

{/* Image section */}
{item.imageUrl && ( {`${item.brand} )} handleChange("imageUrl", e.target.value)} className={inputClass} placeholder="https://..." />
{/* Brand + Model */}
handleChange("model", e.target.value)} className={inputClass} placeholder="e.g. Woodsmoke 700" />
handleChange("category", e.target.value)} className={inputClass} placeholder="e.g. Bikepacking Bags" />
{/* Weight + Price */}
handleChange("weightGrams", e.target.value)} className={inputClass} placeholder="e.g. 450" min="0" step="1" />
handleChange("priceCents", e.target.value)} className={inputClass} placeholder="e.g. 129.99" min="0" step="0.01" />
{/* Tags + Description + Source */}
handleChange("tags", tags)} />