mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user