Add inbound country flags and node load in location picker
- Inbound.CountryCode (ISO alpha-2), set on publish; flag shown before the location name (SVG via country-flag-icons on the site, emoji in bot) - Node.Capacity (optional, not a limit); available inbounds report load percent of capacity or active config count, sorted least loaded first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7743977a1
commit
bb5e59904f
51 files changed
+3160
-284
No files matched your search
@@ -27,6 +27,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.6.20",
|
||||
"i18next": "^26.3.4",
|
||||
"lucide-react": "^1.22.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
|
||||
Generated
+8
@@ -53,6 +53,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
country-flag-icons:
|
||||
specifier: ^1.6.20
|
||||
version: 1.6.20
|
||||
i18next:
|
||||
specifier: ^26.3.4
|
||||
version: 26.3.4(typescript@6.0.3)
|
||||
@@ -1232,6 +1235,9 @@ packages:
|
||||
cookie-es@3.1.1:
|
||||
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
|
||||
|
||||
country-flag-icons@1.6.20:
|
||||
resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -3214,6 +3220,8 @@ snapshots:
|
||||
|
||||
cookie-es@3.1.1: {}
|
||||
|
||||
country-flag-icons@1.6.20: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
|
||||
@@ -6,11 +6,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||
import { COUNTRY_CODES, countryName } from '@/shared/lib/countries'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { listRoles } from '@/features/admin/roles/api'
|
||||
import type { InboundDto } from '@/shared/api/types'
|
||||
import { publishInbound } from './api'
|
||||
|
||||
// Radix Select не допускает value="" у SelectItem — «без флага» кодируем отдельным маркером.
|
||||
const NO_COUNTRY = 'none'
|
||||
|
||||
export function PublishInboundDialog({
|
||||
inbound,
|
||||
open,
|
||||
@@ -20,8 +26,9 @@ export function PublishInboundDialog({
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [countryCode, setCountryCode] = useState(inbound.countryCode ?? NO_COUNTRY)
|
||||
const [isPublished, setIsPublished] = useState(inbound.isPublished)
|
||||
const [displayName, setDisplayName] = useState(inbound.displayName ?? inbound.remark)
|
||||
const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set(inbound.allowedRoleIds))
|
||||
@@ -30,7 +37,13 @@ export function PublishInboundDialog({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
publishInbound(inbound.id, isPublished, displayName.trim() || undefined, Array.from(selectedRoles)),
|
||||
publishInbound(
|
||||
inbound.id,
|
||||
isPublished,
|
||||
displayName.trim() || undefined,
|
||||
countryCode === NO_COUNTRY ? undefined : countryCode,
|
||||
Array.from(selectedRoles),
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.nodes.publishSaved'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] })
|
||||
@@ -73,6 +86,27 @@ export function PublishInboundDialog({
|
||||
<Label htmlFor="displayName">{t('admin.nodes.displayName')}</Label>
|
||||
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.nodes.country')}</Label>
|
||||
<Select value={countryCode} onValueChange={setCountryCode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_COUNTRY}>{t('admin.nodes.noCountry')}</SelectItem>
|
||||
{COUNTRY_CODES.map((code) => [code, countryName(code, i18n.language)] as const)
|
||||
.sort(([, a], [, b]) => a.localeCompare(b, i18n.language))
|
||||
.map(([code, name]) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
<span className="flex items-center gap-2">
|
||||
<CountryFlag code={code} />
|
||||
{name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.nodes.allowedRoles')}</Label>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -9,11 +9,12 @@ export function publishInbound(
|
||||
id: string,
|
||||
isPublished: boolean,
|
||||
displayName: string | undefined,
|
||||
countryCode: string | undefined,
|
||||
allowedRoleIds: string[],
|
||||
) {
|
||||
return apiRequest<InboundDto>(`/admin/inbounds/${id}/publish`, {
|
||||
method: 'PUT',
|
||||
body: { isPublished, displayName: displayName ?? null, allowedRoleIds },
|
||||
body: { isPublished, displayName: displayName ?? null, countryCode: countryCode ?? null, allowedRoleIds },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
||||
const [notifyOnStatusChange, setNotifyOnStatusChange] = useState(node.notifyOnStatusChange)
|
||||
const [username, setUsername] = useState(node.username)
|
||||
const [password, setPassword] = useState('')
|
||||
const [capacity, setCapacity] = useState(node.capacity?.toString() ?? '')
|
||||
const parsedCapacity = capacity.trim() ? Number(capacity) : undefined
|
||||
const capacityValid = parsedCapacity === undefined || (Number.isInteger(parsedCapacity) && parsedCapacity > 0)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -32,6 +35,7 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
||||
notifyOnStatusChange,
|
||||
username.trim() || undefined,
|
||||
password || undefined,
|
||||
parsedCapacity,
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.nodes.updated'))
|
||||
@@ -72,6 +76,20 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
||||
<Label htmlFor="editLocation">{t('admin.nodes.location')}</Label>
|
||||
<Input id="editLocation" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editCapacity">
|
||||
{t('admin.nodes.capacity')} ({t('admin.nodes.optional')})
|
||||
</Label>
|
||||
<Input
|
||||
id="editCapacity"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.nodes.capacityHint')}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.nodes.enabled')}
|
||||
@@ -107,7 +125,7 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!name.trim() || !baseAddress.trim() || mutation.isPending}>
|
||||
<Button type="submit" disabled={!name.trim() || !baseAddress.trim() || !capacityValid || mutation.isPending}>
|
||||
{t('admin.roles.save')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -18,6 +18,7 @@ export function updateNode(
|
||||
notifyOnStatusChange: boolean,
|
||||
username: string | undefined,
|
||||
password: string | undefined,
|
||||
capacity: number | undefined,
|
||||
) {
|
||||
return apiRequest<NodeDto>(`/admin/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
@@ -29,6 +30,7 @@ export function updateNode(
|
||||
notifyOnStatusChange,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
capacity: capacity ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { formatBytes } from '@/shared/lib/format'
|
||||
import type { ConfigStatus, VpnConfigDto } from '@/shared/api/types'
|
||||
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||
import { getConfigLink, revokeConfig, rotateConfig } from './api'
|
||||
|
||||
const STATUS_VARIANT: Record<ConfigStatus, 'success' | 'warning' | 'destructive'> = {
|
||||
@@ -69,7 +70,10 @@ export function ConfigCard({ config }: { config: VpnConfigDto }) {
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">{config.label ?? config.location}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{config.location}</p>
|
||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CountryFlag code={config.countryCode} />
|
||||
{config.location}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{config.protocol}</Badge>
|
||||
|
||||
@@ -11,8 +11,41 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { AvailableInboundDto } from '@/shared/api/types'
|
||||
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { createConfig } from './api'
|
||||
|
||||
// Пороги «светофора» загрузки ноды, % от ёмкости.
|
||||
const LOAD_MEDIUM = 50
|
||||
const LOAD_HIGH = 80
|
||||
|
||||
function NodeLoad({ inbound }: { inbound: AvailableInboundDto }) {
|
||||
const { t } = useTranslation()
|
||||
if (inbound.loadPercent !== null) {
|
||||
const level = inbound.loadPercent >= LOAD_HIGH ? 'high' : inbound.loadPercent >= LOAD_MEDIUM ? 'medium' : 'low'
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-1.5 pl-3 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
level === 'high' ? 'bg-red-500' : level === 'medium' ? 'bg-amber-500' : 'bg-emerald-500',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{t(`configs.load.${level}`)} · {inbound.loadPercent}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (inbound.nodeConfigCount !== null) {
|
||||
return (
|
||||
<span className="ml-auto pl-3 text-xs text-muted-foreground">
|
||||
{t('configs.nodeConfigs', { count: inbound.nodeConfigCount })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto[] }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -74,7 +107,11 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
|
||||
<SelectContent>
|
||||
{inbounds.map((inbound) => (
|
||||
<SelectItem key={inbound.inboundId} value={inbound.inboundId}>
|
||||
{inbound.displayName} ({inbound.protocol})
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<CountryFlag code={inbound.countryCode} />
|
||||
{inbound.displayName} ({inbound.protocol})
|
||||
<NodeLoad inbound={inbound} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { listAllConfigs } from '@/features/admin/configs/api'
|
||||
import { forceRevokeConfig } from '@/features/admin/users/api'
|
||||
import { listNodes } from '@/features/admin/nodes/api'
|
||||
import type { ConfigStatus, VpnProtocol } from '@/shared/api/types'
|
||||
import { CountryFlag } from '@/shared/ui/country-flag'
|
||||
|
||||
const PROTOCOLS: VpnProtocol[] = ['Vless', 'Vmess', 'Trojan', 'Shadowsocks']
|
||||
|
||||
@@ -166,7 +167,10 @@ function AdminConfigsPage() {
|
||||
<Badge variant="outline">{config.protocol}</Badge>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{config.nodeName} · {config.location}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CountryFlag code={config.countryCode} />
|
||||
{config.nodeName} · {config.location}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-muted-foreground">
|
||||
↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)}
|
||||
|
||||
@@ -52,6 +52,7 @@ export type VpnConfigDto = {
|
||||
clientEmail: string
|
||||
protocol: VpnProtocol
|
||||
location: string
|
||||
countryCode: string | null
|
||||
usedUpBytes: number
|
||||
usedDownBytes: number
|
||||
expiresAt: string | null
|
||||
@@ -68,6 +69,7 @@ export type AdminVpnConfigDto = {
|
||||
clientEmail: string
|
||||
protocol: VpnProtocol
|
||||
location: string
|
||||
countryCode: string | null
|
||||
nodeName: string
|
||||
usedUpBytes: number
|
||||
usedDownBytes: number
|
||||
@@ -80,6 +82,11 @@ export type AvailableInboundDto = {
|
||||
inboundId: string
|
||||
displayName: string
|
||||
protocol: VpnProtocol
|
||||
countryCode: string | null
|
||||
/** Загрузка ноды в % от её ёмкости (может быть > 100); null — ёмкость не задана. */
|
||||
loadPercent: number | null
|
||||
/** Число активных конфигов на ноде — только когда ёмкость не задана (иначе null). */
|
||||
nodeConfigCount: number | null
|
||||
}
|
||||
|
||||
export type ConfigLinkDto = {
|
||||
@@ -306,6 +313,7 @@ export type NodeDto = {
|
||||
status: NodeStatus
|
||||
isEnabled: boolean
|
||||
notifyOnStatusChange: boolean
|
||||
capacity: number | null
|
||||
lastSyncAt: string | null
|
||||
}
|
||||
|
||||
@@ -330,6 +338,7 @@ export type InboundDto = {
|
||||
isPublished: boolean
|
||||
isAvailable: boolean
|
||||
displayName: string | null
|
||||
countryCode: string | null
|
||||
allowedRoleIds: string[]
|
||||
lastSyncAt: string | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FlagComponent } from 'country-flag-icons/react/3x2'
|
||||
import {
|
||||
AE, AM, AR, AT, AU, AZ, BE, BG, BR, BY, CA, CH, CL, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GE, GR, HK,
|
||||
HR, HU, ID, IE, IL, IN, IS, IT, JP, KG, KR, KZ, LT, LU, LV, MD, MX, MY, NL, NO, PL, PT, RO, RS, RU,
|
||||
SE, SG, SI, SK, TH, TR, TW, UA, US, UZ, VN, ZA,
|
||||
} from 'country-flag-icons/react/3x2'
|
||||
|
||||
// Курируемый список стран, где обычно арендуют VPN-серверы: полный набор country-flag-icons весит
|
||||
// ~1 МБ SVG, а named-импорты тришейкаются. Нужна новая страна — добавить её сюда (бэкенд принимает
|
||||
// любой ISO 3166-1 alpha-2; неизвестный UI код показывается текстом вместо флага).
|
||||
// Emoji-флаги не используем: Windows их не рисует (вместо 🇩🇪 показывает «DE»).
|
||||
export const FLAGS: Record<string, FlagComponent> = {
|
||||
AE, AM, AR, AT, AU, AZ, BE, BG, BR, BY, CA, CH, CL, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GE, GR, HK,
|
||||
HR, HU, ID, IE, IL, IN, IS, IT, JP, KG, KR, KZ, LT, LU, LV, MD, MX, MY, NL, NO, PL, PT, RO, RS, RU,
|
||||
SE, SG, SI, SK, TH, TR, TW, UA, US, UZ, VN, ZA,
|
||||
}
|
||||
|
||||
export const COUNTRY_CODES = Object.keys(FLAGS)
|
||||
|
||||
/** Локализованное имя страны по ISO-коду (Intl), без собственных переводов. */
|
||||
export function countryName(code: string, language: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([language], { type: 'region' }).of(code) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,11 @@ const resources = {
|
||||
empty: 'У вас пока нет конфигов. Создайте первый.',
|
||||
create: 'Создать конфиг',
|
||||
selectLocation: 'Выберите локацию',
|
||||
load: { low: 'Свободно', medium: 'Средняя загрузка', high: 'Загружен' },
|
||||
nodeConfigs_one: '{{count}} конфиг на сервере',
|
||||
nodeConfigs_few: '{{count}} конфига на сервере',
|
||||
nodeConfigs_many: '{{count}} конфигов на сервере',
|
||||
nodeConfigs_other: '{{count}} конфига на сервере',
|
||||
location: 'Локация',
|
||||
label: 'Метка (необязательно)',
|
||||
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
|
||||
@@ -466,6 +471,9 @@ const resources = {
|
||||
enabled: 'Включена',
|
||||
disabled: 'Отключена',
|
||||
notifyOnStatusChange: 'Уведомлять в Telegram при смене статуса (онлайн/офлайн)',
|
||||
capacity: 'Ёмкость (конфигов)',
|
||||
capacityHint:
|
||||
'Сколько активных конфигов нода нормально тянет. Не лимит: по ней пользователям показывается загрузка в %, менее загруженные локации идут первыми. Пусто — показывается число конфигов.',
|
||||
inbounds: 'Инбаунды',
|
||||
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
||||
publish: 'Публикация',
|
||||
@@ -477,6 +485,8 @@ const resources = {
|
||||
publishSaved: 'Настройки публикации сохранены.',
|
||||
displayName: 'Отображаемое имя',
|
||||
allowedRoles: 'Доступно ролям',
|
||||
country: 'Страна (флаг у пользователей)',
|
||||
noCountry: 'Без флага',
|
||||
isPublishedLabel: 'Опубликовать инбаунд',
|
||||
optional: 'необязательно',
|
||||
passwordKeepUnchanged: 'оставьте пустым, чтобы не менять',
|
||||
@@ -688,6 +698,9 @@ const resources = {
|
||||
empty: "You don't have any configs yet. Create your first one.",
|
||||
create: 'Create config',
|
||||
selectLocation: 'Select location',
|
||||
load: { low: 'Low load', medium: 'Medium load', high: 'Busy' },
|
||||
nodeConfigs_one: '{{count}} config on server',
|
||||
nodeConfigs_other: '{{count}} configs on server',
|
||||
location: 'Location',
|
||||
label: 'Label (optional)',
|
||||
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
|
||||
@@ -1077,6 +1090,9 @@ const resources = {
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
notifyOnStatusChange: 'Notify in Telegram on status change (online/offline)',
|
||||
capacity: 'Capacity (configs)',
|
||||
capacityHint:
|
||||
'How many active configs the node handles comfortably. Not a limit: users see load in % based on it, less loaded locations come first. Empty — the config count is shown instead.',
|
||||
inbounds: 'Inbounds',
|
||||
noInbounds: 'No inbounds found — click "Sync".',
|
||||
publish: 'Publishing',
|
||||
@@ -1088,6 +1104,8 @@ const resources = {
|
||||
publishSaved: 'Publishing settings saved.',
|
||||
displayName: 'Display name',
|
||||
allowedRoles: 'Allowed for roles',
|
||||
country: 'Country (flag shown to users)',
|
||||
noCountry: 'No flag',
|
||||
isPublishedLabel: 'Publish inbound',
|
||||
optional: 'optional',
|
||||
passwordKeepUnchanged: 'leave blank to keep unchanged',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FLAGS } from '@/shared/lib/countries'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
export function CountryFlag({ code, className }: { code: string | null | undefined; className?: string }) {
|
||||
if (!code) return null
|
||||
const Flag = FLAGS[code.toUpperCase()]
|
||||
if (!Flag) {
|
||||
return <span className={cn('font-mono text-xs text-muted-foreground', className)}>{code.toUpperCase()}</span>
|
||||
}
|
||||
return <Flag className={cn('inline-block h-3.5 w-auto shrink-0 rounded-[2px]', className)} aria-hidden />
|
||||
}
|
||||
Reference in new issue
Block a user