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