refactor: integrate TagCombobox into SearchBar and remove TagFilter component
- Added TagCombobox to SearchBar for improved tag selection functionality. - Removed TagFilter component as its functionality is now encapsulated in SearchBar. - Updated ServerList to utilize new tag handling methods from SearchBar.
cognitive-glitch committed
Nov 25, 2025 at 00:36 UTC
e313532f9bc46121a98e6873f347e0c9d4e3f2de
4 files changed
+229
-131
cmd/relay-server/frontend/src/components/SearchBar.tsx
+58
-36
@@ -8,6 +8,7 @@ import {
8
SelectValue,
9
} from "@/components/ui/select";
10
import type { SortOption, StatusFilter } from "@/types/filters";
11
+import { TagCombobox } from "@/components/TagCombobox";
12
13
interface SearchBarProps {
14
searchQuery: string;
@@ -16,6 +17,12 @@ interface SearchBarProps {
17
onStatusChange: (value: StatusFilter) => void;
18
sortBy: SortOption;
19
onSortByChange: (value: SortOption) => void;
20
+ availableTags: string[];
21
+ selectedTags: string[];
22
+ onAddTag: (tag: string) => void;
23
+ onRemoveTag: (tag: string) => void;
24
+ tagMode: "AND" | "OR";
25
+ onTagModeChange: (mode: "AND" | "OR") => void;
26
}
27
28
export function SearchBar({
@@ -25,47 +32,62 @@ export function SearchBar({
32
onStatusChange,
33
sortBy,
34
onSortByChange,
35
+ availableTags,
36
+ selectedTags,
37
+ onAddTag,
38
+ onRemoveTag,
39
+ tagMode,
40
+ onTagModeChange,
41
}: SearchBarProps) {
42
return (
30
- <div className="space-y-4 px-4 sm:px-6">
31
- <label className="flex flex-col min-w-40 h-12 w-full">
32
- <div className="flex w-full flex-1 items-stretch rounded-lg h-full">
33
- <div className="text-text-muted flex border-none bg-border items-center justify-center pl-4 rounded-l-lg border-r-0">
34
- <Search className="w-5 h-5" />
35
- </div>
36
- <Input
37
- placeholder="Search by server name..."
38
- value={searchQuery}
39
- onChange={(e) => onSearchChange(e.target.value)}
40
- />
43
+ <div className="flex flex-wrap items-center gap-3 px-4 sm:px-6">
44
+ <label className="flex min-w-[220px] flex-1 items-stretch h-11">
45
+ <div className="text-text-muted flex items-center justify-center pl-4 pr-2 rounded-l-lg bg-border">
46
+ <Search className="w-5 h-5" />
47
</div>
48
+ <Input
49
+ placeholder="Search by server name..."
50
+ value={searchQuery}
51
+ onChange={(e) => onSearchChange(e.target.value)}
52
+ className="rounded-l-none"
53
+ />
54
</label>
43
- <div className="flex flex-wrap gap-3">
44
- <Select value={status} onValueChange={onStatusChange}>
45
- <SelectTrigger className="w-[140px] h-8">
46
- <SelectValue placeholder="Status" />
47
- </SelectTrigger>
48
- <SelectContent>
49
- <SelectItem value="all">All Status</SelectItem>
50
- <SelectItem value="online">Online</SelectItem>
51
- <SelectItem value="offline">Offline</SelectItem>
52
- </SelectContent>
53
- </Select>
55
55
- <Select value={sortBy} onValueChange={onSortByChange}>
56
- <SelectTrigger className="w-[140px] h-8">
57
- <SelectValue placeholder="Sort By" />
58
- </SelectTrigger>
59
- <SelectContent>
60
- <SelectItem value="default">Default</SelectItem>
61
- <SelectItem value="name-asc">Name (A-Z)</SelectItem>
62
- <SelectItem value="name-desc">Name (Z-A)</SelectItem>
63
- <SelectItem value="updated">Recently Updated</SelectItem>
64
- <SelectItem value="description">Description</SelectItem>
65
- <SelectItem value="tags">Tags</SelectItem>
66
- <SelectItem value="owner">Owner</SelectItem>
67
- </SelectContent>
68
- </Select>
56
+ <Select value={status} onValueChange={onStatusChange}>
57
+ <SelectTrigger className="w-[130px] h-10">
58
+ <SelectValue placeholder="Status" />
59
+ </SelectTrigger>
60
+ <SelectContent>
61
+ <SelectItem value="all">All Status</SelectItem>
62
+ <SelectItem value="online">Online</SelectItem>
63
+ <SelectItem value="offline">Offline</SelectItem>
64
+ </SelectContent>
65
+ </Select>
66
+
67
+ <Select value={sortBy} onValueChange={onSortByChange}>
68
+ <SelectTrigger className="w-[150px] h-10">
69
+ <SelectValue placeholder="Sort By" />
70
+ </SelectTrigger>
71
+ <SelectContent>
72
+ <SelectItem value="default">Default</SelectItem>
73
+ <SelectItem value="name-asc">Name (A-Z)</SelectItem>
74
+ <SelectItem value="name-desc">Name (Z-A)</SelectItem>
75
+ <SelectItem value="updated">Recently Updated</SelectItem>
76
+ <SelectItem value="description">Description</SelectItem>
77
+ <SelectItem value="tags">Tags</SelectItem>
78
+ <SelectItem value="owner">Owner</SelectItem>
79
+ </SelectContent>
80
+ </Select>
81
+
82
+ <div className="relative flex min-w-[320px] flex-1">
83
+ <TagCombobox
84
+ availableTags={availableTags}
85
+ selectedTags={selectedTags}
86
+ onAdd={onAddTag}
87
+ onRemove={onRemoveTag}
88
+ mode={tagMode}
89
+ onModeChange={onTagModeChange}
90
+ />
91
</div>
92
</div>
93
);
cmd/relay-server/frontend/src/components/TagCombobox.tsx
new
+165
@@ -0,0 +1,165 @@
1
+import { useEffect, useMemo, useRef, useState } from "react";
2
+import { Button } from "@/components/ui/button";
3
+import type { TagMode } from "@/types/filters";
4
+import { cn } from "@/lib/utils";
5
+
6
+type TagComboboxProps = {
7
+ availableTags: string[];
8
+ selectedTags: string[];
9
+ mode: TagMode;
10
+ onModeChange: (mode: TagMode) => void;
11
+ onAdd: (tag: string) => void;
12
+ onRemove: (tag: string) => void;
13
+};
14
+
15
+export function TagCombobox({
16
+ availableTags,
17
+ selectedTags,
18
+ mode,
19
+ onModeChange,
20
+ onAdd,
21
+ onRemove,
22
+}: TagComboboxProps) {
23
+ const [inputValue, setInputValue] = useState("");
24
+ const [open, setOpen] = useState(false);
25
+ const [activeIndex, setActiveIndex] = useState(0);
26
+ const listId = "tag-combobox-list";
27
+ const inputRef = useRef<HTMLInputElement | null>(null);
28
+
29
+ const filtered = useMemo(() => {
30
+ const query = inputValue.trim().toLowerCase();
31
+ const pool = availableTags.filter((tag) => !selectedTags.includes(tag));
32
+ if (!query) return pool.slice(0, 8);
33
+ return pool
34
+ .filter((tag) => tag.toLowerCase().includes(query))
35
+ .slice(0, 8);
36
+ }, [availableTags, selectedTags, inputValue]);
37
+
38
+ useEffect(() => {
39
+ setActiveIndex(0);
40
+ }, [filtered.length]);
41
+
42
+ const addTag = (tag: string) => {
43
+ if (!tag || selectedTags.includes(tag)) return;
44
+ onAdd(tag);
45
+ setInputValue("");
46
+ setOpen(false);
47
+ };
48
+
49
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
50
+ if (e.key === "ArrowDown") {
51
+ e.preventDefault();
52
+ setOpen(true);
53
+ setActiveIndex((i) => (i + 1) % Math.max(filtered.length, 1));
54
+ } else if (e.key === "ArrowUp") {
55
+ e.preventDefault();
56
+ setOpen(true);
57
+ setActiveIndex((i) =>
58
+ filtered.length === 0 ? 0 : (i - 1 + filtered.length) % filtered.length
59
+ );
60
+ } else if (e.key === "Enter") {
61
+ e.preventDefault();
62
+ if (open && filtered[activeIndex]) {
63
+ addTag(filtered[activeIndex]);
64
+ } else if (inputValue.trim()) {
65
+ addTag(inputValue.trim());
66
+ }
67
+ } else if (e.key === "Escape") {
68
+ setOpen(false);
69
+ }
70
+ };
71
+
72
+ const handleBlur = () => {
73
+ // allow option click before closing
74
+ setTimeout(() => setOpen(false), 100);
75
+ };
76
+
77
+ return (
78
+ <div className="flex min-w-[320px] flex-1 items-center gap-2 overflow-hidden">
79
+ <div className="flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-border bg-background px-2 py-1">
80
+ <div className="flex flex-wrap items-center gap-2 overflow-hidden">
81
+ {selectedTags.map((tag) => (
82
+ <Button
83
+ key={tag}
84
+ variant="secondary"
85
+ size="sm"
86
+ className="h-7 rounded-full px-3 text-xs"
87
+ onClick={() => onRemove(tag)}
88
+ aria-label={`Remove tag ${tag}`}
89
+ >
90
+ {tag}
91
+ <span className="ml-2 text-foreground/70">×</span>
92
+ </Button>
93
+ ))}
94
+ <input
95
+ ref={inputRef}
96
+ value={inputValue}
97
+ onChange={(e) => {
98
+ setInputValue(e.target.value);
99
+ setOpen(true);
100
+ }}
101
+ onFocus={() => setOpen(true)}
102
+ onBlur={handleBlur}
103
+ onKeyDown={handleKeyDown}
104
+ placeholder="Add tag…"
105
+ className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted"
106
+ role="combobox"
107
+ aria-expanded={open}
108
+ aria-controls={listId}
109
+ aria-autocomplete="list"
110
+ />
111
+ </div>
112
+ </div>
113
+
114
+ <div className="flex items-center rounded-md bg-border text-xs font-semibold text-foreground/80 overflow-hidden">
115
+ <button
116
+ type="button"
117
+ className={cn(
118
+ "px-3 py-2 transition-colors",
119
+ mode === "OR" ? "bg-primary text-black" : "hover:bg-border/80"
120
+ )}
121
+ aria-pressed={mode === "OR"}
122
+ onClick={() => onModeChange("OR")}
123
+ >
124
+ OR
125
+ </button>
126
+ <button
127
+ type="button"
128
+ className={cn(
129
+ "px-3 py-2 transition-colors",
130
+ mode === "AND" ? "bg-primary text-black" : "hover:bg-border/80"
131
+ )}
132
+ aria-pressed={mode === "AND"}
133
+ onClick={() => onModeChange("AND")}
134
+ >
135
+ AND
136
+ </button>
137
+ </div>
138
+
139
+ {open && filtered.length > 0 && (
140
+ <div className="absolute z-20 mt-1 w-[320px] rounded-lg border border-border bg-background shadow-lg">
141
+ <ul id={listId} role="listbox" className="max-h-56 overflow-auto py-1">
142
+ {filtered.map((tag, idx) => (
143
+ <li key={tag} role="option" aria-selected={idx === activeIndex}>
144
+ <button
145
+ type="button"
146
+ className={cn(
147
+ "flex w-full items-center px-3 py-2 text-left text-sm transition-colors",
148
+ idx === activeIndex
149
+ ? "bg-primary/20 text-foreground"
150
+ : "hover:bg-border/60"
151
+ )}
152
+ onMouseDown={(e) => e.preventDefault()}
153
+ onClick={() => addTag(tag)}
154
+ >
155
+ {tag}
156
+ </button>
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ </div>
161
+ )}
162
+ </div>
163
+ );
164
+}
165
+
cmd/relay-server/frontend/src/components/TagFilter.tsx
deleted
-80
@@ -1,80 +0,0 @@
1
-import { Button } from "@/components/ui/button";
2
-import type { TagMode } from "@/types/filters";
3
-
4
-type TagFilterProps = {
5
- availableTags: string[];
6
- selectedTags: string[];
7
- mode: TagMode;
8
- onModeChange: (mode: TagMode) => void;
9
- onToggleTag: (tag: string) => void;
10
- onClear: () => void;
11
-};
12
-
13
-export function TagFilter({
14
- availableTags,
15
- selectedTags,
16
- mode,
17
- onModeChange,
18
- onToggleTag,
19
- onClear,
20
-}: TagFilterProps) {
21
- const isSelected = (tag: string) => selectedTags.includes(tag);
22
-
23
- return (
24
- <div className="flex flex-col gap-3 rounded-xl border border-border/70 bg-background px-4 py-3">
25
- <div className="flex flex-wrap items-center gap-2">
26
- <span className="text-sm font-semibold text-foreground">Tags</span>
27
- <div className="flex rounded-md bg-border text-xs font-semibold text-foreground/80 overflow-hidden">
28
- <button
29
- type="button"
30
- className={`px-3 py-1 transition-colors ${
31
- mode === "OR" ? "bg-primary text-black" : "hover:bg-border/80"
32
- }`}
33
- aria-pressed={mode === "OR"}
34
- onClick={() => onModeChange("OR")}
35
- >
36
- Match Any
37
- </button>
38
- <button
39
- type="button"
40
- className={`px-3 py-1 transition-colors ${
41
- mode === "AND" ? "bg-primary text-black" : "hover:bg-border/80"
42
- }`}
43
- aria-pressed={mode === "AND"}
44
- onClick={() => onModeChange("AND")}
45
- >
46
- Match All
47
- </button>
48
- </div>
49
- {selectedTags.length > 0 && (
50
- <Button
51
- variant="ghost"
52
- size="sm"
53
- className="text-xs"
54
- onClick={onClear}
55
- >
56
- Clear
57
- </Button>
58
- )}
59
- </div>
60
-
61
- <div className="flex flex-wrap gap-2">
62
- {availableTags.map((tag) => (
63
- <Button
64
- key={tag}
65
- variant={isSelected(tag) ? "default" : "secondary"}
66
- size="sm"
67
- className="h-8 rounded-full px-3 text-xs"
68
- onClick={() => onToggleTag(tag)}
69
- aria-pressed={isSelected(tag)}
70
- >
71
- {tag}
72
- </Button>
73
- ))}
74
- {availableTags.length === 0 && (
75
- <span className="text-xs text-text-muted">No tags available</span>
76
- )}
77
- </div>
78
- </div>
79
- );
80
-}
cmd/relay-server/frontend/src/pages/ServerList.tsx
+6
-15
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
2
import { Header } from "@/components/Header";
3
import { SearchBar } from "@/components/SearchBar";
4
import { ServerCard } from "@/components/ServerCard";
5
-import { TagFilter } from "@/components/TagFilter";
5
import { useSSRData } from "@/hooks/useSSRData";
6
import type { ServerData, Metadata } from "@/hooks/useSSRData";
7
import { SsgoiTransition } from "@ssgoi/react";
@@ -219,8 +218,6 @@ export function ServerList() {
218
);
219
};
220
222
- const handleClearTags = () => setSelectedTags([]);
223
-
221
return (
222
<SsgoiTransition id="/">
223
<div className="relative flex h-auto min-h-screen w-full flex-col overflow-x-hidden">
@@ -236,19 +233,13 @@ export function ServerList() {
233
onStatusChange={handleStatusChange}
234
sortBy={sortBy}
235
onSortByChange={handleSortByChange}
236
+ availableTags={availableTags}
237
+ selectedTags={selectedTags}
238
+ onAddTag={handleTagToggle}
239
+ onRemoveTag={handleTagToggle}
240
+ tagMode={tagMode}
241
+ onTagModeChange={setTagMode}
242
/>
240
- {availableTags.length > 0 && (
241
- <div className="mt-4 px-4 sm:px-6">
242
- <TagFilter
243
- availableTags={availableTags}
244
- selectedTags={selectedTags}
245
- mode={tagMode}
246
- onModeChange={setTagMode}
247
- onToggleTag={handleTagToggle}
248
- onClear={handleClearTags}
249
- />
250
- </div>
251
- )}
243
<div className="grid grid-cols-1 min-[500px]:grid-cols-2 md:grid-cols-3 gap-6 p-4 min-[500px]:p-6 mt-4">
244
{visibleServers.length > 0 ? (
245
visibleServers.map((server) => (