import { TOOLS } from "@stirling-image/shared"; import * as icons from "lucide-react"; import { CheckCircle2, ChevronLeft, ChevronRight, Download, Loader2 } from "lucide-react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Crop } from "react-image-crop"; import { useParams } from "react-router-dom"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { Dropzone } from "@/components/common/dropzone"; import { ImageViewer } from "@/components/common/image-viewer"; import { ReviewPanel } from "@/components/common/review-panel"; import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; import { AppLayout } from "@/components/layout/app-layout"; import { CropCanvas } from "@/components/tools/crop-canvas"; import type { EraserCanvasRef } from "@/components/tools/eraser-canvas"; import { EraserCanvas } from "@/components/tools/eraser-canvas"; import type { PreviewTransform } from "@/components/tools/rotate-settings"; import { useMobile } from "@/hooks/use-mobile"; import { formatFileSize } from "@/lib/download"; import { getToolRegistryEntry } from "@/lib/tool-registry"; import { useFileStore } from "@/stores/file-store"; /** Formats that browsers can render in tags. */ const BROWSER_PREVIEWABLE_EXTS = new Set([ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif", ]); function canBrowserPreview(url: string): boolean { const ext = decodeURIComponent(url).split(".").pop()?.toLowerCase() ?? ""; return BROWSER_PREVIEWABLE_EXTS.has(ext); } /** File selection indicator shown in left panel */ function FileSelectionInfo({ files, selectedFileName, selectedFileSize, onClear, onAddMore, }: { files: File[]; selectedFileName: string | null; selectedFileSize: number | null; onClear: () => void; onAddMore: () => void; }) { if (files.length === 0) { return (

Drop or upload an image to get started

); } return (
Files ({files.length})
{selectedFileName ?? files[0].name} {formatFileSize(selectedFileSize ?? files[0].size)}
); } export function ToolPage() { const { toolId } = useParams<{ toolId: string }>(); const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]); const registryEntry = useMemo( () => (toolId ? getToolRegistryEntry(toolId) : undefined), [toolId], ); const { files, entries, setFiles, addFiles, reset, processedUrl, processedPreviewUrl, originalBlobUrl, originalSize, processedSize, selectedFileName, selectedFileSize, undoProcessing, batchZipBlob, batchZipFilename, selectedIndex, setSelectedIndex, navigateNext, navigatePrev, currentEntry, } = useFileStore(); const isMobile = useMobile(); const hasMultiple = entries.length > 1; const hasPrev = selectedIndex > 0; const hasNext = selectedIndex < entries.length - 1; const handleImageKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); } else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); } }, [navigateNext, navigatePrev], ); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true); const [previewTransform, setPreviewTransform] = useState(null); const [previewFilter, setPreviewFilter] = useState(""); const [cropCrop, setCropCrop] = useState({ unit: "%", x: 0, y: 0, width: 100, height: 100, }); const [cropAspect, setCropAspect] = useState(undefined); const [cropShowGrid, setCropShowGrid] = useState(true); const [cropImgDimensions, setCropImgDimensions] = useState<{ width: number; height: number; } | null>(null); const cropState = useMemo( () => ({ crop: cropCrop, aspect: cropAspect, showGrid: cropShowGrid, imgDimensions: cropImgDimensions, }), [cropCrop, cropAspect, cropShowGrid, cropImgDimensions], ); // Eraser state const eraserRef = useRef(null); const [eraserHasStrokes, setEraserHasStrokes] = useState(false); const [eraserBrushSize, setEraserBrushSize] = useState(30); // Reset crop state when the image changes useEffect(() => { setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 }); setCropImgDimensions(null); }, []); const handleFiles = useCallback( (newFiles: File[]) => { reset(); setFiles(newFiles); }, [setFiles, reset], ); const handleUndo = useCallback(() => { undoProcessing(); }, [undoProcessing]); const handleAddMore = useCallback(() => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.accept = "image/*,.heic,.heif,.hif"; input.onchange = (e) => { const newFiles = Array.from((e.target as HTMLInputElement).files || []); if (newFiles.length > 0) addFiles(newFiles); }; input.click(); }, [addFiles]); const handleDownloadAll = useCallback(() => { if (!batchZipBlob) return; const url = URL.createObjectURL(batchZipBlob); const a = document.createElement("a"); a.href = url; a.download = batchZipFilename ?? "processed-images.zip"; a.click(); URL.revokeObjectURL(url); }, [batchZipBlob, batchZipFilename]); if (!tool || !registryEntry) { return (
Tool not found
); } const IconComponent = (icons as unknown as Record>)[tool.icon] || icons.FileImage; const hasFile = files.length > 0; const hasProcessed = !!processedUrl; const displayMode = registryEntry.displayMode; const isNoDropzone = displayMode === "no-dropzone"; const isLivePreview = registryEntry.livePreview ?? false; // Derive processed file info from the actual download URL (has correct extension) const processedFileName = processedUrl ? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image") : "processed-image"; const processedFileType = processedFileName.split(".").pop()?.toUpperCase() || "IMAGE"; const isProcessedPreviewable = processedUrl ? canBrowserPreview(processedUrl) : false; // Use server-generated preview for non-previewable formats (HEIC, TIFF). // Always a string when hasProcessed is true (processedUrl is non-null). const displayUrl = (processedPreviewUrl ?? processedUrl) as string; // Build settings props const settingsProps = { onPreviewTransform: isLivePreview ? setPreviewTransform : undefined, onPreviewFilter: isLivePreview ? setPreviewFilter : undefined, cropProps: displayMode === "interactive-crop" ? { cropState, onCropChange: setCropCrop, onAspectChange: setCropAspect, onGridToggle: setCropShowGrid, } : undefined, eraserProps: displayMode === "interactive-eraser" ? { eraserRef, hasStrokes: eraserHasStrokes, brushSize: eraserBrushSize, onBrushSizeChange: setEraserBrushSize, } : undefined, }; const ToolSettings = registryEntry.Settings; // Render the image viewer based on display mode function renderImageArea() { if (isNoDropzone) { return (

Configure settings and generate.

); } // 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"}

); } if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) { return ( ); } if (displayMode === "interactive-eraser" && hasFile && !hasProcessed && originalBlobUrl) { return ( ); } // Non-previewable format with no server-generated preview - show success card if (hasProcessed && !isProcessedPreviewable && !processedPreviewUrl) { return (

Conversion complete

{processedFileName}

{processedSize != null && (

{formatFileSize(processedSize)} ยท {processedFileType}

)}

{processedFileType} files cannot be previewed in the browser. Use the download button to save your file.

); } if ( hasProcessed && originalBlobUrl && (displayMode === "side-by-side" || displayMode === "interactive-crop") ) { return ( ); } if ( hasProcessed && originalBlobUrl && (displayMode === "live-preview" || displayMode === "no-comparison") ) { return ( ); } if (hasProcessed && originalBlobUrl) { return ( ); } if (hasFile && currentEntry?.previewLoading) { return (

Generating preview...

{selectedFileName}

); } if (hasFile && originalBlobUrl) { return ( ); } return ; } // Navigation arrows (shared between mobile/desktop) function renderNavArrows() { return ( <> {hasMultiple && hasPrev && ( )} {hasMultiple && hasNext && ( )} {hasMultiple && (
{selectedIndex + 1} / {entries.length}
)} ); } // Render the settings panel content (shared between mobile/desktop) function renderSettingsContent() { return ( <> {!isNoDropzone && (

Files

)}

Settings

Loading...
}>
{hasProcessed && processedSize != null && ( )} ); } // Mobile layout: settings above dropzone (stacked) if (isMobile) { return (
{/* Tool header */}

{tool.name}

{/* Collapsible settings */} {mobileSettingsOpen && (
{renderSettingsContent()}
)} {/* Main area: image viewer */}
{renderNavArrows()} {renderImageArea()}
{hasMultiple && ( )}
); } // Desktop layout: side-by-side return (
{/* Tool Settings Panel */}

{tool.name}

{renderSettingsContent()} {/* Batch download */} {entries.length > 1 && hasProcessed && batchZipBlob && (
)}
{/* Main area: image viewer */}
{renderNavArrows()} {renderImageArea()}
{hasMultiple && ( )}
); }