chore: remove internal docs from repo, update public documentation

Remove docs/superpowers/, .claude/ config, and PRD.md from version
control (kept locally via .gitignore). Update README, CHANGELOG,
VitePress docs, and .env.example to reflect recent features: Files
page, teams, admin settings, persistent storage, and various API
improvements.
This commit is contained in:
Siddharth Kumar Sah
2026-03-26 01:11:40 +08:00
parent 3b4f522bf4
commit 627ff8a82c
99 changed files with 853 additions and 15277 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import Database from "better-sqlite3";
import Database, { type Database as DatabaseType } from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { env } from "../config.js";
import * as schema from "./schema.js";
@@ -8,7 +8,7 @@ import * as schema from "./schema.js";
// Ensure data directory exists
mkdirSync(dirname(env.DB_PATH), { recursive: true });
const sqlite = new Database(env.DB_PATH);
const sqlite: DatabaseType = new Database(env.DB_PATH);
// Critical SQLite pragmas for reliability
sqlite.pragma("journal_mode = WAL");
+1 -1
View File
@@ -18,7 +18,7 @@ export function getMaxAgeMs(): number {
.get();
if (row) {
const hours = parseFloat(row.value);
if (!isNaN(hours) && hours > 0) return hours * 60 * 60 * 1000;
if (!Number.isNaN(hours) && hours > 0) return hours * 60 * 60 * 1000;
}
} catch {
/* DB not ready yet, use env */
+2 -2
View File
@@ -128,7 +128,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
// ── Login attempt limit ──────────────────────────────────────────
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 5;
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
function getLoginAttemptLimit(): number {
const row = db
@@ -378,7 +378,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const teamExists = db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, (body as { team?: string }).team!))
.where(eq(schema.teams.id, (body as { team?: string }).team ?? ""))
.get();
if (!teamExists)
return reply.status(400).send({ error: "Team not found", code: "VALIDATION_ERROR" });
+6 -1
View File
@@ -150,7 +150,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
try {
for (let i = 0; i < pipeline.steps.length; i++) {
const step = pipeline.steps[i];
const toolConfig = getToolConfig(step.toolId)!;
const toolConfig = getToolConfig(step.toolId);
if (!toolConfig) {
return reply
.status(400)
.send({ error: `Step ${i + 1}: Tool "${step.toolId}" not found` });
}
// Parse settings through the schema to apply defaults
const settings = toolConfig.settingsSchema.parse(step.settings);
+2 -2
View File
@@ -57,7 +57,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" });
}
const trimmedName = (body!.name as string).trim();
const trimmedName = (body?.name ?? "").trim();
// Check for duplicate name (case-insensitive)
const existing = db
@@ -97,7 +97,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: nameError, code: "VALIDATION_ERROR" });
}
const trimmedName = (body!.name as string).trim();
const trimmedName = (body?.name ?? "").trim();
// Check for duplicate name (case-insensitive), excluding current team
const duplicate = db
+15 -6
View File
@@ -24,18 +24,27 @@ export interface ToolRouteConfig<T> {
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
}
/** Type-erased config stored in the registry (settings type is widened to avoid variance issues). */
export interface AnyToolRouteConfig {
toolId: string;
settingsSchema: z.ZodType<unknown, z.ZodTypeDef, unknown>;
process: (
inputBuffer: Buffer,
settings: unknown,
filename: string,
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
}
/**
* In-memory registry of all tool configs, keyed by toolId.
* Populated by createToolRoute() calls; used by batch processing.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const toolRegistry = new Map<string, ToolRouteConfig<any>>();
const toolRegistry = new Map<string, AnyToolRouteConfig>();
/**
* Retrieve a registered tool config by its ID.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getToolConfig(toolId: string): ToolRouteConfig<any> | undefined {
export function getToolConfig(toolId: string): AnyToolRouteConfig | undefined {
return toolRegistry.get(toolId);
}
@@ -55,8 +64,8 @@ export function getToolConfig(toolId: string): ToolRouteConfig<any> | undefined
* - Response formatting
*/
export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig<T>): void {
// Register in the tool registry for batch processing
toolRegistry.set(config.toolId, config);
// Register in the tool registry for batch processing (cast to type-erased form)
toolRegistry.set(config.toolId, config as AnyToolRouteConfig);
app.post(
`/api/v1/tools/${config.toolId}`,
+3 -2
View File
@@ -60,10 +60,11 @@ export function registerBlurFaces(app: FastifyInstance) {
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+3 -2
View File
@@ -71,10 +71,11 @@ export function registerEraseObject(app: FastifyInstance) {
await writeFile(inputPath, imageBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+3 -2
View File
@@ -73,10 +73,11 @@ export function registerOcr(app: FastifyInstance) {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
@@ -62,10 +62,11 @@ export function registerRemoveBackground(app: FastifyInstance) {
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+1 -3
View File
@@ -75,9 +75,7 @@ function parseXmp(xmpBuffer: Buffer): Record<string, string> {
const xml = xmpBuffer.toString("utf-8");
const result: Record<string, string> = {};
const attrRegex = /(\w+:\w+)="([^"]+)"/g;
let match;
while ((match = attrRegex.exec(xml)) !== null) {
for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) {
const key = match[1];
if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue;
result[key] = match[2];
+3 -2
View File
@@ -62,10 +62,11 @@ export function registerUpscale(app: FastifyInstance) {
await writeFile(inputPath, fileBuffer);
// Process
const onProgress = clientJobId
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: clientJobId!,
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
+5 -2
View File
@@ -47,11 +47,12 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`)
A Fastify v5 server that handles:
- File uploads and temporary workspace management
- File uploads, temporary workspace management, and persistent file storage
- Tool execution (routes each tool request to the image engine or AI bridge)
- Pipeline orchestration (chaining multiple tools sequentially)
- Batch processing with concurrency control via p-queue
- User authentication, API key management, and rate limiting
- User authentication, teams, API key management, and rate limiting
- Admin settings (tool visibility, feature flags, cleanup config, branding)
- Swagger/OpenAPI documentation at `/api/docs`
- Serving the built frontend as a SPA in production
@@ -61,6 +62,8 @@ Key dependencies: Fastify, Drizzle ORM, better-sqlite3, Sharp, Zod for validatio
A React 19 single-page app built with Vite. Uses Zustand for state management, Tailwind CSS v4 for styling, and Lucide for icons. Communicates with the API over REST and SSE (for progress tracking).
Pages include a tool workspace, a Files page for managing persistent uploads and results, an automation/pipeline builder, and an admin settings panel.
The built frontend gets served by the Fastify backend in production, so there is no separate web server in the Docker container.
### Docs (`apps/docs`)
+2 -1
View File
@@ -26,6 +26,7 @@ All configuration is done through environment variables. Every variable has a se
| `STORAGE_MODE` | `local` | `local` or `s3`. Only local storage is currently implemented. |
| `DB_PATH` | `./data/stirling.db` | Path to the SQLite database file. |
| `WORKSPACE_PATH` | `./tmp/workspace` | Directory for temporary files during processing. Cleaned up automatically. |
| `FILES_STORAGE_PATH` | `./data/files` | Directory for persistent user files (uploaded images, saved results). |
### Processing limits
@@ -76,5 +77,5 @@ services:
The Docker container uses two volumes:
- `/data` -- Persistent storage for the SQLite database. Mount this to keep users, API keys, and saved pipelines across container restarts.
- `/data` -- Persistent storage for the SQLite database and user files. Mount this to keep users, API keys, saved pipelines, and uploaded images across container restarts.
- `/tmp/workspace` -- Temporary storage for images being processed. This can be ephemeral, but mounting it avoids filling up the container's writable layer.
+10 -9
View File
@@ -61,18 +61,19 @@ Start the dev server:
pnpm dev
```
This starts both the API server and the React frontend. The app opens at `http://localhost:5173` by default during development.
This starts both the API server and the React frontend. Open `http://localhost:1349` in your browser.
## What you can do
Once logged in, the sidebar lists every available tool. Pick one, upload an image, adjust the settings, and download the result.
The sidebar lists every tool. Pick one, upload an image, tweak the settings, download the result.
A few things to try first:
Some things to try first:
- **Resize** an image to specific dimensions or a percentage
- **Remove a background** using the AI-powered background removal tool
- **Compress** a photo to reduce file size before uploading it somewhere
- **Convert** between formats (JPEG, PNG, WebP, AVIF, TIFF)
- **Batch process** a folder of images through any tool
- Resize an image to specific dimensions or a percentage
- Remove a background with the AI tool
- Compress a photo before uploading it somewhere
- Convert between formats (JPEG, PNG, WebP, AVIF, TIFF)
- Batch process a folder of images through any tool
- Save results to the Files page for later
Every tool in the UI is also available through the [REST API](../api/rest), so you can script your workflows or integrate Stirling Image into other systems.
Every tool is also available through the [REST API](../api/rest), so you can script workflows or plug Stirling Image into other systems.
+1
View File
@@ -36,6 +36,7 @@ class ErrorBoundary extends Component<
{this.state.error?.message || "An unexpected error occurred."}
</p>
<button
type="button"
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
@@ -84,12 +84,22 @@ export function BeforeAfterSlider({
{/* Slider container */}
<div
ref={containerRef}
role="slider"
aria-label="Before/after comparison slider"
aria-valuenow={Math.round(position)}
aria-valuemin={0}
aria-valuemax={100}
tabIndex={0}
className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none"
style={{ cursor: isDragging ? "ew-resize" : "default" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setPosition((p) => Math.max(0, p - 1));
else if (e.key === "ArrowRight") setPosition((p) => Math.min(100, p + 1));
}}
>
{/* Before image (full width, bottom layer) */}
<img src={beforeSrc} alt="Original" className="block w-full h-auto" draggable={false} />
@@ -116,7 +126,14 @@ export function BeforeAfterSlider({
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-primary">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
aria-hidden="true"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
+11 -7
View File
@@ -46,14 +46,14 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
const hasMultipleFiles = currentFiles.length > 1;
return (
<div
<section
aria-label="File drop zone"
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
className={cn(
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors cursor-pointer min-h-[400px] mx-auto max-w-2xl w-full",
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors min-h-[400px] mx-auto max-w-2xl w-full",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
@@ -63,7 +63,11 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
<div className="text-3xl font-bold text-muted-foreground/30">
Stirling <span className="text-primary/30">Image</span>
</div>
<button className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium">
<button
type="button"
onClick={handleClick}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium"
>
<Upload className="h-4 w-4" />
Upload from computer
</button>
@@ -77,9 +81,9 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
{currentFiles.length} files selected
</span>
<div className="max-h-32 overflow-y-auto w-full max-w-xs">
{currentFiles.map((f, i) => (
{currentFiles.map((f) => (
<div
key={i}
key={f.name}
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
>
<span className="truncate">{f.name}</span>
@@ -90,6 +94,6 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
</div>
)}
</div>
</div>
</section>
);
}
@@ -102,6 +102,7 @@ export function ImageViewer({
{/* Toolbar */}
<div className="flex items-center justify-center gap-1 py-2 px-3 border-b border-border shrink-0">
<button
type="button"
onClick={zoomOut}
disabled={zoom <= ZOOM_STEPS[0]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
@@ -113,6 +114,7 @@ export function ImageViewer({
{fitMode === "fit" ? "Fit" : `${zoom}%`}
</span>
<button
type="button"
onClick={zoomIn}
disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
@@ -122,6 +124,7 @@ export function ImageViewer({
</button>
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={fitToContainer}
className={`px-2 py-1 rounded text-xs ${fitMode === "fit" ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Fit to view"
@@ -129,6 +132,7 @@ export function ImageViewer({
<Maximize className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={actualSize}
className={`px-2 py-1 rounded text-xs ${fitMode === "actual" && zoom === 100 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Actual size (100%)"
@@ -7,12 +7,6 @@ import { useFileStore } from "@/stores/file-store";
export function MultiImageViewer() {
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
const currentEntry = entries[selectedIndex];
if (!currentEntry) return null;
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -27,10 +21,18 @@ export function MultiImageViewer() {
[navigateNext, navigatePrev],
);
const currentEntry = entries[selectedIndex];
if (!currentEntry) return null;
const hasMultiple = entries.length > 1;
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const hasProcessed = !!currentEntry.processedUrl;
return (
<div
<section
aria-label="Image viewer"
className="flex flex-col w-full h-full min-h-0"
onKeyDown={hasMultiple ? handleKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -38,6 +40,7 @@ export function MultiImageViewer() {
<div className="flex-1 relative flex items-center justify-center min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -49,7 +52,7 @@ export function MultiImageViewer() {
{hasProcessed ? (
<BeforeAfterSlider
beforeSrc={currentEntry.blobUrl}
afterSrc={currentEntry.processedUrl!}
afterSrc={currentEntry.processedUrl ?? ""}
beforeSize={currentEntry.originalSize}
afterSize={currentEntry.processedSize ?? undefined}
/>
@@ -63,6 +66,7 @@ export function MultiImageViewer() {
</div>
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -77,6 +81,6 @@ export function MultiImageViewer() {
)}
</div>
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
</div>
</section>
);
}
@@ -54,6 +54,7 @@ export function ReviewPanel({
{/* Review header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground"
>
@@ -85,6 +86,7 @@ export function ReviewPanel({
{/* Action buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={onUndo}
className="flex-1 py-2 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted flex items-center justify-center gap-1.5 text-xs font-medium"
>
@@ -92,6 +94,7 @@ export function ReviewPanel({
Undo
</button>
<button
type="button"
onClick={handleDownload}
className="flex-1 py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
@@ -105,6 +108,7 @@ export function ReviewPanel({
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
type="button"
onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)}
className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground"
>
@@ -129,6 +133,7 @@ export function ReviewPanel({
return (
<button
key={tool.id}
type="button"
onClick={() => navigate(tool.route)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-muted group"
>
@@ -32,7 +32,8 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
const isFailed = entry.status === "failed";
return (
<button
key={`${entry.file.name}-${i}`}
key={entry.file.name}
type="button"
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect(i)}
className={`relative shrink-0 rounded overflow-hidden transition-all ${
@@ -15,6 +15,7 @@ export function ToolCard({ tool }: ToolCardProps) {
return (
<div className="group flex items-center gap-3 relative">
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
title="Add to favourites"
>
+32 -11
View File
@@ -48,19 +48,40 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
async function handleOpenFile() {
if (!details) return;
const res = await fetch(getFileDownloadUrl(details.id), {
headers: { Authorization: `Bearer ${localStorage.getItem("stirling-token") || ""}` },
});
if (!res.ok) return;
const blob = await res.blob();
const file = new File([blob], details.originalName, { type: details.mimeType });
setFiles([file]);
const { checkedIds, files: allFiles } = useFilesPageStore.getState();
// If multiple files are checked, open all of them; otherwise just the selected one
const filesToOpen =
checkedIds.size > 1
? allFiles.filter((f) => checkedIds.has(f.id))
: [{ id: details.id, originalName: details.originalName, mimeType: details.mimeType }];
const token = localStorage.getItem("stirling-token") || "";
const downloaded = await Promise.all(
filesToOpen.map(async (f) => {
const res = await fetch(getFileDownloadUrl(f.id), {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const blob = await res.blob();
return { file: new File([blob], f.originalName, { type: f.mimeType }), serverId: f.id };
}),
);
const valid = downloaded.filter((d): d is NonNullable<typeof d> => d !== null);
if (valid.length === 0) return;
setFiles(valid.map((d) => d.file));
navigate("/");
// Set serverFileId so tool processing creates a new version
// Set serverFileId on each entry so tool processing creates new versions
setTimeout(() => {
const entries = useFileStore.getState().entries;
if (entries.length > 0) {
useFileStore.getState().updateEntry(0, { serverFileId: details.id });
const store = useFileStore.getState();
for (let i = 0; i < valid.length; i++) {
if (store.entries[i]) {
store.updateEntry(i, { serverFileId: valid[i].serverId });
}
}
}, 0);
}
+6 -1
View File
@@ -36,13 +36,18 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold text-foreground">Help</h2>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
+17 -14
View File
@@ -44,7 +44,8 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{isMobile && mobileSidebarOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
onClick={() => setMobileSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
@@ -61,25 +62,25 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
</span>
)}
<button
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="p-1.5 rounded-lg hover:bg-muted"
>
<X className="h-4 w-4" />
</button>
</div>
<div onClick={() => setMobileSidebarOpen(false)}>
<Sidebar
onSettingsClick={() => {
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
expanded
/>
</div>
<Sidebar
onSettingsClick={() => {
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
onNavClick={() => setMobileSidebarOpen(false)}
expanded
/>
</div>
</>
)}
@@ -88,6 +89,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
{isMobile && (
<div className="fixed top-0 left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3">
<button
type="button"
onClick={() => setMobileSidebarOpen(true)}
className="p-1.5 rounded-lg hover:bg-muted"
>
@@ -129,6 +131,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
<MobileNavItem icon={Workflow} label="Automate" href="/automate" />
<MobileNavItem icon={FolderOpen} label="Files" href="/files" />
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground"
>
@@ -7,6 +7,7 @@ export function Footer() {
return (
<div className="fixed bottom-4 right-4 flex items-center gap-2 z-50">
<button
type="button"
onClick={toggleTheme}
className="p-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors"
title="Toggle Theme"
@@ -14,6 +15,7 @@ export function Footer() {
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors text-sm"
title="Language"
>
+11 -4
View File
@@ -24,11 +24,18 @@ const bottomItems: SidebarItem[] = [
interface SidebarProps {
onSettingsClick: () => void;
onHelpClick: () => void;
/** Called when a nav link is clicked (e.g., to close mobile sidebar). */
onNavClick?: () => void;
/** When true, renders in expanded mode (for mobile overlay). */
expanded?: boolean;
}
export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: SidebarProps) {
export function Sidebar({
onSettingsClick,
onHelpClick,
onNavClick,
expanded = false,
}: SidebarProps) {
const location = useLocation();
const renderItem = (item: SidebarItem, isActive: boolean) => {
@@ -60,20 +67,20 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
if (item.label === "Settings") {
return (
<button key={item.label} onClick={onSettingsClick} className="w-full">
<button key={item.label} type="button" onClick={onSettingsClick} className="w-full">
{content}
</button>
);
}
if (item.label === "Help") {
return (
<button key={item.label} onClick={onHelpClick} className="w-full">
<button key={item.label} type="button" onClick={onHelpClick} className="w-full">
{content}
</button>
);
}
return (
<Link key={item.label} to={item.href || "/"}>
<Link key={item.label} to={item.href || "/"} onClick={onNavClick}>
{content}
</Link>
);
@@ -76,7 +76,11 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85vh] flex overflow-hidden">
@@ -88,6 +92,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{NAV_ITEMS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSection(item.id)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors",
@@ -105,6 +110,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<button
type="button"
onClick={onClose}
className="absolute top-3 right-3 p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
@@ -207,6 +213,7 @@ function GeneralSection() {
</div>
</div>
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
@@ -336,9 +343,13 @@ function SystemSection() {
alt="Logo"
/>
)}
<label className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors">
<label
htmlFor="system-logo-upload"
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors"
>
Upload
<input
id="system-logo-upload"
type="file"
accept="image/png,image/jpeg,image/svg+xml"
className="hidden"
@@ -346,7 +357,11 @@ function SystemSection() {
/>
</label>
{settings.customLogo === "true" && (
<button onClick={handleLogoDelete} className="text-sm text-destructive hover:underline">
<button
type="button"
onClick={handleLogoDelete}
className="text-sm text-destructive hover:underline"
>
Remove
</button>
)}
@@ -409,6 +424,7 @@ function SystemSection() {
description="Show tools that are still in development. These may be unstable."
>
<button
type="button"
onClick={() =>
updateSetting(
"enableExperimentalTools",
@@ -449,6 +465,7 @@ function SystemSection() {
description="Clean up old temporary files when the server starts"
>
<button
type="button"
onClick={() =>
updateSetting("startupCleanup", settings.startupCleanup === "false" ? "true" : "false")
}
@@ -468,6 +485,7 @@ function SystemSection() {
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -830,6 +848,7 @@ function PeopleSection() {
/>
</div>
<button
type="button"
onClick={() => {
setShowAddForm(!showAddForm);
setAddError(null);
@@ -1051,6 +1070,7 @@ function PeopleSection() {
{/* Actions */}
<div className="flex items-center gap-1 justify-end relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === u.id ? null : u.id);
@@ -1064,10 +1084,11 @@ function PeopleSection() {
{/* Dropdown menu */}
{openMenuId === u.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-44 rounded-lg border border-border bg-background shadow-lg py-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => {
setEditingUser(u);
setEditRole(u.role);
@@ -1080,6 +1101,7 @@ function PeopleSection() {
Edit Role / Team
</button>
<button
type="button"
onClick={() => {
setResetPasswordUser(u);
setResetPassword("");
@@ -1092,6 +1114,7 @@ function PeopleSection() {
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDeleteUser(u.id, u.username)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -1198,6 +1221,7 @@ function ApiKeysSection() {
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48"
/>
<button
type="button"
onClick={generateKey}
disabled={generating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -1215,6 +1239,7 @@ function ApiKeysSection() {
{newKey}
</code>
<button
type="button"
onClick={() => copyKey(newKey)}
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground shrink-0"
title="Copy"
@@ -1244,6 +1269,7 @@ function ApiKeysSection() {
</p>
</div>
<button
type="button"
onClick={() => deleteKey(k.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete key"
@@ -1397,6 +1423,7 @@ function TeamsSection() {
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
@@ -1469,12 +1496,14 @@ function TeamsSection() {
}}
/>
<button
type="button"
onClick={() => handleRename(t.id)}
className="text-xs text-primary hover:underline"
>
Save
</button>
<button
type="button"
onClick={() => setEditingTeamId(null)}
className="text-xs text-muted-foreground hover:underline"
>
@@ -1488,6 +1517,7 @@ function TeamsSection() {
<span className="text-sm text-muted-foreground">{t.memberCount}</span>
<div className="flex items-center gap-1 justify-end relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === t.id ? null : t.id);
@@ -1498,10 +1528,11 @@ function TeamsSection() {
</button>
{openMenuId === t.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-36 rounded-lg border border-border bg-background shadow-lg py-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => {
setEditingTeamId(t.id);
setEditingTeamName(t.name);
@@ -1514,6 +1545,7 @@ function TeamsSection() {
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDelete(t.id, t.name)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -1640,6 +1672,7 @@ function ToolsSection() {
<p className="text-xs text-muted-foreground truncate">{tool.description}</p>
</div>
<button
type="button"
onClick={() => toggleTool(tool.id)}
className={cn(
"w-11 h-6 rounded-full transition-colors relative shrink-0 ml-3",
@@ -1669,6 +1702,7 @@ function ToolsSection() {
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -62,6 +62,7 @@ export function BarcodeReadSettings() {
</p>
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -79,6 +80,7 @@ export function BarcodeReadSettings() {
<p className="text-xs text-muted-foreground">Decoded Text:</p>
<p className="text-sm text-foreground font-mono break-all">{result.text}</p>
<button
type="button"
onClick={copyText}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
>
@@ -26,10 +26,13 @@ export function BlurFacesSettings() {
{/* Blur radius */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Blur Radius</label>
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
Blur Radius
</label>
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
</div>
<input
id="blur-faces-blur-radius"
type="range"
min={5}
max={80}
@@ -46,10 +49,13 @@ export function BlurFacesSettings() {
{/* Sensitivity */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Detection Sensitivity</label>
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
Detection Sensitivity
</label>
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
</div>
<input
id="blur-faces-sensitivity"
type="range"
min={10}
max={90}
@@ -91,6 +97,7 @@ export function BlurFacesSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -38,10 +38,13 @@ export function BorderSettings() {
<div className="space-y-4">
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Border Width</label>
<label htmlFor="border-border-width" className="text-xs text-muted-foreground">
Border Width
</label>
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
</div>
<input
id="border-border-width"
type="range"
min={0}
max={100}
@@ -52,8 +55,11 @@ export function BorderSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Border Color</label>
<label htmlFor="border-border-color" className="text-xs text-muted-foreground">
Border Color
</label>
<input
id="border-border-color"
type="color"
value={borderColor}
onChange={(e) => setBorderColor(e.target.value)}
@@ -63,10 +69,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Corner Radius</label>
<label htmlFor="border-corner-radius" className="text-xs text-muted-foreground">
Corner Radius
</label>
<span className="text-xs font-mono text-foreground">{cornerRadius}px</span>
</div>
<input
id="border-corner-radius"
type="range"
min={0}
max={200}
@@ -78,10 +87,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Padding</label>
<label htmlFor="border-padding" className="text-xs text-muted-foreground">
Padding
</label>
<span className="text-xs font-mono text-foreground">{padding}px</span>
</div>
<input
id="border-padding"
type="range"
min={0}
max={100}
@@ -93,10 +105,13 @@ export function BorderSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Shadow</label>
<label htmlFor="border-shadow" className="text-xs text-muted-foreground">
Shadow
</label>
<span className="text-xs font-mono text-foreground">{shadowBlur}px</span>
</div>
<input
id="border-shadow"
type="range"
min={0}
max={50}
@@ -126,6 +141,7 @@ export function BorderSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -72,8 +72,11 @@ export function BulkRenameSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Pattern</label>
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
Pattern
</label>
<input
id="bulk-rename-pattern"
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
@@ -85,8 +88,11 @@ export function BulkRenameSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Start Index</label>
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
Start Index
</label>
<input
id="bulk-rename-start-index"
type="number"
value={startIndex}
onChange={(e) => setStartIndex(Number(e.target.value))}
@@ -97,11 +103,11 @@ export function BulkRenameSettings() {
{previewNames.length > 0 && (
<div>
<label className="text-xs text-muted-foreground">Preview</label>
<p className="text-xs text-muted-foreground">Preview</p>
<div className="mt-1 space-y-0.5">
{previewNames.map((name, i) => (
{previewNames.map((name) => (
<div
key={i}
key={name}
className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate"
>
{name}
@@ -117,6 +123,7 @@ export function BulkRenameSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing || !pattern}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -71,10 +71,11 @@ export function CollageSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Layout</label>
<p className="text-xs text-muted-foreground">Layout</p>
<div className="grid grid-cols-3 gap-1 mt-1">
{LAYOUTS.map((l) => (
<button
type="button"
key={l.value}
onClick={() => setLayout(l.value)}
className={`text-xs py-1.5 rounded ${layout === l.value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
@@ -87,10 +88,13 @@ export function CollageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Gap</label>
<label htmlFor="collage-gap" className="text-xs text-muted-foreground">
Gap
</label>
<span className="text-xs font-mono text-foreground">{gap}px</span>
</div>
<input
id="collage-gap"
type="range"
min={0}
max={50}
@@ -101,8 +105,11 @@ export function CollageSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<label htmlFor="collage-background-color" className="text-xs text-muted-foreground">
Background Color
</label>
<input
id="collage-background-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -120,6 +127,7 @@ export function CollageSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -57,6 +57,7 @@ export function ColorPaletteSettings() {
return (
<div className="space-y-4">
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -69,13 +70,14 @@ export function ColorPaletteSettings() {
{colors.length > 0 && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<p className="text-xs font-medium text-muted-foreground">
Dominant Colors ({colors.length})
</label>
</p>
<div className="grid grid-cols-2 gap-1.5">
{colors.map((color, i) => (
<button
key={i}
type="button"
key={color}
onClick={() => copyColor(color, i)}
className="flex items-center gap-2 p-1.5 rounded border border-border hover:bg-muted transition-colors"
>
@@ -160,7 +160,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
{/* Effects */}
{tab === "effects" && (
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Color Effect</label>
<p className="text-xs text-muted-foreground">Color Effect</p>
<div className="grid grid-cols-2 gap-1">
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
<button
@@ -261,13 +261,17 @@ function SliderControl({
max: number;
color?: string;
}) {
const id = `color-slider-${label.toLowerCase()}`;
return (
<div>
<div className="flex justify-between items-center">
<label className={`text-xs ${color || "text-muted-foreground"}`}>{label}</label>
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
</label>
<span className="text-xs font-mono text-foreground">{value}</span>
</div>
<input
id={id}
type="range"
min={min}
max={max}
@@ -53,8 +53,11 @@ export function CompareSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Second Image</label>
<label htmlFor="compare-second-image" className="text-xs text-muted-foreground">
Second Image
</label>
<input
id="compare-second-image"
ref={secondInputRef}
type="file"
accept="image/*"
@@ -62,6 +65,7 @@ export function CompareSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => secondInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -84,6 +88,7 @@ export function CompareSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !secondFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -62,8 +62,11 @@ export function ComposeSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Overlay Image</label>
<label htmlFor="compose-overlay-image" className="text-xs text-muted-foreground">
Overlay Image
</label>
<input
id="compose-overlay-image"
ref={overlayInputRef}
type="file"
accept="image/*"
@@ -71,6 +74,7 @@ export function ComposeSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => overlayInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -81,8 +85,11 @@ export function ComposeSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">X Position</label>
<label htmlFor="compose-x-position" className="text-xs text-muted-foreground">
X Position
</label>
<input
id="compose-x-position"
type="number"
value={x}
onChange={(e) => setX(Number(e.target.value))}
@@ -91,8 +98,11 @@ export function ComposeSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Y Position</label>
<label htmlFor="compose-y-position" className="text-xs text-muted-foreground">
Y Position
</label>
<input
id="compose-y-position"
type="number"
value={y}
onChange={(e) => setY(Number(e.target.value))}
@@ -104,10 +114,13 @@ export function ComposeSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="compose-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="compose-opacity"
type="range"
min={0}
max={100}
@@ -118,8 +131,11 @@ export function ComposeSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Blend Mode</label>
<label htmlFor="compose-blend-mode" className="text-xs text-muted-foreground">
Blend Mode
</label>
<select
id="compose-blend-mode"
value={blendMode}
onChange={(e) => setBlendMode(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -147,6 +163,7 @@ export function ComposeSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !overlayFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -49,7 +49,7 @@ export function CompressSettings() {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Mode toggle */}
<div>
<label className="text-sm font-medium text-muted-foreground">Compression Mode</label>
<p className="text-sm font-medium text-muted-foreground">Compression Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
@@ -71,10 +71,13 @@ export function CompressSettings() {
{mode === "quality" ? (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<label htmlFor="compress-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="compress-quality"
type="range"
min={1}
max={100}
@@ -89,8 +92,11 @@ export function CompressSettings() {
</div>
) : (
<div>
<label className="text-xs text-muted-foreground">Target Size (KB)</label>
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
Target Size (KB)
</label>
<input
id="compress-target-size"
type="number"
value={targetSizeKb}
onChange={(e) => setTargetSizeKb(e.target.value)}
@@ -55,7 +55,7 @@ export function ConvertSettings() {
{/* Source format */}
{hasFile && (
<div>
<label className="text-xs text-muted-foreground">Source Format</label>
<p className="text-xs text-muted-foreground">Source Format</p>
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
{sourceExt}
</div>
@@ -64,8 +64,11 @@ export function ConvertSettings() {
{/* Target format */}
<div>
<label className="text-xs text-muted-foreground">Target Format</label>
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
Target Format
</label>
<select
id="convert-target-format"
value={format}
onChange={(e) => setFormat(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -82,10 +85,13 @@ export function ConvertSettings() {
{isLossy && (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<label htmlFor="convert-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="convert-quality"
type="range"
min={1}
max={100}
@@ -165,7 +165,7 @@ export function CropSettings({
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
{aspect !== undefined && (
<button
type="button"
@@ -197,13 +197,14 @@ export function CropSettings({
{/* Position & Size */}
<div>
<label className="text-xs text-muted-foreground">Position & Size</label>
<p className="text-xs text-muted-foreground">Position & Size</p>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-x"
type="number"
value={pixels.left}
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
@@ -213,10 +214,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-y" className="text-[10px] text-muted-foreground">
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-y"
type="number"
value={pixels.top}
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
@@ -226,10 +228,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-width" className="text-[10px] text-muted-foreground">
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-width"
type="number"
value={pixels.width}
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
@@ -239,10 +242,11 @@ export function CropSettings({
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground">
<label htmlFor="crop-height" className="text-[10px] text-muted-foreground">
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-height"
type="number"
value={pixels.height}
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
@@ -114,17 +114,28 @@ export function EraseObjectSettings() {
<div className="space-y-4">
{/* Mask upload */}
<div>
<label className="text-sm font-medium text-muted-foreground">Mask Image</label>
<label htmlFor="erase-object-mask" className="text-sm font-medium text-muted-foreground">
Mask Image
</label>
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
Upload a black &amp; white mask where white areas will be erased. Create the mask in any
image editor.
</p>
<label className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary">
<label
htmlFor="erase-object-mask"
className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary"
>
<Upload className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{maskFile ? maskFile.name : "Select mask image..."}
</span>
<input type="file" accept="image/*" onChange={handleMaskSelect} className="hidden" />
<input
id="erase-object-mask"
type="file"
accept="image/*"
onChange={handleMaskSelect}
className="hidden"
/>
</label>
</div>
@@ -162,6 +173,7 @@ export function EraseObjectSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !maskFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -67,7 +67,7 @@ export function FaviconSettings() {
</p>
<div>
<label className="text-xs font-medium text-muted-foreground">Generated Sizes</label>
<p className="text-xs font-medium text-muted-foreground">Generated Sizes</p>
<div className="mt-1 space-y-0.5">
{SIZES.map((s) => (
<div key={s.name} className="flex justify-between text-xs text-foreground">
@@ -82,6 +82,7 @@ export function FaviconSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -62,6 +62,7 @@ export function FindDuplicatesSettings() {
</p>
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -84,10 +85,13 @@ export function FindDuplicatesSettings() {
<p className="text-xs text-muted-foreground">No duplicates found.</p>
) : (
result.duplicateGroups.map((group, gi) => (
<div key={gi} className="p-2 rounded border border-border space-y-1">
<div
key={group.files.map((f) => f.filename).join(",")}
className="p-2 rounded border border-border space-y-1"
>
<p className="text-xs font-medium text-foreground">Group {gi + 1}</p>
{group.files.map((f, fi) => (
<div key={fi} className="flex justify-between text-xs">
{group.files.map((f) => (
<div key={f.filename} className="flex justify-between text-xs">
<span className="text-foreground truncate">{f.filename}</span>
<span className="text-muted-foreground shrink-0 ml-2">{f.similarity}%</span>
</div>
@@ -32,15 +32,17 @@ export function GifToolsSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Mode</label>
<p className="text-xs text-muted-foreground">Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setMode("resize")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "resize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Resize
</button>
<button
type="button"
onClick={() => setMode("extract")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "extract" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -53,8 +55,11 @@ export function GifToolsSettings() {
<>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="gif-tools-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="gif-tools-width"
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
@@ -63,8 +68,11 @@ export function GifToolsSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="gif-tools-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="gif-tools-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -74,8 +82,12 @@ export function GifToolsSettings() {
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<label
htmlFor="gif-tools-optimize"
className="flex items-center gap-2 text-sm text-foreground"
>
<input
id="gif-tools-optimize"
type="checkbox"
checked={optimize}
onChange={(e) => setOptimize(e.target.checked)}
@@ -86,8 +98,11 @@ export function GifToolsSettings() {
</>
) : (
<div>
<label className="text-xs text-muted-foreground">Frame Number</label>
<label htmlFor="gif-tools-frame" className="text-xs text-muted-foreground">
Frame Number
</label>
<input
id="gif-tools-frame"
type="number"
value={extractFrame}
onChange={(e) => setExtractFrame(e.target.value)}
@@ -118,6 +133,7 @@ export function GifToolsSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -57,8 +57,11 @@ export function ImageToPdfSettings() {
</p>
<div>
<label className="text-xs text-muted-foreground">Page Size</label>
<label htmlFor="image-to-pdf-page-size" className="text-xs text-muted-foreground">
Page Size
</label>
<select
id="image-to-pdf-page-size"
value={pageSize}
onChange={(e) => setPageSize(e.target.value as typeof pageSize)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -71,15 +74,17 @@ export function ImageToPdfSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Orientation</label>
<p className="text-xs text-muted-foreground">Orientation</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setOrientation("portrait")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "portrait" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Portrait
</button>
<button
type="button"
onClick={() => setOrientation("landscape")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "landscape" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -90,10 +95,13 @@ export function ImageToPdfSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Margin</label>
<label htmlFor="image-to-pdf-margin" className="text-xs text-muted-foreground">
Margin
</label>
<span className="text-xs font-mono text-foreground">{margin}pt</span>
</div>
<input
id="image-to-pdf-margin"
type="range"
min={0}
max={100}
@@ -106,6 +114,7 @@ export function ImageToPdfSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -79,6 +79,7 @@ export function InfoSettings() {
return (
<div className="space-y-4">
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -122,7 +123,7 @@ export function InfoSettings() {
{/* Histogram */}
<div>
<label className="text-xs font-medium text-muted-foreground">Channel Stats</label>
<p className="text-xs font-medium text-muted-foreground">Channel Stats</p>
<div className="mt-1 space-y-1.5">
{info.histogram.map((ch) => (
<div key={ch.channel} className="space-y-0.5">
+11 -3
View File
@@ -129,9 +129,10 @@ export function OcrSettings() {
<div className="space-y-4">
{/* Engine selector */}
<div>
<label className="text-sm font-medium text-muted-foreground">OCR Engine</label>
<p className="text-sm font-medium text-muted-foreground">OCR Engine</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setEngine("tesseract")}
className={`flex-1 text-xs py-1.5 rounded ${
engine === "tesseract"
@@ -142,6 +143,7 @@ export function OcrSettings() {
Tesseract
</button>
<button
type="button"
onClick={() => setEngine("paddleocr")}
className={`flex-1 text-xs py-1.5 rounded ${
engine === "paddleocr"
@@ -156,8 +158,11 @@ export function OcrSettings() {
{/* Language selector */}
<div>
<label className="text-xs text-muted-foreground">Language</label>
<label htmlFor="ocr-language" className="text-xs text-muted-foreground">
Language
</label>
<select
id="ocr-language"
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -185,6 +190,7 @@ export function OcrSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -197,10 +203,11 @@ export function OcrSettings() {
{text !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor="ocr-result-text" className="text-xs font-medium text-muted-foreground">
Extracted Text ({detectedEngine})
</label>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
@@ -209,6 +216,7 @@ export function OcrSettings() {
</button>
</div>
<textarea
id="ocr-result-text"
readOnly
value={text}
rows={8}
@@ -158,7 +158,8 @@ export function PipelineBuilder({
return (
<div className="space-y-6">
{/* File Upload Area */}
<div
<section
aria-label="File upload area"
onDragOver={(e) => e.preventDefault()}
onDrop={handleFileDrop}
className={cn(
@@ -178,6 +179,7 @@ export function PipelineBuilder({
</span>
</div>
<button
type="button"
onClick={() => setFile(null)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
@@ -186,6 +188,7 @@ export function PipelineBuilder({
</div>
) : (
<button
type="button"
onClick={handleFileSelect}
className="flex items-center gap-2 mx-auto px-4 py-2 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm"
>
@@ -193,7 +196,7 @@ export function PipelineBuilder({
Upload image to process
</button>
)}
</div>
</section>
{/* Pipeline Steps */}
<div className="space-y-2">
@@ -226,6 +229,7 @@ export function PipelineBuilder({
{/* Controls */}
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
title="Settings"
@@ -235,6 +239,7 @@ export function PipelineBuilder({
/>
</button>
<button
type="button"
onClick={() => moveStep(step.id, "up")}
disabled={idx === 0}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
@@ -243,6 +248,7 @@ export function PipelineBuilder({
<ChevronUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(step.id, "down")}
disabled={idx === steps.length - 1}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
@@ -251,6 +257,7 @@ export function PipelineBuilder({
<ChevronDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(step.id)}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Remove"
@@ -283,6 +290,7 @@ export function PipelineBuilder({
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Add a step</span>
<button
type="button"
onClick={() => setShowToolPicker(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
@@ -294,6 +302,7 @@ export function PipelineBuilder({
return (
<button
key={tool.id}
type="button"
onClick={() => addStep(tool.id)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
>
@@ -308,6 +317,7 @@ export function PipelineBuilder({
</div>
) : (
<button
type="button"
onClick={() => setShowToolPicker(true)}
className="flex items-center gap-2 w-full justify-center px-4 py-2.5 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
@@ -343,6 +353,7 @@ export function PipelineBuilder({
{/* Action Buttons */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleExecute}
disabled={steps.length === 0 || !file || executing}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -362,6 +373,7 @@ export function PipelineBuilder({
{!showSaveForm ? (
<button
type="button"
onClick={() => setShowSaveForm(true)}
disabled={steps.length === 0}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -386,6 +398,7 @@ export function PipelineBuilder({
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1 hidden sm:block"
/>
<button
type="button"
onClick={handleSave}
disabled={!saveName.trim() || saving}
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
@@ -393,6 +406,7 @@ export function PipelineBuilder({
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={() => setShowSaveForm(false)}
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
>
@@ -526,8 +526,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "number":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<input
id={`pipeline-${field.key}`}
type="number"
value={value != null && value !== "" ? Number(value) : ""}
onChange={(e) =>
@@ -548,8 +551,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "text":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<input
id={`pipeline-${field.key}`}
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
@@ -560,12 +566,12 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
);
case "select": {
const opts = field.options!;
const opts = field.options ?? [];
// Use button group for <= 4 options, dropdown for more
if (opts.length <= 4) {
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<p className="text-xs text-muted-foreground">{field.label}</p>
<div className="flex gap-1 mt-0.5">
{opts.map((opt) => (
<button
@@ -587,8 +593,11 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
}
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
{field.label}
</label>
<select
id={`pipeline-${field.key}`}
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -623,15 +632,22 @@ export function PipelineStepSettings({ toolId, settings, onChange }: PipelineSte
case "color":
return (
<div key={field.key}>
<label className="text-xs text-muted-foreground">{field.label}</label>
<label
htmlFor={`pipeline-${field.key}-text`}
className="text-xs text-muted-foreground"
>
{field.label}
</label>
<div className="flex gap-2 mt-0.5">
<input
id={`pipeline-${field.key}-picker`}
type="color"
value={String(value || "#000000").slice(0, 7)}
onChange={(e) => updateField(field.key, e.target.value)}
className="h-8 w-8 rounded border border-border cursor-pointer bg-background"
/>
<input
id={`pipeline-${field.key}-text`}
type="text"
value={String(value ?? "")}
onChange={(e) => updateField(field.key, e.target.value || undefined)}
@@ -52,8 +52,11 @@ export function QrGenerateSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Text / URL</label>
<label htmlFor="qr-text" className="text-xs text-muted-foreground">
Text / URL
</label>
<textarea
id="qr-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text or URL..."
@@ -64,10 +67,13 @@ export function QrGenerateSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Size</label>
<label htmlFor="qr-size" className="text-xs text-muted-foreground">
Size
</label>
<span className="text-xs font-mono text-foreground">{size}px</span>
</div>
<input
id="qr-size"
type="range"
min={100}
max={2000}
@@ -79,8 +85,11 @@ export function QrGenerateSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Error Correction</label>
<label htmlFor="qr-error-correction" className="text-xs text-muted-foreground">
Error Correction
</label>
<select
id="qr-error-correction"
value={errorCorrection}
onChange={(e) => setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -94,8 +103,11 @@ export function QrGenerateSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Foreground</label>
<label htmlFor="qr-foreground" className="text-xs text-muted-foreground">
Foreground
</label>
<input
id="qr-foreground"
type="color"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
@@ -103,8 +115,11 @@ export function QrGenerateSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Background</label>
<label htmlFor="qr-background" className="text-xs text-muted-foreground">
Background
</label>
<input
id="qr-background"
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
@@ -116,6 +131,7 @@ export function QrGenerateSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleGenerate}
disabled={!text || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -133,7 +149,7 @@ export function QrGenerateSettings() {
style={{ maxHeight: 200 }}
/>
<a
href={downloadUrl!}
href={downloadUrl ?? undefined}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
@@ -65,13 +65,14 @@ export function RemoveBgSettings() {
<div className="space-y-4">
{/* Subject type */}
<div>
<label className="text-sm font-medium text-muted-foreground">What's in the photo?</label>
<p className="text-sm font-medium text-muted-foreground">What's in the photo?</p>
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
{SUBJECT_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
onClick={() => {
setSubject(opt.value);
if (opt.value !== "people") setIsPassport(false);
@@ -105,11 +106,12 @@ export function RemoveBgSettings() {
{/* Quality */}
<div>
<label className="text-sm font-medium text-muted-foreground">Quality</label>
<p className="text-sm font-medium text-muted-foreground">Quality</p>
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
quality === opt.value
@@ -126,11 +128,12 @@ export function RemoveBgSettings() {
{/* Background color - intuitive preset buttons */}
<div>
<label className="text-sm font-medium text-muted-foreground">Output Background</label>
<p className="text-sm font-medium text-muted-foreground">Output Background</p>
<div className="flex gap-1.5 mt-1.5 flex-wrap">
{BG_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setBgColor(preset.color)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
bgColor === preset.color
@@ -197,6 +200,7 @@ export function RemoveBgSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -36,9 +36,12 @@ export function ReplaceColorSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Source Color (to replace)</label>
<label htmlFor="replace-source-color" className="text-xs text-muted-foreground">
Source Color (to replace)
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="replace-source-color"
type="color"
value={sourceColor}
onChange={(e) => setSourceColor(e.target.value)}
@@ -60,9 +63,12 @@ export function ReplaceColorSettings() {
{!makeTransparent && (
<div>
<label className="text-xs text-muted-foreground">Target Color (replacement)</label>
<label htmlFor="replace-target-color" className="text-xs text-muted-foreground">
Target Color (replacement)
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="replace-target-color"
type="color"
value={targetColor}
onChange={(e) => setTargetColor(e.target.value)}
@@ -75,10 +81,13 @@ export function ReplaceColorSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Tolerance</label>
<label htmlFor="replace-tolerance" className="text-xs text-muted-foreground">
Tolerance
</label>
<span className="text-xs font-mono text-foreground">{tolerance}</span>
</div>
<input
id="replace-tolerance"
type="range"
min={0}
max={255}
@@ -112,6 +121,7 @@ export function ReplaceColorSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -144,8 +144,11 @@ export function ResizeSettings() {
<div className="space-y-3">
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="resize-width"
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
@@ -162,8 +165,11 @@ export function ResizeSettings() {
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
</button>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="resize-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -175,7 +181,7 @@ export function ResizeSettings() {
{/* Fit mode */}
<div>
<label className="text-xs text-muted-foreground">Fit Mode</label>
<p className="text-xs text-muted-foreground">Fit Mode</p>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
<button
@@ -207,8 +213,11 @@ export function ResizeSettings() {
{tab === "scale" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Scale (%)</label>
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
Scale (%)
</label>
<input
id="resize-scale"
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
@@ -83,7 +83,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
<form onSubmit={handleSubmit} className="space-y-4">
{/* Quick rotate */}
<div>
<label className="text-xs text-muted-foreground">Rotate</label>
<p className="text-xs text-muted-foreground">Rotate</p>
<div className="flex items-center gap-2 mt-1">
<button
type="button"
@@ -112,13 +112,16 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{/* Straighten */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Straighten</label>
<label htmlFor="rotate-straighten" className="text-xs text-muted-foreground">
Straighten
</label>
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{straighten > 0 ? "+" : ""}
{straighten}°
</span>
</div>
<input
id="rotate-straighten"
type="range"
min={-45}
max={45}
@@ -136,7 +139,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
{/* Flip buttons */}
<div>
<label className="text-xs text-muted-foreground">Flip</label>
<p className="text-xs text-muted-foreground">Flip</p>
<div className="flex gap-2 mt-1">
<button
type="button"
@@ -46,8 +46,11 @@ export function SmartCropSettings() {
<div className="space-y-4">
{/* Aspect ratio preset */}
<div>
<label className="text-sm font-medium text-muted-foreground">Target Aspect Ratio</label>
<label htmlFor="smart-crop-preset" className="text-sm font-medium text-muted-foreground">
Target Aspect Ratio
</label>
<select
id="smart-crop-preset"
value={preset}
onChange={(e) => handlePreset(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -63,8 +66,11 @@ export function SmartCropSettings() {
{/* Width / Height */}
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="smart-crop-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="smart-crop-width"
type="number"
value={width}
onChange={(e) => {
@@ -76,8 +82,11 @@ export function SmartCropSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="smart-crop-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="smart-crop-height"
type="number"
value={height}
onChange={(e) => {
@@ -119,6 +128,7 @@ export function SmartCropSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -63,11 +63,12 @@ export function SplitSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Grid Presets</label>
<p className="text-xs text-muted-foreground">Grid Presets</p>
<div className="flex gap-1 mt-1 flex-wrap">
{presets.map((p) => (
<button
key={p.label}
type="button"
onClick={() => {
setColumns(p.c);
setRows(p.r);
@@ -82,8 +83,11 @@ export function SplitSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Columns</label>
<label htmlFor="split-columns" className="text-xs text-muted-foreground">
Columns
</label>
<input
id="split-columns"
type="number"
value={columns}
onChange={(e) => setColumns(Math.max(1, Number(e.target.value)))}
@@ -93,8 +97,11 @@ export function SplitSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Rows</label>
<label htmlFor="split-rows" className="text-xs text-muted-foreground">
Rows
</label>
<input
id="split-rows"
type="number"
value={rows}
onChange={(e) => setRows(Math.max(1, Number(e.target.value)))}
@@ -110,6 +117,7 @@ export function SplitSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -229,7 +229,7 @@ export function StripMetadataSettings() {
}
const data: MetadataResult = await res.json();
setMetadata(data);
setMetadataCache((prev) => new Map(prev).set(fileKey!, data));
if (fileKey) setMetadataCache((prev) => new Map(prev).set(fileKey, data));
} catch (err) {
if ((err as Error).name === "AbortError") return;
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
@@ -239,7 +239,7 @@ export function StripMetadataSettings() {
})();
return () => controller.abort();
}, [currentFile, fileKey, metadataCache.get]);
}, [currentFile, fileKey, metadataCache]);
const handleStripAllChange = (checked: boolean) => {
setStripAll(checked);
@@ -278,7 +278,7 @@ export function StripMetadataSettings() {
{/* Metadata Display */}
{hasFile && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
<p className="text-xs font-medium text-muted-foreground">Current Metadata</p>
{inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
@@ -307,13 +307,13 @@ export function StripMetadataSettings() {
</div>
)}
{hasExif && (
{hasExif && metadata.exif && (
<CollapsibleSection
title="EXIF"
badge={`${Object.keys(metadata.exif!).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
badge={`${Object.keys(metadata.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
defaultOpen
>
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
<MetadataGrid data={metadata.exif} labelMap={EXIF_LABELS} />
</CollapsibleSection>
)}
@@ -321,31 +321,31 @@ export function StripMetadataSettings() {
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
)}
{hasGps && (
{hasGps && metadata.gps && (
<CollapsibleSection
title="GPS"
warning
badge={`${Object.keys(metadata.gps!).filter((k) => !k.startsWith("_")).length} fields`}
badge={`${Object.keys(metadata.gps).filter((k) => !k.startsWith("_")).length} fields`}
>
<MetadataGrid data={metadata.gps!} />
<MetadataGrid data={metadata.gps} />
</CollapsibleSection>
)}
{hasIcc && (
{hasIcc && metadata.icc && (
<CollapsibleSection
title="ICC Profile"
badge={`${Object.keys(metadata.icc!).length} fields`}
badge={`${Object.keys(metadata.icc).length} fields`}
>
<MetadataGrid data={metadata.icc!} />
<MetadataGrid data={metadata.icc} />
</CollapsibleSection>
)}
{hasXmp && (
{hasXmp && metadata.xmp && (
<CollapsibleSection
title="XMP"
badge={`${Object.keys(metadata.xmp!).length} fields`}
badge={`${Object.keys(metadata.xmp).length} fields`}
>
<MetadataGrid data={metadata.xmp!} />
<MetadataGrid data={metadata.xmp} />
</CollapsibleSection>
)}
@@ -374,7 +374,7 @@ export function StripMetadataSettings() {
{/* Individual options */}
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Or select specific metadata:</label>
<p className="text-xs text-muted-foreground">Or select specific metadata:</p>
<label
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
@@ -389,7 +389,7 @@ export function StripMetadataSettings() {
Strip EXIF (camera info, date, exposure)
{hasExif && !stripAll && (
<span className="ml-auto text-[10px] text-muted-foreground">
{Object.keys(metadata?.exif!).filter((k) => !SKIP_KEYS.has(k)).length} fields
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
</span>
)}
</label>
@@ -67,8 +67,11 @@ export function SvgToRasterSettings() {
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<label htmlFor="svg-raster-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="svg-raster-width"
type="number"
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
@@ -78,8 +81,11 @@ export function SvgToRasterSettings() {
/>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<label htmlFor="svg-raster-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="svg-raster-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -90,8 +96,11 @@ export function SvgToRasterSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Output Format</label>
<label htmlFor="svg-raster-format" className="text-xs text-muted-foreground">
Output Format
</label>
<select
id="svg-raster-format"
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value as "png" | "jpg" | "webp")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -115,8 +124,11 @@ export function SvgToRasterSettings() {
{!transparent && (
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<label htmlFor="svg-raster-bg-color" className="text-xs text-muted-foreground">
Background Color
</label>
<input
id="svg-raster-bg-color"
type="color"
value={backgroundColor.slice(0, 7)}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -135,6 +147,7 @@ export function SvgToRasterSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -39,8 +39,11 @@ export function TextOverlaySettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Text</label>
<label htmlFor="text-overlay-text" className="text-xs text-muted-foreground">
Text
</label>
<input
id="text-overlay-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
@@ -50,10 +53,13 @@ export function TextOverlaySettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Font Size</label>
<label htmlFor="text-overlay-font-size" className="text-xs text-muted-foreground">
Font Size
</label>
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
</div>
<input
id="text-overlay-font-size"
type="range"
min={8}
max={200}
@@ -64,8 +70,11 @@ export function TextOverlaySettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Text Color</label>
<label htmlFor="text-overlay-color" className="text-xs text-muted-foreground">
Text Color
</label>
<input
id="text-overlay-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
@@ -74,8 +83,11 @@ export function TextOverlaySettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="text-overlay-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="text-overlay-position"
value={position}
onChange={(e) => setPosition(e.target.value as "top" | "center" | "bottom")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -108,8 +120,11 @@ export function TextOverlaySettings() {
{backgroundBox && (
<div>
<label className="text-xs text-muted-foreground">Box Color</label>
<label htmlFor="text-overlay-box-color" className="text-xs text-muted-foreground">
Box Color
</label>
<input
id="text-overlay-box-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
@@ -138,6 +153,7 @@ export function TextOverlaySettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing || !text}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -21,11 +21,12 @@ export function UpscaleSettings() {
<div className="space-y-4">
{/* Scale factor */}
<div>
<label className="text-sm font-medium text-muted-foreground">Scale Factor</label>
<p className="text-sm font-medium text-muted-foreground">Scale Factor</p>
<div className="flex gap-1 mt-1">
{[2, 4].map((s) => (
<button
key={s}
type="button"
onClick={() => setScale(s)}
className={`flex-1 text-xs py-1.5 rounded ${
scale === s
@@ -68,6 +69,7 @@ export function UpscaleSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -58,15 +58,17 @@ export function VectorizeSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Color Mode</label>
<p className="text-xs text-muted-foreground">Color Mode</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setColorMode("bw")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "bw" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Black & White
</button>
<button
type="button"
onClick={() => setColorMode("color")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
@@ -77,10 +79,13 @@ export function VectorizeSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Threshold</label>
<label htmlFor="vectorize-threshold" className="text-xs text-muted-foreground">
Threshold
</label>
<span className="text-xs font-mono text-foreground">{threshold}</span>
</div>
<input
id="vectorize-threshold"
type="range"
min={0}
max={255}
@@ -91,8 +96,11 @@ export function VectorizeSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Detail Level</label>
<label htmlFor="vectorize-detail" className="text-xs text-muted-foreground">
Detail Level
</label>
<select
id="vectorize-detail"
value={detail}
onChange={(e) => setDetail(e.target.value as "low" | "medium" | "high")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -113,6 +121,7 @@ export function VectorizeSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -63,7 +63,7 @@ export function WatermarkImageSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Watermark Image</label>
<p className="text-xs text-muted-foreground">Watermark Image</p>
<input
ref={watermarkInputRef}
type="file"
@@ -72,6 +72,7 @@ export function WatermarkImageSettings() {
className="hidden"
/>
<button
type="button"
onClick={() => watermarkInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
@@ -81,8 +82,11 @@ export function WatermarkImageSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="watermark-image-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="watermark-image-position"
value={position}
onChange={(e) => setPosition(e.target.value as Position)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -97,10 +101,13 @@ export function WatermarkImageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="watermark-image-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="watermark-image-opacity"
type="range"
min={0}
max={100}
@@ -112,10 +119,13 @@ export function WatermarkImageSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Scale</label>
<label htmlFor="watermark-image-scale" className="text-xs text-muted-foreground">
Scale
</label>
<span className="text-xs font-mono text-foreground">{scale}%</span>
</div>
<input
id="watermark-image-scale"
type="range"
min={5}
max={100}
@@ -135,6 +145,7 @@ export function WatermarkImageSettings() {
)}
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || !watermarkFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -40,8 +40,11 @@ export function WatermarkTextSettings() {
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Watermark Text</label>
<label htmlFor="watermark-text-text" className="text-xs text-muted-foreground">
Watermark Text
</label>
<input
id="watermark-text-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
@@ -51,10 +54,13 @@ export function WatermarkTextSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Font Size</label>
<label htmlFor="watermark-text-font-size" className="text-xs text-muted-foreground">
Font Size
</label>
<span className="text-xs font-mono text-foreground">{fontSize}px</span>
</div>
<input
id="watermark-text-font-size"
type="range"
min={8}
max={200}
@@ -66,8 +72,11 @@ export function WatermarkTextSettings() {
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Color</label>
<label htmlFor="watermark-text-color" className="text-xs text-muted-foreground">
Color
</label>
<input
id="watermark-text-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
@@ -76,10 +85,13 @@ export function WatermarkTextSettings() {
</div>
<div className="flex-1">
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Opacity</label>
<label htmlFor="watermark-text-opacity" className="text-xs text-muted-foreground">
Opacity
</label>
<span className="text-xs font-mono text-foreground">{opacity}%</span>
</div>
<input
id="watermark-text-opacity"
type="range"
min={0}
max={100}
@@ -91,8 +103,11 @@ export function WatermarkTextSettings() {
</div>
<div>
<label className="text-xs text-muted-foreground">Position</label>
<label htmlFor="watermark-text-position" className="text-xs text-muted-foreground">
Position
</label>
<select
id="watermark-text-position"
value={position}
onChange={(e) => setPosition(e.target.value as Position)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
@@ -108,10 +123,13 @@ export function WatermarkTextSettings() {
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Rotation</label>
<label htmlFor="watermark-text-rotation" className="text-xs text-muted-foreground">
Rotation
</label>
<span className="text-xs font-mono text-foreground">{rotation}&deg;</span>
</div>
<input
id="watermark-text-rotation"
type="range"
min={-180}
max={180}
@@ -141,6 +159,7 @@ export function WatermarkTextSettings() {
/>
) : (
<button
type="button"
onClick={handleProcess}
disabled={!hasFile || processing || !text}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
+3 -1
View File
@@ -3,7 +3,9 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
+3
View File
@@ -236,6 +236,7 @@ export function AutomatePage() {
{TEMPLATES.map((tpl) => (
<button
key={tpl.name}
type="button"
onClick={() => loadTemplate(tpl)}
className="w-full text-left p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors"
>
@@ -265,6 +266,7 @@ export function AutomatePage() {
>
<div className="flex items-center justify-between mb-1">
<button
type="button"
onClick={() => loadSaved(pipeline)}
className="text-sm font-medium text-foreground hover:text-primary flex items-center gap-1.5"
>
@@ -272,6 +274,7 @@ export function AutomatePage() {
{pipeline.name}
</button>
<button
type="button"
onClick={() => handleDelete(pipeline.id)}
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
>
@@ -81,6 +81,7 @@ export function FullscreenGridPage() {
{/* Toggle details */}
<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm transition-colors",
@@ -97,6 +98,7 @@ export function FullscreenGridPage() {
{/* Switch to sidebar view */}
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
title="Switch to sidebar view"
+3
View File
@@ -55,6 +55,7 @@ export function HomePage() {
{files.length > 1 && `${files.length} files`}
</p>
<button
type="button"
onClick={reset}
className="text-xs text-muted-foreground hover:text-foreground mt-2"
>
@@ -78,6 +79,7 @@ export function HomePage() {
return (
<button
key={id}
type="button"
onClick={() => handleToolClick(tool.route)}
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
@@ -119,6 +121,7 @@ export function HomePage() {
return (
<button
key={tool.id}
type="button"
onClick={() => handleToolClick(tool.route)}
className={cn(
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
+22 -6
View File
@@ -161,7 +161,11 @@ function FileSelectionInfo({
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">Files ({files.length})</span>
<button onClick={onAddMore} className="text-xs text-primary hover:text-primary/80">
<button
type="button"
onClick={onAddMore}
className="text-xs text-primary hover:text-primary/80"
>
+ Add more
</button>
</div>
@@ -172,7 +176,11 @@ function FileSelectionInfo({
{formatFileSize(selectedFileSize ?? files[0].size)}
</span>
</div>
<button onClick={onClear} className="text-xs text-muted-foreground hover:text-foreground">
<button
type="button"
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
@@ -322,6 +330,7 @@ export function ToolPage() {
</div>
<h2 className="font-semibold text-lg text-foreground flex-1">{tool.name}</h2>
<button
type="button"
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
>
@@ -384,7 +393,8 @@ export function ToolPage() {
)}
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -392,6 +402,7 @@ export function ToolPage() {
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -460,6 +471,7 @@ export function ToolPage() {
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -480,7 +492,7 @@ export function ToolPage() {
onSelect={setSelectedIndex}
/>
)}
</div>
</section>
</div>
</AppLayout>
);
@@ -552,6 +564,7 @@ export function ToolPage() {
<div className="space-y-2">
<div className="border-t border-border pt-2" />
<button
type="button"
onClick={handleDownloadAll}
className="w-full py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
@@ -563,7 +576,8 @@ export function ToolPage() {
</div>
{/* Main area: Dropzone / Image Viewer / Before-After */}
<div
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -571,6 +585,7 @@ export function ToolPage() {
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
@@ -639,6 +654,7 @@ export function ToolPage() {
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
@@ -659,7 +675,7 @@ export function ToolPage() {
onSelect={setSelectedIndex}
/>
)}
</div>
</section>
</div>
</AppLayout>
);