mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
28e7be652c
* feat(labels): add stack_labels schema and DatabaseService CRUD methods
* feat(labels): add label CRUD, assignment, and bulk action API routes
* feat(labels): add oklch label color palette for light and dark themes
* feat(labels): add LabelPill and LabelDot reusable components
* feat(labels): add LabelAssignPopover component for inline label management
* feat(labels): add label pill bar, label dots, and label assignment to sidebar
* feat(labels): add label filtering and label dots to fleet view
* feat(labels): add label-scoped bulk actions (deploy/stop/restart all)
* docs: add Stack Labels feature documentation
* fix(labels): use context menu sub-menu for label assignment and add settings integration
Replace broken Popover-inside-ContextMenu pattern with native Radix
ContextMenuSub for reliable label toggling on right-click. Wrap
ContextMenuSubContent in a Portal to prevent overflow clipping. Add
"Manage labels..." item that opens Settings directly to Labels section.
Fix close button overlap in LabelsSection header. Add LabelsSection
settings component with full CRUD, assignment counts, and ProGate.
Add initialSection prop to SettingsModal for deep-linking. Include
screenshots for documentation.
* docs: update stack labels documentation with screenshots and corrected instructions
* fix(labels): address security and quality issues from code review
- Add NaN validation on parseInt(req.params.id) in label routes
- Scope updateLabel/deleteLabel by nodeId to prevent cross-node IDOR
- Validate labelIds belong to correct node in setStackLabels
- Add requireAdmin check on bulk action endpoint
- Replace error: any with error: unknown and proper narrowing
- Remove unused Label import from index.ts
- Remove unused isPro prop from LabelsSection
- Add strokeWidth={1.5} to Check icons per design system
* chore: update CHANGELOG with stack labels feature
201 lines
7.4 KiB
TypeScript
201 lines
7.4 KiB
TypeScript
import * as React from "react"
|
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const ContextMenu = ContextMenuPrimitive.Root
|
|
|
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
|
|
|
const ContextMenuGroup = ContextMenuPrimitive.Group
|
|
|
|
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
|
|
|
const ContextMenuSub = ContextMenuPrimitive.Sub
|
|
|
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
|
|
|
const ContextMenuSubTrigger = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
inset?: boolean
|
|
}
|
|
>(({ className, inset, children, ...props }, ref) => (
|
|
<ContextMenuPrimitive.SubTrigger
|
|
ref={ref}
|
|
className={cn(
|
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
|
inset && "pl-8",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
<ChevronRight className="ml-auto h-4 w-4" />
|
|
</ContextMenuPrimitive.SubTrigger>
|
|
))
|
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
|
|
|
const ContextMenuSubContent = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
|
>(({ className, ...props }, ref) => (
|
|
<ContextMenuPrimitive.Portal>
|
|
<ContextMenuPrimitive.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-context-menu-content-transform-origin]",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
</ContextMenuPrimitive.Portal>
|
|
))
|
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
|
|
|
const ContextMenuContent = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
|
>(({ className, ...props }, ref) => (
|
|
<ContextMenuPrimitive.Portal>
|
|
<ContextMenuPrimitive.Content
|
|
ref={ref}
|
|
className={cn(
|
|
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-md 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-context-menu-content-transform-origin]",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
</ContextMenuPrimitive.Portal>
|
|
))
|
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
|
|
|
const ContextMenuItem = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
|
inset?: boolean
|
|
}
|
|
>(({ className, inset, ...props }, ref) => (
|
|
<ContextMenuPrimitive.Item
|
|
ref={ref}
|
|
className={cn(
|
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
inset && "pl-8",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
))
|
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
|
|
|
const ContextMenuCheckboxItem = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
|
>(({ className, children, checked, ...props }, ref) => (
|
|
<ContextMenuPrimitive.CheckboxItem
|
|
ref={ref}
|
|
className={cn(
|
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
className
|
|
)}
|
|
checked={checked}
|
|
{...props}
|
|
>
|
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
<ContextMenuPrimitive.ItemIndicator>
|
|
<Check className="h-4 w-4" />
|
|
</ContextMenuPrimitive.ItemIndicator>
|
|
</span>
|
|
{children}
|
|
</ContextMenuPrimitive.CheckboxItem>
|
|
))
|
|
ContextMenuCheckboxItem.displayName =
|
|
ContextMenuPrimitive.CheckboxItem.displayName
|
|
|
|
const ContextMenuRadioItem = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
|
>(({ className, children, ...props }, ref) => (
|
|
<ContextMenuPrimitive.RadioItem
|
|
ref={ref}
|
|
className={cn(
|
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
<ContextMenuPrimitive.ItemIndicator>
|
|
<Circle className="h-4 w-4 fill-current" />
|
|
</ContextMenuPrimitive.ItemIndicator>
|
|
</span>
|
|
{children}
|
|
</ContextMenuPrimitive.RadioItem>
|
|
))
|
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
|
|
|
const ContextMenuLabel = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
|
inset?: boolean
|
|
}
|
|
>(({ className, inset, ...props }, ref) => (
|
|
<ContextMenuPrimitive.Label
|
|
ref={ref}
|
|
className={cn(
|
|
"px-2 py-1.5 text-sm font-medium text-foreground",
|
|
inset && "pl-8",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
))
|
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
|
|
|
const ContextMenuSeparator = React.forwardRef<
|
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
|
>(({ className, ...props }, ref) => (
|
|
<ContextMenuPrimitive.Separator
|
|
ref={ref}
|
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
{...props}
|
|
/>
|
|
))
|
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
|
|
|
const ContextMenuShortcut = ({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
|
|
|
export {
|
|
ContextMenu,
|
|
ContextMenuTrigger,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuCheckboxItem,
|
|
ContextMenuRadioItem,
|
|
ContextMenuLabel,
|
|
ContextMenuSeparator,
|
|
ContextMenuShortcut,
|
|
ContextMenuGroup,
|
|
ContextMenuPortal,
|
|
ContextMenuSub,
|
|
ContextMenuSubContent,
|
|
ContextMenuSubTrigger,
|
|
ContextMenuRadioGroup,
|
|
}
|