fix with feedbacks
Hee Sung Son committed
Nov 25, 2025 at 13:09 UTC
7bd36745c58fd86d7e7f485ec0acf38e0132f978
8 files changed
+110
-107
cmd/relay-server/frontend/package-lock.json
+32
@@ -10,6 +10,7 @@
10
"dependencies": {
11
"@radix-ui/react-dialog": "^1.1.15",
12
"@radix-ui/react-popover": "^1.1.15",
13
+ "@radix-ui/react-scroll-area": "^1.2.10",
14
"@radix-ui/react-select": "^2.2.6",
15
"@radix-ui/react-slot": "^1.2.4",
16
"@ssgoi/react": "^2.5.3",
@@ -1589,6 +1590,37 @@
1590
}
1591
}
1592
},
1593
+ "node_modules/@radix-ui/react-scroll-area": {
1594
+ "version": "1.2.10",
1595
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
1596
+ "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
1597
+ "license": "MIT",
1598
+ "dependencies": {
1599
+ "@radix-ui/number": "1.1.1",
1600
+ "@radix-ui/primitive": "1.1.3",
1601
+ "@radix-ui/react-compose-refs": "1.1.2",
1602
+ "@radix-ui/react-context": "1.1.2",
1603
+ "@radix-ui/react-direction": "1.1.1",
1604
+ "@radix-ui/react-presence": "1.1.5",
1605
+ "@radix-ui/react-primitive": "2.1.3",
1606
+ "@radix-ui/react-use-callback-ref": "1.1.1",
1607
+ "@radix-ui/react-use-layout-effect": "1.1.1"
1608
+ },
1609
+ "peerDependencies": {
1610
+ "@types/react": "*",
1611
+ "@types/react-dom": "*",
1612
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1613
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1614
+ },
1615
+ "peerDependenciesMeta": {
1616
+ "@types/react": {
1617
+ "optional": true
1618
+ },
1619
+ "@types/react-dom": {
1620
+ "optional": true
1621
+ }
1622
+ }
1623
+ },
1624
"node_modules/@radix-ui/react-select": {
1625
"version": "2.2.6",
1626
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
cmd/relay-server/frontend/package.json
+1
@@ -14,6 +14,7 @@
14
"dependencies": {
15
"@radix-ui/react-dialog": "^1.1.15",
16
"@radix-ui/react-popover": "^1.1.15",
17
+ "@radix-ui/react-scroll-area": "^1.2.10",
18
"@radix-ui/react-select": "^2.2.6",
19
"@radix-ui/react-slot": "^1.2.4",
20
"@ssgoi/react": "^2.5.3",
cmd/relay-server/frontend/src/components/SearchBar.tsx
+1
-7
@@ -21,8 +21,6 @@ interface SearchBarProps {
21
selectedTags: string[];
22
onAddTag: (tag: string) => void;
23
onRemoveTag: (tag: string) => void;
24
- tagMode: "AND" | "OR";
25
- onTagModeChange: (mode: "AND" | "OR") => void;
24
}
25
26
export function SearchBar({
@@ -36,11 +34,9 @@ export function SearchBar({
34
selectedTags,
35
onAddTag,
36
onRemoveTag,
39
- tagMode,
40
- onTagModeChange,
37
}: SearchBarProps) {
38
return (
43
- <div className="flex flex-wrap items-center gap-3 px-4 sm:px-6">
39
+ <div className="flex flex-wrap mt-4 sm:mt-6 items-center gap-3 px-4 sm:px-6">
40
<label className="flex min-w-[220px] flex-1 items-stretch h-11">
41
<div className="text-text-muted flex items-center justify-center pl-4 pr-2 rounded-l-lg bg-border">
42
<Search className="w-5 h-5" />
@@ -85,8 +81,6 @@ export function SearchBar({
81
selectedTags={selectedTags}
82
onAdd={onAddTag}
83
onRemove={onRemoveTag}
88
- mode={tagMode}
89
- onModeChange={onTagModeChange}
84
/>
85
</div>
86
</div>
cmd/relay-server/frontend/src/components/ServerCard.tsx
+5
-3
@@ -1,4 +1,5 @@
1
import { Link } from "react-router-dom";
2
+import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
3
4
interface ServerCardProps {
5
serverId: number;
@@ -43,7 +44,7 @@ export function ServerCard({
44
>
45
<div
46
data-hero-key={`server-bg-${serverId}`}
46
- className="relative h-[174.5px] bg-center bg-no-repeat bg-cover rounded-xl shadow-lg hover:shadow-xl transition-shadow duration-300 cursor-pointer z-1 border border-foreground/40"
47
+ className="relative h-[174.5px] bg-center bg-no-repeat bg-cover rounded-xl shadow-lg hover:shadow-xl transition-shadow duration-300 cursor-pointer z-1 border border-foreground dark:border-foreground/40"
48
style={{ ...(thumbnail && { backgroundImage: `url(${thumbnail})` }) }}
49
>
50
{/* Favorite button */}
@@ -94,7 +95,7 @@ export function ServerCard({
95
</p>
96
)}
97
{tags && tags.length > 0 && (
97
- <div className="w-full overflow-x-auto scrollbar-hide mt-1">
98
+ <ScrollArea className="w-full mt-1">
99
<div className="flex gap-1.5 min-w-max">
100
{tags.map((tag, index) => (
101
<span
@@ -105,7 +106,8 @@ export function ServerCard({
106
</span>
107
))}
108
</div>
108
- </div>
109
+ <ScrollBar orientation="horizontal" />
110
+ </ScrollArea>
111
)}
112
{owner && (
113
<p className="text-text-muted text-xs font-normal leading-normal truncate max-w-full">
cmd/relay-server/frontend/src/components/TagCombobox.tsx
+1
-30
@@ -1,14 +1,11 @@
1
import React, { useEffect, useMemo, useRef, useState } from "react";
2
import { createPortal } from "react-dom";
3
import { Button } from "@/components/ui/button";
4
-import type { TagMode } from "@/types/filters";
4
import { cn } from "@/lib/utils";
5
6
type TagComboboxProps = {
7
availableTags: string[];
8
selectedTags: string[];
10
- mode: TagMode;
11
- onModeChange: (mode: TagMode) => void;
9
onAdd: (tag: string) => void;
10
onRemove: (tag: string) => void;
11
};
@@ -16,8 +13,6 @@ type TagComboboxProps = {
13
export function TagCombobox({
14
availableTags,
15
selectedTags,
19
- mode,
20
- onModeChange,
16
onAdd,
17
onRemove,
18
}: TagComboboxProps) {
@@ -126,6 +121,7 @@ export function TagCombobox({
121
setOpen(true);
122
}}
123
onFocus={() => setOpen(true)}
124
+ onClick={() => setOpen(true)}
125
onBlur={handleBlur}
126
onKeyDown={handleKeyDown}
127
placeholder="Add tag…"
@@ -138,31 +134,6 @@ export function TagCombobox({
134
</div>
135
</div>
136
141
- <div className="flex items-center rounded-md bg-border text-xs font-semibold text-foreground/80 overflow-hidden h-10 shrink-0">
142
- <button
143
- type="button"
144
- className={cn(
145
- "h-full px-3 flex items-center justify-center transition-colors",
146
- mode === "OR" ? "bg-primary text-black" : "hover:bg-border/80"
147
- )}
148
- aria-pressed={mode === "OR"}
149
- onClick={() => onModeChange("OR")}
150
- >
151
- OR
152
- </button>
153
- <button
154
- type="button"
155
- className={cn(
156
- "h-full px-3 flex items-center justify-center transition-colors",
157
- mode === "AND" ? "bg-primary text-black" : "hover:bg-border/80"
158
- )}
159
- aria-pressed={mode === "AND"}
160
- onClick={() => onModeChange("AND")}
161
- >
162
- AND
163
- </button>
164
- </div>
165
-
137
{open &&
138
filtered.length > 0 &&
139
panelStyle &&
cmd/relay-server/frontend/src/components/ui/scroll-area.tsx
new
+46
@@ -0,0 +1,46 @@
1
+import * as React from "react";
2
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3
+
4
+import { cn } from "@/lib/utils";
5
+
6
+const ScrollArea = React.forwardRef<
7
+ React.ElementRef<typeof ScrollAreaPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
9
+>(({ className, children, ...props }, ref) => (
10
+ <ScrollAreaPrimitive.Root
11
+ ref={ref}
12
+ className={cn("relative overflow-hidden", className)}
13
+ {...props}
14
+ >
15
+ <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
16
+ {children}
17
+ </ScrollAreaPrimitive.Viewport>
18
+ <ScrollBar />
19
+ <ScrollAreaPrimitive.Corner />
20
+ </ScrollAreaPrimitive.Root>
21
+));
22
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
23
+
24
+const ScrollBar = React.forwardRef<
25
+ React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
26
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
27
+>(({ className, orientation = "vertical", ...props }, ref) => (
28
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
29
+ ref={ref}
30
+ orientation={orientation}
31
+ className={cn(
32
+ "flex touch-none select-none transition-colors",
33
+ orientation === "vertical" &&
34
+ "h-full w-2.5 border-l border-l-transparent p-px",
35
+ orientation === "horizontal" &&
36
+ "h-2.5 flex-col border-t border-t-transparent p-px",
37
+ className
38
+ )}
39
+ {...props}
40
+ >
41
+ <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
42
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
43
+));
44
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
45
+
46
+export { ScrollArea, ScrollBar };
cmd/relay-server/frontend/src/index.css
+10
@@ -112,4 +112,14 @@
112
body.light {
113
color-scheme: light;
114
}
115
+
116
+ /* Hide scrollbar for horizontal scroll */
117
+ .scrollbar-hide {
118
+ -ms-overflow-style: none; /* IE and Edge */
119
+ scrollbar-width: none; /* Firefox */
120
+ }
121
+
122
+ .scrollbar-hide::-webkit-scrollbar {
123
+ display: none; /* Chrome, Safari and Opera */
124
+ }
125
}
cmd/relay-server/frontend/src/pages/ServerList.tsx
+14
-67
@@ -1,15 +1,13 @@
1
-import { useEffect, useMemo, useRef, useState } from "react";
1
+import { useEffect, useMemo, useState } from "react";
2
import { Header } from "@/components/Header";
3
import { SearchBar } from "@/components/SearchBar";
4
import { ServerCard } from "@/components/ServerCard";
5
import { useSSRData } from "@/hooks/useSSRData";
6
import type { ServerData, Metadata } from "@/hooks/useSSRData";
7
import { SsgoiTransition } from "@ssgoi/react";
8
-import type { SortOption, StatusFilter, TagMode } from "@/types/filters";
8
+import type { SortOption, StatusFilter } from "@/types/filters";
9
import { generateRandomServers } from "@/lib/testUtils";
10
11
-const INITIAL_VISIBLE = 12;
12
-const LOAD_CHUNK = 6;
11
const useDebug = false;
12
13
export type ClientServer = {
@@ -71,17 +69,12 @@ export function ServerList() {
69
const [status, setStatus] = useState<StatusFilter>("all");
70
const [sortBy, setSortBy] = useState<SortOption>("default");
71
const [selectedTags, setSelectedTags] = useState<string[]>([]);
74
- const [tagMode, setTagMode] = useState<TagMode>("OR");
75
- const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
72
const [favorites, setFavorites] = useState<number[]>(() => {
73
// Load favorites from localStorage
74
const stored = localStorage.getItem("serverFavorites");
75
return stored ? JSON.parse(stored) : [];
76
});
77
82
- const sentinelRef = useRef<HTMLDivElement | null>(null);
83
- const observerRef = useRef<IntersectionObserver | null>(null);
84
-
78
// Save favorites to localStorage whenever they change
79
useEffect(() => {
80
localStorage.setItem("serverFavorites", JSON.stringify(favorites));
@@ -119,18 +112,14 @@ export function ServerList() {
112
.map(([tag]) => tag);
113
}, [servers]);
114
122
- // Filter and sort servers
115
+ // Filter and sort servers (render all at once, no pagination)
116
const filteredServers = useMemo(() => {
117
const query = searchQuery.toLowerCase();
118
119
const matchesTags = (server: ClientServer) => {
120
if (selectedTags.length === 0) return true;
121
const tagsLower = server.tags.map((t) => t.toLowerCase());
129
- if (tagMode === "AND") {
130
- return selectedTags.every((tag) =>
131
- tagsLower.includes(tag.toLowerCase())
132
- );
133
- }
122
+ // Always use OR mode
123
return selectedTags.some((tag) => tagsLower.includes(tag.toLowerCase()));
124
};
125
@@ -191,43 +180,7 @@ export function ServerList() {
180
});
181
182
return sorted;
194
- }, [servers, searchQuery, status, sortBy, selectedTags, tagMode, favorites]);
195
-
196
- const visibleServers = useMemo(
197
- () => filteredServers.slice(0, visibleCount),
198
- [filteredServers, visibleCount]
199
- );
200
-
201
- const hasMore = visibleCount < filteredServers.length;
202
-
203
- // Reset visible items when filters change
204
- useEffect(() => {
205
- setVisibleCount(INITIAL_VISIBLE);
206
- }, [searchQuery, status, sortBy, selectedTags, tagMode]);
207
-
208
- useEffect(() => {
209
- if (!sentinelRef.current) return;
210
-
211
- // Disconnect any existing observer before creating a new one
212
- if (observerRef.current) {
213
- observerRef.current.disconnect();
214
- }
215
-
216
- const observer = new IntersectionObserver(
217
- (entries) => {
218
- const entry = entries[0];
219
- if (entry.isIntersecting && hasMore) {
220
- setVisibleCount((count) => count + LOAD_CHUNK);
221
- }
222
- },
223
- { rootMargin: "200px 0px" }
224
- );
225
-
226
- observer.observe(sentinelRef.current);
227
- observerRef.current = observer;
228
-
229
- return () => observer.disconnect();
230
- }, [hasMore]);
183
+ }, [servers, searchQuery, status, sortBy, selectedTags, favorites]);
184
185
const handleSearchChange = (value: string) => {
186
setSearchQuery(value);
@@ -257,12 +210,12 @@ export function ServerList() {
210
211
return (
212
<SsgoiTransition id="/">
260
- <div className="relative flex h-auto min-h-screen w-full flex-col overflow-x-hidden">
213
+ <div className="relative flex h-auto min-h-screen w-full flex-col">
214
<div className="flex h-full grow flex-col">
262
- <div className="flex flex-1 justify-center py-5">
215
+ <div className="flex flex-1 justify-center">
216
<div className="flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
264
- <Header />
265
- <main className="flex-1 mt-6">
217
+ <div className="sticky top-0 z-10 bg-background pb-4 pt-5">
218
+ <Header />
219
<SearchBar
220
searchQuery={searchQuery}
221
onSearchChange={handleSearchChange}
@@ -274,12 +227,12 @@ export function ServerList() {
227
selectedTags={selectedTags}
228
onAddTag={handleTagToggle}
229
onRemoveTag={handleTagToggle}
277
- tagMode={tagMode}
278
- onTagModeChange={setTagMode}
230
/>
280
- <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">
281
- {visibleServers.length > 0 ? (
282
- visibleServers.map((server) => (
231
+ </div>
232
+ <main className="flex-1">
233
+ <div className="grid grid-cols-1 min-[500px]:grid-cols-2 md:grid-cols-3 gap-6 p-4 min-[500px]:p-6">
234
+ {filteredServers.length > 0 ? (
235
+ filteredServers.map((server) => (
236
<ServerCard
237
key={server.id}
238
serverId={server.id}
@@ -314,12 +267,6 @@ export function ServerList() {
267
</div>
268
)}
269
</div>
317
- <div ref={sentinelRef} className="h-8 w-full" />
318
- {!hasMore && filteredServers.length > 0 && (
319
- <div className="px-4 sm:px-6 pb-10 text-center text-sm text-text-muted">
320
- You have reached the end of the list.
321
- </div>
322
- )}
270
</main>
271
</div>
272
</div>