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/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 {
+1 -8
View File
@@ -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"
+20 -7
View File
@@ -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
+1 -1
View File
@@ -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}
+30
View File
@@ -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({
+43
View File
@@ -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);
}