Remove the MinIO/S3 storage backend entirely. The filesystem backend is fully implemented, already used in production, and a migrate-storage tool exists for any remaining MinIO deployments to migrate beforehand. Changes: - Delete MinIO client implementation (internal/storage/storage.go) - Delete migrate-storage tool (cmd/migrate-storage, scripts/migrate-storage.sh) - Remove MinIO service, volumes, and env vars from all Docker Compose files - Simplify StorageConfig: remove Endpoint, AccessKey, SecretKey, Bucket, UseSSL, Region fields; add SILO_STORAGE_ROOT_DIR env override - Change all SQL COALESCE defaults from 'minio' to 'filesystem' - Add migration 020 to update column defaults to 'filesystem' - Remove minio-go/v7 dependency (go mod tidy) - Update all config examples, setup scripts, docs, and tests
34 KiB
Silo Frontend Specification
Current as of 2026-02-11. Documents the React + Vite + TypeScript frontend (migration from Go templates is complete).
Overview
The Silo web UI has been migrated from server-rendered Go templates to a React single-page application. The Go templates (~7,000 lines across 7 files) have been removed. The Go API server serves JSON at /api/* and the React SPA at /.
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 | Complete |
Architecture
Browser
└── React SPA (served at /)
├── 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
├── /login, /logout Session auth endpoints (form POST)
├── /auth/oidc OIDC redirect flow
└── /* React SPA (NotFound handler serves index.html for client-side routing)
Auth Flow
- React app loads,
AuthProvidercallsGET /api/auth/me - If 401 → render
LoginPage(React form) - Login form POSTs
application/x-www-form-urlencodedto/login(Go handler sets session cookie) - On success,
AuthProvider.refresh()re-fetches/api/auth/me, user state populates, app renders - OIDC: link to
/auth/oidctriggers Go-served redirect flow, callback sets session, user returns to app - API client auto-redirects to
/loginon 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
│ ├── useFormDescriptor.ts Fetches form descriptor from /api/schemas/{name}/form (replaces useCategories)
│ ├── 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 + <Outlet/>
│ ├── 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: ~40 source files, ~7,600 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 asApiError - 204 responses return
undefined - Exports:
get<T>(),post<T>(),put<T>(),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
Completed Work
Issue #10: Remove Go Templates + Docker Integration -- COMPLETE
Completed in commit 50923cf. All Go templates deleted, web.go handler removed, SPA serves at / via NotFound handler with index.html fallback. build/package/Dockerfile added.
Remaining Work
Issue #5: Component Audit UI (future)
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
# 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.CSSPropertiesobjects, using Catppuccin CSS variables - No class components — functional components with hooks only
- Permission checks: derive
isEditorfromuser.rolein 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:
useLocalStoragehook for user preferences (layout mode, column visibility)
New Frontend Tasks
CreateItemPane — Schema-Driven Dynamic Form
Date: 2026-02-10
Scope: CreateItemPane.tsx renders a dynamic form driven entirely by the form descriptor API (GET /api/schemas/{name}/form). All field groups, field types, widgets, and category-specific fields are defined in YAML and resolved server-side.
Parent: Items page (ItemsPage.tsx) — renders in the detail pane area per existing in-pane CRUD pattern.
Layout
Single-column scrollable form with a green header bar. Field groups are rendered dynamically from the form descriptor. Category-specific field groups appear after global groups when a category is selected.
┌──────────────────────────────────────────────────────────────────────┐
│ Header: "New Item" [green bar] Cancel │ Create │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Category * [Domain buttons: F C R S E M T A P X] │
│ [Subcategory search + filtered list] │
│ │
│ ── Identity ────────────────────────────────────────────────────── │
│ [Type * (auto-derived from category)] [Description ] │
│ │
│ ── Sourcing ────────────────────────────────────────────────────── │
│ [Sourcing Type v] [Manufacturer] [MPN] [Supplier] [SPN] │
│ [Sourcing Link] │
│ │
│ ── Cost & Lead Time ────────────────────────────────────────────── │
│ [Standard Cost $] [Lead Time Days] [Min Order Qty] │
│ │
│ ── Status ──────────────────────────────────────────────────────── │
│ [Lifecycle Status v] [RoHS Compliant ☐] [Country of Origin] │
│ │
│ ── Details ─────────────────────────────────────────────────────── │
│ [Long Description ] │
│ [Projects: [tag][tag] type to search... ] │
│ [Notes ] │
│ │
│ ── Fastener Specifications (category-specific) ─────────────────── │
│ [Material] [Finish] [Thread Size] [Head Type] [Drive Type] ... │
│ │
└──────────────────────────────────────────────────────────────────────┘
Data Source — Form Descriptor API
All form structure is fetched from GET /api/schemas/kindred-rd/form, which returns:
category_picker: Multi-stage picker config (domain → subcategory)item_fields: Definitions for item-level fields (description, item_type, sourcing_type, etc.)field_groups: Ordered groups with resolved field metadata (Identity, Sourcing, Cost, Status, Details)category_field_groups: Per-category-prefix groups (e.g., Fastener Specifications forFprefix)field_overrides: Widget hints (currency, url, select, checkbox)
The YAML schema (schemas/kindred-rd.yaml) is the single source of truth. Adding a new field or category in YAML propagates to all clients with no code changes.
File Location
web/src/components/items/CreateItemPane.tsx
Supporting files:
| File | Purpose |
|---|---|
web/src/components/items/CategoryPicker.tsx |
Multi-stage domain/subcategory selector |
web/src/components/items/FileDropZone.tsx |
Drag-and-drop file upload |
web/src/components/items/TagInput.tsx |
Multi-select tag input for projects |
web/src/hooks/useFormDescriptor.ts |
Fetches and caches form descriptor from /api/schemas/{name}/form |
web/src/hooks/useFileUpload.ts |
Manages presigned URL upload flow |
Component Breakdown
CreateItemPane
Top-level orchestrator. Renders dynamic form from the form descriptor.
Props (unchanged interface):
interface CreateItemPaneProps {
onCreated: (item: Item) => void;
onCancel: () => void;
}
State:
const { descriptor, categories, loading } = useFormDescriptor();
const [category, setCategory] = useState(''); // selected category code, e.g. "F01"
const [fields, setFields] = useState<Record<string, string>>({}); // all field values keyed by name
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
A single fields record holds all form values (both item-level and property fields). The ITEM_LEVEL_FIELDS set (description, item_type, sourcing_type, long_description) determines which fields go into the top-level request vs. the properties map on submission.
Auto-derivation: When a category is selected, item_type is automatically set based on the derived_from_category mapping in the form descriptor (e.g., category prefix A → assembly, T → tooling, default → part).
Dynamic rendering: A renderField() function maps each field's widget type to the appropriate input:
| Widget | Rendered As |
|---|---|
text |
<input type="text"> |
number |
<input type="number"> |
textarea |
<textarea> |
select |
<select> with <option> elements from field.options |
checkbox |
<input type="checkbox"> |
currency |
<input type="number"> with currency prefix (e.g., "$") |
url |
<input type="url"> |
tag_input |
TagInput component with search endpoint |
Submission flow:
- Validate required fields (category must be selected).
- Split
fieldsinto item-level fields and properties usingITEM_LEVEL_FIELDS. POST /api/itemswith{ part_number: '', item_type, description, sourcing_type, long_description, category, properties: {...} }.- Call
onCreated(item).
Header bar: Green (--ctp-green background, --ctp-crust text). "New Item" title on left, Cancel and Create Item buttons on right.
CategoryPicker
Multi-stage category selector driven by the form descriptor's category_picker.stages config.
Props:
interface CategoryPickerProps {
value: string; // selected category code, e.g. "F01"
onChange: (code: string) => void;
categories: Record<string, string>; // flat code → description map
stages?: CategoryPickerStage[]; // from form descriptor
}
Rendering: Two-stage selection:
- Domain row: Horizontal row of buttons, one per domain from
stages[0].values(F=Fasteners, C=Fluid Fittings, etc.). Selected domain has mauve highlight. - Subcategory list: Filtered list of categories matching the selected domain prefix. Includes a search input for filtering. Each row shows code and description.
If no stages prop is provided, falls back to a flat searchable list of all categories.
Below the picker, the selected category is shown as a breadcrumb: Fasteners › F01 — Hex Cap Screw in --ctp-mauve.
Data source: Categories come from useFormDescriptor() which derives them from the category_picker stages and values_by_domain in the form descriptor response.
FileDropZone
Handles drag-and-drop and click-to-browse file uploads.
Props:
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; // storage 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, backgroundrgba(203, 166, 247, 0.05) - Uploading: show progress per file in the file list
Clicking the zone opens a hidden <input type="file" multiple>.
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-overlay0at 10px. - Upload progress bar (thin 2px bar under the file item,
--ctp-mauvefill) when uploading. - Remove button (
×) on the right,--ctp-overlay0→--ctp-redon hover.
Upload flow (managed by useFileUpload hook):
- On file selection/drop, immediately request a presigned upload URL:
POST /api/uploads/presignwith{ filename, content_type, size }. - Backend returns
{ object_key, upload_url, expires_at }. PUTthe file directly to the presigned URL usingXMLHttpRequest(for progress tracking).- On completion, update
PendingAttachment.uploadStatusto'complete'and store theobject_key. - The
object_keyis 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:
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-mauvetext, 11px font, with×remove button. - A bare
<input>(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 <div> 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 }.
useFormDescriptor Hook
function useFormDescriptor(schemaName = "kindred-rd"): {
descriptor: FormDescriptor | null;
categories: Record<string, string>; // flat code → description map derived from descriptor
loading: boolean;
}
Fetches GET /api/schemas/{name}/form on mount. Caches the result in a module-level variable so repeated renders/mounts don't refetch. Derives a flat categories map from the category_picker stages and values_by_domain in the response. Replaces the old useCategories hook (deleted).
useFileUpload Hook
function useFileUpload(): {
upload: (file: File) => Promise<PendingAttachment>;
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:
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
Form sections are rendered dynamically from the field_groups array in the form descriptor. Each section header is a flex row containing a label (11px uppercase, --ctp-overlay0) and a flex: 1 horizontal line (1px solid --ctp-surface0).
Global field groups (from ui.field_groups in YAML):
| Group Key | Label | Fields |
|---|---|---|
| identity | Identity | item_type, description |
| sourcing | Sourcing | sourcing_type, manufacturer, manufacturer_pn, supplier, supplier_pn, sourcing_link |
| cost | Cost & Lead Time | standard_cost, lead_time_days, minimum_order_qty |
| status | Status | lifecycle_status, rohs_compliant, country_of_origin |
| details | Details | long_description, projects, notes |
Category-specific field groups (from ui.category_field_groups in YAML, shown when a category is selected):
| Prefix | Group | Example Fields |
|---|---|---|
| F | Fastener Specifications | material, finish, thread_size, head_type, drive_type, ... |
| C | Fitting Specifications | material, connection_type, size_1, pressure_rating, ... |
| R | Motion Specifications | bearing_type, bore_diameter, load_rating, ... |
| ... | ... | (one group per category prefix, defined in YAML) |
Note: sourcing_link and standard_cost are revision properties (stored in the properties JSONB), not item-level DB columns. They were migrated from item-level fields in PR #1 (migration 013).
Backend Changes
Items 1-5 below are implemented. Item 4 (hierarchical categories) is resolved by the form descriptor's multi-stage category picker.
1. Presigned Upload URL -- IMPLEMENTED
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://...", "expires_at": "2026-02-06T..." }
The Go handler generates a presigned PUT URL for direct upload. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix.
2. File Association -- IMPLEMENTED
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 -- IMPLEMENTED
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 storage. Updates item.thumbnail_key column.
4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor)
Resolved by the schema-driven form descriptor (GET /api/schemas/{name}/form). The YAML schema's ui.category_picker section defines multi-stage selection:
- Stage 1 (domain): Groups categories by first character of category code (F=Fasteners, C=Fluid Fittings, etc.). Values defined in
ui.category_picker.stages[0].values. - Stage 2 (subcategory): Auto-derived by the Go backend's
ValuesByDomain()method, which groups the category enum values by their first character.
No separate categories table is needed — the existing schema enum values are the single source of truth. Adding a new category code to the YAML propagates to the picker automatically.
5. Database Schema Addition -- IMPLEMENTED
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 sourcing_type TEXT NOT NULL DEFAULT 'manufactured';
ALTER TABLE items ADD COLUMN unit_of_measure TEXT NOT NULL DEFAULT 'ea';
ALTER TABLE items ADD COLUMN long_description TEXT;
Implementation Order
- [DONE] Deduplicate sourcing_link/standard_cost — Migrated from item-level DB columns to revision properties (migration 013). Removed from Go structs, API types, frontend types.
- [DONE] Form descriptor API — Added
uisection to YAML, Go structs + validation,GET /api/schemas/{name}/formendpoint. - [DONE] useFormDescriptor hook — Replaces
useCategories, fetches and caches form descriptor. - [DONE] CategoryPicker rewrite — Multi-stage domain/subcategory picker driven by form descriptor.
- [DONE] CreateItemPane rewrite — Dynamic form rendering from field groups, widget-based field rendering.
- TagInput component — reusable, no backend changes needed, uses existing projects API.
- FileDropZone + useFileUpload — requires presigned URL backend endpoint (already implemented).
Types Added
The following types were added to web/src/api/types.ts for the form descriptor system:
// Form descriptor types (from GET /api/schemas/{name}/form)
interface FormFieldDescriptor {
name: string;
type: string;
widget: string;
label: string;
required?: boolean;
default?: string;
unit?: string;
description?: string;
options?: string[];
currency?: string;
derived_from_category?: Record<string, string>;
search_endpoint?: string;
}
interface FormFieldGroup {
key: string;
label: string;
order: number;
fields: FormFieldDescriptor[];
}
interface CategoryPickerStage {
name: string;
label: string;
values?: Record<string, string>;
values_by_domain?: Record<string, Record<string, string>>;
}
interface CategoryPickerDescriptor {
style: string;
stages: CategoryPickerStage[];
}
interface ItemFieldDef {
type: string;
widget: string;
label: string;
required?: boolean;
default?: string;
options?: string[];
derived_from_category?: Record<string, string>;
search_endpoint?: string;
}
interface FieldOverride {
widget?: string;
currency?: string;
options?: string[];
}
interface FormDescriptor {
schema_name: string;
format: string;
category_picker: CategoryPickerDescriptor;
item_fields: Record<string, ItemFieldDef>;
field_groups: FormFieldGroup[];
category_field_groups: Record<string, FormFieldGroup[]>;
field_overrides: Record<string, FieldOverride>;
}
// File uploads (unchanged)
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;
}
// Pending upload (frontend only, not an API type)
interface PendingAttachment {
file: File;
objectKey: string;
uploadProgress: number;
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
error?: string;
}
Note: sourcing_link and standard_cost have been removed from the Item, CreateItemRequest, and UpdateItemRequest interfaces — they are now stored as revision properties and rendered dynamically from the form descriptor.