feat(web): фильтры VPS в стиле reui.io + ReUI-компоненты в форме
- Регистр @reui в components.json (URL https://reui.io/r/{style}/{name}.json) - Установлены @reui/filters, @reui/autocomplete, @reui/number-field, @reui/date-selector и shadcn slider через shadcn CLI - Поправлены TS-импорты в ReUI-исходниках (type-only) и удалены неиспользуемые объявления; в packages/ui добавлены react-day-picker и date-fns для calendar - Новый VpsFiltersToolbar: тулбар с поиском, кнопкой «Фильтр» (ReUI Filters), chips с операторами, Popover «Вид» (группировка/компактность/пресеты) - vps-filters.tsx переписан под массивы значений (мультивыбор) с поддержкой операторов is_any_of / contains / gte; applyVpsFilters принимает как state, так и Filter[] (stateToActiveFilters) - В форме VPS vcpu/ramGb/diskGb/monthlyRate/dailyRate — @reui/number-field через Controller, paidUntil — @reui/date-selector (одиночная дата) - AutoCompleteInput: отступы флага size-4 leading-none, CommandList py-1, CommandItem gap-2.5, PopoverContent min-w-[220px] - build + tsc --noEmit без ошибок Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -10,6 +10,9 @@
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
|
||||
@@ -19,10 +19,13 @@
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
|
||||
@@ -89,7 +89,7 @@ export function AutoCompleteInput({
|
||||
className={cn('w-full justify-between font-normal', className)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{leading ? <span className="text-base leading-none">{leading}</span> : null}
|
||||
{leading ? <span className="size-4 shrink-0 leading-none">{leading}</span> : null}
|
||||
<span className={cn('truncate', !value && 'text-muted-foreground')}>
|
||||
{value ? displayLabel : placeholder}
|
||||
</span>
|
||||
@@ -98,7 +98,7 @@ export function AutoCompleteInput({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-[--anchor-width] p-0">
|
||||
<PopoverContent align="start" className="w-[--anchor-width] min-w-[220px] p-0">
|
||||
<Command shouldFilter={false} loop>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
@@ -108,7 +108,7 @@ export function AutoCompleteInput({
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandList className="py-1">
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allowFreeText && query.trim() && !filtered.some(
|
||||
@@ -117,7 +117,7 @@ export function AutoCompleteInput({
|
||||
<CommandItem
|
||||
value={`__free__:${query.trim()}`}
|
||||
onSelect={() => handleSelect(query.trim())}
|
||||
className="gap-2"
|
||||
className="gap-2.5"
|
||||
>
|
||||
<SearchIcon className="size-4 opacity-50" />
|
||||
<span className="flex-1 truncate">
|
||||
@@ -132,10 +132,10 @@ export function AutoCompleteInput({
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
onSelect={() => handleSelect(opt.value)}
|
||||
className="gap-2"
|
||||
className="gap-2.5"
|
||||
>
|
||||
{opt.leading ? (
|
||||
<span className="text-base leading-none">{opt.leading}</span>
|
||||
<span className="size-4 shrink-0 leading-none">{opt.leading}</span>
|
||||
) : null}
|
||||
<span className="flex-1">{opt.label}</span>
|
||||
{isSelected ? <CheckIcon className="size-4 opacity-60" /> : null}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client"
|
||||
|
||||
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { ScrollArea } from "@cfdm/ui/components/scroll-area"
|
||||
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
|
||||
const inputVariants = cva(
|
||||
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||
default:
|
||||
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Autocomplete = AutocompletePrimitive.Root
|
||||
|
||||
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteInput({
|
||||
className,
|
||||
size = "default",
|
||||
showClear = false,
|
||||
showTrigger = false,
|
||||
...props
|
||||
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||
VariantProps<typeof inputVariants> & {
|
||||
showClear?: boolean
|
||||
showTrigger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<AutocompletePrimitive.Input
|
||||
data-slot="autocomplete-input"
|
||||
data-size={size}
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
{showTrigger && <AutocompleteTrigger />}
|
||||
{showClear && <AutocompleteClear />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteStatus({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Status.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Status
|
||||
data-slot="autocomplete-status"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteBackdrop({
|
||||
...props
|
||||
}: AutocompletePrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Backdrop
|
||||
data-slot="autocomplete-backdrop"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePositioner({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Positioner.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Positioner
|
||||
data-slot="autocomplete-positioner"
|
||||
className={cn("z-50 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteList({
|
||||
className,
|
||||
scrollAreaClassName,
|
||||
...props
|
||||
}: AutocompletePrimitive.List.Props & {
|
||||
scrollAreaClassName?: string
|
||||
scrollFade?: boolean
|
||||
scrollbarGutter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||
scrollAreaClassName
|
||||
)}
|
||||
>
|
||||
<AutocompletePrimitive.List
|
||||
data-slot="autocomplete-list"
|
||||
className={cn(
|
||||
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteCollection({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Collection
|
||||
data-slot="autocomplete-collection"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Row
|
||||
data-slot="autocomplete-row"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Item
|
||||
data-slot="autocomplete-item"
|
||||
className={cn(
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||
typeof AutocompletePrimitive.Popup
|
||||
> {
|
||||
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||
showBackdrop?: boolean
|
||||
}
|
||||
|
||||
function AutocompleteContent({
|
||||
className,
|
||||
children,
|
||||
showBackdrop = false,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
anchor,
|
||||
...props
|
||||
}: AutocompleteContentProps) {
|
||||
return (
|
||||
<AutocompletePortal>
|
||||
{showBackdrop && <AutocompleteBackdrop />}
|
||||
<AutocompletePositioner
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
anchor={anchor}
|
||||
>
|
||||
<div className="relative flex max-h-full">
|
||||
<AutocompletePrimitive.Popup
|
||||
data-slot="autocomplete-popup"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AutocompletePrimitive.Popup>
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||
return (
|
||||
<AutocompletePrimitive.GroupLabel
|
||||
data-slot="autocomplete-group-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Empty
|
||||
data-slot="autocomplete-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteClear({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Clear
|
||||
data-slot="autocomplete-clear"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</AutocompletePrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Trigger
|
||||
data-slot="autocomplete-trigger"
|
||||
className={cn(
|
||||
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||
</AutocompletePrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteArrow({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Separator
|
||||
data-slot="autocomplete-separator"
|
||||
className={cn(
|
||||
"bg-border my-1.5 h-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Autocomplete,
|
||||
AutocompleteValue,
|
||||
AutocompleteTrigger,
|
||||
AutocompleteInput,
|
||||
AutocompleteStatus,
|
||||
AutocompletePortal,
|
||||
AutocompleteBackdrop,
|
||||
AutocompletePositioner,
|
||||
AutocompleteContent,
|
||||
AutocompleteList,
|
||||
AutocompleteCollection,
|
||||
AutocompleteRow,
|
||||
AutocompleteItem,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteClear,
|
||||
AutocompleteArrow,
|
||||
AutocompleteSeparator,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
import { createContext, type ReactNode, useContext, useId } from "react"
|
||||
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Label } from "@cfdm/ui/components/label"
|
||||
import { MinusIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
const NumberFieldContext = createContext<{
|
||||
fieldId: string
|
||||
size: "sm" | "default" | "lg"
|
||||
} | null>(null)
|
||||
|
||||
const numberFieldGroupVariants = cva(
|
||||
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 text-sm",
|
||||
default:
|
||||
"h-8 text-sm",
|
||||
lg: "h-9 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const numberFieldButtonVariants = cva(
|
||||
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
default:
|
||||
"px-2 [&_svg:not([class*='size-'])]:size-4",
|
||||
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const numberFieldInputVariants = cva(
|
||||
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "px-2 py-0.5",
|
||||
default:
|
||||
"px-2.5 py-1",
|
||||
lg: "px-2.5 py-1.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function NumberField({
|
||||
id,
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: NumberFieldPrimitive.Root.Props &
|
||||
VariantProps<typeof numberFieldGroupVariants>) {
|
||||
const generatedId = useId()
|
||||
const fieldId = id ?? generatedId
|
||||
const sizeValue = size ?? "default"
|
||||
|
||||
return (
|
||||
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
|
||||
<NumberFieldPrimitive.Root
|
||||
className={cn("flex w-full flex-col items-start gap-2", className)}
|
||||
data-size={sizeValue}
|
||||
data-slot="number-field"
|
||||
id={fieldId}
|
||||
{...props}
|
||||
/>
|
||||
</NumberFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldGroup({
|
||||
className,
|
||||
size: sizeProp,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Group.Props &
|
||||
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldGroup must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Group
|
||||
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||
data-slot="number-field-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldDecrement({
|
||||
className,
|
||||
size: sizeProp,
|
||||
children,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Decrement.Props &
|
||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldDecrement must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Decrement
|
||||
className={cn(
|
||||
numberFieldButtonVariants({ size }),
|
||||
"rounded-s-lg border-e-0",
|
||||
className
|
||||
)}
|
||||
data-slot="number-field-decrement"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<MinusIcon
|
||||
/>
|
||||
)}
|
||||
</NumberFieldPrimitive.Decrement>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldIncrement({
|
||||
className,
|
||||
size: sizeProp,
|
||||
children,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Increment.Props &
|
||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldIncrement must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Increment
|
||||
className={cn(
|
||||
numberFieldButtonVariants({ size }),
|
||||
"rounded-e-lg border-s-0",
|
||||
className
|
||||
)}
|
||||
data-slot="number-field-increment"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<PlusIcon
|
||||
/>
|
||||
)}
|
||||
</NumberFieldPrimitive.Increment>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldInput({
|
||||
className,
|
||||
size: sizeProp,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Input.Props &
|
||||
Partial<VariantProps<typeof numberFieldInputVariants>>) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldInput must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Input
|
||||
className={cn(numberFieldInputVariants({ size }), className)}
|
||||
data-slot="number-field-input"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldScrubArea({
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: NumberFieldPrimitive.ScrubArea.Props & {
|
||||
label: string
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.ScrubArea
|
||||
className={cn("flex cursor-ew-resize", className)}
|
||||
data-slot="number-field-scrub-area"
|
||||
{...props}
|
||||
>
|
||||
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
|
||||
{label}
|
||||
</Label>
|
||||
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
|
||||
<CursorGrowIcon />
|
||||
</NumberFieldPrimitive.ScrubAreaCursor>
|
||||
</NumberFieldPrimitive.ScrubArea>
|
||||
)
|
||||
}
|
||||
|
||||
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
fill="black"
|
||||
height="14"
|
||||
stroke="white"
|
||||
viewBox="0 0 24 14"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NumberField,
|
||||
NumberFieldScrubArea,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldInput,
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { SearchIcon, SlidersHorizontalIcon, SaveIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@cfdm/ui/components/popover'
|
||||
import { Slider } from '@cfdm/ui/components/slider'
|
||||
|
||||
import {
|
||||
Filters,
|
||||
createFilter,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
type FilterI18nConfig,
|
||||
type FilterOption,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
type VpsFiltersState,
|
||||
buildDefaultVpsFilters,
|
||||
stateToActiveFilters,
|
||||
loadFilterPresets,
|
||||
saveFilterPresets,
|
||||
type VpsFilterPreset,
|
||||
} from '@/components/vps-filters'
|
||||
import { vpsStatusLabel, tariffTypeLabel, environmentLabel, getCountryFlagEmojiByCode } from '@/lib/format'
|
||||
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||
|
||||
interface VpsFiltersToolbarProps {
|
||||
filters: VpsFiltersState
|
||||
onChange: (next: VpsFiltersState) => void
|
||||
providers: Provider[]
|
||||
providerAccounts: ProviderAccount[]
|
||||
vps: Vps[]
|
||||
countryOptions: { value: string; label: string; code?: string }[]
|
||||
cityOptions: { value: string; label: string }[]
|
||||
projectNameOptions: string[]
|
||||
}
|
||||
|
||||
const RU_I18N: FilterI18nConfig = {
|
||||
addFilter: 'Фильтр',
|
||||
searchFields: 'Поиск поля…',
|
||||
noFieldsFound: 'Поля не найдены.',
|
||||
noResultsFound: 'Нет вариантов',
|
||||
select: 'Выбрать…',
|
||||
true: 'Да',
|
||||
false: 'Нет',
|
||||
min: 'Мин',
|
||||
max: 'Макс',
|
||||
to: 'до',
|
||||
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||
selected: 'выбрано',
|
||||
selectedCount: 'выбрано',
|
||||
percent: '%',
|
||||
defaultCurrency: '₽',
|
||||
defaultColor: '#000000',
|
||||
addFilterTitle: 'Добавить фильтр',
|
||||
operators: {
|
||||
is: '=',
|
||||
isNot: '≠',
|
||||
isAnyOf: 'любое из',
|
||||
isNotAnyOf: 'не любое из',
|
||||
includesAll: 'включает все',
|
||||
excludesAll: 'исключает все',
|
||||
before: 'до',
|
||||
after: 'после',
|
||||
between: 'между',
|
||||
notBetween: 'не между',
|
||||
contains: 'содержит',
|
||||
notContains: 'не содержит',
|
||||
startsWith: 'начинается с',
|
||||
endsWith: 'заканчивается на',
|
||||
isExactly: 'точно',
|
||||
equals: '=',
|
||||
notEquals: '≠',
|
||||
greaterThan: '>',
|
||||
lessThan: '<',
|
||||
overlaps: 'пересекается',
|
||||
includes: 'включает',
|
||||
excludes: 'исключает',
|
||||
includesAllOf: 'включает все из',
|
||||
includesAnyOf: 'включает любое из',
|
||||
empty: 'пусто',
|
||||
notEmpty: 'не пусто',
|
||||
},
|
||||
placeholders: {
|
||||
enterField: (t) => `Введите ${t}…`,
|
||||
selectField: 'Выбрать…',
|
||||
searchField: (n) => `Поиск: ${n.toLowerCase()}…`,
|
||||
enterKey: 'Введите ключ…',
|
||||
enterValue: 'Введите значение…',
|
||||
},
|
||||
helpers: {
|
||||
formatOperator: (op) => op.replace(/_/g, ' '),
|
||||
},
|
||||
validation: {
|
||||
invalidEmail: 'Некорректный email',
|
||||
invalidUrl: 'Некорректный URL',
|
||||
invalidTel: 'Некорректный телефон',
|
||||
invalid: 'Некорректный формат',
|
||||
},
|
||||
}
|
||||
|
||||
/** Конвертация VpsFiltersState → Filter[] для ReUI Filters. */
|
||||
function stateToFilters(state: VpsFiltersState): (Filter<string> | Filter<number>)[] {
|
||||
const out: (Filter<string> | Filter<number>)[] = []
|
||||
if (state.providerId.length) out.push(createFilter<string>('providerId', 'is_any_of', state.providerId))
|
||||
if (state.providerAccountId.length) out.push(createFilter<string>('providerAccountId', 'is_any_of', state.providerAccountId))
|
||||
if (state.country.length) out.push(createFilter<string>('country', 'is_any_of', state.country))
|
||||
if (state.city.length) out.push(createFilter<string>('city', 'is_any_of', state.city))
|
||||
if (state.datacenter) out.push(createFilter<string>('datacenter', 'contains', [state.datacenter]))
|
||||
if (state.status.length) out.push(createFilter<string>('status', 'is_any_of', state.status))
|
||||
if (state.environment.length) out.push(createFilter<string>('environment', 'is_any_of', state.environment))
|
||||
if (state.tariffType.length) out.push(createFilter<string>('tariffType', 'is_any_of', state.tariffType))
|
||||
if (state.monitoring.length) out.push(createFilter<string>('monitoring', 'is_any_of', state.monitoring))
|
||||
if (state.backup.length) out.push(createFilter<string>('backup', 'is_any_of', state.backup))
|
||||
if (state.project.length) out.push(createFilter<string>('project', 'is_any_of', state.project))
|
||||
if (state.minVcpu != null) out.push(createFilter<number>('minVcpu', 'is', [state.minVcpu]))
|
||||
if (state.minRamGb != null) out.push(createFilter<number>('minRamGb', 'is', [state.minRamGb]))
|
||||
if (state.minDiskGb != null) out.push(createFilter<number>('minDiskGb', 'is', [state.minDiskGb]))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Конвертация Filter[] → VpsFiltersState (merge с default). */
|
||||
function filtersToState(filters: Filter[], base: VpsFiltersState): VpsFiltersState {
|
||||
const next = buildDefaultVpsFilters()
|
||||
next.search = base.search
|
||||
next.groupByProject = base.groupByProject
|
||||
next.tableCompact = base.tableCompact
|
||||
for (const f of filters) {
|
||||
switch (f.field) {
|
||||
case 'providerId': next.providerId = f.values as string[]; break
|
||||
case 'providerAccountId': next.providerAccountId = f.values as string[]; break
|
||||
case 'country': next.country = f.values as string[]; break
|
||||
case 'city': next.city = f.values as string[]; break
|
||||
case 'datacenter': next.datacenter = (f.values[0] as string) ?? ''; break
|
||||
case 'status': next.status = f.values as string[]; break
|
||||
case 'environment': next.environment = f.values as string[]; break
|
||||
case 'tariffType': next.tariffType = f.values as string[]; break
|
||||
case 'monitoring': next.monitoring = f.values as string[]; break
|
||||
case 'backup': next.backup = f.values as string[]; break
|
||||
case 'project': next.project = f.values as string[]; break
|
||||
case 'minVcpu': next.minVcpu = (f.values[0] as number) ?? null; break
|
||||
case 'minRamGb': next.minRamGb = (f.values[0] as number) ?? null; break
|
||||
case 'minDiskGb': next.minDiskGb = (f.values[0] as number) ?? null; break
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function VpsFiltersToolbar({
|
||||
filters,
|
||||
onChange,
|
||||
providers,
|
||||
providerAccounts,
|
||||
vps,
|
||||
countryOptions,
|
||||
cityOptions,
|
||||
projectNameOptions,
|
||||
}: VpsFiltersToolbarProps) {
|
||||
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
|
||||
|
||||
const reuiFilters = useMemo<(Filter<string> | Filter<number>)[]>(() => stateToFilters(filters), [filters])
|
||||
|
||||
const fields = useMemo<(FilterFieldConfig<string> | FilterFieldConfig<number>)[]>(() => {
|
||||
const count = (pred: (v: Vps) => boolean) => vps.filter(pred).length
|
||||
|
||||
const providerOpts: FilterOption<string>[] = providers.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
metadata: { count: count((v) => v.providerId === p.id) },
|
||||
}))
|
||||
|
||||
const accountOpts: FilterOption<string>[] = providerAccounts.map((a) => ({
|
||||
value: a.id,
|
||||
label: a.name,
|
||||
metadata: { count: count((v) => v.providerAccountId === a.id) },
|
||||
}))
|
||||
|
||||
const countryOpts: FilterOption<string>[] = countryOptions.map((c) => ({
|
||||
value: c.value,
|
||||
label: c.label,
|
||||
icon: c.code ? <span className="size-4 leading-none">{getCountryFlagEmojiByCode(c.code)}</span> : undefined,
|
||||
metadata: { count: count((v) => (v.country ?? '').trim() === c.value) },
|
||||
}))
|
||||
|
||||
const cityOpts: FilterOption<string>[] = cityOptions.map((c) => ({
|
||||
value: c.value,
|
||||
label: c.label,
|
||||
metadata: { count: count((v) => (v.city ?? '').trim() === c.value) },
|
||||
}))
|
||||
|
||||
const projectOpts: FilterOption<string>[] = [
|
||||
{ value: '__none__', label: 'Без проекта', metadata: { count: count((v) => !(v.project ?? '').trim()) } },
|
||||
...projectNameOptions.map((p) => ({
|
||||
value: p,
|
||||
label: p,
|
||||
metadata: { count: count((v) => (v.project ?? '').trim() === p) },
|
||||
})),
|
||||
]
|
||||
|
||||
const statusOpts: FilterOption<string>[] = [
|
||||
{ value: 'active', label: vpsStatusLabel('active') },
|
||||
{ value: 'paused', label: vpsStatusLabel('paused') },
|
||||
{ value: 'archived', label: vpsStatusLabel('archived') },
|
||||
]
|
||||
|
||||
const envOpts: FilterOption<string>[] = [
|
||||
{ value: 'prod', label: environmentLabel('prod') },
|
||||
{ value: 'dev', label: environmentLabel('dev') },
|
||||
{ value: 'staging', label: environmentLabel('staging') },
|
||||
]
|
||||
|
||||
const tariffOpts: FilterOption<string>[] = [
|
||||
{ value: 'monthly', label: tariffTypeLabel('monthly') },
|
||||
{ value: 'daily', label: tariffTypeLabel('daily') },
|
||||
]
|
||||
|
||||
const onOffOpts: FilterOption<string>[] = [
|
||||
{ value: 'on', label: 'Включено' },
|
||||
{ value: 'off', label: 'Выключено' },
|
||||
]
|
||||
|
||||
return [
|
||||
{ key: 'providerId', label: 'Хостер', type: 'multiselect' as const, options: providerOpts, searchable: true, defaultOperator: 'is_any_of' },
|
||||
{ key: 'providerAccountId', label: 'Аккаунт', type: 'multiselect' as const, options: accountOpts, searchable: true, defaultOperator: 'is_any_of' },
|
||||
{ key: 'country', label: 'Страна', type: 'multiselect' as const, options: countryOpts, searchable: true, defaultOperator: 'is_any_of' },
|
||||
{ key: 'city', label: 'Город', type: 'multiselect' as const, options: cityOpts, searchable: true, defaultOperator: 'is_any_of' },
|
||||
{ key: 'datacenter', label: 'Дата-центр', type: 'text' as const, placeholder: 'Напр. Frankfurt', defaultOperator: 'contains' },
|
||||
{ key: 'status', label: 'Статус', type: 'multiselect' as const, options: statusOpts, defaultOperator: 'is_any_of' },
|
||||
{ key: 'environment', label: 'Окружение', type: 'multiselect' as const, options: envOpts, defaultOperator: 'is_any_of' },
|
||||
{ key: 'tariffType', label: 'Тариф', type: 'multiselect' as const, options: tariffOpts, defaultOperator: 'is_any_of' },
|
||||
{ key: 'monitoring', label: 'Мониторинг', type: 'multiselect' as const, options: onOffOpts, defaultOperator: 'is_any_of' },
|
||||
{ key: 'backup', label: 'Бэкап', type: 'multiselect' as const, options: onOffOpts, defaultOperator: 'is_any_of' },
|
||||
{ key: 'project', label: 'Проект', type: 'multiselect' as const, options: projectOpts, searchable: true, defaultOperator: 'is_any_of' },
|
||||
{
|
||||
key: 'minVcpu',
|
||||
label: 'vCPU ≥',
|
||||
type: 'custom' as const,
|
||||
defaultOperator: 'is',
|
||||
operators: [{ value: 'is', label: '≥' }],
|
||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
||||
<Slider
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
value={values[0] ?? 0}
|
||||
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-8 text-sm tabular-nums text-end">{values[0] ?? 0}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'minRamGb',
|
||||
label: 'RAM ≥',
|
||||
type: 'custom' as const,
|
||||
defaultOperator: 'is',
|
||||
operators: [{ value: 'is', label: '≥' }],
|
||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
||||
<Slider
|
||||
min={0}
|
||||
max={256}
|
||||
step={1}
|
||||
value={values[0] ?? 0}
|
||||
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-10 text-sm tabular-nums text-end">{values[0] ?? 0} GB</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'minDiskGb',
|
||||
label: 'Disk ≥',
|
||||
type: 'custom' as const,
|
||||
defaultOperator: 'is',
|
||||
operators: [{ value: 'is', label: '≥' }],
|
||||
customRenderer: ({ values, onChange: onCh }: { values: number[]; onChange: (v: number[]) => void; operator: string }) => (
|
||||
<div className="flex items-center gap-2 px-2 py-1 w-44">
|
||||
<Slider
|
||||
min={0}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={values[0] ?? 0}
|
||||
onValueChange={(v) => onCh([typeof v === 'number' ? v : (v[0] ?? 0)])}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-14 text-sm tabular-nums text-end">{values[0] ?? 0} GB</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [providers, providerAccounts, vps, countryOptions, cityOptions, projectNameOptions])
|
||||
|
||||
const handleFiltersChange = (next: Filter[]) => {
|
||||
onChange(filtersToState(next, filters))
|
||||
}
|
||||
|
||||
const hasActive = reuiFilters.length > 0 || filters.search || filters.groupByProject || filters.tableCompact
|
||||
|
||||
const savePreset = () => {
|
||||
const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`)
|
||||
if (!name) return
|
||||
const next = [...presets.filter((p) => p.name !== name), { name, filters }]
|
||||
setPresets(next)
|
||||
saveFilterPresets(next)
|
||||
}
|
||||
|
||||
const applyPreset = (preset: VpsFilterPreset) => {
|
||||
onChange({ ...buildDefaultVpsFilters(), ...preset.filters })
|
||||
}
|
||||
|
||||
const deletePreset = (name: string) => {
|
||||
const next = presets.filter((p) => p.name !== name)
|
||||
setPresets(next)
|
||||
saveFilterPresets(next)
|
||||
}
|
||||
|
||||
const reset = () => onChange(buildDefaultVpsFilters())
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Поиск: IP, DNS, проект, назначение, ОС"
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Filters
|
||||
filters={reuiFilters as unknown as Filter[]}
|
||||
fields={fields as unknown as FilterFieldConfig[]}
|
||||
onChange={handleFiltersChange}
|
||||
i18n={RU_I18N}
|
||||
size="sm"
|
||||
allowMultiple={false}
|
||||
/>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<SlidersHorizontalIcon data-icon="inline-start" />
|
||||
Вид
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-64 p-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs text-muted-foreground">Отображение</Label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={filters.groupByProject}
|
||||
onCheckedChange={(v) => onChange({ ...filters, groupByProject: Boolean(v) })}
|
||||
/>
|
||||
<span className="text-sm">Группировать по проекту</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={filters.tableCompact}
|
||||
onCheckedChange={(v) => onChange({ ...filters, tableCompact: Boolean(v) })}
|
||||
/>
|
||||
<span className="text-sm">Компактная таблица</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Пресеты</Label>
|
||||
<Button variant="ghost" size="sm" onClick={savePreset} className="h-7 px-2">
|
||||
<SaveIcon className="size-3.5" />
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
{presets.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет сохранённых пресетов</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{presets.map((p) => (
|
||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyPreset(p)}
|
||||
className="flex-1 truncate text-start text-sm"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deletePreset(p.name)}
|
||||
aria-label="Удалить пресет"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{hasActive ? (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Обратная совместимость — не используется напрямую, но экспортируем
|
||||
export { stateToActiveFilters }
|
||||
@@ -1,40 +1,27 @@
|
||||
import { useMemo } from 'react'
|
||||
import { SearchIcon, FilterIcon, XIcon, SaveIcon, TrashIcon } from 'lucide-react'
|
||||
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { AutoCompleteInput, type AutoCompleteOption } from '@/components/auto-complete-input'
|
||||
import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format'
|
||||
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||
import type { Vps } from '@/types/entities'
|
||||
|
||||
/**
|
||||
* Состояние фильтров VPS.
|
||||
* Multiselect-поля (status, environment, tariffType, monitoring, backup,
|
||||
* providerId, providerAccountId, project, country, city) — массивы строк.
|
||||
* Пустой массив = «все».
|
||||
*/
|
||||
export interface VpsFiltersState {
|
||||
search: string
|
||||
providerId: string
|
||||
providerAccountId: string
|
||||
country: string
|
||||
city: string
|
||||
providerId: string[]
|
||||
providerAccountId: string[]
|
||||
country: string[]
|
||||
city: string[]
|
||||
datacenter: string
|
||||
status: string // 'all' | VpsStatus
|
||||
environment: string // 'all' | env
|
||||
tariffType: string // 'all' | TariffType
|
||||
monitoring: string // 'all' | 'on' | 'off'
|
||||
backup: string // 'all' | 'on' | 'off'
|
||||
minVcpu: string
|
||||
minRamGb: string
|
||||
minDiskGb: string
|
||||
project: string // '' | '__none__' | name
|
||||
status: string[]
|
||||
environment: string[]
|
||||
tariffType: string[]
|
||||
monitoring: string[]
|
||||
backup: string[]
|
||||
minVcpu: number | null
|
||||
minRamGb: number | null
|
||||
minDiskGb: number | null
|
||||
project: string[]
|
||||
groupByProject: boolean
|
||||
tableCompact: boolean
|
||||
}
|
||||
@@ -42,60 +29,220 @@ export interface VpsFiltersState {
|
||||
export function buildDefaultVpsFilters(): VpsFiltersState {
|
||||
return {
|
||||
search: '',
|
||||
providerId: '',
|
||||
providerAccountId: '',
|
||||
country: '',
|
||||
city: '',
|
||||
providerId: [],
|
||||
providerAccountId: [],
|
||||
country: [],
|
||||
city: [],
|
||||
datacenter: '',
|
||||
status: 'all',
|
||||
environment: 'all',
|
||||
tariffType: 'all',
|
||||
monitoring: 'all',
|
||||
backup: 'all',
|
||||
minVcpu: '',
|
||||
minRamGb: '',
|
||||
minDiskGb: '',
|
||||
project: '',
|
||||
status: [],
|
||||
environment: [],
|
||||
tariffType: [],
|
||||
monitoring: [],
|
||||
backup: [],
|
||||
minVcpu: null,
|
||||
minRamGb: null,
|
||||
minDiskGb: null,
|
||||
project: [],
|
||||
groupByProject: false,
|
||||
tableCompact: false,
|
||||
}
|
||||
}
|
||||
|
||||
const ALL = 'all'
|
||||
const arrOr = <T,>(v: T[] | T | undefined | null): T[] =>
|
||||
Array.isArray(v) ? v : v == null || v === '' ? [] : [v]
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: ALL, label: 'Все статусы' },
|
||||
{ value: 'active', label: vpsStatusLabel('active') },
|
||||
{ value: 'paused', label: vpsStatusLabel('paused') },
|
||||
{ value: 'archived', label: vpsStatusLabel('archived') },
|
||||
]
|
||||
const matchesAny = <T,>(item: T | undefined, values: T[]): boolean => {
|
||||
if (values.length === 0) return true
|
||||
if (item == null) return false
|
||||
return values.includes(item)
|
||||
}
|
||||
|
||||
const ENV_OPTIONS = [
|
||||
{ value: ALL, label: 'Все окружения' },
|
||||
{ value: 'prod', label: environmentLabel('prod') },
|
||||
{ value: 'dev', label: environmentLabel('dev') },
|
||||
{ value: 'staging', label: environmentLabel('staging') },
|
||||
]
|
||||
const matchesText = (
|
||||
item: string | undefined | null,
|
||||
values: string[],
|
||||
operator: string,
|
||||
): boolean => {
|
||||
if (values.length === 0) return true
|
||||
const v = (item ?? '').toLowerCase()
|
||||
// Для text-фильтра values — массив из одного элемента
|
||||
const q = (values[0] ?? '').toLowerCase()
|
||||
if (!q) return true
|
||||
switch (operator) {
|
||||
case 'not_contains':
|
||||
return !v.includes(q)
|
||||
case 'starts_with':
|
||||
return v.startsWith(q)
|
||||
case 'ends_with':
|
||||
return v.endsWith(q)
|
||||
case 'is':
|
||||
return v === q
|
||||
default:
|
||||
return v.includes(q)
|
||||
}
|
||||
}
|
||||
|
||||
const TARIFF_OPTIONS = [
|
||||
{ value: ALL, label: 'Все тарифы' },
|
||||
{ value: 'monthly', label: tariffTypeLabel('monthly') },
|
||||
{ value: 'daily', label: tariffTypeLabel('daily') },
|
||||
]
|
||||
const matchesNumberGte = (
|
||||
item: number | undefined | null,
|
||||
values: number[],
|
||||
): boolean => {
|
||||
if (values.length === 0) return true
|
||||
const threshold = values[0]
|
||||
if (threshold == null) return true
|
||||
return Number(item ?? 0) >= threshold
|
||||
}
|
||||
|
||||
const ON_OFF_OPTIONS = [
|
||||
{ value: ALL, label: 'Любое' },
|
||||
{ value: 'on', label: 'Включено' },
|
||||
{ value: 'off', label: 'Выключено' },
|
||||
]
|
||||
interface ActiveFilter {
|
||||
field: string
|
||||
operator: string
|
||||
values: unknown[]
|
||||
}
|
||||
|
||||
const PRESETS_KEY = 'vps-tracker:vps-filter-presets'
|
||||
/**
|
||||
* Применяет фильтры к списку VPS.
|
||||
* Принимает активные фильтры в формате ReUI Filters (Filter[]) —
|
||||
* массив объектов { field, operator, values }.
|
||||
*/
|
||||
export function applyVpsFilters(
|
||||
items: Vps[],
|
||||
filters: VpsFiltersState | ActiveFilter[],
|
||||
): Vps[] {
|
||||
// Если передан state — конвертируем в active filters
|
||||
const activeFilters: ActiveFilter[] = Array.isArray(filters)
|
||||
? filters
|
||||
: stateToActiveFilters(filters)
|
||||
|
||||
const search = activeFilters.find((f) => f.field === 'search')?.values?.[0] as string ?? ''
|
||||
const searchLower = search.toLowerCase()
|
||||
|
||||
return items.filter((item) => {
|
||||
if (searchLower) {
|
||||
const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : ''
|
||||
const haystack = [
|
||||
item.ip, item.dns, item.ipv6, extraIps, item.project, item.purpose, item.os,
|
||||
].map((s) => (s ?? '').toLowerCase()).join(' ')
|
||||
if (!haystack.includes(searchLower)) return false
|
||||
}
|
||||
|
||||
for (const f of activeFilters) {
|
||||
if (f.field === 'search') continue
|
||||
switch (f.field) {
|
||||
case 'providerId':
|
||||
if (!matchesAny(item.providerId, f.values as string[])) return false
|
||||
break
|
||||
case 'providerAccountId':
|
||||
if (!matchesAny(item.providerAccountId, f.values as string[])) return false
|
||||
break
|
||||
case 'country':
|
||||
if (!matchesAny((item.country ?? '').trim() || undefined, f.values as string[])) return false
|
||||
break
|
||||
case 'city':
|
||||
if (!matchesAny((item.city ?? '').trim() || undefined, f.values as string[])) return false
|
||||
break
|
||||
case 'datacenter':
|
||||
if (!matchesText(item.datacenter, f.values as string[], f.operator)) return false
|
||||
break
|
||||
case 'status':
|
||||
if (!matchesAny(item.status, f.values as string[])) return false
|
||||
break
|
||||
case 'environment':
|
||||
if (!matchesAny(item.environment, f.values as string[])) return false
|
||||
break
|
||||
case 'tariffType':
|
||||
if (!matchesAny(item.tariffType, f.values as string[])) return false
|
||||
break
|
||||
case 'monitoring': {
|
||||
if (f.values.length === 0) break
|
||||
const on = (f.values as string[]).includes('on')
|
||||
const off = (f.values as string[]).includes('off')
|
||||
const itemOn = Boolean(item.monitoringEnabled)
|
||||
if (on && itemOn) break
|
||||
if (off && !itemOn) break
|
||||
return false
|
||||
}
|
||||
case 'backup': {
|
||||
if (f.values.length === 0) break
|
||||
const on = (f.values as string[]).includes('on')
|
||||
const off = (f.values as string[]).includes('off')
|
||||
const itemOn = Boolean(item.backupEnabled)
|
||||
if (on && itemOn) break
|
||||
if (off && !itemOn) break
|
||||
return false
|
||||
}
|
||||
case 'project': {
|
||||
if (f.values.length === 0) break
|
||||
const proj = (item.project ?? '').trim()
|
||||
const wants = f.values as string[]
|
||||
const wantsNone = wants.includes('__none__')
|
||||
const wantsNamed = wants.filter((v) => v !== '__none__')
|
||||
let ok = false
|
||||
if (wantsNone && !proj) ok = true
|
||||
if (wantsNamed.length && wantsNamed.includes(proj)) ok = true
|
||||
if (!ok) return false
|
||||
break
|
||||
}
|
||||
case 'minVcpu':
|
||||
if (!matchesNumberGte(item.vcpu, f.values as number[])) return false
|
||||
break
|
||||
case 'minRamGb':
|
||||
if (!matchesNumberGte(item.ramGb, f.values as number[])) return false
|
||||
break
|
||||
case 'minDiskGb':
|
||||
if (!matchesNumberGte(item.diskGb, f.values as number[])) return false
|
||||
break
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Конвертация VpsFiltersState → ActiveFilter[] для applyVpsFilters. */
|
||||
export function stateToActiveFilters(state: VpsFiltersState): ActiveFilter[] {
|
||||
const out: ActiveFilter[] = []
|
||||
if (state.search) out.push({ field: 'search', operator: 'contains', values: [state.search] })
|
||||
if (state.providerId.length) out.push({ field: 'providerId', operator: 'is_any_of', values: state.providerId })
|
||||
if (state.providerAccountId.length) out.push({ field: 'providerAccountId', operator: 'is_any_of', values: state.providerAccountId })
|
||||
if (state.country.length) out.push({ field: 'country', operator: 'is_any_of', values: state.country })
|
||||
if (state.city.length) out.push({ field: 'city', operator: 'is_any_of', values: state.city })
|
||||
if (state.datacenter) out.push({ field: 'datacenter', operator: 'contains', values: [state.datacenter] })
|
||||
if (state.status.length) out.push({ field: 'status', operator: 'is_any_of', values: state.status })
|
||||
if (state.environment.length) out.push({ field: 'environment', operator: 'is_any_of', values: state.environment })
|
||||
if (state.tariffType.length) out.push({ field: 'tariffType', operator: 'is_any_of', values: state.tariffType })
|
||||
if (state.monitoring.length) out.push({ field: 'monitoring', operator: 'is_any_of', values: state.monitoring })
|
||||
if (state.backup.length) out.push({ field: 'backup', operator: 'is_any_of', values: state.backup })
|
||||
if (state.project.length) out.push({ field: 'project', operator: 'is_any_of', values: state.project })
|
||||
if (state.minVcpu != null) out.push({ field: 'minVcpu', operator: 'gte', values: [state.minVcpu] })
|
||||
if (state.minRamGb != null) out.push({ field: 'minRamGb', operator: 'gte', values: [state.minRamGb] })
|
||||
if (state.minDiskGb != null) out.push({ field: 'minDiskGb', operator: 'gte', values: [state.minDiskGb] })
|
||||
return out
|
||||
}
|
||||
|
||||
export function countActiveFilters(filters: VpsFiltersState): number {
|
||||
let n = 0
|
||||
if (filters.search) n++
|
||||
n += filters.providerId.length
|
||||
n += filters.providerAccountId.length
|
||||
n += filters.country.length
|
||||
n += filters.city.length
|
||||
if (filters.datacenter) n++
|
||||
n += filters.status.length
|
||||
n += filters.environment.length
|
||||
n += filters.tariffType.length
|
||||
n += filters.monitoring.length
|
||||
n += filters.backup.length
|
||||
n += filters.project.length
|
||||
if (filters.minVcpu != null) n++
|
||||
if (filters.minRamGb != null) n++
|
||||
if (filters.minDiskGb != null) n++
|
||||
return n
|
||||
}
|
||||
|
||||
export interface VpsFilterPreset {
|
||||
name: string
|
||||
filters: VpsFiltersState
|
||||
}
|
||||
|
||||
const PRESETS_KEY = 'vps-tracker:vps-filter-presets'
|
||||
|
||||
export function loadFilterPresets(): VpsFilterPreset[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(PRESETS_KEY)
|
||||
@@ -107,7 +254,7 @@ export function loadFilterPresets(): VpsFilterPreset[] {
|
||||
}
|
||||
}
|
||||
|
||||
function saveFilterPresets(presets: VpsFilterPreset[]): void {
|
||||
export function saveFilterPresets(presets: VpsFilterPreset[]): void {
|
||||
try {
|
||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets))
|
||||
} catch {
|
||||
@@ -115,356 +262,5 @@ function saveFilterPresets(presets: VpsFilterPreset[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVpsFilters(items: Vps[], filters: VpsFiltersState): Vps[] {
|
||||
const search = filters.search.toLowerCase()
|
||||
const minVcpu = Number(filters.minVcpu || 0)
|
||||
const minRamGb = Number(filters.minRamGb || 0)
|
||||
const minDiskGb = Number(filters.minDiskGb || 0)
|
||||
return items.filter((item) => {
|
||||
const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : ''
|
||||
if (
|
||||
search &&
|
||||
!item.ip?.toLowerCase().includes(search) &&
|
||||
!item.dns?.toLowerCase().includes(search) &&
|
||||
!item.ipv6?.toLowerCase().includes(search) &&
|
||||
!extraIps.toLowerCase().includes(search) &&
|
||||
!item.project?.toLowerCase().includes(search) &&
|
||||
!item.purpose?.toLowerCase().includes(search) &&
|
||||
!item.os?.toLowerCase().includes(search)
|
||||
)
|
||||
return false
|
||||
if (filters.providerId && item.providerId !== filters.providerId) return false
|
||||
if (filters.providerAccountId && item.providerAccountId !== filters.providerAccountId) return false
|
||||
if (filters.country && !item.country?.toLowerCase().includes(filters.country.toLowerCase())) return false
|
||||
if (filters.city && !item.city?.toLowerCase().includes(filters.city.toLowerCase())) return false
|
||||
if (filters.datacenter && !item.datacenter?.toLowerCase().includes(filters.datacenter.toLowerCase())) return false
|
||||
if (filters.status !== ALL && item.status !== filters.status) return false
|
||||
if (filters.environment !== ALL && item.environment !== filters.environment) return false
|
||||
if (filters.tariffType !== ALL && item.tariffType !== filters.tariffType) return false
|
||||
if (filters.monitoring !== ALL) {
|
||||
const on = filters.monitoring === 'on'
|
||||
if (on !== Boolean(item.monitoringEnabled)) return false
|
||||
}
|
||||
if (filters.backup !== ALL) {
|
||||
const on = filters.backup === 'on'
|
||||
if (on !== Boolean(item.backupEnabled)) return false
|
||||
}
|
||||
if (minVcpu && Number(item.vcpu || 0) < minVcpu) return false
|
||||
if (minRamGb && Number(item.ramGb || 0) < minRamGb) return false
|
||||
if (minDiskGb && Number(item.diskGb || 0) < minDiskGb) return false
|
||||
const proj = (item.project || '').trim()
|
||||
if (filters.project) {
|
||||
if (filters.project === '__none__' ? proj : proj !== filters.project) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function countActiveFilters(filters: VpsFiltersState): number {
|
||||
let n = 0
|
||||
if (filters.search) n++
|
||||
if (filters.providerId) n++
|
||||
if (filters.providerAccountId) n++
|
||||
if (filters.country) n++
|
||||
if (filters.city) n++
|
||||
if (filters.datacenter) n++
|
||||
if (filters.status !== ALL) n++
|
||||
if (filters.environment !== ALL) n++
|
||||
if (filters.tariffType !== ALL) n++
|
||||
if (filters.monitoring !== ALL) n++
|
||||
if (filters.backup !== ALL) n++
|
||||
if (filters.minVcpu) n++
|
||||
if (filters.minRamGb) n++
|
||||
if (filters.minDiskGb) n++
|
||||
if (filters.project) n++
|
||||
return n
|
||||
}
|
||||
|
||||
interface VpsFiltersProps {
|
||||
filters: VpsFiltersState
|
||||
onChange: (next: VpsFiltersState) => void
|
||||
providers: Provider[]
|
||||
providerAccounts: ProviderAccount[]
|
||||
projectNameOptions: string[]
|
||||
countryOptions: AutoCompleteOption[]
|
||||
cityOptions: AutoCompleteOption[]
|
||||
presets: VpsFilterPreset[]
|
||||
onPresetsChange: (presets: VpsFilterPreset[]) => void
|
||||
}
|
||||
|
||||
export function VpsFilters({
|
||||
filters,
|
||||
onChange,
|
||||
providers,
|
||||
providerAccounts,
|
||||
projectNameOptions,
|
||||
countryOptions,
|
||||
cityOptions,
|
||||
presets,
|
||||
onPresetsChange,
|
||||
}: VpsFiltersProps) {
|
||||
const update = <K extends keyof VpsFiltersState>(key: K, value: VpsFiltersState[K]) =>
|
||||
onChange({ ...filters, [key]: value })
|
||||
|
||||
const accountOptions = useMemo(
|
||||
() =>
|
||||
providerAccounts.filter(
|
||||
(a) => !filters.providerId || a.providerId === filters.providerId,
|
||||
),
|
||||
[providerAccounts, filters.providerId],
|
||||
)
|
||||
|
||||
const activeCount = countActiveFilters(filters)
|
||||
const hasFilters = activeCount > 0 || filters.groupByProject || filters.tableCompact
|
||||
|
||||
const savePreset = () => {
|
||||
const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`)
|
||||
if (!name) return
|
||||
const next = [...presets.filter((p) => p.name !== name), { name, filters }]
|
||||
onPresetsChange(next)
|
||||
saveFilterPresets(next)
|
||||
}
|
||||
|
||||
const applyPreset = (preset: VpsFilterPreset) => {
|
||||
onChange({ ...buildDefaultVpsFilters(), ...preset.filters })
|
||||
}
|
||||
|
||||
const deletePreset = (name: string) => {
|
||||
const next = presets.filter((p) => p.name !== name)
|
||||
onPresetsChange(next)
|
||||
saveFilterPresets(next)
|
||||
}
|
||||
|
||||
const reset = () => onChange(buildDefaultVpsFilters())
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Поиск: IP, DNS, проект, назначение, ОС"
|
||||
value={filters.search}
|
||||
onChange={(e) => update('search', e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
triggerId="flt-status"
|
||||
value={filters.status}
|
||||
onValueChange={(v) => update('status', v ?? ALL)}
|
||||
options={STATUS_OPTIONS}
|
||||
triggerClassName="w-44"
|
||||
/>
|
||||
<SelectField
|
||||
triggerId="flt-provider"
|
||||
placeholder="Все хостеры"
|
||||
value={filters.providerId || null}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, providerId: v ?? '', providerAccountId: '' })
|
||||
}
|
||||
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
||||
triggerClassName="w-44"
|
||||
/>
|
||||
<SelectField
|
||||
triggerId="flt-account"
|
||||
placeholder="Все аккаунты"
|
||||
value={filters.providerAccountId || null}
|
||||
onValueChange={(v) => update('providerAccountId', v ?? '')}
|
||||
options={accountOptions.map((a) => ({ value: a.id, label: a.name }))}
|
||||
triggerClassName="w-44"
|
||||
/>
|
||||
<SelectField
|
||||
triggerId="flt-project"
|
||||
placeholder="Все проекты"
|
||||
value={filters.project || null}
|
||||
onValueChange={(v) => update('project', v ?? '')}
|
||||
options={[
|
||||
{ value: '__none__', label: 'Без проекта' },
|
||||
...projectNameOptions.map((p) => ({ value: p, label: p })),
|
||||
]}
|
||||
triggerClassName="w-44"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<FilterIcon data-icon="inline-start" />
|
||||
Доп. фильтры
|
||||
{activeCount > 0 ? <Badge variant="secondary">{activeCount}</Badge> : null}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-56" />
|
||||
</DropdownMenu>
|
||||
{hasFilters ? (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Сбросить
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onClick={savePreset}>
|
||||
<SaveIcon data-icon="inline-start" />
|
||||
Сохранить пресет
|
||||
</Button>
|
||||
{presets.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="sm">Пресеты ({presets.length})</Button>}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{presets.map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p.name}
|
||||
onClick={() => applyPreset(p)}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<TrashIcon
|
||||
className="size-4 text-muted-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deletePreset(p.name)
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="flt-country" className="text-xs text-muted-foreground">Страна</Label>
|
||||
<AutoCompleteInput
|
||||
id="flt-country"
|
||||
placeholder="Любая"
|
||||
value={filters.country}
|
||||
onChange={(v) => update('country', v)}
|
||||
options={countryOptions}
|
||||
searchPlaceholder="Поиск страны…"
|
||||
emptyText="Нет вариантов"
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="flt-city" className="text-xs text-muted-foreground">Город</Label>
|
||||
<AutoCompleteInput
|
||||
id="flt-city"
|
||||
placeholder="Любой"
|
||||
value={filters.city}
|
||||
onChange={(v) => update('city', v)}
|
||||
options={cityOptions}
|
||||
searchPlaceholder="Поиск города…"
|
||||
emptyText="Нет вариантов"
|
||||
showLeadingInInput={false}
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="flt-dc" className="text-xs text-muted-foreground">Дата-центр</Label>
|
||||
<Input
|
||||
id="flt-dc"
|
||||
placeholder="Любой"
|
||||
value={filters.datacenter}
|
||||
onChange={(e) => update('datacenter', e.target.value)}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Окружение</Label>
|
||||
<SelectField
|
||||
triggerId="flt-env"
|
||||
value={filters.environment}
|
||||
onValueChange={(v) => update('environment', v ?? ALL)}
|
||||
options={ENV_OPTIONS}
|
||||
triggerClassName="w-44"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Тариф</Label>
|
||||
<SelectField
|
||||
triggerId="flt-tariff"
|
||||
value={filters.tariffType}
|
||||
onValueChange={(v) => update('tariffType', v ?? ALL)}
|
||||
options={TARIFF_OPTIONS}
|
||||
triggerClassName="w-36"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Мониторинг</Label>
|
||||
<SelectField
|
||||
triggerId="flt-mon"
|
||||
value={filters.monitoring}
|
||||
onValueChange={(v) => update('monitoring', v ?? ALL)}
|
||||
options={ON_OFF_OPTIONS}
|
||||
triggerClassName="w-36"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Бэкап</Label>
|
||||
<SelectField
|
||||
triggerId="flt-bkp"
|
||||
value={filters.backup}
|
||||
onValueChange={(v) => update('backup', v ?? ALL)}
|
||||
options={ON_OFF_OPTIONS}
|
||||
triggerClassName="w-36"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="flt-vcpu" className="text-xs text-muted-foreground">vCPU ≥</Label>
|
||||
<Input
|
||||
id="flt-vcpu"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
value={filters.minVcpu}
|
||||
onChange={(e) => update('minVcpu', e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="flt-ram" className="text-xs text-muted-foreground">RAM ≥</Label>
|
||||
<Input
|
||||
id="flt-ram"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
value={filters.minRamGb}
|
||||
onChange={(e) => update('minRamGb', e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="flt-disk" className="text-xs text-muted-foreground">Disk ≥</Label>
|
||||
<Input
|
||||
id="flt-disk"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
value={filters.minDiskGb}
|
||||
onChange={(e) => update('minDiskGb', e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={filters.groupByProject}
|
||||
onCheckedChange={(v) => update('groupByProject', Boolean(v))}
|
||||
/>
|
||||
<span className="text-sm">Группировать по проекту</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={filters.tableCompact}
|
||||
onCheckedChange={(v) => update('tableCompact', Boolean(v))}
|
||||
/>
|
||||
<span className="text-sm">Компактная таблица</span>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
// Обратная совместимость — для старого кода, который мог импортировать arrOr
|
||||
export { arrOr }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { format, parseISO, isValid } from 'date-fns'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
@@ -23,13 +25,22 @@ import { SelectField } from '@/components/select-field'
|
||||
import { AutoCompleteInput } from '@/components/auto-complete-input'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
import {
|
||||
VpsFilters,
|
||||
NumberField,
|
||||
NumberFieldGroup,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/components/reui/number-field'
|
||||
import {
|
||||
DateSelector,
|
||||
type DateSelectorValue,
|
||||
} from '@/components/reui/date-selector'
|
||||
import {
|
||||
applyVpsFilters,
|
||||
buildDefaultVpsFilters,
|
||||
loadFilterPresets,
|
||||
type VpsFiltersState,
|
||||
type VpsFilterPreset,
|
||||
} from '@/components/vps-filters'
|
||||
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji, getCountryFlagEmojiByCode } from '@/lib/format'
|
||||
@@ -56,7 +67,6 @@ function VpsPage() {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
||||
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
||||
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
|
||||
|
||||
const settings = snapshot?.settings[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
@@ -154,6 +164,7 @@ function VpsPage() {
|
||||
return {
|
||||
value: name,
|
||||
label: name,
|
||||
code: ref?.code,
|
||||
leading: ref ? getCountryFlagEmojiByCode(ref.code) : getCountryFlagEmoji(name),
|
||||
}
|
||||
})
|
||||
@@ -166,9 +177,9 @@ function VpsPage() {
|
||||
const c = (v.city || '').trim()
|
||||
if (c) names.add(c)
|
||||
}
|
||||
// Из стандартизированного справочника (фильтр по выбранной стране, если есть)
|
||||
const countryCode = filters.country
|
||||
? COUNTRY_BY_NAME_RU[filters.country.toLowerCase()]?.code
|
||||
// Из стандартизированного справочника (фильтр по первой выбранной стране, если есть)
|
||||
const countryCode = filters.country[0]
|
||||
? COUNTRY_BY_NAME_RU[filters.country[0].toLowerCase()]?.code
|
||||
: undefined
|
||||
for (const city of listCities(countryCode)) names.add(city.name)
|
||||
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({
|
||||
@@ -311,16 +322,15 @@ function VpsPage() {
|
||||
>
|
||||
{(snap) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<VpsFilters
|
||||
<VpsFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
providers={snap.providers}
|
||||
providerAccounts={snap.providerAccounts}
|
||||
vps={snap.vps}
|
||||
projectNameOptions={projectNameOptions}
|
||||
countryOptions={countryOptions}
|
||||
cityOptions={cityOptions}
|
||||
presets={presets}
|
||||
onPresetsChange={setPresets}
|
||||
/>
|
||||
{tableSections.map((section) => (
|
||||
<DataTableCard
|
||||
@@ -410,13 +420,67 @@ function VpsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField label="vCPU" htmlFor="vps-vcpu" error={errors.vcpu?.message}>
|
||||
<Input id="vps-vcpu" type="number" min={0} {...register('vcpu', { valueAsNumber: true })} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="vcpu"
|
||||
render={({ field }) => (
|
||||
<NumberField
|
||||
id="vps-vcpu"
|
||||
min={0}
|
||||
step={1}
|
||||
value={Number(field.value ?? 0)}
|
||||
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="RAM (GB)" htmlFor="vps-ram" error={errors.ramGb?.message}>
|
||||
<Input id="vps-ram" type="number" min={0} {...register('ramGb', { valueAsNumber: true })} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="ramGb"
|
||||
render={({ field }) => (
|
||||
<NumberField
|
||||
id="vps-ram"
|
||||
min={0}
|
||||
step={1}
|
||||
value={Number(field.value ?? 0)}
|
||||
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Disk (GB)" htmlFor="vps-disk" error={errors.diskGb?.message}>
|
||||
<Input id="vps-disk" type="number" min={0} {...register('diskGb', { valueAsNumber: true })} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="diskGb"
|
||||
render={({ field }) => (
|
||||
<NumberField
|
||||
id="vps-disk"
|
||||
min={0}
|
||||
step={1}
|
||||
value={Number(field.value ?? 0)}
|
||||
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -449,14 +513,73 @@ function VpsPage() {
|
||||
<Input id="vps-cur" {...register('currency')} />
|
||||
</FormField>
|
||||
<FormField label="Ставка/мес" htmlFor="vps-monthly">
|
||||
<Input id="vps-monthly" type="number" min={0} step="any" {...register('monthlyRate', { valueAsNumber: true })} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="monthlyRate"
|
||||
render={({ field }) => (
|
||||
<NumberField
|
||||
id="vps-monthly"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={Number(field.value ?? 0)}
|
||||
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Ставка/день" htmlFor="vps-daily">
|
||||
<Input id="vps-daily" type="number" min={0} step="any" {...register('dailyRate', { valueAsNumber: true })} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="dailyRate"
|
||||
render={({ field }) => (
|
||||
<NumberField
|
||||
id="vps-daily"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={Number(field.value ?? 0)}
|
||||
onValueChange={(val) => field.onChange(val ?? 0)}
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Оплачено до" htmlFor="vps-paid">
|
||||
<Input id="vps-paid" type="date" {...register('paidUntil')} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="paidUntil"
|
||||
render={({ field }) => {
|
||||
const strVal = (field.value as string | undefined) ?? ''
|
||||
const parsed = strVal ? parseISO(strVal) : undefined
|
||||
const dateVal: DateSelectorValue | undefined =
|
||||
parsed && isValid(parsed)
|
||||
? { period: 'day', operator: 'is', startDate: parsed }
|
||||
: undefined
|
||||
return (
|
||||
<DateSelector
|
||||
value={dateVal}
|
||||
onChange={(v) => {
|
||||
const d = v.startDate
|
||||
field.onChange(d ? format(d, 'yyyy-MM-dd') : '')
|
||||
}}
|
||||
allowRange={false}
|
||||
defaultPeriodType="day"
|
||||
showInput
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Заметки" htmlFor="vps-notes">
|
||||
<Textarea id="vps-notes" {...register('notes')} />
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@cfdm/ui/components",
|
||||
"utils": "@cfdm/ui/lib/utils",
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"recharts": "3.8.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.0.0"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||
vertical:
|
||||
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "button-group-text",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
type Locale,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Button, buttonVariants } from "@cfdm/ui/components/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString(locale?.code, { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-(--cell-radius)",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] text-muted-foreground select-none",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_end
|
||||
),
|
||||
today: cn(
|
||||
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: ({ ...props }) => (
|
||||
<CalendarDayButton locale={locale} {...props} />
|
||||
),
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: SliderPrimitive.Root.Props) {
|
||||
const _values = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max]
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
thumbAlignment="edge"
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
|
||||
>
|
||||
<SliderPrimitive.Indicator
|
||||
data-slot="slider-range"
|
||||
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Control>
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
|
||||
Generated
+48
-3
@@ -86,7 +86,7 @@ importers:
|
||||
dependencies:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
version: 1.6.0(@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
'@cfdm/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
@@ -108,9 +108,15 @@ importers:
|
||||
'@tanstack/react-router-devtools':
|
||||
specifier: ^1.130.2
|
||||
version: 1.167.0(@tanstack/[email protected]([email protected]([email protected]))([email protected]))(@tanstack/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0([email protected])
|
||||
@@ -120,6 +126,9 @@ importers:
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7
|
||||
react-day-picker:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(@types/[email protected])([email protected])
|
||||
react-dom:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7([email protected])
|
||||
@@ -196,7 +205,7 @@ importers:
|
||||
dependencies:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.0(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
version: 1.6.0(@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -206,6 +215,9 @@ importers:
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
lucide-react:
|
||||
specifier: ^0.468.0
|
||||
version: 0.468.0([email protected])
|
||||
@@ -215,6 +227,9 @@ importers:
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.7
|
||||
react-day-picker:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(@types/[email protected])([email protected])
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.7([email protected])
|
||||
@@ -351,6 +366,9 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@date-fns/[email protected]':
|
||||
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||
|
||||
'@drizzle-team/[email protected]':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
@@ -1848,6 +1866,9 @@ packages:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
@@ -2770,6 +2791,16 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8.0'
|
||||
react: '>=16.8.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
||||
peerDependencies:
|
||||
@@ -3460,7 +3491,7 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
'@base-ui/[email protected](@date-fns/[email protected])(@types/[email protected])([email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@base-ui/utils': 0.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -3470,7 +3501,9 @@ snapshots:
|
||||
react-dom: 19.2.7([email protected])
|
||||
use-sync-external-store: 1.6.0([email protected])
|
||||
optionalDependencies:
|
||||
'@date-fns/tz': 1.5.0
|
||||
'@types/react': 19.2.17
|
||||
date-fns: 4.4.0
|
||||
|
||||
'@base-ui/[email protected](@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
@@ -3483,6 +3516,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@date-fns/[email protected]': {}
|
||||
|
||||
'@drizzle-team/[email protected]': {}
|
||||
|
||||
'@esbuild-kit/[email protected]':
|
||||
@@ -4740,6 +4775,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
@@ -5607,6 +5644,14 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.5.0
|
||||
date-fns: 4.4.0
|
||||
react: 19.2.7
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
Reference in New Issue
Block a user