feat: enhance SearchBar and add TagFilter component
- Updated SearchBar to use typed status and sort options. - Added new TagFilter component for tag selection with AND/OR mode. - Enhanced ServerList to integrate TagFilter and improve server filtering by tags. - Introduced new types for filters in filters.ts.
cognitive-glitch committed
Nov 24, 2025 at 23:44 UTC
9a275c5a53832b30ffebee1f80f64bd48d7b7650
4 files changed
+260
-59
cmd/relay-server/frontend/src/components/SearchBar.tsx
+8
-4
@@ -7,14 +7,15 @@ import {
7
SelectTrigger,
8
SelectValue,
9
} from "@/components/ui/select";
10
+import type { SortOption, StatusFilter } from "@/types/filters";
11
12
interface SearchBarProps {
13
searchQuery: string;
14
onSearchChange: (value: string) => void;
14
- status: string;
15
- onStatusChange: (value: string) => void;
16
- sortBy: string;
17
- onSortByChange: (value: string) => void;
15
+ status: StatusFilter;
16
+ onStatusChange: (value: StatusFilter) => void;
17
+ sortBy: SortOption;
18
+ onSortByChange: (value: SortOption) => void;
19
}
20
21
export function SearchBar({
@@ -57,6 +58,9 @@ export function SearchBar({
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>
cmd/relay-server/frontend/src/components/TagFilter.tsx
new
+80
@@ -0,0 +1,80 @@
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
+160
-55
@@ -1,16 +1,31 @@
1
-import { useState, useMemo } from "react";
1
+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 { Pagination } from "@/components/Pagination";
5
+import { TagFilter } from "@/components/TagFilter";
6
import { useSSRData } from "@/hooks/useSSRData";
7
import type { ServerData, Metadata } from "@/hooks/useSSRData";
8
import { SsgoiTransition } from "@ssgoi/react";
9
+import type { SortOption, StatusFilter, TagMode } from "@/types/filters";
10
10
-const ITEMS_PER_PAGE = 6;
11
+const INITIAL_VISIBLE = 12;
12
+const LOAD_CHUNK = 6;
13
+
14
+type ClientServer = {
15
+ id: number;
16
+ name: string;
17
+ description: string;
18
+ tags: string[];
19
+ thumbnail: string;
20
+ owner: string;
21
+ online: boolean;
22
+ dns: string;
23
+ link: string;
24
+ lastUpdated?: string;
25
+};
26
27
// Helper function to convert SSR ServerData to frontend format
13
-function convertSSRDataToServers(ssrData: ServerData[]) {
28
+function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
29
return ssrData.map((row, index) => {
30
// Parse metadata JSON string
31
let metadata: Metadata = {
@@ -29,31 +44,43 @@ function convertSSRDataToServers(ssrData: ServerData[]) {
44
console.error("[App] Failed to parse metadata:", err, row.Metadata);
45
}
46
47
+ const normalizedTags = Array.isArray(metadata.tags)
48
+ ? metadata.tags
49
+ .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
50
+ .filter(Boolean)
51
+ : [];
52
+
53
return {
54
id: index + 1,
55
name: row.Name || row.DNS || "(unnamed)",
56
description: metadata.description || "",
36
- tags: Array.isArray(metadata.tags) ? metadata.tags : [],
57
+ tags: normalizedTags,
58
thumbnail: metadata.thumbnail || "",
59
owner: metadata.owner || "",
60
online: row.Connected,
61
dns: row.DNS || "",
62
link: row.Link,
63
+ lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
64
};
65
});
66
}
67
68
export function ServerList() {
47
- const [currentPage, setCurrentPage] = useState(1);
69
const [searchQuery, setSearchQuery] = useState("");
49
- const [status, setStatus] = useState("all");
50
- const [sortBy, setSortBy] = useState("default");
70
+ const [status, setStatus] = useState<StatusFilter>("all");
71
+ const [sortBy, setSortBy] = useState<SortOption>("default");
72
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
73
+ const [tagMode, setTagMode] = useState<TagMode>("OR");
74
+ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
75
+
76
+ const sentinelRef = useRef<HTMLDivElement | null>(null);
77
+ const observerRef = useRef<IntersectionObserver | null>(null);
78
79
// Get SSR data
80
const ssrData = useSSRData();
81
82
// Use SSR data if available, otherwise fall back to sample servers
56
- const servers = useMemo(() => {
83
+ const servers: ClientServer[] = useMemo(() => {
84
console.log("[App] SSR data length:", ssrData.length);
85
if (ssrData.length > 0) {
86
console.log("[App] Using SSR data");
@@ -65,68 +92,135 @@ export function ServerList() {
92
return [];
93
}, [ssrData]);
94
95
+ const availableTags = useMemo(() => {
96
+ const counts = new Map<string, number>();
97
+ servers.forEach((server) => {
98
+ server.tags.forEach((tag) => {
99
+ counts.set(tag, (counts.get(tag) || 0) + 1);
100
+ });
101
+ });
102
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).map(([tag]) => tag);
103
+ }, [servers]);
104
+
105
// Filter and sort servers
106
const filteredServers = useMemo(() => {
70
- let filtered = servers.filter((server) => {
71
- // Search filter - searches name, description, and tags
107
+ const query = searchQuery.toLowerCase();
108
+
109
+ const matchesTags = (server: ClientServer) => {
110
+ if (selectedTags.length === 0) return true;
111
+ const tagsLower = server.tags.map((t) => t.toLowerCase());
112
+ if (tagMode === "AND") {
113
+ return selectedTags.every((tag) => tagsLower.includes(tag.toLowerCase()));
114
+ }
115
+ return selectedTags.some((tag) => tagsLower.includes(tag.toLowerCase()));
116
+ };
117
+
118
+ const filtered = servers.filter((server) => {
119
const matchesSearch =
73
- searchQuery === "" ||
74
- server.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
75
- server.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
76
- server.tags.some((tag) =>
77
- tag.toLowerCase().includes(searchQuery.toLowerCase())
78
- );
79
-
80
- // Status filter
120
+ query === "" ||
121
+ server.name.toLowerCase().includes(query) ||
122
+ server.description.toLowerCase().includes(query) ||
123
+ server.tags.some((tag) => tag.toLowerCase().includes(query));
124
+
125
const matchesStatus =
126
status === "all" ||
127
(status === "online" && server.online) ||
128
(status === "offline" && !server.online);
129
86
- return matchesSearch && matchesStatus;
130
+ return matchesSearch && matchesStatus && matchesTags(server);
131
});
132
89
- // Sort based on sortBy value
90
- if (sortBy === "description") {
91
- filtered = [...filtered].sort((a, b) =>
92
- a.description.localeCompare(b.description)
93
- );
94
- } else if (sortBy === "tags") {
95
- filtered = [...filtered].sort((a, b) => {
96
- const aTag = a.tags[0] || "";
97
- const bTag = b.tags[0] || "";
98
- return aTag.localeCompare(bTag);
99
- });
100
- } else if (sortBy === "owner") {
101
- filtered = [...filtered].sort((a, b) => a.owner.localeCompare(b.owner));
133
+ const sorted = [...filtered];
134
+ switch (sortBy) {
135
+ case "name-asc":
136
+ sorted.sort((a, b) => a.name.localeCompare(b.name));
137
+ break;
138
+ case "name-desc":
139
+ sorted.sort((a, b) => b.name.localeCompare(a.name));
140
+ break;
141
+ case "updated":
142
+ sorted.sort((a, b) => {
143
+ const aTime = a.lastUpdated ? Date.parse(a.lastUpdated) : 0;
144
+ const bTime = b.lastUpdated ? Date.parse(b.lastUpdated) : 0;
145
+ return bTime - aTime;
146
+ });
147
+ break;
148
+ case "description":
149
+ sorted.sort((a, b) => a.description.localeCompare(b.description));
150
+ break;
151
+ case "tags":
152
+ sorted.sort((a, b) => {
153
+ const aTag = a.tags[0] || "";
154
+ const bTag = b.tags[0] || "";
155
+ return aTag.localeCompare(bTag);
156
+ });
157
+ break;
158
+ case "owner":
159
+ sorted.sort((a, b) => a.owner.localeCompare(b.owner));
160
+ break;
161
+ default:
162
+ break;
163
}
164
104
- return filtered;
105
- }, [servers, searchQuery, status, sortBy]);
165
+ return sorted;
166
+ }, [servers, searchQuery, status, sortBy, selectedTags, tagMode]);
167
107
- // Pagination
108
- const totalPages = Math.ceil(filteredServers.length / ITEMS_PER_PAGE);
109
- const paginatedServers = useMemo(() => {
110
- const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
111
- return filteredServers.slice(startIndex, startIndex + ITEMS_PER_PAGE);
112
- }, [filteredServers, currentPage]);
168
+ const visibleServers = useMemo(
169
+ () => filteredServers.slice(0, visibleCount),
170
+ [filteredServers, visibleCount]
171
+ );
172
+
173
+ const hasMore = visibleCount < filteredServers.length;
174
+
175
+ // Reset visible items when filters change
176
+ useEffect(() => {
177
+ setVisibleCount(INITIAL_VISIBLE);
178
+ }, [searchQuery, status, sortBy, selectedTags, tagMode]);
179
+
180
+ useEffect(() => {
181
+ if (!sentinelRef.current) return;
182
+
183
+ // Disconnect any existing observer before creating a new one
184
+ if (observerRef.current) {
185
+ observerRef.current.disconnect();
186
+ }
187
+
188
+ const observer = new IntersectionObserver(
189
+ (entries) => {
190
+ const entry = entries[0];
191
+ if (entry.isIntersecting && hasMore) {
192
+ setVisibleCount((count) => count + LOAD_CHUNK);
193
+ }
194
+ },
195
+ { rootMargin: "200px 0px" }
196
+ );
197
+
198
+ observer.observe(sentinelRef.current);
199
+ observerRef.current = observer;
200
+
201
+ return () => observer.disconnect();
202
+ }, [hasMore]);
203
114
- // Reset to page 1 when filters change
204
const handleSearchChange = (value: string) => {
205
setSearchQuery(value);
117
- setCurrentPage(1);
206
};
207
120
- const handleStatusChange = (value: string) => {
208
+ const handleStatusChange = (value: StatusFilter) => {
209
setStatus(value);
122
- setCurrentPage(1);
210
};
211
125
- const handleSortByChange = (value: string) => {
212
+ const handleSortByChange = (value: SortOption) => {
213
setSortBy(value);
127
- setCurrentPage(1);
214
};
215
216
+ const handleTagToggle = (tag: string) => {
217
+ setSelectedTags((prev) =>
218
+ prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
219
+ );
220
+ };
221
+
222
+ const handleClearTags = () => setSelectedTags([]);
223
+
224
return (
225
<SsgoiTransition id="/">
226
<div className="relative flex h-auto min-h-screen w-full flex-col overflow-x-hidden">
@@ -143,9 +237,21 @@ export function ServerList() {
237
sortBy={sortBy}
238
onSortByChange={handleSortByChange}
239
/>
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
+ )}
252
<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">
147
- {paginatedServers.length > 0 ? (
148
- paginatedServers.map((server) => (
253
+ {visibleServers.length > 0 ? (
254
+ visibleServers.map((server) => (
255
<ServerCard
256
key={server.id}
257
serverId={server.id}
@@ -178,12 +284,11 @@ export function ServerList() {
284
</div>
285
)}
286
</div>
181
- {totalPages > 0 && (
182
- <Pagination
183
- currentPage={currentPage}
184
- totalPages={totalPages}
185
- onPageChange={setCurrentPage}
186
- />
287
+ <div ref={sentinelRef} className="h-8 w-full" />
288
+ {!hasMore && filteredServers.length > 0 && (
289
+ <div className="px-4 sm:px-6 pb-10 text-center text-sm text-text-muted">
290
+ You have reached the end of the list.
291
+ </div>
292
)}
293
</main>
294
</div>
cmd/relay-server/frontend/src/types/filters.ts
new
+12
@@ -0,0 +1,12 @@
1
+export type StatusFilter = "all" | "online" | "offline";
2
+
3
+export type SortOption =
4
+ | "default"
5
+ | "description"
6
+ | "tags"
7
+ | "owner"
8
+ | "name-asc"
9
+ | "name-desc"
10
+ | "updated";
11
+
12
+export type TagMode = "AND" | "OR";