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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,808 +0,0 @@
# Interactive Crop Tool Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the numbers-only crop UI with a visual, interactive Photoshop-style crop tool with draggable rectangle overlay, aspect ratio presets, bidirectional pixel inputs, and rule-of-thirds grid.
**Architecture:** `react-image-crop` renders the visual crop overlay on the image in a new `CropCanvas` component. `CropSettings` is redesigned with aspect ratio presets and pixel inputs that sync bidirectionally with the visual overlay. Crop state is lifted to `tool-page.tsx` and shared between both components via props. Backend is unchanged — the same `{ left, top, width, height }` pixel values are sent to Sharp's `.extract()`.
**Tech Stack:** React, TypeScript, react-image-crop, Tailwind CSS, Sharp (backend, unchanged)
**Spec:** `docs/superpowers/specs/2026-03-23-interactive-crop-tool-design.md`
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `apps/web/package.json` | Modify | Add `react-image-crop` dependency |
| `apps/web/src/components/tools/crop-canvas.tsx` | Create | Visual crop overlay component with react-image-crop, rule-of-thirds grid, dimension badge, keyboard controls |
| `apps/web/src/components/tools/crop-settings.tsx` | Rewrite | Aspect ratio presets, bidirectional pixel inputs, grid toggle, process/download buttons |
| `apps/web/src/pages/tool-page.tsx` | Modify | Lift crop state, add `INTERACTIVE_CROP_TOOLS` rendering path, pass crop props |
---
### Task 1: Install react-image-crop
**Files:**
- Modify: `apps/web/package.json`
- [ ] **Step 1: Install the dependency**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm add react-image-crop --filter @stirling-image/web
```
- [ ] **Step 2: Verify installation**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm ls react-image-crop --filter @stirling-image/web
```
Expected: `react-image-crop` appears in the dependency list.
- [ ] **Step 3: Commit**
```bash
git add apps/web/package.json pnpm-lock.yaml && git commit -m "feat(crop): add react-image-crop dependency"
```
---
### Task 2: Create CropCanvas component
**Files:**
- Create: `apps/web/src/components/tools/crop-canvas.tsx`
This component renders the image with `ReactCrop` overlay, rule-of-thirds grid, dimension badge, and keyboard controls.
- [ ] **Step 1: Create the CropCanvas component**
Create `apps/web/src/components/tools/crop-canvas.tsx` with the following code:
```tsx
import { useRef, useCallback, useEffect } from "react";
import ReactCrop, { type Crop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
export interface CropCanvasProps {
imageSrc: string;
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
onCropChange: (crop: Crop) => void;
onImageLoad: (dims: { width: number; height: number }) => void;
}
export function CropCanvas({
imageSrc,
crop,
aspect,
showGrid,
imgDimensions,
onCropChange,
onImageLoad,
}: CropCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const handleImageLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
onImageLoad({ width: img.naturalWidth, height: img.naturalHeight });
},
[onImageLoad],
);
// Keyboard nudging
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleKeyDown = (e: KeyboardEvent) => {
const step = e.shiftKey ? 10 : 1;
const { naturalWidth, naturalHeight } = imgRef.current ?? {
naturalWidth: 0,
naturalHeight: 0,
};
if (!naturalWidth || !naturalHeight) return;
// Convert step from pixels to percentage
const stepX = (step / naturalWidth) * 100;
const stepY = (step / naturalHeight) * 100;
let dx = 0;
let dy = 0;
if (e.key === "ArrowLeft") dx = -stepX;
else if (e.key === "ArrowRight") dx = stepX;
else if (e.key === "ArrowUp") dy = -stepY;
else if (e.key === "ArrowDown") dy = stepY;
else if (e.key === "Escape") {
// Reset to full image
onCropChange({
unit: "%",
x: 0,
y: 0,
width: 100,
height: 100,
});
e.preventDefault();
return;
} else if (e.key === "Enter") {
// Submit the crop form (find and submit closest form)
const form = document.querySelector<HTMLFormElement>(
'form[data-crop-form]',
);
if (form) form.requestSubmit();
e.preventDefault();
return;
} else return;
e.preventDefault();
onCropChange({
...crop,
x: Math.max(0, Math.min(100 - crop.width, crop.x + dx)),
y: Math.max(0, Math.min(100 - crop.height, crop.y + dy)),
});
};
el.addEventListener("keydown", handleKeyDown);
return () => el.removeEventListener("keydown", handleKeyDown);
}, [crop, onCropChange]);
// Auto-focus the container on mount
useEffect(() => {
containerRef.current?.focus();
}, []);
// Calculate pixel dimensions for the badge
const pixelWidth =
imgDimensions ? Math.round((crop.width / 100) * imgDimensions.width) : 0;
const pixelHeight =
imgDimensions ? Math.round((crop.height / 100) * imgDimensions.height) : 0;
return (
<div
ref={containerRef}
className="flex flex-col w-full h-full max-w-4xl mx-auto outline-none"
tabIndex={0}
>
{/* Crop area */}
<div className="flex-1 flex items-center justify-center overflow-hidden bg-muted/20 p-4">
<ReactCrop
crop={crop}
onChange={onCropChange}
aspect={aspect}
className="max-h-full"
ruleOfThirds={showGrid}
>
<img
ref={imgRef}
src={imageSrc}
alt="Crop preview"
onLoad={handleImageLoad}
className="max-w-full max-h-[calc(100vh-12rem)] select-none"
draggable={false}
/>
</ReactCrop>
</div>
{/* Info bar */}
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
<span>
Crop region: {pixelWidth} x {pixelHeight}
</span>
{imgDimensions && (
<span>
Original: {imgDimensions.width} x {imgDimensions.height}
</span>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web typecheck
```
Expected: No type errors.
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/crop-canvas.tsx && git commit -m "feat(crop): add CropCanvas component with visual overlay, grid, and keyboard controls"
```
---
### Task 3: Redesign CropSettings component
**Files:**
- Rewrite: `apps/web/src/components/tools/crop-settings.tsx`
This redesigns the settings panel with aspect ratio presets (Free, 1:1, 4:3, 3:2, 16:9, 2:3, 4:5, 9:16), bidirectional pixel inputs (X, Y, Width, Height), a rule-of-thirds grid toggle, and the process/download buttons.
- [ ] **Step 1: Rewrite CropSettings**
Rewrite `apps/web/src/components/tools/crop-settings.tsx` with the following code:
```tsx
import { useCallback } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, ArrowLeftRight, Grid3x3 } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
import type { Crop } from "react-image-crop";
const ASPECT_PRESETS = [
{ label: "Free", value: undefined as number | undefined },
{ label: "1:1", value: 1 },
{ label: "4:3", value: 4 / 3 },
{ label: "3:2", value: 3 / 2 },
{ label: "16:9", value: 16 / 9 },
{ label: "2:3", value: 2 / 3 },
{ label: "4:5", value: 4 / 5 },
{ label: "9:16", value: 9 / 16 },
];
export interface CropSettingsProps {
cropState: {
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
};
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
}
export function CropSettings({
cropState,
onCropChange,
onAspectChange,
onGridToggle,
}: CropSettingsProps) {
const { files } = useFileStore();
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
progress,
} = useToolProcessor("crop");
const { crop, aspect, showGrid, imgDimensions } = cropState;
// Convert percentage crop to pixel values
const toPixels = useCallback(
(c: Crop) => {
if (!imgDimensions) return { left: 0, top: 0, width: 0, height: 0 };
return {
left: Math.round((c.x / 100) * imgDimensions.width),
top: Math.round((c.y / 100) * imgDimensions.height),
width: Math.round((c.width / 100) * imgDimensions.width),
height: Math.round((c.height / 100) * imgDimensions.height),
};
},
[imgDimensions],
);
// Convert pixel value change back to percentage crop
const handlePixelChange = useCallback(
(field: "left" | "top" | "width" | "height", value: number) => {
if (!imgDimensions) return;
const newCrop = { ...crop };
if (field === "left") {
newCrop.x = Math.max(
0,
Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width),
);
} else if (field === "top") {
newCrop.y = Math.max(
0,
Math.min((value / imgDimensions.height) * 100, 100 - newCrop.height),
);
} else if (field === "width") {
const pct = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.x));
newCrop.width = pct;
if (aspect) {
newCrop.height = Math.min(
(pct / 100) * imgDimensions.width * (1 / aspect) * (100 / imgDimensions.height),
100 - newCrop.y,
);
newCrop.y = Math.max(0, Math.min(newCrop.y, 100 - newCrop.height));
}
} else if (field === "height") {
const pct = Math.max(0, Math.min((value / imgDimensions.height) * 100, 100 - newCrop.y));
newCrop.height = pct;
if (aspect) {
newCrop.width = Math.min(
(pct / 100) * imgDimensions.height * aspect * (100 / imgDimensions.width),
100 - newCrop.x,
);
newCrop.x = Math.max(0, Math.min(newCrop.x, 100 - newCrop.width));
}
}
onCropChange(newCrop);
},
[crop, imgDimensions, aspect, onCropChange],
);
const handleAspectSelect = useCallback(
(value: number | undefined) => {
onAspectChange(value);
// When selecting an aspect ratio, adjust the current crop to match
if (value && imgDimensions) {
const imgAspect = imgDimensions.width / imgDimensions.height;
let newWidth: number;
let newHeight: number;
if (value > imgAspect) {
// Wider than image — constrain by width
newWidth = 100;
newHeight = (imgDimensions.width / value / imgDimensions.height) * 100;
} else {
// Taller than image — constrain by height
newHeight = 100;
newWidth = (imgDimensions.height * value / imgDimensions.width) * 100;
}
onCropChange({
unit: "%",
x: (100 - newWidth) / 2,
y: (100 - newHeight) / 2,
width: newWidth,
height: newHeight,
});
}
},
[onAspectChange, onCropChange, imgDimensions],
);
const handleSwapAspect = useCallback(() => {
if (aspect) {
handleAspectSelect(1 / aspect);
}
}, [aspect, handleAspectSelect]);
const pixels = toPixels(crop);
const handleProcess = () => {
const settings = {
left: pixels.left,
top: pixels.top,
width: Math.max(1, pixels.width),
height: Math.max(1, pixels.height),
};
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const hasSize = pixels.width > 0 && pixels.height > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && hasSize && !processing) handleProcess();
};
// Find which preset label matches the current aspect
const activePresetLabel = ASPECT_PRESETS.find((p) => {
if (p.value === undefined && aspect === undefined) return true;
if (p.value !== undefined && aspect !== undefined) {
return Math.abs(p.value - aspect) < 0.01;
}
return false;
})?.label;
return (
<form onSubmit={handleSubmit} className="space-y-4" data-crop-form>
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
{aspect !== undefined && (
<button
type="button"
onClick={handleSwapAspect}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Swap width/height"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap gap-1">
{ASPECT_PRESETS.map(({ label, value }) => (
<button
type="button"
key={label}
onClick={() => handleAspectSelect(value)}
className={`px-2 py-1.5 rounded text-xs transition-colors ${
activePresetLabel === label
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Position & Size */}
<div>
<label className="text-xs text-muted-foreground">Position & Size</label>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label className="text-[10px] text-muted-foreground">
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
type="number"
value={pixels.left}
onChange={(e) =>
handlePixelChange("left", Number(e.target.value))
}
min={0}
max={imgDimensions ? imgDimensions.width - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
type="number"
value={pixels.top}
onChange={(e) =>
handlePixelChange("top", Number(e.target.value))
}
min={0}
max={imgDimensions ? imgDimensions.height - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
type="number"
value={pixels.width}
onChange={(e) =>
handlePixelChange("width", Number(e.target.value))
}
min={1}
max={imgDimensions ? imgDimensions.width : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
type="number"
value={pixels.height}
onChange={(e) =>
handlePixelChange("height", Number(e.target.value))
}
min={1}
max={imgDimensions ? imgDimensions.height : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
</div>
</div>
{/* Grid overlay toggle */}
<button
type="button"
onClick={() => onGridToggle(!showGrid)}
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs transition-colors ${
showGrid
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
<Grid3x3 className="h-3.5 w-3.5" />
Rule of Thirds
</button>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Cropping"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
disabled={!hasFile || !hasSize || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web typecheck
```
Expected: No type errors. (May have errors from `tool-page.tsx` not yet passing props — that's expected and fixed in Task 4.)
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/crop-settings.tsx && git commit -m "feat(crop): redesign CropSettings with aspect presets, pixel inputs, and grid toggle"
```
---
### Task 4: Update tool-page.tsx to wire everything together
**Files:**
- Modify: `apps/web/src/pages/tool-page.tsx`
This task lifts crop state to `tool-page.tsx`, adds the `INTERACTIVE_CROP_TOOLS` rendering path, and passes props to both `CropCanvas` and `CropSettings`.
- [ ] **Step 1: Add imports**
At the top of `apps/web/src/pages/tool-page.tsx`, add after the existing `CropSettings` import:
```tsx
import { CropCanvas } from "@/components/tools/crop-canvas";
```
Also add `type Crop` import near the top:
```tsx
import type { Crop } from "react-image-crop";
```
- [ ] **Step 2: Add INTERACTIVE_CROP_TOOLS set**
After the `LIVE_PREVIEW_TOOLS` line (line 68 currently), add:
```tsx
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
```
- [ ] **Step 3: Add crop state to ToolPage component**
Inside the `ToolPage` function, after the `previewTransform` state declaration (currently line 185), add:
```tsx
const [cropCrop, setCropCrop] = useState<Crop>({
unit: "%",
x: 0,
y: 0,
width: 100,
height: 100,
});
const [cropAspect, setCropAspect] = useState<number | undefined>(undefined);
const [cropShowGrid, setCropShowGrid] = useState(true);
const [cropImgDimensions, setCropImgDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const cropState = useMemo(
() => ({
crop: cropCrop,
aspect: cropAspect,
showGrid: cropShowGrid,
imgDimensions: cropImgDimensions,
}),
[cropCrop, cropAspect, cropShowGrid, cropImgDimensions],
);
// Reset crop state when the image changes
useEffect(() => {
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
setCropImgDimensions(null);
}, [originalBlobUrl]);
```
- [ ] **Step 4: Update ToolSettingsPanel to pass crop props**
Modify the `ToolSettingsPanel` component signature and the crop routing. The component needs access to crop state, so we'll pass it through.
Change the `ToolSettingsPanel` function signature from:
```tsx
function ToolSettingsPanel({
toolId,
onPreviewTransform,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
}) {
```
to:
```tsx
function ToolSettingsPanel({
toolId,
onPreviewTransform,
cropProps,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
cropProps?: {
cropState: {
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
};
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
};
}) {
```
Then change the crop routing line from:
```tsx
if (toolId === "crop") return <CropSettings />;
```
to:
```tsx
if (toolId === "crop" && cropProps) return <CropSettings {...cropProps} />;
```
- [ ] **Step 5: Pass cropProps to ToolSettingsPanel in both layouts**
In both the desktop and mobile layouts, update the `<ToolSettingsPanel>` call to include `cropProps`.
Find every `<ToolSettingsPanel` usage (there should be 2 — one in mobile layout, one in desktop layout) and add the `cropProps` prop:
```tsx
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
cropProps={INTERACTIVE_CROP_TOOLS.has(tool.id) ? {
cropState,
onCropChange: setCropCrop,
onAspectChange: setCropAspect,
onGridToggle: setCropShowGrid,
} : undefined}
/>
```
- [ ] **Step 6: Add CropCanvas rendering path in desktop layout**
In the desktop layout's main area (the `{/* Main area */}` section), add the `CropCanvas` branch. This must come **before** the `SIDE_BY_SIDE_TOOLS` check. Find the line:
```tsx
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
```
And add this branch **before** it (after the `files.length > 1` MultiImageViewer check):
```tsx
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
```
- [ ] **Step 7: Add CropCanvas rendering path in mobile layout**
Do the exact same insertion in the mobile layout's main area — add the `CropCanvas` branch before the `SIDE_BY_SIDE_TOOLS` check, after the `files.length > 1` check:
```tsx
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
```
- [ ] **Step 8: Verify everything compiles**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web typecheck
```
Expected: No type errors.
- [ ] **Step 9: Build the frontend**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build
```
Expected: Build succeeds with no errors.
- [ ] **Step 10: Commit**
```bash
git add apps/web/src/pages/tool-page.tsx && git commit -m "feat(crop): wire CropCanvas and CropSettings into tool-page with bidirectional state"
```
---
### Task 5: Docker rebuild and verification
**Files:** None (Docker rebuild only)
- [ ] **Step 1: Rebuild the Docker container**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && docker-compose -f docker/docker-compose.yml up --build -d
```
Expected: Container builds and starts on port 1349.
- [ ] **Step 2: Manual verification checklist**
Open `http://localhost:1349` in a browser and test:
1. Navigate to the Crop tool
2. Drop an image — verify the crop canvas appears with the rectangle covering the full image
3. Drag a corner handle — verify the rectangle resizes and the pixel inputs update
4. Type a width value in the pixel input — verify the rectangle updates
5. Select "1:1" aspect ratio — verify the rectangle snaps to square
6. Select "16:9" — verify landscape ratio
7. Click the swap button — verify it flips to 9:16
8. Select "Free" — verify unconstrained dragging works
9. Toggle Rule of Thirds — verify grid lines appear/disappear
10. Use arrow keys to nudge — verify the rectangle moves
11. Use Shift+Arrow — verify 10px nudge
12. Press Escape — verify rectangle resets to full image
13. Click "Crop" — verify processing works and side-by-side comparison appears
14. Click Download — verify the cropped image downloads
15. Click Undo (in review panel) — verify it returns to the crop canvas
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,869 +0,0 @@
# Resize & Rotate/Flip UX Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the before/after slider with intuitive side-by-side comparison for resize and live CSS preview for rotate/flip.
**Architecture:** Frontend-only changes. Resize gets a tab-based settings panel (Presets/Custom Size/Scale) and a new SideBySideComparison result view. Rotate/flip gets live CSS transform preview on the ImageViewer with an "Apply" button. The tool-page.tsx conditionally renders the appropriate result view per tool. No backend changes.
**Tech Stack:** React, TypeScript, Tailwind CSS, Zustand, lucide-react
**Spec:** `docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md`
---
### Task 1: Create SideBySideComparison Component
**Files:**
- Create: `apps/web/src/components/common/side-by-side-comparison.tsx`
- [ ] **Step 1: Create the component file**
```tsx
import { useState } from "react";
interface SideBySideComparisonProps {
beforeSrc: string;
afterSrc: string;
beforeSize?: number;
afterSize?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export function SideBySideComparison({
beforeSrc,
afterSrc,
beforeSize,
afterSize,
}: SideBySideComparisonProps) {
const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null);
const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null);
const savingsPercent =
beforeSize && afterSize && beforeSize > 0
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
const checkerboard = {
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
};
return (
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
{/* Side-by-side images */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
{/* Original */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Original
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<img
src={beforeSrc}
alt="Original"
className="max-w-full max-h-full object-contain"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setBeforeDims({ w: img.naturalWidth, h: img.naturalHeight });
}}
/>
</div>
<div className="text-xs text-muted-foreground text-center space-y-0.5">
{beforeDims && (
<p>
{beforeDims.w} × {beforeDims.h}
</p>
)}
{beforeSize != null && <p>{formatSize(beforeSize)}</p>}
</div>
</div>
{/* Resized */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Resized
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<img
src={afterSrc}
alt="Resized"
className="max-w-full max-h-full object-contain"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setAfterDims({ w: img.naturalWidth, h: img.naturalHeight });
}}
/>
</div>
<div className="text-xs text-muted-foreground text-center space-y-0.5">
{afterDims && (
<p>
{afterDims.w} × {afterDims.h}
</p>
)}
{afterSize != null && <p>{formatSize(afterSize)}</p>}
</div>
</div>
</div>
{/* Size savings */}
{savingsPercent !== null && (
<p
className={`text-sm font-medium ${Number(savingsPercent) > 0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`}
>
{Number(savingsPercent) > 0
? `${savingsPercent}% smaller`
: `${Math.abs(Number(savingsPercent))}% larger`}
</p>
)}
</div>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/common/side-by-side-comparison.tsx
git commit -m "feat: add SideBySideComparison component for resize results"
```
---
### Task 2: Rewrite ResizeSettings with Tab-Based UI
**Files:**
- Modify: `apps/web/src/components/tools/resize-settings.tsx`
- [ ] **Step 1: Rewrite resize-settings.tsx with three tabs**
Replace the entire file content with:
```tsx
import { useState } from "react";
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Link, Unlink } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
type ResizeTab = "presets" | "custom" | "scale";
type FitMode = "cover" | "contain" | "fill";
const FIT_LABELS: Record<FitMode, string> = {
cover: "Crop to fit",
contain: "Fit inside",
fill: "Stretch",
};
// Group presets by platform
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
export function ResizeSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, progress } =
useToolProcessor("resize");
const [tab, setTab] = useState<ResizeTab>("presets");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>("");
const [height, setHeight] = useState<string>("");
const [percentage, setPercentage] = useState<string>("50");
const [fit, setFit] = useState<FitMode>("cover");
const [lockAspect, setLockAspect] = useState(true);
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
const key = `${preset.platform}-${preset.name}`;
if (selectedPreset === key) {
setSelectedPreset(null);
setWidth("");
setHeight("");
} else {
setSelectedPreset(key);
setWidth(String(preset.width));
setHeight(String(preset.height));
}
};
const handleProcess = () => {
const settings: Record<string, unknown> = {};
if (tab === "scale") {
settings.percentage = Number(percentage);
} else {
if (width) settings.width = Number(width);
if (height) settings.height = Number(height);
settings.fit = tab === "presets" ? "cover" : fit;
settings.withoutEnlargement = withoutEnlargement;
}
processFiles(files, settings);
};
const hasFile = files.length > 0;
const canProcess =
hasFile &&
!processing &&
(tab === "scale"
? Number(percentage) > 0
: Boolean(width) || Boolean(height));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
const tabClass = (t: ResizeTab) =>
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Tab selector */}
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size
</button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale
</button>
</div>
</div>
{/* Presets tab */}
{tab === "presets" && (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
<div className="space-y-1">
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
const key = `${preset.platform}-${preset.name}`;
const isSelected = selectedPreset === key;
return (
<button
key={key}
type="button"
onClick={() => handlePreset(preset)}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
}`}
>
<span>{preset.name}</span>
<span className="text-xs tabular-nums">
{preset.width} × {preset.height}
</span>
</button>
);
})}
</div>
</div>
))}
{/* Don't enlarge */}
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={withoutEnlargement}
onChange={(e) => setWithoutEnlargement(e.target.checked)}
className="rounded"
/>
Don&apos;t enlarge
</label>
</div>
)}
{/* Custom Size tab */}
{tab === "custom" && (
<div className="space-y-3">
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<input
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<button
type="button"
onClick={() => setLockAspect(!lockAspect)}
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
>
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
</button>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
{/* Fit mode */}
<div>
<label className="text-xs text-muted-foreground">Fit Mode</label>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFit(f)}
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
{FIT_LABELS[f]}
</button>
))}
</div>
</div>
{/* Don't enlarge */}
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={withoutEnlargement}
onChange={(e) => setWithoutEnlargement(e.target.checked)}
className="rounded"
/>
Don&apos;t enlarge
</label>
</div>
)}
{/* Scale tab */}
{tab === "scale" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Scale (%)</label>
<input
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex gap-1">
{[25, 50, 75].map((pct) => (
<button
key={pct}
type="button"
onClick={() => setPercentage(String(pct))}
className={`flex-1 text-xs py-1.5 rounded ${
percentage === String(pct)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{pct}%
</button>
))}
</div>
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process button */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Resizing"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Resize
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/resize-settings.tsx
git commit -m "feat: rewrite resize settings with tab-based UI (presets, custom, scale)"
```
---
### Task 3: Add CSS Transform Props to ImageViewer
**Files:**
- Modify: `apps/web/src/components/common/image-viewer.tsx`
- [ ] **Step 1: Add optional CSS transform props to the ImageViewer interface and apply them**
Add `cssRotate`, `cssFlipH`, `cssFlipV` optional props to the `ImageViewerProps` interface. In the `imageStyle` computation, compose CSS transforms when these props are provided.
Changes to make:
1. Update the interface (line 5-8):
```tsx
interface ImageViewerProps {
src: string;
filename: string;
fileSize: number;
cssRotate?: number;
cssFlipH?: boolean;
cssFlipV?: boolean;
}
```
2. Update the component destructuring (line 14):
```tsx
export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) {
```
3. Update the `imageStyle` computation (lines 65-68) to compose CSS transforms:
```tsx
const previewTransform = [
cssRotate ? `rotate(${cssRotate}deg)` : "",
cssFlipH ? "scaleX(-1)" : "",
cssFlipV ? "scaleY(-1)" : "",
]
.filter(Boolean)
.join(" ");
const imageStyle =
fitMode === "fit"
? {
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain" as const,
...(previewTransform && { transform: previewTransform }),
}
: {
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
transformOrigin: "center center",
};
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/common/image-viewer.tsx
git commit -m "feat: add CSS transform props to ImageViewer for live rotate/flip preview"
```
---
### Task 4: Update RotateSettings with Live Preview Callback
**Files:**
- Modify: `apps/web/src/components/tools/rotate-settings.tsx`
- [ ] **Step 1: Add onPreviewTransform callback prop and change button label**
The component needs to:
1. Accept an optional `onPreviewTransform` callback
2. Call it on every state change (angle, flipH, flipV) via useEffect
3. Change the submit button label from "Rotate" to "Apply"
Replace the entire file:
```tsx
import { useState, useEffect } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import {
Download,
RotateCcw,
RotateCw,
FlipHorizontal,
FlipVertical,
} from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
export interface PreviewTransform {
rotate: number;
flipH: boolean;
flipV: boolean;
}
interface RotateSettingsProps {
onPreviewTransform?: (transform: PreviewTransform) => void;
}
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, progress } =
useToolProcessor("rotate");
const [angle, setAngle] = useState(0);
const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false);
// Emit preview transform on every change
useEffect(() => {
onPreviewTransform?.({ rotate: angle, flipH, flipV });
}, [angle, flipH, flipV, onPreviewTransform]);
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
const rotateRight = () => setAngle((a) => (a + 90) % 360);
const handleProcess = () => {
processFiles(files, {
angle,
horizontal: flipH,
vertical: flipV,
});
};
const hasFile = files.length > 0;
const hasChanges = angle !== 0 || flipH || flipV;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && hasChanges && !processing) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate buttons */}
<div>
<label className="text-xs text-muted-foreground">Quick Rotate</label>
<div className="flex gap-2 mt-1">
<button
type="button"
onClick={rotateLeft}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCcw className="h-4 w-4" />
90 Left
</button>
<button
type="button"
onClick={rotateRight}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCw className="h-4 w-4" />
90 Right
</button>
</div>
</div>
{/* Angle slider */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Angle</label>
<span className="text-xs font-mono text-foreground">{angle} deg</span>
</div>
<input
type="range"
min={0}
max={360}
value={angle}
onChange={(e) => setAngle(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Flip buttons */}
<div>
<label className="text-xs text-muted-foreground">Flip</label>
<div className="flex gap-2 mt-1">
<button
type="button"
onClick={() => setFlipH(!flipH)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
flipH
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<FlipHorizontal className="h-4 w-4" />
Horizontal
</button>
<button
type="button"
onClick={() => setFlipV(!flipV)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
flipV
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<FlipVertical className="h-4 w-4" />
Vertical
</button>
</div>
</div>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Applying"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Apply
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/rotate-settings.tsx
git commit -m "feat: add live preview callback to RotateSettings, rename button to Apply"
```
---
### Task 5: Update tool-page.tsx for Conditional Rendering
**Files:**
- Modify: `apps/web/src/pages/tool-page.tsx`
- [ ] **Step 1: Add imports, preview transform state, and conditional result rendering**
Changes to make in `tool-page.tsx`:
1. Add imports at the top (after existing imports, around line 8):
```tsx
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
```
2. Add a set for tools that use alternate result views (after `NO_DROPZONE_TOOLS` on line 63):
```tsx
const SIDE_BY_SIDE_TOOLS = new Set(["resize"]);
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
```
3. Update `ToolSettingsPanel` to accept and pass through the preview transform callback (replace the function starting at line 65):
```tsx
function ToolSettingsPanel({
toolId,
onPreviewTransform,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
}) {
// Phase 2: Core tools
if (toolId === "resize") return <ResizeSettings />;
if (toolId === "crop") return <CropSettings />;
if (toolId === "rotate") return <RotateSettings onPreviewTransform={onPreviewTransform} />;
```
(Rest of ToolSettingsPanel stays identical)
4. In the `ToolPage` component, add preview transform state (after `const [mobileSettingsOpen, setMobileSettingsOpen]` on line 177):
```tsx
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
```
5. Update ToolSettingsPanel usage in both mobile and desktop layouts — pass the callback:
```tsx
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
/>
```
6. Replace the result rendering logic in the main area for **both mobile and desktop layouts**. Replace the entire conditional block (from `{isNoDropzone ?` through the closing `}`). Apply this **identically** in both the mobile layout (around line 286) and the desktop layout (around line 372):
```tsx
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
```
**Important:** The `BeforeAfterSlider` is kept for all tools except resize (SideBySideComparison) and rotate (ImageViewer). The `BeforeAfterSlider` import must NOT be removed.
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/pages/tool-page.tsx
git commit -m "feat: conditional result views — side-by-side for resize, live preview for rotate"
```
---
### Task 6: Docker Rebuild and Test
**Files:**
- No file changes — build and run existing Docker setup
- [ ] **Step 1: Build Docker image**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image
docker compose -f docker/docker-compose.yml build
```
- [ ] **Step 2: Start the container**
```bash
docker compose -f docker/docker-compose.yml up -d
```
- [ ] **Step 3: Verify the app is running**
```bash
curl -f http://localhost:1349/api/v1/health
```
Expected: Health check passes
- [ ] **Step 4: Report to user for UI testing**
App is running at `http://localhost:1349`. User can test:
- Resize tool: tab-based settings (Presets, Custom Size, Scale), side-by-side result view
- Rotate tool: live CSS preview as controls change, "Apply" button, processed result in standard viewer
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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 |