feat: add worker threads, persistent Python sidecar, graceful shutdown, and architectural improvements

- Graceful shutdown: SIGTERM/SIGINT handlers drain HTTP, stop workers, close DB
- Thumbnail caching: disk-cached thumbnails with immutable Cache-Control headers
- Worker thread pool: Piscina offloads Sharp processing off the main event loop
- Persistent Python dispatcher: pre-imports ML libraries, eliminates cold-start latency
- Tool page registry: declarative tool-to-component mapping replaces 750-line switch
- File store cleanup: remove dead derived fields, stable files array reference
- Job persistence: progress written to SQLite jobs table, stale jobs recovered on startup
This commit is contained in:
Siddharth Kumar Sah
2026-03-29 17:23:41 +08:00
parent 88729e255d
commit 1cbdfa1590
20 changed files with 1575 additions and 530 deletions
+304
View File
@@ -0,0 +1,304 @@
/**
* Tool UI registry.
*
* Maps each toolId to its settings component, display mode, and capabilities.
* Adding a new tool means adding one entry here instead of editing a 750-line file.
*/
import type React from "react";
import { lazy } from "react";
import type { Crop } from "react-image-crop";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
// ── Display modes ──────────────────────────────────────────────────
export type DisplayMode =
| "side-by-side"
| "before-after"
| "live-preview"
| "no-comparison"
| "interactive-crop"
| "interactive-eraser"
| "no-dropzone";
// ── Crop and eraser prop types ─────────────────────────────────────
export interface CropProps {
cropState: {
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
};
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
}
export interface EraserProps {
eraserRef: React.RefObject<EraserCanvasRef | null>;
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
}
// ── Registry entry ─────────────────────────────────────────────────
export interface ToolRegistryEntry {
/** The display mode for this tool's image viewer. */
displayMode: DisplayMode;
/** Whether this tool supports live preview transforms (rotate, color). */
livePreview?: boolean;
/** The settings component for this tool. */
Settings: React.ComponentType<{
onPreviewTransform?: (t: PreviewTransform) => void;
onPreviewFilter?: (filter: string) => void;
cropProps?: CropProps;
eraserProps?: EraserProps;
}>;
}
// ── Lazy-loaded settings components ────────────────────────────────
// Using dynamic imports so the bundle only loads what's needed.
const ResizeSettings = lazy(() =>
import("@/components/tools/resize-settings").then((m) => ({ default: m.ResizeSettings })),
);
const CropSettings = lazy(() =>
import("@/components/tools/crop-settings").then((m) => ({ default: m.CropSettings })),
);
const RotateSettings = lazy(() =>
import("@/components/tools/rotate-settings").then((m) => ({ default: m.RotateSettings })),
);
const ConvertSettings = lazy(() =>
import("@/components/tools/convert-settings").then((m) => ({ default: m.ConvertSettings })),
);
const CompressSettings = lazy(() =>
import("@/components/tools/compress-settings").then((m) => ({ default: m.CompressSettings })),
);
const StripMetadataSettings = lazy(() =>
import("@/components/tools/strip-metadata-settings").then((m) => ({
default: m.StripMetadataSettings,
})),
);
const ColorSettings = lazy(() =>
import("@/components/tools/color-settings").then((m) => ({ default: m.ColorSettings })),
);
const WatermarkTextSettings = lazy(() =>
import("@/components/tools/watermark-text-settings").then((m) => ({
default: m.WatermarkTextSettings,
})),
);
const WatermarkImageSettings = lazy(() =>
import("@/components/tools/watermark-image-settings").then((m) => ({
default: m.WatermarkImageSettings,
})),
);
const TextOverlaySettings = lazy(() =>
import("@/components/tools/text-overlay-settings").then((m) => ({
default: m.TextOverlaySettings,
})),
);
const ComposeSettings = lazy(() =>
import("@/components/tools/compose-settings").then((m) => ({ default: m.ComposeSettings })),
);
const InfoSettings = lazy(() =>
import("@/components/tools/info-settings").then((m) => ({ default: m.InfoSettings })),
);
const CompareSettings = lazy(() =>
import("@/components/tools/compare-settings").then((m) => ({ default: m.CompareSettings })),
);
const FindDuplicatesSettings = lazy(() =>
import("@/components/tools/find-duplicates-settings").then((m) => ({
default: m.FindDuplicatesSettings,
})),
);
const ColorPaletteSettings = lazy(() =>
import("@/components/tools/color-palette-settings").then((m) => ({
default: m.ColorPaletteSettings,
})),
);
const QrGenerateSettings = lazy(() =>
import("@/components/tools/qr-generate-settings").then((m) => ({
default: m.QrGenerateSettings,
})),
);
const BarcodeReadSettings = lazy(() =>
import("@/components/tools/barcode-read-settings").then((m) => ({
default: m.BarcodeReadSettings,
})),
);
const CollageSettings = lazy(() =>
import("@/components/tools/collage-settings").then((m) => ({ default: m.CollageSettings })),
);
const SplitSettings = lazy(() =>
import("@/components/tools/split-settings").then((m) => ({ default: m.SplitSettings })),
);
const BorderSettings = lazy(() =>
import("@/components/tools/border-settings").then((m) => ({ default: m.BorderSettings })),
);
const SvgToRasterSettings = lazy(() =>
import("@/components/tools/svg-to-raster-settings").then((m) => ({
default: m.SvgToRasterSettings,
})),
);
const VectorizeSettings = lazy(() =>
import("@/components/tools/vectorize-settings").then((m) => ({
default: m.VectorizeSettings,
})),
);
const GifToolsSettings = lazy(() =>
import("@/components/tools/gif-tools-settings").then((m) => ({
default: m.GifToolsSettings,
})),
);
const BulkRenameSettings = lazy(() =>
import("@/components/tools/bulk-rename-settings").then((m) => ({
default: m.BulkRenameSettings,
})),
);
const FaviconSettings = lazy(() =>
import("@/components/tools/favicon-settings").then((m) => ({ default: m.FaviconSettings })),
);
const ImageToPdfSettings = lazy(() =>
import("@/components/tools/image-to-pdf-settings").then((m) => ({
default: m.ImageToPdfSettings,
})),
);
const ReplaceColorSettings = lazy(() =>
import("@/components/tools/replace-color-settings").then((m) => ({
default: m.ReplaceColorSettings,
})),
);
const RemoveBgSettings = lazy(() =>
import("@/components/tools/remove-bg-settings").then((m) => ({
default: m.RemoveBgSettings,
})),
);
const UpscaleSettings = lazy(() =>
import("@/components/tools/upscale-settings").then((m) => ({ default: m.UpscaleSettings })),
);
const OcrSettings = lazy(() =>
import("@/components/tools/ocr-settings").then((m) => ({ default: m.OcrSettings })),
);
const BlurFacesSettings = lazy(() =>
import("@/components/tools/blur-faces-settings").then((m) => ({
default: m.BlurFacesSettings,
})),
);
const EraseObjectSettings = lazy(() =>
import("@/components/tools/erase-object-settings").then((m) => ({
default: m.EraseObjectSettings,
})),
);
const SmartCropSettings = lazy(() =>
import("@/components/tools/smart-crop-settings").then((m) => ({
default: m.SmartCropSettings,
})),
);
// ── Color tool wrapper ─────────────────────────────────────────────
// Color tools share a single component but differ by toolId.
function makeColorSettingsComponent(
toolId: string,
): React.ComponentType<{ onPreviewFilter?: (filter: string) => void }> {
return function ColorSettingsForTool(props: { onPreviewFilter?: (filter: string) => void }) {
return <ColorSettings toolId={toolId} onPreviewFilter={props.onPreviewFilter} />;
};
}
// ── Crop/Eraser wrappers ───────────────────────────────────────────
// These tools need special props that are passed through the registry.
function CropSettingsWrapper(props: { cropProps?: CropProps }) {
if (!props.cropProps) return null;
return <CropSettings {...props.cropProps} />;
}
function EraseObjectSettingsWrapper(props: { eraserProps?: EraserProps }) {
if (!props.eraserProps) return null;
return <EraseObjectSettings {...props.eraserProps} />;
}
// ── The registry ───────────────────────────────────────────────────
export const toolRegistry = new Map<string, ToolRegistryEntry>([
// Essentials
["resize", { displayMode: "side-by-side", Settings: ResizeSettings }],
["crop", { displayMode: "interactive-crop", Settings: CropSettingsWrapper as never }],
[
"rotate",
{
displayMode: "side-by-side",
livePreview: true,
Settings: RotateSettings as never,
},
],
["convert", { displayMode: "no-comparison", Settings: ConvertSettings }],
["compress", { displayMode: "before-after", Settings: CompressSettings }],
["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }],
// Color adjustments (all share ColorSettings with different toolId)
...(["brightness-contrast", "saturation", "color-channels", "color-effects"] as const).map(
(id) =>
[
id,
{
displayMode: "live-preview" as DisplayMode,
livePreview: true,
Settings: makeColorSettingsComponent(id) as never,
},
] as const,
),
// Watermark & Overlay
["watermark-text", { displayMode: "before-after", Settings: WatermarkTextSettings }],
["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }],
["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }],
["compose", { displayMode: "before-after", Settings: ComposeSettings }],
// Utilities
["info", { displayMode: "before-after", Settings: InfoSettings }],
["compare", { displayMode: "before-after", Settings: CompareSettings }],
["find-duplicates", { displayMode: "before-after", Settings: FindDuplicatesSettings }],
["color-palette", { displayMode: "before-after", Settings: ColorPaletteSettings }],
["qr-generate", { displayMode: "no-dropzone", Settings: QrGenerateSettings }],
["barcode-read", { displayMode: "before-after", Settings: BarcodeReadSettings }],
// Layout & Composition
["collage", { displayMode: "before-after", Settings: CollageSettings }],
["split", { displayMode: "before-after", Settings: SplitSettings }],
["border", { displayMode: "before-after", Settings: BorderSettings }],
// Format & Conversion
["svg-to-raster", { displayMode: "before-after", Settings: SvgToRasterSettings }],
["vectorize", { displayMode: "before-after", Settings: VectorizeSettings }],
["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }],
// Optimization extras
["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }],
["favicon", { displayMode: "before-after", Settings: FaviconSettings }],
["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }],
// Adjustments extra
["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }],
// AI Tools
["remove-background", { displayMode: "before-after", Settings: RemoveBgSettings }],
["upscale", { displayMode: "before-after", Settings: UpscaleSettings }],
["ocr", { displayMode: "before-after", Settings: OcrSettings }],
["blur-faces", { displayMode: "before-after", Settings: BlurFacesSettings }],
[
"erase-object",
{
displayMode: "interactive-eraser",
Settings: EraseObjectSettingsWrapper as never,
},
],
["smart-crop", { displayMode: "before-after", Settings: SmartCropSettings }],
]);
export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined {
return toolRegistry.get(toolId);
}
+210 -453
View File
@@ -1,7 +1,7 @@
import { TOOLS } from "@stirling-image/shared";
import * as icons from "lucide-react";
import { CheckCircle2, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "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";
@@ -11,151 +11,15 @@ 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 { BarcodeReadSettings } from "@/components/tools/barcode-read-settings";
import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
import { BorderSettings } from "@/components/tools/border-settings";
// Phase 3: Optimization extras
import { BulkRenameSettings } from "@/components/tools/bulk-rename-settings";
// Phase 3: Layout & Composition
import { CollageSettings } from "@/components/tools/collage-settings";
import { ColorPaletteSettings } from "@/components/tools/color-palette-settings";
import { ColorSettings } from "@/components/tools/color-settings";
import { CompareSettings } from "@/components/tools/compare-settings";
import { ComposeSettings } from "@/components/tools/compose-settings";
import { CompressSettings } from "@/components/tools/compress-settings";
import { ConvertSettings } from "@/components/tools/convert-settings";
import { CropCanvas } from "@/components/tools/crop-canvas";
import { CropSettings } from "@/components/tools/crop-settings";
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import { EraserCanvas } from "@/components/tools/eraser-canvas";
import { FaviconSettings } from "@/components/tools/favicon-settings";
import { FindDuplicatesSettings } from "@/components/tools/find-duplicates-settings";
import { GifToolsSettings } from "@/components/tools/gif-tools-settings";
import { ImageToPdfSettings } from "@/components/tools/image-to-pdf-settings";
// Phase 3: Utilities
import { InfoSettings } from "@/components/tools/info-settings";
import { OcrSettings } from "@/components/tools/ocr-settings";
import { QrGenerateSettings } from "@/components/tools/qr-generate-settings";
// Phase 4: AI Tools
import { RemoveBgSettings } from "@/components/tools/remove-bg-settings";
// Phase 3: Adjustments extra
import { ReplaceColorSettings } from "@/components/tools/replace-color-settings";
import { ResizeSettings } from "@/components/tools/resize-settings";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
import { RotateSettings } from "@/components/tools/rotate-settings";
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
import { SplitSettings } from "@/components/tools/split-settings";
import { StripMetadataSettings } from "@/components/tools/strip-metadata-settings";
// Phase 3: Format & Conversion
import { SvgToRasterSettings } from "@/components/tools/svg-to-raster-settings";
import { TextOverlaySettings } from "@/components/tools/text-overlay-settings";
import { UpscaleSettings } from "@/components/tools/upscale-settings";
import { VectorizeSettings } from "@/components/tools/vectorize-settings";
import { WatermarkImageSettings } from "@/components/tools/watermark-image-settings";
// Phase 3: Watermark & Overlay
import { WatermarkTextSettings } from "@/components/tools/watermark-text-settings";
import { useMobile } from "@/hooks/use-mobile";
import { formatFileSize } from "@/lib/download";
import { getToolRegistryEntry } from "@/lib/tool-registry";
import { useFileStore } from "@/stores/file-store";
const COLOR_TOOL_IDS = new Set([
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
]);
// Tools that don't need a file dropzone (they generate content or have custom UI)
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate", "erase-object"]);
const LIVE_PREVIEW_TOOLS = new Set([
"rotate",
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
]);
const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]);
const INTERACTIVE_CROP_TOOLS = new Set(["crop"]);
const INTERACTIVE_ERASER_TOOLS = new Set(["erase-object"]);
function ToolSettingsPanel({
toolId,
onPreviewTransform,
onPreviewFilter,
cropProps,
eraserProps,
}: {
toolId: string;
onPreviewTransform?: (t: PreviewTransform) => void;
onPreviewFilter?: (filter: string) => void;
cropProps?: {
cropState: {
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
};
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
};
eraserProps?: {
eraserRef: React.RefObject<EraserCanvasRef | null>;
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
};
}) {
// Phase 2: Core tools
if (toolId === "resize") return <ResizeSettings />;
if (toolId === "crop" && cropProps) return <CropSettings {...cropProps} />;
if (toolId === "rotate") return <RotateSettings onPreviewTransform={onPreviewTransform} />;
if (toolId === "convert") return <ConvertSettings />;
if (toolId === "compress") return <CompressSettings />;
if (toolId === "strip-metadata") return <StripMetadataSettings />;
if (COLOR_TOOL_IDS.has(toolId))
return <ColorSettings toolId={toolId} onPreviewFilter={onPreviewFilter} />;
// Phase 3: Watermark & Overlay
if (toolId === "watermark-text") return <WatermarkTextSettings />;
if (toolId === "watermark-image") return <WatermarkImageSettings />;
if (toolId === "text-overlay") return <TextOverlaySettings />;
if (toolId === "compose") return <ComposeSettings />;
// Phase 3: Utilities
if (toolId === "info") return <InfoSettings />;
if (toolId === "compare") return <CompareSettings />;
if (toolId === "find-duplicates") return <FindDuplicatesSettings />;
if (toolId === "color-palette") return <ColorPaletteSettings />;
if (toolId === "qr-generate") return <QrGenerateSettings />;
if (toolId === "barcode-read") return <BarcodeReadSettings />;
// Phase 3: Layout & Composition
if (toolId === "collage") return <CollageSettings />;
if (toolId === "split") return <SplitSettings />;
if (toolId === "border") return <BorderSettings />;
// Phase 3: Format & Conversion
if (toolId === "svg-to-raster") return <SvgToRasterSettings />;
if (toolId === "vectorize") return <VectorizeSettings />;
if (toolId === "gif-tools") return <GifToolsSettings />;
// Phase 3: Optimization extras
if (toolId === "bulk-rename") return <BulkRenameSettings />;
if (toolId === "favicon") return <FaviconSettings />;
if (toolId === "image-to-pdf") return <ImageToPdfSettings />;
// Phase 3: Adjustments extra
if (toolId === "replace-color") return <ReplaceColorSettings />;
// Phase 4: AI Tools
if (toolId === "remove-background") return <RemoveBgSettings />;
if (toolId === "upscale") return <UpscaleSettings />;
if (toolId === "ocr") return <OcrSettings />;
if (toolId === "blur-faces") return <BlurFacesSettings />;
if (toolId === "erase-object" && eraserProps) return <EraseObjectSettings {...eraserProps} />;
if (toolId === "smart-crop") return <SmartCropSettings />;
return (
<p className="text-xs text-muted-foreground italic">Settings for this tool are coming soon.</p>
);
}
/** File selection indicator shown in left panel */
function FileSelectionInfo({
files,
@@ -209,6 +73,10 @@ function FileSelectionInfo({
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,
@@ -319,7 +187,7 @@ export function ToolPage() {
URL.revokeObjectURL(url);
}, [batchZipBlob, batchZipFilename]);
if (!tool) {
if (!tool || !registryEntry) {
return (
<AppLayout>
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -335,7 +203,9 @@ export function ToolPage() {
const hasFile = files.length > 0;
const hasProcessed = !!processedUrl;
const isNoDropzone = NO_DROPZONE_TOOLS.has(tool.id);
const displayMode = registryEntry.displayMode;
const isNoDropzone = displayMode === "no-dropzone";
const isLivePreview = registryEntry.livePreview ?? false;
// Derive processed file info from context
const processedFileName = selectedFileName ? `processed-${selectedFileName}` : "processed-image";
@@ -343,6 +213,198 @@ export function ToolPage() {
? selectedFileName.split(".").pop()?.toUpperCase() || "IMAGE"
: "IMAGE";
// 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 (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
);
}
if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) {
return (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
);
}
if (displayMode === "interactive-eraser" && hasFile && !hasProcessed && originalBlobUrl) {
return (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
);
}
if (hasProcessed && originalBlobUrl && displayMode === "side-by-side") {
return (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
);
}
if (
hasProcessed &&
originalBlobUrl &&
(displayMode === "live-preview" || displayMode === "no-comparison")
) {
return (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
);
}
if (hasProcessed && originalBlobUrl) {
return (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
);
}
if (hasFile && originalBlobUrl) {
return (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(isLivePreview && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
{...(isLivePreview && previewFilter ? { cssFilter: previewFilter } : {})}
/>
);
}
return <Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />;
}
// Navigation arrows (shared between mobile/desktop)
function renderNavArrows() {
return (
<>
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
</>
);
}
// Render the settings panel content (shared between mobile/desktop)
function renderSettingsContent() {
return (
<>
{!isNoDropzone && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<FileSelectionInfo
files={files}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={reset}
onAddMore={handleAddMore}
/>
</div>
)}
<div className="border-t border-border" />
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<Suspense fallback={<div className="text-xs text-muted-foreground">Loading...</div>}>
<ToolSettings {...settingsProps} />
</Suspense>
</div>
{hasProcessed && processedSize != null && (
<ReviewPanel
filename={processedFileName}
fileSize={processedSize}
fileType={processedFileType}
downloadUrl={processedUrl}
previewUrl={processedUrl}
onUndo={handleUndo}
currentToolId={tool?.id ?? ""}
/>
)}
</>
);
}
// Mobile layout: settings above dropzone (stacked)
if (isMobile) {
return (
@@ -366,69 +428,11 @@ export function ToolPage() {
{/* Collapsible settings */}
{mobileSettingsOpen && (
<div className="p-4 border-b border-border space-y-3 shrink-0 max-h-[40vh] overflow-y-auto">
{/* File info */}
{!isNoDropzone && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<FileSelectionInfo
files={files}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={reset}
onAddMore={handleAddMore}
/>
</div>
)}
<div className="border-t border-border" />
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={
LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined
}
onPreviewFilter={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewFilter : undefined}
cropProps={
INTERACTIVE_CROP_TOOLS.has(tool.id)
? {
cropState,
onCropChange: setCropCrop,
onAspectChange: setCropAspect,
onGridToggle: setCropShowGrid,
}
: undefined
}
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/>
</div>
{/* Review panel (mobile) */}
{hasProcessed && processedSize != null && (
<ReviewPanel
filename={processedFileName}
fileSize={processedSize}
fileType={processedFileType}
downloadUrl={processedUrl}
previewUrl={processedUrl}
onUndo={handleUndo}
currentToolId={tool.id}
/>
)}
{renderSettingsContent()}
</div>
)}
{/* Main area: Dropzone / Image Viewer / Before-After */}
{/* Main area: image viewer */}
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
@@ -436,103 +440,8 @@ export function ToolPage() {
tabIndex={hasMultiple ? 0 : undefined}
>
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : INTERACTIVE_CROP_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewFilter
? { cssFilter: previewFilter }
: {})}
/>
) : (
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
{renderNavArrows()}
{renderImageArea()}
</div>
{hasMultiple && (
<ThumbnailStrip
@@ -560,64 +469,7 @@ export function ToolPage() {
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
</div>
{/* File info - hidden for tools that don't need files */}
{!isNoDropzone && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<FileSelectionInfo
files={files}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={reset}
onAddMore={handleAddMore}
/>
</div>
)}
<div className="border-t border-border" />
{/* Tool-specific settings */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
onPreviewFilter={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewFilter : undefined}
cropProps={
INTERACTIVE_CROP_TOOLS.has(tool.id)
? {
cropState,
onCropChange: setCropCrop,
onAspectChange: setCropAspect,
onGridToggle: setCropShowGrid,
}
: undefined
}
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/>
</div>
{/* Review panel (desktop - below settings) */}
{hasProcessed && processedSize != null && (
<ReviewPanel
filename={processedFileName}
fileSize={processedSize}
fileType={processedFileType}
downloadUrl={processedUrl}
previewUrl={processedUrl}
onUndo={handleUndo}
currentToolId={tool.id}
/>
)}
{renderSettingsContent()}
{/* Batch download */}
{entries.length > 1 && hasProcessed && batchZipBlob && (
@@ -635,7 +487,7 @@ export function ToolPage() {
)}
</div>
{/* Main area: Dropzone / Image Viewer / Before-After */}
{/* Main area: image viewer */}
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
@@ -643,103 +495,8 @@ export function ToolPage() {
tabIndex={hasMultiple ? 0 : undefined}
>
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : INTERACTIVE_CROP_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewFilter
? { cssFilter: previewFilter }
: {})}
/>
) : (
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
{renderNavArrows()}
{renderImageArea()}
</div>
{hasMultiple && (
<ThumbnailStrip
+53 -35
View File
@@ -35,6 +35,40 @@ function revokeEntries(entries: FileEntry[]): void {
}
}
// ---------------------------------------------------------------------------
// Derived state helpers
// ---------------------------------------------------------------------------
/**
* Derive fields from the selected entry. Only recomputes fields that are
* actually consumed by components (tool-page, home-page, use-tool-processor).
*/
function deriveSelected(entries: FileEntry[], selectedIndex: number) {
const entry = entries[selectedIndex];
return {
currentEntry: entry,
selectedFileName: entry ? entry.file.name : null,
selectedFileSize: entry ? entry.file.size : null,
originalBlobUrl: entry ? entry.blobUrl : null,
processedUrl: entry ? entry.processedUrl : null,
originalSize: entry ? entry.originalSize : null,
processedSize: entry ? entry.processedSize : null,
};
}
/**
* Build the File[] array from entries, reusing the previous reference
* when the underlying File objects haven't changed.
*/
let prevFiles: File[] = [];
function deriveFiles(entries: FileEntry[]): File[] {
if (entries.length === prevFiles.length && entries.every((e, i) => e.file === prevFiles[i])) {
return prevFiles;
}
prevFiles = entries.map((e) => e.file);
return prevFiles;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
@@ -47,11 +81,9 @@ interface FileState {
processing: boolean;
error: string | null;
// Backward compat getters (computed from entries + selectedIndex)
// Derived from entries (selected entry fields)
readonly files: File[];
readonly currentEntry: FileEntry | undefined;
readonly hasFiles: boolean;
readonly allProcessed: boolean;
readonly selectedFileName: string | null;
readonly selectedFileSize: number | null;
readonly originalBlobUrl: string | null;
@@ -77,26 +109,6 @@ interface FileState {
reset: () => void;
}
/**
* Compute backward-compat derived values from core state.
* Called after every state mutation to keep derived fields in sync.
*/
function deriveCompat(entries: FileEntry[], selectedIndex: number) {
const entry = entries[selectedIndex];
return {
files: entries.map((e) => e.file),
currentEntry: entry,
hasFiles: entries.length > 0,
allProcessed: entries.length > 0 && entries.every((e) => e.status === "completed"),
selectedFileName: entry ? entry.file.name : null,
selectedFileSize: entry ? entry.file.size : null,
originalBlobUrl: entry ? entry.blobUrl : null,
processedUrl: entry ? entry.processedUrl : null,
originalSize: entry ? entry.originalSize : null,
processedSize: entry ? entry.processedSize : null,
};
}
export const useFileStore = create<FileState>((set, get) => ({
entries: [],
selectedIndex: 0,
@@ -106,7 +118,8 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
// Initial derived values (empty state)
...deriveCompat([], 0),
files: [],
...deriveSelected([], 0),
// -- Actions --------------------------------------------------------------
@@ -117,14 +130,15 @@ export const useFileStore = create<FileState>((set, get) => ({
entries,
selectedIndex: 0,
error: null,
...deriveCompat(entries, 0),
files: deriveFiles(entries),
...deriveSelected(entries, 0),
});
},
addFiles: (files) => {
const entries = [...get().entries, ...files.map(createEntry)];
const idx = get().selectedIndex;
set({ entries, ...deriveCompat(entries, idx) });
set({ entries, files: deriveFiles(entries), ...deriveSelected(entries, idx) });
},
removeFile: (index) => {
@@ -147,14 +161,15 @@ export const useFileStore = create<FileState>((set, get) => ({
set({
entries: newEntries,
selectedIndex: newIndex,
...deriveCompat(newEntries, newIndex),
files: deriveFiles(newEntries),
...deriveSelected(newEntries, newIndex),
});
},
setSelectedIndex: (index) => {
set({
selectedIndex: index,
...deriveCompat(get().entries, index),
...deriveSelected(get().entries, index),
});
},
@@ -162,7 +177,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const { selectedIndex, entries } = get();
if (selectedIndex < entries.length - 1) {
const idx = selectedIndex + 1;
set({ selectedIndex: idx, ...deriveCompat(entries, idx) });
set({ selectedIndex: idx, ...deriveSelected(entries, idx) });
}
},
@@ -170,7 +185,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const { selectedIndex, entries } = get();
if (selectedIndex > 0) {
const idx = selectedIndex - 1;
set({ selectedIndex: idx, ...deriveCompat(entries, idx) });
set({ selectedIndex: idx, ...deriveSelected(entries, idx) });
}
},
@@ -179,7 +194,7 @@ export const useFileStore = create<FileState>((set, get) => ({
if (!entries[index]) return;
entries[index] = { ...entries[index], ...patch };
const idx = get().selectedIndex;
set({ entries, ...deriveCompat(entries, idx) });
set({ entries, files: deriveFiles(entries), ...deriveSelected(entries, idx) });
},
setBatchZip: (blob, filename) => set({ batchZipBlob: blob, batchZipFilename: filename }),
@@ -209,7 +224,7 @@ export const useFileStore = create<FileState>((set, get) => ({
status: "pending",
};
}
set({ entries: updated, ...deriveCompat(updated, selectedIndex) });
set({ entries: updated, ...deriveSelected(updated, selectedIndex) });
},
setSizes: (original, processed) => {
@@ -221,7 +236,7 @@ export const useFileStore = create<FileState>((set, get) => ({
originalSize: original,
processedSize: processed,
};
set({ entries: updated, ...deriveCompat(updated, selectedIndex) });
set({ entries: updated, ...deriveSelected(updated, selectedIndex) });
},
undoProcessing: () => {
@@ -239,12 +254,14 @@ export const useFileStore = create<FileState>((set, get) => ({
set({
entries: resetEntries,
error: null,
...deriveCompat(resetEntries, selectedIndex),
files: deriveFiles(resetEntries),
...deriveSelected(resetEntries, selectedIndex),
});
},
reset: () => {
revokeEntries(get().entries);
prevFiles = [];
set({
entries: [],
selectedIndex: 0,
@@ -252,7 +269,8 @@ export const useFileStore = create<FileState>((set, get) => ({
batchZipFilename: null,
processing: false,
error: null,
...deriveCompat([], 0),
files: [],
...deriveSelected([], 0),
});
},
}));