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 */}
-
+