diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 69974c0c..b36caf74 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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 */} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 119f1282..80f9799c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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 ; case 'api-tokens': return ; case 'registries': return ; - case 'labels': return ; + case 'labels': return ; case 'system': return ( void; +} + +export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) { const [labels, setLabels] = useState([]); const [loading, setLoading] = useState(true); const [assignmentCounts, setAssignmentCounts] = useState>({}); @@ -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.'); } diff --git a/frontend/src/components/sidebar/LabelInlineCreateForm.tsx b/frontend/src/components/sidebar/LabelInlineCreateForm.tsx new file mode 100644 index 00000000..367b8b4c --- /dev/null +++ b/frontend/src/components/sidebar/LabelInlineCreateForm.tsx @@ -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; + onCancel: () => void; +} + +export function LabelInlineCreateForm({ onSubmit, onCancel }: LabelInlineCreateFormProps) { + const [name, setName] = useState(''); + const [color, setColor] = useState('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 ( +
e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} + > + 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(); } + }} + /> +
+ {LABEL_COLORS.map(c => ( +
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/sidebar/StackContextMenu.tsx b/frontend/src/components/sidebar/StackContextMenu.tsx index c5c5e047..e79be42c 100644 --- a/frontend/src/components/sidebar/StackContextMenu.tsx +++ b/frontend/src/components/sidebar/StackContextMenu.tsx @@ -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 ( - + { if (!open) setCreating(false); }}> {item.label} - - {ctx.labels.length === 0 && ( - - No labels yet - - )} - {ctx.labels.map(label => { - const assigned = ctx.assignedLabelIds.includes(label.id); - return ( - ctx.toggleLabel(label.id)}> - - {label.name} - {assigned && } + + {creating ? ( + { + await ctx.createAndAssignLabel(name, color); + setCreating(false); + }} + onCancel={() => setCreating(false)} + /> + ) : ( + <> + {ctx.labels.length === 0 && ( + + No labels yet + + )} + {ctx.labels.map(label => { + const assigned = ctx.assignedLabelIds.includes(label.id); + return ( + ctx.toggleLabel(label.id)}> + + {label.name} + {assigned && } + + ); + })} + + {ctx.labels.length < MAX_LABELS_PER_NODE && ( + { e.preventDefault(); setCreating(true); }}> + + New label + + )} + + Manage labels... - ); - })} - - - Manage labels... - + + )} ); diff --git a/frontend/src/components/sidebar/StackKebabMenu.tsx b/frontend/src/components/sidebar/StackKebabMenu.tsx index ff002a3d..be938d44 100644 --- a/frontend/src/components/sidebar/StackKebabMenu.tsx +++ b/frontend/src/components/sidebar/StackKebabMenu.tsx @@ -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 ( - + { if (!open) setCreating(false); }}> {item.label} - - {ctx.labels.length === 0 && ( - - No labels yet - - )} - {ctx.labels.map(label => { - const assigned = ctx.assignedLabelIds.includes(label.id); - return ( - { e.preventDefault(); ctx.toggleLabel(label.id); }}> - - {label.name} - {assigned && } + + {creating ? ( + { + await ctx.createAndAssignLabel(name, color); + setCreating(false); + }} + onCancel={() => setCreating(false)} + /> + ) : ( + <> + {ctx.labels.length === 0 && ( + + No labels yet + + )} + {ctx.labels.map(label => { + const assigned = ctx.assignedLabelIds.includes(label.id); + return ( + { e.preventDefault(); ctx.toggleLabel(label.id); }}> + + {label.name} + {assigned && } + + ); + })} + + {ctx.labels.length < MAX_LABELS_PER_NODE && ( + { e.preventDefault(); setCreating(true); }}> + + New label + + )} + + Manage labels... - ); - })} - - - Manage labels... - + + )} ); diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index ffe993bc..fb6d4fcb 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -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; openLabelManager: () => void; } diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index 4289103e..92c10681 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -42,14 +42,16 @@ const DropdownMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + + + )) DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 53cf3e1e..00d94532 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -27,6 +27,7 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { pin: vi.fn(), unpin: vi.fn(), toggleLabel: vi.fn(), + createAndAssignLabel: vi.fn(), openLabelManager: vi.fn(), ...overrides, };