diff --git a/apps/web/package.json b/apps/web/package.json index 3b15bed..9ff086d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.130.2", "@tanstack/react-router-devtools": "^1.130.2", + "cmdk": "^1.1.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.6", "react": "^19.2.0", diff --git a/apps/web/src/components/auto-complete-input.tsx b/apps/web/src/components/auto-complete-input.tsx new file mode 100644 index 0000000..3cf24a1 --- /dev/null +++ b/apps/web/src/components/auto-complete-input.tsx @@ -0,0 +1,135 @@ +import * as React from 'react' +import { CheckIcon, SearchIcon } from 'lucide-react' + +import { Input } from '@cfdm/ui/components/input' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@cfdm/ui/components/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@cfdm/ui/components/popover' +import { cn } from '@cfdm/ui/lib/utils' + +export interface AutoCompleteOption { + value: string + label: string + /** Левый префикс (например эмодзи-флаг). */ + leading?: React.ReactNode +} + +interface AutoCompleteInputProps { + id?: string + value: string + onChange: (value: string) => void + options: AutoCompleteOption[] + placeholder?: string + searchPlaceholder?: string + emptyText?: string + className?: string + /** Показывать ли выбранный leading в самом Input (например флаг). */ + showLeadingInInput?: boolean +} + +export function AutoCompleteInput({ + id, + value, + onChange, + options, + placeholder, + searchPlaceholder = 'Поиск…', + emptyText = 'Ничего не найдено', + className, + showLeadingInInput = true, +}: AutoCompleteInputProps) { + const [open, setOpen] = React.useState(false) + const [query, setQuery] = React.useState('') + + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return options + return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)) + }, [options, query]) + + const selected = options.find((o) => o.value.toLowerCase() === value.trim().toLowerCase()) + const leading = selected?.leading + + return ( + + + {showLeadingInInput && leading ? ( + + {leading} + + ) : null} + { + onChange(e.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + onBlur={() => { + // задержка чтобы клик по item успел сработать + setTimeout(() => setOpen(false), 150) + }} + className={cn( + showLeadingInInput && leading ? 'pl-8' : '', + className, + )} + /> + + } + /> + + +
+ + +
+ + {emptyText} + + {filtered.map((opt) => { + const isSelected = opt.value.toLowerCase() === value.trim().toLowerCase() + return ( + { + onChange(opt.value) + setQuery('') + setOpen(false) + }} + className="gap-2" + > + {opt.leading ? {opt.leading} : null} + {opt.label} + {isSelected ? : null} + + ) + })} + + +
+
+
+ ) +} diff --git a/apps/web/src/components/vps-filters.tsx b/apps/web/src/components/vps-filters.tsx index 198789d..b1e0a4e 100644 --- a/apps/web/src/components/vps-filters.tsx +++ b/apps/web/src/components/vps-filters.tsx @@ -15,6 +15,7 @@ import { } 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' @@ -185,6 +186,8 @@ interface VpsFiltersProps { providers: Provider[] providerAccounts: ProviderAccount[] projectNameOptions: string[] + countryOptions: AutoCompleteOption[] + cityOptions: AutoCompleteOption[] presets: VpsFilterPreset[] onPresetsChange: (presets: VpsFilterPreset[]) => void } @@ -195,6 +198,8 @@ export function VpsFilters({ providers, providerAccounts, projectNameOptions, + countryOptions, + cityOptions, presets, onPresetsChange, }: VpsFiltersProps) { @@ -333,21 +338,28 @@ export function VpsFilters({
- update('country', e.target.value)} + onChange={(v) => update('country', v)} + options={countryOptions} + searchPlaceholder="Поиск страны…" + emptyText="Нет вариантов" className="w-36" />
- update('city', e.target.value)} + onChange={(v) => update('city', v)} + options={cityOptions} + searchPlaceholder="Поиск города…" + emptyText="Нет вариантов" + showLeadingInInput={false} className="w-36" />
diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index d792292..69617c7 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -20,6 +20,7 @@ import { FormSheetRhf } from '@/components/form-sheet-rhf' import { FormField } from '@/components/form-field' import { Input } from '@cfdm/ui/components/input' import { SelectField } from '@/components/select-field' +import { AutoCompleteInput } from '@/components/auto-complete-input' import { Textarea } from '@cfdm/ui/components/textarea' import { VpsFilters, @@ -31,7 +32,7 @@ import { } from '@/components/vps-filters' import type { Vps } from '@/types/entities' -import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format' +import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji } from '@/lib/format' import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager' export const Route = createFileRoute('/_auth/vps')({ @@ -138,6 +139,31 @@ function VpsPage() { [snapshot?.vps, filters], ) + const countryOptions = useMemo(() => { + const names = new Set() + for (const v of snapshot?.vps ?? []) { + const c = (v.country || '').trim() + if (c) names.add(c) + } + return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({ + value: name, + label: name, + leading: getCountryFlagEmoji(name), + })) + }, [snapshot?.vps]) + + const cityOptions = useMemo(() => { + const names = new Set() + for (const v of snapshot?.vps ?? []) { + const c = (v.city || '').trim() + if (c) names.add(c) + } + return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({ + value: name, + label: name, + })) + }, [snapshot?.vps]) + const tableSections = useMemo(() => { if (!filters.groupByProject) { return [{ key: '_flat', label: null as string | null, items: filteredVps }] @@ -278,6 +304,8 @@ function VpsPage() { providers={snap.providers} providerAccounts={snap.providerAccounts} projectNameOptions={projectNameOptions} + countryOptions={countryOptions} + cityOptions={cityOptions} presets={presets} onPresetsChange={setPresets} /> @@ -341,10 +369,27 @@ function VpsPage() {
- + setValue('country', v)} + options={countryOptions} + searchPlaceholder="Поиск страны…" + emptyText="Нет вариантов" + /> - + setValue('city', v)} + options={cityOptions} + searchPlaceholder="Поиск города…" + emptyText="Нет вариантов" + showLeadingInInput={false} + /> diff --git a/packages/ui/package.json b/packages/ui/package.json index a5a11cd..0756b5a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,6 +13,7 @@ "@base-ui/react": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.6", "recharts": "3.8.0", diff --git a/packages/ui/src/components/command.tsx b/packages/ui/src/components/command.tsx new file mode 100644 index 0000000..4d0b745 --- /dev/null +++ b/packages/ui/src/components/command.tsx @@ -0,0 +1,196 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" + +import { cn } from "@cfdm/ui/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@cfdm/ui/components/dialog" +import { + InputGroup, + InputGroupAddon, +} from "@cfdm/ui/components/input-group" +import { SearchIcon, CheckIcon } from "lucide-react" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = false, + ...props +}: Omit, "children"> & { + title?: string + description?: string + className?: string + showCloseButton?: boolean + children: React.ReactNode +}) { + return ( + + + {title} + {description} + + + {children} + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + + + + + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx new file mode 100644 index 0000000..fd8ee87 --- /dev/null +++ b/packages/ui/src/components/input-group.tsx @@ -0,0 +1,156 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { Input } from "@cfdm/ui/components/input" +import { Textarea } from "@cfdm/ui/components/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset" + }) { + return ( +