# Silo Frontend Specification Current as of 2026-02-06. Tracks the React + Vite + TypeScript frontend migration (epic #6). ## Overview The Silo web UI is being migrated from server-rendered Go templates with vanilla JavaScript (~7,000 lines across 7 templates) to a React single-page application. The Go API server remains unchanged — it serves JSON at `/api/*` and the React app consumes it. **Stack**: React 19, React Router 7, Vite 6, TypeScript 5.7 **Theme**: Catppuccin Mocha (dark) via CSS custom properties **Styling**: Inline React styles using `React.CSSProperties` — no CSS modules, no Tailwind, no styled-components **State**: Local `useState` + custom hooks. No global state library (no Redux, Zustand, etc.) **Dependencies**: Minimal — only `react`, `react-dom`, `react-router-dom`. No axios, no tanstack-query. ## Migration Status | Phase | Issue | Title | Status | |-------|-------|-------|--------| | 1 | #7 | Scaffold React + Vite + TS, shared layout, auth, API client | Code complete | | 2 | #8 | Migrate Items page with UI improvements | Code complete | | 3 | #9 | Migrate Projects, Schemas, Settings, Login pages | Code complete | | 4 | #10 | Remove Go templates, Docker integration, cleanup | Not started | ## Architecture ``` Browser └── React SPA (served at /app/* during transition, / after Phase 4) ├── Vite dev server (development) → proxies /api/* to Go backend └── Static files in web/dist/ (production) → served by Go binary Go Server (silod) ├── /api/* JSON REST API (unchanged) ├── /login, /logout Session auth endpoints (form POST) ├── /auth/oidc OIDC redirect flow ├── /app/* React SPA static files (current transition) └── /* Go template pages (removed in Phase 4) ``` ### Auth Flow 1. React app loads, `AuthProvider` calls `GET /api/auth/me` 2. If 401 → render `LoginPage` (React form) 3. Login form POSTs `application/x-www-form-urlencoded` to `/login` (Go handler sets session cookie) 4. On success, `AuthProvider.refresh()` re-fetches `/api/auth/me`, user state populates, app renders 5. OIDC: link to `/auth/oidc` triggers Go-served redirect flow, callback sets session, user returns to app 6. API client auto-redirects to `/login` on any 401 response ### Public API Endpoint `GET /api/auth/config` — returns `{ oidc_enabled: bool, local_enabled: bool }` so the login page can conditionally show the OIDC button without hardcoding. ## File Structure ``` web/ ├── index.html ├── package.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── src/ ├── main.tsx Entry point, renders AuthProvider + BrowserRouter + App ├── App.tsx Route definitions, auth guard ├── api/ │ ├── client.ts fetch wrapper: get, post, put, del + ApiError class │ └── types.ts All TypeScript interfaces (272 lines) ├── context/ │ └── AuthContext.tsx AuthProvider with login/logout/refresh methods ├── hooks/ │ ├── useAuth.ts Context consumer hook │ ├── useItems.ts Items fetching with search, filters, pagination, debounce │ └── useLocalStorage.ts Typed localStorage persistence hook ├── styles/ │ ├── theme.css Catppuccin Mocha CSS custom properties │ └── global.css Base element styles ├── components/ │ ├── AppShell.tsx Header nav + user info + │ ├── ContextMenu.tsx Reusable right-click positioned menu │ └── items/ Items page components (16 files) │ ├── ItemsToolbar.tsx Search, filters, layout toggle, action buttons │ ├── ItemTable.tsx Sortable table, column config, compact rows │ ├── ItemDetail.tsx 5-tab detail panel (Main, Properties, Revisions, BOM, Where Used) │ ├── MainTab.tsx Metadata display, project tags editor, file info │ ├── PropertiesTab.tsx Form/JSON dual-mode property editor │ ├── RevisionsTab.tsx Revision list, compare diff, status, rollback │ ├── BOMTab.tsx BOM table with inline CRUD, cost calculations │ ├── WhereUsedTab.tsx Parent assemblies table │ ├── SplitPanel.tsx Resizable horizontal/vertical layout container │ ├── FooterStats.tsx Fixed bottom bar with item counts │ ├── CreateItemPane.tsx In-pane create form with schema category properties │ ├── EditItemPane.tsx In-pane edit form │ ├── DeleteItemPane.tsx In-pane delete confirmation │ └── ImportItemsPane.tsx CSV upload with dry-run/import flow └── pages/ ├── LoginPage.tsx Username/password form + OIDC button ├── ItemsPage.tsx Orchestrator: toolbar, split panel, table, detail/CRUD panes ├── ProjectsPage.tsx Project CRUD with sortable table, in-pane forms ├── SchemasPage.tsx Schema browser with collapsible segments, enum value CRUD ├── SettingsPage.tsx Account info, API token management └── AuditPage.tsx Audit completeness (placeholder, expanded in Issue #5) ``` **Total**: 32 source files, ~5,300 lines of TypeScript/TSX. ## Design System ### Theme Catppuccin Mocha dark theme. All colors referenced via CSS custom properties: | Token | Use | |-------|-----| | `--ctp-base` | Page background, input backgrounds | | `--ctp-mantle` | Header background | | `--ctp-surface0` | Card backgrounds, even table rows | | `--ctp-surface1` | Borders, dividers, hover states | | `--ctp-surface2` | Secondary button backgrounds | | `--ctp-text` | Primary text | | `--ctp-subtext0/1` | Secondary/muted text, labels | | `--ctp-overlay0` | Placeholder text | | `--ctp-mauve` | Brand accent, primary buttons, nav active | | `--ctp-blue` | Editor role badge, edit headers | | `--ctp-green` | Success banners, create headers | | `--ctp-red` | Errors, delete actions, danger buttons | | `--ctp-peach` | Part numbers, project codes, token prefixes | | `--ctp-teal` | Viewer role badge | | `--ctp-sapphire` | Links, collapsible toggles | | `--ctp-crust` | Dark text on colored backgrounds | ### Typography - Body: system font stack (Inter, -apple-system, etc.) - Monospace: JetBrains Mono (part numbers, codes, tokens) - Table cells: 0.85rem - Labels: 0.85rem, weight 500 - Table headers: 0.8rem, uppercase, letter-spacing 0.05em ### Component Patterns **Tables**: Inline styles, compact rows (28-32px), alternating `base`/`surface0` backgrounds, sortable headers with arrow indicators, right-click column config (Items page). **Forms**: In-pane forms (Infor ERP-style) — not modal overlays. Create/Edit/Delete forms render in the detail pane area with a colored header bar (green=create, blue=edit, red=delete). Cancel returns to previous view. **Cards**: `surface0` background, `0.75rem` border radius, `1.5rem` padding. **Buttons**: Primary (`mauve` bg, `crust` text), secondary (`surface1` bg), danger (`red` bg or translucent red bg with red text). **Errors**: Red text with translucent red background banner, `0.4rem` border radius. **Role badges**: Colored pill badges — admin=mauve, editor=blue, viewer=teal. ## Page Specifications ### Items Page (completed in #8) The most complex page. Master-detail layout with resizable split panel. **Toolbar**: Debounced search (300ms) with scope toggle (All/PN/Description), type and project filter dropdowns, layout toggle (horizontal/vertical), export/import/create buttons. **Table**: 7 configurable columns (part_number, item_type, description, revision, projects, created, actions). Visibility stored per layout mode in localStorage. Right-click header opens ContextMenu with checkboxes. Compact rows, zebra striping, click to select. **Detail panel**: 5 tabs — Main (metadata + project tags + file info), Properties (form/JSON editor, save creates revision), Revisions (compare, status management, rollback), BOM (inline CRUD, cost calculations, CSV export), Where Used (parent assemblies). **CRUD panes**: In-pane forms for Create (schema category properties, project tags), Edit (basic fields), Delete (confirmation), Import (CSV upload with dry-run). **Footer**: Fixed 28px bottom bar showing Total | Parts | Assemblies | Documents counts, reactive to filters. **State**: `PaneMode` discriminated union manages which pane is shown. `useItems` hook handles fetching, search, filters, pagination. `useLocalStorage` persists layout and column preferences. ### Projects Page (completed in #9) Sortable table with columns: Code, Name, Description, Items (count fetched per project), Created, Actions. **CRUD**: In-pane forms above the table. Create requires code (2-10 chars, auto-uppercase), name, description. Edit allows name and description changes. Delete shows confirmation with project code. **Navigation**: Click project code navigates to Items page with `?project=CODE` filter. **Permissions**: Create/Edit/Delete buttons only visible to editor/admin roles. ### Schemas Page (completed in #9) Schema cards with collapsible segment details. Each schema shows name, description, format string, version, and example part numbers. **Segments**: Expandable list showing segment name, type badge, description. Enum segments include a values table with code and description columns. **Enum CRUD**: Inline table operations — add row at bottom, edit replaces the row, delete highlights the row with confirmation. All operations call `POST/PUT/DELETE /api/schemas/{name}/segments/{segment}/values/{code}`. ### Settings Page (completed in #9) Two cards: **Account**: Read-only grid showing username, display name, email, auth source, role (with colored badge). Data from `useAuth()` context. **API Tokens**: Create form (name input + button), one-time token display in green banner with copy-to-clipboard, token list table (name, prefix, created, last used, expires, revoke). Revoke has inline confirm step. Uses `GET/POST/DELETE /api/auth/tokens`. ### Login Page (completed in #9) Standalone centered card (no AppShell). Username/password form, OIDC button shown conditionally based on `GET /api/auth/config`. Error messages in red banner. Submit calls `AuthContext.login()` which POSTs form data to `/login` then re-fetches the user. ### Audit Page (placeholder) Basic table showing audit completeness data from `GET /api/audit/completeness`. Will be expanded as part of Issue #5 (Component Audit UI with completeness scoring and inline editing). ## API Client `web/src/api/client.ts` — thin wrapper around `fetch`: - Always sends `credentials: 'include'` for session cookies - Always sets `Content-Type: application/json` - 401 responses redirect to `/login` - Non-OK responses parsed as `{ error, message }` and thrown as `ApiError` - 204 responses return `undefined` - Exports: `get()`, `post()`, `put()`, `del()` ## Type Definitions `web/src/api/types.ts` — 272 lines covering all API response and request shapes: **Core models**: User, Item, Project, Schema, SchemaSegment, Revision, BOMEntry **Audit**: AuditFieldResult, AuditItemResult, AuditSummary, AuditCompletenessResponse **Search**: FuzzyResult (extends Item with score) **BOM**: WhereUsedEntry, AddBOMEntryRequest, UpdateBOMEntryRequest **Items**: CreateItemRequest, UpdateItemRequest, CreateRevisionRequest **Projects**: CreateProjectRequest, UpdateProjectRequest **Schemas**: CreateSchemaValueRequest, UpdateSchemaValueRequest, PropertyDef, PropertySchema **Auth**: AuthConfig, ApiToken, ApiTokenCreated **Revisions**: RevisionComparison **Import**: CSVImportResult, CSVImportError **Errors**: ErrorResponse ## Remaining Work ### Issue #10: Remove Go Templates + Docker Integration The final phase completes the migration: **Remove Go templates**: Delete `internal/api/templates/` (7 HTML files), remove template loading code, remove web handler route group and CSRF middleware for web routes, remove `HandleIndex`, `HandleProjects`, `HandleSchemas`, `HandleSettings` handlers. **SPA serving**: Serve `web/dist/` at `/` with SPA fallback (non-API routes return `index.html`). Either `go:embed` for single-binary deployment or filesystem serving. `/api/*` routes must take precedence. Cache headers for Vite's hashed assets. **Docker**: Update `build/package/Dockerfile` to multi-stage — Stage 1: Node (`npm ci && npm run build`), Stage 2: Go build with `web/dist/`, Final: minimal Alpine image with single binary. **Makefile**: Existing `web-install`, `web-dev`, `web-build` targets need verification. `make build` should include the web build step. `make clean` should include `web/dist/`. **Acceptance criteria**: Single Docker image serves both API and React frontend. `make build` produces working binary. No Go template code remains. All pages accessible at React Router paths. ### Issue #5: Component Audit UI (future) After migration completes, the Audit page will be expanded with completeness scoring, inline editing, tier filtering, and category breakdowns. This will be built natively in React using the patterns established in the migration. ## Development ```bash # Install dependencies cd web && npm install # Dev server (proxies /api/* to Go backend on :8080) npm run dev # Type check npx tsc --noEmit # Production build npm run build ``` Vite dev server runs on port 5173 with proxy config in `vite.config.ts` forwarding `/api/*`, `/login`, `/logout`, `/auth/*` to the Go backend. ## Conventions - **No modals for CRUD** — use in-pane forms (Infor ERP-style pattern) - **No shared component library extraction** until a pattern repeats 3+ times - **Inline styles only** — all styling via `React.CSSProperties` objects, using Catppuccin CSS variables - **No class components** — functional components with hooks only - **Permission checks**: derive `isEditor` from `user.role` in each page, conditionally render write actions - **Error handling**: try/catch with error state, display in red banners inline - **Data fetching**: `useEffect` + API client on mount, loading/error/data states - **Persistence**: `useLocalStorage` hook for user preferences (layout mode, column visibility) ## New Frontend Tasks # CreateItemPane Redesign Specification **Date**: 2026-02-06 **Scope**: Replace existing `CreateItemPane.tsx` with a two-column layout, multi-stage category picker, file attachment via MinIO, and full use of screen real estate. **Parent**: Items page (`ItemsPage.tsx`) — renders in the detail pane area per existing in-pane CRUD pattern. --- ## Layout The pane uses a CSS Grid two-column layout instead of the current single-column form: ``` ┌──────────────────────────────────────────────────────┬──────────────┐ │ Header: "New Item" [green bar] Cancel │ Create │ │ ├──────────────────────────────────────────────────────┤ │ │ │ Auto- │ │ ── Identity ────────────────────────────────────── │ assigned │ │ [Part Number *] [Type * v] │ metadata │ │ [Description ] │ │ │ Category * [Domain │ Group │ Subtype ] │──────────────│ │ Mechanical│ Structural│ Bracket │ │ │ │ Electrical│ Bearings │ Plate │ │ Attachments │ │ ... │ ... │ ... │ │ ┌─ ─ ─ ─ ┐ │ │ ── Sourcing ────────────────────────────────────── │ │ Drop │ │ │ [Sourcing Type v] [Standard Cost $ ] │ │ zone │ │ │ [Unit of Measure v] [Sourcing Link ] │ └─ ─ ─ ─ ┘ │ │ │ file.FCStd │ │ ── Details ─────────────────────────────────────── │ drawing.pdf │ │ [Long Description ] │ │ │ [Projects: [tag][tag] type to search... ] │──────────────│ │ │ Thumbnail │ │ │ [preview] │ └──────────────────────────────────────────────────────┴──────────────┘ ``` Grid definition: `grid-template-columns: 1fr 320px`. The left column scrolls independently if content overflows. The right sidebar is a flex column with sections separated by `--ctp-surface1` borders. ## File Location `web/src/components/items/CreateItemPane.tsx` (replaces existing file) New supporting files: | File | Purpose | |------|---------| | `web/src/components/items/CategoryPicker.tsx` | Multi-stage category selector | | `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs | | `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects | | `web/src/hooks/useCategories.ts` | Fetches category tree from schema data | | `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow | ## Component Breakdown ### CreateItemPane Top-level orchestrator. Manages form state, submission, and layout. **Props** (unchanged interface): ```typescript interface CreateItemPaneProps { onCreated: (item: Item) => void; onCancel: () => void; } ``` **State**: ```typescript const [form, setForm] = useState({ part_number: '', item_type: 'part', description: '', category_path: [], // e.g. ['Mechanical', 'Structural', 'Bracket'] sourcing_type: 'manufactured', standard_cost: '', unit_of_measure: 'ea', sourcing_link: '', long_description: '', project_ids: [], }); const [attachments, setAttachments] = useState([]); const [thumbnail, setThumbnail] = useState(null); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); ``` **Submission flow**: 1. Validate required fields (part_number, item_type, category_path length === 3). 2. `POST /api/items` with form data → returns created `Item` with UUID. 3. For each attachment in `attachments[]`, call the file association endpoint: `POST /api/items/{id}/files` with the MinIO object key returned from upload. 4. If thumbnail exists, `PUT /api/items/{id}/thumbnail` with the object key. 5. Call `onCreated(item)`. If step 2 fails, show error banner. If file association fails, show warning but still navigate (item was created, files can be re-attached). **Header bar**: Green (`--ctp-green` background, `--ctp-crust` text) per existing create-pane convention. "New Item" title on left, Cancel (ghost button) and Create Item (primary button, `--ctp-green` bg) on right. ### CategoryPicker Three-column scrollable list for hierarchical category selection. **Props**: ```typescript interface CategoryPickerProps { value: string[]; // current selection path, e.g. ['Mechanical', 'Structural'] onChange: (path: string[]) => void; categories: CategoryNode[]; // top-level nodes } interface CategoryNode { name: string; children?: CategoryNode[]; } ``` **Rendering**: Three side-by-side `
` columns inside a container with `border: 1px solid var(--ctp-surface1)` and `border-radius: 0.4rem`. Each column has: - A sticky header row (10px uppercase, `--ctp-overlay0` text, `--ctp-mantle` background) labeling the tier. Labels come from the schema definition if available, otherwise "Level 1", "Level 2", "Level 3". - A scrollable list of options. Each option is a `
` row, 28px height, `0.85rem` font. Hover: `--ctp-surface0` background. Selected: translucent mauve background (`rgba(203, 166, 247, 0.12)`), `--ctp-mauve` text, weight 600. - If a node has children, show a `›` chevron on the right side of the row. Column 1 always shows all top-level nodes. Column 2 shows children of the selected Column 1 node. Column 3 shows children of the selected Column 2 node. If nothing is selected in a column, the next column shows an empty state with muted text: "Select a [tier name]". Below the picker, render a breadcrumb trail: `Mechanical › Structural › Bracket` in `--ctp-mauve` with `›` separators in `--ctp-overlay0`. Only show segments that are selected. **Data source**: Categories are derived from schemas. The `useCategories` hook calls `GET /api/schemas` and transforms the response into a `CategoryNode[]` tree. The exact mapping depends on how schemas define category hierarchies — if schemas don't currently support hierarchical categories, this requires a backend addition (see Backend Changes section). **Max height**: 180px per column with `overflow-y: auto`. ### FileDropZone Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow. **Props**: ```typescript interface FileDropZoneProps { files: PendingAttachment[]; onFilesAdded: (files: PendingAttachment[]) => void; onFileRemoved: (index: number) => void; accept?: string; // e.g. '.FCStd,.step,.stl,.pdf,.png,.jpg' } interface PendingAttachment { file: File; objectKey: string; // MinIO key after upload uploadProgress: number; // 0-100 uploadStatus: 'pending' | 'uploading' | 'complete' | 'error'; error?: string; } ``` **Drop zone UI**: Dashed `2px` border using `--ctp-surface1`, `border-radius: 0.5rem`, centered content with a paperclip icon (Unicode 📎 or inline SVG), "Drop files here or **browse**" text, and accepted formats in `--ctp-overlay0` at 10px. States: - **Default**: dashed border `--ctp-surface1` - **Drag over**: dashed border `--ctp-mauve`, background `rgba(203, 166, 247, 0.05)` - **Uploading**: show progress per file in the file list Clicking the zone opens a hidden ``. **File list**: Rendered below the drop zone. Each file shows: - Type icon: colored 28×28 rounded square. Color mapping: `.FCStd`/`.step`/`.stl` → `--ctp-blue` ("CAD"), `.pdf` → `--ctp-red` ("PDF"), `.png`/`.jpg` → `--ctp-green` ("IMG"), other → `--ctp-overlay1` ("FILE"). - File name (truncated with ellipsis). - File size + type label in `--ctp-overlay0` at 10px. - Upload progress bar (thin 2px bar under the file item, `--ctp-mauve` fill) when uploading. - Remove button (`×`) on the right, `--ctp-overlay0` → `--ctp-red` on hover. **Upload flow** (managed by `useFileUpload` hook): 1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`. 2. Backend returns `{ object_key, upload_url, expires_at }`. 3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking). 4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`. 5. The `object_key` is later sent to the item creation endpoint to associate the file. If the presigned URL endpoint doesn't exist yet, see Backend Changes. ### TagInput Reusable multi-select input for projects (and potentially other tag-like fields). **Props**: ```typescript interface TagInputProps { value: string[]; // selected project IDs onChange: (ids: string[]) => void; placeholder?: string; searchFn: (query: string) => Promise<{ id: string; label: string }[]>; } ``` **Rendering**: Container styled like a form input (`--ctp-crust` bg, `--ctp-surface1` border, `border-radius: 0.4rem`). Inside: - Selected tags as inline pills: `rgba(203, 166, 247, 0.15)` bg, `--ctp-mauve` text, 11px font, with `×` remove button. - A bare `` (no border/bg) that grows to fill remaining width, `min-width: 80px`. **Behavior**: On typing, debounce 200ms, call `searchFn(query)`. Show a dropdown below the input with matching results. Click or Enter selects. Already-selected items are excluded from results. Escape or blur closes the dropdown. The dropdown is an absolutely-positioned `
` below the input container, `--ctp-crust` background, `--ctp-surface1` border, `border-radius: 0.4rem`, `max-height: 160px`, `overflow-y: auto`. Each row is 28px, hover `--ctp-surface0`. **For projects**: `searchFn` calls `GET /api/projects?q={query}` and maps to `{ id: project.id, label: project.code + ' — ' + project.name }`. ### useCategories Hook ```typescript function useCategories(): { categories: CategoryNode[]; loading: boolean; error: string | null; } ``` Fetches `GET /api/schemas` on mount and transforms into a category tree. Caches in a module-level variable so repeated renders don't refetch. If the API doesn't currently support hierarchical categories, this returns a flat list as a single-tier picker until the backend is extended. ### useFileUpload Hook ```typescript function useFileUpload(): { upload: (file: File) => Promise; uploading: boolean; } ``` Encapsulates the presigned URL flow. Returns a function that takes a `File`, gets a presigned URL, uploads via XHR with progress tracking, and returns the completed `PendingAttachment`. The component manages the array of attachments in its own state. ## Styling All styling via inline `React.CSSProperties` objects, per project convention. Reference Catppuccin tokens through `var(--ctp-*)` strings. No CSS modules, no Tailwind, no class names. Common style patterns to extract as `const` objects at the top of each file: ```typescript const styles = { container: { display: 'grid', gridTemplateColumns: '1fr 320px', height: '100%', overflow: 'hidden', } as React.CSSProperties, formArea: { padding: '1.5rem 2rem', overflowY: 'auto', } as React.CSSProperties, formGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.25rem 1.5rem', maxWidth: '800px', } as React.CSSProperties, sidebar: { background: 'var(--ctp-mantle)', borderLeft: '1px solid var(--ctp-surface0)', display: 'flex', flexDirection: 'column' as const, overflowY: 'auto', } as React.CSSProperties, // ... etc }; ``` ## Form Sections The form is visually divided by section headers. Each header is a flex row containing a label (11px uppercase, `--ctp-overlay0`) and a `flex: 1` horizontal line (`1px solid --ctp-surface0`). Sections span `grid-column: 1 / -1`. | Section | Fields | |---------|--------| | Identity | Part Number*, Type*, Description, Category* | | Sourcing | Sourcing Type, Standard Cost, Unit of Measure, Sourcing Link | | Details | Long Description, Projects | ## Sidebar Sections The right sidebar is divided into three sections with `borderBottom: 1px solid var(--ctp-surface0)`: **Auto-assigned metadata**: Read-only key-value rows showing: - UUID: "On create" in `--ctp-teal` italic - Revision: "A" (hardcoded initial) - Created By: current user's display name from `useAuth()` **Attachments**: `FileDropZone` component. Takes `flex: 1` to fill available space. **Thumbnail**: A 4:3 aspect ratio placeholder box (`--ctp-crust` bg, `--ctp-surface0` border) with centered text "Generated from CAD file or upload manually". Clicking opens file picker filtered to images. If a thumbnail is uploaded, show it as an `` with `object-fit: cover`. ## Backend Changes Required The following API additions are needed. These should be tracked as sub-tasks or a separate issue. ### 1. Presigned Upload URL ``` POST /api/uploads/presign Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 } Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." } ``` The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix. ### 2. File Association ``` POST /api/items/{id}/files Request: { "object_key": "uploads/tmp/{uuid}/bracket.FCStd", "filename": "bracket.FCStd", "content_type": "...", "size": 2400000 } Response: { "file_id": "uuid", "filename": "...", "size": ..., "created_at": "..." } ``` Moves the object from the temp prefix to `items/{item_id}/files/{file_id}` and creates a row in a new `item_files` table. ### 3. Thumbnail ``` PUT /api/items/{id}/thumbnail Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" } Response: 204 ``` Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column. ### 4. Hierarchical Categories If schemas don't currently support a hierarchical category tree, one of these approaches: **Option A — Schema-driven**: Add a `category_tree` JSON column to the `schemas` table that defines the hierarchy. The `GET /api/schemas` response already returns schemas; the frontend transforms this into the picker tree. **Option B — Dedicated table**: ```sql CREATE TABLE categories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, parent_id UUID REFERENCES categories(id), sort_order INT NOT NULL DEFAULT 0, UNIQUE(parent_id, name) ); ``` With endpoints: ``` GET /api/categories → flat list with parent_id, frontend builds tree POST /api/categories → { name, parent_id? } PUT /api/categories/{id} → { name, sort_order } DELETE /api/categories/{id} → cascade check ``` **Recommendation**: Option B is more flexible and keeps categories as a first-class entity. The three-tier picker doesn't need to be limited to exactly three levels — it can render as many columns as the deepest category path, but three is the practical default (Domain → Group → Subtype). ### 5. Database Schema Addition ```sql CREATE TABLE item_files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, filename TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'application/octet-stream', size BIGINT NOT NULL DEFAULT 0, object_key TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_item_files_item ON item_files(item_id); ALTER TABLE items ADD COLUMN thumbnail_key TEXT; ALTER TABLE items ADD COLUMN category_id UUID REFERENCES categories(id); ALTER TABLE items ADD COLUMN sourcing_type TEXT NOT NULL DEFAULT 'manufactured'; ALTER TABLE items ADD COLUMN sourcing_link TEXT; ALTER TABLE items ADD COLUMN standard_cost NUMERIC(12,2); ALTER TABLE items ADD COLUMN unit_of_measure TEXT NOT NULL DEFAULT 'ea'; ALTER TABLE items ADD COLUMN long_description TEXT; ``` ## Implementation Order 1. **TagInput component** — reusable, no backend changes needed, uses existing projects API. 2. **CategoryPicker component** — start with flat/mock data, wire to real API after backend adds categories. 3. **FileDropZone + useFileUpload** — requires presigned URL backend endpoint first. 4. **CreateItemPane rewrite** — compose the above into the two-column layout. 5. **Backend: categories table + endpoints** — unblocks real category data. 6. **Backend: presigned uploads + item_files** — unblocks file attachments. 7. **Backend: items table migration** — adds new columns (sourcing_type, standard_cost, etc.). Steps 1-2 can start immediately. Steps 5-7 can run in parallel once specified. Step 4 ties it all together. ## Types to Add Add to `web/src/api/types.ts`: ```typescript // Categories interface Category { id: string; name: string; parent_id: string | null; sort_order: number; } interface CategoryNode { name: string; id: string; children?: CategoryNode[]; } // File uploads interface PresignRequest { filename: string; content_type: string; size: number; } interface PresignResponse { object_key: string; upload_url: string; expires_at: string; } interface ItemFile { id: string; item_id: string; filename: string; content_type: string; size: number; object_key: string; created_at: string; } // Extended create request interface CreateItemRequest { part_number: string; item_type: 'part' | 'assembly' | 'document'; description?: string; category_id?: string; sourcing_type?: 'manufactured' | 'purchased' | 'phantom'; standard_cost?: number; unit_of_measure?: string; sourcing_link?: string; long_description?: string; project_ids?: string[]; } // Pending upload (frontend only, not an API type) interface PendingAttachment { file: File; objectKey: string; uploadProgress: number; uploadStatus: 'pending' | 'uploading' | 'complete' | 'error'; error?: string; } ```