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>