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:
Denozordec
2026-06-26 23:53:16 +07:00
co-authored by Cursor
parent b87d7c6fad
commit 63da57687d
22 changed files with 5122 additions and 454 deletions
+3
View File
@@ -10,6 +10,9 @@
"cssVariables": true
},
"iconLibrary": "lucide",
"registries": {
"@reui": "https://reui.io/r/{style}/{name}.json"
},
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
+3
View File
@@ -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 }
+220 -424
View File
@@ -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 }
+19
View File
@@ -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
}
+139 -16
View File
@@ -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')} />
+3
View File
@@ -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",
+2
View File
@@ -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,
}
+221
View File
@@ -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"
+26
View File
@@ -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 }
+2
View File
@@ -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"
+52
View File
@@ -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 }
-2
View File
@@ -1,5 +1,3 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
+48 -3
View File
@@ -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