fix: resolve all QA report issues from 2026-06-05 sweep

Merges 77 QA issue fixes across routing, editor, i18n, pipeline,
and settings. Includes catch-all 404 page, keyboard shortcuts,
export filename, password validation, selection masking, brush flow,
eraser modes, feather control, pipeline persistence, RTL properties,
license info, and i18n for all 21 locales.
This commit is contained in:
SnapOtter
2026-06-06 10:37:36 +08:00
44 changed files with 1127 additions and 210 deletions
+2 -2
View File
@@ -264,7 +264,7 @@ export function Dropzone({
<>
<div className="flex items-center gap-2 w-full max-w-xs">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">or</span>
<span className="text-xs text-muted-foreground">{t.dropzone.orSeparator}</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex gap-2 w-full max-w-sm">
@@ -295,7 +295,7 @@ export function Dropzone({
disabled={urlLoading || !urlInput.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{urlLoading ? "..." : "Add"}
{urlLoading ? t.dropzone.urlLoadingIndicator : t.dropzone.addUrlButton}
</button>
</div>
{urlError && <p className="text-xs text-destructive">{urlError}</p>}
@@ -1,5 +1,6 @@
import { Check, FolderOpen, ImageIcon, Loader2, Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import {
apiListFiles,
formatHeaders,
@@ -62,6 +63,7 @@ interface FileLibraryModalProps {
}
export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) {
const { t } = useTranslation();
const [files, setFiles] = useState<UserFile[]>([]);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
@@ -151,7 +153,7 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<FolderOpen className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold text-foreground flex-1">Import from Library</h2>
<h2 className="text-sm font-semibold text-foreground flex-1">{t.automate.importFromLibrary}</h2>
<button
type="button"
onClick={onClose}
@@ -75,6 +75,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
transparent: true,
});
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [estimatedSize, setEstimatedSize] = useState<number | null>(null);
const [copyStatus, setCopyStatus] = useState<"idle" | "copied">("idle");
const aspectRatio = canvasSize.width / canvasSize.height;
@@ -103,7 +104,22 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
height: canvasSize.height,
});
setPreviewUrl(url);
}, [canvasSize, settings.format, settings.quality]);
const pixelRatio = settings.width / canvasSize.width;
const fullUrl = stage.toDataURL({
pixelRatio,
mimeType: previewMime,
quality: settings.quality / 100,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
fetch(fullUrl)
.then((res) => res.blob())
.then((blob) => setEstimatedSize(blob.size))
.catch(() => setEstimatedSize(null));
}, [canvasSize, settings.format, settings.quality, settings.width, settings.height]);
// Generate preview thumbnail on format/transparency change
useEffect(() => {
@@ -454,6 +470,14 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
alt="Export preview"
className="max-h-[120px] object-contain rounded"
/>
{estimatedSize !== null && (
<p className="text-[10px] text-muted-foreground text-center mt-1">
~
{estimatedSize < 1024 * 1024
? `${(estimatedSize / 1024).toFixed(0)} KB`
: `${(estimatedSize / (1024 * 1024)).toFixed(1)} MB`}
</p>
)}
</div>
)}
@@ -57,6 +57,7 @@ function useMenuDefinitions(callbacks: MenuBarCallbacks): MenuDef[] {
const setSelection = useEditorStore((s) => s.setSelection);
const invertSelection = useEditorStore((s) => s.invertSelection);
const canvasSize = useEditorStore((s) => s.canvasSize);
const setPanOffset = useEditorStore((s) => s.setPanOffset);
const rotateCanvas = useEditorStore((s) => s.rotateCanvas);
const flipCanvasHorizontal = useEditorStore((s) => s.flipCanvasHorizontal);
const flipCanvasVertical = useEditorStore((s) => s.flipCanvasVertical);
@@ -327,11 +328,34 @@ function useMenuDefinitions(callbacks: MenuBarCallbacks): MenuDef[] {
items: [
{ label: "Zoom In", shortcut: mod("Ctrl+="), action: () => setZoom(zoom * 1.25) },
{ label: "Zoom Out", shortcut: mod("Ctrl+-"), action: () => setZoom(zoom / 1.25) },
{ label: "Fit on Screen", shortcut: mod("Ctrl+0"), action: () => setZoom(1) },
{
label: "Fit on Screen",
shortcut: mod("Ctrl+0"),
action: () => {
const editorCanvas = document.querySelector("[data-testid='editor-canvas']");
if (!editorCanvas) return;
const { width: vw, height: vh } = editorCanvas.getBoundingClientRect();
const scaleX = vw / canvasSize.width;
const scaleY = vh / canvasSize.height;
const fitZoom = Math.min(scaleX, scaleY) * 0.9;
const offsetX = (vw - canvasSize.width * fitZoom) / 2;
const offsetY = (vh - canvasSize.height * fitZoom) / 2;
setZoom(fitZoom);
setPanOffset({ x: offsetX, y: offsetY });
},
},
{
label: "Actual Pixels",
shortcut: mod("Ctrl+1"),
action: () => setZoom(1),
action: () => {
const editorCanvas = document.querySelector("[data-testid='editor-canvas']");
if (!editorCanvas) return;
const { width: vw, height: vh } = editorCanvas.getBoundingClientRect();
const offsetX = (vw - canvasSize.width) / 2;
const offsetY = (vh - canvasSize.height) / 2;
setZoom(1);
setPanOffset({ x: offsetX, y: offsetY });
},
dividerAfter: true,
},
{ label: "Rulers", checked: rulersVisible, action: toggleRulers },
@@ -10,14 +10,41 @@ export function BrushOptions() {
const brushSize = useEditorStore((s) => s.brushSize);
const brushOpacity = useEditorStore((s) => s.brushOpacity);
const brushHardness = useEditorStore((s) => s.brushHardness);
const brushFlow = useEditorStore((s) => s.brushFlow);
const setBrushSize = useEditorStore((s) => s.setBrushSize);
const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity);
const setBrushHardness = useEditorStore((s) => s.setBrushHardness);
const setBrushFlow = useEditorStore((s) => s.setBrushFlow);
const eraserMode = useEditorStore((s) => s.eraserMode);
const setEraserMode = useEditorStore((s) => s.setEraserMode);
if (!BRUSH_OPTION_TOOLS.has(activeTool)) return null;
return (
<div className="flex items-center gap-3">
{/* Eraser mode selector */}
{activeTool === "eraser" && (
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground w-12 shrink-0">Mode</span>
<div className="flex gap-0.5 flex-1">
<button
type="button"
onClick={() => setEraserMode("brush")}
className={`flex-1 text-xs py-1 rounded ${eraserMode === "brush" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Brush
</button>
<button
type="button"
onClick={() => setEraserMode("block")}
className={`flex-1 text-xs py-1 rounded ${eraserMode === "block" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Block
</button>
</div>
</div>
)}
{/* Size */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Size
@@ -84,6 +111,24 @@ export function BrushOptions() {
<span className="text-[10px]">%</span>
</label>
)}
{/* Flow (not for pencil) */}
{activeTool !== "pencil" && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-12 shrink-0">Flow</span>
<input
type="range"
min={0}
max={100}
value={Math.round(brushFlow * 100)}
onChange={(e) => setBrushFlow(Number(e.target.value) / 100)}
className="flex-1 min-w-0"
/>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-end">
{Math.round(brushFlow * 100)}%
</span>
</div>
)}
</div>
);
}
@@ -50,6 +50,8 @@ export function SelectionOptions() {
const setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance);
const magicWandContiguous = useEditorStore((s) => s.magicWandContiguous);
const setMagicWandContiguous = useEditorStore((s) => s.setMagicWandContiguous);
const selectionFeather = useEditorStore((s) => s.selectionFeather);
const setSelectionFeather = useEditorStore((s) => s.setSelectionFeather);
const selectionType: SelectionType =
activeTool === "marquee-ellipse"
@@ -170,6 +172,25 @@ export function SelectionOptions() {
</>
)}
{/* Feather radius */}
{(isMarquee || isLasso) && (
<>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">Feather:</span>
<input
type="number"
min={0}
max={100}
value={selectionFeather}
onChange={(e) => setSelectionFeather(Number(e.target.value))}
className="w-14 px-1.5 py-0.5 text-xs rounded border border-border bg-background text-foreground tabular-nums"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</>
)}
{/* Magic Wand tolerance + contiguous */}
{isMagicWand && (
<>
@@ -1,4 +1,4 @@
import { FlipHorizontal2, FlipVertical2, Lock, Unlock } from "lucide-react";
import { Check, FlipHorizontal2, FlipVertical2, Lock, Unlock, X } from "lucide-react";
import { useCallback } from "react";
import type { TransformToolApi } from "@/components/editor/tools/transform-tool";
import { cn } from "@/lib/utils";
@@ -55,7 +55,7 @@ function NumericInput({
}
export function TransformOptions({ api }: { api: TransformToolApi }) {
const { values, lockedAspect, setLockedAspect, setValues, flipHorizontal, flipVertical } = api;
const { values, lockedAspect, setLockedAspect, setValues, flipHorizontal, flipVertical, applyTransform, cancelTransform } = api;
return (
<div className="flex items-center gap-3">
@@ -137,6 +137,24 @@ export function TransformOptions({ api }: { api: TransformToolApi }) {
>
<FlipVertical2 className="h-4 w-4" />
</button>
<div className="h-5 w-px bg-border mx-1" />
<button
type="button"
onClick={cancelTransform}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
<button
type="button"
onClick={applyTransform}
className="p-1.5 rounded bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
title="Apply"
>
<Check className="h-4 w-4" />
</button>
</div>
);
}
@@ -18,7 +18,7 @@ export function useBrushTool() {
const stage = e.target.getStage();
if (!stage) return;
const { activeTool, foregroundColor, brushSize, brushOpacity, brushHardness, zoom, panOffset } =
const { activeTool, foregroundColor, brushSize, brushOpacity, brushHardness, brushFlow, zoom, panOffset } =
useEditorStore.getState();
if (activeTool !== "brush" && activeTool !== "pencil") return;
@@ -29,6 +29,15 @@ export function useBrushTool() {
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
const { selection } = useEditorStore.getState();
if (selection) {
const { bounds } = selection;
if (x < bounds.x || x > bounds.x + bounds.width ||
y < bounds.y || y > bounds.y + bounds.height) {
return;
}
}
const id = generateId();
const shadowBlurValue = activeTool === "pencil" ? 0 : brushSize * 0.4 * (1 - brushHardness);
@@ -39,7 +48,7 @@ export function useBrushTool() {
tension: activeTool === "pencil" ? 0 : 0.5,
lineCap: "round",
lineJoin: "round",
opacity: brushOpacity,
opacity: brushOpacity * brushFlow,
globalCompositeOperation: "source-over",
...(shadowBlurValue > 0 && {
shadowBlur: shadowBlurValue,
@@ -73,6 +82,15 @@ export function useBrushTool() {
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
const { selection } = useEditorStore.getState();
if (selection) {
const { bounds } = selection;
if (x < bounds.x || x > bounds.x + bounds.width ||
y < bounds.y || y > bounds.y + bounds.height) {
return;
}
}
strokeRef.current.points = [...strokeRef.current.points, x, y];
useEditorStore.getState().updateObject(strokeRef.current.objectId, {
@@ -18,7 +18,7 @@ export function useEraserTool() {
const stage = e.target.getStage();
if (!stage) return;
const { activeTool, brushSize, brushOpacity, brushHardness, zoom, panOffset } =
const { activeTool, brushSize, brushOpacity, brushHardness, eraserMode, zoom, panOffset } =
useEditorStore.getState();
if (activeTool !== "eraser") return;
@@ -29,16 +29,31 @@ export function useEraserTool() {
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
const { selection } = useEditorStore.getState();
if (selection) {
const { bounds } = selection;
if (
x < bounds.x ||
x > bounds.x + bounds.width ||
y < bounds.y ||
y > bounds.y + bounds.height
) {
return;
}
}
const id = generateId();
const shadowBlurValue = brushSize * 0.4 * (1 - brushHardness);
const isBlock = eraserMode === "block";
const attrs: LineAttrs = {
points: [x, y],
stroke: "#000000",
strokeWidth: brushSize,
tension: 0.5,
lineCap: "round",
lineJoin: "round",
tension: isBlock ? 0 : 0.5,
lineCap: isBlock ? "butt" : "round",
lineJoin: isBlock ? "miter" : "round",
opacity: brushOpacity,
globalCompositeOperation: "destination-out",
...(shadowBlurValue > 0 && {
@@ -73,6 +88,19 @@ export function useEraserTool() {
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
const { selection } = useEditorStore.getState();
if (selection) {
const { bounds } = selection;
if (
x < bounds.x ||
x > bounds.x + bounds.width ||
y < bounds.y ||
y > bounds.y + bounds.height
) {
return;
}
}
strokeRef.current.points = [...strokeRef.current.points, x, y];
useEditorStore.getState().updateObject(strokeRef.current.objectId, {
@@ -53,7 +53,20 @@ export function useTransformTool(): TransformToolApi {
// Read values from selected object(s)
useEffect(() => {
if (!isTransforming || selectedObjectIds.length === 0) return;
if (!isTransforming) return;
if (selectedObjectIds.length === 0) {
const sel = useEditorStore.getState().selection;
if (sel && sel.bounds.width > 0) {
setValuesState({
x: sel.bounds.x,
y: sel.bounds.y,
width: sel.bounds.width,
height: sel.bounds.height,
rotation: 0,
});
}
return;
}
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
if (!obj) return;
const a = obj.attrs as unknown as Record<string, unknown>;
@@ -82,7 +95,26 @@ export function useTransformTool(): TransformToolApi {
}, [isTransforming, selectedObjectIds]);
const activate = useCallback(() => {
if (selectedObjectIds.length === 0) return;
if (selectedObjectIds.length === 0) {
const sel = useEditorStore.getState().selection;
if (!sel || sel.bounds.width === 0) return;
preTransformRef.current = {
x: sel.bounds.x,
y: sel.bounds.y,
width: sel.bounds.width,
height: sel.bounds.height,
rotation: 0,
};
setValuesState({
x: sel.bounds.x,
y: sel.bounds.y,
width: sel.bounds.width,
height: sel.bounds.height,
rotation: 0,
});
setIsTransforming(true);
return;
}
setIsTransforming(true);
// Store pre-transform state for cancel
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
+18 -12
View File
@@ -2,6 +2,7 @@ import { TOOLS } from "@snapotter/shared";
import { FileImage, ImageIcon, Workflow } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import {
apiGetFileDetails,
formatHeaders,
@@ -74,6 +75,7 @@ interface FileDetailsProps {
}
export function FileDetails({ mobile = false }: FileDetailsProps) {
const { t } = useTranslation();
const { selectedFileId } = useFilesPageStore();
const setFiles = useFileStore((s) => s.setFiles);
const navigate = useNavigate();
@@ -139,11 +141,11 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
"flex flex-col items-center justify-center text-muted-foreground",
mobile
? "flex flex-col gap-4"
: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col",
: "w-60 border-s border-border p-4 shrink-0 hidden lg:flex flex-col",
)}
>
<FileImage className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm">Select a file to view details</p>
<p className="text-sm">{t.files.selectFilePrompt}</p>
</div>
);
}
@@ -155,7 +157,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
"flex items-center justify-center",
mobile
? "flex flex-col gap-4"
: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col",
: "w-60 border-s border-border p-4 shrink-0 hidden lg:flex flex-col",
)}
>
<div className="h-6 w-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
@@ -171,7 +173,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
"overflow-y-auto",
mobile
? "flex flex-col gap-4"
: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col",
: "w-60 border-s border-border p-4 shrink-0 hidden lg:flex flex-col",
)}
>
{/* Thumbnail */}
@@ -187,24 +189,28 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
<div className="flex-1">
<div className="rounded-lg border border-border overflow-hidden">
<div className="bg-blue-500/10 border-b border-border px-3 py-2">
<h4 className="text-sm font-semibold text-blue-600 dark:text-blue-400">File Details</h4>
<h4 className="text-sm font-semibold text-blue-600 dark:text-blue-400">
{t.files.fileDetailsHeading}
</h4>
</div>
<div className="divide-y divide-border">
<DetailRow label="Name" value={details.originalName} />
<DetailRow label={t.files.name} value={details.originalName} />
<DetailRow
label="Format"
label={t.files.format}
value={details.mimeType.replace("image/", "").toUpperCase()}
/>
<DetailRow label="Size" value={formatSize(details.size)} />
<DetailRow label={t.files.size} value={formatSize(details.size)} />
<DetailRow
label="Dimensions"
label={t.files.dimensions}
value={details.width && details.height ? `${details.width} × ${details.height}` : "—"}
/>
<DetailRow label="Version" value={`V${details.version}`} />
<DetailRow label={t.files.version} value={`V${details.version}`} />
<DetailRow
label="Tools Used"
label={t.files.toolsUsed}
value={
details.toolChain.length > 0 ? details.toolChain.map(toolName).join(", ") : "None"
details.toolChain.length > 0
? details.toolChain.map(toolName).join(", ")
: t.files.none
}
/>
</div>
+11 -6
View File
@@ -1,11 +1,14 @@
import { Download, Search, Trash2, Workflow } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { getFileDownloadUrl } from "@/lib/api";
import { format } from "@/lib/format";
import { useFilesPageStore } from "@/stores/files-page-store";
import { FileListItem } from "./file-list-item";
export function FileList() {
const { t } = useTranslation();
const {
files,
checkedIds,
@@ -61,7 +64,7 @@ export function FileList() {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search files..."
placeholder={t.files.searchPlaceholder}
value={inputValue}
onChange={handleSearchChange}
className="w-full ps-8 pe-3 py-1.5 text-sm bg-muted rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground placeholder:text-muted-foreground"
@@ -78,7 +81,9 @@ export function FileList() {
className="h-4 w-4 accent-primary"
/>
<span className="text-xs text-muted-foreground flex-1">
{someChecked ? `${checkedIds.size} selected` : `${files.length} files`}
{someChecked
? format(t.files.selectedCount, { count: checkedIds.size })
: format(t.files.fileCount, { count: files.length })}
</span>
{someChecked && (
<>
@@ -88,7 +93,7 @@ export function FileList() {
className="flex items-center gap-1 px-2 py-1 text-xs text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
{t.files.delete}
</button>
<button
type="button"
@@ -96,7 +101,7 @@ export function FileList() {
className="flex items-center gap-1 px-2 py-1 text-xs text-primary hover:bg-primary/10 rounded-lg transition-colors"
>
<Workflow className="h-3.5 w-3.5" />
Pipeline
{t.files.pipeline}
</button>
<button
type="button"
@@ -104,7 +109,7 @@ export function FileList() {
className="flex items-center gap-1 px-2 py-1 text-xs text-foreground hover:bg-muted rounded-lg transition-colors"
>
<Download className="h-3.5 w-3.5" />
Download
{t.files.download}
</button>
</>
)}
@@ -124,7 +129,7 @@ export function FileList() {
)}
{!loading && !error && files.length === 0 && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-muted-foreground">No files found</p>
<p className="text-sm text-muted-foreground">{t.files.noFilesFound}</p>
</div>
)}
{!loading && !error && files.map((file) => <FileListItem key={file.id} file={file} />)}
+6 -4
View File
@@ -1,17 +1,19 @@
import { Clock, Upload } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useFilesPageStore } from "@/stores/files-page-store";
export function FilesNav() {
const { t } = useTranslation();
const { activeTab, setActiveTab } = useFilesPageStore();
const items = [
{ id: "recent" as const, label: "Recent", icon: Clock },
{ id: "upload" as const, label: "Upload Files", icon: Upload },
{ id: "recent" as const, label: t.files.recentTab, icon: Clock },
{ id: "upload" as const, label: t.files.uploadTab, icon: Upload },
];
return (
<div className="w-48 border-r border-border p-4 shrink-0 hidden md:block">
<h3 className="text-sm font-semibold text-foreground mb-3">My Files</h3>
<div className="w-48 border-e border-border p-4 shrink-0 hidden md:block">
<h3 className="text-sm font-semibold text-foreground mb-3">{t.files.myFiles}</h3>
<div className="space-y-1">
{items.map((item) => (
<button
+1 -1
View File
@@ -110,7 +110,7 @@ export function Sidebar({
}
return (
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
<aside className="flex flex-col items-center w-16 bg-sidebar border-e border-border py-3 gap-1 shrink-0">
<div className="mb-2 flex items-center justify-center">
<OtterLogo className="h-7 w-7 text-primary" />
</div>
@@ -21,10 +21,12 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
const [targetSizeValue, setTargetSizeValue] = useState("");
const [sizeUnit, setSizeUnit] = useState<SizeUnit>("KB");
const initializedRef = useRef(false);
const prevSettingsKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (!initialSettings) return;
const key = JSON.stringify(initialSettings);
if (prevSettingsKeyRef.current === key) return;
prevSettingsKeyRef.current = key;
if (initialSettings.mode != null) setMode(initialSettings.mode as CompressMode);
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
if (initialSettings.targetSizeKb != null)
@@ -129,7 +129,7 @@ function SortableStep({
e.stopPropagation();
onRemove();
}}
title="Remove"
title={t.automate.removeStep}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" />
@@ -159,6 +159,7 @@ export function PipelineBuilder({
onUpdateSettings,
onToggleStep,
}: PipelineBuilderProps) {
const { t } = useTranslation();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -177,10 +178,8 @@ export function PipelineBuilder({
<div className="p-4 rounded-full bg-muted/50 mb-4">
<FileImage className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">No steps yet</h3>
<p className="text-sm text-muted-foreground max-w-[240px]">
Click tools from the palette to build your pipeline
</p>
<h3 className="text-sm font-medium text-foreground mb-1">{t.automate.noStepsHeading}</h3>
<p className="text-sm text-muted-foreground max-w-[240px]">{t.automate.addToolsPrompt}</p>
</div>
);
}
@@ -66,12 +66,16 @@ export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="px-3 pt-3 pb-2 shrink-0">
<SearchBar value={search} onChange={setSearch} placeholder="Search tools..." />
<SearchBar
value={search}
onChange={setSearch}
placeholder={t.automate.searchToolsPlaceholder}
/>
</div>
<div className="flex-1 overflow-y-auto px-3 pb-3">
{availableTools.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
<p className="text-sm text-muted-foreground text-center py-8">{t.common.noToolsFound}</p>
) : isSearching ? (
<div className="space-y-1">
{availableTools.map((tool) => (
@@ -394,6 +394,7 @@ export function useEditorShortcuts(callbacks?: {
useHotkeys(
"mod+a",
(e) => {
if (isInputFocused()) return;
e.preventDefault();
const state = useEditorStore.getState();
const allIds = state.objects.map((o) => o.id);
@@ -406,6 +407,7 @@ export function useEditorShortcuts(callbacks?: {
useHotkeys(
"mod+d",
(e) => {
if (isInputFocused()) return;
e.preventDefault();
useEditorStore.getState().setSelectedObjects([]);
useEditorStore.getState().setSelection(null);
@@ -496,6 +498,7 @@ export function useEditorShortcuts(callbacks?: {
useHotkeys(
"mod+t",
(e) => {
if (isInputFocused()) return;
e.preventDefault();
useEditorStore.getState().setTool("transform");
},
@@ -506,6 +509,7 @@ export function useEditorShortcuts(callbacks?: {
useHotkeys(
"mod+j",
(e) => {
if (isInputFocused()) return;
e.preventDefault();
const state = useEditorStore.getState();
state.duplicateLayer(state.activeLayerId);
@@ -517,6 +521,7 @@ export function useEditorShortcuts(callbacks?: {
useHotkeys(
"mod+shift+n",
(e) => {
if (isInputFocused()) return;
e.preventDefault();
useEditorStore.getState().addLayer();
},
+22
View File
@@ -0,0 +1,22 @@
import { Link } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
export function NotFoundPage() {
const { t } = useTranslation();
return (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<div className="text-center space-y-4 max-w-md px-6">
<h1 className="text-6xl font-bold text-primary">404</h1>
<h2 className="text-xl font-semibold">{t.common.pageNotFound}</h2>
<p className="text-sm text-muted-foreground">{t.common.pageNotFoundDescription}</p>
<Link
to="/"
className="inline-block px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
>
{t.common.goHome}
</Link>
</div>
</div>
);
}
+7
View File
@@ -8,6 +8,7 @@ import type {
CanvasObject,
EditorLayer,
EditorState,
EraserMode,
FilterConfig,
SelectionMode,
StrokeDashStyle,
@@ -152,6 +153,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
brushSize: 10,
brushOpacity: 1,
brushHardness: 1,
brushFlow: 1,
eraserMode: "brush" as EraserMode,
// --- Colors ---
foregroundColor: "#000000",
@@ -171,6 +174,7 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
selectionMode: "new" as SelectionMode,
magicWandTolerance: 32,
magicWandContiguous: true,
selectionFeather: 0,
// --- Crop ---
cropState: null,
@@ -954,6 +958,7 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
setSelectionMode: (mode) => set({ selectionMode: mode }),
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
setMagicWandContiguous: (v: boolean) => set({ magicWandContiguous: v }),
setSelectionFeather: (v) => set({ selectionFeather: v }),
invertSelection: () => {
const { selection, canvasSize } = get();
@@ -1067,6 +1072,8 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(MAX_BRUSH_SIZE, size)) }),
setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }),
setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }),
setBrushFlow: (flow) => set({ brushFlow: Math.max(0, Math.min(1, flow)) }),
setEraserMode: (mode) => set({ eraserMode: mode }),
// Clipboard
copyObjects: () => {
+53 -40
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { generateId } from "@/lib/utils";
export interface PipelineStep {
@@ -30,51 +31,63 @@ interface PipelineState {
reset: () => void;
}
export const usePipelineStore = create<PipelineState>((set, get) => ({
steps: [],
expandedStepId: null,
savedPipelines: [],
export const usePipelineStore = create<PipelineState>()(
persist(
(set, get) => ({
steps: [],
expandedStepId: null,
savedPipelines: [],
addStep: (toolId) => {
const step: PipelineStep = { id: generateId(), toolId, settings: {} };
set({ steps: [...get().steps, step], expandedStepId: step.id });
},
addStep: (toolId) => {
const step: PipelineStep = { id: generateId(), toolId, settings: {} };
set({ steps: [...get().steps, step], expandedStepId: step.id });
},
removeStep: (id) => {
const { steps, expandedStepId } = get();
set({
steps: steps.filter((s) => s.id !== id),
expandedStepId: expandedStepId === id ? null : expandedStepId,
});
},
removeStep: (id) => {
const { steps, expandedStepId } = get();
set({
steps: steps.filter((s) => s.id !== id),
expandedStepId: expandedStepId === id ? null : expandedStepId,
});
},
reorderSteps: (activeId, overId) => {
const { steps } = get();
const oldIndex = steps.findIndex((s) => s.id === activeId);
const newIndex = steps.findIndex((s) => s.id === overId);
if (oldIndex < 0 || newIndex < 0) return;
const reordered = [...steps];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
set({ steps: reordered });
},
reorderSteps: (activeId, overId) => {
const { steps } = get();
const oldIndex = steps.findIndex((s) => s.id === activeId);
const newIndex = steps.findIndex((s) => s.id === overId);
if (oldIndex < 0 || newIndex < 0) return;
const reordered = [...steps];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
set({ steps: reordered });
},
updateStepSettings: (id, settings) => {
set({ steps: get().steps.map((s) => (s.id === id ? { ...s, settings } : s)) });
},
updateStepSettings: (id, settings) => {
set({ steps: get().steps.map((s) => (s.id === id ? { ...s, settings } : s)) });
},
setExpandedStep: (id) => set({ expandedStepId: id }),
setExpandedStep: (id) => set({ expandedStepId: id }),
loadSteps: (rawSteps) => {
const steps = rawSteps.map((s) => ({
id: generateId(),
toolId: s.toolId,
settings: { ...s.settings },
}));
set({ steps, expandedStepId: null });
},
loadSteps: (rawSteps) => {
const steps = rawSteps.map((s) => ({
id: generateId(),
toolId: s.toolId,
settings: { ...s.settings },
}));
set({ steps, expandedStepId: null });
},
setSavedPipelines: (pipelines) => set({ savedPipelines: pipelines }),
setSavedPipelines: (pipelines) => set({ savedPipelines: pipelines }),
reset: () => set({ steps: [], expandedStepId: null, savedPipelines: [] }),
}));
reset: () => set({ steps: [], expandedStepId: null, savedPipelines: [] }),
}),
{
name: "snapotter-pipeline",
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
steps: state.steps,
expandedStepId: state.expandedStepId,
}),
},
),
);
+8
View File
@@ -1,5 +1,7 @@
// apps/web/src/types/editor.ts
export type EraserMode = "brush" | "block";
export type SelectionMode = "new" | "add" | "subtract";
export type StrokeDashStyle = "solid" | "dashed" | "dotted";
@@ -281,6 +283,8 @@ export interface EditorState {
brushSize: number;
brushOpacity: number;
brushHardness: number;
brushFlow: number;
eraserMode: EraserMode;
// Colors
foregroundColor: string;
@@ -300,6 +304,7 @@ export interface EditorState {
selectionMode: SelectionMode;
magicWandTolerance: number;
magicWandContiguous: boolean;
selectionFeather: number;
// Crop
cropState: CropState | null;
@@ -425,6 +430,7 @@ export interface EditorState {
setSelectionMode: (mode: SelectionMode) => void;
setMagicWandTolerance: (v: number) => void;
setMagicWandContiguous: (v: boolean) => void;
setSelectionFeather: (v: number) => void;
invertSelection: () => void;
// Crop
@@ -435,6 +441,8 @@ export interface EditorState {
setBrushSize: (size: number) => void;
setBrushOpacity: (opacity: number) => void;
setBrushHardness: (hardness: number) => void;
setBrushFlow: (flow: number) => void;
setEraserMode: (mode: EraserMode) => void;
// Clipboard
copyObjects: () => void;