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.
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
<!--  -->
|
||||
|
||||
## 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 place — resize, 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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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}°</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,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>,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -1,869 +0,0 @@
|
||||
# Resize & Rotate/Flip UX Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the before/after slider with intuitive side-by-side comparison for resize and live CSS preview for rotate/flip.
|
||||
|
||||
**Architecture:** Frontend-only changes. Resize gets a tab-based settings panel (Presets/Custom Size/Scale) and a new SideBySideComparison result view. Rotate/flip gets live CSS transform preview on the ImageViewer with an "Apply" button. The tool-page.tsx conditionally renders the appropriate result view per tool. No backend changes.
|
||||
|
||||
**Tech Stack:** React, TypeScript, Tailwind CSS, Zustand, lucide-react
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create SideBySideComparison Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/components/common/side-by-side-comparison.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the component file**
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
|
||||
interface SideBySideComparisonProps {
|
||||
beforeSrc: string;
|
||||
afterSrc: string;
|
||||
beforeSize?: number;
|
||||
afterSize?: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function SideBySideComparison({
|
||||
beforeSrc,
|
||||
afterSrc,
|
||||
beforeSize,
|
||||
afterSize,
|
||||
}: SideBySideComparisonProps) {
|
||||
const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null);
|
||||
const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
const savingsPercent =
|
||||
beforeSize && afterSize && beforeSize > 0
|
||||
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
|
||||
: null;
|
||||
|
||||
const checkerboard = {
|
||||
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
||||
{/* Side-by-side images */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
{/* Original */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Original
|
||||
</span>
|
||||
<div
|
||||
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
||||
style={checkerboard}
|
||||
>
|
||||
<img
|
||||
src={beforeSrc}
|
||||
alt="Original"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setBeforeDims({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
||||
{beforeDims && (
|
||||
<p>
|
||||
{beforeDims.w} × {beforeDims.h}
|
||||
</p>
|
||||
)}
|
||||
{beforeSize != null && <p>{formatSize(beforeSize)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resized */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Resized
|
||||
</span>
|
||||
<div
|
||||
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
||||
style={checkerboard}
|
||||
>
|
||||
<img
|
||||
src={afterSrc}
|
||||
alt="Resized"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setAfterDims({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
||||
{afterDims && (
|
||||
<p>
|
||||
{afterDims.w} × {afterDims.h}
|
||||
</p>
|
||||
)}
|
||||
{afterSize != null && <p>{formatSize(afterSize)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size savings */}
|
||||
{savingsPercent !== null && (
|
||||
<p
|
||||
className={`text-sm font-medium ${Number(savingsPercent) > 0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`}
|
||||
>
|
||||
{Number(savingsPercent) > 0
|
||||
? `${savingsPercent}% smaller`
|
||||
: `${Math.abs(Number(savingsPercent))}% larger`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/common/side-by-side-comparison.tsx
|
||||
git commit -m "feat: add SideBySideComparison component for resize results"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite ResizeSettings with Tab-Based UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/components/tools/resize-settings.tsx`
|
||||
|
||||
- [ ] **Step 1: Rewrite resize-settings.tsx with three tabs**
|
||||
|
||||
Replace the entire file content with:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Link, Unlink } from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
|
||||
type ResizeTab = "presets" | "custom" | "scale";
|
||||
type FitMode = "cover" | "contain" | "fill";
|
||||
|
||||
const FIT_LABELS: Record<FitMode, string> = {
|
||||
cover: "Crop to fit",
|
||||
contain: "Fit inside",
|
||||
fill: "Stretch",
|
||||
};
|
||||
|
||||
// Group presets by platform
|
||||
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
||||
|
||||
export function ResizeSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("resize");
|
||||
|
||||
const [tab, setTab] = useState<ResizeTab>("presets");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [width, setWidth] = useState<string>("");
|
||||
const [height, setHeight] = useState<string>("");
|
||||
const [percentage, setPercentage] = useState<string>("50");
|
||||
const [fit, setFit] = useState<FitMode>("cover");
|
||||
const [lockAspect, setLockAspect] = useState(true);
|
||||
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||
|
||||
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
||||
const key = `${preset.platform}-${preset.name}`;
|
||||
if (selectedPreset === key) {
|
||||
setSelectedPreset(null);
|
||||
setWidth("");
|
||||
setHeight("");
|
||||
} else {
|
||||
setSelectedPreset(key);
|
||||
setWidth(String(preset.width));
|
||||
setHeight(String(preset.height));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
|
||||
if (tab === "scale") {
|
||||
settings.percentage = Number(percentage);
|
||||
} else {
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
settings.fit = tab === "presets" ? "cover" : fit;
|
||||
settings.withoutEnlargement = withoutEnlargement;
|
||||
}
|
||||
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess =
|
||||
hasFile &&
|
||||
!processing &&
|
||||
(tab === "scale"
|
||||
? Number(percentage) > 0
|
||||
: Boolean(width) || Boolean(height));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
const tabClass = (t: ResizeTab) =>
|
||||
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Tab selector */}
|
||||
<div>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||||
Presets
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
||||
Custom Size
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||
Scale
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets tab */}
|
||||
{tab === "presets" && (
|
||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
|
||||
{platforms.map((platform) => (
|
||||
<div key={platform}>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
|
||||
<div className="space-y-1">
|
||||
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
|
||||
const key = `${preset.platform}-${preset.name}`;
|
||||
const isSelected = selectedPreset === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => handlePreset(preset)}
|
||||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{preset.name}</span>
|
||||
<span className="text-xs tabular-nums">
|
||||
{preset.width} × {preset.height}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Don't enlarge */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withoutEnlargement}
|
||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Don't enlarge
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Size tab */}
|
||||
{tab === "custom" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLockAspect(!lockAspect)}
|
||||
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
||||
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
>
|
||||
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fit mode */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Fit Mode</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFit(f)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{FIT_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Don't enlarge */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withoutEnlargement}
|
||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Don't enlarge
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scale tab */}
|
||||
{tab === "scale" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Scale (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={percentage}
|
||||
onChange={(e) => setPercentage(e.target.value)}
|
||||
min={1}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[25, 50, 75].map((pct) => (
|
||||
<button
|
||||
key={pct}
|
||||
type="button"
|
||||
onClick={() => setPercentage(String(pct))}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
percentage === String(pct)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{pct}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Process button */}
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Resizing"
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Resize
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/tools/resize-settings.tsx
|
||||
git commit -m "feat: rewrite resize settings with tab-based UI (presets, custom, scale)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add CSS Transform Props to ImageViewer
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/components/common/image-viewer.tsx`
|
||||
|
||||
- [ ] **Step 1: Add optional CSS transform props to the ImageViewer interface and apply them**
|
||||
|
||||
Add `cssRotate`, `cssFlipH`, `cssFlipV` optional props to the `ImageViewerProps` interface. In the `imageStyle` computation, compose CSS transforms when these props are provided.
|
||||
|
||||
Changes to make:
|
||||
|
||||
1. Update the interface (line 5-8):
|
||||
|
||||
```tsx
|
||||
interface ImageViewerProps {
|
||||
src: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
cssRotate?: number;
|
||||
cssFlipH?: boolean;
|
||||
cssFlipV?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
2. Update the component destructuring (line 14):
|
||||
|
||||
```tsx
|
||||
export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) {
|
||||
```
|
||||
|
||||
3. Update the `imageStyle` computation (lines 65-68) to compose CSS transforms:
|
||||
|
||||
```tsx
|
||||
const previewTransform = [
|
||||
cssRotate ? `rotate(${cssRotate}deg)` : "",
|
||||
cssFlipH ? "scaleX(-1)" : "",
|
||||
cssFlipV ? "scaleY(-1)" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const imageStyle =
|
||||
fitMode === "fit"
|
||||
? {
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain" as const,
|
||||
...(previewTransform && { transform: previewTransform }),
|
||||
}
|
||||
: {
|
||||
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
|
||||
transformOrigin: "center center",
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/common/image-viewer.tsx
|
||||
git commit -m "feat: add CSS transform props to ImageViewer for live rotate/flip preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update RotateSettings with Live Preview Callback
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/components/tools/rotate-settings.tsx`
|
||||
|
||||
- [ ] **Step 1: Add onPreviewTransform callback prop and change button label**
|
||||
|
||||
The component needs to:
|
||||
1. Accept an optional `onPreviewTransform` callback
|
||||
2. Call it on every state change (angle, flipH, flipV) via useEffect
|
||||
3. Change the submit button label from "Rotate" to "Apply"
|
||||
|
||||
Replace the entire file:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import {
|
||||
Download,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
} from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
|
||||
export interface PreviewTransform {
|
||||
rotate: number;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
}
|
||||
|
||||
interface RotateSettingsProps {
|
||||
onPreviewTransform?: (transform: PreviewTransform) => void;
|
||||
}
|
||||
|
||||
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("rotate");
|
||||
|
||||
const [angle, setAngle] = useState(0);
|
||||
const [flipH, setFlipH] = useState(false);
|
||||
const [flipV, setFlipV] = useState(false);
|
||||
|
||||
// Emit preview transform on every change
|
||||
useEffect(() => {
|
||||
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
||||
}, [angle, flipH, flipV, onPreviewTransform]);
|
||||
|
||||
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
|
||||
const rotateRight = () => setAngle((a) => (a + 90) % 360);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
angle,
|
||||
horizontal: flipH,
|
||||
vertical: flipV,
|
||||
});
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges = angle !== 0 || flipH || flipV;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && hasChanges && !processing) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Quick rotate buttons */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Quick Rotate</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={rotateLeft}
|
||||
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
90 Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={rotateRight}
|
||||
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
90 Right
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Angle slider */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Angle</label>
|
||||
<span className="text-xs font-mono text-foreground">{angle} deg</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={angle}
|
||||
onChange={(e) => setAngle(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flip buttons */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Flip</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlipH(!flipH)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
|
||||
flipH
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4" />
|
||||
Horizontal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlipV(!flipV)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
|
||||
flipV
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<FlipVertical className="h-4 w-4" />
|
||||
Vertical
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Process */}
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Applying"
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasFile || !hasChanges || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/tools/rotate-settings.tsx
|
||||
git commit -m "feat: add live preview callback to RotateSettings, rename button to Apply"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update tool-page.tsx for Conditional Rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/tool-page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports, preview transform state, and conditional result rendering**
|
||||
|
||||
Changes to make in `tool-page.tsx`:
|
||||
|
||||
1. Add imports at the top (after existing imports, around line 8):
|
||||
|
||||
```tsx
|
||||
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
||||
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
||||
```
|
||||
|
||||
2. Add a set for tools that use alternate result views (after `NO_DROPZONE_TOOLS` on line 63):
|
||||
|
||||
```tsx
|
||||
const SIDE_BY_SIDE_TOOLS = new Set(["resize"]);
|
||||
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
|
||||
```
|
||||
|
||||
3. Update `ToolSettingsPanel` to accept and pass through the preview transform callback (replace the function starting at line 65):
|
||||
|
||||
```tsx
|
||||
function ToolSettingsPanel({
|
||||
toolId,
|
||||
onPreviewTransform,
|
||||
}: {
|
||||
toolId: string;
|
||||
onPreviewTransform?: (t: PreviewTransform) => void;
|
||||
}) {
|
||||
// Phase 2: Core tools
|
||||
if (toolId === "resize") return <ResizeSettings />;
|
||||
if (toolId === "crop") return <CropSettings />;
|
||||
if (toolId === "rotate") return <RotateSettings onPreviewTransform={onPreviewTransform} />;
|
||||
```
|
||||
|
||||
(Rest of ToolSettingsPanel stays identical)
|
||||
|
||||
4. In the `ToolPage` component, add preview transform state (after `const [mobileSettingsOpen, setMobileSettingsOpen]` on line 177):
|
||||
|
||||
```tsx
|
||||
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
||||
```
|
||||
|
||||
5. Update ToolSettingsPanel usage in both mobile and desktop layouts — pass the callback:
|
||||
|
||||
```tsx
|
||||
<ToolSettingsPanel
|
||||
toolId={tool.id}
|
||||
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
|
||||
/>
|
||||
```
|
||||
|
||||
6. Replace the result rendering logic in the main area for **both mobile and desktop layouts**. Replace the entire conditional block (from `{isNoDropzone ?` through the closing `}`). Apply this **identically** in both the mobile layout (around line 286) and the desktop layout (around line 372):
|
||||
|
||||
```tsx
|
||||
{isNoDropzone ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">Configure settings and generate.</p>
|
||||
</div>
|
||||
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
|
||||
<SideBySideComparison
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
|
||||
<ImageViewer
|
||||
src={processedUrl}
|
||||
filename={processedFileName}
|
||||
fileSize={processedSize ?? 0}
|
||||
/>
|
||||
) : hasProcessed && originalBlobUrl ? (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
) : hasFile && originalBlobUrl ? (
|
||||
<ImageViewer
|
||||
src={originalBlobUrl}
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
|
||||
? {
|
||||
cssRotate: previewTransform.rotate,
|
||||
cssFlipH: previewTransform.flipH,
|
||||
cssFlipV: previewTransform.flipV,
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Important:** The `BeforeAfterSlider` is kept for all tools except resize (SideBySideComparison) and rotate (ImageViewer). The `BeforeAfterSlider` import must NOT be removed.
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/tool-page.tsx
|
||||
git commit -m "feat: conditional result views — side-by-side for resize, live preview for rotate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Docker Rebuild and Test
|
||||
|
||||
**Files:**
|
||||
- No file changes — build and run existing Docker setup
|
||||
|
||||
- [ ] **Step 1: Build Docker image**
|
||||
|
||||
```bash
|
||||
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Start the container**
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the app is running**
|
||||
|
||||
```bash
|
||||
curl -f http://localhost:1349/api/v1/health
|
||||
```
|
||||
|
||||
Expected: Health check passes
|
||||
|
||||
- [ ] **Step 4: Report to user for UI testing**
|
||||
|
||||
App is running at `http://localhost:1349`. User can test:
|
||||
- Resize tool: tab-based settings (Presets, Custom Size, Scale), side-by-side result view
|
||||
- Rotate tool: live CSS preview as controls change, "Apply" button, processed result in standard viewer
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 44 KiB |