Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1da243a
chore: release v2.2.1
kastov Oct 26, 2025
4d9cbb2
chore: release v2.2.2
kastov Oct 26, 2025
381850c
chore: release v2.2.3
kastov Oct 27, 2025
dffa984
chore: release v2.2.4
kastov Nov 2, 2025
4ef75bb
chore: release v2.2.5
kastov Nov 2, 2025
fed660a
chore: release v2.2.6
kastov Nov 6, 2025
3a4cb85
chore: release v2.3.0
kastov Dec 7, 2025
5fd3bb8
chore: release v2.3.1
kastov Dec 8, 2025
d71a3ee
chore: release v2.3.2
kastov Dec 9, 2025
5da7d1b
chore: release v2.4.0
kastov Dec 19, 2025
511aa08
fix: handle null date range in date picker across multiple components
kastov Dec 19, 2025
3ccc51f
chore: release v2.4.1
kastov Dec 20, 2025
add6e24
chore: release v2.4.2
kastov Dec 20, 2025
f5e5c2b
chore: release v2.4.3
kastov Dec 20, 2025
b2f9d68
chore: release v2.4.4
kastov Dec 22, 2025
bd06ce0
chore: release v2.5.0
kastov Jan 7, 2026
e569e81
chore: release v2.5.1
kastov Jan 7, 2026
611c1cd
chore: release v2.5.2
kastov Jan 7, 2026
3e8651b
chore: release v2.5.3
kastov Jan 9, 2026
5b9560c
chore: release v2.5.4
kastov Jan 16, 2026
d4e71d4
chore: release v2.5.5
kastov Jan 17, 2026
9463e71
chore: release v2.5.6
kastov Jan 20, 2026
8adeb59
chore: release v2.5.7
kastov Jan 21, 2026
95082f0
chore: release v2.6.0
kastov Jan 30, 2026
fb63d23
chore: release v2.6.1
kastov Feb 16, 2026
ad8a27e
chore: release v2.6.2
kastov Feb 24, 2026
500f103
chore: release v2.6.3
kastov Feb 25, 2026
39434c1
chore: release v2.6.4
kastov Feb 25, 2026
a65d4c9
chore: release v2.7.0
kastov Mar 28, 2026
7f18f5f
chore: release v2.7.1
kastov Mar 28, 2026
f4ec933
chore: release v2.7.2
kastov Mar 28, 2026
5f58671
chore: release v2.7.3
kastov Mar 29, 2026
180d246
chore: release v2.7.4
kastov Mar 30, 2026
8524329
feat: поддержка множественных тегов для хостов
StylerHub Apr 8, 2026
54b953d
fix: вернуть description в HostTagsInputWidget
StylerHub Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/shared/ui/forms/hosts/base-host-form/base-host-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,15 @@ export const BaseHostForm = <T extends CreateHostCommand.Request | UpdateHostCom
</Group>

<HostTagsInputWidget
key={form.key('tag')}
{...form.getInputProps('tag')}
value={form.getValues().tag}
key={form.key('tags')}
{...form.getInputProps('tags')}
error={
Object.keys(form.errors)
.filter((key) => key.startsWith('tags.'))
.map((key) => form.errors[key])
.join(', ') ||
form.getInputProps('tags').error
}
Comment on lines +466 to +472
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Array-error join may produce [object Object] for non-string ReactNode errors

form.errors[key] is typed as ReactNode, so .map(...).join(', ') will stringify any React element to [object Object]. In practice Zod validation only produces string messages, but the cast is fragile. Casting explicitly keeps this safe:

Suggested change
error={
Object.keys(form.errors)
.filter((key) => key.startsWith('tags.'))
.map((key) => form.errors[key])
.join(', ') ||
form.getInputProps('tags').error
}
error={
Object.keys(form.errors)
.filter((key) => key.startsWith('tags.'))
.map((key) => form.errors[key] as string)
.join(', ') ||
form.getInputProps('tags').error
}

/>

<MultiSelect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export const EditHostModalWidget = memo(() => {
muxParams: muxParamsParsed,
sockoptParams: sockoptParamsParsed,
finalMask: finalMaskParsed,
tag: host.tag ?? undefined,
tags: host.tags ?? [],
isHidden: host.isHidden,
overrideSniFromAddress: host.overrideSniFromAddress,
keepSniBlank: host.keepSniBlank,
Expand Down Expand Up @@ -259,7 +259,7 @@ export const EditHostModalWidget = memo(() => {
muxParams,
sockoptParams,
finalMask,
tag: values.tag === '' ? null : values.tag
tags: values.tags ?? []
}
})
})
Expand Down Expand Up @@ -300,7 +300,7 @@ export const EditHostModalWidget = memo(() => {
},
serverDescription: host.serverDescription ?? undefined,
sockoptParams: host.sockoptParams ?? undefined,
tag: host.tag ?? undefined,
tags: host.tags ?? [],
overrideSniFromAddress: host.overrideSniFromAddress,
keepSniBlank: host.keepSniBlank,
vlessRouteId: host.vlessRouteId ?? undefined,
Expand Down
79 changes: 65 additions & 14 deletions src/widgets/dashboard/hosts/host-card/host-card.widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function HostCardWidget(props: IProps) {
const openModalWithData = useModalsStoreOpenWithData()

const [isHovered, setIsHovered] = useState(false)
const [tagsExpanded, setTagsExpanded] = useState(false)
const isMobile = useMediaQuery('(max-width: 48em)')

const configProfile = configProfiles?.find(
Expand All @@ -46,7 +47,7 @@ export function HostCardWidget(props: IProps) {
const isFiltered =
(!!filters.configProfileUuid && configProfile?.uuid !== filters.configProfileUuid) ||
(!!filters.inboundUuid && item.inbound.configProfileInboundUuid !== filters.inboundUuid) ||
(!!filters.hostTag && item.tag !== filters.hostTag)
(!!filters.hostTag && !item.tags?.includes(filters.hostTag))

const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.uuid,
Expand Down Expand Up @@ -123,16 +124,33 @@ export function HostCardWidget(props: IProps) {
</Box>
</Group>

<Group gap="xs">
{item.tag && (
<Group gap="xs" wrap="wrap">
{(tagsExpanded ? item.tags : item.tags?.slice(0, 2))?.map(
(tag) => (
<Badge
autoContrast
color={ch.hex(tag)}
key={tag}
leftSection={<TbTagStarred size={12} />}
size="md"
variant="outline"
>
{tag}
</Badge>
)
)}
{(item.tags?.length ?? 0) > 2 && (
<Badge
autoContrast
color={ch.hex(item.tag)}
leftSection={<TbTagStarred size={12} />}
color="gray"
onClick={(e) => {
e.stopPropagation()
setTagsExpanded((v) => !v)
}}
size="md"
variant="outline"
style={{ cursor: 'pointer' }}
variant="default"
>
{item.tag}
{tagsExpanded ? '−' : `+${item.tags!.length - 2}`}
</Badge>
)}
</Group>
Expand Down Expand Up @@ -348,15 +366,31 @@ export function HostCardWidget(props: IProps) {
</Badge>
)}

{item.tag && (
{!tagsExpanded &&
item.tags?.slice(0, 2).map((tag) => (
<Badge
autoContrast
color={ch.hex(tag)}
key={tag}
leftSection={<TbTagStarred size={12} />}
size="md"
variant="outline"
>
{tag}
</Badge>
))}
Comment on lines +369 to +381
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Expanded state hides inline tags entirely

When tagsExpanded is true, the {!tagsExpanded && ...} guard removes the first-two tags from the header row entirely; only the "−" collapse badge stays. The expanded row below shows all tags, so users can see them, but it creates a visual jump (tags disappear from the header, re-appear below). A more common pattern is to keep showing the first N tags inline and add/remove the overflow row on expand. Consider whether this is the intended UX.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

{(item.tags?.length ?? 0) > 2 && (
<Badge
autoContrast
color={ch.hex(item.tag)}
leftSection={<TbTagStarred size={12} />}
color="gray"
onClick={(e) => {
e.stopPropagation()
setTagsExpanded((v) => !v)
}}
size="md"
variant="outline"
style={{ cursor: 'pointer' }}
variant="default"
>
{item.tag}
{tagsExpanded ? '−' : `+${item.tags!.length - 2}`}
</Badge>
)}

Expand Down Expand Up @@ -392,6 +426,23 @@ export function HostCardWidget(props: IProps) {
</Badge>
</Group>
</Group>

{tagsExpanded && (item.tags?.length ?? 0) > 0 && (
<Group gap="xs" mt="xs" wrap="wrap">
{item.tags!.map((tag) => (
<Badge
autoContrast
color={ch.hex(tag)}
key={tag}
leftSection={<TbTagStarred size={12} />}
size="md"
variant="outline"
>
{tag}
</Badge>
))}
</Group>
)}
</Box>
</Group>
</Box>
Expand Down
156 changes: 16 additions & 140 deletions src/widgets/dashboard/hosts/host-tags-input/host-tags-input.tsx
Original file line number Diff line number Diff line change
@@ -1,155 +1,31 @@
/* eslint-disable no-nested-ternary */
import { CloseButton, Combobox, InputBase, Loader, useCombobox } from '@mantine/core'
import { TagsInput, TagsInputProps } from '@mantine/core'
import { useTranslation } from 'react-i18next'
import { PiTagDuotone } from 'react-icons/pi'
import { useEffect, useState } from 'react'

import { useGetHostTags } from '@shared/api/hooks'

import type { IProps } from './interfaces/props.interface'

export function HostTagsInputWidget(props: IProps) {
const { value, onChange, defaultValue, ...restProps } = props
const [data, setData] = useState<string[]>([])
const [search, setSearch] = useState(value?.toString() || '')
const [error, setError] = useState('')

export function HostTagsInputWidget(props: TagsInputProps) {
const { t } = useTranslation()

const {
data: hostTags,
isRefetching: isHostTagsRefetching,
isLoading: isHostTagsLoading
} = useGetHostTags({
const { data: hostTags } = useGetHostTags({
rQueryParams: {
enabled: false
}
})

const combobox = useCombobox({
onDropdownOpen: async () => {
if (!isHostTagsLoading && !isHostTagsRefetching) {
setData(hostTags?.tags || [])
}
}
})

const validateTag = (tag: string) => {
if (!/^[A-Z0-9_:]+$/.test(tag)) {
return t(
'host-tags-input.tag-can-only-contain-uppercase-letters-numbers-underscores-and-colons'
)
}
if (tag.length > 32) {
return t('host-tags-input.tag-must-be-less-than-32-characters')
}
return null
}

useEffect(() => {
if (hostTags && !isHostTagsLoading && !isHostTagsRefetching) {
setData(hostTags.tags)
}
}, [hostTags, isHostTagsLoading, isHostTagsRefetching])

const exactOptionMatch = data.some((item) => item === search)
const filteredOptions = exactOptionMatch
? data
: data.filter((item) => item.toLowerCase().includes(search.toLowerCase().trim()))

const options = filteredOptions.map((item) => (
<Combobox.Option key={item} value={item}>
{item}
</Combobox.Option>
))

return (
<Combobox
onExitTransitionEnd={() => {
combobox.resetSelectedOption()
}}
onOptionSubmit={(val) => {
if (val === '$create') {
const validationError = validateTag(search)
if (validationError) {
setError(validationError)
return
}
setData((current) => [...current, search])
onChange?.(search)
setError('')

return
}

onChange?.(val)
setSearch(val)
setError('')

combobox.closeDropdown()
}}
position="top"
store={combobox}
withinPortal={false}
>
<Combobox.Target>
<InputBase
{...restProps}
description={t(
'host-tags-input.tags-are-not-visible-to-end-users-tag-will-be-sent-with-raw-subscription-only'
)}
error={error || restProps.error}
label="Tag"
leftSection={<PiTagDuotone size="16px" />}
onBlur={() => {
combobox.closeDropdown()
onChange?.(search.trim() === '' ? null : search)
}}
onChange={(event) => {
combobox.openDropdown()
combobox.updateSelectedOptionIndex()
setSearch(event.currentTarget.value)
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
placeholder="ROUTING_HOST"
rightSection={
isHostTagsRefetching ? (
<Loader size="xs" />
) : (value !== null && value !== undefined) || search ? (
<CloseButton
aria-label={t('host-tags-input.clear-value')}
onClick={() => {
onChange?.(null)
setError('')
setSearch('')
}}
onMouseDown={(event) => event.preventDefault()}
size="sm"
/>
) : (
<Combobox.Chevron />
)
}
rightSectionPointerEvents={
(value === null || value === undefined) && !search ? 'none' : 'all'
}
value={search}
/>
</Combobox.Target>

<Combobox.Dropdown>
<Combobox.Options mah={200} style={{ overflowY: 'auto' }}>
{isHostTagsLoading || isHostTagsRefetching ? (
<Combobox.Empty>Loading....</Combobox.Empty>
) : (
options
)}
{!exactOptionMatch && search.trim().length > 0 && (
<Combobox.Option value="$create">+ {search}</Combobox.Option>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
<TagsInput
clearable
data={hostTags?.tags || []}
description={t(
'host-tags-input.tags-are-not-visible-to-end-users-tag-will-be-sent-with-raw-subscription-only'
)}
label="Tags"
leftSection={<PiTagDuotone size="16px" />}
maxTags={10}
placeholder="Enter tags (comma, space, semicolon)"
splitChars={[',', ' ', ';']}
Comment on lines +24 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded strings not passed through i18n

label and placeholder are plain English strings while description uses t(...). In a locale other than English these two strings will always appear in English. For consistency with the rest of the form, both should go through useTranslation.

Suggested change
leftSection={<PiTagDuotone size="16px" />}
maxTags={10}
placeholder="Enter tags (comma, space, semicolon)"
splitChars={[',', ' ', ';']}
label={t('host-tags-input.label')}
leftSection={<PiTagDuotone size="16px" />}
maxTags={10}
placeholder={t('host-tags-input.placeholder')}

{...props}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import type { InputBaseProps } from '@mantine/core'
import type { TagsInputProps } from '@mantine/core'

export interface IProps extends InputBaseProps {
defaultValue?: null | string
onChange?: (value: null | string) => void
value?: null | string
}
export interface IProps extends TagsInputProps {}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function buildMarkdownDescription(host: Host): string {
`| **Status** | ${icon} ${label} |`
]

if (host.tag) rows.push(`| **Tag** | \`${host.tag}\` |`)
if (host.tags?.length) rows.push(`| **Tags** | \`${host.tags.join(', ')}\` |`)
if (host.sni) rows.push(`| **SNI** | \`${host.sni}\` |`)
if (host.serverDescription) rows.push(`| **Description** | ${host.serverDescription} |`)
if (host.inbound.configProfileUuid) {
Expand Down