Skip to content

Commit 147df6e

Browse files
committed
feat: add search functionality across multiple components for improved user experience
1 parent eb9a6cd commit 147df6e

File tree

9 files changed

+200
-12
lines changed

9 files changed

+200
-12
lines changed

dashboard/public/statics/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"noNodes": "No nodes configured",
6161
"noNodesDescription": "Master Panel doesn't have any cores configured. You need to add and configure a node from",
6262
"noNodesDescription2": "connect and configure it to the panel.",
63+
"noSearchResults": "No nodes match your search criteria. Try adjusting your search terms.",
6364
"logs": {
6465
"title": "Logs",
6566
"description": "View and monitor node logs",

dashboard/public/statics/locales/fa.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,7 @@
12831283
"noNodes": "هیچ گره ای پیکربندی نشده",
12841284
"noNodesDescription": "پنل اصلی هیچ هسته‌ای ندارد. شما باید یک گره از",
12851285
"noNodesDescription2": "پیکربندی کرده و آن را به پنل متصل کنید.",
1286+
"noSearchResults": "هیچ گره‌ای با معیارهای جستجوی شما مطابقت ندارد. لطفاً عبارات جستجوی خود را تغییر دهید.",
12861287
"logs": {
12871288
"title": "لاگ‌ها",
12881289
"description": "مشاهده و نظارت بر لاگ‌های گره‌ها",

dashboard/public/statics/locales/ru.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"noNodes": "Узлы не настроены",
8888
"noNodesDescription": "Главная панель не имеет настроенных ядер. Вам нужно добавить и настроить узел из",
8989
"noNodesDescription2": "подключить и настроить его к панели.",
90+
"noSearchResults": "Нет узлов, соответствующих вашим критериям поиска. Попробуйте изменить условия поиска.",
9091
"logs": {
9192
"title": "Журналы",
9293
"description": "Просмотр и мониторинг журналов узла",

dashboard/public/statics/locales/zh.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"noNodes": "未配置节点",
5757
"noNodesDescription": "主面板没有配置任何核心。您需要从",
5858
"noNodesDescription2": "连接和配置节点,并将其连接到面板。",
59+
"noSearchResults": "没有节点匹配您的搜索条件。请尝试调整搜索词。",
5960
"logs": {
6061
"title": "日志",
6162
"description": "查看和监控节点日志",

dashboard/src/components/groups/groups-list.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useGetAllGroups, useModifyGroup } from '@/service/api'
22
import { GroupResponse } from '@/service/api'
33
import Group from './group'
4-
import { useState } from 'react'
4+
import { useState, useMemo } from 'react'
55
import GroupModal, { groupFormSchema, GroupFormValues } from '@/components/dialogs/group-modal'
66
import { useForm } from 'react-hook-form'
77
import { zodResolver } from '@hookform/resolvers/zod'
@@ -12,6 +12,9 @@ import { queryClient } from '@/utils/query-client'
1212
import useDirDetection from '@/hooks/use-dir-detection'
1313
import { Skeleton } from '@/components/ui/skeleton'
1414
import { Card } from '@/components/ui/card'
15+
import { Input } from '@/components/ui/input'
16+
import { Search, X } from 'lucide-react'
17+
import { cn } from '@/lib/utils'
1518

1619
const initialDefaultValues: Partial<GroupFormValues> = {
1720
name: '',
@@ -26,6 +29,7 @@ interface GroupsListProps {
2629

2730
export default function GroupsList({ isDialogOpen, onOpenChange }: GroupsListProps) {
2831
const [editingGroup, setEditingGroup] = useState<GroupResponse | null>(null)
32+
const [searchQuery, setSearchQuery] = useState('')
2933
const { t } = useTranslation()
3034
const modifyGroupMutation = useModifyGroup()
3135
const dir = useDirDetection()
@@ -78,8 +82,32 @@ export default function GroupsList({ isDialogOpen, onOpenChange }: GroupsListPro
7882
}
7983
}
8084

85+
const filteredGroups = useMemo(() => {
86+
if (!groupsData?.groups || !searchQuery.trim()) return groupsData?.groups
87+
const query = searchQuery.toLowerCase().trim()
88+
return groupsData.groups.filter((group: GroupResponse) => group.name?.toLowerCase().includes(query))
89+
}, [groupsData?.groups, searchQuery])
90+
8191
return (
8292
<div className="w-full flex-1 space-y-4 pt-4">
93+
{/* Search Input */}
94+
<div className="relative w-full md:w-[calc(100%/3-10px)]" dir={dir}>
95+
<Search className={cn('absolute', dir === 'rtl' ? 'right-2' : 'left-2', 'top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground')} />
96+
<Input
97+
placeholder={t('search')}
98+
value={searchQuery}
99+
onChange={e => setSearchQuery(e.target.value)}
100+
className={cn('pl-8 pr-10', dir === 'rtl' && 'pr-8 pl-10')}
101+
/>
102+
{searchQuery && (
103+
<button
104+
onClick={() => setSearchQuery('')}
105+
className={cn('absolute', dir === 'rtl' ? 'left-2' : 'right-2', 'top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground')}
106+
>
107+
<X className="h-4 w-4" />
108+
</button>
109+
)}
110+
</div>
83111
<ScrollArea className="h-[calc(100vh-8rem)]">
84112
<div dir={dir} className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
85113
{isLoading
@@ -95,7 +123,7 @@ export default function GroupsList({ isDialogOpen, onOpenChange }: GroupsListPro
95123
</div>
96124
</Card>
97125
))
98-
: groupsData?.groups.map(group => <Group key={group.id} group={group} onEdit={handleEdit} onToggleStatus={handleToggleStatus} />)}
126+
: filteredGroups?.map(group => <Group key={group.id} group={group} onEdit={handleEdit} onToggleStatus={handleToggleStatus} />)}
99127
</div>
100128
</ScrollArea>
101129

dashboard/src/components/hosts/hosts-list.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ import { queryClient } from '@/utils/query-client'
33
import { closestCenter, DndContext, DragEndEvent, KeyboardSensor, PointerSensor, UniqueIdentifier, useSensor, useSensors } from '@dnd-kit/core'
44
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
55
import { zodResolver } from '@hookform/resolvers/zod'
6-
import { useEffect, useState } from 'react'
6+
import { useEffect, useState, useMemo } from 'react'
77
import { useForm } from 'react-hook-form'
88
import { useTranslation } from 'react-i18next'
99
import { toast } from 'sonner'
1010
import * as z from 'zod'
1111
import HostModal from '../dialogs/host-modal'
1212
import SortableHost from './sortable-host'
13+
import { Input } from '@/components/ui/input'
14+
import { Search, X } from 'lucide-react'
15+
import useDirDetection from '@/hooks/use-dir-detection'
16+
import { cn } from '@/lib/utils'
1317

1418
interface Brutal {
1519
enable?: boolean
@@ -440,7 +444,9 @@ export interface HostsListProps {
440444
export default function HostsList({ data, onAddHost, isDialogOpen, onSubmit, editingHost, setEditingHost }: HostsListProps) {
441445
const [hosts, setHosts] = useState<BaseHost[] | undefined>()
442446
const [isUpdatingPriorities, setIsUpdatingPriorities] = useState(false)
447+
const [searchQuery, setSearchQuery] = useState('')
443448
const { t } = useTranslation()
449+
const dir = useDirDetection()
444450

445451
// Set up hosts data from props
446452
useEffect(() => {
@@ -792,14 +798,47 @@ export default function HostsList({ data, onAddHost, isDialogOpen, onSubmit, edi
792798
return idA - idB
793799
})
794800

801+
// Filter hosts by search query
802+
const filteredHosts = useMemo(() => {
803+
if (!searchQuery.trim()) return sortedHosts
804+
const query = searchQuery.toLowerCase().trim()
805+
return sortedHosts.filter(host => {
806+
const remarkMatch = host.remark?.toLowerCase().includes(query)
807+
const addressMatch = Array.isArray(host.address) ? host.address.some(addr => addr.toLowerCase().includes(query)) : false
808+
const inboundTagMatch = host.inbound_tag?.toLowerCase().includes(query)
809+
const hostMatch = Array.isArray(host.host) ? host.host.some(h => h.toLowerCase().includes(query)) : false
810+
return remarkMatch || addressMatch || inboundTagMatch || hostMatch
811+
})
812+
}, [sortedHosts, searchQuery])
813+
795814
return (
796815
<div>
816+
{/* Search Input */}
817+
<div className="mb-4">
818+
<div className="relative w-full md:w-[calc(100%/3-10px)]" dir={dir}>
819+
<Search className={cn('absolute', dir === 'rtl' ? 'right-2' : 'left-2', 'top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground')} />
820+
<Input
821+
placeholder={t('search')}
822+
value={searchQuery}
823+
onChange={e => setSearchQuery(e.target.value)}
824+
className={cn('pl-8 pr-10', dir === 'rtl' && 'pr-8 pl-10')}
825+
/>
826+
{searchQuery && (
827+
<button
828+
onClick={() => setSearchQuery('')}
829+
className={cn('absolute', dir === 'rtl' ? 'left-2' : 'right-2', 'top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground')}
830+
>
831+
<X className="h-4 w-4" />
832+
</button>
833+
)}
834+
</div>
835+
</div>
797836
<div>
798837
<DndContext sensors={isUpdatingPriorities ? [] : sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
799838
<SortableContext items={sortableHosts} strategy={rectSortingStrategy}>
800839
<div className="max-w-screen-[2000px] min-h-screen overflow-hidden">
801840
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
802-
{sortedHosts.map(host => (
841+
{filteredHosts.map(host => (
803842
<SortableHost key={host.id ?? 'new'} host={host} onEdit={handleEdit} onDuplicate={handleDuplicate} onDataChanged={refreshHostsData} disabled={isUpdatingPriorities} />
804843
))}
805844
</div>

dashboard/src/components/nodes/nodes-list.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState, useEffect, useMemo } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import Node from '@/components/nodes/node'
44
import { useGetNodes, useModifyNode, NodeResponse, NodeConnectionType } from '@/service/api'
@@ -10,6 +10,10 @@ import { zodResolver } from '@hookform/resolvers/zod'
1010
import { nodeFormSchema, NodeFormValues } from '@/components/dialogs/node-modal'
1111
import { Card, CardContent } from '@/components/ui/card'
1212
import { Skeleton } from '@/components/ui/skeleton'
13+
import { Input } from '@/components/ui/input'
14+
import { Search, X } from 'lucide-react'
15+
import useDirDetection from '@/hooks/use-dir-detection'
16+
import { cn } from '@/lib/utils'
1317

1418
const initialDefaultValues: Partial<NodeFormValues> = {
1519
name: '',
@@ -25,7 +29,9 @@ export default function NodesList() {
2529
const { t } = useTranslation()
2630
const [isDialogOpen, setIsDialogOpen] = useState(false)
2731
const [editingNode, setEditingNode] = useState<NodeResponse | null>(null)
32+
const [searchQuery, setSearchQuery] = useState('')
2833
const modifyNodeMutation = useModifyNode()
34+
const dir = useDirDetection()
2935

3036
const { data: nodesData, isLoading } = useGetNodes(undefined, {
3137
query: {
@@ -99,9 +105,39 @@ export default function NodesList() {
99105
}
100106
}
101107

108+
const filteredNodes = useMemo(() => {
109+
if (!nodesData || !searchQuery.trim()) return nodesData
110+
const query = searchQuery.toLowerCase().trim()
111+
return nodesData.filter(
112+
node =>
113+
node.name?.toLowerCase().includes(query) ||
114+
node.address?.toLowerCase().includes(query) ||
115+
node.connection_type?.toLowerCase().includes(query),
116+
)
117+
}, [nodesData, searchQuery])
118+
102119
return (
103120
<div className="flex w-full flex-col items-start gap-2">
104121
<div className="w-full flex-1 space-y-4 pt-6">
122+
{/* Search Input */}
123+
<div className="relative w-full md:w-[calc(100%/3-10px)]" dir={dir}>
124+
<Search className={cn('absolute', dir === 'rtl' ? 'right-2' : 'left-2', 'top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground')} />
125+
<Input
126+
placeholder={t('search')}
127+
value={searchQuery}
128+
onChange={e => setSearchQuery(e.target.value)}
129+
className={cn('pl-8 pr-10', dir === 'rtl' && 'pr-8 pl-10')}
130+
/>
131+
{searchQuery && (
132+
<button
133+
onClick={() => setSearchQuery('')}
134+
className={cn('absolute', dir === 'rtl' ? 'left-2' : 'right-2', 'top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground')}
135+
>
136+
<X className="h-4 w-4" />
137+
</button>
138+
)}
139+
</div>
140+
105141
<div
106142
className="mb-12 grid transform-gpu animate-slide-up grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
107143
style={{ animationDuration: '500ms', animationDelay: '100ms', animationFillMode: 'both' }}
@@ -122,7 +158,7 @@ export default function NodesList() {
122158
</div>
123159
</Card>
124160
))
125-
: nodesData?.map(node => <Node key={node.id} node={node} onEdit={handleEdit} onToggleStatus={handleToggleStatus} />)}
161+
: filteredNodes?.map(node => <Node key={node.id} node={node} onEdit={handleEdit} onToggleStatus={handleToggleStatus} />)}
126162
</div>
127163

128164
{!isLoading && (!nodesData || nodesData.length === 0) && (
@@ -142,6 +178,19 @@ export default function NodesList() {
142178
</Card>
143179
)}
144180

181+
{!isLoading && nodesData && nodesData.length > 0 && (!filteredNodes || filteredNodes.length === 0) && (
182+
<Card className="mb-12">
183+
<CardContent className="p-8 text-center">
184+
<div className="space-y-4">
185+
<h3 className="text-lg font-semibold">{t('noResults')}</h3>
186+
<p className="mx-auto max-w-2xl text-muted-foreground">
187+
{t('nodes.noSearchResults', { defaultValue: 'No nodes match your search criteria. Try adjusting your search terms.' })}
188+
</p>
189+
</div>
190+
</CardContent>
191+
</Card>
192+
)}
193+
145194
<NodeModal
146195
isDialogOpen={isDialogOpen}
147196
onOpenChange={open => {

dashboard/src/components/settings/cores.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useGetAllCores, useModifyCoreConfig } from '@/service/api'
22
import { CoreResponse } from '@/service/api'
33
import Core from './core'
4-
import { useState, useEffect } from 'react'
4+
import { useState, useEffect, useMemo } from 'react'
55
import CoreConfigModal, { coreConfigFormSchema, CoreConfigFormValues } from '@/components/dialogs/core-config-modal'
66
import { useForm } from 'react-hook-form'
77
import { zodResolver } from '@hookform/resolvers/zod'
@@ -12,6 +12,9 @@ import { queryClient } from '@/utils/query-client'
1212
import useDirDetection from '@/hooks/use-dir-detection'
1313
import { Skeleton } from '@/components/ui/skeleton'
1414
import { Card } from '@/components/ui/card'
15+
import { Input } from '@/components/ui/input'
16+
import { Search, X } from 'lucide-react'
17+
import { cn } from '@/lib/utils'
1518

1619
const initialDefaultValues: Partial<CoreConfigFormValues> = {
1720
name: '',
@@ -30,6 +33,7 @@ interface CoresProps {
3033

3134
export default function Cores({ isDialogOpen, onOpenChange, cores, onEditCore, onDuplicateCore, onDeleteCore }: CoresProps) {
3235
const [editingCore, setEditingCore] = useState<CoreResponse | null>(null)
36+
const [searchQuery, setSearchQuery] = useState('')
3337
const { t } = useTranslation()
3438
const modifyCoreMutation = useModifyCoreConfig()
3539
const dir = useDirDetection()
@@ -105,10 +109,38 @@ export default function Cores({ isDialogOpen, onOpenChange, cores, onEditCore, o
105109
onOpenChange?.(open)
106110
}
107111

112+
const coresList = cores || coresData?.cores || []
113+
114+
const filteredCores = useMemo(() => {
115+
if (!searchQuery.trim()) return coresList
116+
const query = searchQuery.toLowerCase().trim()
117+
return coresList.filter((core: CoreResponse) => core.name?.toLowerCase().includes(query))
118+
}, [coresList, searchQuery])
119+
108120
return (
109-
<div className="w-full flex-1">
121+
<div className={cn('flex w-full flex-col gap-4 py-4', dir === 'rtl' && 'rtl')}>
122+
<div className='mt-2'>
123+
{/* Search Input */}
124+
<div className="relative w-full md:w-[calc(100%/3-10px)]" dir={dir}>
125+
<Search className={cn('absolute', dir === 'rtl' ? 'right-2' : 'left-2', 'top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground')} />
126+
<Input
127+
placeholder={t('search')}
128+
value={searchQuery}
129+
onChange={e => setSearchQuery(e.target.value)}
130+
className={cn('pl-8 pr-10', dir === 'rtl' && 'pr-8 pl-10')}
131+
/>
132+
{searchQuery && (
133+
<button
134+
onClick={() => setSearchQuery('')}
135+
className={cn('absolute', dir === 'rtl' ? 'left-2' : 'right-2', 'top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground')}
136+
>
137+
<X className="h-4 w-4" />
138+
</button>
139+
)}
140+
</div>
141+
</div>
110142
<ScrollArea dir={dir} className="h-[calc(100vh-8rem)]">
111-
<div className="grid w-full grid-cols-1 gap-4 pt-6 md:grid-cols-2 lg:grid-cols-3">
143+
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
112144
{isLoading
113145
? [...Array(6)].map((_, i) => (
114146
<Card key={i} className="px-4 py-5">
@@ -121,7 +153,7 @@ export default function Cores({ isDialogOpen, onOpenChange, cores, onEditCore, o
121153
</div>
122154
</Card>
123155
))
124-
: (cores || coresData?.cores)?.map((core: CoreResponse) => (
156+
: filteredCores.map((core: CoreResponse) => (
125157
<Core
126158
key={core.id}
127159
core={core}

0 commit comments

Comments
 (0)