add favorite , debug mode for layout

Hee Sung Son committed Nov 25, 2025 at 11:42 UTC e747e7c276fcc97b6365bfe7f16cb46381e0c10e
3 files changed +215 -11
cmd/relay-server/frontend/src/components/ServerCard.tsx
+42 -9
@@ -12,6 +12,8 @@ interface ServerCardProps {
12 serverUrl: string;
13 navigationPath: string;
14 navigationState: any;
15 + isFavorite?: boolean;
16 + onToggleFavorite?: (serverId: number) => void;
17 }
18
19 export function ServerCard({
@@ -24,7 +26,15 @@ export function ServerCard({
26 online,
27 navigationPath,
28 navigationState,
29 + isFavorite = false,
30 + onToggleFavorite,
31 }: ServerCardProps) {
32 + const handleFavoriteClick = (e: React.MouseEvent) => {
33 + e.preventDefault();
34 + e.stopPropagation();
35 + onToggleFavorite?.(serverId);
36 + };
37 +
38 return (
39 <Link
40 to={navigationPath}
@@ -36,6 +46,27 @@ export function ServerCard({
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 style={{ ...(thumbnail && { backgroundImage: `url(${thumbnail})` }) }}
48 >
49 + {/* Favorite button */}
50 + <button
51 + onClick={handleFavoriteClick}
52 + className="absolute top-3 right-3 z-10 p-2 rounded-full bg-background/80 hover:bg-background transition-colors duration-200"
53 + aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
54 + >
55 + <svg
56 + xmlns="http://www.w3.org/2000/svg"
57 + viewBox="0 0 24 24"
58 + className="w-5 h-5 transition-colors duration-200"
59 + fill={isFavorite ? "currentColor" : "none"}
60 + stroke="currentColor"
61 + strokeWidth="2"
62 + strokeLinecap="round"
63 + strokeLinejoin="round"
64 + style={{ color: isFavorite ? "var(--primary)" : "currentColor" }}
65 + >
66 + <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
67 + </svg>
68 + </button>
69 +
70 {/* Content overlay - not part of hero transition */}
71 <div className="relative h-full w-full bg-background/80 rounded-xl flex flex-col gap-4 p-4 items-start text-start">
72 <div className="w-full flex flex-1 flex-col justify-between gap-4">
@@ -63,15 +94,17 @@ export function ServerCard({
94 </p>
95 )}
96 {tags && tags.length > 0 && (
66 - <div className="flex flex-wrap gap-1.5 mt-1">
67 - {tags.map((tag, index) => (
68 - <span
69 - key={index}
70 - className="px-2 py-1 bg-secondary text-primary text-xs font-medium rounded truncate max-w-[120px]"
71 - >
72 - {tag}
73 - </span>
74 - ))}
97 + <div className="w-full overflow-x-auto scrollbar-hide mt-1">
98 + <div className="flex gap-1.5 min-w-max">
99 + {tags.map((tag, index) => (
100 + <span
101 + key={index}
102 + className="px-2 py-1 bg-secondary text-primary text-xs font-medium rounded whitespace-nowrap"
103 + >
104 + {tag}
105 + </span>
106 + ))}
107 + </div>
108 </div>
109 )}
110 {owner && (
cmd/relay-server/frontend/src/lib/testUtils.ts new
+136
@@ -0,0 +1,136 @@
1 +import { ClientServer } from "@/pages/ServerList";
2 +
3 +// Generate random sample servers
4 +export const generateRandomServers = (
5 + count: number,
6 + startId: number = 1
7 +): ClientServer[] => {
8 + const serverNames = [
9 + "Atlas Network",
10 + "Phoenix Hub",
11 + "Nebula Station",
12 + "Quantum Gateway",
13 + "Crystal Core",
14 + "Thunder Relay",
15 + "Skyline Portal",
16 + "Velocity Node",
17 + "Horizon Link",
18 + "Apex Server",
19 + "Nova Cluster",
20 + "Titan Network",
21 + "Eclipse Gateway",
22 + "Zenith Hub",
23 + "Aurora Station",
24 + "Pulse Center",
25 + "Matrix Core",
26 + "Frontier Node",
27 + "Summit Link",
28 + "Vortex Portal",
29 + "Cipher Network",
30 + "Nexus Hub",
31 + "Prism Station",
32 + "Radiant Gateway",
33 + "Omega Core",
34 + "Spectrum Relay",
35 + "Cascade Portal",
36 + "Infinity Node",
37 + ];
38 +
39 + const descriptions = [
40 + "High-performance gaming server with low latency",
41 + "Community hub for developers and creators",
42 + "Private cloud infrastructure for enterprises",
43 + "Media streaming and content delivery platform",
44 + "Real-time collaboration workspace",
45 + "Secure data processing and analytics",
46 + "Educational platform for online learning",
47 + "Social network for creative professionals",
48 + "E-commerce platform with global reach",
49 + "Healthcare data management system",
50 + "Financial trading and analytics hub",
51 + "IoT device management platform",
52 + "AI/ML model training infrastructure",
53 + "Video conferencing and webinar service",
54 + "Open source project hosting",
55 + "Blockchain node and validator",
56 + ];
57 +
58 + const tagOptions = [
59 + "Gaming",
60 + "Development",
61 + "Cloud",
62 + "Media",
63 + "Collaboration",
64 + "Analytics",
65 + "Education",
66 + "Social",
67 + "Commerce",
68 + "Healthcare",
69 + "Finance",
70 + "IoT",
71 + "AI/ML",
72 + "Video",
73 + "Open Source",
74 + "Blockchain",
75 + "Security",
76 + "High-Performance",
77 + "Low-Latency",
78 + "Global",
79 + ];
80 +
81 + const owners = [
82 + "TechCorp",
83 + "DevTeam",
84 + "CloudNet",
85 + "MediaHub",
86 + "DataLabs",
87 + "SecureOps",
88 + "GlobalTech",
89 + "InnovateCo",
90 + "NextGen",
91 + "AlphaSystems",
92 + "BetaWorks",
93 + "GammaNet",
94 + "DeltaCloud",
95 + "EpsilonLabs",
96 + "ZetaTech",
97 + ];
98 +
99 + const thumbnails = [
100 + "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400",
101 + "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=400",
102 + "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=400",
103 + "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=400",
104 + "https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400",
105 + "https://images.unsplash.com/photo-1518770660439-4636190af475?w=400",
106 + "https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?w=400",
107 + "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=400",
108 + ];
109 +
110 + return Array.from({ length: count }, (_, i) => {
111 + const id = startId + i;
112 + const online = Math.random() > 0.3; // 70% online
113 + const numTags = Math.floor(Math.random() * 4) + 1; // 1-4 tags
114 + const selectedTags = Array.from(
115 + { length: numTags },
116 + () => tagOptions[Math.floor(Math.random() * tagOptions.length)]
117 + ).filter((tag, index, self) => self.indexOf(tag) === index); // Remove duplicates
118 +
119 + return {
120 + id,
121 + name:
122 + serverNames[Math.floor(Math.random() * serverNames.length)] + ` #${id}`,
123 + description:
124 + descriptions[Math.floor(Math.random() * descriptions.length)],
125 + tags: selectedTags,
126 + thumbnail: thumbnails[Math.floor(Math.random() * thumbnails.length)],
127 + owner: owners[Math.floor(Math.random() * owners.length)],
128 + online,
129 + dns: `server-${id}.example.com`,
130 + link: `https://server-${id}.example.com`,
131 + lastUpdated: new Date(
132 + Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000
133 + ).toISOString(),
134 + };
135 + });
136 +};
cmd/relay-server/frontend/src/pages/ServerList.tsx
+37 -2
@@ -6,11 +6,13 @@ 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";
9 +import { generateRandomServers } from "@/lib/testUtils";
10
11 const INITIAL_VISIBLE = 12;
12 const LOAD_CHUNK = 6;
13 +const useDebug = true;
14
13 -type ClientServer = {
15 +export type ClientServer = {
16 id: number;
17 name: string;
18 description: string;
@@ -71,16 +73,30 @@ export function ServerList() {
73 const [selectedTags, setSelectedTags] = useState<string[]>([]);
74 const [tagMode, setTagMode] = useState<TagMode>("OR");
75 const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
76 + const [favorites, setFavorites] = useState<number[]>(() => {
77 + // Load favorites from localStorage
78 + const stored = localStorage.getItem("serverFavorites");
79 + return stored ? JSON.parse(stored) : [];
80 + });
81
82 const sentinelRef = useRef<HTMLDivElement | null>(null);
83 const observerRef = useRef<IntersectionObserver | null>(null);
84
85 + // Save favorites to localStorage whenever they change
86 + useEffect(() => {
87 + localStorage.setItem("serverFavorites", JSON.stringify(favorites));
88 + }, [favorites]);
89 +
90 // Get SSR data
91 const ssrData = useSSRData();
92
93 // Use SSR data if available, otherwise fall back to sample servers
94 const servers: ClientServer[] = useMemo(() => {
95 console.log("[App] SSR data length:", ssrData.length);
96 +
97 + if (useDebug) {
98 + return generateRandomServers(100);
99 + }
100 if (ssrData.length > 0) {
101 console.log("[App] Using SSR data");
102 const converted = convertSSRDataToServers(ssrData);
@@ -165,8 +181,17 @@ export function ServerList() {
181 break;
182 }
183
184 + // Sort by favorites first
185 + sorted.sort((a, b) => {
186 + const aIsFav = favorites.includes(a.id);
187 + const bIsFav = favorites.includes(b.id);
188 + if (aIsFav && !bIsFav) return -1;
189 + if (!aIsFav && bIsFav) return 1;
190 + return 0;
191 + });
192 +
193 return sorted;
169 - }, [servers, searchQuery, status, sortBy, selectedTags, tagMode]);
194 + }, [servers, searchQuery, status, sortBy, selectedTags, tagMode, favorites]);
195
196 const visibleServers = useMemo(
197 () => filteredServers.slice(0, visibleCount),
@@ -222,6 +247,14 @@ export function ServerList() {
247 );
248 };
249
250 + const handleToggleFavorite = (serverId: number) => {
251 + setFavorites((prev) =>
252 + prev.includes(serverId)
253 + ? prev.filter((id) => id !== serverId)
254 + : [...prev, serverId]
255 + );
256 + };
257 +
258 return (
259 <SsgoiTransition id="/">
260 <div className="relative flex h-auto min-h-screen w-full flex-col overflow-x-hidden">
@@ -269,6 +302,8 @@ export function ServerList() {
302 online: server.online,
303 serverUrl: server.link,
304 }}
305 + isFavorite={favorites.includes(server.id)}
306 + onToggleFavorite={handleToggleFavorite}
307 />
308 ))
309 ) : (