chore: remove internal docs from repo, update public documentation

Remove docs/superpowers/, .claude/ config, and PRD.md from version
control (kept locally via .gitignore). Update README, CHANGELOG,
VitePress docs, and .env.example to reflect recent features: Files
page, teams, admin settings, persistent storage, and various API
improvements.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 01:11:40 +08:00
parent 3b4f522bf4
commit 627ff8a82c
99 changed files with 853 additions and 15277 deletions
@@ -1,313 +0,0 @@
# Real Progress Bars for All Tools
## Problem
The current progress indicators are either fake (AI tools use time-based guessing) or nonexistent (fast tools show only a spinner icon on the button). Users have no real visibility into what's happening during processing.
## Solution
Replace all progress indicators with a unified, honest `ProgressCard` component backed by real progress data:
- **Upload phase**: real byte-level tracking via `XMLHttpRequest.upload.onprogress`
- **Processing phase**: real server-side progress via SSE for AI tools; honest brief state for fast tools
- **Completion**: card disappears immediately, download button appears
## Architecture
### Data Flow
```
Frontend Backend
| |
|-- Open SSE (jobId) ----------------->| (side channel ready)
|-- POST file + settings + jobId ----->|
| (XMLHttpRequest tracks upload %) |-- parse multipart
| |-- call tool.process()
| | |-- bridge.ts spawns Python
| | | Python emits progress to stderr
| | | bridge.ts parses, calls updateJobProgress()
|<-- SSE: {stage, percent} ------------|
|<-- SSE: {stage, percent} ------------|
| | |-- processing done
|<-- POST response (result) ----------|
|-- Close SSE ----------------------->|
```
The frontend opens an SSE connection *before* POSTing the file, using a client-generated jobId. This avoids restructuring the existing synchronous API. The SSE is a parallel side-channel for progress updates. Fast tools skip the SSE step entirely.
### Progress Phases
| Phase | Source | Data |
|-------|--------|------|
| Upload | `XMLHttpRequest.upload.onprogress` | Real bytes sent / total bytes |
| Processing (fast tools) | Implied — between upload complete and POST response | No sub-stages, honest "Processing..." |
| Processing (AI tools) | SSE from backend, driven by Python stderr | Real stage labels + granular percentages |
| Complete | POST response received | Card disappears, download button shown |
## Frontend
### `ProgressCard` Component
Replaces the existing `AIProgressBar`. Card-style compact design.
**Visual structure:**
```
┌─────────────────────────────────────────────┐
│ [icon] Removing background 45% │
│ Analyzing image · 8s │
│ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────────────────────┘
```
- **Icon**: upload arrow during upload, spinner during processing
- **Primary label**: action name ("Uploading image" / "Removing background")
- **Sub-label**: current stage + elapsed time
- **Percentage**: monospace, right-aligned, blue
- **Progress bar**: thin (4px), rounded, blue fill on dark track
- **Container**: dark card with subtle border, rounded corners
**Props:**
```typescript
interface ProgressCardProps {
/** Whether processing is active */
active: boolean;
/** Current phase */
phase: 'uploading' | 'processing' | 'complete';
/** Primary action label (e.g., "Removing background") */
label: string;
/** Current stage detail (e.g., "Analyzing image") */
stage?: string;
/** Progress percentage 0-100 */
percent: number;
/** Elapsed seconds */
elapsed: number;
}
```
**Location:** `apps/web/src/components/common/progress-card.tsx`
The old `AIProgressBar` component (`apps/web/src/components/common/ai-progress-bar.tsx`) will be deleted after all tools are migrated.
### `useToolProcessor` Hook Changes
Rewrite to support real progress tracking.
**New return type:**
```typescript
interface ToolProcessorResult {
processFiles: (files: File[], settings: Record<string, unknown>) => void;
processing: boolean;
error: string | null;
downloadUrl: string | null;
originalSize: number | null;
processedSize: number | null;
/** New: real-time progress state */
progress: {
phase: 'idle' | 'uploading' | 'processing' | 'complete';
percent: number;
stage?: string;
elapsed: number;
};
}
```
**Implementation changes:**
1. Generate a UUID `clientJobId` client-side before each operation
2. Replace `fetch` with `XMLHttpRequest` for upload progress tracking
3. For AI tools: open an `EventSource` SSE connection to `/api/v1/jobs/{clientJobId}/progress` before starting the upload
4. Track elapsed time internally
5. Merge upload progress and SSE progress into unified `progress` state
**State location:** The `progress` object is local React state within the hook (via `useState`), not stored in the Zustand `useFileStore`. Progress is transient per-request and only relevant to the component rendering it. The existing Zustand fields (`processing`, `error`, `processedUrl`, `originalSize`, `processedSize`) remain in the store unchanged.
**Note:** The hook returns `progress.phase` with `'idle'` as a possible value. The `ProgressCard` component accepts only `'uploading' | 'processing' | 'complete'` — the card is simply not rendered when phase is `'idle'` (controlled by the `active` prop).
**Determining if a tool is AI-powered:** Import the tool category from `@stirling-image/shared`. If `category === 'ai'`, enable SSE progress. Otherwise, skip SSE.
### Tool Settings Components
All ~37 tool settings components that currently show `AIProgressBar` or just a spinner need to:
1. Use the new `progress` field from `useToolProcessor`
2. Render `<ProgressCard>` instead of `<AIProgressBar>` or inline `<Loader2>` spinner
3. Show the progress card in place of the process button while active
The `ProgressCard` replaces both the process button AND any progress indicator during processing. When complete, the process button reappears along with the download button.
## Backend
### `progress.ts` — Extend for Single-File Progress
Add a discriminated union type that encompasses both batch and single-file progress:
```typescript
interface BaseProgress {
jobId: string;
type: 'batch' | 'single';
}
interface BatchProgress extends BaseProgress {
type: 'batch';
status: 'processing' | 'completed' | 'failed';
totalFiles: number;
completedFiles: number;
failedFiles: number;
errors: Array<{ filename: string; error: string }>;
currentFile?: string;
}
interface SingleFileProgress extends BaseProgress {
type: 'single';
phase: 'processing' | 'complete' | 'failed';
stage?: string; // "Loading model", "Running inference"
percent: number; // 0-100
error?: string;
}
type ProgressEvent = BatchProgress | SingleFileProgress;
```
Update the listener map type to `Map<string, Set<(data: ProgressEvent) => void>>`. The existing `updateJobProgress` function wraps its data with `type: 'batch'`. Add a new `updateSingleFileProgress(progress: Omit<SingleFileProgress, 'type'>)` function that adds `type: 'single'` and pushes through the same listener infrastructure. The existing SSE endpoint `/api/v1/jobs/:jobId/progress` serves both — the frontend discriminates on the `type` field.
### `bridge.ts` — Stream Python Stderr
Switch from `execFile` to `spawn` for child process management:
```typescript
function runPythonWithProgress(
script: string,
args: string[],
options: {
jobId?: string;
onProgress?: (percent: number, stage: string) => void;
timeout?: number;
maxBuffer?: number;
}
): Promise<string>
```
- Use `child_process.spawn` instead of `execFile`
- Capture stderr line-by-line
- Parse each line as JSON: `{ "progress": number, "stage": string }`
- Non-JSON stderr lines are collected as error output (backward compatible)
- Forward parsed progress to `onProgress` callback
- Stdout is still collected as the final result (JSON output)
- Timeout and cleanup behavior remains the same
**Venv fallback handling:** The current `bridge.ts` retries with system `python3` if the venv binary throws ENOENT. With `spawn`, ENOENT surfaces as an `'error'` event on the child process (not a thrown exception). The implementation must listen for the `'error'` event with code `ENOENT` and retry with the fallback `python3` path, preserving the existing behavior.
### Custom AI Route Handlers — Extract `clientJobId`
The 5 AI tools have custom route handlers (not using `createToolRoute`). Each needs to:
1. Extract `clientJobId` from the multipart form data (alongside `file` and `settings`)
2. Pass it through to the AI wrapper function, which forwards it to `bridge.ts`
Files to modify:
- `apps/api/src/routes/tools/remove-background.ts`
- `apps/api/src/routes/tools/upscale.ts`
- `apps/api/src/routes/tools/blur-faces.ts`
- `apps/api/src/routes/tools/erase-object.ts`
- `apps/api/src/routes/tools/ocr.ts`
Non-AI tools use `createToolRoute` and do not need SSE progress — no changes needed to `tool-factory.ts`.
**JobId lifecycle:** Two IDs exist per request:
- `clientJobId` (from frontend): used only for SSE progress correlation. Sent by frontend in the multipart form. Frontend opens SSE at `/api/v1/jobs/{clientJobId}/progress` before uploading.
- `jobId` (server-generated): used for workspace paths and download URLs. Returned in the response as today. These never cross; they serve different purposes.
### AI Tool TypeScript Wrappers
Each AI tool wrapper (`packages/ai/src/*.ts`) needs to:
1. Accept optional `jobId` parameter
2. Pass it to `runPythonWithProgress`
3. Wire up the `onProgress` callback to `updateSingleFileProgress`
### Python Scripts — Emit Progress
Each Python script emits progress as JSON lines to stderr:
```python
import sys, json
def emit_progress(percent: int, stage: str):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
```
**Per-script progress granularity:**
#### `remove_bg.py`
- Replace existing `sys.stderr.write()` calls with `emit_progress()` JSON calls
- `emit_progress(10, "Loading model")` — before session creation
- `emit_progress(25, "Model loaded")` — after session ready
- `emit_progress(30, "Analyzing image")` — before `rembg.remove()`
- `emit_progress(80, "Background removed")` — after remove completes
- `emit_progress(90, "Applying alpha matting")` — if alpha matting enabled
- `emit_progress(95, "Saving result")` — before file write
#### `detect_faces.py`
- `emit_progress(10, "Loading face detection model")`
- `emit_progress(20, "Model ready")`
- `emit_progress(25, "Scanning for faces")`
- `emit_progress(50, "Found N faces")` — after detection
- `emit_progress(50 + (i/N)*40, "Blurring face {i+1} of {N}")` — per-face progress
- `emit_progress(95, "Saving result")`
#### `upscale.py`
- `emit_progress(10, "Loading upscale model")`
- `emit_progress(20, "Model ready")`
- For Real-ESRGAN with tiles: `emit_progress(20 + (tile/total)*70, "Upscaling tile {tile} of {total}")`
- For Lanczos fallback: `emit_progress(50, "Upscaling with Lanczos")`
- `emit_progress(95, "Saving result")`
#### `inpaint.py`
- `emit_progress(10, "Loading inpainting model")`
- `emit_progress(25, "Analyzing mask")`
- `emit_progress(40, "Inpainting region")`
- `emit_progress(85, "Refining edges")`
- `emit_progress(95, "Saving result")`
#### `ocr.py`
- `emit_progress(10, "Loading OCR engine")`
- `emit_progress(30, "Analyzing text regions")`
- `emit_progress(70, "Extracting text")`
- `emit_progress(95, "Formatting results")`
#### Smart Crop (no Python script — uses Sharp)
- Smart crop is implemented in TypeScript/Sharp, not Python. It does not go through `bridge.ts`, so it behaves like a fast tool (upload progress only, brief "Processing..." state). No SSE progress needed.
## File Changes Summary
### New Files
- `apps/web/src/components/common/progress-card.tsx` — new ProgressCard component
### Modified Files
- `apps/web/src/hooks/use-tool-processor.ts` — add XHR upload progress + SSE progress
- `apps/api/src/routes/progress.ts` — add ProgressEvent union type + updateSingleFileProgress function
- `apps/api/src/routes/tools/remove-background.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/upscale.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/blur-faces.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/erase-object.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/ocr.ts` — extract clientJobId, pass to AI wrapper
- `packages/ai/src/bridge.ts` — switch to spawn, stream stderr progress, preserve venv fallback
- `packages/ai/src/background-removal.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/face-detection.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/upscaling.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/inpainting.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/ocr.ts` — accept progressJobId, wire progress callback
- `packages/ai/python/remove_bg.py` — replace stderr writes with emit_progress() JSON calls
- `packages/ai/python/detect_faces.py` — add emit_progress() calls
- `packages/ai/python/upscale.py` — add emit_progress() calls
- `packages/ai/python/inpaint.py` — add emit_progress() calls
- `packages/ai/python/ocr.py` — add emit_progress() calls
- 33 tool settings components in `apps/web/src/components/tools/` — swap AIProgressBar/spinner for ProgressCard (4 currently use AIProgressBar, 29 use only a Loader2 spinner)
### Deleted Files
- `apps/web/src/components/common/ai-progress-bar.tsx` — replaced by ProgressCard
## Edge Cases
- **SSE connection fails**: Fall back to upload-progress-only mode. Processing phase shows "Processing..." without percentage. System still works.
- **Python script doesn't emit progress**: Bridge treats it as zero progress updates — frontend shows "Processing..." until POST completes. Backward compatible.
- **Very large file upload**: Upload phase shows real progress, which is the most useful part for large files.
- **User navigates away mid-processing**: XHR abort + SSE close. Backend process may continue but workspace cleanup handles orphaned files.
- **Multiple rapid requests**: Each gets its own jobId, progress is isolated. Previous progress card is replaced.
- **Cancellation**: No explicit cancel button in v1. Users can navigate away to abort (XHR abort + SSE close). A cancel button for long-running AI operations (60s+ BiRefNet) is a natural follow-up but out of scope for this spec.
File diff suppressed because it is too large Load Diff
@@ -1,197 +0,0 @@
# Interactive Crop Tool Design
## Overview
Replace the current numbers-only crop UI with a visual, interactive crop tool featuring a draggable rectangle overlay on the image (Photoshop-style), aspect ratio presets, bidirectional pixel inputs, rule-of-thirds grid, and keyboard controls.
## Approach
**`react-image-crop`** (~5KB, zero deps) provides the core overlay with 8 drag handles, aspect ratio locking, and dimmed excluded area. Custom enhancements: rule-of-thirds grid (SVG), bidirectional pixel inputs, aspect ratio preset buttons, keyboard nudging. Actual cropping remains server-side via Sharp.
## Interaction Model
### Pre-crop (image loaded, not yet processed)
- Right panel shows `CropCanvas` component instead of `ImageViewer`
- Image fills available space (maintaining aspect ratio)
- Crop rectangle overlays the image via `react-image-crop`
- Rectangle starts covering the full image; user drags inward
- Area outside rectangle dimmed at ~50% opacity
- 8 drag handles: 4 corners + 4 edge midpoints
### Post-crop (after clicking "Crop")
- Switches to existing `SideBySideComparison` view showing before/after
- Download button appears in settings panel
### Flow
1. Drop image -> crop canvas appears with full-image crop selection
2. Adjust crop rectangle (drag handles, move, keyboard, or type pixel values)
3. Click "Crop" -> processes via Sharp backend -> shows before/after comparison
4. Download or undo to re-crop
## Settings Panel (Left Side)
### Aspect Ratio
- "Free" button (default, selected state) -- unconstrained dragging
- Preset buttons in a wrapped grid: `1:1`, `4:3`, `3:2`, `16:9`, `2:3`, `4:5`, `9:16`
- Swap button next to active preset to flip landscape/portrait (e.g. 16:9 -> 9:16)
- When a preset is selected, crop rectangle snaps to that ratio and drag handles maintain it
### Position & Size (pixel inputs)
- 2x2 grid: X (left), Y (top), Width, Height
- Bidirectionally synced with visual crop overlay -- dragging updates numbers, typing updates rectangle
- Shows original image dimensions as reference (e.g. "of 1920" hint text)
- Values clamped to valid ranges
### Grid Overlay
- Toggle: "Rule of Thirds" (on by default)
- Renders 3x3 grid inside crop area as thin semi-transparent lines
### Process Section
- "Crop" button (or "Crop (N files)" for batch)
- Progress card during processing
- Download button after completion
## CropCanvas Component
New file: `apps/web/src/components/tools/crop-canvas.tsx`
- Wraps uploaded image with `ReactCrop` from `react-image-crop`
- Uses percentage-based crop coordinates internally (overlay works at any display size)
- Converts to absolute pixels when syncing with settings inputs and submitting to API
- Image rendered with `object-fit: contain` to fill available space
### Dimension Badge
- Small floating label near bottom-right of crop area
- Shows resulting dimensions in real-time (e.g. "640 x 480")
### Rule of Thirds Grid
- SVG overlay inside crop area
- 4 lines (2 horizontal, 2 vertical) at 1/3 and 2/3 positions
- Thin white lines at ~40% opacity
### Keyboard Controls
- Arrow keys: nudge crop box by 1px
- Shift+Arrow: nudge by 10px
- Enter: apply crop (submit form)
- Escape: reset crop to full image
### Touch Support
- Handled by `react-image-crop` out of the box
## State Management
Crop state is owned by `tool-page.tsx` and passed **bidirectionally** to both `CropSettings` and `CropCanvas`. This differs from the rotate tool's one-way `onPreviewTransform` callback — crop requires both components to read and write the same state.
State shape:
```typescript
interface CropState {
crop: Crop; // react-image-crop's Crop type (percentage-based)
aspect: number | undefined; // locked aspect ratio or undefined for free
showGrid: boolean; // rule of thirds toggle
imgDimensions: { width: number; height: number } | null; // natural image dimensions
}
```
`tool-page.tsx` holds `[cropState, setCropState] = useState<CropState>(...)` and passes:
- To `CropCanvas`: `cropState`, `onCropChange`, `imageSrc` (from `originalBlobUrl`), `onImageLoad` (to capture natural dimensions)
- To `CropSettings`: `cropState`, `onCropChange`, `onAspectChange`, `onGridToggle`
### CropSettings Prop Interface
```typescript
interface CropSettingsProps {
cropState: CropState;
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
}
```
`CropSettings` continues to use `useToolProcessor("crop")` internally for submission. The pixel input fields convert between percentage-based `Crop` and absolute pixels using `cropState.imgDimensions`.
### CropCanvas Prop Interface
```typescript
interface CropCanvasProps {
imageSrc: string;
cropState: CropState;
onCropChange: (crop: Crop) => void;
onImageLoad: (dims: { width: number; height: number }) => void;
}
```
`CropCanvas` reads `imageSrc` as a prop (sourced from `originalBlobUrl` in the file store). It reports natural image dimensions via `onImageLoad` when the `<img>` fires its load event.
### Keyboard Focus
`CropCanvas` container has `tabIndex={0}` and captures focus on mount. Arrow key handlers call `e.preventDefault()` to suppress page scrolling. The component uses a `keydown` event listener on its container div.
## Rendering Path in tool-page.tsx
Add a new set: `const INTERACTIVE_CROP_TOOLS = new Set(["crop"])`.
The main area rendering logic adds a new branch **before** the existing `SIDE_BY_SIDE_TOOLS` check:
```
if (INTERACTIVE_CROP_TOOLS.has(toolId) && hasFile && !hasProcessed) {
return <CropCanvas ... />;
}
```
- **Pre-crop**: `CropCanvas` renders (interactive overlay on image)
- **Post-crop**: Falls through to `SIDE_BY_SIDE_TOOLS` which already includes `"crop"` -> shows `SideBySideComparison`
- **Undo**: `undoProcessing()` clears `processedUrl`, which causes `hasProcessed` to become false, routing back to `CropCanvas` (not `ImageViewer`)
The `ToolSettingsPanel` routing passes crop props to `CropSettings`:
```
if (toolId === "crop") return <CropSettings cropState={...} onCropChange={...} ... />;
```
## Batch / Multi-Image Behavior
When multiple files are loaded (`files.length > 1`), the interactive crop canvas is **not shown** — the existing `MultiImageViewer` renders instead (this check comes first in the rendering logic). The crop settings fall back to the pixel-input-only mode (no visual overlay) for batch, since different images may have different dimensions.
Single-image interactive cropping is the primary use case. Batch cropping with identical pixel coordinates is an advanced/power-user flow that works via the numeric inputs alone.
## Data Flow
1. User adjusts crop rectangle -> `react-image-crop` emits percentage-based `Crop` object
2. `CropCanvas` calls `onCropChange(crop)` -> `tool-page.tsx` updates `cropState`
3. `CropSettings` reads `cropState` and converts percentages to absolute pixels using `imgDimensions`
4. User types in pixel inputs -> `CropSettings` converts back to percentages and calls `onCropChange`
5. On submit, `CropSettings` converts final `cropState.crop` to `{ left, top, width, height }` (pixels) and sends to `useToolProcessor("crop")`
6. Backend processes via Sharp `.extract()`, returns `downloadUrl`
7. `tool-page.tsx` rendering falls through to `SideBySideComparison`
## Backend
No changes needed. Existing crop API endpoint accepts `{ left, top, width, height }` in pixels. Note: the backend currently hardcodes output as `image/png` regardless of input format — this is a pre-existing limitation not addressed in this spec.
## Files to Modify
- `apps/web/src/components/tools/crop-settings.tsx` -- redesign with aspect ratio presets, synced pixel inputs
- `apps/web/src/pages/tool-page.tsx` -- add crop canvas rendering path, lift crop state, add `INTERACTIVE_CROP_TOOLS` set
- **New:** `apps/web/src/components/tools/crop-canvas.tsx` -- visual cropper component
- `apps/web/package.json` -- add `react-image-crop` dependency
## Files NOT Modified
- Backend API routes
- Image engine operations
- Shared constants/types
- Docker (just rebuild)
## Dependencies
- `react-image-crop` (~5KB gzipped, zero transitive dependencies)
@@ -1,233 +0,0 @@
# Multi-Image UX Redesign
## Problem
When uploading multiple images, the tool page only previews the first image. There's no way to navigate between uploaded files, processing only handles one file, and downloads are single-file only. The strip-metadata tool doesn't show what metadata exists before removing it.
## Design Decisions
- **Processing model**: Hybrid — "Apply to all" batch processing with navigation to preview individual files before/after processing.
- **Thumbnail layout**: Bottom filmstrip strip below the main preview area. Horizontal scroll when many images. Left/right arrows on the main image.
- **Download model**: Individual per-file downloads + "Download All as ZIP" option.
- **Metadata display**: Fully parsed EXIF/GPS/ICC/XMP shown in a categorized table before stripping. GPS gets a red privacy warning badge.
## Architecture
### 1. File Store (`file-store.ts`)
Evolve from single-file state to multi-file aware state.
**Current state**: `files[]`, `originalBlobUrl` (first file only), `processedUrl` (single), `selectedFileName`, `selectedFileSize`.
**New state**:
```typescript
interface FileEntry {
file: File;
blobUrl: string;
/** Server download URL (single-file) or client blob URL (batch/ZIP extraction). */
processedUrl: string | null;
processedSize: number | null;
originalSize: number;
status: 'pending' | 'processing' | 'completed' | 'failed';
error: string | null;
}
interface FileState {
entries: FileEntry[];
selectedIndex: number;
/** Cached ZIP blob from batch processing, for "Download All" button. */
batchZipBlob: Blob | null;
batchZipFilename: string | null;
// Derived getters
currentEntry: FileEntry | null;
hasFiles: boolean;
allProcessed: boolean;
// Actions
setFiles: (files: File[]) => void;
addFiles: (files: File[]) => void;
removeFile: (index: number) => void;
setSelectedIndex: (index: number) => void;
navigateNext: () => void;
navigatePrev: () => void;
updateEntry: (index: number, updates: Partial<FileEntry>) => void;
setBatchZip: (blob: Blob, filename: string) => void;
/** Reset all entries to pending state, clear processed results. Replaces the old `undoProcessing()`. */
undoProcessing: () => void;
reset: () => void;
}
```
Key changes:
- Blob URLs generated for ALL files, not just first.
- `selectedIndex` replaces `selectedFileName` for navigation.
- Per-file processing state (`status`, `processedUrl`, `processedSize`).
- `processedUrl` can be either a server download path (single-file processing) or a client-side blob URL (from batch ZIP extraction). Both work as `src` for `<img>` or `<a href>`.
- `addFiles()` for the "+ Add more" button (appends to existing).
- `removeFile()` for removing individual files.
- `batchZipBlob` / `batchZipFilename` — cached ZIP blob from batch processing. The "Download All (ZIP)" button uses this directly instead of re-requesting.
- `undoProcessing()` — replaces the old single-file version. Resets all entries' `processedUrl`, `processedSize`, `status` back to `pending`, clears `error`, revokes any client-side blob URLs from processed results, and clears `batchZipBlob`.
- Blob URL cleanup on unmount/reset for all entries.
- Memory: the frontend enforces the same `MAX_BATCH_SIZE` limit as the backend. For large batches, thumbnails use the same blob URLs as the full preview (browser handles scaling via CSS `object-fit`).
### 2. Multi-Image Viewer Component
New `MultiImageViewer` component wraps the existing `ImageViewer`. Only renders the navigation chrome when `entries.length > 1`.
**Structure**:
```
┌──────────────────────────────────┐
│ [zoom toolbar] │
├──────────────────────────────────┤
│ Main Image │ │ ← arrows overlay, "2/5" badge
├──────────────────────────────────┤
│ filename.jpg 4032x3024 2.4MB │
├──────────────────────────────────┤
│ [thumb] [thumb] [thumb] [thumb] │ ← filmstrip, horizontal scroll
└──────────────────────────────────┘
```
**Props**: Uses file store directly (no props drilling). Renders `ImageViewer` for the currently selected entry, or `BeforeAfterSlider` if the current entry has a `processedUrl`.
**Navigation**:
- Left/right arrow buttons (circular, semi-transparent, positioned over image).
- Keyboard: left/right arrow keys, **only when the viewer container has focus** (not when a tool-specific input like crop handles or text fields is focused). Use `onKeyDown` on the viewer container div with `tabIndex={0}`, not a global listener.
- Click thumbnail to jump to that image.
- "N / M" counter badge in top-right of image area.
**Filmstrip**:
- Horizontal row of thumbnails (52x38px) with 6px gap.
- Active thumbnail has `outline: 2px solid primary` with 1px offset.
- Processed thumbnails have a green checkmark badge (14px circle, top-right corner).
- Failed thumbnails have a red X badge.
- Horizontal scroll via CSS `overflow-x: auto` with `scroll-behavior: smooth`.
- Auto-scroll to keep selected thumbnail visible (use `scrollIntoView({ block: 'nearest', inline: 'nearest' })`).
**Single-file fallback**: When only 1 file is uploaded, render the existing `ImageViewer` directly — no arrows, no filmstrip, no counter. Identical to current behavior.
### 3. Tool Processor (`use-tool-processor.ts`)
Add batch processing alongside the existing single-file processing.
**New method: `processAllFiles(entries, settings)`**:
Uses `fetch()` (not XHR) to POST to `/api/v1/tools/{toolId}/batch`:
- Build `FormData` with all files + settings JSON + `clientJobId`.
- Use `fetch()` so we can read response headers immediately — specifically the `X-Job-Id` header to correlate with SSE progress.
**Batch progress via SSE**:
- The batch endpoint needs a small change: parse `clientJobId` from multipart and use it as the job ID (instead of generating a random one server-side). This lets the client open the SSE connection _before_ the upload completes, matching the existing AI tool pattern.
- SSE events update per-file `status` in the store via `updateEntry()`. The `currentFile` field in `JobProgress` maps to the entry by filename.
- Overall progress: "Processing 3 / 5 files..." shown in a `ProgressCard`.
**ZIP extraction after batch completes**:
- The `fetch()` response body is consumed as a `Blob`.
- The blob is stored in `batchZipBlob` for the "Download All" button.
- Use `fflate` to decompress the ZIP in the browser.
- For each file in the ZIP, create a blob URL and call `updateEntry(index, { processedUrl, processedSize, status: 'completed' })`.
- Match ZIP entries to store entries **by index/order** (the batch endpoint processes files in submission order, and `archiver` appends results in completion order via p-queue — but since we use `concurrency: 1` equivalent ordering, or we can sort by original filename). To be safe, the batch endpoint will include an `X-File-Order` response header listing original filenames in order, so the client can map ZIP entries back to store entries even if `getUniqueName()` renamed duplicates.
- **Important**: per-file `status` updates to `'completed'` happen via SSE during processing (for progress UI), but `processedUrl` is only populated after the full ZIP is downloaded and extracted. The UI shows a checkmark on the thumbnail as soon as SSE reports completion, but the before/after preview for that image becomes available only after ZIP extraction.
**Single-file processing**: Keep existing `processFiles()` method unchanged for tools that only work with one file (compare, collage, etc.).
### 4. Tool Page (`tool-page.tsx`)
**Changes to left panel**:
- `FileSelectionInfo` replaced with richer file summary:
- Shows "Files (N)" with count.
- "+ Add more" link that opens file picker (calls `addFiles`).
- Currently selected filename + size.
- "Clear all" to reset.
- Process button text changes: "Process All (N files)" when N > 1, "Process" when N = 1.
**Changes to main area**:
- Replace direct `ImageViewer` usage with `MultiImageViewer`.
- `MultiImageViewer` handles all states: single image, multiple images, pre-process, post-process.
- When processed and multiple files: arrows navigate between before/after results per image.
**Download section in left panel (post-processing)**:
- "Download This" — downloads current file's processed result (uses `processedUrl` from the entry).
- "Download All (ZIP)" — creates a download link from `batchZipBlob` stored in the file store. No re-request needed.
- Per-file stats: "2.4 MB → 2.1 MB (300 KB)".
- Overall stats: "Processed: 5/5, Total saved: 1.2 MB".
### 5. Strip Metadata Enhancement
#### Backend: Existing `/inspect` endpoint
The strip-metadata tool already has a `POST /api/v1/tools/strip-metadata/inspect` endpoint that returns parsed EXIF, GPS, ICC, and XMP metadata. The frontend already calls this endpoint and displays the results in collapsible sections with a GPS privacy warning. **No new backend endpoint is needed.**
The existing response shape:
```typescript
interface MetadataResult {
filename: string;
fileSize: number;
exif?: Record<string, unknown> | null;
exifError?: string;
gps?: Record<string, unknown> | null;
icc?: Record<string, string> | null;
xmp?: Record<string, string> | null;
}
```
This already works. The only change needed is making the metadata display **multi-file aware**.
#### Frontend: `StripMetadataSettings` changes for multi-file
The existing metadata auto-fetch logic fetches metadata for `files[0]`. Change it to:
- Fetch metadata for `entries[selectedIndex].file` instead of `files[0]`.
- Cache metadata per-file to avoid re-fetching when navigating between images (use a `Map<string, MetadataResult>` keyed by file identity).
- When the user navigates to a different image via the filmstrip, the metadata panel updates to show that image's metadata.
- The strip options and "Process All" button apply to all files uniformly.
### 6. Batch Endpoint Change (`batch.ts`)
One small backend change: accept `clientJobId` from the multipart form and use it as the job ID.
```typescript
// In the multipart parsing loop, add:
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
// Then use it:
const jobId = clientJobId || randomUUID();
```
This lets the client open the SSE connection before upload completes, enabling real-time progress tracking for batch operations.
### 7. Dropzone Changes
**"+ Add more" support**: New `addFiles()` action in store. The dropzone on the tool page is replaced by the image viewer after upload, but a small "+ Add more" link in the left panel opens a file picker dialog (reuses the same `input.click()` pattern from the existing dropzone).
**No dropzone changes needed for the main drop area**: The existing dropzone handles multi-file upload correctly. After files are uploaded, it's replaced by `MultiImageViewer`.
### 8. Docker Build
Update the local Docker build to ensure the UI changes are testable:
- No new system dependencies needed (`fflate` is pure JS).
- Ensure `pnpm install` picks up new deps and frontend builds correctly.
## New Dependencies
- `fflate` — Lightweight ZIP decompression in the browser. Pure JS. Added to `apps/web`.
## Files Changed
### New files:
- `apps/web/src/components/common/multi-image-viewer.tsx` — Wrapper with filmstrip + arrows
- `apps/web/src/components/common/thumbnail-strip.tsx` — Horizontal thumbnail filmstrip
### Modified files:
- `apps/web/src/stores/file-store.ts` — Multi-file state with per-file tracking
- `apps/web/src/hooks/use-tool-processor.ts` — Add `processAllFiles` batch method
- `apps/web/src/pages/tool-page.tsx` — Use `MultiImageViewer`, update left panel
- `apps/web/src/components/tools/strip-metadata-settings.tsx` — Multi-file metadata display
- `apps/api/src/routes/batch.ts` — Accept `clientJobId` from multipart
## Out of Scope
- Per-file different settings (all files get same settings in batch mode).
- Drag-to-reorder files in the filmstrip.
- Pipeline/chaining multiple tools on batch results.
- Mobile-specific filmstrip optimizations (will use same horizontal scroll, works fine on touch).
@@ -1,94 +0,0 @@
# Resize & Rotate/Flip UX Redesign
## Problem
The before/after comparison slider is a poor fit for resize and rotate/flip tools:
- **Resize** overlays two different-sized images — doesn't communicate anything useful. Users care about "how big will it be?" not pixel-level comparison.
- **Rotate/Flip** transformations are self-evident. A slider adds nothing.
The resize settings also use technical jargon (contain, cover, fill, inside, outside) that confuses layman users.
## Design
### Resize: Tab-Based Settings with Presets Front-and-Center
Replace the current mode toggle (Pixels/Percentage) with three tabs:
#### Presets Tab (default)
- Single-column scrollable list of cards grouped by platform (Instagram, Twitter/X, Facebook, YouTube, LinkedIn) — the sidebar is 18rem wide, so single-column avoids cramped cards
- Each card shows: platform icon, preset name (e.g., "Post", "Story", "Header"), dimensions (e.g., "1080 × 1080")
- Clicking a card selects it (highlighted border), clicking again deselects
- Selected preset populates the dimensions automatically
- Presets use "Crop to fit" (cover) as the default fit mode — this is the expected behavior for social media sizing
- "Don't enlarge" checkbox available below preset grid
- Process button at bottom
#### Custom Size Tab
- Width and Height number inputs
- Aspect ratio lock toggle between them (link/unlink icon)
- Fit mode as 3 plain-language options:
- "Crop to fit" (maps to sharp `cover`)
- "Fit inside" (maps to sharp `contain`)
- "Stretch" (maps to sharp `fill`)
- Remove "inside" and "outside" fit modes — they confuse laymen
- "Don't enlarge" checkbox
- Process button at bottom
#### Scale Tab
- Percentage number input
- Quick-select buttons: 25% | 50% | 75%
- `fit` and `withoutEnlargement` are intentionally omitted — percentage scaling doesn't need them. API defaults (`contain`, `false`) are sent.
- Process button at bottom
### Resize: Side-by-Side Result Display
Replace the before/after slider with side-by-side thumbnails:
- Two image thumbnails side by side, each fitted within its half
- **Left**: "Original" label, the original image, dimensions below (e.g., "3000 × 2000"), file size (e.g., "2.4 MB")
- **Right**: "Resized" label, the processed image, new dimensions below (e.g., "1080 × 720"), file size (e.g., "340 KB")
- File size savings shown between/below (e.g., "86% smaller")
- Checkerboard background for transparency (same pattern as current viewer)
- **Dimensions**: Read client-side from blob URLs using `Image.onload` to get `naturalWidth`/`naturalHeight`. No backend changes needed.
- **File sizes**: Use existing `originalSize`/`processedSize` from the API response (already available in the file store).
- **Mobile**: On small screens, thumbnails stack vertically instead of side-by-side.
- Review panel (undo, download, continue editing) remains unchanged
### Rotate/Flip: Live CSS Preview
Replace the "process then compare" flow with live preview:
**State architecture**: `rotate-settings.tsx` emits transform values (angle, flipH, flipV) via a callback prop from `tool-page.tsx`. `tool-page.tsx` holds the preview transform state and passes it down to `ImageViewer` as optional props (`cssRotate`, `cssFlipH`, `cssFlipV`). `ImageViewer` applies these as CSS `transform: rotate(Xdeg) scaleX(Y) scaleY(Z)`.
- When a file is loaded, it shows in the image viewer as normal
- As the user adjusts controls (rotate buttons, angle slider, flip toggles), CSS transforms update the preview in real-time — no server call
- Controls stay the same: quick rotate 90 left/right, angle slider 0-360, horizontal/vertical flip toggles
- **Non-90-degree angles**: CSS preview will clip corners (the image rotates within its container). This is acceptable as a preview — the final server output will have proper canvas extension. This is a known discrepancy.
- The "Process" button label changes to "Apply" to signal finality
- "Apply" button remains disabled when no changes are made (angle=0, no flips) — same as current behavior
- Clicking "Apply" sends to the server, produces the final file
### Rotate/Flip: Result Display
After applying:
- Result shows in the standard ImageViewer (no before/after slider, no side-by-side)
- The transformation is self-evident
- Review panel appears with undo/download options
### Other Tools
The `BeforeAfterSlider` remains for all other tools (compress, filters, etc.). Only resize and rotate/flip get special treatment. In `tool-page.tsx`, branch on `toolId` using a set (e.g., `TOOLS_WITHOUT_SLIDER`) for extensibility.
## Files to Modify
### Frontend
- `apps/web/src/components/tools/resize-settings.tsx` — rewrite with tab-based UI
- `apps/web/src/components/tools/rotate-settings.tsx` — emit transform values via callback, change button label to "Apply"
- `apps/web/src/pages/tool-page.tsx` — hold preview transform state, conditionally render side-by-side for resize, ImageViewer for rotate/flip, BeforeAfterSlider for everything else
- `apps/web/src/components/common/image-viewer.tsx` — accept optional CSS transform props for live rotate/flip preview
- `apps/web/src/components/common/side-by-side-comparison.tsx` — new component for side-by-side thumbnail comparison with dimensions and file size
### No Backend Changes
- Resize and rotate API routes remain unchanged
- Image engine operations remain unchanged
- Only the frontend presentation and interaction model changes
@@ -1,313 +0,0 @@
# Files Page — Design Spec
## Overview
A persistent file manager for Stirling Image, modeled after Stirling-PDF's Files tab. Users can upload images, browse recent files, view file details with image metadata, and re-open files for further processing. Files processed through any tool automatically save the result as a new version, building a version chain (V1 → V2 → V3...) with tool attribution.
### Scope
- **In scope:** Recent files view, file upload, file details panel, version tracking, search, bulk select/delete/download, "Open File" navigation
- **Out of scope:** Google Drive integration (placeholder shown as "Coming Soon"), file sharing, folder organization
### Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Persistence | Server-persisted with SQLite metadata + disk storage | Survives restarts, enables version history |
| Version tracking | Auto-save on tool processing | Matches Stirling-PDF; makes Files page useful |
| "Open File" behavior | Navigate to home page with file pre-loaded | Most flexible — user can pick any tool |
| Auth | Required when auth is enabled; works without auth in single-user mode | Files are per-user when auth is on, shared when off |
---
## 1. Database Schema
New table `user_files` in the existing SQLite database:
```sql
CREATE TABLE user_files (
id TEXT PRIMARY KEY, -- UUID
user_id TEXT, -- FK to users.id (nullable for no-auth mode)
original_name TEXT NOT NULL, -- Original filename as uploaded
stored_name TEXT NOT NULL, -- UUID-based name on disk
mime_type TEXT NOT NULL, -- e.g. "image/jpeg"
size INTEGER NOT NULL, -- File size in bytes
width INTEGER, -- Image width in px
height INTEGER, -- Image height in px
version INTEGER NOT NULL DEFAULT 1, -- Version number
parent_id TEXT, -- FK to user_files.id (previous version)
tool_chain TEXT, -- JSON array of tool IDs applied, e.g. ["resize", "compress"]
created_at INTEGER NOT NULL, -- Unix timestamp (ms)
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (parent_id) REFERENCES user_files(id)
);
CREATE INDEX idx_user_files_user_id ON user_files(user_id);
CREATE INDEX idx_user_files_created_at ON user_files(created_at);
CREATE INDEX idx_user_files_parent_id ON user_files(parent_id);
```
### Drizzle ORM Definition
```typescript
export const userFiles = sqliteTable("user_files", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id),
originalName: text("original_name").notNull(),
storedName: text("stored_name").notNull(),
mimeType: text("mime_type").notNull(),
size: integer("size").notNull(),
width: integer("width"),
height: integer("height"),
version: integer("version").notNull().default(1),
parentId: text("parent_id"),
toolChain: text("tool_chain"), // JSON string: ["resize", "compress"]
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
});
```
---
## 2. File Storage
- **Storage directory:** `{DATA_DIR}/files/` (configurable via `FILES_STORAGE_PATH` env var, default: `/data/files/`)
- **File naming:** `{uuid}.{ext}` — avoids collisions, the original name is in the DB
- **No subdirectories per user** — a flat directory with UUID names is simpler and avoids path traversal issues
- **Cleanup:** Files deleted from the DB also have their disk file removed. No cron needed — deletion is explicit.
---
## 3. API Routes
All routes prefixed with `/api/v1/files`. Auth required when auth is enabled.
### 3.1 List Files (Recent)
```
GET /api/v1/files?search=&limit=50&offset=0
```
Returns the latest version of each file group (grouped by root parent), sorted by `created_at` DESC.
**Response:**
```json
{
"files": [
{
"id": "uuid",
"originalName": "beach_sunset.jpg",
"mimeType": "image/jpeg",
"size": 2400000,
"width": 1920,
"height": 1080,
"version": 3,
"toolChain": ["resize", "compress"],
"createdAt": "2026-03-24T21:15:00Z"
}
],
"total": 42
}
```
### 3.2 Upload Files
```
POST /api/v1/files/upload
Content-Type: multipart/form-data
Body: file (one or more image files)
```
Validates each file (magic bytes, supported format), extracts dimensions via Sharp, stores to disk, creates DB record with version=1.
**Response:**
```json
{
"files": [
{ "id": "uuid", "originalName": "photo.jpg", "size": 2400000, "version": 1 }
]
}
```
### 3.3 Get File Details
```
GET /api/v1/files/:id
```
Returns full metadata for a single file, including all versions in the chain.
**Response:**
```json
{
"id": "uuid",
"originalName": "beach_sunset.jpg",
"mimeType": "image/jpeg",
"size": 2400000,
"width": 1920,
"height": 1080,
"version": 3,
"toolChain": ["resize", "compress"],
"createdAt": "2026-03-24T21:15:00Z",
"versions": [
{ "id": "uuid-v1", "version": 1, "size": 5000000, "toolChain": [], "createdAt": "..." },
{ "id": "uuid-v2", "version": 2, "size": 3000000, "toolChain": ["resize"], "createdAt": "..." },
{ "id": "uuid-v3", "version": 3, "size": 2400000, "toolChain": ["resize", "compress"], "createdAt": "..." }
]
}
```
### 3.4 Download File
```
GET /api/v1/files/:id/download
```
Streams the file from disk with `Content-Disposition: attachment`.
### 3.5 Get File Thumbnail
```
GET /api/v1/files/:id/thumbnail
```
Returns a 300px-wide JPEG thumbnail (generated on-the-fly via Sharp, can be cached later).
### 3.6 Delete Files
```
DELETE /api/v1/files
Body: { "ids": ["uuid1", "uuid2"] }
```
Deletes specified files from DB and disk. When deleting a file that has child versions, deletes the entire chain.
### 3.7 Save Tool Result (internal — called by tool-factory)
```
POST /api/v1/files/save-result
Body: { parentId?: string, toolId: string, buffer: <binary>, filename: string }
```
This is an internal route called by the tool processing pipeline. It:
1. Looks up the parent file (if parentId provided)
2. Computes the new version number (parent.version + 1)
3. Builds the tool chain (parent.toolChain + [toolId])
4. Stores the file to disk
5. Creates the DB record
6. Returns the new file record
---
## 4. Tool Processing Integration
The tool-factory needs a small addition: after successfully processing a file, if the input file came from the Files store (identified by a `fileId` parameter in the request), save the result as a new version.
**Flow:**
1. User clicks "Open File" on Files page → navigates to home with file loaded
2. The file-store entry carries a `fileId` (the user_files.id from the server)
3. User picks a tool, adjusts settings, clicks Process
4. Tool processes the file as normal
5. After success, the response includes the new `fileId` of the saved version
6. The file-store entry updates its `fileId` to the new version
**Changes to tool-factory.ts:**
- Accept optional `fileId` field in multipart body
- After processing, call the save-result logic internally (not an HTTP call — direct function call)
- Return `fileId` in the response alongside existing `jobId` and `downloadUrl`
---
## 5. Frontend
### 5.1 New Files Page (`apps/web/src/pages/files-page.tsx`)
Three-panel layout inside `AppLayout`:
- **Left panel (180px):** "My Files" heading, nav items (Recent, Upload Files, Google Drive disabled)
- **Center panel (flex):** Search bar, toolbar (select all, delete, download), scrollable file list
- **Right panel (240px):** Thumbnail preview, File Details card, "Open File" button. Hidden when no file selected.
### 5.2 Components
```
apps/web/src/components/files/
├── files-nav.tsx # Left nav (Recent, Upload, Drive placeholder)
├── file-list.tsx # Center: search + toolbar + file rows
├── file-list-item.tsx # Single file row (checkbox, name, size, date, version, tools)
├── file-details.tsx # Right panel (thumbnail, metadata, Open File)
├── file-upload-area.tsx # Dropzone for the Upload Files tab
```
### 5.3 Files Store (`apps/web/src/stores/files-page-store.ts`)
Separate Zustand store for the Files page (distinct from the existing `file-store.ts` which manages tool processing state):
```typescript
interface FilesPageState {
// Data
files: UserFile[];
selectedFileId: string | null;
selectedFileIds: Set<string>; // for bulk operations
total: number;
// UI state
activeTab: "recent" | "upload";
searchQuery: string;
loading: boolean;
// Actions
fetchFiles: () => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
deleteFiles: (ids: string[]) => Promise<void>;
selectFile: (id: string) => void;
toggleFileSelection: (id: string) => void;
selectAll: () => void;
deselectAll: () => void;
setSearchQuery: (query: string) => void;
setActiveTab: (tab: "recent" | "upload") => void;
}
```
### 5.4 "Open File" Flow
When user clicks "Open File":
1. Fetch the file blob from `GET /api/v1/files/:id/download`
2. Create a `File` object from the blob
3. Add it to the existing `file-store` with the `fileId` attached
4. Navigate to `/` (home page)
5. Home page sees the file in the store and shows the tool selection + preview
### 5.5 Routing
Add to `App.tsx`:
```typescript
<Route path="/files" element={<FilesPage />} />
```
Re-add Files to sidebar and mobile nav (reverting the earlier removal).
### 5.6 Mobile Layout
On mobile, the three-panel layout collapses:
- Left nav becomes tabs at the top (Recent | Upload)
- File list takes full width
- File details shows as a bottom sheet when a file is tapped
- "Open File" button is prominent in the bottom sheet
---
## 6. Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FILES_STORAGE_PATH` | `/data/files` | Directory for persistent file storage |
| `MAX_STORED_FILES` | `500` | Maximum files per user (0 = unlimited) |
---
## 7. Error Handling
- **Upload validation:** Same as existing file validation (magic bytes, format, size limit)
- **Storage full:** Return 507 if disk write fails
- **File not found:** Return 404 if file ID doesn't exist or belongs to another user
- **Auth:** Return 401 if auth is enabled and user is not authenticated
@@ -1,200 +0,0 @@
# Settings Phase 1 — Admin Control Panel
Inspired by Stirling-PDF's settings system, this phase adds five features to the existing settings dialog to make Stirling-Image feel like a serious self-hosted product.
## Decisions
- Extend existing settings dialog (no separate admin panel route)
- Teams are organizational labels only — no per-team permissions
- Tool disabling and feature flags require server restart
- Temp file management is minimal (max age + startup cleanup)
- Custom branding is app name + logo (no favicon, no custom theme colors)
## 1. Teams Management
**New "Teams" tab in settings dialog.**
Simple CRUD for teams. Users are assigned to teams from the existing People section.
### Database
New `teams` table:
| Column | Type | Notes |
|---|---|---|
| id | text | Primary key (UUID) |
| name | text | Unique, not null |
| createdAt | integer (timestamp) | Auto-set |
Migration steps (single Drizzle migration file):
1. Create `teams` table
2. Insert a "Default" team with a known UUID
3. Collect all distinct `users.team` string values; for each non-"Default" value, insert a new team row
4. Update `users.team` from the string value to the corresponding team UUID
5. Keep `users.team` as a plain `text` column (no DB-level FK — SQLite doesn't support adding FK constraints via ALTER TABLE). Enforce the relationship at the application level.
Note: The existing `0003_add_team_to_users.sql` migration added the `team` column as free text. This new migration extends that by creating the `teams` table and converting values.
### Team Name Validation
- 1-50 characters
- Trimmed (no leading/trailing whitespace)
- Unique (case-insensitive)
- The "Default" team cannot be deleted (it's the fallback for new users)
### API Routes
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/teams | auth | List all teams with member count |
| POST | /api/v1/teams | admin | Create team (body: `{ name }`) |
| PUT | /api/v1/teams/:id | admin | Rename team (body: `{ name }`) |
| DELETE | /api/v1/teams/:id | admin | Delete team (fails if team has members or is "Default") |
### UI
- Table with columns: Team Name, Total Members, actions (three-dot menu: Rename, Delete)
- "+ Create New Team" button
- Delete blocked with message if team has assigned members or is the Default team
- People section's team assignment dropdown pulls from teams table
## 2. Tool Disabling
**New "Tools" tab in settings dialog.**
Admin can globally disable specific tools. Disabled tools are hidden from all users.
### Settings Key
`disabledTools` — JSON array of tool IDs. Default: `"[]"`
### Behavior
- On save, shows "Restart required for changes to take effect" banner
- On API startup, server reads `disabledTools` and skips registering those tool routes (server-side enforcement)
- Frontend also filters disabled tools from the tool panel for immediate visual feedback after save, but API routes remain active until restart
- Pipelines containing disabled tools: step renders but shows "tool unavailable" badge
### UI
- Searchable list of all registered tools
- Each tool has a toggle (on = enabled, off = disabled)
- Search/filter bar at top
- Tools grouped by category for easier scanning
- "Restart required" banner appears after any change is saved
## 3. Feature Flags
**Added to existing "System Settings" section.**
Single toggle controlling visibility of experimental tools.
### Settings Key
`enableExperimentalTools``"true"` or `"false"`. Default: `"false"`
### Tool Registry Change
Reuse the existing `alpha?: boolean` field on the `Tool` type in `packages/shared/src/types.ts`. Rename it to `experimental?: boolean` for clarity (update all references). Tools marked experimental are hidden unless the flag is enabled.
### Behavior
- Works independently of tool disabling (a tool can be both experimental AND manually disabled)
- On save, shows "Restart required" banner
- When flag is off, experimental tools are excluded from: tool panel, fullscreen grid, pipeline step picker
### UI
- Single toggle row in System Settings: "Enable Experimental Tools" with description "Show tools that are still in development. These may be unstable."
## 4. Temp File Management
**Added to existing "System Settings" section under "File Management" sub-heading.**
Admin controls how long processed files persist and whether to clean on startup.
### Settings Keys
| Key | Default | Description |
|---|---|---|
| tempFileMaxAgeHours | "24" | Hours before temp files are eligible for cleanup |
| startupCleanup | "true" | Whether to run cleanup on server boot |
### Behavior
- The cleanup function re-reads `tempFileMaxAgeHours` from the settings DB on every cycle (not cached at startup). If the setting is not set, falls back to the `FILE_MAX_AGE_HOURS` env var (default 24). DB setting takes precedence over env var.
- On startup, if `startupCleanup` is true, cleanup runs asynchronously (does not block server startup — matches current behavior where `startCleanupCron()` is non-blocking)
- Changes take effect on next cleanup cycle (no restart required)
### UI
- Number input: "Max File Age (hours)" with description "How long processed files are kept before automatic cleanup"
- Toggle: "Startup Cleanup" with description "Clean up old temporary files when the server starts"
## 5. Custom Branding — Logo Upload
**Added to existing "System Settings" section, below App Name.**
Admin uploads a custom logo displayed in the sidebar/navbar.
### API Routes
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/settings/logo | admin | Upload logo (PNG/SVG/JPEG, max 500KB) |
| GET | /api/v1/settings/logo | public | Serve custom logo (404 if none) |
| DELETE | /api/v1/settings/logo | admin | Remove custom logo |
### Settings Key
`customLogo``"true"` or `"false"`. Default: `"false"`
### Behavior
- All uploaded logos are converted to PNG and stored to `data/branding/logo.png` (SVGs are rasterized, JPEGs are re-encoded)
- Server resizes to max 128x128 via Sharp on upload
- `GET /api/v1/settings/logo` serves with `Content-Type: image/png`. This route must be added to `PUBLIC_PATHS` in `auth.ts` so the logo is accessible on the login page.
- Sidebar/navbar checks `customLogo` on mount — if true, loads from logo endpoint; otherwise uses built-in SVG
- No restart required — logo change is immediate
### UI
- Logo upload area with drag-and-drop, preview thumbnail
- "Remove" button to revert to default logo
- Accepts PNG, SVG, JPEG. Max 500KB.
- Shows current logo preview if one is set
## 6. Settings Dialog Navigation
### Current Sections
General, System Settings, Security, People, API Keys, About
### New Sections
General, System Settings, Security, People, **Teams**, API Keys, **Tools**, About
### Section Contents
| Section | What's new |
|---|---|
| System Settings | Feature flags toggle, temp file management controls, logo upload area (all added to existing section) |
| Teams | Entirely new — team CRUD table |
| Tools | Entirely new — tool enable/disable list |
### Frontend Type Changes
- Add `"teams" | "tools"` to the `Section` type union in `settings-dialog.tsx`
- Add corresponding entries to `NAV_ITEMS` array
### i18n
Add translation keys to `packages/shared/src/i18n/en.ts` under `settings` for the new sections (teams, tools) and their UI strings.
## Summary
| Feature | UI Location | New DB/API | Restart Required |
|---|---|---|---|
| Teams CRUD | New "Teams" tab | `teams` table, 4 CRUD routes | No |
| Tool disabling | New "Tools" tab | `disabledTools` setting key | Yes |
| Feature flags | System Settings | `enableExperimentalTools` setting key | Yes |
| Temp file management | System Settings | 2 setting keys | No |
| Logo upload | System Settings | 3 routes, `customLogo` key, `data/branding/` | No |