diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 45259be2..d301e276 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,12 @@ import { APP_VERSION, en, shouldShowConsent } from "@snapotter/shared"; import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react"; -import { BrowserRouter, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom"; +import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Toaster, toast } from "sonner"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { I18nProvider } from "./contexts/i18n-context"; import { useAuth } from "./hooks/use-auth"; +import { useMobile } from "./hooks/use-mobile"; import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics"; import { useAnalyticsStore } from "./stores/analytics-store"; @@ -32,9 +33,6 @@ const AnalyticsConsentPage = lazy(() => const EditorPage = lazy(() => import("./pages/editor-page").then((m) => ({ default: m.EditorPage })), ); -const NotFoundPage = lazy(() => - import("./pages/not-found-page").then((m) => ({ default: m.NotFoundPage })), -); const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage }))); class ErrorBoundary extends Component< @@ -170,11 +168,6 @@ function AuthGuard({ children }: { children: React.ReactNode }) { return <>{children}; } -function ToolRedirect() { - const { toolId } = useParams<{ toolId: string }>(); - return ; -} - // Single page-level loading fallback — shown while JS for a route downloads. function PageLoader() { return ( @@ -185,6 +178,7 @@ function PageLoader() { } export function App() { + const isMobile = useMobile(); const analyticsConfig = useAnalyticsStore((s) => s.config); const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded); const fetchAnalyticsConfig = useAnalyticsStore((s) => s.fetchConfig); @@ -225,7 +219,7 @@ export function App() { - + @@ -250,10 +244,8 @@ export function App() { } /> } /> } /> - } /> } /> } /> - } /> diff --git a/apps/web/src/components/common/file-library-modal.tsx b/apps/web/src/components/common/file-library-modal.tsx index a951cdd6..46da53ac 100644 --- a/apps/web/src/components/common/file-library-modal.tsx +++ b/apps/web/src/components/common/file-library-modal.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [importing, setImporting] = useState(false); @@ -147,11 +149,11 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default" onClick={onClose} /> -
+
{/* Header */}
-

Import from Library

+

{t.automate.importFromLibrary}

)} - {/* Filename */} -
- Filename - setFilename(e.target.value)} - placeholder="export" - className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary" - /> -
- {/* Format */}
Format diff --git a/apps/web/src/components/editor/options/brush-options.tsx b/apps/web/src/components/editor/options/brush-options.tsx index 68af1a06..e236e373 100644 --- a/apps/web/src/components/editor/options/brush-options.tsx +++ b/apps/web/src/components/editor/options/brush-options.tsx @@ -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 (
+ {/* Eraser mode selector */} + {activeTool === "eraser" && ( +
+ Mode +
+ + +
+
+ )} + {/* Size */} )} + + {/* Flow (not for pencil) */} + {activeTool !== "pencil" && ( +
+ Flow + setBrushFlow(Number(e.target.value) / 100)} + className="flex-1 min-w-0" + /> + + {Math.round(brushFlow * 100)}% + +
+ )}
); } diff --git a/apps/web/src/components/editor/options/selection-options.tsx b/apps/web/src/components/editor/options/selection-options.tsx index 88bab21d..4806cdfa 100644 --- a/apps/web/src/components/editor/options/selection-options.tsx +++ b/apps/web/src/components/editor/options/selection-options.tsx @@ -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) && ( + <> +
+
+ Feather: + 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" + /> + px +
+ + )} + {/* Magic Wand tolerance + contiguous */} {isMagicWand && ( <> diff --git a/apps/web/src/components/editor/options/transform-options.tsx b/apps/web/src/components/editor/options/transform-options.tsx index 69a80c84..d2a7818b 100644 --- a/apps/web/src/components/editor/options/transform-options.tsx +++ b/apps/web/src/components/editor/options/transform-options.tsx @@ -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 (
@@ -137,6 +137,24 @@ export function TransformOptions({ api }: { api: TransformToolApi }) { > + +
+ +
); } diff --git a/apps/web/src/components/editor/tools/brush-tool.tsx b/apps/web/src/components/editor/tools/brush-tool.tsx index 22795cd1..9b833aa5 100644 --- a/apps/web/src/components/editor/tools/brush-tool.tsx +++ b/apps/web/src/components/editor/tools/brush-tool.tsx @@ -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, { diff --git a/apps/web/src/components/editor/tools/eraser-tool.tsx b/apps/web/src/components/editor/tools/eraser-tool.tsx index 61f18904..a84b7394 100644 --- a/apps/web/src/components/editor/tools/eraser-tool.tsx +++ b/apps/web/src/components/editor/tools/eraser-tool.tsx @@ -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, { diff --git a/apps/web/src/components/editor/tools/transform-tool.tsx b/apps/web/src/components/editor/tools/transform-tool.tsx index dce009aa..f3665610 100644 --- a/apps/web/src/components/editor/tools/transform-tool.tsx +++ b/apps/web/src/components/editor/tools/transform-tool.tsx @@ -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; @@ -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]); diff --git a/apps/web/src/components/layout/app-layout.tsx b/apps/web/src/components/layout/app-layout.tsx index 19dd01a1..5e233708 100644 --- a/apps/web/src/components/layout/app-layout.tsx +++ b/apps/web/src/components/layout/app-layout.tsx @@ -1,25 +1,16 @@ -import { - FolderOpen, - Globe, - LayoutGrid, - Menu, - Settings as SettingsIcon, - Workflow, - X, -} from "lucide-react"; +import { Globe, Menu, X } from "lucide-react"; import { useState } from "react"; -import { Link } from "react-router-dom"; import { useTranslation } from "@/contexts/i18n-context"; import { useMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useConnectionStore } from "@/stores/connection-store"; import { Dropzone } from "../common/dropzone"; -import { ImageEditIcon } from "../common/image-edit-icon"; import { OtterLogo } from "../common/otter-logo"; import { HelpDialog } from "../help/help-dialog"; import { SettingsDialog } from "../settings/settings-dialog"; import { AiInstallIndicator } from "./ai-install-indicator"; import { Footer } from "./footer"; +import { MobileBottomNav } from "./mobile-bottom-nav"; import { Sidebar } from "./sidebar"; import { ToolPanel } from "./tool-panel"; @@ -39,7 +30,7 @@ export function AppLayout({ const [settingsOpen, setSettingsOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const { t, locale, setLocale, supportedLocales } = useTranslation(); + const { locale, setLocale, supportedLocales } = useTranslation(); const isMobile = useMobile(); const connectionStatus = useConnectionStore((s) => s.status); const bannerVisible = connectionStatus !== "connected"; @@ -67,7 +58,7 @@ export function AppLayout({ className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default" onClick={() => setMobileSidebarOpen(false)} /> -
+
@@ -78,9 +69,9 @@ export function AppLayout({
setMobileSidebarOpen(true)} - className="p-1.5 rounded-lg hover:bg-muted" + className="p-2.5 -ms-1 rounded-lg hover:bg-muted" > @@ -141,7 +132,7 @@ export function AppLayout({ {showToolPanel && !isMobile && } -
+
{children || }
@@ -150,22 +141,7 @@ export function AppLayout({ {!isMobile &&
} {/* Mobile bottom nav */} - {isMobile && ( - - )} + {isMobile && setSettingsOpen(true)} />} {/* Settings dialog */} setSettingsOpen(false)} /> @@ -178,23 +154,3 @@ export function AppLayout({
); } - -function MobileNavItem({ - icon: Icon, - label, - href, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - href: string; -}) { - return ( - - - {label} - - ); -} diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 37d0d89d..a7f43cdd 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -29,6 +29,7 @@ import { import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; +import { useMobile } from "@/hooks/use-mobile"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { format, plural } from "@/lib/format"; import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n"; @@ -124,6 +125,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const [section, setSection] = useState
("general"); const { hasPermission, authEnabled } = useAuth(); const { t } = useTranslation(); + const isMobile = useMobile(); const NAV_ITEMS = useNavItems(); const visibleNavItems = NAV_ITEMS.filter( @@ -144,6 +146,60 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { if (!open) return null; + if (isMobile) { + return ( +
+ {/* Mobile header */} +
+

{t.settings.heading}

+ +
+ + {/* Mobile pill strip nav */} +
+ {visibleNavItems.map((item) => ( + + ))} +
+ + {/* Mobile content */} +
+ {section === "general" && } + {section === "system" && } + {section === "security" && } + {section === "people" && } + {section === "teams" && } + {section === "roles" && } + {section === "audit-log" && } + {section === "api-keys" && } + {section === "ai-features" && } + {section === "tools" && } + {section === "analytics" && } + {section === "about" && } +
+
+ ); + } + return (
{/* Backdrop */} @@ -157,7 +213,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{/* Sidebar nav */}
@@ -249,7 +305,7 @@ interface UserEntry { } interface TeamEntry { - id: number; + id: string; name: string; memberCount: number; createdAt: string; @@ -624,6 +680,7 @@ function SecuritySection() { const [confirmPassword, setConfirmPassword] = useState(""); const [showCurrent, setShowCurrent] = useState(false); const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); @@ -634,7 +691,7 @@ function SecuritySection() { setMessage({ type: "error", text: t.settings.security.passwordsMismatch }); return; } - if (newPassword.length < 4) { + if (newPassword.length < 8) { setMessage({ type: "error", text: t.settings.security.passwordTooShort }); return; } @@ -709,14 +766,24 @@ function SecuritySection() {
- setConfirmPassword(e.target.value)} - placeholder={t.settings.security.confirmPasswordPlaceholder} - className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" - required - /> +
+ setConfirmPassword(e.target.value)} + placeholder={t.settings.security.confirmPasswordPlaceholder} + className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" + required + /> + +
{message && (

([]); const [maxUsers, setMaxUsers] = useState(5); const [loading, setLoading] = useState(true); @@ -1029,7 +1097,7 @@ function PeopleSection() {

{t.settings.people.newMemberHeading}

-
+
- {/* Table header */} -
- {t.settings.people.tableHeaderUser} - {t.settings.people.tableHeaderRole} - {t.settings.people.tableHeaderTeam} - -
+ {/* Table header (desktop only) */} + {!isMobile && ( +
+ {t.settings.people.tableHeaderUser} + {t.settings.people.tableHeaderRole} + {t.settings.people.tableHeaderTeam} + +
+ )} {/* Table rows */} {filteredUsers.length === 0 ? ( @@ -1277,45 +1347,91 @@ function PeopleSection() { filteredUsers.map((u) => (
- {/* User cell */} -
-
- {u.username.charAt(0).toUpperCase()} -
- {u.username} - {u.hasOidcLink && u.hasLocalPassword !== false && ( - - {t.auth.methodBoth} - - )} - {u.hasOidcLink && u.hasLocalPassword === false && ( - - {t.auth.methodOidc} - - )} -
+ {isMobile ? ( + <> + {/* Mobile card layout */} +
+ {u.username.charAt(0).toUpperCase()} +
+
+
+ + {u.username} + + {u.hasOidcLink && u.hasLocalPassword !== false && ( + + {t.auth.methodBoth} + + )} + {u.hasOidcLink && u.hasLocalPassword === false && ( + + {t.auth.methodOidc} + + )} +
+
+ + {u.role} + + {u.team} +
+
+ + ) : ( + <> + {/* Desktop row layout */} +
+
+ {u.username.charAt(0).toUpperCase()} +
+ + {u.username} + + {u.hasOidcLink && u.hasLocalPassword !== false && ( + + {t.auth.methodBoth} + + )} + {u.hasOidcLink && u.hasLocalPassword === false && ( + + {t.auth.methodOidc} + + )} +
- {/* Role badge */} -
- - {u.role} - -
+ {/* Role badge */} +
+ + {u.role} + +
- {/* Team */} - {u.team} + {/* Team */} + {u.team} + + )} {/* Actions */} -
+
)} @@ -2545,7 +2716,7 @@ function ToolsSection() { />
-
+
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (

@@ -2706,6 +2877,14 @@ function AboutSection() {

+
+ {t.settings.about.licenseLabel} +
+ AGPLv3 +

{t.settings.about.licenseDescription}

+
+
+

{t.settings.about.linksHeading}

@@ -2750,13 +2929,19 @@ function SettingRow({ description: string; children: React.ReactNode; }) { + const isMobile = useMobile(); return ( -
+

{label}

{description}

-
{children}
+
{children}
); } diff --git a/apps/web/src/hooks/use-editor-shortcuts.ts b/apps/web/src/hooks/use-editor-shortcuts.ts index 624b57c9..54911d49 100644 --- a/apps/web/src/hooks/use-editor-shortcuts.ts +++ b/apps/web/src/hooks/use-editor-shortcuts.ts @@ -41,8 +41,6 @@ const SHAPE_CYCLE: ToolType[] = [ "shape-polygon", "shape-star", ]; -// Dodge/burn/sponge cycle -const DODGE_CYCLE: ToolType[] = ["dodge", "burn", "sponge"]; // Fill/gradient cycle const FILL_CYCLE: ToolType[] = ["fill", "gradient"]; @@ -201,29 +199,12 @@ export function useEditorShortcuts(callbacks?: { { preventDefault: true }, ); - // O - Dodge/Burn/Sponge (cycles) + // O - Dodge tool useHotkeys( "o", () => { if (isInputFocused()) return; - const current = useEditorStore.getState().activeTool; - if (DODGE_CYCLE.includes(current)) { - useEditorStore.getState().setTool(cycleSubtool(current, DODGE_CYCLE)); - } else { - useEditorStore.getState().setTool("dodge"); - } - }, - { preventDefault: true }, - ); - - // Shift+O - Cycle dodge/burn/sponge subtypes - useHotkeys( - "shift+o", - () => { - if (isInputFocused()) return; - useEditorStore - .getState() - .setTool(cycleSubtool(useEditorStore.getState().activeTool, DODGE_CYCLE)); + useEditorStore.getState().setTool("dodge"); }, { preventDefault: true }, ); @@ -380,16 +361,6 @@ export function useEditorShortcuts(callbacks?: { { preventDefault: true }, ); - // Ctrl+Y / Cmd+Y - Redo (alternative) - useHotkeys( - "mod+y", - (e) => { - e.preventDefault(); - useEditorStore.temporal.getState().redo(); - }, - { preventDefault: true }, - ); - // Ctrl+S / Cmd+S - Save project useHotkeys( "mod+s", @@ -423,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); @@ -435,6 +407,7 @@ export function useEditorShortcuts(callbacks?: { useHotkeys( "mod+d", (e) => { + if (isInputFocused()) return; e.preventDefault(); useEditorStore.getState().setSelectedObjects([]); useEditorStore.getState().setSelection(null); @@ -525,6 +498,7 @@ export function useEditorShortcuts(callbacks?: { useHotkeys( "mod+t", (e) => { + if (isInputFocused()) return; e.preventDefault(); useEditorStore.getState().setTool("transform"); }, @@ -535,6 +509,7 @@ export function useEditorShortcuts(callbacks?: { useHotkeys( "mod+j", (e) => { + if (isInputFocused()) return; e.preventDefault(); const state = useEditorStore.getState(); state.duplicateLayer(state.activeLayerId); @@ -546,6 +521,7 @@ export function useEditorShortcuts(callbacks?: { useHotkeys( "mod+shift+n", (e) => { + if (isInputFocused()) return; e.preventDefault(); useEditorStore.getState().addLayer(); }, diff --git a/apps/web/src/pages/fullscreen-grid-page.tsx b/apps/web/src/pages/fullscreen-grid-page.tsx index e43a139c..7982deed 100644 --- a/apps/web/src/pages/fullscreen-grid-page.tsx +++ b/apps/web/src/pages/fullscreen-grid-page.tsx @@ -4,7 +4,9 @@ import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { OtterLogo } from "@/components/common/otter-logo"; +import { MobileBottomNav } from "@/components/layout/mobile-bottom-nav"; import { useTranslation } from "@/contexts/i18n-context"; +import { useMobile } from "@/hooks/use-mobile"; import { track } from "@/lib/analytics"; import { apiGet } from "@/lib/api"; import { ICON_MAP } from "@/lib/icon-map"; @@ -14,6 +16,7 @@ import { useFeaturesStore } from "@/stores/features-store"; export function FullscreenGridPage() { const { t } = useTranslation(); + const isMobile = useMobile(); const [search, setSearch] = useState(""); const [showDetails, setShowDetails] = useState(true); const navigate = useNavigate(); @@ -79,7 +82,7 @@ export function FullscreenGridPage() { const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id)); return ( -
+
{/* Top bar */}
@@ -154,6 +157,7 @@ export function FullscreenGridPage() {
)} + {isMobile && }
); } diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 4be11bbd..1775c15b 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -6,6 +6,7 @@ import { ImageViewer } from "@/components/common/image-viewer"; import { MultiImageViewer } from "@/components/common/multi-image-viewer"; import { AppLayout } from "@/components/layout/app-layout"; import { useTranslation } from "@/contexts/i18n-context"; +import { useMobile } from "@/hooks/use-mobile"; import { ICON_MAP } from "@/lib/icon-map"; import { getCategoryName, getToolName } from "@/lib/tool-i18n"; import { useFeaturesStore } from "@/stores/features-store"; @@ -32,6 +33,7 @@ export function HomePage() { const location = useLocation(); const { fetch: fetchSettings, defaultToolView, loaded: settingsLoaded } = useSettingsStore(); const { fetch: fetchFeatures, bundles, installing, queued } = useFeaturesStore(); + const isMobile = useMobile(); useEffect(() => { if (location.state?.fromLibrary) { @@ -93,12 +95,96 @@ export function HomePage() { return ; } - // File uploaded — show tool selector on left, image preview on right + // File uploaded — mobile: stacked layout + if (isMobile && hasFile) { + return ( + +
+ {/* File info bar */} +
+ + + {selectedFileName ?? files[0].name} + + + {selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""} + + +
+ + {/* Quick action buttons - horizontal scroll */} +
+ {QUICK_ACTION_IDS.map((id) => { + const tool = TOOLS.find((t) => t.id === id); + if (!tool) return null; + const Icon = + (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? + ICON_MAP.FileImage; + const status = getToolStatus(id); + return ( + + ); + })} +
+ + {/* Full-width image preview */} +
+ {files.length > 1 ? ( + + ) : currentEntry?.previewLoading ? ( +
+ +

{t.homePage.generatingPreview}

+

{selectedFileName}

+
+ ) : originalBlobUrl ? ( + + ) : ( +
+

{t.homePage.loadingPreview}

+
+ )} +
+
+
+ ); + } + + // File uploaded — desktop: tool selector on left, image preview on right return (
{/* Left panel: Tool selector */} -
+
{/* File info */}
diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index dd50b2e3..0e830950 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -9,8 +9,9 @@ import { } from "lucide-react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Crop } from "react-image-crop"; -import { useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; +import { BottomSheet } from "@/components/common/bottom-sheet"; import { Dropzone } from "@/components/common/dropzone"; import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer"; import { ReviewPanel } from "@/components/common/review-panel"; @@ -213,7 +214,7 @@ export function ToolPage() { }, [navigateNext, navigatePrev], ); - const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true); + const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false); const [previewTransform, setPreviewTransform] = useState(null); const [previewFilter, setPreviewFilter] = useState(""); const [imageWrapperStyle, setImageWrapperStyle] = useState(null); @@ -274,7 +275,7 @@ export function ToolPage() { setEraserMaskedCount(0); setEraserBrushSize(30); setEraserSliderInitPos(null); - setMobileSettingsOpen(true); + setMobileSettingsOpen(false); }, [toolId]); const toolAccept = registryEntry?.accept; @@ -346,8 +347,14 @@ export function ToolPage() { if (!tool || !registryEntry) { return ( -
- {t.toolPage.notFound} +
+

{t.toolPage.notFound}

+ + {t.common.goHome} +
); @@ -777,7 +784,7 @@ export function ToolPage() { ); } - // Mobile layout: settings above dropzone (stacked) + // Mobile layout: full-height image area with BottomSheet for settings if (isMobile) { return ( @@ -795,18 +802,11 @@ export function ToolPage() { onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)} className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted" > - {mobileSettingsOpen ? t.toolPage.hideSettings : t.common.settings} + {t.common.settings}
- {/* Collapsible settings */} - {mobileSettingsOpen && ( -
- {renderSettingsContent()} -
- )} - - {/* Main area: image viewer */} + {/* Main area: image viewer (full height) */}
)}
+ + {/* Settings BottomSheet */} + setMobileSettingsOpen(false)} + title={t.common.settings} + > +
{renderSettingsContent()}
+
); @@ -835,7 +844,7 @@ export function ToolPage() {
{/* Tool Settings Panel */} -
+
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 218ae24a..a4c41786 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -8,6 +8,7 @@ import type { CanvasObject, EditorLayer, EditorState, + EraserMode, FilterConfig, SelectionMode, StrokeDashStyle, @@ -152,6 +153,8 @@ export const useEditorStore = create()( brushSize: 10, brushOpacity: 1, brushHardness: 1, + brushFlow: 1, + eraserMode: "brush" as EraserMode, // --- Colors --- foregroundColor: "#000000", @@ -171,6 +174,7 @@ export const useEditorStore = create()( selectionMode: "new" as SelectionMode, magicWandTolerance: 32, magicWandContiguous: true, + selectionFeather: 0, // --- Crop --- cropState: null, @@ -954,6 +958,7 @@ export const useEditorStore = create()( 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()( 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: () => { diff --git a/apps/web/src/stores/pipeline-store.ts b/apps/web/src/stores/pipeline-store.ts index 7f9879e8..df1c619a 100644 --- a/apps/web/src/stores/pipeline-store.ts +++ b/apps/web/src/stores/pipeline-store.ts @@ -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((set, get) => ({ - steps: [], - expandedStepId: null, - savedPipelines: [], +export const usePipelineStore = create()( + 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, + }), + }, + ), +); diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts index 34d631c4..43240e1f 100644 --- a/apps/web/src/types/editor.ts +++ b/apps/web/src/types/editor.ts @@ -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; diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 7c3d8e94..e0a2c2aa 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -37,9 +37,8 @@ export const ar: TranslationKeys = { retry: "إعادة المحاولة", privacyPolicy: "سياسة الخصوصية", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "الأساسيات", optimization: "التحسين", @@ -590,6 +589,13 @@ export const ar: TranslationKeys = { intensity: "الشدة", addShadow: "إضافة ظل", opacity: "الشفافية", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "جاري المعالجة...", submit: "إزالة الخلفية", submitBatch: "إزالة الخلفية ({count} ملف)", @@ -1683,7 +1689,8 @@ export const ar: TranslationKeys = { docsLink: "التوثيق", apiRefLink: "مرجع API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1824,9 +1831,9 @@ export const ar: TranslationKeys = { pipelineName: "اسم Pipeline", pipelineDescription: "الوصف (اختياري)", noStepsPrompt: "أضف خطوات لبناء أتمتتك", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "خطوة", +step: "خطوة", }, nav: { tools: "الأدوات", @@ -1839,11 +1846,11 @@ export const ar: TranslationKeys = { grid: "شبكة", }, files: { - recentTab: "الأخيرة", - uploadTab: "رفع", - fileDetailsAriaLabel: "تفاصيل الملف", - fileDetailsHeading: "تفاصيل الملف", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 097d7332..9631cc94 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -37,9 +37,8 @@ export const de: TranslationKeys = { retry: "Erneut versuchen", privacyPolicy: "Datenschutzerklaerung", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Grundlagen", optimization: "Optimierung", @@ -598,6 +597,13 @@ export const de: TranslationKeys = { intensity: "Intensitaet", addShadow: "Schatten hinzufuegen", opacity: "Deckkraft", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Wird gerendert...", submit: "Hintergrund entfernen", submitBatch: "Hintergrund entfernen ({count} Dateien)", @@ -1708,7 +1714,8 @@ export const de: TranslationKeys = { docsLink: "Dokumentation", apiRefLink: "API-Referenz (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1853,9 +1860,9 @@ export const de: TranslationKeys = { pipelineName: "Pipeline-Name", pipelineDescription: "Beschreibung (optional)", noStepsPrompt: "Fuegen Sie Schritte hinzu, um Ihre Automatisierung zu erstellen", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Schritt", +step: "Schritt", }, nav: { tools: "Werkzeuge", @@ -1868,11 +1875,11 @@ export const de: TranslationKeys = { grid: "Raster", }, files: { - recentTab: "Zuletzt", - uploadTab: "Hochladen", - fileDetailsAriaLabel: "Dateidetails", - fileDetailsHeading: "Dateidetails", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 533a33f1..5e8f6c20 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -36,7 +36,6 @@ export const en = { privacyPolicy: "Privacy Policy", pageNotFound: "Page not found", pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", }, categories: { essentials: "Essentials", @@ -548,6 +547,13 @@ export const en = { intensity: "Intensity", addShadow: "Add Shadow", opacity: "Opacity", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Rendering...", submit: "Remove Background", submitBatch: "Remove Background ({count} files)", @@ -1427,7 +1433,8 @@ export const en = { newPasswordPlaceholder: "New Password", confirmPasswordPlaceholder: "Confirm New Password", passwordsMismatch: "Passwords do not match", - passwordTooShort: "Password must be at least 4 characters", + passwordTooShort: + "Password must be at least 8 characters with uppercase, lowercase, and a number", changeSuccess: "Password changed successfully", changeFailed: "Failed to change password", currentPasswordIncorrect: "Current password is incorrect", @@ -1642,7 +1649,8 @@ export const en = { docsLink: "Documentation", apiRefLink: "API Reference (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1799,11 +1807,11 @@ export const en = { grid: "Grid", }, files: { + myFiles: "My Files", recentTab: "Recent", - uploadTab: "Upload", + uploadTab: "Upload Files", fileDetailsAriaLabel: "File Details", fileDetailsHeading: "File Details", - myFiles: "My Files", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index c88232b0..f1511ff0 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -37,9 +37,8 @@ export const es: TranslationKeys = { retry: "Reintentar", privacyPolicy: "Politica de privacidad", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Esenciales", optimization: "Optimizacion", @@ -582,6 +581,13 @@ export const es: TranslationKeys = { intensity: "Intensidad", addShadow: "Agregar sombra", opacity: "Opacidad", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderizando...", submit: "Eliminar fondo", submitBatch: "Eliminar fondo ({count} archivos)", @@ -1687,7 +1693,8 @@ export const es: TranslationKeys = { docsLink: "Documentacion", apiRefLink: "Referencia API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1830,9 +1837,9 @@ export const es: TranslationKeys = { pipelineName: "Nombre del Pipeline", pipelineDescription: "Descripcion (opcional)", noStepsPrompt: "Agrega pasos para construir tu automatizacion", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Paso", +step: "Paso", }, nav: { tools: "Herramientas", @@ -1845,11 +1852,11 @@ export const es: TranslationKeys = { grid: "Cuadricula", }, files: { - recentTab: "Recientes", - uploadTab: "Subir", - fileDetailsAriaLabel: "Detalles del archivo", - fileDetailsHeading: "Detalles del archivo", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 1b251318..cbf0c72e 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -37,9 +37,8 @@ export const fr: TranslationKeys = { retry: "Réessayer", privacyPolicy: "Politique de confidentialite", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Essentiels", optimization: "Optimisation", @@ -599,6 +598,13 @@ export const fr: TranslationKeys = { intensity: "Intensite", addShadow: "Ajouter une ombre", opacity: "Opacite", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Rendu en cours...", submit: "Supprimer l'arriere-plan", submitBatch: "Supprimer l'arriere-plan ({count} fichiers)", @@ -1706,7 +1712,8 @@ export const fr: TranslationKeys = { docsLink: "Documentation", apiRefLink: "Reference API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1851,9 +1858,9 @@ export const fr: TranslationKeys = { pipelineName: "Nom du Pipeline", pipelineDescription: "Description (optionnel)", noStepsPrompt: "Ajoutez des etapes pour construire votre automatisation", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Etape", +step: "Etape", }, nav: { tools: "Outils", @@ -1866,11 +1873,11 @@ export const fr: TranslationKeys = { grid: "Grille", }, files: { - recentTab: "Recents", - uploadTab: "Importer", - fileDetailsAriaLabel: "Details du fichier", - fileDetailsHeading: "Details du fichier", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 8da66ed9..ec590886 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -37,9 +37,8 @@ export const hi: TranslationKeys = { retry: "पुनः प्रयास करें", privacyPolicy: "गोपनीयता नीति", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "आवश्यक टूल्स", optimization: "ऑप्टिमाइज़ेशन", @@ -586,6 +585,13 @@ export const hi: TranslationKeys = { intensity: "तीव्रता", addShadow: "शैडो जोड़ें", opacity: "ओपेसिटी", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "रेंडर हो रहा है...", submit: "बैकग्राउंड हटाएं", submitBatch: "बैकग्राउंड हटाएं ({count} फाइलें)", @@ -1679,7 +1685,8 @@ export const hi: TranslationKeys = { docsLink: "डॉक्यूमेंटेशन", apiRefLink: "API रेफरेंस (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1821,9 +1828,9 @@ export const hi: TranslationKeys = { pipelineName: "Pipeline का नाम", pipelineDescription: "विवरण (वैकल्पिक)", noStepsPrompt: "अपना ऑटोमेशन बनाने के लिए स्टेप्स जोड़ें", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "स्टेप", +step: "स्टेप", }, nav: { tools: "टूल्स", @@ -1836,11 +1843,11 @@ export const hi: TranslationKeys = { grid: "ग्रिड", }, files: { - recentTab: "हाल के", - uploadTab: "अपलोड", - fileDetailsAriaLabel: "फाइल विवरण", - fileDetailsHeading: "फाइल विवरण", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 360f33a3..bf9585b0 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -37,9 +37,8 @@ export const id: TranslationKeys = { retry: "Coba lagi", privacyPolicy: "Kebijakan Privasi", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Dasar", optimization: "Optimasi", @@ -596,6 +595,13 @@ export const id: TranslationKeys = { intensity: "Intensitas", addShadow: "Tambah Bayangan", opacity: "Opasitas", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Merender...", submit: "Hapus Latar Belakang", submitBatch: "Hapus Latar Belakang ({count} file)", @@ -1695,7 +1701,8 @@ export const id: TranslationKeys = { docsLink: "Dokumentasi", apiRefLink: "Referensi API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1838,9 +1845,9 @@ export const id: TranslationKeys = { pipelineName: "Nama Pipeline", pipelineDescription: "Deskripsi (opsional)", noStepsPrompt: "Tambahkan langkah untuk membangun otomasi Anda", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Langkah", +step: "Langkah", }, nav: { tools: "Alat", @@ -1853,11 +1860,11 @@ export const id: TranslationKeys = { grid: "Grid", }, files: { - recentTab: "Terbaru", - uploadTab: "Unggah", - fileDetailsAriaLabel: "Detail File", - fileDetailsHeading: "Detail File", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index cfd0eb02..4f4c4a85 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -37,9 +37,8 @@ export const it: TranslationKeys = { retry: "Riprova", privacyPolicy: "Informativa sulla privacy", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Essenziali", optimization: "Ottimizzazione", @@ -595,6 +594,13 @@ export const it: TranslationKeys = { intensity: "Intensita", addShadow: "Aggiungi ombra", opacity: "Opacita", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Rendering...", submit: "Rimuovi sfondo", submitBatch: "Rimuovi sfondo ({count} file)", @@ -1701,7 +1707,8 @@ export const it: TranslationKeys = { docsLink: "Documentazione", apiRefLink: "Riferimento API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1845,9 +1852,9 @@ export const it: TranslationKeys = { pipelineName: "Nome del Pipeline", pipelineDescription: "Descrizione (opzionale)", noStepsPrompt: "Aggiungi passaggi per costruire la tua automazione", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Passaggio", +step: "Passaggio", }, nav: { tools: "Strumenti", @@ -1860,11 +1867,11 @@ export const it: TranslationKeys = { grid: "Griglia", }, files: { - recentTab: "Recenti", - uploadTab: "Carica", - fileDetailsAriaLabel: "Dettagli file", - fileDetailsHeading: "Dettagli file", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index cb68ba0e..4f6f51e7 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -37,9 +37,8 @@ export const ja: TranslationKeys = { retry: "再試行", privacyPolicy: "プライバシーポリシー", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "基本ツール", optimization: "最適化", @@ -555,6 +554,13 @@ export const ja: TranslationKeys = { intensity: "強度", addShadow: "シャドウ追加", opacity: "不透明度", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "レンダリング中...", submit: "背景を除去", submitBatch: "背景を除去({count}ファイル)", @@ -1652,7 +1658,8 @@ export const ja: TranslationKeys = { docsLink: "ドキュメント", apiRefLink: "APIリファレンス(Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1794,9 +1801,9 @@ export const ja: TranslationKeys = { pipelineName: "Pipeline名", pipelineDescription: "説明(任意)", noStepsPrompt: "ステップを追加して自動化を構築", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "ステップ", +step: "ステップ", }, nav: { tools: "ツール", @@ -1809,11 +1816,11 @@ export const ja: TranslationKeys = { grid: "グリッド", }, files: { - recentTab: "最近", - uploadTab: "アップロード", - fileDetailsAriaLabel: "ファイル詳細", - fileDetailsHeading: "ファイル詳細", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 6c23ba75..1a910826 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -37,9 +37,8 @@ export const ko: TranslationKeys = { retry: "재시도", privacyPolicy: "개인정보 처리방침", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "기본 도구", optimization: "최적화", @@ -542,6 +541,13 @@ export const ko: TranslationKeys = { intensity: "강도", addShadow: "그림자 추가", opacity: "불투명도", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "렌더링 중...", submit: "배경 제거", submitBatch: "배경 제거 ({count}개 파일)", @@ -1637,7 +1643,8 @@ export const ko: TranslationKeys = { docsLink: "문서", apiRefLink: "API 레퍼런스 (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1779,9 +1786,9 @@ export const ko: TranslationKeys = { pipelineName: "Pipeline 이름", pipelineDescription: "설명 (선택)", noStepsPrompt: "단계를 추가하여 자동화를 구성하세요", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "단계", +step: "단계", }, nav: { tools: "도구", @@ -1794,11 +1801,11 @@ export const ko: TranslationKeys = { grid: "그리드", }, files: { - recentTab: "최근", - uploadTab: "업로드", - fileDetailsAriaLabel: "파일 상세", - fileDetailsHeading: "파일 상세", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 2a1b4a2b..e519ecdf 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -37,9 +37,8 @@ export const nl: TranslationKeys = { retry: "Opnieuw proberen", privacyPolicy: "Privacybeleid", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Basistools", optimization: "Optimalisatie", @@ -596,6 +595,13 @@ export const nl: TranslationKeys = { intensity: "Intensiteit", addShadow: "Schaduw toevoegen", opacity: "Dekking", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderen...", submit: "Achtergrond verwijderen", submitBatch: "Achtergrond verwijderen ({count} bestanden)", @@ -1698,7 +1704,8 @@ export const nl: TranslationKeys = { docsLink: "Documentatie", apiRefLink: "API-referentie (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1841,9 +1848,9 @@ export const nl: TranslationKeys = { pipelineName: "Pipeline-naam", pipelineDescription: "Beschrijving (optioneel)", noStepsPrompt: "Voeg stappen toe om je automatisering te bouwen", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Stap", +step: "Stap", }, nav: { tools: "Tools", @@ -1856,11 +1863,11 @@ export const nl: TranslationKeys = { grid: "Raster", }, files: { - recentTab: "Recent", - uploadTab: "Uploaden", - fileDetailsAriaLabel: "Bestandsdetails", - fileDetailsHeading: "Bestandsdetails", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index d28958bc..34f5a797 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -37,9 +37,8 @@ export const pl: TranslationKeys = { retry: "Ponów", privacyPolicy: "Polityka prywatności", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Podstawowe", optimization: "Optymalizacja", @@ -599,6 +598,13 @@ export const pl: TranslationKeys = { intensity: "Intensywność", addShadow: "Dodaj cień", opacity: "Krycie", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderowanie...", submit: "Usuń tło", submitBatch: "Usuń tło ({count} plików)", @@ -1704,7 +1710,8 @@ export const pl: TranslationKeys = { docsLink: "Dokumentacja", apiRefLink: "Dokumentacja API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1848,9 +1855,9 @@ export const pl: TranslationKeys = { pipelineName: "Nazwa Pipeline", pipelineDescription: "Opis (opcjonalnie)", noStepsPrompt: "Dodaj kroki, aby zbudować automatyzację", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Krok", +step: "Krok", }, nav: { tools: "Narzędzia", @@ -1863,11 +1870,11 @@ export const pl: TranslationKeys = { grid: "Siatka", }, files: { - recentTab: "Ostatnie", - uploadTab: "Przesyłanie", - fileDetailsAriaLabel: "Szczegóły pliku", - fileDetailsHeading: "Szczegóły pliku", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 6e44f2cf..b4be05a5 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -37,9 +37,8 @@ export const ptBR: TranslationKeys = { retry: "Tentar novamente", privacyPolicy: "Politica de privacidade", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Essenciais", optimization: "Otimizacao", @@ -595,6 +594,13 @@ export const ptBR: TranslationKeys = { intensity: "Intensidade", addShadow: "Adicionar sombra", opacity: "Opacidade", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderizando...", submit: "Remover fundo", submitBatch: "Remover fundo ({count} arquivos)", @@ -1697,7 +1703,8 @@ export const ptBR: TranslationKeys = { docsLink: "Documentacao", apiRefLink: "Referencia da API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1841,9 +1848,9 @@ export const ptBR: TranslationKeys = { pipelineName: "Nome do Pipeline", pipelineDescription: "Descricao (opcional)", noStepsPrompt: "Adicione passos para construir sua automacao", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Passo", +step: "Passo", }, nav: { tools: "Ferramentas", @@ -1856,11 +1863,11 @@ export const ptBR: TranslationKeys = { grid: "Grade", }, files: { - recentTab: "Recentes", - uploadTab: "Enviar", - fileDetailsAriaLabel: "Detalhes do arquivo", - fileDetailsHeading: "Detalhes do arquivo", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 3a4228c4..217f3efb 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -37,9 +37,8 @@ export const ru: TranslationKeys = { retry: "Повторить", privacyPolicy: "Политика конфиденциальности", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Основные", optimization: "Оптимизация", @@ -597,6 +596,13 @@ export const ru: TranslationKeys = { intensity: "Интенсивность", addShadow: "Добавить тень", opacity: "Непрозрачность", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Рендеринг...", submit: "Удалить фон", submitBatch: "Удалить фон ({count} файлов)", @@ -1697,7 +1703,8 @@ export const ru: TranslationKeys = { docsLink: "Документация", apiRefLink: "Справочник API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1840,9 +1847,9 @@ export const ru: TranslationKeys = { pipelineName: "Название Pipeline", pipelineDescription: "Описание (необязательно)", noStepsPrompt: "Добавьте шаги для построения автоматизации", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Шаг", +step: "Шаг", }, nav: { tools: "Инструменты", @@ -1855,11 +1862,11 @@ export const ru: TranslationKeys = { grid: "Сетка", }, files: { - recentTab: "Недавние", - uploadTab: "Загрузка", - fileDetailsAriaLabel: "Сведения о файле", - fileDetailsHeading: "Сведения о файле", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index 3ba8026d..6a508cb0 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -37,9 +37,8 @@ export const sv: TranslationKeys = { retry: "Försök igen", privacyPolicy: "Integritetspolicy", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Grundlaggande", optimization: "Optimering", @@ -594,6 +593,13 @@ export const sv: TranslationKeys = { intensity: "Intensitet", addShadow: "Lagg till skugga", opacity: "Opacitet", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Renderar...", submit: "Ta bort bakgrund", submitBatch: "Ta bort bakgrund ({count} filer)", @@ -1693,7 +1699,8 @@ export const sv: TranslationKeys = { docsLink: "Dokumentation", apiRefLink: "API-referens (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1835,9 +1842,9 @@ export const sv: TranslationKeys = { pipelineName: "Pipeline-namn", pipelineDescription: "Beskrivning (valfritt)", noStepsPrompt: "Lagg till steg for att bygga din automatisering", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Steg", +step: "Steg", }, nav: { tools: "Verktyg", @@ -1850,11 +1857,11 @@ export const sv: TranslationKeys = { grid: "Rutnat", }, files: { - recentTab: "Senaste", - uploadTab: "Ladda upp", - fileDetailsAriaLabel: "Fildetaljer", - fileDetailsHeading: "Fildetaljer", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index 44b6b59e..cf3d897a 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -37,9 +37,8 @@ export const th: TranslationKeys = { retry: "ลองใหม่", privacyPolicy: "นโยบายความเป็นส่วนตัว", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "พื้นฐาน", optimization: "การเพิ่มประสิทธิภาพ", @@ -586,6 +585,13 @@ export const th: TranslationKeys = { intensity: "ความเข้ม", addShadow: "เพิ่มเงา", opacity: "ความทึบ", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "กำลังเรนเดอร์...", submit: "ลบพื้นหลัง", submitBatch: "ลบพื้นหลัง ({count} ไฟล์)", @@ -1671,7 +1677,8 @@ export const th: TranslationKeys = { docsLink: "เอกสารประกอบ", apiRefLink: "อ้างอิง API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1812,9 +1819,9 @@ export const th: TranslationKeys = { pipelineName: "ชื่อ Pipeline", pipelineDescription: "คำอธิบาย (ไม่บังคับ)", noStepsPrompt: "เพิ่มขั้นตอนเพื่อสร้างการทำงานอัตโนมัติ", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "ขั้นตอน", +step: "ขั้นตอน", }, nav: { tools: "เครื่องมือ", @@ -1827,11 +1834,11 @@ export const th: TranslationKeys = { grid: "กริด", }, files: { - recentTab: "ล่าสุด", - uploadTab: "อัปโหลด", - fileDetailsAriaLabel: "รายละเอียดไฟล์", - fileDetailsHeading: "รายละเอียดไฟล์", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 5db6b604..a3fcf512 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -37,9 +37,8 @@ export const tr: TranslationKeys = { retry: "Yeniden dene", privacyPolicy: "Gizlilik Politikası", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Temel Araçlar", optimization: "Optimizasyon", @@ -598,6 +597,13 @@ export const tr: TranslationKeys = { intensity: "Yoğunluk", addShadow: "Gölge Ekle", opacity: "Opaklık", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "İşleniyor...", submit: "Arka Planı Kaldır", submitBatch: "Arka Planı Kaldır ({count} dosya)", @@ -1701,7 +1707,8 @@ export const tr: TranslationKeys = { docsLink: "Dokümantasyon", apiRefLink: "API Referansı (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1845,9 +1852,9 @@ export const tr: TranslationKeys = { pipelineName: "Pipeline Adı", pipelineDescription: "Açıklama (isteğe bağlı)", noStepsPrompt: "Otomasyonunuzu oluşturmak için adımlar ekleyin", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Adım", +step: "Adım", }, nav: { tools: "Araçlar", @@ -1860,11 +1867,11 @@ export const tr: TranslationKeys = { grid: "Izgara", }, files: { - recentTab: "Son Kullanılanlar", - uploadTab: "Yükle", - fileDetailsAriaLabel: "Dosya Ayrıntıları", - fileDetailsHeading: "Dosya Ayrıntıları", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index ebaac0e2..0f94de30 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -37,9 +37,8 @@ export const uk: TranslationKeys = { retry: "Повторити", privacyPolicy: "Політика конфіденційності", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Основні", optimization: "Оптимізація", @@ -597,6 +596,13 @@ export const uk: TranslationKeys = { intensity: "Інтенсивність", addShadow: "Додати тінь", opacity: "Непрозорість", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Рендеринг...", submit: "Видалити фон", submitBatch: "Видалити фон ({count} файлів)", @@ -1697,7 +1703,8 @@ export const uk: TranslationKeys = { docsLink: "Документація", apiRefLink: "Довідник API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1841,9 +1848,9 @@ export const uk: TranslationKeys = { pipelineName: "Назва Pipeline", pipelineDescription: "Опис (необов'язково)", noStepsPrompt: "Додайте кроки для побудови автоматизації", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Крок", +step: "Крок", }, nav: { tools: "Інструменти", @@ -1856,11 +1863,11 @@ export const uk: TranslationKeys = { grid: "Сітка", }, files: { - recentTab: "Нещодавні", - uploadTab: "Завантаження", - fileDetailsAriaLabel: "Відомості про файл", - fileDetailsHeading: "Відомості про файл", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index 448630e3..489c1a39 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -37,9 +37,8 @@ export const vi: TranslationKeys = { retry: "Thử lại", privacyPolicy: "Chính sách bảo mật", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "Cơ bản", optimization: "Tối ưu hóa", @@ -597,6 +596,13 @@ export const vi: TranslationKeys = { intensity: "Cường độ", addShadow: "Thêm bóng đổ", opacity: "Độ mờ", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "Đang kết xuất...", submit: "Xóa nền", submitBatch: "Xóa nền ({count} tệp)", @@ -1693,7 +1699,8 @@ export const vi: TranslationKeys = { docsLink: "Tài liệu", apiRefLink: "Tham chiếu API (Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1835,9 +1842,9 @@ export const vi: TranslationKeys = { pipelineName: "Tên Pipeline", pipelineDescription: "Mô tả (tùy chọn)", noStepsPrompt: "Thêm các bước để xây dựng tự động hóa", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "Bước", +step: "Bước", }, nav: { tools: "Công cụ", @@ -1850,11 +1857,11 @@ export const vi: TranslationKeys = { grid: "Lưới", }, files: { - recentTab: "Gần đây", - uploadTab: "Tải lên", - fileDetailsAriaLabel: "Chi tiết tệp", - fileDetailsHeading: "Chi tiết tệp", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 2b4e965a..8cf3d29b 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -37,9 +37,8 @@ export const zhCN: TranslationKeys = { retry: "重试", privacyPolicy: "隐私政策", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "基础工具", optimization: "优化", @@ -540,6 +539,13 @@ export const zhCN: TranslationKeys = { intensity: "强度", addShadow: "添加阴影", opacity: "不透明度", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "渲染中...", submit: "移除背景", submitBatch: "移除背景({count} 个文件)", @@ -1623,7 +1629,8 @@ export const zhCN: TranslationKeys = { docsLink: "文档", apiRefLink: "API 参考(Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1763,9 +1770,9 @@ export const zhCN: TranslationKeys = { pipelineName: "Pipeline 名称", pipelineDescription: "描述(可选)", noStepsPrompt: "添加步骤来构建自动化流程", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "步骤", +step: "步骤", }, nav: { tools: "工具", @@ -1778,11 +1785,11 @@ export const zhCN: TranslationKeys = { grid: "网格", }, files: { - recentTab: "最近", - uploadTab: "上传", - fileDetailsAriaLabel: "文件详情", - fileDetailsHeading: "文件详情", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index d7484381..7a6bbf4a 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -37,9 +37,8 @@ export const zhTW: TranslationKeys = { retry: "重試", privacyPolicy: "隱私權政策", pageNotFound: "Page not found", - pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", - noToolsFound: "No tools found", - }, + pageNotFoundDescription: "The page you are looking for does not exist or has been moved.", +}, categories: { essentials: "基本工具", optimization: "最佳化", @@ -539,6 +538,13 @@ export const zhTW: TranslationKeys = { intensity: "強度", addShadow: "加入陰影", opacity: "不透明度", + outputFormat: "Output Format", + edgeSmoothing: "Edge Smoothing", + edgeSmoothingOff: "Off", + edgeSmoothingLight: "Light", + edgeSmoothingMedium: "Medium", + edgeSmoothingStrong: "Strong", + colorDecontamination: "Color Decontamination", rendering: "算繪中...", submit: "移除背景", submitBatch: "移除背景({count}個檔案)", @@ -1621,7 +1627,8 @@ export const zhTW: TranslationKeys = { docsLink: "說明文件", apiRefLink: "API參考(Swagger)", licenseLabel: "License:", - licenseDescription: "Dual-licensed under AGPLv3 and a commercial license.", + licenseDescription: + "SnapOtter is open-source software licensed under the GNU Affero General Public License v3 (AGPLv3).", }, }, auth: { @@ -1761,9 +1768,9 @@ export const zhTW: TranslationKeys = { pipelineName: "Pipeline名稱", pipelineDescription: "描述(選填)", noStepsPrompt: "加入步驟來建構自動化", - noStepsHeading: "No steps yet", + noStepsHeading: "No steps yet", searchToolsPlaceholder: "Search tools...", - step: "步驟", +step: "步驟", }, nav: { tools: "工具", @@ -1776,11 +1783,11 @@ export const zhTW: TranslationKeys = { grid: "格線", }, files: { - recentTab: "最近", - uploadTab: "上傳", - fileDetailsAriaLabel: "檔案詳情", - fileDetailsHeading: "檔案詳情", myFiles: "My Files", + recentTab: "Recent", + uploadTab: "Upload Files", + fileDetailsAriaLabel: "File Details", + fileDetailsHeading: "File Details", searchPlaceholder: "Search files...", selectedCount: "{count} selected", fileCount: "{count} files", diff --git a/tests/e2e/gui-qa-fixes.spec.ts b/tests/e2e/gui-qa-fixes.spec.ts new file mode 100644 index 00000000..46313430 --- /dev/null +++ b/tests/e2e/gui-qa-fixes.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from "./helpers"; + +test.describe("QA fixes verification", () => { + test("invalid tool slug shows 404 page with Go Home link", async ({ loggedInPage: page }) => { + await page.goto("/nonexistent-tool-slug-xyz"); + await expect(page.locator("text=Tool not found").or(page.locator("text=404"))).toBeVisible({ + timeout: 10_000, + }); + const goHome = page.getByRole("link", { name: /go home/i }); + await expect(goHome).toBeVisible(); + await goHome.click(); + await expect(page).toHaveURL("/"); + }); + + test("multi-segment invalid URL shows 404 page", async ({ loggedInPage: page }) => { + await page.goto("/some/deep/nested/path"); + await expect(page.locator("text=404")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("link", { name: /go home/i })).toBeVisible(); + }); + + test("/tools/:toolId redirects to /:toolId", async ({ loggedInPage: page }) => { + await page.goto("/tools/resize"); + await page.waitForURL("**/resize", { timeout: 5_000 }); + await expect(page).toHaveURL(/\/resize$/); + }); + + test("confirm password field has visibility toggle", async ({ loggedInPage: page }) => { + await page.goto("/"); + // Open settings + const settingsBtn = page.locator('[class*="sidebar"]').getByRole("button").last(); + await settingsBtn.click().catch(() => {}); + // Try to navigate to Security tab + const securityTab = page.getByText("Security"); + if (await securityTab.isVisible({ timeout: 3_000 }).catch(() => false)) { + await securityTab.click(); + // Find all password eye toggle buttons + const eyeButtons = page.locator('button[tabindex="-1"]'); + const count = await eyeButtons.count(); + // Should have at least 3 eye buttons (current, new, confirm) + expect(count).toBeGreaterThanOrEqual(3); + } + }); + + test("pipeline steps survive navigation", async ({ loggedInPage: page }) => { + await page.goto("/automate"); + await page.waitForLoadState("networkidle"); + + // Add a step by clicking a tool in the palette + const resizeTool = page.locator("text=Resize").first(); + if (await resizeTool.isVisible({ timeout: 5_000 }).catch(() => false)) { + await resizeTool.click(); + // Wait for the step to appear + await page.waitForTimeout(500); + + // Navigate away + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // Navigate back + await page.goto("/automate"); + await page.waitForLoadState("networkidle"); + + // Steps should still be there (persisted in sessionStorage) + const removeButtons = page.locator('button[title="Remove"], button:has-text("Remove")'); + const count = await removeButtons.count(); + expect(count).toBeGreaterThanOrEqual(1); + } + }); + + test("export dialog has filename input", async ({ loggedInPage: page }) => { + await page.goto("/editor"); + await page.waitForLoadState("networkidle"); + + // Try to open export dialog via keyboard + await page.keyboard.press("Control+Shift+S"); + await page.waitForTimeout(1000); + + const filenameInput = page.locator('input[placeholder="export"]'); + if (await filenameInput.isVisible({ timeout: 3_000 }).catch(() => false)) { + await expect(filenameInput).toBeVisible(); + await filenameInput.fill("my-image"); + await expect(filenameInput).toHaveValue("my-image"); + } + }); +});