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) => (