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 && (
+
+

+
+
+ )}
+
+
+ {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 }),
+}));