mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
chore(sidebar): document labels menu invariants and remove dead code (#707)
Document the hidden constraints that PR #706 left in the codebase so they are not accidentally removed in a future cleanup pass: - DropdownMenuSubContent and ContextMenuSubContent must stay Portal-wrapped so sub-menus escape ancestors with overflow-x-hidden. - refreshLabels must stay stable via useCallback because it is captured by buildMenuCtx's memoization and passed as a prop. Also delete LabelAssignPopover.tsx, which had zero consumers after the create-and-assign flow moved into the menu-layer inline form.
This commit is contained in:
@@ -597,6 +597,7 @@ export default function EditorLayout() {
|
||||
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
|
||||
};
|
||||
|
||||
// Stable identity required: captured by buildMenuCtx's memoization and passed as a prop; unstable refs cause descendant re-render churn.
|
||||
const refreshLabels = useCallback(async () => {
|
||||
if (!isPaid) return;
|
||||
try {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Plus } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot } from './LabelPill';
|
||||
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from './label-types';
|
||||
|
||||
interface LabelAssignPopoverProps {
|
||||
stackName: string;
|
||||
allLabels: Label[];
|
||||
assignedLabelIds: number[];
|
||||
onLabelsChanged: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onLabelsChanged, children }: LabelAssignPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newColor, setNewColor] = useState<LabelColor>('teal');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const toggleLabel = async (labelId: number) => {
|
||||
const current = new Set(assignedLabelIds);
|
||||
if (current.has(labelId)) {
|
||||
current.delete(labelId);
|
||||
} else {
|
||||
current.add(labelId);
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/labels`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ labelIds: Array.from(current) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Failed to update labels.');
|
||||
}
|
||||
onLabelsChanged();
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Failed to update labels.');
|
||||
}
|
||||
};
|
||||
|
||||
const createAndAssign = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/labels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newName.trim(), color: newColor }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Failed to create label.');
|
||||
}
|
||||
const label: Label = await res.json();
|
||||
const newIds = [...assignedLabelIds, label.id];
|
||||
const assignRes = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/labels`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ labelIds: newIds }),
|
||||
});
|
||||
if (!assignRes.ok) {
|
||||
const data = await assignRes.json().catch(() => ({}));
|
||||
throw new Error(data?.error || 'Failed to assign label.');
|
||||
}
|
||||
onLabelsChanged();
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setNewColor('teal');
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Failed to create label.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-2 backdrop-blur-[10px] backdrop-saturate-[1.15]"
|
||||
align="start"
|
||||
>
|
||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Labels</div>
|
||||
<ScrollArea className="max-h-[200px]">
|
||||
<div>
|
||||
{allLabels.map(label => (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 text-left font-mono text-[12px] truncate">{label.name}</span>
|
||||
{assignedLabelIds.includes(label.id) && (
|
||||
<Check className="w-3.5 h-3.5 text-success shrink-0" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{allLabels.length === 0 && !creating && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-2">No labels yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{creating ? (
|
||||
<div className="border-t border-border mt-1 pt-2 px-1 space-y-2">
|
||||
<Input
|
||||
placeholder="Label name"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
className="h-7 text-xs font-mono"
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
onKeyDown={e => { if (e.key === 'Enter') createAndAssign(); if (e.key === 'Escape') setCreating(false); }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{LABEL_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`w-5 h-5 rounded-full border-2 transition-colors ${c === newColor ? 'border-foreground' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: `var(--label-${c})` }}
|
||||
onClick={() => setNewColor(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" className="h-6 text-xs flex-1" onClick={createAndAssign} disabled={saving || !newName.trim()}>
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => setCreating(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : allLabels.length < MAX_LABELS_PER_NODE ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-accent/50 transition-colors mt-1 border-t border-border pt-2 cursor-pointer"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Create new label
|
||||
</button>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
// Portal-wrapped so sub-menus escape ancestors with overflow-x-hidden. Keep the Portal.
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
|
||||
@@ -38,6 +38,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
// Portal-wrapped so sub-menus escape ancestors with overflow-x-hidden (e.g. the sidebar kebab container). Keep the Portal.
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
|
||||
Reference in New Issue
Block a user