mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
refactor: rename Tool.alpha to Tool.experimental
This commit is contained in:
@@ -1,19 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Workflow,
|
||||
Trash2,
|
||||
Play,
|
||||
Zap,
|
||||
ShieldOff,
|
||||
Globe,
|
||||
User,
|
||||
Stamp,
|
||||
} from "lucide-react";
|
||||
import { Globe, Play, ShieldOff, Stamp, Trash2, User, Workflow, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import {
|
||||
PipelineBuilder,
|
||||
type PipelineStep,
|
||||
} from "@/components/tools/pipeline-builder";
|
||||
import { PipelineBuilder, type PipelineStep } from "@/components/tools/pipeline-builder";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Pipeline template definition. */
|
||||
@@ -151,7 +139,7 @@ export function AutomatePage() {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[steps, loadPipelines]
|
||||
[steps, loadPipelines],
|
||||
);
|
||||
|
||||
// Delete pipeline
|
||||
@@ -167,7 +155,7 @@ export function AutomatePage() {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[loadPipelines]
|
||||
[loadPipelines],
|
||||
);
|
||||
|
||||
// Execute pipeline
|
||||
@@ -185,7 +173,7 @@ export function AutomatePage() {
|
||||
toolId: s.toolId,
|
||||
settings: s.settings,
|
||||
})),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await fetch("/api/v1/pipeline/execute", {
|
||||
@@ -209,22 +197,19 @@ export function AutomatePage() {
|
||||
setExecuting(false);
|
||||
}
|
||||
},
|
||||
[steps]
|
||||
[steps],
|
||||
);
|
||||
|
||||
// Load template into builder
|
||||
const loadTemplate = useCallback(
|
||||
(template: PipelineTemplate) => {
|
||||
const newSteps: PipelineStep[] = template.steps.map((s) => ({
|
||||
id: crypto.randomUUID(),
|
||||
toolId: s.toolId,
|
||||
settings: { ...s.settings },
|
||||
}));
|
||||
setSteps(newSteps);
|
||||
setExecutionResult(null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const loadTemplate = useCallback((template: PipelineTemplate) => {
|
||||
const newSteps: PipelineStep[] = template.steps.map((s) => ({
|
||||
id: crypto.randomUUID(),
|
||||
toolId: s.toolId,
|
||||
settings: { ...s.settings },
|
||||
}));
|
||||
setSteps(newSteps);
|
||||
setExecutionResult(null);
|
||||
}, []);
|
||||
|
||||
// Load saved pipeline into builder
|
||||
const loadSaved = useCallback((pipeline: SavedPipeline) => {
|
||||
@@ -258,13 +243,9 @@ export function AutomatePage() {
|
||||
<div className={cn("p-1.5 rounded-md", tpl.color)}>
|
||||
<tpl.icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{tpl.name}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">{tpl.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tpl.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -320,9 +301,7 @@ export function AutomatePage() {
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Automation Pipeline
|
||||
</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">Automation Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chain multiple tools into a single workflow
|
||||
</p>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Search,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LayoutGrid,
|
||||
List,
|
||||
FileImage,
|
||||
} from "lucide-react";
|
||||
import type { CategoryInfo, Tool } from "@stirling-image/shared";
|
||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
|
||||
import type { Tool, CategoryInfo } from "@stirling-image/shared";
|
||||
import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FullscreenGridPage() {
|
||||
@@ -25,7 +18,7 @@ export function FullscreenGridPage() {
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
t.category.toLowerCase().includes(q)
|
||||
t.category.toLowerCase().includes(q),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
@@ -41,10 +34,7 @@ export function FullscreenGridPage() {
|
||||
|
||||
const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id));
|
||||
|
||||
const iconsMap = icons as unknown as Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
>;
|
||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
@@ -74,7 +64,7 @@ export function FullscreenGridPage() {
|
||||
"flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm transition-colors",
|
||||
showDetails
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{showDetails ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
@@ -139,23 +129,21 @@ function CategoryCard({
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 flex items-center gap-3"
|
||||
style={{ backgroundColor: category.color + "15" }}
|
||||
style={{ backgroundColor: `${category.color}15` }}
|
||||
>
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: category.color + "25", color: category.color }}
|
||||
style={{ backgroundColor: `${category.color}25`, color: category.color }}
|
||||
>
|
||||
<CategoryIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
{category.name}
|
||||
</h3>
|
||||
<h3 className="font-semibold text-foreground text-sm">{category.name}</h3>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: category.color + "20",
|
||||
backgroundColor: `${category.color}20`,
|
||||
color: category.color,
|
||||
}}
|
||||
>
|
||||
@@ -182,9 +170,9 @@ function CategoryCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tool.alpha && (
|
||||
{tool.experimental && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 font-medium shrink-0">
|
||||
Alpha
|
||||
Experimental
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
|
||||
import * as icons from "lucide-react";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
// Tools shown prominently as "quick actions" at the top
|
||||
const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
|
||||
|
||||
export function HomePage() {
|
||||
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } = useFileStore();
|
||||
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
|
||||
useFileStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleFiles = useCallback(
|
||||
@@ -70,7 +71,10 @@ export function HomePage() {
|
||||
{QUICK_ACTION_IDS.map((id) => {
|
||||
const tool = TOOLS.find((t) => t.id === id);
|
||||
if (!tool) return null;
|
||||
const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
|
||||
const Icon =
|
||||
(icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[
|
||||
tool.icon
|
||||
] || icons.FileImage;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
@@ -97,19 +101,28 @@ export function HomePage() {
|
||||
if (categoryTools.length === 0) return null;
|
||||
return (
|
||||
<div key={category.id} className="mb-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5" style={{ color: category.color }}>
|
||||
<p
|
||||
className="text-xs font-medium text-muted-foreground mb-1.5"
|
||||
style={{ color: category.color }}
|
||||
>
|
||||
{category.name}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{categoryTools.map((tool) => {
|
||||
const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
|
||||
const Icon =
|
||||
(
|
||||
icons as unknown as Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
>
|
||||
)[tool.icon] || icons.FileImage;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => handleToolClick(tool.route)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
|
||||
"hover:bg-muted text-foreground"
|
||||
"hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { setToken } from "@/lib/api";
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -47,7 +47,9 @@ export function LoginPage() {
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">Username</label>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
@@ -59,7 +61,9 @@ export function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1 text-foreground">Password</label>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -85,7 +89,8 @@ export function LoginPage() {
|
||||
<div className="max-w-lg space-y-6 text-center">
|
||||
<h2 className="text-3xl font-bold">Your one-stop-shop for all your image needs.</h2>
|
||||
<p className="text-lg text-white/80">
|
||||
A privacy-first image suite that lets you resize, compress, convert, and process images with 30+ powerful tools.
|
||||
A privacy-first image suite that lets you resize, compress, convert, and process images
|
||||
with 30+ powerful tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+151
-121
@@ -1,61 +1,61 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMemo, useCallback, useState, useEffect } from "react";
|
||||
import type { Crop } from "react-image-crop";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import * as icons from "lucide-react";
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, 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 { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
||||
import { ReviewPanel } from "@/components/common/review-panel";
|
||||
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { ResizeSettings } from "@/components/tools/resize-settings";
|
||||
import { CropSettings } from "@/components/tools/crop-settings";
|
||||
import { CropCanvas } from "@/components/tools/crop-canvas";
|
||||
import { RotateSettings } from "@/components/tools/rotate-settings";
|
||||
import { ConvertSettings } from "@/components/tools/convert-settings";
|
||||
import { CompressSettings } from "@/components/tools/compress-settings";
|
||||
import { StripMetadataSettings } from "@/components/tools/strip-metadata-settings";
|
||||
import { ColorSettings } from "@/components/tools/color-settings";
|
||||
// Phase 3: Watermark & Overlay
|
||||
import { WatermarkTextSettings } from "@/components/tools/watermark-text-settings";
|
||||
import { WatermarkImageSettings } from "@/components/tools/watermark-image-settings";
|
||||
import { TextOverlaySettings } from "@/components/tools/text-overlay-settings";
|
||||
import { ComposeSettings } from "@/components/tools/compose-settings";
|
||||
// Phase 3: Utilities
|
||||
import { InfoSettings } from "@/components/tools/info-settings";
|
||||
import { CompareSettings } from "@/components/tools/compare-settings";
|
||||
import { FindDuplicatesSettings } from "@/components/tools/find-duplicates-settings";
|
||||
import { ColorPaletteSettings } from "@/components/tools/color-palette-settings";
|
||||
import { QrGenerateSettings } from "@/components/tools/qr-generate-settings";
|
||||
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";
|
||||
// Phase 3: Layout & Composition
|
||||
import { CollageSettings } from "@/components/tools/collage-settings";
|
||||
import { SplitSettings } from "@/components/tools/split-settings";
|
||||
import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
|
||||
import { BorderSettings } from "@/components/tools/border-settings";
|
||||
// Phase 3: Format & Conversion
|
||||
import { SvgToRasterSettings } from "@/components/tools/svg-to-raster-settings";
|
||||
import { VectorizeSettings } from "@/components/tools/vectorize-settings";
|
||||
import { GifToolsSettings } from "@/components/tools/gif-tools-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 { 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: Adjustments extra
|
||||
import { ReplaceColorSettings } from "@/components/tools/replace-color-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";
|
||||
import { UpscaleSettings } from "@/components/tools/upscale-settings";
|
||||
import { OcrSettings } from "@/components/tools/ocr-settings";
|
||||
import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
|
||||
import { EraseObjectSettings } from "@/components/tools/erase-object-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 * as icons from "lucide-react";
|
||||
import { CheckCircle2, Download, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
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 { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const COLOR_TOOL_IDS = new Set([
|
||||
"brightness-contrast",
|
||||
@@ -133,9 +133,7 @@ function ToolSettingsPanel({
|
||||
if (toolId === "smart-crop") return <SmartCropSettings />;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Settings for this tool are coming soon.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground italic">Settings for this tool are coming soon.</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,9 +153,7 @@ function FileSelectionInfo({
|
||||
}) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Drop or upload an image to get started
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground italic">Drop or upload an image to get started</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,14 +161,20 @@ function FileSelectionInfo({
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">Files ({files.length})</span>
|
||||
<button onClick={onAddMore} className="text-xs text-primary hover:text-primary/80">+ Add more</button>
|
||||
<button onClick={onAddMore} className="text-xs text-primary hover:text-primary/80">
|
||||
+ Add more
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-foreground bg-muted rounded px-2 py-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="truncate flex-1">{selectedFileName ?? files[0].name}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-1">{formatFileSize(selectedFileSize ?? files[0].size)}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-1">
|
||||
{formatFileSize(selectedFileSize ?? files[0].size)}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClear} className="text-xs text-muted-foreground hover:text-foreground">Clear all</button>
|
||||
<button onClick={onClear} className="text-xs text-muted-foreground hover:text-foreground">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -205,10 +207,18 @@ export function ToolPage() {
|
||||
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 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<PreviewTransform | null>(null);
|
||||
|
||||
@@ -240,7 +250,7 @@ export function ToolPage() {
|
||||
useEffect(() => {
|
||||
setCropCrop({ unit: "%", x: 0, y: 0, width: 100, height: 100 });
|
||||
setCropImgDimensions(null);
|
||||
}, [originalBlobUrl]);
|
||||
}, []);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
@@ -287,21 +297,15 @@ export function ToolPage() {
|
||||
}
|
||||
|
||||
const IconComponent =
|
||||
(
|
||||
icons as unknown as Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
>
|
||||
)[tool.icon] || icons.FileImage;
|
||||
(icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] ||
|
||||
icons.FileImage;
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasProcessed = !!processedUrl;
|
||||
const isNoDropzone = NO_DROPZONE_TOOLS.has(tool.id);
|
||||
|
||||
// Derive processed file info from context
|
||||
const processedFileName = selectedFileName
|
||||
? `processed-${selectedFileName}`
|
||||
: "processed-image";
|
||||
const processedFileName = selectedFileName ? `processed-${selectedFileName}` : "processed-image";
|
||||
const processedFileType = selectedFileName
|
||||
? selectedFileName.split(".").pop()?.toUpperCase() || "IMAGE"
|
||||
: "IMAGE";
|
||||
@@ -316,9 +320,7 @@ export function ToolPage() {
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground flex-1">
|
||||
{tool.name}
|
||||
</h2>
|
||||
<h2 className="font-semibold text-lg text-foreground flex-1">{tool.name}</h2>
|
||||
<button
|
||||
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
|
||||
@@ -333,9 +335,7 @@ export function ToolPage() {
|
||||
{/* File info */}
|
||||
{!isNoDropzone && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Files
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
|
||||
<FileSelectionInfo
|
||||
files={files}
|
||||
selectedFileName={selectedFileName}
|
||||
@@ -349,18 +349,22 @@ export function ToolPage() {
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Settings
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
|
||||
<ToolSettingsPanel
|
||||
toolId={tool.id}
|
||||
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
|
||||
cropProps={INTERACTIVE_CROP_TOOLS.has(tool.id) ? {
|
||||
cropState,
|
||||
onCropChange: setCropCrop,
|
||||
onAspectChange: setCropAspect,
|
||||
onGridToggle: setCropShowGrid,
|
||||
} : undefined}
|
||||
onPreviewTransform={
|
||||
LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined
|
||||
}
|
||||
cropProps={
|
||||
INTERACTIVE_CROP_TOOLS.has(tool.id)
|
||||
? {
|
||||
cropState,
|
||||
onCropChange: setCropCrop,
|
||||
onAspectChange: setCropAspect,
|
||||
onGridToggle: setCropShowGrid,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -380,10 +384,18 @@ export function ToolPage() {
|
||||
)}
|
||||
|
||||
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
||||
<div className="flex-1 flex flex-col min-h-0" onKeyDown={hasMultiple ? handleImageKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
|
||||
<div
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
|
||||
{hasMultiple && hasPrev && (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -391,7 +403,10 @@ export function ToolPage() {
|
||||
<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 ? (
|
||||
) : INTERACTIVE_CROP_TOOLS.has(tool.id) &&
|
||||
hasFile &&
|
||||
!hasProcessed &&
|
||||
originalBlobUrl ? (
|
||||
<CropCanvas
|
||||
imageSrc={originalBlobUrl}
|
||||
crop={cropCrop}
|
||||
@@ -441,15 +456,14 @@ export function ToolPage() {
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
/>
|
||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
||||
)}
|
||||
{hasMultiple && hasNext && (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -460,7 +474,11 @@ export function ToolPage() {
|
||||
)}
|
||||
</div>
|
||||
{hasMultiple && (
|
||||
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
|
||||
<ThumbnailStrip
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,17 +496,13 @@ export function ToolPage() {
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground">
|
||||
{tool.name}
|
||||
</h2>
|
||||
<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>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
|
||||
<FileSelectionInfo
|
||||
files={files}
|
||||
selectedFileName={selectedFileName}
|
||||
@@ -503,18 +517,20 @@ export function ToolPage() {
|
||||
|
||||
{/* Tool-specific settings */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Settings
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
|
||||
<ToolSettingsPanel
|
||||
toolId={tool.id}
|
||||
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
|
||||
cropProps={INTERACTIVE_CROP_TOOLS.has(tool.id) ? {
|
||||
cropState,
|
||||
onCropChange: setCropCrop,
|
||||
onAspectChange: setCropAspect,
|
||||
onGridToggle: setCropShowGrid,
|
||||
} : undefined}
|
||||
cropProps={
|
||||
INTERACTIVE_CROP_TOOLS.has(tool.id)
|
||||
? {
|
||||
cropState,
|
||||
onCropChange: setCropCrop,
|
||||
onAspectChange: setCropAspect,
|
||||
onGridToggle: setCropShowGrid,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -547,10 +563,18 @@ export function ToolPage() {
|
||||
</div>
|
||||
|
||||
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
||||
<div className="flex-1 flex flex-col min-h-0" onKeyDown={hasMultiple ? handleImageKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
|
||||
<div
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
|
||||
{hasMultiple && hasPrev && (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -558,7 +582,10 @@ export function ToolPage() {
|
||||
<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 ? (
|
||||
) : INTERACTIVE_CROP_TOOLS.has(tool.id) &&
|
||||
hasFile &&
|
||||
!hasProcessed &&
|
||||
originalBlobUrl ? (
|
||||
<CropCanvas
|
||||
imageSrc={originalBlobUrl}
|
||||
crop={cropCrop}
|
||||
@@ -608,15 +635,14 @@ export function ToolPage() {
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
/>
|
||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
||||
)}
|
||||
{hasMultiple && hasNext && (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -627,7 +653,11 @@ export function ToolPage() {
|
||||
)}
|
||||
</div>
|
||||
{hasMultiple && (
|
||||
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
|
||||
<ThumbnailStrip
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user