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/docs",
|
||||
"/api/v1/openapi.yaml",
|
||||
"/api/v1/meme-templates/",
|
||||
];
|
||||
|
||||
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-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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user