mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
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'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'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 |
|
||||
Reference in New Issue
Block a user