diff --git a/apps/api/src/routes/tools/crop.ts b/apps/api/src/routes/tools/crop.ts index d89acf71..08214b22 100644 --- a/apps/api/src/routes/tools/crop.ts +++ b/apps/api/src/routes/tools/crop.ts @@ -6,10 +6,11 @@ import { resolveOutputFormat } from "../../lib/output-format.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ - left: z.number().int().min(0), - top: z.number().int().min(0), - width: z.number().int().positive(), - height: z.number().int().positive(), + left: z.number().min(0), + top: z.number().min(0), + width: z.number().positive(), + height: z.number().positive(), + unit: z.enum(["px", "percent"]).optional(), }); export function registerCrop(app: FastifyInstance) { diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx index 01c988ba..7e4a5280 100644 --- a/apps/web/src/components/tools/crop-settings.tsx +++ b/apps/web/src/components/tools/crop-settings.tsx @@ -40,6 +40,10 @@ export function CropSettings({ const { crop, aspect, showGrid, imgDimensions } = cropState; + const [customMode, setCustomMode] = useState(false); + const [customW, setCustomW] = useState("3"); + const [customH, setCustomH] = useState("2"); + // Convert percentage crop to pixel values const toPixels = useCallback( (c: Crop) => { @@ -94,6 +98,7 @@ export function CropSettings({ const handleAspectSelect = useCallback( (value: number | undefined) => { + setCustomMode(false); onAspectChange(value); // When selecting an aspect ratio, adjust the current crop to match if (value && imgDimensions) { @@ -121,6 +126,42 @@ export function CropSettings({ [onAspectChange, onCropChange, imgDimensions], ); + const applyCustomAspect = useCallback( + (w: number, h: number) => { + if (w > 0 && h > 0) { + const value = w / h; + onAspectChange(value); + if (imgDimensions) { + const imgAspect = imgDimensions.width / imgDimensions.height; + let newWidth: number; + let newHeight: number; + if (value > imgAspect) { + newWidth = 100; + newHeight = (imgDimensions.width / value / imgDimensions.height) * 100; + } else { + newHeight = 100; + newWidth = ((imgDimensions.height * value) / imgDimensions.width) * 100; + } + onCropChange({ + unit: "%", + x: (100 - newWidth) / 2, + y: (100 - newHeight) / 2, + width: newWidth, + height: newHeight, + }); + } + } + }, + [onAspectChange, onCropChange, imgDimensions], + ); + + const handleCustomSelect = useCallback(() => { + setCustomMode(true); + const w = Number(customW); + const h = Number(customH); + applyCustomAspect(w, h); + }, [customW, customH, applyCustomAspect]); + const handleSwapAspect = useCallback(() => { if (aspect) { handleAspectSelect(1 / aspect); @@ -130,15 +171,23 @@ export function CropSettings({ const pixels = toPixels(crop); const handleProcess = () => { - const settings = { - left: pixels.left, - top: pixels.top, - width: Math.max(1, pixels.width), - height: Math.max(1, pixels.height), - }; if (files.length > 1) { + // Send percentage-based crop so the server adapts to each image's dimensions + const settings = { + left: crop.x, + top: crop.y, + width: Math.max(0.1, crop.width), + height: Math.max(0.1, crop.height), + unit: "percent" as const, + }; processAllFiles(files, settings); } else { + const settings = { + left: pixels.left, + top: pixels.top, + width: Math.max(1, pixels.width), + height: Math.max(1, pixels.height), + }; processFiles(files, settings); } }; @@ -184,7 +233,7 @@ export function CropSettings({ key={label} onClick={() => handleAspectSelect(value)} className={`px-2 py-1.5 rounded text-xs transition-colors ${ - activePresetLabel === label + !customMode && activePresetLabel === label ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground" }`} @@ -192,7 +241,47 @@ export function CropSettings({ {label} ))} + + {customMode && ( +
{error}
} diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 309f8b2e..91024d73 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -96,6 +96,7 @@ export function ToolPage() { setSelectedIndex, navigateNext, navigatePrev, + currentEntry, } = useFileStore(); const isMobile = useMobile(); const hasMultiple = entries.length > 1; @@ -249,6 +250,18 @@ export function ToolPage() { ); } + // Show error state for failed batch files (before interactive canvas blocks, + // which also match !hasProcessed and would show the canvas instead of the error) + if (hasFile && !hasProcessed && currentEntry?.status === "failed") { + return ( ++ {currentEntry.error ?? "Processing failed for this file"} +
+