mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -765,6 +765,7 @@ const PUBLIC_PATHS = [
|
|||||||
"/api/v1/jobs/",
|
"/api/v1/jobs/",
|
||||||
"/api/docs",
|
"/api/docs",
|
||||||
"/api/v1/openapi.yaml",
|
"/api/v1/openapi.yaml",
|
||||||
|
"/api/v1/meme-templates/",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isPublicRoute(url: string): boolean {
|
function isPublicRoute(url: string): boolean {
|
||||||
|
|||||||
@@ -228,14 +228,7 @@ export function App() {
|
|||||||
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
|
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
|
||||||
<Route
|
<Route path="/editor" element={<EditorPage />} />
|
||||||
path="/editor"
|
|
||||||
element={
|
|
||||||
<AppLayout showToolPanel={false}>
|
|
||||||
<EditorPage />
|
|
||||||
</AppLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/:toolId" element={<ToolPage />} />
|
<Route path="/:toolId" element={<ToolPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function FillDialog({ open, onClose }: FillDialogProps) {
|
|||||||
max={100}
|
max={100}
|
||||||
value={opacity}
|
value={opacity}
|
||||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||||
className="flex-1 h-1 accent-primary"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function IconButton({
|
|||||||
shortcut,
|
shortcut,
|
||||||
active,
|
active,
|
||||||
disabled,
|
disabled,
|
||||||
size = 18,
|
size = 16,
|
||||||
onClick,
|
onClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
className,
|
className,
|
||||||
@@ -38,7 +38,7 @@ export function IconButton({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
className={cn(
|
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",
|
"hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed",
|
||||||
active && "bg-primary text-primary-foreground hover:bg-primary/90",
|
active && "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
!active && "text-muted-foreground",
|
!active && "text-muted-foreground",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function SliderRow({
|
|||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
className="flex-1 h-1 accent-primary cursor-pointer"
|
className="flex-1 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// apps/web/src/components/editor/editor-menu-bar.tsx
|
// 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
|
|
||||||
@@ -441,6 +442,7 @@ export function EditorMenuBar(props: MenuBarCallbacks) {
|
|||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
const close = useCallback(() => setOpenMenu(null), []);
|
const close = useCallback(() => setOpenMenu(null), []);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openMenu) return;
|
if (!openMenu) return;
|
||||||
@@ -465,18 +467,27 @@ export function EditorMenuBar(props: MenuBarCallbacks) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={barRef}
|
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"
|
data-testid="editor-menu-bar"
|
||||||
>
|
>
|
||||||
|
<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) => (
|
{menus.map((menu) => (
|
||||||
<div key={menu.testId} className="relative">
|
<div key={menu.testId} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-0.5 text-xs rounded-sm transition-colors",
|
"px-2.5 h-8 text-xs transition-colors",
|
||||||
openMenu === menu.testId
|
openMenu === menu.testId
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "text-foreground hover:bg-accent/50",
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
data-testid={`menu-${menu.testId}`}
|
data-testid={`menu-${menu.testId}`}
|
||||||
onClick={() => setOpenMenu(openMenu === menu.testId ? null : menu.testId)}
|
onClick={() => setOpenMenu(openMenu === menu.testId ? null : menu.testId)}
|
||||||
@@ -488,7 +499,7 @@ export function EditorMenuBar(props: MenuBarCallbacks) {
|
|||||||
</button>
|
</button>
|
||||||
{openMenu === menu.testId && (
|
{openMenu === menu.testId && (
|
||||||
<div
|
<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"
|
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}`}
|
data-testid={`menu-dropdown-${menu.testId}`}
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
@@ -500,5 +511,6 @@ export function EditorMenuBar(props: MenuBarCallbacks) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,15 +84,15 @@ export function EditorOptionsBar() {
|
|||||||
const foregroundColor = useEditorStore((s) => s.foregroundColor);
|
const foregroundColor = useEditorStore((s) => s.foregroundColor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3">
|
<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">
|
<span className="text-xs font-medium text-muted-foreground shrink-0">
|
||||||
{activeTool
|
{activeTool
|
||||||
.replace(/-/g, " ")
|
.replace(/-/g, " ")
|
||||||
.replace(/^shape /, "")
|
.replace(/^shape /, "")
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border shrink-0" />
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-x-auto">
|
||||||
{OptionsComponent && <OptionsComponent />}
|
{OptionsComponent && <OptionsComponent />}
|
||||||
{activeTool === "eyedropper" && (
|
{activeTool === "eyedropper" && (
|
||||||
<EyedropperOptions
|
<EyedropperOptions
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function EditorRightPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* 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 === "layers" && <LayersPanel />}
|
||||||
{activeTab === "adjustments" && <AdjustmentsPanel />}
|
{activeTab === "adjustments" && <AdjustmentsPanel />}
|
||||||
{activeTab === "history" && <HistoryPanel />}
|
{activeTab === "history" && <HistoryPanel />}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function EditorStatusBar() {
|
|||||||
const zoomPercent = Math.round(zoom * 100);
|
const zoomPercent = Math.round(zoom * 100);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3" data-testid="status-cursor">
|
||||||
{sourceImageUrl && (
|
{sourceImageUrl && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -112,10 +112,10 @@ export function EditorToolbar() {
|
|||||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||||
|
|
||||||
return (
|
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) => (
|
{TOOL_GROUPS.map((group, gi) => (
|
||||||
<div key={group.tools[0].tool}>
|
<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) => (
|
{group.tools.map((t) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
key={t.tool}
|
key={t.tool}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function BrushOptions() {
|
|||||||
max={500}
|
max={500}
|
||||||
value={brushSize}
|
value={brushSize}
|
||||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -48,7 +48,7 @@ export function BrushOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={Math.round(brushOpacity * 100)}
|
value={Math.round(brushOpacity * 100)}
|
||||||
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
|
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -71,7 +71,7 @@ export function BrushOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={Math.round(brushHardness * 100)}
|
value={Math.round(brushHardness * 100)}
|
||||||
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
|
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function CloneStampOptions() {
|
|||||||
max={500}
|
max={500}
|
||||||
value={brushSize}
|
value={brushSize}
|
||||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -47,7 +47,7 @@ export function CloneStampOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={Math.round(brushOpacity * 100)}
|
value={Math.round(brushOpacity * 100)}
|
||||||
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
|
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -69,7 +69,7 @@ export function CloneStampOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={Math.round(brushHardness * 100)}
|
value={Math.round(brushHardness * 100)}
|
||||||
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
|
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function DodgeBurnOptions() {
|
|||||||
max={500}
|
max={500}
|
||||||
value={brushSize}
|
value={brushSize}
|
||||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||||
className="w-16 h-1 accent-primary"
|
className="w-16"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -88,7 +88,7 @@ export function DodgeBurnOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={dodgeBurnExposure}
|
value={dodgeBurnExposure}
|
||||||
onChange={(e) => setDodgeBurnExposure(Number(e.target.value))}
|
onChange={(e) => setDodgeBurnExposure(Number(e.target.value))}
|
||||||
className="w-16 h-1 accent-primary"
|
className="w-16"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -127,7 +127,7 @@ export function DodgeBurnOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={spongeFlow}
|
value={spongeFlow}
|
||||||
onChange={(e) => setSpongeFlow(Number(e.target.value))}
|
onChange={(e) => setSpongeFlow(Number(e.target.value))}
|
||||||
className="w-16 h-1 accent-primary"
|
className="w-16"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function FillOptions() {
|
|||||||
max={255}
|
max={255}
|
||||||
value={tolerance}
|
value={tolerance}
|
||||||
onChange={(e) => setFillTolerance(Number(e.target.value))}
|
onChange={(e) => setFillTolerance(Number(e.target.value))}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function GradientOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={opacityPercent}
|
value={opacityPercent}
|
||||||
onChange={(e) => setGradientOpacity(Number(e.target.value) / 100)}
|
onChange={(e) => setGradientOpacity(Number(e.target.value) / 100)}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function PixelBrushOptions() {
|
|||||||
max={500}
|
max={500}
|
||||||
value={brushSize}
|
value={brushSize}
|
||||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -61,7 +61,7 @@ export function PixelBrushOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={strength}
|
value={strength}
|
||||||
onChange={(e) => setPixelBrushStrength(Number(e.target.value))}
|
onChange={(e) => setPixelBrushStrength(Number(e.target.value))}
|
||||||
className="w-20 h-1 accent-primary"
|
className="w-20"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function ShapeOptions() {
|
|||||||
max={50}
|
max={50}
|
||||||
value={shapeStrokeWidth}
|
value={shapeStrokeWidth}
|
||||||
onChange={(e) => setShapeStrokeWidth(Number(e.target.value))}
|
onChange={(e) => setShapeStrokeWidth(Number(e.target.value))}
|
||||||
className="w-16 h-1 accent-primary"
|
className="w-16"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -110,7 +110,7 @@ export function ShapeOptions() {
|
|||||||
max={100}
|
max={100}
|
||||||
value={shapeCornerRadius}
|
value={shapeCornerRadius}
|
||||||
onChange={(e) => setShapeCornerRadius(Number(e.target.value))}
|
onChange={(e) => setShapeCornerRadius(Number(e.target.value))}
|
||||||
className="w-16 h-1 accent-primary"
|
className="w-16"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function LayersPanel() {
|
|||||||
|
|
||||||
function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode: string) => void }) {
|
function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode: string) => void }) {
|
||||||
return (
|
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">
|
<label htmlFor="blend-mode-select" className="text-[10px] text-muted-foreground shrink-0">
|
||||||
Blend
|
Blend
|
||||||
</label>
|
</label>
|
||||||
@@ -269,7 +269,7 @@ function BlendModeSelect({ value, onChange }: { value: string; onChange: (mode:
|
|||||||
id="blend-mode-select"
|
id="blend-mode-select"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.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"
|
data-testid="blend-mode-select"
|
||||||
>
|
>
|
||||||
{BLEND_MODES.map((mode) => (
|
{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 }) {
|
function OpacitySlider({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||||
const percent = Math.round(value * 100);
|
const percent = Math.round(value * 100);
|
||||||
return (
|
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">
|
<label htmlFor="layer-opacity-slider" className="text-[10px] text-muted-foreground shrink-0">
|
||||||
Opacity
|
Opacity
|
||||||
</label>
|
</label>
|
||||||
@@ -300,10 +300,10 @@ function OpacitySlider({ value, onChange }: { value: number; onChange: (v: numbe
|
|||||||
max={100}
|
max={100}
|
||||||
value={percent}
|
value={percent}
|
||||||
onChange={(e) => onChange(Number(e.target.value) / 100)}
|
onChange={(e) => onChange(Number(e.target.value) / 100)}
|
||||||
className="flex-1"
|
className="flex-1 min-w-0"
|
||||||
data-testid="layer-opacity-slider"
|
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}%
|
{percent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -990,7 +990,7 @@ function EffectSlider({
|
|||||||
onChange: (v: number) => void;
|
onChange: (v: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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>
|
<span className="text-[10px] text-muted-foreground shrink-0 w-12">{label}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -998,9 +998,9 @@ function EffectSlider({
|
|||||||
max={max}
|
max={max}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.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}
|
{value}
|
||||||
{suffix}
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { Minus, Plus } from "lucide-react";
|
import { Minus, Plus } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
|
|
||||||
const THUMBNAIL_MAX_HEIGHT = 80;
|
const THUMBNAIL_MAX_HEIGHT = 80;
|
||||||
@@ -257,11 +256,7 @@ export function NavigatorPanel() {
|
|||||||
step={0.001}
|
step={0.001}
|
||||||
value={Math.log(zoom / MIN_ZOOM) / Math.log(MAX_ZOOM / MIN_ZOOM)}
|
value={Math.log(zoom / MIN_ZOOM) / Math.log(MAX_ZOOM / MIN_ZOOM)}
|
||||||
onChange={handleZoomSlider}
|
onChange={handleZoomSlider}
|
||||||
className={cn(
|
className="flex-1"
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -630,14 +630,16 @@ export function AutomatePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleExportPipeline(p)}
|
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" />
|
<Download className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeletePipeline(p.id)}
|
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" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -652,17 +654,28 @@ export function AutomatePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="space-y-1">
|
||||||
{savedPipelines.slice(0, 3).map((p) => (
|
{savedPipelines.slice(0, 3).map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleLoadPipeline(p)}
|
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]"
|
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Play className="h-3 w-3 shrink-0" />
|
{p.name}
|
||||||
<span className="truncate">{p.name}</span>
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
|
||||||
|
</span>
|
||||||
</button>
|
</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 && (
|
{savedPipelines.length > 3 && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function EditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden bg-background text-foreground">
|
||||||
<EditorMenuBar
|
<EditorMenuBar
|
||||||
onNewDocument={() => setShowNewDocument(true)}
|
onNewDocument={() => setShowNewDocument(true)}
|
||||||
onOpenImage={handleOpenImage}
|
onOpenImage={handleOpenImage}
|
||||||
|
|||||||
@@ -184,6 +184,36 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
installBundle: async (bundleId: string) => {
|
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 };
|
const errors = { ...get().errors };
|
||||||
delete errors[bundleId];
|
delete errors[bundleId];
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -27,3 +27,46 @@
|
|||||||
--color-sidebar: #1e293b;
|
--color-sidebar: #1e293b;
|
||||||
--color-sidebar-foreground: #cbd5e1;
|
--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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user