chore: remove internal docs from repo, update public documentation

Remove docs/superpowers/, .claude/ config, and PRD.md from version
control (kept locally via .gitignore). Update README, CHANGELOG,
VitePress docs, and .env.example to reflect recent features: Files
page, teams, admin settings, persistent storage, and various API
improvements.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 01:11:40 +08:00
parent 3b4f522bf4
commit 627ff8a82c
99 changed files with 853 additions and 15277 deletions
-13
View File
@@ -1,13 +0,0 @@
const fs = require("node:fs");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const cmd = input.tool_input?.command || "";
const devPattern = /\b(pnpm|npm|yarn|bun)\s+(run\s+)?dev\b/;
if (devPattern.test(cmd) && !/tmux/.test(cmd) && !/&\s*$/.test(cmd)) {
console.log(
"TIP: Dev servers block the session. Consider running in tmux instead:\n" +
` tmux new-session -d -s stirling-dev '${cmd}'\n` +
"Or append & to background the process.",
);
}
-10
View File
@@ -1,10 +0,0 @@
const fs = require("node:fs");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const cmd = input.tool_input?.command || "";
if (/--no-verify/.test(cmd)) {
console.log(
"BLOCKED: --no-verify bypasses git hooks. Fix the underlying issue instead of skipping checks.",
);
process.exit(2);
}
-20
View File
@@ -1,20 +0,0 @@
const fs = require("node:fs");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const filePath = input.tool_input?.file_path || "";
const protectedPatterns = [
/biome\.json$/,
/\.eslintrc/,
/eslint\.config/,
/\.prettierrc/,
/prettier\.config/,
/tsconfig.*\.json$/,
/\.editorconfig$/,
];
if (protectedPatterns.some((p) => p.test(filePath))) {
console.log(
`BLOCKED: Cannot modify config file "${filePath}". Fix the code to match the config, not the other way around.`,
);
process.exit(2);
}
-21
View File
@@ -1,21 +0,0 @@
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const path = require("node:path");
const input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const filePath = input.tool_input?.file_path || "";
const formattable = /\.(ts|tsx|js|jsx|json)$/;
if (filePath && formattable.test(filePath) && fs.existsSync(filePath)) {
const projectRoot = path.resolve(__dirname, "..", "..");
const biomeBin = path.join(projectRoot, "node_modules", ".bin", "biome");
try {
execFileSync(biomeBin, ["check", "--write", filePath], {
stdio: "pipe",
cwd: projectRoot,
});
} catch {
// Format failures are non-blocking
}
}
-23
View File
@@ -1,23 +0,0 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const _input = JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
const counterFile = path.join(os.tmpdir(), "stirling-claude-tool-count.json");
let count = 0;
try {
const data = JSON.parse(fs.readFileSync(counterFile, "utf8"));
count = data.count || 0;
} catch {
// First call or file missing
}
count++;
fs.writeFileSync(counterFile, JSON.stringify({ count }));
if (count === 50 || (count > 50 && (count - 50) % 25 === 0)) {
console.log(
`${count} tool calls this session. Consider /compact at a logical boundary to free up context.`,
);
}
-28
View File
@@ -1,28 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "node .claude/hooks/block-no-verify.js"
},
{
"matcher": "Bash",
"command": "node .claude/hooks/auto-tmux-dev.js"
},
{
"matcher": "Edit|Write",
"command": "node .claude/hooks/config-protection.js"
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"command": "node .claude/hooks/post-edit-format.js"
},
{
"matcher": "Edit|Write|Bash",
"command": "node .claude/hooks/suggest-compact.js"
}
]
}
}
+1
View File
@@ -14,6 +14,7 @@ MAX_MEGAPIXELS=100
RATE_LIMIT_PER_MIN=100
DB_PATH=./data/stirling.db
WORKSPACE_PATH=./tmp/workspace
FILES_STORAGE_PATH=./data/files
DEFAULT_THEME=light
DEFAULT_LOCALE=en
APP_NAME=Stirling Image
+3 -1
View File
@@ -27,7 +27,9 @@ blob-report/
# IDE / tool scratch
.superpowers/
.claude/settings.local.json
.claude/
docs/superpowers/
PRD.md
# Ad-hoc test screenshots and reports
test-*.png
+138 -68
View File
@@ -1,112 +1,182 @@
# [0.7.0](https://github.com/siddharthksah/Stirling-Image/compare/v0.6.0...v0.7.0) (2026-03-24)
# Changelog
All notable changes to this project will be documented in this file.
### Features
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* harden auth, security headers, SVG sanitization, and pipeline ownership ([beaad1d](https://github.com/siddharthksah/Stirling-Image/commit/beaad1d3044f6b2535aacb6a67541464f736778b))
* **pipeline:** add inline settings configuration for automation steps ([827eae8](https://github.com/siddharthksah/Stirling-Image/commit/827eae824543ab9cea2a5d3a82acd388736fa424))
## [Unreleased]
# [0.6.0](https://github.com/siddharthksah/Stirling-Image/compare/v0.5.2...v0.6.0) (2026-03-24)
### Added
- Files page — persistent server-side storage with version tracking and a mobile layout
- Teams — full CRUD API, database table, and migration
- Admin settings panel covering teams, tool visibility, feature flags, temp file cleanup, and logo upload
- Branding API routes (logo upload, serve, delete)
- Tool filtering and DB-backed cleanup settings on the API side
- i18n keys for the new settings screens
- `userFiles` table for persistent file management
- `FILES_STORAGE_PATH` config variable for controlling where uploaded files live
- Tool results auto-save to persistent storage when a `fileId` is provided
### Features
### Changed
* extract auto-orient utility and expand test coverage ([8622c4a](https://github.com/siddharthksah/Stirling-Image/commit/8622c4a40245504372e75d3cd851535528639dea))
- Renamed `Tool.alpha` to `Tool.experimental` everywhere
- Tightened TypeScript types in tool-factory registry (removed `any` casts)
- Bumped login attempt limit from 5 to 10
- Switched `isNaN` to `Number.isNaN` in cleanup interval parsing
- Null-safe team lookup in auth registration
## [0.5.2](https://github.com/siddharthksah/Stirling-Image/compare/v0.5.1...v0.5.2) (2026-03-23)
### Removed
- Internal planning docs (`docs/superpowers/`) and Claude Code config (`.claude/`) from version control — these stay local only
### Bug Fixes
## [0.7.0] - 2026-03-24
* restore APP_VERSION import used by health endpoint ([2a74b60](https://github.com/siddharthksah/Stirling-Image/commit/2a74b604466193994bcc87ba2ef589f4c15d9547))
### Added
## [0.5.1](https://github.com/siddharthksah/Stirling-Image/compare/v0.5.0...v0.5.1) (2026-03-23)
- Inline settings configuration for pipeline automation steps, allowing per-step parameter overrides
### Security
### Bug Fixes
- Hardened authentication with stricter session validation
- Added security headers (HSTS, CSP, X-Content-Type-Options)
- SVG sanitization on upload to prevent XSS via malicious SVGs
- Pipeline ownership enforcement — users can only execute their own pipelines
* **crop:** use percentCrop from onChange to fix inflated pixel values ([f238820](https://github.com/siddharthksah/Stirling-Image/commit/f2388206324e4ddc6114b91421ca8bcb634fc340))
## [0.6.0] - 2026-03-24
# [0.5.0](https://github.com/siddharthksah/Stirling-Image/compare/v0.4.1...v0.5.0) (2026-03-23)
### Added
- Extracted reusable auto-orient utility from image processing pipeline
- Expanded integration test coverage across tool routes
### Bug Fixes
## [0.5.2] - 2026-03-23
* resolve TypeScript Uint8Array type error with fflate ([c1b06b3](https://github.com/siddharthksah/Stirling-Image/commit/c1b06b37f2cf2aef6971f1b16b5691ce7d932b87))
* white screen crash when uploading photos with null GPS EXIF data ([c913df9](https://github.com/siddharthksah/Stirling-Image/commit/c913df9c0eccb0a1d0ff305cc6dcb1c99ddf96f4))
### Fixed
- Restored `APP_VERSION` import used by the `/health` endpoint, fixing version reporting in production
### Features
## [0.5.1] - 2026-03-23
* accept clientJobId in batch endpoint for SSE progress correlation ([8ed57f4](https://github.com/siddharthksah/Stirling-Image/commit/8ed57f400fd083fbef7528f51231f59ecd3b1bee))
* add CSS transform props to ImageViewer for live rotate/flip preview ([7627853](https://github.com/siddharthksah/Stirling-Image/commit/762785394b2e66a25ee858da96868cd752942a7d))
* add live preview callback to RotateSettings, rename button to Apply ([06844ec](https://github.com/siddharthksah/Stirling-Image/commit/06844ec1ada4d5685691f26655ecb8712b4d94d9))
* add MultiImageViewer with arrow navigation and filmstrip ([1fa8747](https://github.com/siddharthksah/Stirling-Image/commit/1fa874758ca58efb4b9f68d76a02648d6a84abfe))
* add processAllFiles batch method to tool processor hook ([cd1e180](https://github.com/siddharthksah/Stirling-Image/commit/cd1e18026fb4b866c4bbde6c259412674a501f05))
* add SideBySideComparison component for resize results ([2d0ba5a](https://github.com/siddharthksah/Stirling-Image/commit/2d0ba5a65683b45a6382ca1aea9f7a75d1143702))
* add ThumbnailStrip filmstrip component ([9caa613](https://github.com/siddharthksah/Stirling-Image/commit/9caa613883a7f0f0ccfbb268d3e88596e92ce094))
* conditional result views — side-by-side for resize, live preview for rotate ([6682649](https://github.com/siddharthksah/Stirling-Image/commit/668264990c33667e586e4e24703f1957b18e1c0c))
* **crop:** add CropCanvas component with visual overlay, grid, and keyboard controls ([018bbf4](https://github.com/siddharthksah/Stirling-Image/commit/018bbf44bfed4acf475de49ff9f3f5c20ee63295))
* **crop:** add react-image-crop dependency ([b7ecd41](https://github.com/siddharthksah/Stirling-Image/commit/b7ecd41897de4f48e6f970dfbb1188848e9bcd2a))
* **crop:** redesign CropSettings with aspect presets, pixel inputs, and grid toggle ([d75f458](https://github.com/siddharthksah/Stirling-Image/commit/d75f458dc0f13e69640bc012adea0ee29d02b82b))
* **crop:** wire CropCanvas and CropSettings into tool-page with bidirectional state ([ea7fb46](https://github.com/siddharthksah/Stirling-Image/commit/ea7fb46f7ecad5639c007ba43d652fd72c75b39f))
* integrate MultiImageViewer and multi-file UX into tool page ([52aab1e](https://github.com/siddharthksah/Stirling-Image/commit/52aab1e2bf5f938a800a78b4e5c11f5ad9fbae27))
* merge multi-image UX — batch processing, filmstrip navigation, resize/rotate redesign ([9bfdb75](https://github.com/siddharthksah/Stirling-Image/commit/9bfdb75f5c205906f2bd62d496e96d6b402e1dc1))
* multi-file metadata display with per-file caching ([42b59f3](https://github.com/siddharthksah/Stirling-Image/commit/42b59f3e2d84b3d9fd6724b9ed9fb272fb11201a))
* rewrite file-store with FileEntry model for multi-image support ([abbb3e4](https://github.com/siddharthksah/Stirling-Image/commit/abbb3e4d6e8f21f35b385c51fb659010099556d1))
* rewrite resize settings with tab-based UI (presets, custom, scale) ([3b39a8c](https://github.com/siddharthksah/Stirling-Image/commit/3b39a8cc5f11a5093f42bcdbe97989fce3841616))
* wire up batch processing across tool settings components ([1d87091](https://github.com/siddharthksah/Stirling-Image/commit/1d87091bbc4bce9d8e78b0cddd474d21dd4dcd1c))
### Fixed
## [0.4.1](https://github.com/siddharthksah/Stirling-Image/compare/v0.4.0...v0.4.1) (2026-03-23)
- Crop tool now uses `percentCrop` from `onChange` callback, fixing inflated pixel values that produced incorrect crop regions
### Changed
### Bug Fixes
- Removed unused Swagger dependencies, reducing bundle size
- Parallelized CI jobs for faster pipeline execution
* unify project on port 1349, improve strip-metadata and UI components ([4912ee3](https://github.com/siddharthksah/Stirling-Image/commit/4912ee37e961b9ba9748d7ffa9164d7ca5ae0abb))
## [0.5.0] - 2026-03-23
# [0.4.0](https://github.com/siddharthksah/Stirling-Image/compare/v0.3.1...v0.4.0) (2026-03-23)
### Added
- **Interactive crop tool** with visual overlay, grid lines, aspect ratio presets, pixel input fields, and keyboard controls
- **Multi-image support** — upload and process multiple files with arrow navigation and filmstrip thumbnail strip
- Batch processing wired across all tool settings with `processAllFiles` method
- `clientJobId` correlation for SSE progress during batch operations
- Side-by-side comparison view for resize results
- Live CSS transform preview for rotate/flip operations
- Redesigned resize settings with tabbed UI (presets, custom dimensions, scale percentage)
- Client-side ZIP extraction via `fflate` for batch result downloads
- Per-file metadata display with caching
### Bug Fixes
### Fixed
* streamline CI/CD — remove broken AI docs updater, fix Docker publish ([ad2c96d](https://github.com/siddharthksah/Stirling-Image/commit/ad2c96d7b86b55b602e973f5da30d517605ed5cd))
- White screen crash when uploading photos with null GPS EXIF data
- TypeScript `Uint8Array` type incompatibility with `fflate`
## [0.4.1] - 2026-03-23
### Features
### Fixed
* add CSS transform props to ImageViewer for live rotate/flip preview ([de3340f](https://github.com/siddharthksah/Stirling-Image/commit/de3340fc5b201cc5c046cbb38a274e7dfa026b41))
* add live preview callback to RotateSettings, rename button to Apply ([17be50b](https://github.com/siddharthksah/Stirling-Image/commit/17be50b213e1ca6d1d6e8d949b65de70c68d108d))
* add SideBySideComparison component for resize results ([d037305](https://github.com/siddharthksah/Stirling-Image/commit/d037305f29a32ead0addf6d79ace4823c9ba0e2b))
* conditional result views — side-by-side for resize, live preview for rotate ([f0d18be](https://github.com/siddharthksah/Stirling-Image/commit/f0d18bee5ebc110e63bb3cf9fd829d80900759ce))
* rewrite resize settings with tab-based UI (presets, custom, scale) ([b9f3ac0](https://github.com/siddharthksah/Stirling-Image/commit/b9f3ac0d222f3712d35becd6c831dc62f728a16a))
- Unified all services on port 1349 (was split across multiple ports)
- Strip-metadata tool now correctly removes all EXIF data
- Before/after slider and side-by-side comparison component rendering fixes
## [0.3.1](https://github.com/siddharthksah/Stirling-Image/compare/v0.3.0...v0.3.1) (2026-03-23)
## [0.4.0] - 2026-03-23
### Added
### Bug Fixes
- **Resize tool redesign** with tabbed settings UI — presets, custom dimensions, and scale percentage
- **Rotate/flip live preview** using CSS transforms before server round-trip
- Side-by-side comparison component for visual before/after on resize
- Conditional result views — side-by-side for resize, live preview for rotate
* resolve tsx not found in AI docs updater workflow ([dfbef8d](https://github.com/siddharthksah/Stirling-Image/commit/dfbef8d723dcc2960187db36676107f708707e33))
### Fixed
# [0.3.0](https://github.com/siddharthksah/Stirling-Image/compare/v0.2.1...v0.3.0) (2026-03-23)
- CI/CD pipeline — removed broken AI docs updater workflow, fixed Docker publish job
## [0.3.1] - 2026-03-23
### Bug Fixes
### Fixed
* add SSE progress endpoint to public paths ([18c3da0](https://github.com/siddharthksah/Stirling-Image/commit/18c3da0d41cba74c55fffd1a9f58c1a8ee5d5574))
* apply continuous progress bar to erase-object and OCR ([196c553](https://github.com/siddharthksah/Stirling-Image/commit/196c553af57bb9efbd32282dd24fc080fb7228dd))
* continuous progress bar (no 100%→0% reset) ([b4abefe](https://github.com/siddharthksah/Stirling-Image/commit/b4abefe94776a1b9a9700f469e56de060c7626ca))
* setError(null) was overriding setProcessing(true) ([2be94b7](https://github.com/siddharthksah/Stirling-Image/commit/2be94b77b2b288086b55101e6854bf0407935b28))
- CI workflow failure: `tsx` binary not found in AI docs updater action
## [0.3.0] - 2026-03-23
### Features
### Added
* **ai:** add emit_progress() calls to all Python AI scripts ([eb6f57d](https://github.com/siddharthksah/Stirling-Image/commit/eb6f57dfa35fa10ada4493e9fd73fe4d4788c03c))
* **ai:** add onProgress callback to all AI wrapper functions ([021c9f1](https://github.com/siddharthksah/Stirling-Image/commit/021c9f12b5a1aca6c6c7cb8c9d9fad3d0406ab94))
* **ai:** rewrite bridge.ts to stream stderr progress via spawn ([9d9c45a](https://github.com/siddharthksah/Stirling-Image/commit/9d9c45a04c2a85e99021a35a3da94e1e19cb9043))
* **api:** add SingleFileProgress type and SSE update function ([12b85d4](https://github.com/siddharthksah/Stirling-Image/commit/12b85d4def1f29ca291d6f5e538181f2bcbcf774))
* **api:** wire AI route handlers to SSE progress via clientJobId ([a3f85da](https://github.com/siddharthksah/Stirling-Image/commit/a3f85da20f73f02cd5ec141519aa73fcfeb2157b))
* replace model dropdown with intuitive subject/quality selector in remove-bg ([bc26d60](https://github.com/siddharthksah/Stirling-Image/commit/bc26d60d54a47a9fb6f58115131845d1ae5ee868))
* **web:** add ProgressCard component ([ed69488](https://github.com/siddharthksah/Stirling-Image/commit/ed6948804b52ee5d8977732130a55c6c1efc358d))
* **web:** add ProgressCard to non-AI tool settings (Group A) ([17035e9](https://github.com/siddharthksah/Stirling-Image/commit/17035e98abc84f7abd0de91b9c0b324403a31c71))
* **web:** migrate AI tool settings to ProgressCard ([eed4fc2](https://github.com/siddharthksah/Stirling-Image/commit/eed4fc28db80967aa3bb513459bf05d9b417d65f))
* **web:** rewrite useToolProcessor with XHR upload progress and SSE ([305f50b](https://github.com/siddharthksah/Stirling-Image/commit/305f50b5f4bd87cc19a3a8fe0d0374bb78bad101))
- **Real progress bars** replacing indeterminate spinners across all tools
- SSE-based progress streaming from Python AI scripts through the API to the frontend
- `ProgressCard` component with determinate progress display for all tool types
- `useToolProcessor` hook rewritten with XHR upload progress and SSE event streaming
- `onProgress` callback support in all AI wrapper functions (rembg, RealESRGAN, PaddleOCR, MediaPipe, LaMa)
- `emit_progress()` calls in all Python AI scripts for granular status updates
- Intuitive subject/quality selector replacing raw model dropdown in background removal tool
### Fixed
- Progress bar no longer resets from 100% to 0% between processing stages
- `setError(null)` no longer overrides `setProcessing(true)` race condition
- SSE progress endpoint added to public auth paths (was returning 401)
## [0.2.1] - 2026-03-22
### Added
- **Monorepo foundation** — Turborepo with pnpm workspaces (`apps/api`, `apps/web`, `apps/docs`, `packages/shared`, `packages/image-engine`, `packages/ai`)
- **Fastify API server** with health check, environment config, and SQLite database via Drizzle ORM
- **Authentication system** with default admin user, login page, and session management
- **React SPA** with Vite, Tailwind CSS, dark/light/system theme, and sidebar layout
- **14 Sharp-based image operations** — resize, crop, rotate, convert, compress, metadata strip, color adjust, and more
- **Generic tool route factory** for declarative API endpoint creation
- **Tool settings UI** for all core image tools with before/after comparison slider
- **Batch processing** with ZIP download and SSE progress tracking
- **30+ tools** including watermark, text overlay, composition, collage, splitting, border/frame, image info, compare, duplicates, color palette, QR code, barcode, replace-color, SVG-to-raster, vectorize, GIF, favicon, and image-to-PDF
- **6 AI-powered tools** via Python sidecar — background removal, upscaling, OCR, face detection/blur, object erasure (LaMa inpainting)
- **Pipeline system** — builder UI with templates, execution/save/list API endpoints
- i18n architecture with English translations
- Keyboard shortcuts for tool navigation
- Mobile responsive layout with bottom navigation
- Fullscreen tool grid view
- Settings dialog (general, security, API keys, about)
- API key management routes
- Home page redesign with upload flow and auth guard
- Multi-stage Docker build with Python ML dependencies
- Playwright end-to-end test suite
- VitePress documentation site with GitHub Pages deployment
- GitHub Actions CI pipeline and Docker Hub auto-publish workflow
- Semantic-release for automated versioning
- Swagger/OpenAPI documentation at `/api/docs`
- Automatic workspace file cleanup cron
### Fixed
- Python bridge ENOENT handling for venv fallback — no longer swallows script errors
- Port configuration unified to 1349 for the UI across all modes
- Home upload flow, auth redirect, and form submit handling bugs
- Background removal defaults to U2-Net (fast, ~2s) instead of slow BiRefNet
[Unreleased]: https://github.com/siddharthksah/Stirling-Image/compare/v0.7.0...HEAD
[0.7.0]: https://github.com/siddharthksah/Stirling-Image/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/siddharthksah/Stirling-Image/compare/v0.5.2...v0.6.0
[0.5.2]: https://github.com/siddharthksah/Stirling-Image/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/siddharthksah/Stirling-Image/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/siddharthksah/Stirling-Image/compare/v0.4.1...v0.5.0
[0.4.1]: https://github.com/siddharthksah/Stirling-Image/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/siddharthksah/Stirling-Image/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/siddharthksah/Stirling-Image/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/siddharthksah/Stirling-Image/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/siddharthksah/Stirling-Image/releases/tag/v0.2.1
-1043
View File
File diff suppressed because it is too large Load Diff
+30 -28
View File
@@ -12,36 +12,40 @@
---
A self-hosted, privacy-first image processing suite with 37+ tools. Resize, compress, convert, watermark, remove backgrounds, and more — all from a single Docker container. No data ever leaves your server.
Self-hosted image processing with 37+ tools in a single Docker container. Resize, compress, convert, watermark, remove backgrounds, run OCR, and more. Nothing leaves your server.
Inspired by [Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF), built for images.
<!-- TODO: Add screenshot here -->
<!-- ![Dashboard](docs/screenshot-dashboard.png) -->
## Quick Start
## Quick start
```bash
docker run -d -p 1349:1349 -v ./data:/data ghcr.io/siddharthksah/stirling-image:latest
```
Then open [http://localhost:1349](http://localhost:1349).
Open [http://localhost:1349](http://localhost:1349). Default login is `admin` / `admin`.
## Key Capabilities
## What it does
- **37+ image tools** — Resize, crop, rotate, compress, convert, watermark, color adjustments, and more in one place.
- **37+ image tools** in one placeresize, crop, rotate, compress, convert, watermark, color adjustments, and the rest.
- **AI-powered processing** — Background removal (rembg), image upscaling (Real-ESRGAN), OCR text extraction, face/PII auto-blurring, and smart cropping — all running locally.
- **AI tools that run locally** — background removal (rembg), upscaling (Real-ESRGAN), OCR (PaddleOCR), face blurring (MediaPipe), object erasing (LaMa). No external API calls.
- **Privacy first** — Every operation runs on your hardware. No files are sent to external servers. No telemetry, no tracking, no cloud dependencies.
- **Your hardware, your data** — no telemetry, no tracking, no cloud. Files stay on your machine.
- **Batch processing** — Drop 200 images, apply any tool, download results as a ZIP. Concurrent processing with configurable limits.
- **Batch processing** — drop up to 200 images, apply any tool, get a ZIP back. Configurable concurrency.
- **Automation pipelines** — Chain tools into reusable workflows (e.g., Resize, Compress, Convert to WebP, Strip Metadata). Save and reuse pipelines.
- **Pipelines** — chain tools into reusable workflows (resize, then compress, then convert to WebP, then strip metadata). Save them and rerun later.
- **Full API** — Every tool is available via REST API with Swagger documentation at `/api/docs`. Automate image processing from scripts, CI/CD, or other tools.
- **REST API** — every tool is exposed at `/api/v1/tools/:toolId`. Swagger docs at `/api/docs`.
- **Self-hosted & portable** — Single Docker container. Works on Intel, AMD, and Apple Silicon (multi-arch: `linux/amd64` + `linux/arm64`).
- **Persistent file storage** — save processed images server-side with version tracking. Pick up where you left off.
- **Teams and admin settings** — manage users, toggle tool visibility, configure cleanup, upload a custom logo.
- **Single container** — runs on Intel, AMD, and Apple Silicon (`linux/amd64` + `linux/arm64`).
## Tools
@@ -57,27 +61,28 @@ Then open [http://localhost:1349](http://localhost:1349).
| **Format** | SVG to Raster, Image to SVG, GIF Tools |
| **Automation** | Pipeline Builder, Batch Processing |
## Supported Formats
## Supported formats
**Input:** JPG, PNG, WebP, AVIF, TIFF, BMP, GIF (animated), SVG, HEIC/HEIF, JPEG XL, ICO, RAW (CR2, NEF, ARW, DNG)
**In:** JPG, PNG, WebP, AVIF, TIFF, BMP, GIF (animated), SVG, HEIC/HEIF, JPEG XL, ICO, RAW (CR2, NEF, ARW, DNG)
**Output:** JPG, PNG, WebP, AVIF, TIFF, GIF, JPEG XL, SVG, ICO, PDF
**Out:** JPG, PNG, WebP, AVIF, TIFF, GIF, JPEG XL, SVG, ICO, PDF
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `1349` | Application port |
| `AUTH_ENABLED` | `true` | Enable login (default credentials: `admin` / `admin`) |
| `MAX_UPLOAD_SIZE_MB` | `100` | Maximum file upload size |
| `MAX_BATCH_SIZE` | `200` | Maximum files per batch |
| `PORT` | `1349` | Server port |
| `AUTH_ENABLED` | `true` | Require login (`admin` / `admin` by default) |
| `MAX_UPLOAD_SIZE_MB` | `100` | Max file upload size |
| `MAX_BATCH_SIZE` | `200` | Max files per batch |
| `CONCURRENT_JOBS` | `3` | Parallel processing limit |
| `FILE_MAX_AGE_HOURS` | `24` | Auto-cleanup temp files after this duration |
| `FILE_MAX_AGE_HOURS` | `24` | Auto-delete temp files after this many hours |
| `FILES_STORAGE_PATH` | `./data/files` | Where persistent user files are stored |
| `STORAGE_MODE` | `local` | Storage backend (`local` or `s3`) |
See [`.env.example`](.env.example) for the full list.
## Docker Compose
## Docker Compose example
```yaml
services:
@@ -106,16 +111,13 @@ pnpm dev
Requires Node.js 22+ and pnpm 9+.
## Tech Stack
## Tech stack
- **Frontend:** React 19, Vite, Tailwind CSS 4, shadcn/ui
- **Backend:** Fastify, Sharp (libvips), Drizzle ORM, SQLite
- **AI/ML:** Python (rembg, Real-ESRGAN, PaddleOCR, MediaPipe)
- **Infrastructure:** Turborepo monorepo, Docker multi-arch
React 19 + Vite frontend, Fastify + Sharp backend, SQLite via Drizzle ORM, Python sidecar for AI/ML models. Monorepo with pnpm workspaces. Multi-arch Docker builds.
## Support This Project
## Support this project
If Stirling Image is useful to you, consider supporting its development:
If you find this useful, consider supporting development:
<p align="center">
<a href="https://github.com/sponsors/siddharthksah"><img src="https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=github-sponsors" alt="GitHub Sponsors"></a>
@@ -124,7 +126,7 @@ If Stirling Image is useful to you, consider supporting its development:
## Contributing
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
Contributions welcome. Open an issue first so we can talk about what you have in mind.
## License
+2 -2
View File
@@ -1,6 +1,6 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import Database from "better-sqlite3";
import Database, { type Database as DatabaseType } from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { env } from "../config.js";
import * as schema from "./schema.js";
@@ -8,7 +8,7 @@ import * as schema from "./schema.js";
// Ensure data directory exists
mkdirSync(dirname(env.DB_PATH), { recursive: true });
const sqlite = new Database(env.DB_PATH);
const sqlite: DatabaseType = new Database(env.DB_PATH);
// Critical SQLite pragmas for reliability
sqlite.pragma("journal_mode = WAL");
+1 -1
View File
@@ -18,7 +18,7 @@ export function getMaxAgeMs(): number {
.get();
if (row) {
const hours = parseFloat(row.value);
if (!isNaN(hours) && hours > 0) return hours * 60 * 60 * 1000;
if (!Number.isNaN(hours) && hours > 0) return hours * 60 * 60 * 1000;
}
} catch {
/* DB not ready yet, use env */
+2 -2
View File
@@ -128,7 +128,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
// ── Login attempt limit ──────────────────────────────────────────
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 5;
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
function getLoginAttemptLimit(): number {
const row = db
@@ -378,7 +378,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const teamExists = db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, (body as { team?: string }).team!))
.where(eq(schema.teams.id, (body as { team?: string }).team ?? ""))
.get();
if (!teamExists)
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
+6 -1
View File
@@ -150,7 +150,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
try {
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId)!;
const toolConfig = getToolConfig(step.toolId);
if (!toolConfig) {
return reply
.status(400)
.send({ error: `Step ${i + 1}: Tool "${step.toolId}" not found` });
}
// Parse settings through the schema to apply defaults
const settings = toolConfig.settingsSchema.parse(step.settings);
+2 -2
View File
@@ -57,7 +57,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" });
}
const trimmedName = (body!.name as string).trim();
const trimmedName = (body?.name ?? "").trim();
// Check for duplicate name (case-insensitive)
const existing = db
@@ -97,7 +97,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" });
}
const trimmedName = (body!.name as string).trim();
const trimmedName = (body?.name ?? "").trim();
// Check for duplicate name (case-insensitive), excluding current team
const duplicate = db
+15 -6
View File
@@ -24,18 +24,27 @@ export interface ToolRouteConfig<T> {
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
}
/** Type-erased config stored in the registry (settings type is widened to avoid variance issues). */
export interface AnyToolRouteConfig {
toolId: string;
settingsSchema: z.ZodType<unknown, z.ZodTypeDef, unknown>;
process: (
inputBuffer: Buffer,
settings: unknown,
filename: string,
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
}
/**
* In-memory registry of all tool configs, keyed by toolId.
* Populated by createToolRoute() calls; used by batch processing.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const toolRegistry = new Map<string, ToolRouteConfig<any>>();
const toolRegistry = new Map<string, AnyToolRouteConfig>();
/**
* Retrieve a registered tool config by its ID.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getToolConfig(toolId: string): ToolRouteConfig<any> | undefined {
export function getToolConfig(toolId: string): AnyToolRouteConfig | undefined {
return toolRegistry.get(toolId);
}
@@ -55,8 +64,8 @@ export function getToolConfig(toolId: string): ToolRouteConfig<any> | undefined
* - Response formatting
*/
export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig<T>): void {
// Register in the tool registry for batch processing
toolRegistry.set(config.toolId, config);
// Register in the tool registry for batch processing (cast to type-erased form)
toolRegistry.set(config.toolId, config as AnyToolRouteConfig);
app.post(
`/api/v1/tools/${config.toolId}`,
+3 -2
View File
@@ -60,10 +60,11 @@ export function registerBlurFaces(app: FastifyInstance) {
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+3 -2
View File
@@ -71,10 +71,11 @@ export function registerEraseObject(app: FastifyInstance) {
await writeFile(inputPath, imageBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+3 -2
View File
@@ -73,10 +73,11 @@ export function registerOcr(app: FastifyInstance) {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
@@ -62,10 +62,11 @@ export function registerRemoveBackground(app: FastifyInstance) {
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+1 -3
View File
@@ -75,9 +75,7 @@ function parseXmp(xmpBuffer: Buffer): Record<string, string> {
const xml = xmpBuffer.toString("utf-8");
const result: Record<string, string> = {};
const attrRegex = /(\w+:\w+)="([^"]+)"/g;
let match;
while ((match = attrRegex.exec(xml)) !== null) {
for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) {
const key = match[1];
if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue;
result[key] = match[2];
+3 -2
View File
@@ -62,10 +62,11 @@ export function registerUpscale(app: FastifyInstance) {
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+5 -2
View File
@@ -47,11 +47,12 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`)
A Fastify v5 server that handles:
- File uploads and temporary workspace management
- File uploads, temporary workspace management, and persistent file storage
- Tool execution (routes each tool request to the image engine or AI bridge)
- Pipeline orchestration (chaining multiple tools sequentially)
- Batch processing with concurrency control via p-queue
- User authentication, API key management, and rate limiting
- User authentication, teams, API key management, and rate limiting
- Admin settings (tool visibility, feature flags, cleanup config, branding)
- Swagger/OpenAPI documentation at `/api/docs`
- Serving the built frontend as a SPA in production
@@ -61,6 +62,8 @@ Key dependencies: Fastify, Drizzle ORM, better-sqlite3, Sharp, Zod for validatio
A React 19 single-page app built with Vite. Uses Zustand for state management, Tailwind CSS v4 for styling, and Lucide for icons. Communicates with the API over REST and SSE (for progress tracking).
Pages include a tool workspace, a Files page for managing persistent uploads and results, an automation/pipeline builder, and an admin settings panel.
The built frontend gets served by the Fastify backend in production, so there is no separate web server in the Docker container.
### Docs (`apps/docs`)
+2 -1
View File
@@ -26,6 +26,7 @@ All configuration is done through environment variables. Every variable has a se
| `STORAGE_MODE` | `local` | `local` or `s3`. Only local storage is currently implemented. |
| `DB_PATH` | `./data/stirling.db` | Path to the SQLite database file. |
| `WORKSPACE_PATH` | `./tmp/workspace` | Directory for temporary files during processing. Cleaned up automatically. |
| `FILES_STORAGE_PATH` | `./data/files` | Directory for persistent user files (uploaded images, saved results). |
### Processing limits
@@ -76,5 +77,5 @@ services:
The Docker container uses two volumes:
- `/data` -- Persistent storage for the SQLite database. Mount this to keep users, API keys, and saved pipelines across container restarts.
- `/data` -- Persistent storage for the SQLite database and user files. Mount this to keep users, API keys, saved pipelines, and uploaded images across container restarts.
- `/tmp/workspace` -- Temporary storage for images being processed. This can be ephemeral, but mounting it avoids filling up the container's writable layer.
+10 -9
View File
@@ -61,18 +61,19 @@ Start the dev server:
pnpm dev
```
This starts both the API server and the React frontend. The app opens at `http://localhost:5173` by default during development.
This starts both the API server and the React frontend. Open `http://localhost:1349` in your browser.
## What you can do
Once logged in, the sidebar lists every available tool. Pick one, upload an image, adjust the settings, and download the result.
The sidebar lists every tool. Pick one, upload an image, tweak the settings, download the result.
A few things to try first:
Some things to try first:
- **Resize** an image to specific dimensions or a percentage
- **Remove a background** using the AI-powered background removal tool
- **Compress** a photo to reduce file size before uploading it somewhere
- **Convert** between formats (JPEG, PNG, WebP, AVIF, TIFF)
- **Batch process** a folder of images through any tool
- Resize an image to specific dimensions or a percentage
- Remove a background with the AI tool
- Compress a photo before uploading it somewhere
- Convert between formats (JPEG, PNG, WebP, AVIF, TIFF)
- Batch process a folder of images through any tool
- Save results to the Files page for later
Every tool in the UI is also available through the [REST API](../api/rest), so you can script your workflows or integrate Stirling Image into other systems.
Every tool is also available through the [REST API](../api/rest), so you can script workflows or plug Stirling Image into other systems.
+1
View File
@@ -36,6 +36,7 @@ class ErrorBoundary extends Component<
{this.state.error?.message || "An unexpected error occurred."}
</p>
<button
type="button"
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
@@ -84,12 +84,22 @@ export function BeforeAfterSlider({
{/* Slider container */}
<div
ref={containerRef}
role="slider"
aria-label="Before/after comparison slider"
aria-valuenow={Math.round(position)}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none"
style={{ cursor: isDragging ? "ew-resize" : "default" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1));
else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1));
}}
>
{/* Before image (full width, bottom layer) */}
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
@@ -116,7 +126,14 @@ export function BeforeAfterSlider({
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-primary">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
aria-hidden="true"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
+11 -7
View File
@@ -46,14 +46,14 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
const hasMultipleFiles = currentFiles.length > 1;
return (
<div
<section
aria-label="File drop zone"
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
className={cn(
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors cursor-pointer min-h-[400px] mx-auto max-w-2xl w-full",
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors min-h-[400px] mx-auto max-w-2xl w-full",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
@@ -63,7 +63,11 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
<div className="text-3xl font-bold text-muted-foreground/30">
Stirling <span className="text-primary/30">Image</span>
</div>
<button className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium">
<button
type="button"
onClick={handleClick}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium"
>
<Upload className="h-4 w-4" />
Upload from computer
</button>
@@ -77,9 +81,9 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
{currentFiles.length} files selected
</span>
<div className="max-h-32 overflow-y-auto w-full max-w-xs">
{currentFiles.map((f, i) => (
{currentFiles.map((f) => (
<div
key={i}
key={f.name}
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
>
<span className="truncate">{f.name}</span>
@@ -90,6 +94,6 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
</div>
)}
</div>
</div>
</section>
);
}
@@ -102,6 +102,7 @@ export function ImageViewer({
{/* Toolbar */}
<div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0">
<button
type="button"
onClick={zoomOut}
disabled={zoom <= ZOOM_STEPS[0]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
@@ -113,6 +114,7 @@ export function ImageViewer({
{fitMode === "fit" ? "Fit" : `${zoom}%`}
</span>
<button
type="button"
onClick={zoomIn}
disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
@@ -122,6 +124,7 @@ export function ImageViewer({
</button>
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={fitToContainer}
className={`px-2 py-1 rounded text-xs ${fitMode === "fit" ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Fit to view"
@@ -129,6 +132,7 @@ export function ImageViewer({
<Maximize className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={actualSize}
className={`px-2 py-1 rounded text-xs ${fitMode === "actual" && zoom === 100 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Actual size (100%)"
@@ -7,12 +7,6 @@ import { useFileStore } from "@/stores/file-store";
export function MultiImageViewer() {
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
const currentEntry = entries[selectedIndex];
if (!currentEntry) return null;
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -27,10 +21,18 @@ export function MultiImageViewer() {
[navigateNext, navigatePrev],
);
const currentEntry = entries[selectedIndex];
if (!currentEntry) return null;
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const hasProcessed = !!currentEntry.processedUrl;
return (
<div
<section
aria-label="Image viewer"
className="flex flex-col w-full h-full min-h-0"
onKeyDown={hasMultiple ? handleKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -38,6 +40,7 @@ export function MultiImageViewer() {
<div className="flex-1 relative flex items-center justify-center min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -49,7 +52,7 @@ export function MultiImageViewer() {
{hasProcessed ? (
<BeforeAfterSlider
beforeSrc={currentEntry.blobUrl}
afterSrc={currentEntry.processedUrl!}
afterSrc={currentEntry.processedUrl ?? ""}
beforeSize={currentEntry.originalSize}
afterSize={currentEntry.processedSize ?? undefined}
/>
@@ -63,6 +66,7 @@ export function MultiImageViewer() {
</div>
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -77,6 +81,6 @@ export function MultiImageViewer() {
)}
</div>
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
</div>
</section>
);
}
@@ -54,6 +54,7 @@ export function ReviewPanel({
{/* Review header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground"
>
@@ -85,6 +86,7 @@ export function ReviewPanel({
{/* Action buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={onUndo}
className="flex-1 py-2 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted flex items-center justify-center gap-1.5 text-xs font-medium"
>
@@ -92,6 +94,7 @@ export function ReviewPanel({
Undo
</button>
<button
type="button"
onClick={handleDownload}
className="flex-1 py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
@@ -105,6 +108,7 @@ export function ReviewPanel({
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
type="button"
onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)}
className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground"
>
@@ -129,6 +133,7 @@ export function ReviewPanel({
return (
<button
key={tool.id}
type="button"
onClick={() => navigate(tool.route)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-muted group"
>
@@ -32,7 +32,8 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
const isFailed = entry.status === "failed";
return (
<button
key={`${entry.file.name}-${i}`}
key={entry.file.name}
type="button"
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect(i)}
className={`relative shrink-0 rounded overflow-hidden transition-all ${
@@ -15,6 +15,7 @@ export function ToolCard({ tool }: ToolCardProps) {
return (
<div className="group flex items-center gap-3 relative">
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
title="Add to favourites"
>
+32 -11
View File
@@ -48,19 +48,40 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
async function handleOpenFile() {
if (!details) return;
const res = await fetch(getFileDownloadUrl(details.id), {
headers: { Authorization: `Bearer ${localStorage.getItem("stirling-token") || ""}` },
});
if (!res.ok) return;
const blob = await res.blob();
const file = new File([blob], details.originalName, { type: details.mimeType });
setFiles([file]);
const { checkedIds, files: allFiles } = useFilesPageStore.getState();
// If multiple files are checked, open all of them; otherwise just the selected one
const filesToOpen =
checkedIds.size > 1
? allFiles.filter((f) => checkedIds.has(f.id))
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
const token = localStorage.getItem("stirling-token") || "";
const downloaded = await Promise.all(
filesToOpen.map(async (f) => {
const res = await fetch(getFileDownloadUrl(f.id), {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const blob = await res.blob();
return { file: new File([blob], f.originalName, { type: f.mimeType }), serverId: f.id };
}),
);
const valid = downloaded.filter((d): d is NonNullable<typeof d> => d !== null);
if (valid.length === 0) return;
setFiles(valid.map((d) => d.file));
navigate("/");
// Set serverFileId so tool processing creates a new version
// Set serverFileId on each entry so tool processing creates new versions
setTimeout(() => {
const entries = useFileStore.getState().entries;
if (entries.length > 0) {
useFileStore.getState().updateEntry(0, { serverFileId: details.id });
const store = useFileStore.getState();
for (let i = 0; i < valid.length; i++) {
if (store.entries[i]) {
store.updateEntry(i, { serverFileId: valid[i].serverId });
}
}
}, 0);
}
+6 -1
View File
@@ -36,13 +36,18 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold text-foreground">Help</h2>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
+17 -14
View File
@@ -44,7 +44,8 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{isMobile && mobileSidebarOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
onClick={() => setMobileSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
@@ -61,25 +62,25 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
</span>
)}
<button
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="p-1.5 rounded-lg hover:bg-muted"
>
<X className="h-4 w-4" />
</button>
</div>
<div onClick={() => setMobileSidebarOpen(false)}>
<Sidebar
onSettingsClick={() => {
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
expanded
/>
</div>
<Sidebar
onSettingsClick={() => {
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
onNavClick={() => setMobileSidebarOpen(false)}
expanded
/>
</div>
</>
)}
@@ -88,6 +89,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{isMobile && (
<div className="fixed top-0 left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3">
<button
type="button"
onClick={() => setMobileSidebarOpen(true)}
className="p-1.5 rounded-lg hover:bg-muted"
>
@@ -129,6 +131,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
<MobileNavItem icon={Workflow} label="Automate" href="/automate" />
<MobileNavItem icon={FolderOpen} label="Files" href="/files" />
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground"
>
@@ -7,6 +7,7 @@ export function Footer() {
return (
<div className="fixed bottom-4 right-4 flex items-center gap-2 z-50">
<button
type="button"
onClick={toggleTheme}
className="p-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors"
title="Toggle Theme"
@@ -14,6 +15,7 @@ export function Footer() {
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors text-sm"
title="Language"
>
+11 -4
View File
@@ -24,11 +24,18 @@ const bottomItems: SidebarItem[] = [
interface SidebarProps {
onSettingsClick: () => void;
onHelpClick: () => void;
/** Called when a nav link is clicked (e.g., to close mobile sidebar). */
onNavClick?: () => void;
/** When true, renders in expanded mode (for mobile overlay). */
expanded?: boolean;
}
export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: SidebarProps) {
export function Sidebar({
onSettingsClick,
onHelpClick,
onNavClick,
expanded = false,
}: SidebarProps) {
const location = useLocation();
const renderItem = (item: SidebarItem, isActive: boolean) => {
@@ -60,20 +67,20 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
if (item.label === "Settings") {
return (
<button key={item.label} onClick={onSettingsClick} className="w-full">
<button key={item.label} type="button" onClick={onSettingsClick} className="w-full">
{content}
</button>
);
}
if (item.label === "Help") {
return (
<button key={item.label} onClick={onHelpClick} className="w-full">
<button key={item.label} type="button" onClick={onHelpClick} className="w-full">
{content}
</button>
);
}
return (
<Link key={item.label} to={item.href || "/"}>
<Link key={item.label} to={item.href || "/"} onClick={onNavClick}>
{content}
</Link>
);
@@ -76,7 +76,11 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85vh] flex overflow-hidden">
@@ -88,6 +92,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSection(item.id)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors",
@@ -105,6 +110,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<button
type="button"
onClick={onClose}
className="absolute top-3 right-3 p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
@@ -207,6 +213,7 @@ function GeneralSection() {
</div>
</div>
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
@@ -336,9 +343,13 @@ function SystemSection() {
alt="Logo"
/>
)}
<label className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors">
<label
htmlFor="system-logo-upload"
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors"
>
Upload
<input
id="system-logo-upload"
type="file"
accept="image/png,image/jpeg,image/svg+xml"
className="hidden"
@@ -346,7 +357,11 @@ function SystemSection() {
/>
</label>
{settings.customLogo === "true" && (
<button onClick={handleLogoDelete} className="text-sm text-destructive hover:underline">
<button
type="button"
onClick={handleLogoDelete}
className="text-sm text-destructive hover:underline"
>
Remove
</button>
)}
@@ -409,6 +424,7 @@ function SystemSection() {
description="Show tools that are still in development. These may be unstable."
>
<button
type="button"
onClick={() =>
updateSetting(
"enableExperimentalTools",
@@ -449,6 +465,7 @@ function SystemSection() {
description="Clean up old temporary files when the server starts"
>
<button
type="button"
onClick={() =>
updateSetting("startupCleanup", settings.startupCleanup === "false" ? "true" : "false")
}
@@ -468,6 +485,7 @@ function SystemSection() {
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -830,6 +848,7 @@ function PeopleSection() {
/>
</div>
<button
type="button"
onClick={() => {
setShowAddForm(!showAddForm);
setAddError(null);
@@ -1051,6 +1070,7 @@ function PeopleSection() {
{/* Actions */}
<div className="flex items-center gap-1 justify-end relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === u.id ? null : u.id);
@@ -1064,10 +1084,11 @@ function PeopleSection() {
{/* Dropdown menu */}
{openMenuId === u.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-44 rounded-lg border border-border bg-background shadow-lg py-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => {
setEditingUser(u);
setEditRole(u.role);
@@ -1080,6 +1101,7 @@ function PeopleSection() {
Edit Role / Team
</button>
<button
type="button"
onClick={() => {
setResetPasswordUser(u);
setResetPassword("");
@@ -1092,6 +1114,7 @@ function PeopleSection() {
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDeleteUser(u.id, u.username)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -1198,6 +1221,7 @@ function ApiKeysSection() {
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48"
/>
<button
type="button"
onClick={generateKey}
disabled={generating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -1215,6 +1239,7 @@ function ApiKeysSection() {
{newKey}
</code>
<button
type="button"
onClick={() => copyKey(newKey)}
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground shrink-0"
title="Copy"
@@ -1244,6 +1269,7 @@ function ApiKeysSection() {
</p>
</div>
<button
type="button"
onClick={() => deleteKey(k.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete key"
@@ -1397,6 +1423,7 @@ function TeamsSection() {
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
@@ -1469,12 +1496,14 @@ function TeamsSection() {
}}
/>
<button
type="button"
onClick={() => handleRename(t.id)}
className="text-xs text-primary hover:underline"
>
Save
</button>
<button
type="button"
onClick={() => setEditingTeamId(null)}
className="text-xs text-muted-foreground hover:underline"
>
@@ -1488,6 +1517,7 @@ function TeamsSection() {
<span className="text-sm text-muted-foreground">{t.memberCount}</span>
<div className="flex items-center gap-1 justify-end relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === t.id ? null : t.id);
@@ -1498,10 +1528,11 @@ function TeamsSection() {
</button>
{openMenuId === t.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-36 rounded-lg border border-border bg-background shadow-lg py-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => {
setEditingTeamId(t.id);
setEditingTeamName(t.name);
@@ -1514,6 +1545,7 @@ function TeamsSection() {
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDelete(t.id, t.name)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -1640,6 +1672,7 @@ function ToolsSection() {
<p className="text-xs text-muted-foreground truncate">{tool.description}</p>
</div>
<button
type="button"
onClick={() => toggleTool(tool.id)}
className={cn(
"w-11 h-6 rounded-full transition-colors relative shrink-0 ml-3",
@@ -1669,6 +1702,7 @@ function ToolsSection() {
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -62,6 +62,7 @@ export function BarcodeReadSettings() {
</p>
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -79,6 +80,7 @@ export function BarcodeReadSettings() {
<p className="text-xs text-muted-foreground">Decoded Text:</p>
<p className="text-sm text-foreground font-mono break-all">{result.text}</p>
<button
type="button"
onClick={copyText}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
>
@@ -26,10 +26,13 @@ export function BlurFacesSettings() {
{/* Blur radius */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Blur Radius</label>
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
Blur Radius
</label>
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
</div>
<input
id="blur-faces-blur-radius"
type="range"
min={5}
max={80}
@@ -46,10 +49,13 @@ export function BlurFacesSettings() {
{/* Sensitivity */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Detection Sensitivity</label>
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
Detection Sensitivity
</label>
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
</div>
<input
id="blur-faces-sensitivity"
type="range"
min={10}
max={90}
@@ -91,6 +97,7 @@ export function BlurFacesSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -38,10 +38,13 @@ export function BorderSettings() {
<div className="space-y-4">
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Border Width</label>
<label htmlFor="border-border-width" className="text-xs text-muted-foreground">
Border Width
</label>
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
</div>
<input
id="border-border-width"
type="range"
min={0}
max={100}
@@ -52,8 +55,11 @@ export function BorderSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Border Color</label>
<label htmlFor="border-border-color" className="text-xs text-muted-foreground">
Border Color
</label>
<input
id="border-border-color"
type="color"
value={borderColor}
onChange={(e) => setBorderColor(e.target.value)}
@@ -63,10 +69,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Corner Radius</label>
<label htmlFor="border-corner-radius" className="text-xs text-muted-foreground">
Corner Radius
</label>
<span className="text-xs font-mono text-foreground">{cornerRadius}px</span>
</div>
<input
id="border-corner-radius"
type="range"
min={0}
max={200}
@@ -78,10 +87,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Padding</label>
<label htmlFor="border-padding" className="text-xs text-muted-foreground">
Padding
</label>
<span className="text-xs font-mono text-foreground">{padding}px</span>
</div>
<input
id="border-padding"
type="range"
min={0}
max={100}
@@ -93,10 +105,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Shadow</label>
<label htmlFor="border-shadow" className="text-xs text-muted-foreground">
Shadow
</label>
<span className="text-xs font-mono text-foreground">{shadowBlur}px</span>
</div>
<input
id="border-shadow"
type="range"
min={0}
max={50}
@@ -126,6 +141,7 @@ export function BorderSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -72,8 +72,11 @@ export function BulkRenameSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Pattern</label>
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
Pattern
</label>
<input
id="bulk-rename-pattern"
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
@@ -85,8 +88,11 @@ export function BulkRenameSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Start Index</label>
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
Start Index
</label>
<input
id="bulk-rename-start-index"
type="number"
value={startIndex}
onChange={(e) => setStartIndex(Number(e.target.value))}
@@ -97,11 +103,11 @@ export function BulkRenameSettings() {
{previewNames.length > 0 && (
<div>
<label className="text-xs text-muted-foreground">Preview</label>
<p className="text-xs text-muted-foreground">Preview</p>
<div className="mt-1 space-y-0.5">
{previewNames.map((name, i) => (
{previewNames.map((name) => (
<div
key={i}
key={name}
className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate"
>
{name}
@@ -117,6 +123,7 @@ export function BulkRenameSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing || !pattern}
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"
@@ -71,10 +71,11 @@ export function CollageSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Layout</label>
<p className="text-xs text-muted-foreground">Layout</p>
<div className="grid grid-cols-3 gap-1 mt-1">
{LAYOUTS.map((l) => (
<button
type="button"
key={l.value}
onClick={() => setLayout(l.value)}
className={`text-xs py-1.5 rounded ${layout === l.value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
@@ -87,10 +88,13 @@ export function CollageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Gap</label>
<label htmlFor="collage-gap" className="text-xs text-muted-foreground">
Gap
</label>
<span className="text-xs font-mono text-foreground">{gap}px</span>
</div>
<input
id="collage-gap"
type="range"
min={0}
max={50}
@@ -101,8 +105,11 @@ export function CollageSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<label htmlFor="collage-background-color" className="text-xs text-muted-foreground">
Background Color
</label>
<input
id="collage-background-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -120,6 +127,7 @@ export function CollageSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || 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"
@@ -57,6 +57,7 @@ export function ColorPaletteSettings() {
return (
<div className="space-y-4">
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -69,13 +70,14 @@ export function ColorPaletteSettings() {
{colors.length > 0 && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<p className="text-xs font-medium text-muted-foreground">
Dominant Colors ({colors.length})
</label>
</p>
<div className="grid grid-cols-2 gap-1.5">
{colors.map((color, i) => (
<button
key={i}
type="button"
key={color}
onClick={() => copyColor(color, i)}
className="flex items-center gap-2 p-1.5 rounded border border-border hover:bg-muted transition-colors"
>
@@ -160,7 +160,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
{/* Effects */}
{tab === "effects" && (
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Color Effect</label>
<p className="text-xs text-muted-foreground">Color Effect</p>
<div className="grid grid-cols-2 gap-1">
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
<button
@@ -261,13 +261,17 @@ function SliderControl({
max: number;
color?: string;
}) {
const id = `color-slider-${label.toLowerCase()}`;
return (
<div>
<div className="flex justify-between items-center">
<label className={`text-xs ${color || "text-muted-foreground"}`}>{label}</label>
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
</label>
<span className="text-xs font-mono text-foreground">{value}</span>
</div>
<input
id={id}
type="range"
min={min}
max={max}
@@ -53,8 +53,11 @@ export function CompareSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Second Image</label>
<label htmlFor="compare-second-image" className="text-xs text-muted-foreground">
Second Image
</label>
<input
id="compare-second-image"
ref={secondInputRef}
type="file"
accept="image/*"
@@ -62,6 +65,7 @@ export function CompareSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => secondInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -84,6 +88,7 @@ export function CompareSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !secondFile || 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"
@@ -62,8 +62,11 @@ export function ComposeSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Overlay Image</label>
<label htmlFor="compose-overlay-image" className="text-xs text-muted-foreground">
Overlay Image
</label>
<input
id="compose-overlay-image"
ref={overlayInputRef}
type="file"
accept="image/*"
@@ -71,6 +74,7 @@ export function ComposeSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => overlayInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -81,8 +85,11 @@ export function ComposeSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">X Position</label>
<label htmlFor="compose-x-position" className="text-xs text-muted-foreground">
X Position
</label>
<input
id="compose-x-position"
type="number"
value={x}
onChange={(e) => setX(Number(e.target.value))}
@@ -91,8 +98,11 @@ export function ComposeSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Y Position</label>
<label htmlFor="compose-y-position" className="text-xs text-muted-foreground">
Y Position
</label>
<input
id="compose-y-position"
type="number"
value={y}
onChange={(e) => setY(Number(e.target.value))}
@@ -104,10 +114,13 @@ export function ComposeSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="compose-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="compose-opacity"
type="range"
min={0}
max={100}
@@ -118,8 +131,11 @@ export function ComposeSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Blend Mode</label>
<label htmlFor="compose-blend-mode" className="text-xs text-muted-foreground">
Blend Mode
</label>
<select
id="compose-blend-mode"
value={blendMode}
onChange={(e) => setBlendMode(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -147,6 +163,7 @@ export function ComposeSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !overlayFile || 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"
@@ -49,7 +49,7 @@ export function CompressSettings() {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Mode toggle */}
<div>
<label className="text-sm font-medium text-muted-foreground">Compression Mode</label>
<p className="text-sm font-medium text-muted-foreground">Compression Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
@@ -71,10 +71,13 @@ export function CompressSettings() {
{mode === "quality" ? (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<label htmlFor="compress-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="compress-quality"
type="range"
min={1}
max={100}
@@ -89,8 +92,11 @@ export function CompressSettings() {
</div>
) : (
<div>
<label className="text-xs text-muted-foreground">Target Size (KB)</label>
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
Target Size (KB)
</label>
<input
id="compress-target-size"
type="number"
value={targetSizeKb}
onChange={(e) => setTargetSizeKb(e.target.value)}
@@ -55,7 +55,7 @@ export function ConvertSettings() {
{/* Source format */}
{hasFile && (
<div>
<label className="text-xs text-muted-foreground">Source Format</label>
<p className="text-xs text-muted-foreground">Source Format</p>
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
{sourceExt}
</div>
@@ -64,8 +64,11 @@ export function ConvertSettings() {
{/* Target format */}
<div>
<label className="text-xs text-muted-foreground">Target Format</label>
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
Target Format
</label>
<select
id="convert-target-format"
value={format}
onChange={(e) => setFormat(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -82,10 +85,13 @@ export function ConvertSettings() {
{isLossy && (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<label htmlFor="convert-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="convert-quality"
type="range"
min={1}
max={100}
@@ -165,7 +165,7 @@ export function CropSettings({
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
{aspect !== undefined && (
<button
type="button"
@@ -197,13 +197,14 @@ export function CropSettings({
{/* Position & Size */}
<div>
<label className="text-xs text-muted-foreground">Position & Size</label>
<p className="text-xs text-muted-foreground">Position & Size</p>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-x"
type="number"
value={pixels.left}
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
@@ -213,10 +214,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-y" className="text-[10px] text-muted-foreground">
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-y"
type="number"
value={pixels.top}
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
@@ -226,10 +228,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-width" className="text-[10px] text-muted-foreground">
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-width"
type="number"
value={pixels.width}
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
@@ -239,10 +242,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-height" className="text-[10px] text-muted-foreground">
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-height"
type="number"
value={pixels.height}
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
@@ -114,17 +114,28 @@ export function EraseObjectSettings() {
<div className="space-y-4">
{/* Mask upload */}
<div>
<label className="text-sm font-medium text-muted-foreground">Mask Image</label>
<label htmlFor="erase-object-mask" className="text-sm font-medium text-muted-foreground">
Mask Image
</label>
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
Upload a black &amp; white mask where white areas will be erased. Create the mask in any
image editor.
</p>
<label className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary">
<label
htmlFor="erase-object-mask"
className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary"
>
<Upload className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{maskFile ? maskFile.name : "Select mask image..."}
</span>
<input type="file" accept="image/*" onChange={handleMaskSelect} className="hidden" />
<input
id="erase-object-mask"
type="file"
accept="image/*"
onChange={handleMaskSelect}
className="hidden"
/>
</label>
</div>
@@ -162,6 +173,7 @@ export function EraseObjectSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !maskFile || 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"
@@ -67,7 +67,7 @@ export function FaviconSettings() {
</p>
<div>
<label className="text-xs font-medium text-muted-foreground">Generated Sizes</label>
<p className="text-xs font-medium text-muted-foreground">Generated Sizes</p>
<div className="mt-1 space-y-0.5">
{SIZES.map((s) => (
<div key={s.name} className="flex justify-between text-xs text-foreground">
@@ -82,6 +82,7 @@ export function FaviconSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -62,6 +62,7 @@ export function FindDuplicatesSettings() {
</p>
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || 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"
@@ -84,10 +85,13 @@ export function FindDuplicatesSettings() {
<p className="text-xs text-muted-foreground">No duplicates found.</p>
) : (
result.duplicateGroups.map((group, gi) => (
<div key={gi} className="p-2 rounded border border-border space-y-1">
<div
key={group.files.map((f) => f.filename).join(",")}
className="p-2 rounded border border-border space-y-1"
>
<p className="text-xs font-medium text-foreground">Group {gi + 1}</p>
{group.files.map((f, fi) => (
<div key={fi} className="flex justify-between text-xs">
{group.files.map((f) => (
<div key={f.filename} className="flex justify-between text-xs">
<span className="text-foreground truncate">{f.filename}</span>
<span className="text-muted-foreground shrink-0 ml-2">{f.similarity}%</span>
</div>
@@ -32,15 +32,17 @@ export function GifToolsSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Mode</label>
<p className="text-xs text-muted-foreground">Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setMode("resize")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "resize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Resize
</button>
<button
type="button"
onClick={() => setMode("extract")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "extract" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -53,8 +55,11 @@ export function GifToolsSettings() {
<>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="gif-tools-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="gif-tools-width"
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
@@ -63,8 +68,11 @@ export function GifToolsSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="gif-tools-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="gif-tools-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -74,8 +82,12 @@ export function GifToolsSettings() {
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<label
htmlFor="gif-tools-optimize"
className="flex items-center gap-2 text-sm text-foreground"
>
<input
id="gif-tools-optimize"
type="checkbox"
checked={optimize}
onChange={(e) => setOptimize(e.target.checked)}
@@ -86,8 +98,11 @@ export function GifToolsSettings() {
</>
) : (
<div>
<label className="text-xs text-muted-foreground">Frame Number</label>
<label htmlFor="gif-tools-frame" className="text-xs text-muted-foreground">
Frame Number
</label>
<input
id="gif-tools-frame"
type="number"
value={extractFrame}
onChange={(e) => setExtractFrame(e.target.value)}
@@ -118,6 +133,7 @@ export function GifToolsSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -57,8 +57,11 @@ export function ImageToPdfSettings() {
</p>
<div>
<label className="text-xs text-muted-foreground">Page Size</label>
<label htmlFor="image-to-pdf-page-size" className="text-xs text-muted-foreground">
Page Size
</label>
<select
id="image-to-pdf-page-size"
value={pageSize}
onChange={(e) => setPageSize(e.target.value as typeof pageSize)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -71,15 +74,17 @@ export function ImageToPdfSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Orientation</label>
<p className="text-xs text-muted-foreground">Orientation</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setOrientation("portrait")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "portrait" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Portrait
</button>
<button
type="button"
onClick={() => setOrientation("landscape")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "landscape" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -90,10 +95,13 @@ export function ImageToPdfSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Margin</label>
<label htmlFor="image-to-pdf-margin" className="text-xs text-muted-foreground">
Margin
</label>
<span className="text-xs font-mono text-foreground">{margin}pt</span>
</div>
<input
id="image-to-pdf-margin"
type="range"
min={0}
max={100}
@@ -106,6 +114,7 @@ export function ImageToPdfSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || 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"
@@ -79,6 +79,7 @@ export function InfoSettings() {
return (
<div className="space-y-4">
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -122,7 +123,7 @@ export function InfoSettings() {
{/* Histogram */}
<div>
<label className="text-xs font-medium text-muted-foreground">Channel Stats</label>
<p className="text-xs font-medium text-muted-foreground">Channel Stats</p>
<div className="mt-1 space-y-1.5">
{info.histogram.map((ch) => (
<div key={ch.channel} className="space-y-0.5">
+11 -3
View File
@@ -129,9 +129,10 @@ export function OcrSettings() {
<div className="space-y-4">
{/* Engine selector */}
<div>
<label className="text-sm font-medium text-muted-foreground">OCR Engine</label>
<p className="text-sm font-medium text-muted-foreground">OCR Engine</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setEngine("tesseract")}
className={`flex-1 text-xs py-1.5 rounded ${
engine === "tesseract"
@@ -142,6 +143,7 @@ export function OcrSettings() {
Tesseract
</button>
<button
type="button"
onClick={() => setEngine("paddleocr")}
className={`flex-1 text-xs py-1.5 rounded ${
engine === "paddleocr"
@@ -156,8 +158,11 @@ export function OcrSettings() {
{/* Language selector */}
<div>
<label className="text-xs text-muted-foreground">Language</label>
<label htmlFor="ocr-language" className="text-xs text-muted-foreground">
Language
</label>
<select
id="ocr-language"
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -185,6 +190,7 @@ export function OcrSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -197,10 +203,11 @@ export function OcrSettings() {
{text !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor="ocr-result-text" className="text-xs font-medium text-muted-foreground">
Extracted Text ({detectedEngine})
</label>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
@@ -209,6 +216,7 @@ export function OcrSettings() {
</button>
</div>
<textarea
id="ocr-result-text"
readOnly
value={text}
rows={8}
@@ -158,7 +158,8 @@ export function PipelineBuilder({
return (
<div className="space-y-6">
{/* File Upload Area */}
<div
<section
aria-label="File upload area"
onDragOver={(e) => e.preventDefault()}
onDrop={handleFileDrop}
className={cn(
@@ -178,6 +179,7 @@ export function PipelineBuilder({
</span>
</div>
<button
type="button"
onClick={() => setFile(null)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
@@ -186,6 +188,7 @@ export function PipelineBuilder({
</div>
) : (
<button
type="button"
onClick={handleFileSelect}
className="flex items-center gap-2 mx-auto px-4 py-2 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm"
>
@@ -193,7 +196,7 @@ export function PipelineBuilder({
Upload image to process
</button>
)}
</div>
</section>
{/* Pipeline Steps */}
<div className="space-y-2">
@@ -226,6 +229,7 @@ export function PipelineBuilder({
{/* Controls */}
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
title="Settings"
@@ -235,6 +239,7 @@ export function PipelineBuilder({
/>
</button>
<button
type="button"
onClick={() => moveStep(step.id, "up")}
disabled={idx === 0}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
@@ -243,6 +248,7 @@ export function PipelineBuilder({
<ChevronUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(step.id, "down")}
disabled={idx === steps.length - 1}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
@@ -251,6 +257,7 @@ export function PipelineBuilder({
<ChevronDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(step.id)}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Remove"
@@ -283,6 +290,7 @@ export function PipelineBuilder({
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Add a step</span>
<button
type="button"
onClick={() => setShowToolPicker(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
@@ -294,6 +302,7 @@ export function PipelineBuilder({
return (
<button
key={tool.id}
type="button"
onClick={() => addStep(tool.id)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
>
@@ -308,6 +317,7 @@ export function PipelineBuilder({
</div>
) : (
<button
type="button"
onClick={() => setShowToolPicker(true)}
className="flex items-center gap-2 w-full justify-center px-4 py-2.5 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
@@ -343,6 +353,7 @@ export function PipelineBuilder({
{/* Action Buttons */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleExecute}
disabled={steps.length === 0 || !file || executing}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -362,6 +373,7 @@ export function PipelineBuilder({
{!showSaveForm ? (
<button
type="button"
onClick={() => setShowSaveForm(true)}
disabled={steps.length === 0}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -386,6 +398,7 @@ export function PipelineBuilder({
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1 hidden sm:block"
/>
<button
type="button"
onClick={handleSave}
disabled={!saveName.trim() || saving}
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
@@ -393,6 +406,7 @@ export function PipelineBuilder({
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={() => setShowSaveForm(false)}
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
>
@@ -526,8 +526,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "number":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<input
id={`pipeline-${field.key}`}
type="number"
value={value != null && value !== "" ? Number(value) : ""}
onChange={(e) =>
@@ -548,8 +551,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "text":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<input
id={`pipeline-${field.key}`}
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
@@ -560,12 +566,12 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
);
case "select": {
const opts = field.options!;
const opts = field.options ?? [];
// Use button group for <= 4 options, dropdown for more
if (opts.length <= 4) {
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<p className="text-xs text-muted-foreground">{field.label}</p>
<div className="flex gap-1 mt-0.5">
{opts.map((opt) => (
<button
@@ -587,8 +593,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
}
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<select
id={`pipeline-${field.key}`}
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -623,15 +632,22 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "color":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label
htmlFor={`pipeline-${field.key}-text`}
className="text-xs text-muted-foreground"
>
{field.label}
</label>
<div className="flex gap-2 mt-0.5">
<input
id={`pipeline-${field.key}-picker`}
type="color"
value={String(value || "#000000").slice(0, 7)}
onChange={(e) => updateField(field.key, e.target.value)}
className="h-8 w-8 rounded border border-border cursor-pointer bg-background"
/>
<input
id={`pipeline-${field.key}-text`}
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
@@ -52,8 +52,11 @@ export function QrGenerateSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Text / URL</label>
<label htmlFor="qr-text" className="text-xs text-muted-foreground">
Text / URL
</label>
<textarea
id="qr-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text or URL..."
@@ -64,10 +67,13 @@ export function QrGenerateSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Size</label>
<label htmlFor="qr-size" className="text-xs text-muted-foreground">
Size
</label>
<span className="text-xs font-mono text-foreground">{size}px</span>
</div>
<input
id="qr-size"
type="range"
min={100}
max={2000}
@@ -79,8 +85,11 @@ export function QrGenerateSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Error Correction</label>
<label htmlFor="qr-error-correction" className="text-xs text-muted-foreground">
Error Correction
</label>
<select
id="qr-error-correction"
value={errorCorrection}
onChange={(e) => setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -94,8 +103,11 @@ export function QrGenerateSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Foreground</label>
<label htmlFor="qr-foreground" className="text-xs text-muted-foreground">
Foreground
</label>
<input
id="qr-foreground"
type="color"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
@@ -103,8 +115,11 @@ export function QrGenerateSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Background</label>
<label htmlFor="qr-background" className="text-xs text-muted-foreground">
Background
</label>
<input
id="qr-background"
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
@@ -116,6 +131,7 @@ export function QrGenerateSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleGenerate}
disabled={!text || 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"
@@ -133,7 +149,7 @@ export function QrGenerateSettings() {
style={{ maxHeight: 200 }}
/>
<a
href={downloadUrl!}
href={downloadUrl ?? undefined}
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"
>
@@ -65,13 +65,14 @@ export function RemoveBgSettings() {
<div className="space-y-4">
{/* Subject type */}
<div>
<label className="text-sm font-medium text-muted-foreground">What's in the photo?</label>
<p className="text-sm font-medium text-muted-foreground">What's in the photo?</p>
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
{SUBJECT_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
onClick={() => {
setSubject(opt.value);
if (opt.value !== "people") setIsPassport(false);
@@ -105,11 +106,12 @@ export function RemoveBgSettings() {
{/* Quality */}
<div>
<label className="text-sm font-medium text-muted-foreground">Quality</label>
<p className="text-sm font-medium text-muted-foreground">Quality</p>
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
quality === opt.value
@@ -126,11 +128,12 @@ export function RemoveBgSettings() {
{/* Background color - intuitive preset buttons */}
<div>
<label className="text-sm font-medium text-muted-foreground">Output Background</label>
<p className="text-sm font-medium text-muted-foreground">Output Background</p>
<div className="flex gap-1.5 mt-1.5 flex-wrap">
{BG_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setBgColor(preset.color)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
bgColor === preset.color
@@ -197,6 +200,7 @@ export function RemoveBgSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -36,9 +36,12 @@ export function ReplaceColorSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Source Color (to replace)</label>
<label htmlFor="replace-source-color" className="text-xs text-muted-foreground">
Source Color (to replace)
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="replace-source-color"
type="color"
value={sourceColor}
onChange={(e) => setSourceColor(e.target.value)}
@@ -60,9 +63,12 @@ export function ReplaceColorSettings() {
{!makeTransparent && (
<div>
<label className="text-xs text-muted-foreground">Target Color (replacement)</label>
<label htmlFor="replace-target-color" className="text-xs text-muted-foreground">
Target Color (replacement)
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="replace-target-color"
type="color"
value={targetColor}
onChange={(e) => setTargetColor(e.target.value)}
@@ -75,10 +81,13 @@ export function ReplaceColorSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Tolerance</label>
<label htmlFor="replace-tolerance" className="text-xs text-muted-foreground">
Tolerance
</label>
<span className="text-xs font-mono text-foreground">{tolerance}</span>
</div>
<input
id="replace-tolerance"
type="range"
min={0}
max={255}
@@ -112,6 +121,7 @@ export function ReplaceColorSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -144,8 +144,11 @@ export function ResizeSettings() {
<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>
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="resize-width"
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
@@ -162,8 +165,11 @@ export function ResizeSettings() {
{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>
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="resize-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -175,7 +181,7 @@ export function ResizeSettings() {
{/* Fit mode */}
<div>
<label className="text-xs text-muted-foreground">Fit Mode</label>
<p className="text-xs text-muted-foreground">Fit Mode</p>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
<button
@@ -207,8 +213,11 @@ export function ResizeSettings() {
{tab === "scale" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Scale (%)</label>
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
Scale (%)
</label>
<input
id="resize-scale"
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
@@ -83,7 +83,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate */}
<div>
<label className="text-xs text-muted-foreground">Rotate</label>
<p className="text-xs text-muted-foreground">Rotate</p>
<div className="flex items-center gap-2 mt-1">
<button
type="button"
@@ -112,13 +112,16 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{/* Straighten */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Straighten</label>
<label htmlFor="rotate-straighten" className="text-xs text-muted-foreground">
Straighten
</label>
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{straighten > 0 ? "+" : ""}
{straighten}°
</span>
</div>
<input
id="rotate-straighten"
type="range"
min={-45}
max={45}
@@ -136,7 +139,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{/* Flip buttons */}
<div>
<label className="text-xs text-muted-foreground">Flip</label>
<p className="text-xs text-muted-foreground">Flip</p>
<div className="flex gap-2 mt-1">
<button
type="button"
@@ -46,8 +46,11 @@ export function SmartCropSettings() {
<div className="space-y-4">
{/* Aspect ratio preset */}
<div>
<label className="text-sm font-medium text-muted-foreground">Target Aspect Ratio</label>
<label htmlFor="smart-crop-preset" className="text-sm font-medium text-muted-foreground">
Target Aspect Ratio
</label>
<select
id="smart-crop-preset"
value={preset}
onChange={(e) => handlePreset(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -63,8 +66,11 @@ export function SmartCropSettings() {
{/* Width / Height */}
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="smart-crop-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="smart-crop-width"
type="number"
value={width}
onChange={(e) => {
@@ -76,8 +82,11 @@ export function SmartCropSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="smart-crop-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="smart-crop-height"
type="number"
value={height}
onChange={(e) => {
@@ -119,6 +128,7 @@ export function SmartCropSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !canProcess || 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"
@@ -63,11 +63,12 @@ export function SplitSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Grid Presets</label>
<p className="text-xs text-muted-foreground">Grid Presets</p>
<div className="flex gap-1 mt-1 flex-wrap">
{presets.map((p) => (
<button
key={p.label}
type="button"
onClick={() => {
setColumns(p.c);
setRows(p.r);
@@ -82,8 +83,11 @@ export function SplitSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Columns</label>
<label htmlFor="split-columns" className="text-xs text-muted-foreground">
Columns
</label>
<input
id="split-columns"
type="number"
value={columns}
onChange={(e) => setColumns(Math.max(1, Number(e.target.value)))}
@@ -93,8 +97,11 @@ export function SplitSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Rows</label>
<label htmlFor="split-rows" className="text-xs text-muted-foreground">
Rows
</label>
<input
id="split-rows"
type="number"
value={rows}
onChange={(e) => setRows(Math.max(1, Number(e.target.value)))}
@@ -110,6 +117,7 @@ export function SplitSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -229,7 +229,7 @@ export function StripMetadataSettings() {
}
const data: MetadataResult = await res.json();
setMetadata(data);
setMetadataCache((prev) => new Map(prev).set(fileKey!, data));
if (fileKey) setMetadataCache((prev) => new Map(prev).set(fileKey, data));
} catch (err) {
if ((err as Error).name === "AbortError") return;
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
@@ -239,7 +239,7 @@ export function StripMetadataSettings() {
})();
return () => controller.abort();
}, [currentFile, fileKey, metadataCache.get]);
}, [currentFile, fileKey, metadataCache]);
const handleStripAllChange = (checked: boolean) => {
setStripAll(checked);
@@ -278,7 +278,7 @@ export function StripMetadataSettings() {
{/* Metadata Display */}
{hasFile && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
<p className="text-xs font-medium text-muted-foreground">Current Metadata</p>
{inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
@@ -307,13 +307,13 @@ export function StripMetadataSettings() {
</div>
)}
{hasExif && (
{hasExif && metadata.exif && (
<CollapsibleSection
title="EXIF"
badge={`${Object.keys(metadata.exif!).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
badge={`${Object.keys(metadata.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
defaultOpen
>
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
<MetadataGrid data={metadata.exif} labelMap={EXIF_LABELS} />
</CollapsibleSection>
)}
@@ -321,31 +321,31 @@ export function StripMetadataSettings() {
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
)}
{hasGps && (
{hasGps && metadata.gps && (
<CollapsibleSection
title="GPS"
warning
badge={`${Object.keys(metadata.gps!).filter((k) => !k.startsWith("_")).length} fields`}
badge={`${Object.keys(metadata.gps).filter((k) => !k.startsWith("_")).length} fields`}
>
<MetadataGrid data={metadata.gps!} />
<MetadataGrid data={metadata.gps} />
</CollapsibleSection>
)}
{hasIcc && (
{hasIcc && metadata.icc && (
<CollapsibleSection
title="ICC Profile"
badge={`${Object.keys(metadata.icc!).length} fields`}
badge={`${Object.keys(metadata.icc).length} fields`}
>
<MetadataGrid data={metadata.icc!} />
<MetadataGrid data={metadata.icc} />
</CollapsibleSection>
)}
{hasXmp && (
{hasXmp && metadata.xmp && (
<CollapsibleSection
title="XMP"
badge={`${Object.keys(metadata.xmp!).length} fields`}
badge={`${Object.keys(metadata.xmp).length} fields`}
>
<MetadataGrid data={metadata.xmp!} />
<MetadataGrid data={metadata.xmp} />
</CollapsibleSection>
)}
@@ -374,7 +374,7 @@ export function StripMetadataSettings() {
{/* Individual options */}
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Or select specific metadata:</label>
<p className="text-xs text-muted-foreground">Or select specific metadata:</p>
<label
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
@@ -389,7 +389,7 @@ export function StripMetadataSettings() {
Strip EXIF (camera info, date, exposure)
{hasExif && !stripAll && (
<span className="ml-auto text-[10px] text-muted-foreground">
{Object.keys(metadata?.exif!).filter((k) => !SKIP_KEYS.has(k)).length} fields
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
</span>
)}
</label>
@@ -67,8 +67,11 @@ export function SvgToRasterSettings() {
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="svg-raster-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="svg-raster-width"
type="number"
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
@@ -78,8 +81,11 @@ export function SvgToRasterSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="svg-raster-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="svg-raster-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -90,8 +96,11 @@ export function SvgToRasterSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Output Format</label>
<label htmlFor="svg-raster-format" className="text-xs text-muted-foreground">
Output Format
</label>
<select
id="svg-raster-format"
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value as "png" | "jpg" | "webp")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -115,8 +124,11 @@ export function SvgToRasterSettings() {
{!transparent && (
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<label htmlFor="svg-raster-bg-color" className="text-xs text-muted-foreground">
Background Color
</label>
<input
id="svg-raster-bg-color"
type="color"
value={backgroundColor.slice(0, 7)}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -135,6 +147,7 @@ export function SvgToRasterSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -39,8 +39,11 @@ export function TextOverlaySettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Text</label>
<label htmlFor="text-overlay-text" className="text-xs text-muted-foreground">
Text
</label>
<input
id="text-overlay-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
@@ -50,10 +53,13 @@ export function TextOverlaySettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Font Size</label>
<label htmlFor="text-overlay-font-size" className="text-xs text-muted-foreground">
Font Size
</label>
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
</div>
<input
id="text-overlay-font-size"
type="range"
min={8}
max={200}
@@ -64,8 +70,11 @@ export function TextOverlaySettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Text Color</label>
<label htmlFor="text-overlay-color" className="text-xs text-muted-foreground">
Text Color
</label>
<input
id="text-overlay-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
@@ -74,8 +83,11 @@ export function TextOverlaySettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="text-overlay-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="text-overlay-position"
value={position}
onChange={(e) => setPosition(e.target.value as "top" | "center" | "bottom")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -108,8 +120,11 @@ export function TextOverlaySettings() {
{backgroundBox && (
<div>
<label className="text-xs text-muted-foreground">Box Color</label>
<label htmlFor="text-overlay-box-color" className="text-xs text-muted-foreground">
Box Color
</label>
<input
id="text-overlay-box-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -138,6 +153,7 @@ export function TextOverlaySettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing || !text}
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"
@@ -21,11 +21,12 @@ export function UpscaleSettings() {
<div className="space-y-4">
{/* Scale factor */}
<div>
<label className="text-sm font-medium text-muted-foreground">Scale Factor</label>
<p className="text-sm font-medium text-muted-foreground">Scale Factor</p>
<div className="flex gap-1 mt-1">
{[2, 4].map((s) => (
<button
key={s}
type="button"
onClick={() => setScale(s)}
className={`flex-1 text-xs py-1.5 rounded ${
scale === s
@@ -68,6 +69,7 @@ export function UpscaleSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -58,15 +58,17 @@ export function VectorizeSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Color Mode</label>
<p className="text-xs text-muted-foreground">Color Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setColorMode("bw")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "bw" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Black & White
</button>
<button
type="button"
onClick={() => setColorMode("color")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -77,10 +79,13 @@ export function VectorizeSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Threshold</label>
<label htmlFor="vectorize-threshold" className="text-xs text-muted-foreground">
Threshold
</label>
<span className="text-xs font-mono text-foreground">{threshold}</span>
</div>
<input
id="vectorize-threshold"
type="range"
min={0}
max={255}
@@ -91,8 +96,11 @@ export function VectorizeSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Detail Level</label>
<label htmlFor="vectorize-detail" className="text-xs text-muted-foreground">
Detail Level
</label>
<select
id="vectorize-detail"
value={detail}
onChange={(e) => setDetail(e.target.value as "low" | "medium" | "high")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -113,6 +121,7 @@ export function VectorizeSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || 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"
@@ -63,7 +63,7 @@ export function WatermarkImageSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Watermark Image</label>
<p className="text-xs text-muted-foreground">Watermark Image</p>
<input
ref={watermarkInputRef}
type="file"
@@ -72,6 +72,7 @@ export function WatermarkImageSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => watermarkInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -81,8 +82,11 @@ export function WatermarkImageSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="watermark-image-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="watermark-image-position"
value={position}
onChange={(e) => setPosition(e.target.value as Position)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -97,10 +101,13 @@ export function WatermarkImageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="watermark-image-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="watermark-image-opacity"
type="range"
min={0}
max={100}
@@ -112,10 +119,13 @@ export function WatermarkImageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Scale</label>
<label htmlFor="watermark-image-scale" className="text-xs text-muted-foreground">
Scale
</label>
<span className="text-xs font-mono text-foreground">{scale}%</span>
</div>
<input
id="watermark-image-scale"
type="range"
min={5}
max={100}
@@ -135,6 +145,7 @@ export function WatermarkImageSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !watermarkFile || 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"
@@ -40,8 +40,11 @@ export function WatermarkTextSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Watermark Text</label>
<label htmlFor="watermark-text-text" className="text-xs text-muted-foreground">
Watermark Text
</label>
<input
id="watermark-text-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
@@ -51,10 +54,13 @@ export function WatermarkTextSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Font Size</label>
<label htmlFor="watermark-text-font-size" className="text-xs text-muted-foreground">
Font Size
</label>
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
</div>
<input
id="watermark-text-font-size"
type="range"
min={8}
max={200}
@@ -66,8 +72,11 @@ export function WatermarkTextSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Color</label>
<label htmlFor="watermark-text-color" className="text-xs text-muted-foreground">
Color
</label>
<input
id="watermark-text-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
@@ -76,10 +85,13 @@ export function WatermarkTextSettings() {
</div>
<div className="flex-1">
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="watermark-text-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="watermark-text-opacity"
type="range"
min={0}
max={100}
@@ -91,8 +103,11 @@ export function WatermarkTextSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="watermark-text-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="watermark-text-position"
value={position}
onChange={(e) => setPosition(e.target.value as Position)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -108,10 +123,13 @@ export function WatermarkTextSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Rotation</label>
<label htmlFor="watermark-text-rotation" className="text-xs text-muted-foreground">
Rotation
</label>
<span className="text-xs font-mono text-foreground">{rotation}&deg;</span>
</div>
<input
id="watermark-text-rotation"
type="range"
min={-180}
max={180}
@@ -141,6 +159,7 @@ export function WatermarkTextSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing || !text}
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"
+3 -1
View File
@@ -3,7 +3,9 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
+3
View File
@@ -236,6 +236,7 @@ export function AutomatePage() {
{TEMPLATES.map((tpl) => (
<button
key={tpl.name}
type="button"
onClick={() => loadTemplate(tpl)}
className="w-full text-left p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors"
>
@@ -265,6 +266,7 @@ export function AutomatePage() {
>
<div className="flex items-center justify-between mb-1">
<button
type="button"
onClick={() => loadSaved(pipeline)}
className="text-sm font-medium text-foreground hover:text-primary flex items-center gap-1.5"
>
@@ -272,6 +274,7 @@ export function AutomatePage() {
{pipeline.name}
</button>
<button
type="button"
onClick={() => handleDelete(pipeline.id)}
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
>
@@ -81,6 +81,7 @@ export function FullscreenGridPage() {
{/* Toggle details */}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm transition-colors",
@@ -97,6 +98,7 @@ export function FullscreenGridPage() {
{/* Switch to sidebar view */}
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
title="Switch to sidebar view"
+3
View File
@@ -55,6 +55,7 @@ export function HomePage() {
{files.length > 1 && `${files.length} files`}
</p>
<button
type="button"
onClick={reset}
className="text-xs text-muted-foreground hover:text-foreground mt-2"
>
@@ -78,6 +79,7 @@ export function HomePage() {
return (
<button
key={id}
type="button"
onClick={() => handleToolClick(tool.route)}
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
@@ -119,6 +121,7 @@ export function HomePage() {
return (
<button
key={tool.id}
type="button"
onClick={() => handleToolClick(tool.route)}
className={cn(
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
+22 -6
View File
@@ -161,7 +161,11 @@ function FileSelectionInfo({
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">Files ({files.length})</span>
<button onClick={onAddMore} className="text-xs text-primary hover:text-primary/80">
<button
type="button"
onClick={onAddMore}
className="text-xs text-primary hover:text-primary/80"
>
+ Add more
</button>
</div>
@@ -172,7 +176,11 @@ function FileSelectionInfo({
{formatFileSize(selectedFileSize ?? files[0].size)}
</span>
</div>
<button onClick={onClear} className="text-xs text-muted-foreground hover:text-foreground">
<button
type="button"
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
@@ -322,6 +330,7 @@ export function ToolPage() {
</div>
<h2 className="font-semibold text-lg text-foreground flex-1">{tool.name}</h2>
<button
type="button"
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
>
@@ -384,7 +393,8 @@ export function ToolPage() {
)}
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -392,6 +402,7 @@ export function ToolPage() {
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -460,6 +471,7 @@ export function ToolPage() {
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -480,7 +492,7 @@ export function ToolPage() {
onSelect={setSelectedIndex}
/>
)}
</div>
</section>
</div>
</AppLayout>
);
@@ -552,6 +564,7 @@ export function ToolPage() {
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
type="button"
onClick={handleDownloadAll}
className="w-full py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
@@ -563,7 +576,8 @@ export function ToolPage() {
</div>
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -571,6 +585,7 @@ export function ToolPage() {
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -639,6 +654,7 @@ export function ToolPage() {
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -659,7 +675,7 @@ export function ToolPage() {
onSelect={setSelectedIndex}
/>
)}
</div>
</section>
</div>
</AppLayout>
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,808 +0,0 @@
# Interactive Crop Tool Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the numbers-only crop UI with a visual, interactive Photoshop-style crop tool with draggable rectangle overlay, aspect ratio presets, bidirectional pixel inputs, and rule-of-thirds grid.
**Architecture:** `react-image-crop` renders the visual crop overlay on the image in a new `CropCanvas` component. `CropSettings` is redesigned with aspect ratio presets and pixel inputs that sync bidirectionally with the visual overlay. Crop state is lifted to `tool-page.tsx` and shared between both components via props. Backend is unchanged — the same `{ left, top, width, height }` pixel values are sent to Sharp's `.extract()`.
**Tech Stack:** React, TypeScript, react-image-crop, Tailwind CSS, Sharp (backend, unchanged)
**Spec:** `docs/superpowers/specs/2026-03-23-interactive-crop-tool-design.md`
---
## File Structure
| File | Action | Responsibility |
|------|--------|---------------|
| `apps/web/package.json` | Modify | Add `react-image-crop` dependency |
| `apps/web/src/components/tools/crop-canvas.tsx` | Create | Visual crop overlay component with react-image-crop, rule-of-thirds grid, dimension badge, keyboard controls |
| `apps/web/src/components/tools/crop-settings.tsx` | Rewrite | Aspect ratio presets, bidirectional pixel inputs, grid toggle, process/download buttons |
| `apps/web/src/pages/tool-page.tsx` | Modify | Lift crop state, add `INTERACTIVE_CROP_TOOLS` rendering path, pass crop props |
---
### Task 1: Install react-image-crop
**Files:**
- Modify: `apps/web/package.json`
- [ ] **Step 1: Install the dependency**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm add react-image-crop --filter @stirling-image/web
```
- [ ] **Step 2: Verify installation**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm ls react-image-crop --filter @stirling-image/web
```
Expected: `react-image-crop` appears in the dependency list.
- [ ] **Step 3: Commit**
```bash
git add apps/web/package.json pnpm-lock.yaml && git commit -m "feat(crop): add react-image-crop dependency"
```
---
### Task 2: Create CropCanvas component
**Files:**
- Create: `apps/web/src/components/tools/crop-canvas.tsx`
This component renders the image with `ReactCrop` overlay, rule-of-thirds grid, dimension badge, and keyboard controls.
- [ ] **Step 1: Create the CropCanvas component**
Create `apps/web/src/components/tools/crop-canvas.tsx` with the following code:
```tsx
import { useRef, useCallback, useEffect } from "react";
import ReactCrop, { type Crop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
export interface CropCanvasProps {
imageSrc: string;
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
onCropChange: (crop: Crop) => void;
onImageLoad: (dims: { width: number; height: number }) => void;
}
export function CropCanvas({
imageSrc,
crop,
aspect,
showGrid,
imgDimensions,
onCropChange,
onImageLoad,
}: CropCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const handleImageLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
onImageLoad({ width: img.naturalWidth, height: img.naturalHeight });
},
[onImageLoad],
);
// Keyboard nudging
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleKeyDown = (e: KeyboardEvent) => {
const step = e.shiftKey ? 10 : 1;
const { naturalWidth, naturalHeight } = imgRef.current ?? {
naturalWidth: 0,
naturalHeight: 0,
};
if (!naturalWidth || !naturalHeight) return;
// Convert step from pixels to percentage
const stepX = (step / naturalWidth) * 100;
const stepY = (step / naturalHeight) * 100;
let dx = 0;
let dy = 0;
if (e.key === "ArrowLeft") dx = -stepX;
else if (e.key === "ArrowRight") dx = stepX;
else if (e.key === "ArrowUp") dy = -stepY;
else if (e.key === "ArrowDown") dy = stepY;
else if (e.key === "Escape") {
// Reset to full image
onCropChange({
unit: "%",
x: 0,
y: 0,
width: 100,
height: 100,
});
e.preventDefault();
return;
} else if (e.key === "Enter") {
// Submit the crop form (find and submit closest form)
const form = document.querySelector<HTMLFormElement>(
'form[data-crop-form]',
);
if (form) form.requestSubmit();
e.preventDefault();
return;
} else return;
e.preventDefault();
onCropChange({
...crop,
x: Math.max(0, Math.min(100 - crop.width, crop.x + dx)),
y: Math.max(0, Math.min(100 - crop.height, crop.y + dy)),
});
};
el.addEventListener("keydown", handleKeyDown);
return () => el.removeEventListener("keydown", handleKeyDown);
}, [crop, onCropChange]);
// Auto-focus the container on mount
useEffect(() => {
containerRef.current?.focus();
}, []);
// Calculate pixel dimensions for the badge
const pixelWidth =
imgDimensions ? Math.round((crop.width / 100) * imgDimensions.width) : 0;
const pixelHeight =
imgDimensions ? Math.round((crop.height / 100) * imgDimensions.height) : 0;
return (
<div
ref={containerRef}
className="flex flex-col w-full h-full max-w-4xl mx-auto outline-none"
tabIndex={0}
>
{/* Crop area */}
<div className="flex-1 flex items-center justify-center overflow-hidden bg-muted/20 p-4">
<ReactCrop
crop={crop}
onChange={onCropChange}
aspect={aspect}
className="max-h-full"
ruleOfThirds={showGrid}
>
<img
ref={imgRef}
src={imageSrc}
alt="Crop preview"
onLoad={handleImageLoad}
className="max-w-full max-h-[calc(100vh-12rem)] select-none"
draggable={false}
/>
</ReactCrop>
</div>
{/* Info bar */}
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
<span>
Crop region: {pixelWidth} x {pixelHeight}
</span>
{imgDimensions && (
<span>
Original: {imgDimensions.width} x {imgDimensions.height}
</span>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web typecheck
```
Expected: No type errors.
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/crop-canvas.tsx && git commit -m "feat(crop): add CropCanvas component with visual overlay, grid, and keyboard controls"
```
---
### Task 3: Redesign CropSettings component
**Files:**
- Rewrite: `apps/web/src/components/tools/crop-settings.tsx`
This redesigns the settings panel with aspect ratio presets (Free, 1:1, 4:3, 3:2, 16:9, 2:3, 4:5, 9:16), bidirectional pixel inputs (X, Y, Width, Height), a rule-of-thirds grid toggle, and the process/download buttons.
- [ ] **Step 1: Rewrite CropSettings**
Rewrite `apps/web/src/components/tools/crop-settings.tsx` with the following code:
```tsx
import { useCallback } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, ArrowLeftRight, Grid3x3 } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
import type { Crop } from "react-image-crop";
const ASPECT_PRESETS = [
{ label: "Free", value: undefined as number | undefined },
{ label: "1:1", value: 1 },
{ label: "4:3", value: 4 / 3 },
{ label: "3:2", value: 3 / 2 },
{ label: "16:9", value: 16 / 9 },
{ label: "2:3", value: 2 / 3 },
{ label: "4:5", value: 4 / 5 },
{ label: "9:16", value: 9 / 16 },
];
export interface CropSettingsProps {
cropState: {
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
};
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
}
export function CropSettings({
cropState,
onCropChange,
onAspectChange,
onGridToggle,
}: CropSettingsProps) {
const { files } = useFileStore();
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
progress,
} = useToolProcessor("crop");
const { crop, aspect, showGrid, imgDimensions } = cropState;
// Convert percentage crop to pixel values
const toPixels = useCallback(
(c: Crop) => {
if (!imgDimensions) return { left: 0, top: 0, width: 0, height: 0 };
return {
left: Math.round((c.x / 100) * imgDimensions.width),
top: Math.round((c.y / 100) * imgDimensions.height),
width: Math.round((c.width / 100) * imgDimensions.width),
height: Math.round((c.height / 100) * imgDimensions.height),
};
},
[imgDimensions],
);
// Convert pixel value change back to percentage crop
const handlePixelChange = useCallback(
(field: "left" | "top" | "width" | "height", value: number) => {
if (!imgDimensions) return;
const newCrop = { ...crop };
if (field === "left") {
newCrop.x = Math.max(
0,
Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width),
);
} else if (field === "top") {
newCrop.y = Math.max(
0,
Math.min((value / imgDimensions.height) * 100, 100 - newCrop.height),
);
} else if (field === "width") {
const pct = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.x));
newCrop.width = pct;
if (aspect) {
newCrop.height = Math.min(
(pct / 100) * imgDimensions.width * (1 / aspect) * (100 / imgDimensions.height),
100 - newCrop.y,
);
newCrop.y = Math.max(0, Math.min(newCrop.y, 100 - newCrop.height));
}
} else if (field === "height") {
const pct = Math.max(0, Math.min((value / imgDimensions.height) * 100, 100 - newCrop.y));
newCrop.height = pct;
if (aspect) {
newCrop.width = Math.min(
(pct / 100) * imgDimensions.height * aspect * (100 / imgDimensions.width),
100 - newCrop.x,
);
newCrop.x = Math.max(0, Math.min(newCrop.x, 100 - newCrop.width));
}
}
onCropChange(newCrop);
},
[crop, imgDimensions, aspect, onCropChange],
);
const handleAspectSelect = useCallback(
(value: number | undefined) => {
onAspectChange(value);
// When selecting an aspect ratio, adjust the current crop to match
if (value && imgDimensions) {
const imgAspect = imgDimensions.width / imgDimensions.height;
let newWidth: number;
let newHeight: number;
if (value > imgAspect) {
// Wider than image — constrain by width
newWidth = 100;
newHeight = (imgDimensions.width / value / imgDimensions.height) * 100;
} else {
// Taller than image — constrain by height
newHeight = 100;
newWidth = (imgDimensions.height * value / imgDimensions.width) * 100;
}
onCropChange({
unit: "%",
x: (100 - newWidth) / 2,
y: (100 - newHeight) / 2,
width: newWidth,
height: newHeight,
});
}
},
[onAspectChange, onCropChange, imgDimensions],
);
const handleSwapAspect = useCallback(() => {
if (aspect) {
handleAspectSelect(1 / aspect);
}
}, [aspect, handleAspectSelect]);
const pixels = toPixels(crop);
const handleProcess = () => {
const settings = {
left: pixels.left,
top: pixels.top,
width: Math.max(1, pixels.width),
height: Math.max(1, pixels.height),
};
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const hasSize = pixels.width > 0 && pixels.height > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && hasSize && !processing) handleProcess();
};
// Find which preset label matches the current aspect
const activePresetLabel = ASPECT_PRESETS.find((p) => {
if (p.value === undefined && aspect === undefined) return true;
if (p.value !== undefined && aspect !== undefined) {
return Math.abs(p.value - aspect) < 0.01;
}
return false;
})?.label;
return (
<form onSubmit={handleSubmit} className="space-y-4" data-crop-form>
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
{aspect !== undefined && (
<button
type="button"
onClick={handleSwapAspect}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Swap width/height"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap gap-1">
{ASPECT_PRESETS.map(({ label, value }) => (
<button
type="button"
key={label}
onClick={() => handleAspectSelect(value)}
className={`px-2 py-1.5 rounded text-xs transition-colors ${
activePresetLabel === label
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Position & Size */}
<div>
<label className="text-xs text-muted-foreground">Position & Size</label>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label className="text-[10px] text-muted-foreground">
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
type="number"
value={pixels.left}
onChange={(e) =>
handlePixelChange("left", Number(e.target.value))
}
min={0}
max={imgDimensions ? imgDimensions.width - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
type="number"
value={pixels.top}
onChange={(e) =>
handlePixelChange("top", Number(e.target.value))
}
min={0}
max={imgDimensions ? imgDimensions.height - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
type="number"
value={pixels.width}
onChange={(e) =>
handlePixelChange("width", Number(e.target.value))
}
min={1}
max={imgDimensions ? imgDimensions.width : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
type="number"
value={pixels.height}
onChange={(e) =>
handlePixelChange("height", Number(e.target.value))
}
min={1}
max={imgDimensions ? imgDimensions.height : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
</div>
</div>
{/* Grid overlay toggle */}
<button
type="button"
onClick={() => onGridToggle(!showGrid)}
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs transition-colors ${
showGrid
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
<Grid3x3 className="h-3.5 w-3.5" />
Rule of Thirds
</button>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Cropping"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
disabled={!hasFile || !hasSize || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web typecheck
```
Expected: No type errors. (May have errors from `tool-page.tsx` not yet passing props — that's expected and fixed in Task 4.)
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/crop-settings.tsx && git commit -m "feat(crop): redesign CropSettings with aspect presets, pixel inputs, and grid toggle"
```
---
### Task 4: Update tool-page.tsx to wire everything together
**Files:**
- Modify: `apps/web/src/pages/tool-page.tsx`
This task lifts crop state to `tool-page.tsx`, adds the `INTERACTIVE_CROP_TOOLS` rendering path, and passes props to both `CropCanvas` and `CropSettings`.
- [ ] **Step 1: Add imports**
At the top of `apps/web/src/pages/tool-page.tsx`, add after the existing `CropSettings` import:
```tsx
import { CropCanvas } from "@/components/tools/crop-canvas";
```
Also add `type Crop` import near the top:
```tsx
import type { Crop } from "react-image-crop";
```
- [ ] **Step 2: Add INTERACTIVE_CROP_TOOLS set**
After the `LIVE_PREVIEW_TOOLS` line (line 68 currently), add:
```tsx
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
```
- [ ] **Step 3: Add crop state to ToolPage component**
Inside the `ToolPage` function, after the `previewTransform` state declaration (currently line 185), add:
```tsx
const [cropCrop, setCropCrop] = useState<Crop>({
unit: "%",
x: 0,
y: 0,
width: 100,
height: 100,
});
const [cropAspect, setCropAspect] = useState<number | undefined>(undefined);
const [cropShowGrid, setCropShowGrid] = useState(true);
const [cropImgDimensions, setCropImgDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const cropState = useMemo(
() => ({
crop: cropCrop,
aspect: cropAspect,
showGrid: cropShowGrid,
imgDimensions: cropImgDimensions,
}),
[cropCrop, cropAspect, cropShowGrid, cropImgDimensions],
);
// Reset crop state when the image changes
useEffect(() => {
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
setCropImgDimensions(null);
}, [originalBlobUrl]);
```
- [ ] **Step 4: Update ToolSettingsPanel to pass crop props**
Modify the `ToolSettingsPanel` component signature and the crop routing. The component needs access to crop state, so we'll pass it through.
Change the `ToolSettingsPanel` function signature from:
```tsx
function ToolSettingsPanel({
toolId,
onPreviewTransform,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
}) {
```
to:
```tsx
function ToolSettingsPanel({
toolId,
onPreviewTransform,
cropProps,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
cropProps?: {
cropState: {
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
};
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
};
}) {
```
Then change the crop routing line from:
```tsx
if (toolId === "crop") return <CropSettings />;
```
to:
```tsx
if (toolId === "crop" && cropProps) return <CropSettings {...cropProps} />;
```
- [ ] **Step 5: Pass cropProps to ToolSettingsPanel in both layouts**
In both the desktop and mobile layouts, update the `<ToolSettingsPanel>` call to include `cropProps`.
Find every `<ToolSettingsPanel` usage (there should be 2 — one in mobile layout, one in desktop layout) and add the `cropProps` prop:
```tsx
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
cropProps={INTERACTIVE_CROP_TOOLS.has(tool.id) ? {
cropState,
onCropChange: setCropCrop,
onAspectChange: setCropAspect,
onGridToggle: setCropShowGrid,
} : undefined}
/>
```
- [ ] **Step 6: Add CropCanvas rendering path in desktop layout**
In the desktop layout's main area (the `{/* Main area */}` section), add the `CropCanvas` branch. This must come **before** the `SIDE_BY_SIDE_TOOLS` check. Find the line:
```tsx
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
```
And add this branch **before** it (after the `files.length > 1` MultiImageViewer check):
```tsx
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
```
- [ ] **Step 7: Add CropCanvas rendering path in mobile layout**
Do the exact same insertion in the mobile layout's main area — add the `CropCanvas` branch before the `SIDE_BY_SIDE_TOOLS` check, after the `files.length > 1` check:
```tsx
) : INTERACTIVE_CROP_TOOLS.has(tool.id) && hasFile && !hasProcessed && originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
```
- [ ] **Step 8: Verify everything compiles**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web typecheck
```
Expected: No type errors.
- [ ] **Step 9: Build the frontend**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build
```
Expected: Build succeeds with no errors.
- [ ] **Step 10: Commit**
```bash
git add apps/web/src/pages/tool-page.tsx && git commit -m "feat(crop): wire CropCanvas and CropSettings into tool-page with bidirectional state"
```
---
### Task 5: Docker rebuild and verification
**Files:** None (Docker rebuild only)
- [ ] **Step 1: Rebuild the Docker container**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && docker-compose -f docker/docker-compose.yml up --build -d
```
Expected: Container builds and starts on port 1349.
- [ ] **Step 2: Manual verification checklist**
Open `http://localhost:1349` in a browser and test:
1. Navigate to the Crop tool
2. Drop an image — verify the crop canvas appears with the rectangle covering the full image
3. Drag a corner handle — verify the rectangle resizes and the pixel inputs update
4. Type a width value in the pixel input — verify the rectangle updates
5. Select "1:1" aspect ratio — verify the rectangle snaps to square
6. Select "16:9" — verify landscape ratio
7. Click the swap button — verify it flips to 9:16
8. Select "Free" — verify unconstrained dragging works
9. Toggle Rule of Thirds — verify grid lines appear/disappear
10. Use arrow keys to nudge — verify the rectangle moves
11. Use Shift+Arrow — verify 10px nudge
12. Press Escape — verify rectangle resets to full image
13. Click "Crop" — verify processing works and side-by-side comparison appears
14. Click Download — verify the cropped image downloads
15. Click Undo (in review panel) — verify it returns to the crop canvas
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,869 +0,0 @@
# Resize & Rotate/Flip UX Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the before/after slider with intuitive side-by-side comparison for resize and live CSS preview for rotate/flip.
**Architecture:** Frontend-only changes. Resize gets a tab-based settings panel (Presets/Custom Size/Scale) and a new SideBySideComparison result view. Rotate/flip gets live CSS transform preview on the ImageViewer with an "Apply" button. The tool-page.tsx conditionally renders the appropriate result view per tool. No backend changes.
**Tech Stack:** React, TypeScript, Tailwind CSS, Zustand, lucide-react
**Spec:** `docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md`
---
### Task 1: Create SideBySideComparison Component
**Files:**
- Create: `apps/web/src/components/common/side-by-side-comparison.tsx`
- [ ] **Step 1: Create the component file**
```tsx
import { useState } from "react";
interface SideBySideComparisonProps {
beforeSrc: string;
afterSrc: string;
beforeSize?: number;
afterSize?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export function SideBySideComparison({
beforeSrc,
afterSrc,
beforeSize,
afterSize,
}: SideBySideComparisonProps) {
const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null);
const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null);
const savingsPercent =
beforeSize && afterSize && beforeSize > 0
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
const checkerboard = {
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
};
return (
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
{/* Side-by-side images */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
{/* Original */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Original
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<img
src={beforeSrc}
alt="Original"
className="max-w-full max-h-full object-contain"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setBeforeDims({ w: img.naturalWidth, h: img.naturalHeight });
}}
/>
</div>
<div className="text-xs text-muted-foreground text-center space-y-0.5">
{beforeDims && (
<p>
{beforeDims.w} × {beforeDims.h}
</p>
)}
{beforeSize != null && <p>{formatSize(beforeSize)}</p>}
</div>
</div>
{/* Resized */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Resized
</span>
<div
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
style={checkerboard}
>
<img
src={afterSrc}
alt="Resized"
className="max-w-full max-h-full object-contain"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setAfterDims({ w: img.naturalWidth, h: img.naturalHeight });
}}
/>
</div>
<div className="text-xs text-muted-foreground text-center space-y-0.5">
{afterDims && (
<p>
{afterDims.w} × {afterDims.h}
</p>
)}
{afterSize != null && <p>{formatSize(afterSize)}</p>}
</div>
</div>
</div>
{/* Size savings */}
{savingsPercent !== null && (
<p
className={`text-sm font-medium ${Number(savingsPercent) > 0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`}
>
{Number(savingsPercent) > 0
? `${savingsPercent}% smaller`
: `${Math.abs(Number(savingsPercent))}% larger`}
</p>
)}
</div>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/common/side-by-side-comparison.tsx
git commit -m "feat: add SideBySideComparison component for resize results"
```
---
### Task 2: Rewrite ResizeSettings with Tab-Based UI
**Files:**
- Modify: `apps/web/src/components/tools/resize-settings.tsx`
- [ ] **Step 1: Rewrite resize-settings.tsx with three tabs**
Replace the entire file content with:
```tsx
import { useState } from "react";
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Link, Unlink } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
type ResizeTab = "presets" | "custom" | "scale";
type FitMode = "cover" | "contain" | "fill";
const FIT_LABELS: Record<FitMode, string> = {
cover: "Crop to fit",
contain: "Fit inside",
fill: "Stretch",
};
// Group presets by platform
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
export function ResizeSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, progress } =
useToolProcessor("resize");
const [tab, setTab] = useState<ResizeTab>("presets");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>("");
const [height, setHeight] = useState<string>("");
const [percentage, setPercentage] = useState<string>("50");
const [fit, setFit] = useState<FitMode>("cover");
const [lockAspect, setLockAspect] = useState(true);
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
const key = `${preset.platform}-${preset.name}`;
if (selectedPreset === key) {
setSelectedPreset(null);
setWidth("");
setHeight("");
} else {
setSelectedPreset(key);
setWidth(String(preset.width));
setHeight(String(preset.height));
}
};
const handleProcess = () => {
const settings: Record<string, unknown> = {};
if (tab === "scale") {
settings.percentage = Number(percentage);
} else {
if (width) settings.width = Number(width);
if (height) settings.height = Number(height);
settings.fit = tab === "presets" ? "cover" : fit;
settings.withoutEnlargement = withoutEnlargement;
}
processFiles(files, settings);
};
const hasFile = files.length > 0;
const canProcess =
hasFile &&
!processing &&
(tab === "scale"
? Number(percentage) > 0
: Boolean(width) || Boolean(height));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
const tabClass = (t: ResizeTab) =>
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Tab selector */}
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size
</button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale
</button>
</div>
</div>
{/* Presets tab */}
{tab === "presets" && (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
<div className="space-y-1">
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
const key = `${preset.platform}-${preset.name}`;
const isSelected = selectedPreset === key;
return (
<button
key={key}
type="button"
onClick={() => handlePreset(preset)}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
}`}
>
<span>{preset.name}</span>
<span className="text-xs tabular-nums">
{preset.width} × {preset.height}
</span>
</button>
);
})}
</div>
</div>
))}
{/* Don't enlarge */}
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={withoutEnlargement}
onChange={(e) => setWithoutEnlargement(e.target.checked)}
className="rounded"
/>
Don&apos;t enlarge
</label>
</div>
)}
{/* Custom Size tab */}
{tab === "custom" && (
<div className="space-y-3">
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<input
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<button
type="button"
onClick={() => setLockAspect(!lockAspect)}
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
>
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
</button>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
{/* Fit mode */}
<div>
<label className="text-xs text-muted-foreground">Fit Mode</label>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFit(f)}
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
{FIT_LABELS[f]}
</button>
))}
</div>
</div>
{/* Don't enlarge */}
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={withoutEnlargement}
onChange={(e) => setWithoutEnlargement(e.target.checked)}
className="rounded"
/>
Don&apos;t enlarge
</label>
</div>
)}
{/* Scale tab */}
{tab === "scale" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Scale (%)</label>
<input
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex gap-1">
{[25, 50, 75].map((pct) => (
<button
key={pct}
type="button"
onClick={() => setPercentage(String(pct))}
className={`flex-1 text-xs py-1.5 rounded ${
percentage === String(pct)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{pct}%
</button>
))}
</div>
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process button */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Resizing"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Resize
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/resize-settings.tsx
git commit -m "feat: rewrite resize settings with tab-based UI (presets, custom, scale)"
```
---
### Task 3: Add CSS Transform Props to ImageViewer
**Files:**
- Modify: `apps/web/src/components/common/image-viewer.tsx`
- [ ] **Step 1: Add optional CSS transform props to the ImageViewer interface and apply them**
Add `cssRotate`, `cssFlipH`, `cssFlipV` optional props to the `ImageViewerProps` interface. In the `imageStyle` computation, compose CSS transforms when these props are provided.
Changes to make:
1. Update the interface (line 5-8):
```tsx
interface ImageViewerProps {
src: string;
filename: string;
fileSize: number;
cssRotate?: number;
cssFlipH?: boolean;
cssFlipV?: boolean;
}
```
2. Update the component destructuring (line 14):
```tsx
export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) {
```
3. Update the `imageStyle` computation (lines 65-68) to compose CSS transforms:
```tsx
const previewTransform = [
cssRotate ? `rotate(${cssRotate}deg)` : "",
cssFlipH ? "scaleX(-1)" : "",
cssFlipV ? "scaleY(-1)" : "",
]
.filter(Boolean)
.join(" ");
const imageStyle =
fitMode === "fit"
? {
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain" as const,
...(previewTransform && { transform: previewTransform }),
}
: {
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
transformOrigin: "center center",
};
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/common/image-viewer.tsx
git commit -m "feat: add CSS transform props to ImageViewer for live rotate/flip preview"
```
---
### Task 4: Update RotateSettings with Live Preview Callback
**Files:**
- Modify: `apps/web/src/components/tools/rotate-settings.tsx`
- [ ] **Step 1: Add onPreviewTransform callback prop and change button label**
The component needs to:
1. Accept an optional `onPreviewTransform` callback
2. Call it on every state change (angle, flipH, flipV) via useEffect
3. Change the submit button label from "Rotate" to "Apply"
Replace the entire file:
```tsx
import { useState, useEffect } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import {
Download,
RotateCcw,
RotateCw,
FlipHorizontal,
FlipVertical,
} from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
export interface PreviewTransform {
rotate: number;
flipH: boolean;
flipV: boolean;
}
interface RotateSettingsProps {
onPreviewTransform?: (transform: PreviewTransform) => void;
}
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, progress } =
useToolProcessor("rotate");
const [angle, setAngle] = useState(0);
const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false);
// Emit preview transform on every change
useEffect(() => {
onPreviewTransform?.({ rotate: angle, flipH, flipV });
}, [angle, flipH, flipV, onPreviewTransform]);
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
const rotateRight = () => setAngle((a) => (a + 90) % 360);
const handleProcess = () => {
processFiles(files, {
angle,
horizontal: flipH,
vertical: flipV,
});
};
const hasFile = files.length > 0;
const hasChanges = angle !== 0 || flipH || flipV;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && hasChanges && !processing) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate buttons */}
<div>
<label className="text-xs text-muted-foreground">Quick Rotate</label>
<div className="flex gap-2 mt-1">
<button
type="button"
onClick={rotateLeft}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCcw className="h-4 w-4" />
90 Left
</button>
<button
type="button"
onClick={rotateRight}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCw className="h-4 w-4" />
90 Right
</button>
</div>
</div>
{/* Angle slider */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Angle</label>
<span className="text-xs font-mono text-foreground">{angle} deg</span>
</div>
<input
type="range"
min={0}
max={360}
value={angle}
onChange={(e) => setAngle(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Flip buttons */}
<div>
<label className="text-xs text-muted-foreground">Flip</label>
<div className="flex gap-2 mt-1">
<button
type="button"
onClick={() => setFlipH(!flipH)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
flipH
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<FlipHorizontal className="h-4 w-4" />
Horizontal
</button>
<button
type="button"
onClick={() => setFlipV(!flipV)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
flipV
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<FlipVertical className="h-4 w-4" />
Vertical
</button>
</div>
</div>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Applying"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Apply
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/tools/rotate-settings.tsx
git commit -m "feat: add live preview callback to RotateSettings, rename button to Apply"
```
---
### Task 5: Update tool-page.tsx for Conditional Rendering
**Files:**
- Modify: `apps/web/src/pages/tool-page.tsx`
- [ ] **Step 1: Add imports, preview transform state, and conditional result rendering**
Changes to make in `tool-page.tsx`:
1. Add imports at the top (after existing imports, around line 8):
```tsx
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
```
2. Add a set for tools that use alternate result views (after `NO_DROPZONE_TOOLS` on line 63):
```tsx
const SIDE_BY_SIDE_TOOLS = new Set(["resize"]);
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
```
3. Update `ToolSettingsPanel` to accept and pass through the preview transform callback (replace the function starting at line 65):
```tsx
function ToolSettingsPanel({
toolId,
onPreviewTransform,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
}) {
// Phase 2: Core tools
if (toolId === "resize") return <ResizeSettings />;
if (toolId === "crop") return <CropSettings />;
if (toolId === "rotate") return <RotateSettings onPreviewTransform={onPreviewTransform} />;
```
(Rest of ToolSettingsPanel stays identical)
4. In the `ToolPage` component, add preview transform state (after `const [mobileSettingsOpen, setMobileSettingsOpen]` on line 177):
```tsx
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
```
5. Update ToolSettingsPanel usage in both mobile and desktop layouts — pass the callback:
```tsx
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
/>
```
6. Replace the result rendering logic in the main area for **both mobile and desktop layouts**. Replace the entire conditional block (from `{isNoDropzone ?` through the closing `}`). Apply this **identically** in both the mobile layout (around line 286) and the desktop layout (around line 372):
```tsx
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
```
**Important:** The `BeforeAfterSlider` is kept for all tools except resize (SideBySideComparison) and rotate (ImageViewer). The `BeforeAfterSlider` import must NOT be removed.
- [ ] **Step 2: Verify it compiles**
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
Expected: Build succeeds
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/pages/tool-page.tsx
git commit -m "feat: conditional result views — side-by-side for resize, live preview for rotate"
```
---
### Task 6: Docker Rebuild and Test
**Files:**
- No file changes — build and run existing Docker setup
- [ ] **Step 1: Build Docker image**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image
docker compose -f docker/docker-compose.yml build
```
- [ ] **Step 2: Start the container**
```bash
docker compose -f docker/docker-compose.yml up -d
```
- [ ] **Step 3: Verify the app is running**
```bash
curl -f http://localhost:1349/api/v1/health
```
Expected: Health check passes
- [ ] **Step 4: Report to user for UI testing**
App is running at `http://localhost:1349`. User can test:
- Resize tool: tab-based settings (Presets, Custom Size, Scale), side-by-side result view
- Rotate tool: live CSS preview as controls change, "Apply" button, processed result in standard viewer
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,313 +0,0 @@
# Real Progress Bars for All Tools
## Problem
The current progress indicators are either fake (AI tools use time-based guessing) or nonexistent (fast tools show only a spinner icon on the button). Users have no real visibility into what's happening during processing.
## Solution
Replace all progress indicators with a unified, honest `ProgressCard` component backed by real progress data:
- **Upload phase**: real byte-level tracking via `XMLHttpRequest.upload.onprogress`
- **Processing phase**: real server-side progress via SSE for AI tools; honest brief state for fast tools
- **Completion**: card disappears immediately, download button appears
## Architecture
### Data Flow
```
Frontend Backend
| |
|-- Open SSE (jobId) ----------------->| (side channel ready)
|-- POST file + settings + jobId ----->|
| (XMLHttpRequest tracks upload %) |-- parse multipart
| |-- call tool.process()
| | |-- bridge.ts spawns Python
| | | Python emits progress to stderr
| | | bridge.ts parses, calls updateJobProgress()
|<-- SSE: {stage, percent} ------------|
|<-- SSE: {stage, percent} ------------|
| | |-- processing done
|<-- POST response (result) ----------|
|-- Close SSE ----------------------->|
```
The frontend opens an SSE connection *before* POSTing the file, using a client-generated jobId. This avoids restructuring the existing synchronous API. The SSE is a parallel side-channel for progress updates. Fast tools skip the SSE step entirely.
### Progress Phases
| Phase | Source | Data |
|-------|--------|------|
| Upload | `XMLHttpRequest.upload.onprogress` | Real bytes sent / total bytes |
| Processing (fast tools) | Implied — between upload complete and POST response | No sub-stages, honest "Processing..." |
| Processing (AI tools) | SSE from backend, driven by Python stderr | Real stage labels + granular percentages |
| Complete | POST response received | Card disappears, download button shown |
## Frontend
### `ProgressCard` Component
Replaces the existing `AIProgressBar`. Card-style compact design.
**Visual structure:**
```
┌─────────────────────────────────────────────┐
│ [icon] Removing background 45% │
│ Analyzing image · 8s │
│ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────────────────────┘
```
- **Icon**: upload arrow during upload, spinner during processing
- **Primary label**: action name ("Uploading image" / "Removing background")
- **Sub-label**: current stage + elapsed time
- **Percentage**: monospace, right-aligned, blue
- **Progress bar**: thin (4px), rounded, blue fill on dark track
- **Container**: dark card with subtle border, rounded corners
**Props:**
```typescript
interface ProgressCardProps {
/** Whether processing is active */
active: boolean;
/** Current phase */
phase: 'uploading' | 'processing' | 'complete';
/** Primary action label (e.g., "Removing background") */
label: string;
/** Current stage detail (e.g., "Analyzing image") */
stage?: string;
/** Progress percentage 0-100 */
percent: number;
/** Elapsed seconds */
elapsed: number;
}
```
**Location:** `apps/web/src/components/common/progress-card.tsx`
The old `AIProgressBar` component (`apps/web/src/components/common/ai-progress-bar.tsx`) will be deleted after all tools are migrated.
### `useToolProcessor` Hook Changes
Rewrite to support real progress tracking.
**New return type:**
```typescript
interface ToolProcessorResult {
processFiles: (files: File[], settings: Record<string, unknown>) => void;
processing: boolean;
error: string | null;
downloadUrl: string | null;
originalSize: number | null;
processedSize: number | null;
/** New: real-time progress state */
progress: {
phase: 'idle' | 'uploading' | 'processing' | 'complete';
percent: number;
stage?: string;
elapsed: number;
};
}
```
**Implementation changes:**
1. Generate a UUID `clientJobId` client-side before each operation
2. Replace `fetch` with `XMLHttpRequest` for upload progress tracking
3. For AI tools: open an `EventSource` SSE connection to `/api/v1/jobs/{clientJobId}/progress` before starting the upload
4. Track elapsed time internally
5. Merge upload progress and SSE progress into unified `progress` state
**State location:** The `progress` object is local React state within the hook (via `useState`), not stored in the Zustand `useFileStore`. Progress is transient per-request and only relevant to the component rendering it. The existing Zustand fields (`processing`, `error`, `processedUrl`, `originalSize`, `processedSize`) remain in the store unchanged.
**Note:** The hook returns `progress.phase` with `'idle'` as a possible value. The `ProgressCard` component accepts only `'uploading' | 'processing' | 'complete'` — the card is simply not rendered when phase is `'idle'` (controlled by the `active` prop).
**Determining if a tool is AI-powered:** Import the tool category from `@stirling-image/shared`. If `category === 'ai'`, enable SSE progress. Otherwise, skip SSE.
### Tool Settings Components
All ~37 tool settings components that currently show `AIProgressBar` or just a spinner need to:
1. Use the new `progress` field from `useToolProcessor`
2. Render `<ProgressCard>` instead of `<AIProgressBar>` or inline `<Loader2>` spinner
3. Show the progress card in place of the process button while active
The `ProgressCard` replaces both the process button AND any progress indicator during processing. When complete, the process button reappears along with the download button.
## Backend
### `progress.ts` — Extend for Single-File Progress
Add a discriminated union type that encompasses both batch and single-file progress:
```typescript
interface BaseProgress {
jobId: string;
type: 'batch' | 'single';
}
interface BatchProgress extends BaseProgress {
type: 'batch';
status: 'processing' | 'completed' | 'failed';
totalFiles: number;
completedFiles: number;
failedFiles: number;
errors: Array<{ filename: string; error: string }>;
currentFile?: string;
}
interface SingleFileProgress extends BaseProgress {
type: 'single';
phase: 'processing' | 'complete' | 'failed';
stage?: string; // "Loading model", "Running inference"
percent: number; // 0-100
error?: string;
}
type ProgressEvent = BatchProgress | SingleFileProgress;
```
Update the listener map type to `Map<string, Set<(data: ProgressEvent) => void>>`. The existing `updateJobProgress` function wraps its data with `type: 'batch'`. Add a new `updateSingleFileProgress(progress: Omit<SingleFileProgress, 'type'>)` function that adds `type: 'single'` and pushes through the same listener infrastructure. The existing SSE endpoint `/api/v1/jobs/:jobId/progress` serves both — the frontend discriminates on the `type` field.
### `bridge.ts` — Stream Python Stderr
Switch from `execFile` to `spawn` for child process management:
```typescript
function runPythonWithProgress(
script: string,
args: string[],
options: {
jobId?: string;
onProgress?: (percent: number, stage: string) => void;
timeout?: number;
maxBuffer?: number;
}
): Promise<string>
```
- Use `child_process.spawn` instead of `execFile`
- Capture stderr line-by-line
- Parse each line as JSON: `{ "progress": number, "stage": string }`
- Non-JSON stderr lines are collected as error output (backward compatible)
- Forward parsed progress to `onProgress` callback
- Stdout is still collected as the final result (JSON output)
- Timeout and cleanup behavior remains the same
**Venv fallback handling:** The current `bridge.ts` retries with system `python3` if the venv binary throws ENOENT. With `spawn`, ENOENT surfaces as an `'error'` event on the child process (not a thrown exception). The implementation must listen for the `'error'` event with code `ENOENT` and retry with the fallback `python3` path, preserving the existing behavior.
### Custom AI Route Handlers — Extract `clientJobId`
The 5 AI tools have custom route handlers (not using `createToolRoute`). Each needs to:
1. Extract `clientJobId` from the multipart form data (alongside `file` and `settings`)
2. Pass it through to the AI wrapper function, which forwards it to `bridge.ts`
Files to modify:
- `apps/api/src/routes/tools/remove-background.ts`
- `apps/api/src/routes/tools/upscale.ts`
- `apps/api/src/routes/tools/blur-faces.ts`
- `apps/api/src/routes/tools/erase-object.ts`
- `apps/api/src/routes/tools/ocr.ts`
Non-AI tools use `createToolRoute` and do not need SSE progress — no changes needed to `tool-factory.ts`.
**JobId lifecycle:** Two IDs exist per request:
- `clientJobId` (from frontend): used only for SSE progress correlation. Sent by frontend in the multipart form. Frontend opens SSE at `/api/v1/jobs/{clientJobId}/progress` before uploading.
- `jobId` (server-generated): used for workspace paths and download URLs. Returned in the response as today. These never cross; they serve different purposes.
### AI Tool TypeScript Wrappers
Each AI tool wrapper (`packages/ai/src/*.ts`) needs to:
1. Accept optional `jobId` parameter
2. Pass it to `runPythonWithProgress`
3. Wire up the `onProgress` callback to `updateSingleFileProgress`
### Python Scripts — Emit Progress
Each Python script emits progress as JSON lines to stderr:
```python
import sys, json
def emit_progress(percent: int, stage: str):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
```
**Per-script progress granularity:**
#### `remove_bg.py`
- Replace existing `sys.stderr.write()` calls with `emit_progress()` JSON calls
- `emit_progress(10, "Loading model")` — before session creation
- `emit_progress(25, "Model loaded")` — after session ready
- `emit_progress(30, "Analyzing image")` — before `rembg.remove()`
- `emit_progress(80, "Background removed")` — after remove completes
- `emit_progress(90, "Applying alpha matting")` — if alpha matting enabled
- `emit_progress(95, "Saving result")` — before file write
#### `detect_faces.py`
- `emit_progress(10, "Loading face detection model")`
- `emit_progress(20, "Model ready")`
- `emit_progress(25, "Scanning for faces")`
- `emit_progress(50, "Found N faces")` — after detection
- `emit_progress(50 + (i/N)*40, "Blurring face {i+1} of {N}")` — per-face progress
- `emit_progress(95, "Saving result")`
#### `upscale.py`
- `emit_progress(10, "Loading upscale model")`
- `emit_progress(20, "Model ready")`
- For Real-ESRGAN with tiles: `emit_progress(20 + (tile/total)*70, "Upscaling tile {tile} of {total}")`
- For Lanczos fallback: `emit_progress(50, "Upscaling with Lanczos")`
- `emit_progress(95, "Saving result")`
#### `inpaint.py`
- `emit_progress(10, "Loading inpainting model")`
- `emit_progress(25, "Analyzing mask")`
- `emit_progress(40, "Inpainting region")`
- `emit_progress(85, "Refining edges")`
- `emit_progress(95, "Saving result")`
#### `ocr.py`
- `emit_progress(10, "Loading OCR engine")`
- `emit_progress(30, "Analyzing text regions")`
- `emit_progress(70, "Extracting text")`
- `emit_progress(95, "Formatting results")`
#### Smart Crop (no Python script — uses Sharp)
- Smart crop is implemented in TypeScript/Sharp, not Python. It does not go through `bridge.ts`, so it behaves like a fast tool (upload progress only, brief "Processing..." state). No SSE progress needed.
## File Changes Summary
### New Files
- `apps/web/src/components/common/progress-card.tsx` — new ProgressCard component
### Modified Files
- `apps/web/src/hooks/use-tool-processor.ts` — add XHR upload progress + SSE progress
- `apps/api/src/routes/progress.ts` — add ProgressEvent union type + updateSingleFileProgress function
- `apps/api/src/routes/tools/remove-background.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/upscale.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/blur-faces.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/erase-object.ts` — extract clientJobId, pass to AI wrapper
- `apps/api/src/routes/tools/ocr.ts` — extract clientJobId, pass to AI wrapper
- `packages/ai/src/bridge.ts` — switch to spawn, stream stderr progress, preserve venv fallback
- `packages/ai/src/background-removal.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/face-detection.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/upscaling.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/inpainting.ts` — accept progressJobId, wire progress callback
- `packages/ai/src/ocr.ts` — accept progressJobId, wire progress callback
- `packages/ai/python/remove_bg.py` — replace stderr writes with emit_progress() JSON calls
- `packages/ai/python/detect_faces.py` — add emit_progress() calls
- `packages/ai/python/upscale.py` — add emit_progress() calls
- `packages/ai/python/inpaint.py` — add emit_progress() calls
- `packages/ai/python/ocr.py` — add emit_progress() calls
- 33 tool settings components in `apps/web/src/components/tools/` — swap AIProgressBar/spinner for ProgressCard (4 currently use AIProgressBar, 29 use only a Loader2 spinner)
### Deleted Files
- `apps/web/src/components/common/ai-progress-bar.tsx` — replaced by ProgressCard
## Edge Cases
- **SSE connection fails**: Fall back to upload-progress-only mode. Processing phase shows "Processing..." without percentage. System still works.
- **Python script doesn't emit progress**: Bridge treats it as zero progress updates — frontend shows "Processing..." until POST completes. Backward compatible.
- **Very large file upload**: Upload phase shows real progress, which is the most useful part for large files.
- **User navigates away mid-processing**: XHR abort + SSE close. Backend process may continue but workspace cleanup handles orphaned files.
- **Multiple rapid requests**: Each gets its own jobId, progress is isolated. Previous progress card is replaced.
- **Cancellation**: No explicit cancel button in v1. Users can navigate away to abort (XHR abort + SSE close). A cancel button for long-running AI operations (60s+ BiRefNet) is a natural follow-up but out of scope for this spec.
File diff suppressed because it is too large Load Diff
@@ -1,197 +0,0 @@
# Interactive Crop Tool Design
## Overview
Replace the current numbers-only crop UI with a visual, interactive crop tool featuring a draggable rectangle overlay on the image (Photoshop-style), aspect ratio presets, bidirectional pixel inputs, rule-of-thirds grid, and keyboard controls.
## Approach
**`react-image-crop`** (~5KB, zero deps) provides the core overlay with 8 drag handles, aspect ratio locking, and dimmed excluded area. Custom enhancements: rule-of-thirds grid (SVG), bidirectional pixel inputs, aspect ratio preset buttons, keyboard nudging. Actual cropping remains server-side via Sharp.
## Interaction Model
### Pre-crop (image loaded, not yet processed)
- Right panel shows `CropCanvas` component instead of `ImageViewer`
- Image fills available space (maintaining aspect ratio)
- Crop rectangle overlays the image via `react-image-crop`
- Rectangle starts covering the full image; user drags inward
- Area outside rectangle dimmed at ~50% opacity
- 8 drag handles: 4 corners + 4 edge midpoints
### Post-crop (after clicking "Crop")
- Switches to existing `SideBySideComparison` view showing before/after
- Download button appears in settings panel
### Flow
1. Drop image -> crop canvas appears with full-image crop selection
2. Adjust crop rectangle (drag handles, move, keyboard, or type pixel values)
3. Click "Crop" -> processes via Sharp backend -> shows before/after comparison
4. Download or undo to re-crop
## Settings Panel (Left Side)
### Aspect Ratio
- "Free" button (default, selected state) -- unconstrained dragging
- Preset buttons in a wrapped grid: `1:1`, `4:3`, `3:2`, `16:9`, `2:3`, `4:5`, `9:16`
- Swap button next to active preset to flip landscape/portrait (e.g. 16:9 -> 9:16)
- When a preset is selected, crop rectangle snaps to that ratio and drag handles maintain it
### Position & Size (pixel inputs)
- 2x2 grid: X (left), Y (top), Width, Height
- Bidirectionally synced with visual crop overlay -- dragging updates numbers, typing updates rectangle
- Shows original image dimensions as reference (e.g. "of 1920" hint text)
- Values clamped to valid ranges
### Grid Overlay
- Toggle: "Rule of Thirds" (on by default)
- Renders 3x3 grid inside crop area as thin semi-transparent lines
### Process Section
- "Crop" button (or "Crop (N files)" for batch)
- Progress card during processing
- Download button after completion
## CropCanvas Component
New file: `apps/web/src/components/tools/crop-canvas.tsx`
- Wraps uploaded image with `ReactCrop` from `react-image-crop`
- Uses percentage-based crop coordinates internally (overlay works at any display size)
- Converts to absolute pixels when syncing with settings inputs and submitting to API
- Image rendered with `object-fit: contain` to fill available space
### Dimension Badge
- Small floating label near bottom-right of crop area
- Shows resulting dimensions in real-time (e.g. "640 x 480")
### Rule of Thirds Grid
- SVG overlay inside crop area
- 4 lines (2 horizontal, 2 vertical) at 1/3 and 2/3 positions
- Thin white lines at ~40% opacity
### Keyboard Controls
- Arrow keys: nudge crop box by 1px
- Shift+Arrow: nudge by 10px
- Enter: apply crop (submit form)
- Escape: reset crop to full image
### Touch Support
- Handled by `react-image-crop` out of the box
## State Management
Crop state is owned by `tool-page.tsx` and passed **bidirectionally** to both `CropSettings` and `CropCanvas`. This differs from the rotate tool's one-way `onPreviewTransform` callback — crop requires both components to read and write the same state.
State shape:
```typescript
interface CropState {
crop: Crop; // react-image-crop's Crop type (percentage-based)
aspect: number | undefined; // locked aspect ratio or undefined for free
showGrid: boolean; // rule of thirds toggle
imgDimensions: { width: number; height: number } | null; // natural image dimensions
}
```
`tool-page.tsx` holds `[cropState, setCropState] = useState<CropState>(...)` and passes:
- To `CropCanvas`: `cropState`, `onCropChange`, `imageSrc` (from `originalBlobUrl`), `onImageLoad` (to capture natural dimensions)
- To `CropSettings`: `cropState`, `onCropChange`, `onAspectChange`, `onGridToggle`
### CropSettings Prop Interface
```typescript
interface CropSettingsProps {
cropState: CropState;
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
}
```
`CropSettings` continues to use `useToolProcessor("crop")` internally for submission. The pixel input fields convert between percentage-based `Crop` and absolute pixels using `cropState.imgDimensions`.
### CropCanvas Prop Interface
```typescript
interface CropCanvasProps {
imageSrc: string;
cropState: CropState;
onCropChange: (crop: Crop) => void;
onImageLoad: (dims: { width: number; height: number }) => void;
}
```
`CropCanvas` reads `imageSrc` as a prop (sourced from `originalBlobUrl` in the file store). It reports natural image dimensions via `onImageLoad` when the `<img>` fires its load event.
### Keyboard Focus
`CropCanvas` container has `tabIndex={0}` and captures focus on mount. Arrow key handlers call `e.preventDefault()` to suppress page scrolling. The component uses a `keydown` event listener on its container div.
## Rendering Path in tool-page.tsx
Add a new set: `const INTERACTIVE_CROP_TOOLS = new Set(["crop"])`.
The main area rendering logic adds a new branch **before** the existing `SIDE_BY_SIDE_TOOLS` check:
```
if (INTERACTIVE_CROP_TOOLS.has(toolId) && hasFile && !hasProcessed) {
return <CropCanvas ... />;
}
```
- **Pre-crop**: `CropCanvas` renders (interactive overlay on image)
- **Post-crop**: Falls through to `SIDE_BY_SIDE_TOOLS` which already includes `"crop"` -> shows `SideBySideComparison`
- **Undo**: `undoProcessing()` clears `processedUrl`, which causes `hasProcessed` to become false, routing back to `CropCanvas` (not `ImageViewer`)
The `ToolSettingsPanel` routing passes crop props to `CropSettings`:
```
if (toolId === "crop") return <CropSettings cropState={...} onCropChange={...} ... />;
```
## Batch / Multi-Image Behavior
When multiple files are loaded (`files.length > 1`), the interactive crop canvas is **not shown** — the existing `MultiImageViewer` renders instead (this check comes first in the rendering logic). The crop settings fall back to the pixel-input-only mode (no visual overlay) for batch, since different images may have different dimensions.
Single-image interactive cropping is the primary use case. Batch cropping with identical pixel coordinates is an advanced/power-user flow that works via the numeric inputs alone.
## Data Flow
1. User adjusts crop rectangle -> `react-image-crop` emits percentage-based `Crop` object
2. `CropCanvas` calls `onCropChange(crop)` -> `tool-page.tsx` updates `cropState`
3. `CropSettings` reads `cropState` and converts percentages to absolute pixels using `imgDimensions`
4. User types in pixel inputs -> `CropSettings` converts back to percentages and calls `onCropChange`
5. On submit, `CropSettings` converts final `cropState.crop` to `{ left, top, width, height }` (pixels) and sends to `useToolProcessor("crop")`
6. Backend processes via Sharp `.extract()`, returns `downloadUrl`
7. `tool-page.tsx` rendering falls through to `SideBySideComparison`
## Backend
No changes needed. Existing crop API endpoint accepts `{ left, top, width, height }` in pixels. Note: the backend currently hardcodes output as `image/png` regardless of input format — this is a pre-existing limitation not addressed in this spec.
## Files to Modify
- `apps/web/src/components/tools/crop-settings.tsx` -- redesign with aspect ratio presets, synced pixel inputs
- `apps/web/src/pages/tool-page.tsx` -- add crop canvas rendering path, lift crop state, add `INTERACTIVE_CROP_TOOLS` set
- **New:** `apps/web/src/components/tools/crop-canvas.tsx` -- visual cropper component
- `apps/web/package.json` -- add `react-image-crop` dependency
## Files NOT Modified
- Backend API routes
- Image engine operations
- Shared constants/types
- Docker (just rebuild)
## Dependencies
- `react-image-crop` (~5KB gzipped, zero transitive dependencies)
@@ -1,233 +0,0 @@
# Multi-Image UX Redesign
## Problem
When uploading multiple images, the tool page only previews the first image. There's no way to navigate between uploaded files, processing only handles one file, and downloads are single-file only. The strip-metadata tool doesn't show what metadata exists before removing it.
## Design Decisions
- **Processing model**: Hybrid — "Apply to all" batch processing with navigation to preview individual files before/after processing.
- **Thumbnail layout**: Bottom filmstrip strip below the main preview area. Horizontal scroll when many images. Left/right arrows on the main image.
- **Download model**: Individual per-file downloads + "Download All as ZIP" option.
- **Metadata display**: Fully parsed EXIF/GPS/ICC/XMP shown in a categorized table before stripping. GPS gets a red privacy warning badge.
## Architecture
### 1. File Store (`file-store.ts`)
Evolve from single-file state to multi-file aware state.
**Current state**: `files[]`, `originalBlobUrl` (first file only), `processedUrl` (single), `selectedFileName`, `selectedFileSize`.
**New state**:
```typescript
interface FileEntry {
file: File;
blobUrl: string;
/** Server download URL (single-file) or client blob URL (batch/ZIP extraction). */
processedUrl: string | null;
processedSize: number | null;
originalSize: number;
status: 'pending' | 'processing' | 'completed' | 'failed';
error: string | null;
}
interface FileState {
entries: FileEntry[];
selectedIndex: number;
/** Cached ZIP blob from batch processing, for "Download All" button. */
batchZipBlob: Blob | null;
batchZipFilename: string | null;
// Derived getters
currentEntry: FileEntry | null;
hasFiles: boolean;
allProcessed: boolean;
// Actions
setFiles: (files: File[]) => void;
addFiles: (files: File[]) => void;
removeFile: (index: number) => void;
setSelectedIndex: (index: number) => void;
navigateNext: () => void;
navigatePrev: () => void;
updateEntry: (index: number, updates: Partial<FileEntry>) => void;
setBatchZip: (blob: Blob, filename: string) => void;
/** Reset all entries to pending state, clear processed results. Replaces the old `undoProcessing()`. */
undoProcessing: () => void;
reset: () => void;
}
```
Key changes:
- Blob URLs generated for ALL files, not just first.
- `selectedIndex` replaces `selectedFileName` for navigation.
- Per-file processing state (`status`, `processedUrl`, `processedSize`).
- `processedUrl` can be either a server download path (single-file processing) or a client-side blob URL (from batch ZIP extraction). Both work as `src` for `<img>` or `<a href>`.
- `addFiles()` for the "+ Add more" button (appends to existing).
- `removeFile()` for removing individual files.
- `batchZipBlob` / `batchZipFilename` — cached ZIP blob from batch processing. The "Download All (ZIP)" button uses this directly instead of re-requesting.
- `undoProcessing()` — replaces the old single-file version. Resets all entries' `processedUrl`, `processedSize`, `status` back to `pending`, clears `error`, revokes any client-side blob URLs from processed results, and clears `batchZipBlob`.
- Blob URL cleanup on unmount/reset for all entries.
- Memory: the frontend enforces the same `MAX_BATCH_SIZE` limit as the backend. For large batches, thumbnails use the same blob URLs as the full preview (browser handles scaling via CSS `object-fit`).
### 2. Multi-Image Viewer Component
New `MultiImageViewer` component wraps the existing `ImageViewer`. Only renders the navigation chrome when `entries.length > 1`.
**Structure**:
```
┌──────────────────────────────────┐
│ [zoom toolbar] │
├──────────────────────────────────┤
│ Main Image │ │ ← arrows overlay, "2/5" badge
├──────────────────────────────────┤
│ filename.jpg 4032x3024 2.4MB │
├──────────────────────────────────┤
│ [thumb] [thumb] [thumb] [thumb] │ ← filmstrip, horizontal scroll
└──────────────────────────────────┘
```
**Props**: Uses file store directly (no props drilling). Renders `ImageViewer` for the currently selected entry, or `BeforeAfterSlider` if the current entry has a `processedUrl`.
**Navigation**:
- Left/right arrow buttons (circular, semi-transparent, positioned over image).
- Keyboard: left/right arrow keys, **only when the viewer container has focus** (not when a tool-specific input like crop handles or text fields is focused). Use `onKeyDown` on the viewer container div with `tabIndex={0}`, not a global listener.
- Click thumbnail to jump to that image.
- "N / M" counter badge in top-right of image area.
**Filmstrip**:
- Horizontal row of thumbnails (52x38px) with 6px gap.
- Active thumbnail has `outline: 2px solid primary` with 1px offset.
- Processed thumbnails have a green checkmark badge (14px circle, top-right corner).
- Failed thumbnails have a red X badge.
- Horizontal scroll via CSS `overflow-x: auto` with `scroll-behavior: smooth`.
- Auto-scroll to keep selected thumbnail visible (use `scrollIntoView({ block: 'nearest', inline: 'nearest' })`).
**Single-file fallback**: When only 1 file is uploaded, render the existing `ImageViewer` directly — no arrows, no filmstrip, no counter. Identical to current behavior.
### 3. Tool Processor (`use-tool-processor.ts`)
Add batch processing alongside the existing single-file processing.
**New method: `processAllFiles(entries, settings)`**:
Uses `fetch()` (not XHR) to POST to `/api/v1/tools/{toolId}/batch`:
- Build `FormData` with all files + settings JSON + `clientJobId`.
- Use `fetch()` so we can read response headers immediately — specifically the `X-Job-Id` header to correlate with SSE progress.
**Batch progress via SSE**:
- The batch endpoint needs a small change: parse `clientJobId` from multipart and use it as the job ID (instead of generating a random one server-side). This lets the client open the SSE connection _before_ the upload completes, matching the existing AI tool pattern.
- SSE events update per-file `status` in the store via `updateEntry()`. The `currentFile` field in `JobProgress` maps to the entry by filename.
- Overall progress: "Processing 3 / 5 files..." shown in a `ProgressCard`.
**ZIP extraction after batch completes**:
- The `fetch()` response body is consumed as a `Blob`.
- The blob is stored in `batchZipBlob` for the "Download All" button.
- Use `fflate` to decompress the ZIP in the browser.
- For each file in the ZIP, create a blob URL and call `updateEntry(index, { processedUrl, processedSize, status: 'completed' })`.
- Match ZIP entries to store entries **by index/order** (the batch endpoint processes files in submission order, and `archiver` appends results in completion order via p-queue — but since we use `concurrency: 1` equivalent ordering, or we can sort by original filename). To be safe, the batch endpoint will include an `X-File-Order` response header listing original filenames in order, so the client can map ZIP entries back to store entries even if `getUniqueName()` renamed duplicates.
- **Important**: per-file `status` updates to `'completed'` happen via SSE during processing (for progress UI), but `processedUrl` is only populated after the full ZIP is downloaded and extracted. The UI shows a checkmark on the thumbnail as soon as SSE reports completion, but the before/after preview for that image becomes available only after ZIP extraction.
**Single-file processing**: Keep existing `processFiles()` method unchanged for tools that only work with one file (compare, collage, etc.).
### 4. Tool Page (`tool-page.tsx`)
**Changes to left panel**:
- `FileSelectionInfo` replaced with richer file summary:
- Shows "Files (N)" with count.
- "+ Add more" link that opens file picker (calls `addFiles`).
- Currently selected filename + size.
- "Clear all" to reset.
- Process button text changes: "Process All (N files)" when N > 1, "Process" when N = 1.
**Changes to main area**:
- Replace direct `ImageViewer` usage with `MultiImageViewer`.
- `MultiImageViewer` handles all states: single image, multiple images, pre-process, post-process.
- When processed and multiple files: arrows navigate between before/after results per image.
**Download section in left panel (post-processing)**:
- "Download This" — downloads current file's processed result (uses `processedUrl` from the entry).
- "Download All (ZIP)" — creates a download link from `batchZipBlob` stored in the file store. No re-request needed.
- Per-file stats: "2.4 MB → 2.1 MB (300 KB)".
- Overall stats: "Processed: 5/5, Total saved: 1.2 MB".
### 5. Strip Metadata Enhancement
#### Backend: Existing `/inspect` endpoint
The strip-metadata tool already has a `POST /api/v1/tools/strip-metadata/inspect` endpoint that returns parsed EXIF, GPS, ICC, and XMP metadata. The frontend already calls this endpoint and displays the results in collapsible sections with a GPS privacy warning. **No new backend endpoint is needed.**
The existing response shape:
```typescript
interface MetadataResult {
filename: string;
fileSize: number;
exif?: Record<string, unknown> | null;
exifError?: string;
gps?: Record<string, unknown> | null;
icc?: Record<string, string> | null;
xmp?: Record<string, string> | null;
}
```
This already works. The only change needed is making the metadata display **multi-file aware**.
#### Frontend: `StripMetadataSettings` changes for multi-file
The existing metadata auto-fetch logic fetches metadata for `files[0]`. Change it to:
- Fetch metadata for `entries[selectedIndex].file` instead of `files[0]`.
- Cache metadata per-file to avoid re-fetching when navigating between images (use a `Map<string, MetadataResult>` keyed by file identity).
- When the user navigates to a different image via the filmstrip, the metadata panel updates to show that image's metadata.
- The strip options and "Process All" button apply to all files uniformly.
### 6. Batch Endpoint Change (`batch.ts`)
One small backend change: accept `clientJobId` from the multipart form and use it as the job ID.
```typescript
// In the multipart parsing loop, add:
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
// Then use it:
const jobId = clientJobId || randomUUID();
```
This lets the client open the SSE connection before upload completes, enabling real-time progress tracking for batch operations.
### 7. Dropzone Changes
**"+ Add more" support**: New `addFiles()` action in store. The dropzone on the tool page is replaced by the image viewer after upload, but a small "+ Add more" link in the left panel opens a file picker dialog (reuses the same `input.click()` pattern from the existing dropzone).
**No dropzone changes needed for the main drop area**: The existing dropzone handles multi-file upload correctly. After files are uploaded, it's replaced by `MultiImageViewer`.
### 8. Docker Build
Update the local Docker build to ensure the UI changes are testable:
- No new system dependencies needed (`fflate` is pure JS).
- Ensure `pnpm install` picks up new deps and frontend builds correctly.
## New Dependencies
- `fflate` — Lightweight ZIP decompression in the browser. Pure JS. Added to `apps/web`.
## Files Changed
### New files:
- `apps/web/src/components/common/multi-image-viewer.tsx` — Wrapper with filmstrip + arrows
- `apps/web/src/components/common/thumbnail-strip.tsx` — Horizontal thumbnail filmstrip
### Modified files:
- `apps/web/src/stores/file-store.ts` — Multi-file state with per-file tracking
- `apps/web/src/hooks/use-tool-processor.ts` — Add `processAllFiles` batch method
- `apps/web/src/pages/tool-page.tsx` — Use `MultiImageViewer`, update left panel
- `apps/web/src/components/tools/strip-metadata-settings.tsx` — Multi-file metadata display
- `apps/api/src/routes/batch.ts` — Accept `clientJobId` from multipart
## Out of Scope
- Per-file different settings (all files get same settings in batch mode).
- Drag-to-reorder files in the filmstrip.
- Pipeline/chaining multiple tools on batch results.
- Mobile-specific filmstrip optimizations (will use same horizontal scroll, works fine on touch).
@@ -1,94 +0,0 @@
# Resize & Rotate/Flip UX Redesign
## Problem
The before/after comparison slider is a poor fit for resize and rotate/flip tools:
- **Resize** overlays two different-sized images — doesn't communicate anything useful. Users care about "how big will it be?" not pixel-level comparison.
- **Rotate/Flip** transformations are self-evident. A slider adds nothing.
The resize settings also use technical jargon (contain, cover, fill, inside, outside) that confuses layman users.
## Design
### Resize: Tab-Based Settings with Presets Front-and-Center
Replace the current mode toggle (Pixels/Percentage) with three tabs:
#### Presets Tab (default)
- Single-column scrollable list of cards grouped by platform (Instagram, Twitter/X, Facebook, YouTube, LinkedIn) — the sidebar is 18rem wide, so single-column avoids cramped cards
- Each card shows: platform icon, preset name (e.g., "Post", "Story", "Header"), dimensions (e.g., "1080 × 1080")
- Clicking a card selects it (highlighted border), clicking again deselects
- Selected preset populates the dimensions automatically
- Presets use "Crop to fit" (cover) as the default fit mode — this is the expected behavior for social media sizing
- "Don't enlarge" checkbox available below preset grid
- Process button at bottom
#### Custom Size Tab
- Width and Height number inputs
- Aspect ratio lock toggle between them (link/unlink icon)
- Fit mode as 3 plain-language options:
- "Crop to fit" (maps to sharp `cover`)
- "Fit inside" (maps to sharp `contain`)
- "Stretch" (maps to sharp `fill`)
- Remove "inside" and "outside" fit modes — they confuse laymen
- "Don't enlarge" checkbox
- Process button at bottom
#### Scale Tab
- Percentage number input
- Quick-select buttons: 25% | 50% | 75%
- `fit` and `withoutEnlargement` are intentionally omitted — percentage scaling doesn't need them. API defaults (`contain`, `false`) are sent.
- Process button at bottom
### Resize: Side-by-Side Result Display
Replace the before/after slider with side-by-side thumbnails:
- Two image thumbnails side by side, each fitted within its half
- **Left**: "Original" label, the original image, dimensions below (e.g., "3000 × 2000"), file size (e.g., "2.4 MB")
- **Right**: "Resized" label, the processed image, new dimensions below (e.g., "1080 × 720"), file size (e.g., "340 KB")
- File size savings shown between/below (e.g., "86% smaller")
- Checkerboard background for transparency (same pattern as current viewer)
- **Dimensions**: Read client-side from blob URLs using `Image.onload` to get `naturalWidth`/`naturalHeight`. No backend changes needed.
- **File sizes**: Use existing `originalSize`/`processedSize` from the API response (already available in the file store).
- **Mobile**: On small screens, thumbnails stack vertically instead of side-by-side.
- Review panel (undo, download, continue editing) remains unchanged
### Rotate/Flip: Live CSS Preview
Replace the "process then compare" flow with live preview:
**State architecture**: `rotate-settings.tsx` emits transform values (angle, flipH, flipV) via a callback prop from `tool-page.tsx`. `tool-page.tsx` holds the preview transform state and passes it down to `ImageViewer` as optional props (`cssRotate`, `cssFlipH`, `cssFlipV`). `ImageViewer` applies these as CSS `transform: rotate(Xdeg) scaleX(Y) scaleY(Z)`.
- When a file is loaded, it shows in the image viewer as normal
- As the user adjusts controls (rotate buttons, angle slider, flip toggles), CSS transforms update the preview in real-time — no server call
- Controls stay the same: quick rotate 90 left/right, angle slider 0-360, horizontal/vertical flip toggles
- **Non-90-degree angles**: CSS preview will clip corners (the image rotates within its container). This is acceptable as a preview — the final server output will have proper canvas extension. This is a known discrepancy.
- The "Process" button label changes to "Apply" to signal finality
- "Apply" button remains disabled when no changes are made (angle=0, no flips) — same as current behavior
- Clicking "Apply" sends to the server, produces the final file
### Rotate/Flip: Result Display
After applying:
- Result shows in the standard ImageViewer (no before/after slider, no side-by-side)
- The transformation is self-evident
- Review panel appears with undo/download options
### Other Tools
The `BeforeAfterSlider` remains for all other tools (compress, filters, etc.). Only resize and rotate/flip get special treatment. In `tool-page.tsx`, branch on `toolId` using a set (e.g., `TOOLS_WITHOUT_SLIDER`) for extensibility.
## Files to Modify
### Frontend
- `apps/web/src/components/tools/resize-settings.tsx` — rewrite with tab-based UI
- `apps/web/src/components/tools/rotate-settings.tsx` — emit transform values via callback, change button label to "Apply"
- `apps/web/src/pages/tool-page.tsx` — hold preview transform state, conditionally render side-by-side for resize, ImageViewer for rotate/flip, BeforeAfterSlider for everything else
- `apps/web/src/components/common/image-viewer.tsx` — accept optional CSS transform props for live rotate/flip preview
- `apps/web/src/components/common/side-by-side-comparison.tsx` — new component for side-by-side thumbnail comparison with dimensions and file size
### No Backend Changes
- Resize and rotate API routes remain unchanged
- Image engine operations remain unchanged
- Only the frontend presentation and interaction model changes
@@ -1,313 +0,0 @@
# Files Page — Design Spec
## Overview
A persistent file manager for Stirling Image, modeled after Stirling-PDF's Files tab. Users can upload images, browse recent files, view file details with image metadata, and re-open files for further processing. Files processed through any tool automatically save the result as a new version, building a version chain (V1 → V2 → V3...) with tool attribution.
### Scope
- **In scope:** Recent files view, file upload, file details panel, version tracking, search, bulk select/delete/download, "Open File" navigation
- **Out of scope:** Google Drive integration (placeholder shown as "Coming Soon"), file sharing, folder organization
### Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Persistence | Server-persisted with SQLite metadata + disk storage | Survives restarts, enables version history |
| Version tracking | Auto-save on tool processing | Matches Stirling-PDF; makes Files page useful |
| "Open File" behavior | Navigate to home page with file pre-loaded | Most flexible — user can pick any tool |
| Auth | Required when auth is enabled; works without auth in single-user mode | Files are per-user when auth is on, shared when off |
---
## 1. Database Schema
New table `user_files` in the existing SQLite database:
```sql
CREATE TABLE user_files (
id TEXT PRIMARY KEY, -- UUID
user_id TEXT, -- FK to users.id (nullable for no-auth mode)
original_name TEXT NOT NULL, -- Original filename as uploaded
stored_name TEXT NOT NULL, -- UUID-based name on disk
mime_type TEXT NOT NULL, -- e.g. "image/jpeg"
size INTEGER NOT NULL, -- File size in bytes
width INTEGER, -- Image width in px
height INTEGER, -- Image height in px
version INTEGER NOT NULL DEFAULT 1, -- Version number
parent_id TEXT, -- FK to user_files.id (previous version)
tool_chain TEXT, -- JSON array of tool IDs applied, e.g. ["resize", "compress"]
created_at INTEGER NOT NULL, -- Unix timestamp (ms)
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (parent_id) REFERENCES user_files(id)
);
CREATE INDEX idx_user_files_user_id ON user_files(user_id);
CREATE INDEX idx_user_files_created_at ON user_files(created_at);
CREATE INDEX idx_user_files_parent_id ON user_files(parent_id);
```
### Drizzle ORM Definition
```typescript
export const userFiles = sqliteTable("user_files", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id),
originalName: text("original_name").notNull(),
storedName: text("stored_name").notNull(),
mimeType: text("mime_type").notNull(),
size: integer("size").notNull(),
width: integer("width"),
height: integer("height"),
version: integer("version").notNull().default(1),
parentId: text("parent_id"),
toolChain: text("tool_chain"), // JSON string: ["resize", "compress"]
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
});
```
---
## 2. File Storage
- **Storage directory:** `{DATA_DIR}/files/` (configurable via `FILES_STORAGE_PATH` env var, default: `/data/files/`)
- **File naming:** `{uuid}.{ext}` — avoids collisions, the original name is in the DB
- **No subdirectories per user** — a flat directory with UUID names is simpler and avoids path traversal issues
- **Cleanup:** Files deleted from the DB also have their disk file removed. No cron needed — deletion is explicit.
---
## 3. API Routes
All routes prefixed with `/api/v1/files`. Auth required when auth is enabled.
### 3.1 List Files (Recent)
```
GET /api/v1/files?search=&limit=50&offset=0
```
Returns the latest version of each file group (grouped by root parent), sorted by `created_at` DESC.
**Response:**
```json
{
"files": [
{
"id": "uuid",
"originalName": "beach_sunset.jpg",
"mimeType": "image/jpeg",
"size": 2400000,
"width": 1920,
"height": 1080,
"version": 3,
"toolChain": ["resize", "compress"],
"createdAt": "2026-03-24T21:15:00Z"
}
],
"total": 42
}
```
### 3.2 Upload Files
```
POST /api/v1/files/upload
Content-Type: multipart/form-data
Body: file (one or more image files)
```
Validates each file (magic bytes, supported format), extracts dimensions via Sharp, stores to disk, creates DB record with version=1.
**Response:**
```json
{
"files": [
{ "id": "uuid", "originalName": "photo.jpg", "size": 2400000, "version": 1 }
]
}
```
### 3.3 Get File Details
```
GET /api/v1/files/:id
```
Returns full metadata for a single file, including all versions in the chain.
**Response:**
```json
{
"id": "uuid",
"originalName": "beach_sunset.jpg",
"mimeType": "image/jpeg",
"size": 2400000,
"width": 1920,
"height": 1080,
"version": 3,
"toolChain": ["resize", "compress"],
"createdAt": "2026-03-24T21:15:00Z",
"versions": [
{ "id": "uuid-v1", "version": 1, "size": 5000000, "toolChain": [], "createdAt": "..." },
{ "id": "uuid-v2", "version": 2, "size": 3000000, "toolChain": ["resize"], "createdAt": "..." },
{ "id": "uuid-v3", "version": 3, "size": 2400000, "toolChain": ["resize", "compress"], "createdAt": "..." }
]
}
```
### 3.4 Download File
```
GET /api/v1/files/:id/download
```
Streams the file from disk with `Content-Disposition: attachment`.
### 3.5 Get File Thumbnail
```
GET /api/v1/files/:id/thumbnail
```
Returns a 300px-wide JPEG thumbnail (generated on-the-fly via Sharp, can be cached later).
### 3.6 Delete Files
```
DELETE /api/v1/files
Body: { "ids": ["uuid1", "uuid2"] }
```
Deletes specified files from DB and disk. When deleting a file that has child versions, deletes the entire chain.
### 3.7 Save Tool Result (internal — called by tool-factory)
```
POST /api/v1/files/save-result
Body: { parentId?: string, toolId: string, buffer: <binary>, filename: string }
```
This is an internal route called by the tool processing pipeline. It:
1. Looks up the parent file (if parentId provided)
2. Computes the new version number (parent.version + 1)
3. Builds the tool chain (parent.toolChain + [toolId])
4. Stores the file to disk
5. Creates the DB record
6. Returns the new file record
---
## 4. Tool Processing Integration
The tool-factory needs a small addition: after successfully processing a file, if the input file came from the Files store (identified by a `fileId` parameter in the request), save the result as a new version.
**Flow:**
1. User clicks "Open File" on Files page → navigates to home with file loaded
2. The file-store entry carries a `fileId` (the user_files.id from the server)
3. User picks a tool, adjusts settings, clicks Process
4. Tool processes the file as normal
5. After success, the response includes the new `fileId` of the saved version
6. The file-store entry updates its `fileId` to the new version
**Changes to tool-factory.ts:**
- Accept optional `fileId` field in multipart body
- After processing, call the save-result logic internally (not an HTTP call — direct function call)
- Return `fileId` in the response alongside existing `jobId` and `downloadUrl`
---
## 5. Frontend
### 5.1 New Files Page (`apps/web/src/pages/files-page.tsx`)
Three-panel layout inside `AppLayout`:
- **Left panel (180px):** "My Files" heading, nav items (Recent, Upload Files, Google Drive disabled)
- **Center panel (flex):** Search bar, toolbar (select all, delete, download), scrollable file list
- **Right panel (240px):** Thumbnail preview, File Details card, "Open File" button. Hidden when no file selected.
### 5.2 Components
```
apps/web/src/components/files/
├── files-nav.tsx # Left nav (Recent, Upload, Drive placeholder)
├── file-list.tsx # Center: search + toolbar + file rows
├── file-list-item.tsx # Single file row (checkbox, name, size, date, version, tools)
├── file-details.tsx # Right panel (thumbnail, metadata, Open File)
├── file-upload-area.tsx # Dropzone for the Upload Files tab
```
### 5.3 Files Store (`apps/web/src/stores/files-page-store.ts`)
Separate Zustand store for the Files page (distinct from the existing `file-store.ts` which manages tool processing state):
```typescript
interface FilesPageState {
// Data
files: UserFile[];
selectedFileId: string | null;
selectedFileIds: Set<string>; // for bulk operations
total: number;
// UI state
activeTab: "recent" | "upload";
searchQuery: string;
loading: boolean;
// Actions
fetchFiles: () => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
deleteFiles: (ids: string[]) => Promise<void>;
selectFile: (id: string) => void;
toggleFileSelection: (id: string) => void;
selectAll: () => void;
deselectAll: () => void;
setSearchQuery: (query: string) => void;
setActiveTab: (tab: "recent" | "upload") => void;
}
```
### 5.4 "Open File" Flow
When user clicks "Open File":
1. Fetch the file blob from `GET /api/v1/files/:id/download`
2. Create a `File` object from the blob
3. Add it to the existing `file-store` with the `fileId` attached
4. Navigate to `/` (home page)
5. Home page sees the file in the store and shows the tool selection + preview
### 5.5 Routing
Add to `App.tsx`:
```typescript
<Route path="/files" element={<FilesPage />} />
```
Re-add Files to sidebar and mobile nav (reverting the earlier removal).
### 5.6 Mobile Layout
On mobile, the three-panel layout collapses:
- Left nav becomes tabs at the top (Recent | Upload)
- File list takes full width
- File details shows as a bottom sheet when a file is tapped
- "Open File" button is prominent in the bottom sheet
---
## 6. Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FILES_STORAGE_PATH` | `/data/files` | Directory for persistent file storage |
| `MAX_STORED_FILES` | `500` | Maximum files per user (0 = unlimited) |
---
## 7. Error Handling
- **Upload validation:** Same as existing file validation (magic bytes, format, size limit)
- **Storage full:** Return 507 if disk write fails
- **File not found:** Return 404 if file ID doesn't exist or belongs to another user
- **Auth:** Return 401 if auth is enabled and user is not authenticated
@@ -1,200 +0,0 @@
# Settings Phase 1 — Admin Control Panel
Inspired by Stirling-PDF's settings system, this phase adds five features to the existing settings dialog to make Stirling-Image feel like a serious self-hosted product.
## Decisions
- Extend existing settings dialog (no separate admin panel route)
- Teams are organizational labels only — no per-team permissions
- Tool disabling and feature flags require server restart
- Temp file management is minimal (max age + startup cleanup)
- Custom branding is app name + logo (no favicon, no custom theme colors)
## 1. Teams Management
**New "Teams" tab in settings dialog.**
Simple CRUD for teams. Users are assigned to teams from the existing People section.
### Database
New `teams` table:
| Column | Type | Notes |
|---|---|---|
| id | text | Primary key (UUID) |
| name | text | Unique, not null |
| createdAt | integer (timestamp) | Auto-set |
Migration steps (single Drizzle migration file):
1. Create `teams` table
2. Insert a "Default" team with a known UUID
3. Collect all distinct `users.team` string values; for each non-"Default" value, insert a new team row
4. Update `users.team` from the string value to the corresponding team UUID
5. Keep `users.team` as a plain `text` column (no DB-level FK — SQLite doesn't support adding FK constraints via ALTER TABLE). Enforce the relationship at the application level.
Note: The existing `0003_add_team_to_users.sql` migration added the `team` column as free text. This new migration extends that by creating the `teams` table and converting values.
### Team Name Validation
- 1-50 characters
- Trimmed (no leading/trailing whitespace)
- Unique (case-insensitive)
- The "Default" team cannot be deleted (it's the fallback for new users)
### API Routes
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/teams | auth | List all teams with member count |
| POST | /api/v1/teams | admin | Create team (body: `{ name }`) |
| PUT | /api/v1/teams/:id | admin | Rename team (body: `{ name }`) |
| DELETE | /api/v1/teams/:id | admin | Delete team (fails if team has members or is "Default") |
### UI
- Table with columns: Team Name, Total Members, actions (three-dot menu: Rename, Delete)
- "+ Create New Team" button
- Delete blocked with message if team has assigned members or is the Default team
- People section's team assignment dropdown pulls from teams table
## 2. Tool Disabling
**New "Tools" tab in settings dialog.**
Admin can globally disable specific tools. Disabled tools are hidden from all users.
### Settings Key
`disabledTools` — JSON array of tool IDs. Default: `"[]"`
### Behavior
- On save, shows "Restart required for changes to take effect" banner
- On API startup, server reads `disabledTools` and skips registering those tool routes (server-side enforcement)
- Frontend also filters disabled tools from the tool panel for immediate visual feedback after save, but API routes remain active until restart
- Pipelines containing disabled tools: step renders but shows "tool unavailable" badge
### UI
- Searchable list of all registered tools
- Each tool has a toggle (on = enabled, off = disabled)
- Search/filter bar at top
- Tools grouped by category for easier scanning
- "Restart required" banner appears after any change is saved
## 3. Feature Flags
**Added to existing "System Settings" section.**
Single toggle controlling visibility of experimental tools.
### Settings Key
`enableExperimentalTools``"true"` or `"false"`. Default: `"false"`
### Tool Registry Change
Reuse the existing `alpha?: boolean` field on the `Tool` type in `packages/shared/src/types.ts`. Rename it to `experimental?: boolean` for clarity (update all references). Tools marked experimental are hidden unless the flag is enabled.
### Behavior
- Works independently of tool disabling (a tool can be both experimental AND manually disabled)
- On save, shows "Restart required" banner
- When flag is off, experimental tools are excluded from: tool panel, fullscreen grid, pipeline step picker
### UI
- Single toggle row in System Settings: "Enable Experimental Tools" with description "Show tools that are still in development. These may be unstable."
## 4. Temp File Management
**Added to existing "System Settings" section under "File Management" sub-heading.**
Admin controls how long processed files persist and whether to clean on startup.
### Settings Keys
| Key | Default | Description |
|---|---|---|
| tempFileMaxAgeHours | "24" | Hours before temp files are eligible for cleanup |
| startupCleanup | "true" | Whether to run cleanup on server boot |
### Behavior
- The cleanup function re-reads `tempFileMaxAgeHours` from the settings DB on every cycle (not cached at startup). If the setting is not set, falls back to the `FILE_MAX_AGE_HOURS` env var (default 24). DB setting takes precedence over env var.
- On startup, if `startupCleanup` is true, cleanup runs asynchronously (does not block server startup — matches current behavior where `startCleanupCron()` is non-blocking)
- Changes take effect on next cleanup cycle (no restart required)
### UI
- Number input: "Max File Age (hours)" with description "How long processed files are kept before automatic cleanup"
- Toggle: "Startup Cleanup" with description "Clean up old temporary files when the server starts"
## 5. Custom Branding — Logo Upload
**Added to existing "System Settings" section, below App Name.**
Admin uploads a custom logo displayed in the sidebar/navbar.
### API Routes
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/settings/logo | admin | Upload logo (PNG/SVG/JPEG, max 500KB) |
| GET | /api/v1/settings/logo | public | Serve custom logo (404 if none) |
| DELETE | /api/v1/settings/logo | admin | Remove custom logo |
### Settings Key
`customLogo``"true"` or `"false"`. Default: `"false"`
### Behavior
- All uploaded logos are converted to PNG and stored to `data/branding/logo.png` (SVGs are rasterized, JPEGs are re-encoded)
- Server resizes to max 128x128 via Sharp on upload
- `GET /api/v1/settings/logo` serves with `Content-Type: image/png`. This route must be added to `PUBLIC_PATHS` in `auth.ts` so the logo is accessible on the login page.
- Sidebar/navbar checks `customLogo` on mount — if true, loads from logo endpoint; otherwise uses built-in SVG
- No restart required — logo change is immediate
### UI
- Logo upload area with drag-and-drop, preview thumbnail
- "Remove" button to revert to default logo
- Accepts PNG, SVG, JPEG. Max 500KB.
- Shows current logo preview if one is set
## 6. Settings Dialog Navigation
### Current Sections
General, System Settings, Security, People, API Keys, About
### New Sections
General, System Settings, Security, People, **Teams**, API Keys, **Tools**, About
### Section Contents
| Section | What's new |
|---|---|
| System Settings | Feature flags toggle, temp file management controls, logo upload area (all added to existing section) |
| Teams | Entirely new — team CRUD table |
| Tools | Entirely new — tool enable/disable list |
### Frontend Type Changes
- Add `"teams" | "tools"` to the `Section` type union in `settings-dialog.tsx`
- Add corresponding entries to `NAV_ITEMS` array
### i18n
Add translation keys to `packages/shared/src/i18n/en.ts` under `settings` for the new sections (teams, tools) and their UI strings.
## Summary
| Feature | UI Location | New DB/API | Restart Required |
|---|---|---|---|
| Teams CRUD | New "Teams" tab | `teams` table, 4 CRUD routes | No |
| Tool disabling | New "Tools" tab | `disabledTools` setting key | Yes |
| Feature flags | System Settings | `enableExperimentalTools` setting key | Yes |
| Temp file management | System Settings | 2 setting keys | No |
| Logo upload | System Settings | 3 routes, `customLogo` key, `data/branding/` | No |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 44 KiB