fix: editor native layout, UI polish, meme templates auth, pipeline export, install queuing

- Remove editor from AppLayout so it owns the full viewport with no sidebar/padding
- Add back-arrow in menu bar for navigation, restyle menu bar to match native app feel
- Slim left toolbar from 48px to 36px with smaller icons and subtler dividers
- Fix options bar overlapping canvas via shrink-0 and overflow-hidden
- Fix opacity/blend controls clipping in right panel with min-w-0 on flex children
- Add global range slider CSS for consistent thin-track + round-thumb styling
- Make meme template routes public so img tags can load without Bearer auth
- Show pipeline export buttons in default compact view, not just expanded hover
- Queue individual AI feature installs instead of erroring when one is active
This commit is contained in:
SnapOtter
2026-05-08 23:30:27 +08:00
parent 23f24c10d1
commit e22ffa4ec9
23 changed files with 181 additions and 94 deletions
+1
View File
@@ -765,6 +765,7 @@ const PUBLIC_PATHS = [
"/api/v1/jobs/",
"/api/docs",
"/api/v1/openapi.yaml",
"/api/v1/meme-templates/",
];
function isPublicRoute(url: string): boolean {
+1 -8
View File
@@ -228,14 +228,7 @@ export function App() {
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
<Route
path="/editor"
element={
<AppLayout showToolPanel={false}>
<EditorPage />
</AppLayout>
}
/>
<Route path="/editor" element={<EditorPage />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
@@ -162,7 +162,7 @@ export function FillDialog({ open, onClose }: FillDialogProps) {
max={100}
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="flex-1 h-1 accent-primary"
className="flex-1"
/>
<input
type="number"
@@ -23,7 +23,7 @@ export function IconButton({
shortcut,
active,
disabled,
size = 18,
size = 16,
onClick,
onContextMenu,
className,
@@ -38,7 +38,7 @@ export function IconButton({
onClick={onClick}
onContextMenu={onContextMenu}
className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-md transition-colors",
"relative flex items-center justify-center w-7 h-7 rounded transition-colors",
"hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed",
active && "bg-primary text-primary-foreground hover:bg-primary/90",
!active && "text-muted-foreground",
@@ -31,7 +31,7 @@ export function SliderRow({
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1 h-1 accent-primary cursor-pointer"
className="flex-1 cursor-pointer"
/>
<input
type="number"
@@ -1,7 +1,8 @@
// apps/web/src/components/editor/editor-menu-bar.tsx
import { Check, ChevronRight } from "lucide-react";
import { ArrowLeft, Check, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -441,6 +442,7 @@ export function EditorMenuBar(props: MenuBarCallbacks) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpenMenu(null), []);
const navigate = useNavigate();
useEffect(() => {
if (!openMenu) return;
@@ -465,40 +467,50 @@ export function EditorMenuBar(props: MenuBarCallbacks) {
return (
<div
ref={barRef}
className="flex items-center h-7 bg-card border-b border-border px-1 select-none shrink-0"
className="flex items-center h-8 bg-background border-b border-border select-none shrink-0"
data-testid="editor-menu-bar"
>
{menus.map((menu) => (
<div key={menu.testId} className="relative">
<button
type="button"
className={cn(
"px-2.5 py-0.5 text-xs rounded-sm transition-colors",
openMenu === menu.testId
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-accent/50",
)}
data-testid={`menu-${menu.testId}`}
onClick={() => setOpenMenu(openMenu === menu.testId ? null : menu.testId)}
onMouseEnter={() => {
if (openMenu) setOpenMenu(menu.testId);
}}
>
{menu.label}
</button>
{openMenu === menu.testId && (
<div
className="absolute left-0 top-full mt-0.5 min-w-[220px] bg-card border border-border rounded-md shadow-lg py-1 z-50"
data-testid={`menu-dropdown-${menu.testId}`}
role="menu"
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center gap-1.5 px-2.5 h-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors border-r border-border"
title="Back to SnapOtter"
>
<ArrowLeft size={14} />
</button>
<div className="flex items-center px-1">
{menus.map((menu) => (
<div key={menu.testId} className="relative">
<button
type="button"
className={cn(
"px-2.5 h-8 text-xs transition-colors",
openMenu === menu.testId
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
data-testid={`menu-${menu.testId}`}
onClick={() => setOpenMenu(openMenu === menu.testId ? null : menu.testId)}
onMouseEnter={() => {
if (openMenu) setOpenMenu(menu.testId);
}}
>
{menu.items.map((item) => (
<MenuItemRow key={item.label} item={item} onClose={close} />
))}
</div>
)}
</div>
))}
{menu.label}
</button>
{openMenu === menu.testId && (
<div
className="absolute left-0 top-full min-w-[220px] bg-card border border-border rounded-md shadow-lg py-1 z-50"
data-testid={`menu-dropdown-${menu.testId}`}
role="menu"
>
{menu.items.map((item) => (
<MenuItemRow key={item.label} item={item} onClose={close} />
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
@@ -84,15 +84,15 @@ export function EditorOptionsBar() {
const foregroundColor = useEditorStore((s) => s.foregroundColor);
return (
<div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3">
<span className="text-xs font-medium text-muted-foreground">
<div className="flex items-center h-9 px-3 bg-card border-b border-border gap-3 shrink-0 overflow-hidden">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{activeTool
.replace(/-/g, " ")
.replace(/^shape /, "")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2 flex-1">
<div className="h-4 w-px bg-border shrink-0" />
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-x-auto">
{OptionsComponent && <OptionsComponent />}
{activeTool === "eyedropper" && (
<EyedropperOptions
@@ -70,7 +70,7 @@ export function EditorRightPanel() {
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-2">
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{activeTab === "layers" && <LayersPanel />}
{activeTab === "adjustments" && <AdjustmentsPanel />}
{activeTab === "history" && <HistoryPanel />}
@@ -11,7 +11,7 @@ export function EditorStatusBar() {
const zoomPercent = Math.round(zoom * 100);
return (
<div className="flex items-center justify-between h-7 px-3 bg-card border-t border-border text-xs text-muted-foreground">
<div className="flex items-center justify-between h-7 px-3 bg-background border-t border-border text-xs text-muted-foreground shrink-0">
<div className="flex items-center gap-3" data-testid="status-cursor">
{sourceImageUrl && (
<>
@@ -112,10 +112,10 @@ export function EditorToolbar() {
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
return (
<div className="flex flex-col items-center w-12 bg-card border-r border-border py-2 gap-0.5 overflow-y-auto">
<div className="flex flex-col items-center w-9 bg-card border-r border-border py-1.5 gap-0 overflow-y-auto">
{TOOL_GROUPS.map((group, gi) => (
<div key={group.tools[0].tool}>
{gi > 0 && <div className="w-6 h-px bg-border mx-auto my-1" />}
{gi > 0 && <div className="w-4 h-px bg-border/50 mx-auto my-0.5" />}
{group.tools.map((t) => (
<IconButton
key={t.tool}
@@ -27,7 +27,7 @@ export function BrushOptions() {
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -48,7 +48,7 @@ export function BrushOptions() {
max={100}
value={Math.round(brushOpacity * 100)}
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -71,7 +71,7 @@ export function BrushOptions() {
max={100}
value={Math.round(brushHardness * 100)}
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -26,7 +26,7 @@ export function CloneStampOptions() {
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -47,7 +47,7 @@ export function CloneStampOptions() {
max={100}
value={Math.round(brushOpacity * 100)}
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -69,7 +69,7 @@ export function CloneStampOptions() {
max={100}
value={Math.round(brushHardness * 100)}
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -48,7 +48,7 @@ export function DodgeBurnOptions() {
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-16 h-1 accent-primary"
className="w-16"
/>
<input
type="number"
@@ -88,7 +88,7 @@ export function DodgeBurnOptions() {
max={100}
value={dodgeBurnExposure}
onChange={(e) => setDodgeBurnExposure(Number(e.target.value))}
className="w-16 h-1 accent-primary"
className="w-16"
/>
<input
type="number"
@@ -127,7 +127,7 @@ export function DodgeBurnOptions() {
max={100}
value={spongeFlow}
onChange={(e) => setSpongeFlow(Number(e.target.value))}
className="w-16 h-1 accent-primary"
className="w-16"
/>
<input
type="number"
@@ -22,7 +22,7 @@ export function FillOptions() {
max={255}
value={tolerance}
onChange={(e) => setFillTolerance(Number(e.target.value))}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -39,7 +39,7 @@ export function GradientOptions() {
max={100}
value={opacityPercent}
onChange={(e) => setGradientOpacity(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -40,7 +40,7 @@ export function PixelBrushOptions() {
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -61,7 +61,7 @@ export function PixelBrushOptions() {
max={100}
value={strength}
onChange={(e) => setPixelBrushStrength(Number(e.target.value))}
className="w-20 h-1 accent-primary"
className="w-20"
/>
<input
type="number"
@@ -88,7 +88,7 @@ export function ShapeOptions() {
max={50}
value={shapeStrokeWidth}
onChange={(e) => setShapeStrokeWidth(Number(e.target.value))}
className="w-16 h-1 accent-primary"
className="w-16"
/>
<input
type="number"
@@ -110,7 +110,7 @@ export function ShapeOptions() {
max={100}
value={shapeCornerRadius}
onChange={(e) => setShapeCornerRadius(Number(e.target.value))}
className="w-16 h-1 accent-primary"
className="w-16"
/>
<input
type="number"
@@ -261,7 +261,7 @@ export function LayersPanel() {
function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode: string) => void }) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<label htmlFor="blend-mode-select" className="text-[10px] text-muted-foreground shrink-0">
Blend
</label>
@@ -269,7 +269,7 @@ function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode:
id="blend-mode-select"
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 h-7 text-xs bg-muted border border-border rounded px-1.5 text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
className="flex-1 min-w-0 h-7 text-xs bg-muted border border-border rounded px-1.5 text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
data-testid="blend-mode-select"
>
{BLEND_MODES.map((mode) => (
@@ -289,7 +289,7 @@ function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode:
function OpacitySlider({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const percent = Math.round(value * 100);
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<label htmlFor="layer-opacity-slider" className="text-[10px] text-muted-foreground shrink-0">
Opacity
</label>
@@ -300,10 +300,10 @@ function OpacitySlider({ value, onChange }: { value: number; onChange: (v: numbe
max={100}
value={percent}
onChange={(e) => onChange(Number(e.target.value) / 100)}
className="flex-1"
className="flex-1 min-w-0"
data-testid="layer-opacity-slider"
/>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
<span className="text-[10px] font-mono text-foreground tabular-nums shrink-0">
{percent}%
</span>
</div>
@@ -990,7 +990,7 @@ function EffectSlider({
onChange: (v: number) => void;
}) {
return (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] text-muted-foreground shrink-0 w-12">{label}</span>
<input
type="range"
@@ -998,9 +998,9 @@ function EffectSlider({
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1"
className="flex-1 min-w-0"
/>
<span className="text-[10px] font-mono text-foreground tabular-nums w-10 text-right">
<span className="text-[10px] font-mono text-foreground tabular-nums w-11 text-right shrink-0">
{value}
{suffix}
</span>
@@ -2,7 +2,6 @@
import { Minus, Plus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
const THUMBNAIL_MAX_HEIGHT = 80;
@@ -257,11 +256,7 @@ export function NavigatorPanel() {
step={0.001}
value={Math.log(zoom / MIN_ZOOM) / Math.log(MAX_ZOOM / MIN_ZOOM)}
onChange={handleZoomSlider}
className={cn(
"flex-1 h-1 appearance-none rounded-full bg-muted",
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5",
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:cursor-pointer",
)}
className="flex-1"
/>
<button
type="button"
+25 -12
View File
@@ -630,14 +630,16 @@ export function AutomatePage() {
<button
type="button"
onClick={() => handleExportPipeline(p)}
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-all shrink-0"
className="p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-all shrink-0"
title="Export pipeline"
>
<Download className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => handleDeletePipeline(p.id)}
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all shrink-0"
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all shrink-0"
title="Delete pipeline"
>
<Trash2 className="h-3 w-3" />
</button>
@@ -652,17 +654,28 @@ export function AutomatePage() {
</button>
</div>
) : (
<div className="flex flex-wrap gap-1">
<div className="space-y-1">
{savedPipelines.slice(0, 3).map((p) => (
<button
key={p.id}
type="button"
onClick={() => handleLoadPipeline(p)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors truncate max-w-[110px]"
>
<Play className="h-3 w-3 shrink-0" />
<span className="truncate">{p.name}</span>
</button>
<div key={p.id} className="flex items-center gap-1">
<button
type="button"
onClick={() => handleLoadPipeline(p)}
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
>
{p.name}
<span className="text-muted-foreground ml-1">
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
</span>
</button>
<button
type="button"
onClick={() => handleExportPipeline(p)}
className="p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-all shrink-0"
title="Export pipeline"
>
<Download className="h-3 w-3" />
</button>
</div>
))}
{savedPipelines.length > 3 && (
<button
+1 -1
View File
@@ -151,7 +151,7 @@ export function EditorPage() {
}
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex flex-col h-screen overflow-hidden bg-background text-foreground">
<EditorMenuBar
onNewDocument={() => setShowNewDocument(true)}
onOpenImage={handleOpenImage}
+30
View File
@@ -184,6 +184,36 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
},
installBundle: async (bundleId: string) => {
const activeIds = Object.keys(get().installing);
if (activeIds.length > 0 && !activeIds.includes(bundleId)) {
const alreadyQueued = get().queued.includes(bundleId);
if (!alreadyQueued) {
set({ queued: [...get().queued, bundleId] });
}
const errors = { ...get().errors };
delete errors[bundleId];
set({ errors });
await new Promise<void>((resolve) => {
const check = () => {
const current = get().installing;
if (Object.keys(current).length === 0 || Object.keys(current).includes(bundleId)) {
resolve();
} else {
setTimeout(check, 500);
}
};
check();
});
set({ queued: get().queued.filter((id) => id !== bundleId) });
const currentBundle = get().bundles.find((b) => b.id === bundleId);
if (currentBundle?.status === "installed") {
resolveCompletion(bundleId);
return;
}
}
const errors = { ...get().errors };
delete errors[bundleId];
set({
+43
View File
@@ -27,3 +27,46 @@
--color-sidebar: #1e293b;
--color-sidebar-foreground: #cbd5e1;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 9999px;
background: var(--color-muted);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-foreground);
cursor: pointer;
border: 2px solid var(--color-background);
box-shadow: 0 0 0 1px var(--color-border);
transition: transform 0.1s;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-foreground);
cursor: pointer;
border: 2px solid var(--color-background);
box-shadow: 0 0 0 1px var(--color-border);
}
input[type="range"]::-moz-range-track {
height: 4px;
border-radius: 9999px;
background: var(--color-muted);
}