import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared"; import { CheckCircle2, ChevronLeft, ChevronRight, Download, FileImage, Loader2, Upload, } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Crop } from "react-image-crop"; import { Link, useParams } from "react-router-dom"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { BottomSheet } from "@/components/common/bottom-sheet"; 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 { ToolDropzone } from "@/components/common/tool-dropzone"; 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 { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; import { useMobile } from "@/hooks/use-mobile"; import { usePageTitle } from "@/hooks/use-page-title"; import { recordRecentTool } from "@/hooks/use-recent-tools"; import { formatFileSize } from "@/lib/download"; import { format } from "@/lib/format"; import { ICON_MAP } from "@/lib/icon-map"; import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes"; import { getToolName } from "@/lib/tool-i18n"; import { getToolRegistryEntry } from "@/lib/tool-registry"; import { useBase64Store } from "@/stores/base64-store"; import { useCollageStore } from "@/stores/collage-store"; import { useDuplicateStore } from "@/stores/duplicate-store"; import { useFeaturesStore } from "@/stores/features-store"; import { useFileStore } from "@/stores/file-store"; import { useHtmlToImageStore } from "@/stores/html-to-image-store"; import { usePdfToImageStore } from "@/stores/pdf-to-image-store"; import { useQrStore } from "@/stores/qr-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useSplitStore } from "@/stores/split-store"; const MediaPlayerView = lazy(() => import("@/components/tools/media-player-view").then((m) => ({ default: m.MediaPlayerView })), ); const DocumentView = lazy(() => import("@/components/tools/document-view").then((m) => ({ default: m.DocumentView })), ); /** Formats that browsers can render in tags. */ const BROWSER_PREVIEWABLE_EXTS = new Set([ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "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); } function getFileFormat(name: string): string { const ext = name.split(".").pop()?.toLowerCase() ?? ""; return ext.toUpperCase() || "?"; } const COLLAPSED_LIMIT = 5; /** File selection indicator shown in left panel */ function FileSelectionInfo({ files, selectedIndex, onSelect, onClear, onAddMore, }: { files: File[]; selectedIndex: number; onSelect: (index: number) => void; onClear: () => void; onAddMore: () => void; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); if (files.length === 0) { return

{t.toolPage.emptyPrompt}

; } const showToggle = files.length > COLLAPSED_LIMIT; const visible = expanded ? files : files.slice(0, COLLAPSED_LIMIT); return (
Files ({files.length})
{visible.map((file, i) => { const isSelected = i === selectedIndex; return ( ); })}
{showToggle ? ( ) : ( )}
); } export function ToolPage() { const { t } = useTranslation(); 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 showSizeComparison = toolId === "compress" || toolId === "optimize-for-web"; usePageTitle(tool ? getToolName(t, tool.id, tool.name) : undefined); const { hasPermission } = useAuth(); const isAdmin = hasPermission("settings:write"); const disabledTools = useSettingsStore((s) => s.disabledTools); const breadcrumb = useMemo(() => { if (!tool) return undefined; const modalityInfo = MODALITIES.find((m) => m.id === tool.modality); const modalityDisplay = tool.modality === "file" ? "Data" : (modalityInfo?.name ?? tool.modality); return { modality: modalityDisplay, toolName: getToolName(t, tool.id, tool.name), }; }, [tool, t]); useEffect(() => { if (isAiTool) fetchFeatures(); }, [isAiTool, fetchFeatures]); useEffect(() => { if (tool) { recordRecentTool(tool.id); } }, [tool]); 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 liveMessage = useMemo(() => { if (!currentEntry) return ""; if (currentEntry.status === "completed" && processedUrl) return t.a11y.processingComplete; if (currentEntry.status === "failed") return t.a11y.processingFailed; return ""; }, [currentEntry, processedUrl, t.a11y.processingComplete, t.a11y.processingFailed]); 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(false); const [previewTransform, setPreviewTransform] = useState(null); const [previewFilter, setPreviewFilter] = useState(""); const [imageWrapperStyle, setImageWrapperStyle] = useState(null); const [imageWrapperChildren, setImageWrapperChildren] = 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); const [eraserMaskedCount, setEraserMaskedCount] = useState(0); // Center of the painted mask as a 0-100 percentage — used to init the slider at the right spot const [eraserSliderInitPos, setEraserSliderInitPos] = useState(null); // Page-level drag overlay state const [isDraggingOver, setIsDraggingOver] = useState(false); const dragCounter = useRef(0); const isMultiFileTool = toolId ? MULTI_FILE_TOOLS.has(toolId) : false; // biome-ignore lint/correctness/useExhaustiveDependencies: toolId triggers intentional reset on tool navigation useEffect(() => { useFileStore.getState().undoProcessing(); useBase64Store.getState().reset(); useCollageStore.getState().reset(); useDuplicateStore.getState().reset(); usePdfToImageStore.getState().reset(); useQrStore.getState().reset(); useSplitStore.getState().reset(); useHtmlToImageStore.getState().reset(); setPreviewTransform(null); setPreviewFilter(""); setBgPreview(null); setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 }); setCropAspect(undefined); setCropShowGrid(true); setCropImgDimensions(null); setEraserHasStrokes(false); setEraserMaskedCount(0); setEraserBrushSize(30); setEraserSliderInitPos(null); setMobileSettingsOpen(false); }, [toolId]); const toolAccept = registryEntry?.accept; const toolAcceptExts = useMemo( () => toolAccept?.split(",").map((e) => e.trim().replace(/^\./, "").toLowerCase()), [toolAccept], ); const toolFileFilter = useMemo(() => { if (!toolAcceptExts) return undefined; return (file: File) => { const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; return toolAcceptExts.includes(ext); }; }, [toolAcceptExts]); const toolAcceptDescription = useMemo( () => toolAcceptExts ? `${toolAcceptExts.map((e) => e.toUpperCase()).join(", ")} files only` : undefined, [toolAcceptExts], ); const handleFiles = useCallback( (newFiles: File[]) => { setEraserSliderInitPos(null); reset(); setFiles(newFiles); }, [setFiles, reset], ); const handleUrlImport = useCallback( (file: File) => { addFiles([file]); }, [addFiles], ); const handleUndo = useCallback(() => { undoProcessing(); setEraserSliderInitPos(null); }, [undoProcessing]); const startOver = useCallback(() => { reset(); }, [reset]); const handleAddMore = useCallback(() => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.accept = toolAccept ?? "image/*,.avif,.heic,.heif,.hif,.jxl,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.qoi,.eps,.dds,.cur,.apng,.dpx,.cin,.fits,.ppm,.pgm,.pbm,.pfm"; input.onchange = (e) => { const selected = Array.from((e.target as HTMLInputElement).files || []); const newFiles = toolFileFilter ? selected.filter(toolFileFilter) : selected; if (newFiles.length > 0) addFiles(newFiles); }; input.click(); }, [addFiles, toolAccept, toolFileFilter]); // Page-level drag handlers (active when a file is already loaded) const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounter.current++; if (e.dataTransfer.types.includes("Files")) { setIsDraggingOver(true); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDragLeave = useCallback(() => { dragCounter.current--; if (dragCounter.current === 0) setIsDraggingOver(false); }, []); const handlePageDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); dragCounter.current = 0; setIsDraggingOver(false); const droppedFiles = Array.from(e.dataTransfer.files); if (droppedFiles.length === 0) return; const validFiles = toolFileFilter ? droppedFiles.filter(toolFileFilter) : droppedFiles; if (validFiles.length === 0) return; if (isMultiFileTool) { addFiles(validFiles); } else { setFiles(validFiles); } }, [toolFileFilter, addFiles, setFiles, isMultiFileTool], ); // Document-level paste handler (skip for generator tools that don't accept file input) useEffect(() => { if (registryEntry?.displayMode === "no-dropzone") return; const handlePaste = (e: ClipboardEvent) => { const pastedFiles: File[] = []; if (e.clipboardData?.files.length) { pastedFiles.push(...Array.from(e.clipboardData.files)); } else if (e.clipboardData?.items) { for (const item of Array.from(e.clipboardData.items)) { if (item.kind === "file") { const file = item.getAsFile(); if (file) pastedFiles.push(file); } } } if (pastedFiles.length > 0) { e.preventDefault(); const filtered = toolFileFilter ? pastedFiles.filter(toolFileFilter) : pastedFiles; if (filtered.length > 0) { if (isMultiFileTool) addFiles(filtered); else setFiles(filtered); } } }; document.addEventListener("paste", handlePaste); return () => document.removeEventListener("paste", handlePaste); }, [toolFileFilter, isMultiFileTool, addFiles, setFiles, registryEntry?.displayMode]); 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 (toolId && TOOLS.some((tt) => tt.id === toolId) && disabledTools.includes(toolId)) { return (

{t.toolPage.disabledByAdmin}

{t.toolPage.browseOtherTools}
); } if (!tool || !registryEntry) { return (

{t.toolPage.notFound}

{t.common.goHome}
); } if (isAiTool && !featuresLoaded) { return (
{t.common.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). // Falls back to the upload-decoded blobUrl so TIFF/DNG always have a renderable src. const displayUrl = (processedPreviewUrl ?? (isProcessedPreviewable ? processedUrl : null) ?? originalBlobUrl ?? processedUrl) as string; // Build settings props const settingsProps = { onPreviewTransform: isLivePreview ? setPreviewTransform : undefined, onPreviewFilter: isLivePreview ? setPreviewFilter : undefined, onImageStyle: isLivePreview ? setImageWrapperStyle : undefined, onImageOverlay: isLivePreview ? (c: React.ReactNode) => setImageWrapperChildren(c) : 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, maskedFileCount: eraserMaskedCount, } : 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 (

{t.toolPage.configureAndGenerate}

); } // Media player: native