mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
feat(sidebar): inline label create, live sync, and kebab submenu parity (#706)
Portal-wrap DropdownMenuSubContent so the kebab Labels submenu renders outside the clipped dropdown container, matching ContextMenuSubContent and fixing the empty/broken kebab submenu. Thread onLabelsChanged from LabelsSection through SettingsModal to EditorLayout so label creates and deletes in Settings propagate to the sidebar menus without a page refresh. Add an inline "New label" form in both the kebab and context menu label submenus that creates and assigns a label in one interaction, removing the Settings round-trip from the label assignment flow.
This commit is contained in:
@@ -20,7 +20,7 @@ import { springs } from '@/lib/motion';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { type Label as StackLabel } from './label-types';
|
||||
import { type Label as StackLabel, type LabelColor } from './label-types';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
import { NotificationPanel } from './NotificationPanel';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
@@ -597,7 +597,7 @@ export default function EditorLayout() {
|
||||
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
|
||||
};
|
||||
|
||||
const refreshLabels = async () => {
|
||||
const refreshLabels = useCallback(async () => {
|
||||
if (!isPaid) return;
|
||||
try {
|
||||
const [labelsRes, assignmentsRes] = await Promise.all([
|
||||
@@ -609,7 +609,7 @@ export default function EditorLayout() {
|
||||
} catch {
|
||||
// Labels are non-critical; fail silently
|
||||
}
|
||||
};
|
||||
}, [isPaid]);
|
||||
|
||||
/**
|
||||
* Populate the per-stack "pending git source update" map. Runs on mount and
|
||||
@@ -1902,6 +1902,37 @@ export default function EditorLayout() {
|
||||
toast.dismiss(loadingId);
|
||||
}
|
||||
},
|
||||
createAndAssignLabel: async (name: string, color: LabelColor) => {
|
||||
const loadingId = toast.loading('Creating label...');
|
||||
try {
|
||||
const createRes = await apiFetch('/labels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const data = await createRes.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string })?.error || 'Failed to create label.');
|
||||
}
|
||||
const created: StackLabel = await createRes.json();
|
||||
const currentIds = (stackLabelMap[file] ?? []).map(l => l.id);
|
||||
const newIds = [...currentIds, created.id];
|
||||
const assignRes = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ labelIds: newIds }),
|
||||
});
|
||||
if (!assignRes.ok) {
|
||||
const data = await assignRes.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string })?.error || 'Failed to assign label.');
|
||||
}
|
||||
toast.success(`Label "${created.name}" created.`);
|
||||
refreshLabels();
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Failed to create label.');
|
||||
throw err;
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
}
|
||||
},
|
||||
openLabelManager: () => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); },
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -2844,6 +2875,7 @@ export default function EditorLayout() {
|
||||
isOpen={settingsModalOpen}
|
||||
onClose={() => { setSettingsModalOpen(false); setSettingsInitialSection('account'); }}
|
||||
initialSection={settingsInitialSection}
|
||||
onLabelsChanged={refreshLabels}
|
||||
/>
|
||||
|
||||
{/* Stack Alert Sheet */}
|
||||
|
||||
@@ -63,9 +63,10 @@ interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: SectionId;
|
||||
onLabelsChanged?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModalProps) {
|
||||
export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged }: SettingsModalProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const { license, isPaid } = useLicense();
|
||||
@@ -302,7 +303,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
case 'sso': return <SSOSection />;
|
||||
case 'api-tokens': return <ApiTokensSection />;
|
||||
case 'registries': return <RegistriesSection />;
|
||||
case 'labels': return <LabelsSection />;
|
||||
case 'labels': return <LabelsSection onLabelsChanged={onLabelsChanged} />;
|
||||
case 'system':
|
||||
return (
|
||||
<SystemSection
|
||||
|
||||
@@ -26,7 +26,11 @@ import { CapabilityGate } from '../CapabilityGate';
|
||||
import { LabelDot } from '../LabelPill';
|
||||
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types';
|
||||
|
||||
export function LabelsSection() {
|
||||
interface LabelsSectionProps {
|
||||
onLabelsChanged?: () => void;
|
||||
}
|
||||
|
||||
export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [assignmentCounts, setAssignmentCounts] = useState<Record<number, number>>({});
|
||||
@@ -98,6 +102,7 @@ export function LabelsSection() {
|
||||
toast.success(`Label ${editingLabel ? 'updated' : 'created'}.`);
|
||||
setDialogOpen(false);
|
||||
fetchLabels();
|
||||
onLabelsChanged?.();
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
@@ -116,6 +121,7 @@ export function LabelsSection() {
|
||||
toast.success('Label deleted.');
|
||||
setDeleteTarget(null);
|
||||
fetchLabels();
|
||||
onLabelsChanged?.();
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Something went wrong.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LABEL_COLORS, type LabelColor } from '@/components/label-types';
|
||||
|
||||
interface LabelInlineCreateFormProps {
|
||||
onSubmit: (name: string, color: LabelColor) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function LabelInlineCreateForm({ onSubmit, onCancel }: LabelInlineCreateFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState<LabelColor>('teal');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSubmit(trimmed, color);
|
||||
} catch {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-2 py-2 space-y-2"
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
placeholder="Label name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="h-7 text-xs font-mono"
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{LABEL_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={`Color ${c}`}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-colors ${c === color ? 'border-foreground' : 'border-transparent hover:border-muted-foreground/30'}`}
|
||||
style={{ backgroundColor: `var(--label-${c})` }}
|
||||
onClick={() => setColor(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs flex-1"
|
||||
onClick={submit}
|
||||
disabled={saving || !name.trim()}
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-xs"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, Plus } from 'lucide-react';
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
|
||||
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { LabelDot } from '@/components/LabelPill';
|
||||
import { MAX_LABELS_PER_NODE } from '@/components/label-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
|
||||
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
|
||||
import { LabelInlineCreateForm } from './LabelInlineCreateForm';
|
||||
|
||||
interface StackContextMenuProps {
|
||||
file: string;
|
||||
@@ -27,32 +29,51 @@ function GroupHeader({ id }: { id: string }) {
|
||||
}
|
||||
|
||||
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSub onOpenChange={open => { if (!open) setCreating(false); }}>
|
||||
<ContextMenuSubTrigger>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[180px]">
|
||||
{ctx.labels.length === 0 && (
|
||||
<ContextMenuItem disabled>
|
||||
<span className="text-xs text-muted-foreground">No labels yet</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{ctx.labels.map(label => {
|
||||
const assigned = ctx.assignedLabelIds.includes(label.id);
|
||||
return (
|
||||
<ContextMenuItem key={label.id} onClick={() => ctx.toggleLabel(label.id)}>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
|
||||
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
|
||||
<ContextMenuSubContent className="min-w-[200px]">
|
||||
{creating ? (
|
||||
<LabelInlineCreateForm
|
||||
onSubmit={async (name, color) => {
|
||||
await ctx.createAndAssignLabel(name, color);
|
||||
setCreating(false);
|
||||
}}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{ctx.labels.length === 0 && (
|
||||
<ContextMenuItem disabled>
|
||||
<span className="text-xs text-muted-foreground">No labels yet</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{ctx.labels.map(label => {
|
||||
const assigned = ctx.assignedLabelIds.includes(label.id);
|
||||
return (
|
||||
<ContextMenuItem key={label.id} onClick={() => ctx.toggleLabel(label.id)}>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
|
||||
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
})}
|
||||
<ContextMenuSeparator />
|
||||
{ctx.labels.length < MAX_LABELS_PER_NODE && (
|
||||
<ContextMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="text-xs">New label</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
})}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { MoreVertical, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, Check, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { LabelDot } from '@/components/LabelPill';
|
||||
import { MAX_LABELS_PER_NODE } from '@/components/label-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
|
||||
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
|
||||
import { LabelInlineCreateForm } from './LabelInlineCreateForm';
|
||||
|
||||
interface StackKebabMenuProps {
|
||||
file: string;
|
||||
@@ -26,32 +29,51 @@ function GroupHeader({ id }: { id: string }) {
|
||||
}
|
||||
|
||||
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSub onOpenChange={open => { if (!open) setCreating(false); }}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
{ctx.labels.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-xs text-muted-foreground">No labels yet</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{ctx.labels.map(label => {
|
||||
const assigned = ctx.assignedLabelIds.includes(label.id);
|
||||
return (
|
||||
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
|
||||
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
|
||||
<DropdownMenuSubContent className="min-w-[200px]">
|
||||
{creating ? (
|
||||
<LabelInlineCreateForm
|
||||
onSubmit={async (name, color) => {
|
||||
await ctx.createAndAssignLabel(name, color);
|
||||
setCreating(false);
|
||||
}}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{ctx.labels.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-xs text-muted-foreground">No labels yet</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{ctx.labels.map(label => {
|
||||
const assigned = ctx.assignedLabelIds.includes(label.id);
|
||||
return (
|
||||
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
|
||||
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
{ctx.labels.length < MAX_LABELS_PER_NODE && (
|
||||
<DropdownMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="text-xs">New label</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { Label } from '../label-types';
|
||||
import type { Label, LabelColor } from '../label-types';
|
||||
|
||||
export type MenuGroupId = 'inspect' | 'organize' | 'lifecycle' | 'destructive';
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface StackMenuCtx {
|
||||
pin: () => void;
|
||||
unpin: () => void;
|
||||
toggleLabel: (labelId: number) => void;
|
||||
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
|
||||
openLabelManager: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,14 +42,16 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
@@ -27,6 +27,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
pin: vi.fn(),
|
||||
unpin: vi.fn(),
|
||||
toggleLabel: vi.fn(),
|
||||
createAndAssignLabel: vi.fn(),
|
||||
openLabelManager: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user