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";