import { PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@ashim/shared"; import { CheckCircle2, ChevronLeft, ChevronRight, Download, FileImage, 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 { type BgPreviewState, 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 { FeatureInstallPrompt } from "@/components/features/feature-install-prompt"; 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 { useAuth } from "@/hooks/use-auth"; import { useMobile } from "@/hooks/use-mobile"; import { formatFileSize } from "@/lib/download"; import { ICON_MAP } from "@/lib/icon-map"; import { getToolRegistryEntry } from "@/lib/tool-registry"; import { useFeaturesStore } from "@/stores/features-store"; 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, filename?: string | null): boolean { // Blob URLs are always renderable in tags โ€” no extension to check if (url.startsWith("blob:")) return true; // For batch results, check the stored filename (has extension) rather than the blob URL const source = filename ?? url; const ext = decodeURIComponent(source).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 isAiTool = toolId ? (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(toolId) : false; const featuresLoaded = useFeaturesStore((s) => s.loaded); const featureBundles = useFeaturesStore((s) => s.bundles); const fetchFeatures = useFeaturesStore((s) => s.fetch); const featureBundle = useMemo(() => { if (!toolId) return null; const bundleId = TOOL_BUNDLE_MAP[toolId]; if (!bundleId) return null; return featureBundles.find((b) => b.id === bundleId) ?? null; }, [toolId, featureBundles]); const toolInstalled = featureBundle ? featureBundle.status === "installed" : !isAiTool; const { hasPermission } = useAuth(); const isAdmin = hasPermission("settings:write"); useEffect(() => { if (isAiTool) fetchFeatures(); }, [isAiTool, fetchFeatures]); 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 [imageWrapperStyle, setImageWrapperStyle] = useState(null); const [bgPreview, setBgPreview] = useState(null); 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); // Center of the painted mask as a 0-100 percentage โ€” used to init the slider at the right spot const [eraserSliderInitPos, setEraserSliderInitPos] = useState(null); // 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[]) => { setEraserSliderInitPos(null); reset(); setFiles(newFiles); }, [setFiles, reset], ); const handleUndo = useCallback(() => { undoProcessing(); setEraserSliderInitPos(null); }, [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
); } if (isAiTool && !featuresLoaded) { return (
Loading...
); } if (isAiTool && !toolInstalled && featureBundle) { return ( ); } const IconComponent = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? 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: use stored filename for batch results (blob URLs), // fall back to parsing the download URL for single-file results const processedFileName = currentEntry?.processedFilename ?? (processedUrl ? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image") : "processed-image"); const processedFileType = processedFileName.split(".").pop()?.toUpperCase() || "IMAGE"; const isProcessedPreviewable = processedUrl ? canBrowserPreview(processedUrl, currentEntry?.processedFilename) : 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, onImageStyle: isLivePreview ? setImageWrapperStyle : undefined, onBgPreview: setBgPreview, cropProps: displayMode === "interactive-crop" ? { cropState, onCropChange: setCropCrop, onAspectChange: setCropAspect, onGridToggle: setCropShowGrid, } : undefined, eraserProps: displayMode === "interactive-eraser" ? { eraserRef, hasStrokes: eraserHasStrokes, brushSize: eraserBrushSize, onBrushSizeChange: setEraserBrushSize, onMaskCenter: setEraserSliderInitPos, } : undefined, }; const ToolSettings = registryEntry.Settings; // Render the image viewer based on display mode function renderImageArea() { if (isNoDropzone) { if (registryEntry?.ResultsPanel) { const Panel = registryEntry.ResultsPanel; return ( Loading...}> ); } 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"}

); } // Custom results panel (find-duplicates, etc.) if (displayMode === "custom-results" && registryEntry?.ResultsPanel) { if (!hasFile) return ; const ResultsPanel = registryEntry.ResultsPanel; return ( Loading...}> ); } if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) { return ( ); } if (displayMode === "interactive-eraser" && hasFile && !hasProcessed && originalBlobUrl) { return ( ); } // After erasing: compare clean original vs inpainted result. // Initialise the divider at the center of the painted area so the comparison // lands right where the object was removed. if (displayMode === "interactive-eraser" && hasProcessed && originalBlobUrl) { return ( ); } if (displayMode === "interactive-split" && hasFile && originalBlobUrl) { if (registryEntry?.ResultsPanel) { const Panel = registryEntry.ResultsPanel; return ( Loading...}> ); } } // 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 === "no-comparison") { return ( ); } // For live-preview tools: keep showing the CSS-styled original so WYSIWYG. // The server-rendered result is available via download. if (hasProcessed && originalBlobUrl && displayMode === "live-preview" && imageWrapperStyle) { return ( ); } if (hasProcessed && originalBlobUrl && displayMode === "live-preview") { return ( ); } if (hasProcessed && originalBlobUrl) { // When bg preview state is set (remove-background effects mode), // show the ImageViewer with layered CSS preview instead of before/after slider if (bgPreview) { return ( ); } 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...
}>
{/* Batch download โ€” shown right after settings for easy access */} {entries.length > 1 && hasProcessed && batchZipBlob && ( )} {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()}
{/* Main area: image viewer */}
{renderNavArrows()} {renderImageArea()}
{hasMultiple && ( )}
); }