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
) : (