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..42b34d6 --- /dev/null +++ b/apps/web/src/components/auto-complete-input.tsx @@ -0,0 +1,134 @@ +'use client' + +import * as React from 'react' +import { CheckIcon } from 'lucide-react' + +import { cn } from '@cdnmanager/ui/lib/utils' +import { + Autocomplete, + AutocompleteContent, + AutocompleteEmpty, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, +} from '@/components/reui/autocomplete' +import { TruncatedText } from '@/components/truncated-text' + +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 в поле (например флаг). */ + showLeadingInInput?: boolean + /** Разрешать ли произвольный ввод (не только из списка). По умолчанию true. */ + allowFreeText?: boolean + disabled?: boolean +} + +export function AutoCompleteInput({ + id, + value, + onChange, + options, + placeholder = 'Выбрать…', + searchPlaceholder, + emptyText = 'Ничего не найдено', + className, + showLeadingInInput = true, + allowFreeText = true, + disabled = false, +}: AutoCompleteInputProps) { + const trimmedValue = value.trim() + + const selected = React.useMemo( + () => options.find((o) => o.value.toLowerCase() === trimmedValue.toLowerCase()), + [options, trimmedValue], + ) + const leading = showLeadingInInput ? selected?.leading : undefined + + const inputPlaceholder = searchPlaceholder ?? placeholder + + const handleValueChange = React.useCallback( + (inputVal: string) => { + const q = inputVal.trim() + const match = options.find( + (o) => + o.label.toLowerCase() === q.toLowerCase() || + o.value.toLowerCase() === q.toLowerCase(), + ) + if (match) { + onChange(match.value) + return + } + if (allowFreeText) { + onChange(inputVal) + } + }, + [options, onChange, allowFreeText], + ) + + return ( + item.label} + mode="list" + autoHighlight + openOnInputClick + disabled={disabled} + > +
+ {leading ? ( + + {leading} + + ) : null} + +
+ + {emptyText} + + {(item) => { + const isSelected = item.value.toLowerCase() === trimmedValue.toLowerCase() + return ( + + {item.leading ? ( + {item.leading} + ) : null} + + {item.label} + + {isSelected ? ( + + ) : null} + + ) + }} + + +
+ ) +} diff --git a/apps/web/src/components/country-flag.tsx b/apps/web/src/components/country-flag.tsx new file mode 100644 index 0000000..218a06d --- /dev/null +++ b/apps/web/src/components/country-flag.tsx @@ -0,0 +1,22 @@ +import { cn } from '@cdnmanager/ui/lib/utils' +import { countryCodeFromName, getCountryFlagUrl } from '@/lib/country-labels' + +interface CountryFlagProps { + code?: string + country?: string + className?: string +} + +export function CountryFlag({ code, country, className }: CountryFlagProps) { + const resolvedCode = code ?? (country ? countryCodeFromName(country) : undefined) + const url = getCountryFlagUrl(resolvedCode) + if (!url) return null + + return ( + + ) +} diff --git a/apps/web/src/lib/country-labels.ts b/apps/web/src/lib/country-labels.ts index 1610628..92990c1 100644 --- a/apps/web/src/lib/country-labels.ts +++ b/apps/web/src/lib/country-labels.ts @@ -1,5 +1,5 @@ -/** ISO country codes from fleet locations seed → RU labels for selects. */ -const COUNTRY_LABELS: Record = { +/** ISO → русское название (seed fleet locations). */ +const COUNTRY_NAME_BY_CODE: Record = { RU: 'Россия', DE: 'Германия', NL: 'Нидерланды', @@ -7,7 +7,23 @@ const COUNTRY_LABELS: Record = { FR: 'Франция', } -export function countrySelectLabel(code: string): string { - const name = COUNTRY_LABELS[code] - return name ? `${code} — ${name}` : code +const COUNTRY_CODE_BY_NAME: Record = Object.fromEntries( + Object.entries(COUNTRY_NAME_BY_CODE).map(([code, name]) => [name.toLowerCase(), code]), +) + +export function countryNameFromCode(code: string | null | undefined): string { + if (!code) return '' + return COUNTRY_NAME_BY_CODE[code.toUpperCase()] ?? code +} + +export function countryCodeFromName(name: string | null | undefined): string { + if (!name?.trim()) return '' + const trimmed = name.trim() + if (trimmed.length === 2) return trimmed.toUpperCase() + return COUNTRY_CODE_BY_NAME[trimmed.toLowerCase()] ?? '' +} + +export function getCountryFlagUrl(code?: string): string | undefined { + if (!code || code.length !== 2) return undefined + return `https://flagcdn.com/${code.toLowerCase()}.svg` } diff --git a/apps/web/src/routes/_auth/nodes.tsx b/apps/web/src/routes/_auth/nodes.tsx index ad7b736..df0736b 100644 --- a/apps/web/src/routes/_auth/nodes.tsx +++ b/apps/web/src/routes/_auth/nodes.tsx @@ -25,7 +25,13 @@ import { Button } from '@cdnmanager/ui/components/button' import { Input } from '@cdnmanager/ui/components/input' import { Label } from '@cdnmanager/ui/components/label' import { SelectField } from '@/components/select-field' -import { countrySelectLabel } from '@/lib/country-labels' +import { AutoCompleteInput } from '@/components/auto-complete-input' +import { CountryFlag } from '@/components/country-flag' +import { FormFieldSimple } from '@/components/form-field' +import { + countryCodeFromName, + countryNameFromCode, +} from '@/lib/country-labels' import { queryClient } from '@/lib/query-client' import { createNode, @@ -81,7 +87,8 @@ function NodesPage() { const [editing, setEditing] = useState(null) const [deleteId, setDeleteId] = useState(null) const [preview, setPreview] = useState('') - const [countryCode, setCountryCode] = useState('') + const [countryName, setCountryName] = useState('') + const [locationQuery, setLocationQuery] = useState('') const form = useForm({ resolver: zodResolver(formSchema), @@ -104,14 +111,23 @@ function NodesPage() { const watchIndex = form.watch('indexNum') const watchProvider = form.watch('providerTag') + const countryCode = countryCodeFromName(countryName) + const countryOptions = useMemo(() => { const codes = new Set() for (const loc of locations) { if (loc.country) codes.add(loc.country) } return [...codes] - .sort((a, b) => a.localeCompare(b)) - .map((code) => ({ value: code, label: countrySelectLabel(code) })) + .map((code) => { + const name = countryNameFromCode(code) + return { + value: name, + label: name, + leading: , + } + }) + .sort((a, b) => a.label.localeCompare(b.label, 'ru')) }, [locations]) const locationOptions = useMemo(() => { @@ -119,13 +135,30 @@ function NodesPage() { return locations .filter((l) => l.country === countryCode) .map((l) => ({ - value: l.id, + value: `${l.code} — ${l.name}`, label: `${l.code} — ${l.name}`, })) }, [locations, countryCode]) - function countryForLocationId(locationId: string): string { - return locations.find((l) => l.id === locationId)?.country ?? '' + function locationLabel(locationId: string): string { + const loc = locations.find((l) => l.id === locationId) + return loc ? `${loc.code} — ${loc.name}` : '' + } + + function countryNameForLocationId(locationId: string): string { + const code = locations.find((l) => l.id === locationId)?.country + return countryNameFromCode(code) + } + + function locationIdFromDisplay(display: string): string { + const q = display.trim().toLowerCase() + if (!q) return '' + const match = locations.find((l) => { + if (countryCode && l.country !== countryCode) return false + const label = `${l.code} — ${l.name}`.toLowerCase() + return label === q || l.code.toLowerCase() === q || l.name.toLowerCase() === q + }) + return match?.id ?? '' } async function refreshPreview() { @@ -174,7 +207,8 @@ function NodesPage() { toast.success(editing ? 'Нода обновлена' : 'Нода создана') setSheetOpen(false) setEditing(null) - setCountryCode('') + setCountryName('') + setLocationQuery('') form.reset() void qc.invalidateQueries({ queryKey: ['fleet'] }) }, @@ -294,7 +328,8 @@ function NodesPage() { onClick={() => { const n = row.original setEditing(n) - setCountryCode(countryForLocationId(n.locationId)) + setCountryName(countryNameForLocationId(n.locationId)) + setLocationQuery(locationLabel(n.locationId)) form.reset({ zoneId: n.zoneId, locationId: n.locationId, @@ -335,7 +370,8 @@ function NodesPage() { onClick={() => { setEditing(null) const firstLoc = locations[0] - setCountryCode(firstLoc?.country ?? '') + setCountryName(countryNameFromCode(firstLoc?.country)) + setLocationQuery(firstLoc ? `${firstLoc.code} — ${firstLoc.name}` : '') form.reset({ zoneId: zones[0]?.id ?? '', locationId: firstLoc?.id ?? '', @@ -426,42 +462,47 @@ function NodesPage() { ) : null} -
-
- - { - const next = v ?? '' - setCountryCode(next) - const currentLoc = locations.find((l) => l.id === form.getValues('locationId')) - if (!next || !currentLoc || currentLoc.country !== next) { - form.setValue('locationId', '') - setPreview('') - } - void refreshPreview() - }} - placeholder="Страна" - options={countryOptions} - /> -
-
- - { - const id = v ?? '' - form.setValue('locationId', id) - const loc = locations.find((l) => l.id === id) - if (loc?.country) setCountryCode(loc.country) - void refreshPreview() - }} - placeholder={countryCode ? 'Локация' : 'Сначала страна'} - options={locationOptions} - /> -
-
+ + { + setCountryName(v) + const nextCode = countryCodeFromName(v) + const currentLoc = locations.find((l) => l.id === form.getValues('locationId')) + if (!nextCode || !currentLoc || currentLoc.country !== nextCode) { + form.setValue('locationId', '') + setLocationQuery('') + setPreview('') + } + void refreshPreview() + }} + options={countryOptions} + searchPlaceholder="Поиск страны…" + emptyText="Нет вариантов" + /> + + + { + setLocationQuery(v) + const id = locationIdFromDisplay(v) + form.setValue('locationId', id) + const loc = locations.find((l) => l.id === id) + if (loc?.country) setCountryName(countryNameFromCode(loc.country)) + void refreshPreview() + }} + options={locationOptions} + searchPlaceholder="Поиск локации…" + emptyText={countryCode ? 'Нет вариантов' : 'Сначала выберите страну'} + showLeadingInInput={false} + disabled={!countryCode} + /> +