fix: portal-ui card, add light theme, and hero transitioin.
Hee Sung Son committed
Nov 21, 2025 at 12:52 UTC
28a53483e3cd8aa0e976ee29dfb316a3f227fe79
15 files changed
+719
-366
cmd/relay-server/frontend/index.html
+1
-10
@@ -1,23 +1,14 @@
1
<!doctype html>
2
-<html lang="en" class="dark" style="color-scheme: dark;">
2
+<html lang="en" class="dark">
3
4
<head>
5
<meta charset="UTF-8" />
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
<meta name="color-scheme" content="dark" />
8
- <meta name="theme-color" content="#0f1e23" />
8
<meta name="description"
9
content="Transform your local services into web-accessible endpoints. Instant access from anywhere." />
10
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
11
<title>Portal - Local to web. Instant access.</title>
13
- <style>
14
- /* Prevent flash of light mode */
15
- html,
16
- body {
17
- background-color: #0f1e23;
18
- color: #ffffff;
19
- }
20
- </style>
12
</head>
13
14
<body class="dark">
cmd/relay-server/frontend/package-lock.json
+152
-72
@@ -12,21 +12,23 @@
12
"@radix-ui/react-popover": "^1.1.15",
13
"@radix-ui/react-select": "^2.2.6",
14
"@radix-ui/react-slot": "^1.2.4",
15
+ "@ssgoi/react": "^2.5.3",
16
"class-variance-authority": "^0.7.1",
17
"clsx": "^2.1.1",
18
"cmdk": "^1.1.1",
18
- "lucide-react": "^0.553.0",
19
+ "lucide-react": "^0.554.0",
20
"react": "^19.2.0",
21
"react-dom": "^19.2.0",
22
+ "react-router-dom": "^7.9.6",
23
"tailwind-merge": "^3.4.0"
24
},
25
"devDependencies": {
26
"@tailwindcss/vite": "^4.1.17",
27
"@types/node": "^24.10.1",
26
- "@types/react": "^19.2.5",
28
+ "@types/react": "^19.2.6",
29
"@types/react-dom": "^19.2.3",
28
- "@typescript-eslint/eslint-plugin": "^8.46.4",
29
- "@typescript-eslint/parser": "^8.46.4",
30
+ "@typescript-eslint/eslint-plugin": "^8.47.0",
31
+ "@typescript-eslint/parser": "^8.47.0",
32
"@vitejs/plugin-react": "^5.1.1",
33
"babel-plugin-react-compiler": "rc",
34
"eslint": "^9.39.1",
@@ -34,7 +36,7 @@
36
"eslint-plugin-react-refresh": "^0.4.24",
37
"tailwindcss": "^4.1.17",
38
"typescript": "^5.9.3",
37
- "vite": "^7.2.2"
39
+ "vite": "^7.2.4"
40
}
41
},
42
"node_modules/@babel/code-frame": {
@@ -2146,6 +2148,31 @@
2148
"win32"
2149
]
2150
},
2151
+ "node_modules/@ssgoi/core": {
2152
+ "version": "2.5.3",
2153
+ "resolved": "https://registry.npmjs.org/@ssgoi/core/-/core-2.5.3.tgz",
2154
+ "integrity": "sha512-e9F/AuhDKtZtScAY7mQ2eho+p4Tdd0N0u+9k/vwD6z0zFhJww7sj5GetLNQXXn2S4frXjAwcl83hahl2d6Gljw==",
2155
+ "license": "MIT",
2156
+ "engines": {
2157
+ "node": ">=18.0.0"
2158
+ }
2159
+ },
2160
+ "node_modules/@ssgoi/react": {
2161
+ "version": "2.5.3",
2162
+ "resolved": "https://registry.npmjs.org/@ssgoi/react/-/react-2.5.3.tgz",
2163
+ "integrity": "sha512-Os9zCw7GOZRNDRrWlyVTgBZObVDiRREqEP6I97jbYu/4XrloPJy34TN0FzdWD+EPACiNIsUCc50Aq1R7KiIWdA==",
2164
+ "license": "MIT",
2165
+ "dependencies": {
2166
+ "@ssgoi/core": "2.5.3"
2167
+ },
2168
+ "engines": {
2169
+ "node": ">=18.0.0"
2170
+ },
2171
+ "peerDependencies": {
2172
+ "react": "^19.0.0",
2173
+ "react-dom": "^19.0.0"
2174
+ }
2175
+ },
2176
"node_modules/@tailwindcss/node": {
2177
"version": "4.1.17",
2178
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
@@ -2488,13 +2515,13 @@
2515
}
2516
},
2517
"node_modules/@types/react": {
2491
- "version": "19.2.5",
2492
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
2493
- "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==",
2518
+ "version": "19.2.6",
2519
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
2520
+ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
2521
"devOptional": true,
2522
"license": "MIT",
2523
"dependencies": {
2497
- "csstype": "^3.0.2"
2524
+ "csstype": "^3.2.2"
2525
}
2526
},
2527
"node_modules/@types/react-dom": {
@@ -2508,17 +2535,17 @@
2535
}
2536
},
2537
"node_modules/@typescript-eslint/eslint-plugin": {
2511
- "version": "8.46.4",
2512
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz",
2513
- "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==",
2538
+ "version": "8.47.0",
2539
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz",
2540
+ "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==",
2541
"dev": true,
2542
"license": "MIT",
2543
"dependencies": {
2544
"@eslint-community/regexpp": "^4.10.0",
2518
- "@typescript-eslint/scope-manager": "8.46.4",
2519
- "@typescript-eslint/type-utils": "8.46.4",
2520
- "@typescript-eslint/utils": "8.46.4",
2521
- "@typescript-eslint/visitor-keys": "8.46.4",
2545
+ "@typescript-eslint/scope-manager": "8.47.0",
2546
+ "@typescript-eslint/type-utils": "8.47.0",
2547
+ "@typescript-eslint/utils": "8.47.0",
2548
+ "@typescript-eslint/visitor-keys": "8.47.0",
2549
"graphemer": "^1.4.0",
2550
"ignore": "^7.0.0",
2551
"natural-compare": "^1.4.0",
@@ -2532,22 +2559,22 @@
2559
"url": "https://opencollective.com/typescript-eslint"
2560
},
2561
"peerDependencies": {
2535
- "@typescript-eslint/parser": "^8.46.4",
2562
+ "@typescript-eslint/parser": "^8.47.0",
2563
"eslint": "^8.57.0 || ^9.0.0",
2564
"typescript": ">=4.8.4 <6.0.0"
2565
}
2566
},
2567
"node_modules/@typescript-eslint/parser": {
2541
- "version": "8.46.4",
2542
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz",
2543
- "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
2568
+ "version": "8.47.0",
2569
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz",
2570
+ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
2571
"dev": true,
2572
"license": "MIT",
2573
"dependencies": {
2547
- "@typescript-eslint/scope-manager": "8.46.4",
2548
- "@typescript-eslint/types": "8.46.4",
2549
- "@typescript-eslint/typescript-estree": "8.46.4",
2550
- "@typescript-eslint/visitor-keys": "8.46.4",
2574
+ "@typescript-eslint/scope-manager": "8.47.0",
2575
+ "@typescript-eslint/types": "8.47.0",
2576
+ "@typescript-eslint/typescript-estree": "8.47.0",
2577
+ "@typescript-eslint/visitor-keys": "8.47.0",
2578
"debug": "^4.3.4"
2579
},
2580
"engines": {
@@ -2563,14 +2590,14 @@
2590
}
2591
},
2592
"node_modules/@typescript-eslint/project-service": {
2566
- "version": "8.46.4",
2567
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz",
2568
- "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==",
2593
+ "version": "8.47.0",
2594
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
2595
+ "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
2596
"dev": true,
2597
"license": "MIT",
2598
"dependencies": {
2572
- "@typescript-eslint/tsconfig-utils": "^8.46.4",
2573
- "@typescript-eslint/types": "^8.46.4",
2599
+ "@typescript-eslint/tsconfig-utils": "^8.47.0",
2600
+ "@typescript-eslint/types": "^8.47.0",
2601
"debug": "^4.3.4"
2602
},
2603
"engines": {
@@ -2585,14 +2612,14 @@
2612
}
2613
},
2614
"node_modules/@typescript-eslint/scope-manager": {
2588
- "version": "8.46.4",
2589
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz",
2590
- "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==",
2615
+ "version": "8.47.0",
2616
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
2617
+ "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
2618
"dev": true,
2619
"license": "MIT",
2620
"dependencies": {
2594
- "@typescript-eslint/types": "8.46.4",
2595
- "@typescript-eslint/visitor-keys": "8.46.4"
2621
+ "@typescript-eslint/types": "8.47.0",
2622
+ "@typescript-eslint/visitor-keys": "8.47.0"
2623
},
2624
"engines": {
2625
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2603,9 +2630,9 @@
2630
}
2631
},
2632
"node_modules/@typescript-eslint/tsconfig-utils": {
2606
- "version": "8.46.4",
2607
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz",
2608
- "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==",
2633
+ "version": "8.47.0",
2634
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
2635
+ "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
2636
"dev": true,
2637
"license": "MIT",
2638
"engines": {
@@ -2620,15 +2647,15 @@
2647
}
2648
},
2649
"node_modules/@typescript-eslint/type-utils": {
2623
- "version": "8.46.4",
2624
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz",
2625
- "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==",
2650
+ "version": "8.47.0",
2651
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz",
2652
+ "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==",
2653
"dev": true,
2654
"license": "MIT",
2655
"dependencies": {
2629
- "@typescript-eslint/types": "8.46.4",
2630
- "@typescript-eslint/typescript-estree": "8.46.4",
2631
- "@typescript-eslint/utils": "8.46.4",
2656
+ "@typescript-eslint/types": "8.47.0",
2657
+ "@typescript-eslint/typescript-estree": "8.47.0",
2658
+ "@typescript-eslint/utils": "8.47.0",
2659
"debug": "^4.3.4",
2660
"ts-api-utils": "^2.1.0"
2661
},
@@ -2645,9 +2672,9 @@
2672
}
2673
},
2674
"node_modules/@typescript-eslint/types": {
2648
- "version": "8.46.4",
2649
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz",
2650
- "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==",
2675
+ "version": "8.47.0",
2676
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
2677
+ "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
2678
"dev": true,
2679
"license": "MIT",
2680
"engines": {
@@ -2659,16 +2686,16 @@
2686
}
2687
},
2688
"node_modules/@typescript-eslint/typescript-estree": {
2662
- "version": "8.46.4",
2663
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz",
2664
- "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==",
2689
+ "version": "8.47.0",
2690
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
2691
+ "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
2692
"dev": true,
2693
"license": "MIT",
2694
"dependencies": {
2668
- "@typescript-eslint/project-service": "8.46.4",
2669
- "@typescript-eslint/tsconfig-utils": "8.46.4",
2670
- "@typescript-eslint/types": "8.46.4",
2671
- "@typescript-eslint/visitor-keys": "8.46.4",
2695
+ "@typescript-eslint/project-service": "8.47.0",
2696
+ "@typescript-eslint/tsconfig-utils": "8.47.0",
2697
+ "@typescript-eslint/types": "8.47.0",
2698
+ "@typescript-eslint/visitor-keys": "8.47.0",
2699
"debug": "^4.3.4",
2700
"fast-glob": "^3.3.2",
2701
"is-glob": "^4.0.3",
@@ -2688,16 +2715,16 @@
2715
}
2716
},
2717
"node_modules/@typescript-eslint/utils": {
2691
- "version": "8.46.4",
2692
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz",
2693
- "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==",
2718
+ "version": "8.47.0",
2719
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
2720
+ "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
2721
"dev": true,
2722
"license": "MIT",
2723
"dependencies": {
2724
"@eslint-community/eslint-utils": "^4.7.0",
2698
- "@typescript-eslint/scope-manager": "8.46.4",
2699
- "@typescript-eslint/types": "8.46.4",
2700
- "@typescript-eslint/typescript-estree": "8.46.4"
2725
+ "@typescript-eslint/scope-manager": "8.47.0",
2726
+ "@typescript-eslint/types": "8.47.0",
2727
+ "@typescript-eslint/typescript-estree": "8.47.0"
2728
},
2729
"engines": {
2730
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2712,13 +2739,13 @@
2739
}
2740
},
2741
"node_modules/@typescript-eslint/visitor-keys": {
2715
- "version": "8.46.4",
2716
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz",
2717
- "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==",
2742
+ "version": "8.47.0",
2743
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
2744
+ "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
2745
"dev": true,
2746
"license": "MIT",
2747
"dependencies": {
2721
- "@typescript-eslint/types": "8.46.4",
2748
+ "@typescript-eslint/types": "8.47.0",
2749
"eslint-visitor-keys": "^4.2.1"
2750
},
2751
"engines": {
@@ -3041,6 +3068,15 @@
3068
"dev": true,
3069
"license": "MIT"
3070
},
3071
+ "node_modules/cookie": {
3072
+ "version": "1.0.2",
3073
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
3074
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
3075
+ "license": "MIT",
3076
+ "engines": {
3077
+ "node": ">=18"
3078
+ }
3079
+ },
3080
"node_modules/cross-spawn": {
3081
"version": "7.0.6",
3082
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3057,9 +3093,9 @@
3093
}
3094
},
3095
"node_modules/csstype": {
3060
- "version": "3.2.1",
3061
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz",
3062
- "integrity": "sha512-98XGutrXoh75MlgLihlNxAGbUuFQc7l1cqcnEZlLNKc0UrVdPndgmaDmYTDDh929VS/eqTZV0rozmhu2qqT1/g==",
3096
+ "version": "3.2.3",
3097
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
3098
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
3099
"devOptional": true,
3100
"license": "MIT"
3101
},
@@ -4133,9 +4169,9 @@
4169
}
4170
},
4171
"node_modules/lucide-react": {
4136
- "version": "0.553.0",
4137
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
4138
- "integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
4172
+ "version": "0.554.0",
4173
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz",
4174
+ "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==",
4175
"license": "ISC",
4176
"peerDependencies": {
4177
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -4482,6 +4518,44 @@
4518
}
4519
}
4520
},
4521
+ "node_modules/react-router": {
4522
+ "version": "7.9.6",
4523
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
4524
+ "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
4525
+ "license": "MIT",
4526
+ "dependencies": {
4527
+ "cookie": "^1.0.1",
4528
+ "set-cookie-parser": "^2.6.0"
4529
+ },
4530
+ "engines": {
4531
+ "node": ">=20.0.0"
4532
+ },
4533
+ "peerDependencies": {
4534
+ "react": ">=18",
4535
+ "react-dom": ">=18"
4536
+ },
4537
+ "peerDependenciesMeta": {
4538
+ "react-dom": {
4539
+ "optional": true
4540
+ }
4541
+ }
4542
+ },
4543
+ "node_modules/react-router-dom": {
4544
+ "version": "7.9.6",
4545
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
4546
+ "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
4547
+ "license": "MIT",
4548
+ "dependencies": {
4549
+ "react-router": "7.9.6"
4550
+ },
4551
+ "engines": {
4552
+ "node": ">=20.0.0"
4553
+ },
4554
+ "peerDependencies": {
4555
+ "react": ">=18",
4556
+ "react-dom": ">=18"
4557
+ }
4558
+ },
4559
"node_modules/react-style-singleton": {
4560
"version": "2.2.3",
4561
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -4610,6 +4684,12 @@
4684
"node": ">=10"
4685
}
4686
},
4687
+ "node_modules/set-cookie-parser": {
4688
+ "version": "2.7.2",
4689
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
4690
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
4691
+ "license": "MIT"
4692
+ },
4693
"node_modules/shebang-command": {
4694
"version": "2.0.0",
4695
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4899,9 +4979,9 @@
4979
}
4980
},
4981
"node_modules/vite": {
4902
- "version": "7.2.2",
4903
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
4904
- "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
4982
+ "version": "7.2.4",
4983
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
4984
+ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
4985
"dev": true,
4986
"license": "MIT",
4987
"dependencies": {
cmd/relay-server/frontend/package.json
+8
-6
@@ -16,28 +16,30 @@
16
"@radix-ui/react-popover": "^1.1.15",
17
"@radix-ui/react-select": "^2.2.6",
18
"@radix-ui/react-slot": "^1.2.4",
19
+ "@ssgoi/react": "^2.5.3",
20
"class-variance-authority": "^0.7.1",
21
"clsx": "^2.1.1",
22
"cmdk": "^1.1.1",
22
- "lucide-react": "^0.553.0",
23
+ "lucide-react": "^0.554.0",
24
"react": "^19.2.0",
25
"react-dom": "^19.2.0",
26
+ "react-router-dom": "^7.9.6",
27
"tailwind-merge": "^3.4.0"
28
},
29
"devDependencies": {
30
"@tailwindcss/vite": "^4.1.17",
31
"@types/node": "^24.10.1",
30
- "@types/react": "^19.2.5",
32
+ "@types/react": "^19.2.6",
33
"@types/react-dom": "^19.2.3",
32
- "@typescript-eslint/eslint-plugin": "^8.46.4",
33
- "@typescript-eslint/parser": "^8.46.4",
34
+ "@typescript-eslint/eslint-plugin": "^8.47.0",
35
+ "@typescript-eslint/parser": "^8.47.0",
36
"@vitejs/plugin-react": "^5.1.1",
37
+ "babel-plugin-react-compiler": "rc",
38
"eslint": "^9.39.1",
39
"eslint-plugin-react-hooks": "^7.0.1",
40
"eslint-plugin-react-refresh": "^0.4.24",
41
"tailwindcss": "^4.1.17",
42
"typescript": "^5.9.3",
40
- "vite": "^7.2.2",
41
- "babel-plugin-react-compiler": "rc"
43
+ "vite": "^7.2.4"
44
}
45
}
cmd/relay-server/frontend/src/App.tsx
+7
-175
@@ -1,181 +1,13 @@
1
-import { useState, useMemo } 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";
6
-import { useSSRData } from "@/hooks/useSSRData";
7
-import type { ServerData, Metadata } from "@/hooks/useSSRData";
8
-
9
-const ITEMS_PER_PAGE = 6;
10
-
11
-// Helper function to convert SSR ServerData to frontend format
12
-function convertSSRDataToServers(ssrData: ServerData[]) {
13
- return ssrData.map((row, index) => {
14
- // Parse metadata JSON string
15
- let metadata: Metadata = {
16
- description: "",
17
- tags: [],
18
- thumbnail: "",
19
- owner: "",
20
- hide: false,
21
- };
22
-
23
- try {
24
- if (row.Metadata) {
25
- metadata = JSON.parse(row.Metadata);
26
- }
27
- } catch (err) {
28
- console.error("[App] Failed to parse metadata:", err, row.Metadata);
29
- }
30
-
31
- return {
32
- id: index + 1,
33
- name: row.Name || row.DNS || "(unnamed)",
34
- description: metadata.description || "",
35
- tags: Array.isArray(metadata.tags) ? metadata.tags : [],
36
- thumbnail: metadata.thumbnail || "",
37
- owner: metadata.owner || "",
38
- online: row.Connected,
39
- dns: row.DNS || "",
40
- link: row.Link,
41
- };
42
- });
43
-}
1
+import { Routes, Route } from "react-router-dom";
2
+import { ServerList } from "@/pages/ServerList";
3
+import { ServerDetail } from "@/pages/ServerDetail";
4
5
function App() {
46
- const [currentPage, setCurrentPage] = useState(1);
47
- const [searchQuery, setSearchQuery] = useState("");
48
- const [status, setStatus] = useState("all");
49
- const [sortBy, setSortBy] = useState("default");
50
-
51
- // Get SSR data
52
- const ssrData = useSSRData();
53
-
54
- // Use SSR data if available, otherwise fall back to sample servers
55
- const servers = useMemo(() => {
56
- console.log("[App] SSR data length:", ssrData.length);
57
- if (ssrData.length > 0) {
58
- console.log("[App] Using SSR data");
59
- const converted = convertSSRDataToServers(ssrData);
60
- console.log("[App] Converted servers:", converted);
61
- return converted;
62
- }
63
- console.log("[App] Using sample servers");
64
- return [];
65
- }, [ssrData]);
66
-
67
- // Filter and sort servers
68
- const filteredServers = useMemo(() => {
69
- let filtered = servers.filter((server) => {
70
- // Search filter - searches name, description, and tags
71
- const matchesSearch =
72
- searchQuery === "" ||
73
- server.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
74
- server.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
75
- server.tags.some((tag) =>
76
- tag.toLowerCase().includes(searchQuery.toLowerCase())
77
- );
78
-
79
- // Status filter
80
- const matchesStatus =
81
- status === "all" ||
82
- (status === "online" && server.online) ||
83
- (status === "offline" && !server.online);
84
-
85
- return matchesSearch && matchesStatus;
86
- });
87
-
88
- // Sort based on sortBy value
89
- if (sortBy === "description") {
90
- filtered = [...filtered].sort((a, b) =>
91
- a.description.localeCompare(b.description)
92
- );
93
- } else if (sortBy === "tags") {
94
- filtered = [...filtered].sort((a, b) => {
95
- const aTag = a.tags[0] || "";
96
- const bTag = b.tags[0] || "";
97
- return aTag.localeCompare(bTag);
98
- });
99
- } else if (sortBy === "owner") {
100
- filtered = [...filtered].sort((a, b) => a.owner.localeCompare(b.owner));
101
- }
102
-
103
- return filtered;
104
- }, [servers, searchQuery, status, sortBy]);
105
-
106
- // Pagination
107
- const totalPages = Math.ceil(filteredServers.length / ITEMS_PER_PAGE);
108
- const paginatedServers = useMemo(() => {
109
- const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
110
- return filteredServers.slice(startIndex, startIndex + ITEMS_PER_PAGE);
111
- }, [filteredServers, currentPage]);
112
-
113
- // Reset to page 1 when filters change
114
- const handleSearchChange = (value: string) => {
115
- setSearchQuery(value);
116
- setCurrentPage(1);
117
- };
118
-
119
- const handleStatusChange = (value: string) => {
120
- setStatus(value);
121
- setCurrentPage(1);
122
- };
123
-
124
- const handleSortByChange = (value: string) => {
125
- setSortBy(value);
126
- setCurrentPage(1);
127
- };
128
-
6
return (
130
- <div className="relative flex h-auto min-h-screen w-full flex-col overflow-x-hidden">
131
- <div className="layout-container flex h-full grow flex-col">
132
- <div className="flex flex-1 justify-center py-5">
133
- <div className="layout-content-container flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
134
- <Header />
135
- <main className="flex-1 mt-6">
136
- <SearchBar
137
- searchQuery={searchQuery}
138
- onSearchChange={handleSearchChange}
139
- status={status}
140
- onStatusChange={handleStatusChange}
141
- sortBy={sortBy}
142
- onSortByChange={handleSortByChange}
143
- />
144
- <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">
145
- {paginatedServers.length > 0 ? (
146
- paginatedServers.map((server) => (
147
- <ServerCard
148
- key={server.id}
149
- name={server.name}
150
- description={server.description}
151
- tags={server.tags}
152
- thumbnail={server.thumbnail}
153
- owner={server.owner}
154
- online={server.online}
155
- dns={server.dns}
156
- serverUrl={server.link}
157
- />
158
- ))
159
- ) : (
160
- <div className="col-span-full text-center py-12">
161
- <p className="text-text-muted text-lg">
162
- No servers found matching your criteria
163
- </p>
164
- </div>
165
- )}
166
- </div>
167
- {totalPages > 0 && (
168
- <Pagination
169
- currentPage={currentPage}
170
- totalPages={totalPages}
171
- onPageChange={setCurrentPage}
172
- />
173
- )}
174
- </main>
175
- </div>
176
- </div>
177
- </div>
178
- </div>
7
+ <Routes>
8
+ <Route path="/" element={<ServerList />} />
9
+ <Route path="/server/:id" element={<ServerDetail />} />
10
+ </Routes>
11
);
12
}
13
cmd/relay-server/frontend/src/components/Header.tsx
+46
-4
@@ -1,7 +1,38 @@
1
+import { useEffect, useState } from "react";
2
+import { Moon, Sun } from "lucide-react";
3
+
4
export function Header() {
5
+ const [theme, setTheme] = useState<"light" | "dark">("dark");
6
+
7
+ useEffect(() => {
8
+ // Check localStorage for saved theme
9
+ const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null;
10
+ if (savedTheme) {
11
+ setTheme(savedTheme);
12
+ document.documentElement.classList.remove("light", "dark");
13
+ document.documentElement.classList.add(savedTheme);
14
+ document.body.classList.remove("light", "dark");
15
+ document.body.classList.add(savedTheme);
16
+ } else {
17
+ // Default to dark mode
18
+ document.documentElement.classList.add("dark");
19
+ document.body.classList.add("dark");
20
+ }
21
+ }, []);
22
+
23
+ const toggleTheme = () => {
24
+ const newTheme = theme === "dark" ? "light" : "dark";
25
+ setTheme(newTheme);
26
+ localStorage.setItem("theme", newTheme);
27
+ document.documentElement.classList.remove("light", "dark");
28
+ document.documentElement.classList.add(newTheme);
29
+ document.body.classList.remove("light", "dark");
30
+ document.body.classList.add(newTheme);
31
+ };
32
+
33
return (
3
- <header className="flex items-center justify-between whitespace-nowrap border-b border-solid border-border-dark px-4 sm:px-6 py-3">
4
- <div className="flex items-center gap-4 text-white">
34
+ <header className="flex items-center justify-between whitespace-nowrap border-b border-solid border-b-border px-4 sm:px-6 py-3">
35
+ <div className="flex items-center gap-4 text-foreground">
36
<div className="text-primary size-6">
37
<svg
38
xmlns="http://www.w3.org/2000/svg"
@@ -15,7 +46,7 @@ export function Header() {
46
></path>
47
</svg>
48
</div>
18
- <h2 className="text-white text-lg font-bold leading-tight tracking-[-0.015em]">
49
+ <h2 className="text-foreground text-lg font-bold leading-tight tracking-[-0.015em]">
50
PORTAL
51
</h2>
52
</div>
@@ -24,7 +55,7 @@ export function Header() {
55
href="https://github.com/gosuda/portal"
56
target="_blank"
57
rel="noopener noreferrer"
27
- className="text-white hover:text-primary transition-colors"
58
+ className="text-foreground hover:text-primary transition-colors"
59
aria-label="View source on GitHub"
60
>
61
<svg
@@ -37,6 +68,17 @@ export function Header() {
68
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
69
</svg>
70
</a>
71
+ <button
72
+ onClick={toggleTheme}
73
+ className="text-foreground hover:text-primary transition-colors p-1 rounded-lg hover:bg-secondary"
74
+ aria-label="Toggle theme"
75
+ >
76
+ {theme === "dark" ? (
77
+ <Sun className="w-6 h-6" />
78
+ ) : (
79
+ <Moon className="w-6 h-6" />
80
+ )}
81
+ </button>
82
{/* <Button>
83
<span className="truncate">Add Your Server</span>
84
</Button> */}
cmd/relay-server/frontend/src/components/Pagination.tsx
+2
-2
@@ -16,7 +16,7 @@ export function Pagination({
16
<button
17
onClick={() => onPageChange(currentPage - 1)}
18
disabled={currentPage === 1}
19
- className="flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-border-dark text-white hover:bg-[#2d3f58] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
19
+ className="flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-border text-foreground hover:bg-border/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
20
>
21
<ChevronLeft className="w-6 h-6" />
22
</button>
@@ -26,7 +26,7 @@ export function Pagination({
26
<button
27
onClick={() => onPageChange(currentPage + 1)}
28
disabled={currentPage === totalPages}
29
- className="flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-border-dark text-white hover:bg-[#2d3f58] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
29
+ className="flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-border text-foreground hover:bg-border/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
30
>
31
<ChevronRight className="w-6 h-6" />
32
</button>
cmd/relay-server/frontend/src/components/SearchBar.tsx
+1
-1
@@ -29,7 +29,7 @@ export function SearchBar({
29
<div className="space-y-4 px-4 sm:px-6">
30
<label className="flex flex-col min-w-40 h-12 w-full">
31
<div className="flex w-full flex-1 items-stretch rounded-lg h-full">
32
- <div className="text-text-muted flex border-none bg-border-dark items-center justify-center pl-4 rounded-l-lg border-r-0">
32
+ <div className="text-text-muted flex border-none bg-border items-center justify-center pl-4 rounded-l-lg border-r-0">
33
<Search className="w-5 h-5" />
34
</div>
35
<Input
cmd/relay-server/frontend/src/components/ServerCard.tsx
+67
-51
@@ -1,4 +1,7 @@
1
+import { Link } from "react-router-dom";
2
+
3
interface ServerCardProps {
4
+ serverId: number;
5
name: string;
6
description: string;
7
tags: string[];
@@ -7,76 +10,89 @@ interface ServerCardProps {
10
online: boolean;
11
dns: string;
12
serverUrl: string;
13
+ navigationPath: string;
14
+ navigationState: any;
15
}
16
17
export function ServerCard({
18
+ serverId,
19
name,
20
description,
21
tags,
22
thumbnail,
23
owner,
24
online,
19
- serverUrl,
25
+ navigationPath,
26
+ navigationState,
27
}: ServerCardProps) {
28
const defaultThumbnail =
29
"https://cdn.jsdelivr.net/gh/gosuda/portal@main/portal.jpg";
30
24
- const goServer = () => {
25
- window.location.href = serverUrl;
26
- };
31
return (
28
- <button
29
- type="button"
30
- onClick={goServer}
31
- bg-card-dark
32
- className="h-[174.5px] bg-center bg-no-repeat bg-cover rounded-xl shadow-sm hover:shadow-lg hover:scale-105 transition-all duration-300 cursor-pointer"
33
- style={{ backgroundImage: `url(${thumbnail || defaultThumbnail})` }}
32
+ <Link
33
+ to={navigationPath}
34
+ state={navigationState}
35
+ className="relative hover:scale-105 transition-all duration-300"
36
>
35
- <div className="h-full w-full bg-black/70 rounded-xl z-1 flex flex-col gap-4 p-4 items-start text-start">
36
- <div className="w-full flex flex-1 flex-col justify-between gap-4">
37
- <div className="flex flex-col gap-2">
38
- <div className="flex items-center gap-2">
39
- <div
40
- className={`w-2.5 h-2.5 rounded-full ${
41
- online ? "bg-green-status" : "bg-red-500"
42
- }`}
43
- />
44
- <p
45
- className={`text-sm font-medium leading-normal ${
46
- online ? "text-green-status" : "text-red-500"
47
- }`}
48
- >
49
- {online ? "Online" : "Offline"}
50
- </p>
51
- </div>
52
- <p className="text-white text-lg font-bold leading-tight truncate max-w-full">
53
- {name}
54
- </p>
55
- {description && (
56
- <p className="text-text-muted text-sm font-normal leading-normal truncate max-w-full">
57
- {description}
58
- </p>
59
- )}
60
- {tags && tags.length > 0 && (
61
- <div className="flex flex-wrap gap-1.5 mt-1">
62
- {tags.map((tag, index) => (
63
- <span
64
- key={index}
65
- className="px-2 py-1 bg-border-dark text-primary text-xs font-medium rounded truncate max-w-[120px]"
66
- >
67
- {tag}
68
- </span>
69
- ))}
37
+ <div
38
+ data-hero-key={`server-bg-${serverId}`}
39
+ 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"
40
+ style={{ backgroundImage: `url(${thumbnail || defaultThumbnail})` }}
41
+ >
42
+ {/* Hero element - just the background image */}
43
+ {/* <img
44
+ src={thumbnail || defaultThumbnail}
45
+ alt={name}
46
+ className="absolute inset-0 w-full h-full object-cover"
47
+ /> */}
48
+ {/* Content overlay - not part of hero transition */}
49
+ <div className="relative h-full w-full bg-background/80 rounded-xl flex flex-col gap-4 p-4 items-start text-start">
50
+ <div className="w-full flex flex-1 flex-col justify-between gap-4">
51
+ <div className="flex flex-col gap-2">
52
+ <div className="flex items-center gap-2">
53
+ <div
54
+ className={`w-2.5 h-2.5 rounded-full ${
55
+ online ? "bg-green-status" : "bg-red-500"
56
+ }`}
57
+ />
58
+ <p
59
+ className={`text-sm font-medium leading-normal ${
60
+ online ? "text-green-status" : "text-red-500"
61
+ }`}
62
+ >
63
+ {online ? "Online" : "Offline"}
64
+ </p>
65
</div>
71
- )}
72
- {owner && (
73
- <p className="text-text-muted text-xs font-normal leading-normal truncate max-w-full">
74
- by {owner}
66
+ <p className="text-foreground text-lg font-bold leading-tight truncate max-w-full">
67
+ {name}
68
</p>
76
- )}
69
+ {description && (
70
+ <p className="text-text-muted text-sm font-normal leading-normal truncate max-w-full">
71
+ {description}
72
+ </p>
73
+ )}
74
+ {tags && tags.length > 0 && (
75
+ <div className="flex flex-wrap gap-1.5 mt-1">
76
+ {tags.map((tag, index) => (
77
+ <span
78
+ key={index}
79
+ className="px-2 py-1 bg-secondary text-primary text-xs font-medium rounded truncate max-w-[120px]"
80
+ >
81
+ {tag}
82
+ </span>
83
+ ))}
84
+ </div>
85
+ )}
86
+ {owner && (
87
+ <p className="text-text-muted text-xs font-normal leading-normal truncate max-w-full">
88
+ by {owner}
89
+ </p>
90
+ )}
91
+ </div>
92
</div>
93
</div>
94
</div>
80
- </button>
95
+ <div className="absolute top-2 left-2 h-full w-full bg-secondary/70 rounded-xl z-0" />
96
+ </Link>
97
);
98
}
cmd/relay-server/frontend/src/components/ui/button.tsx
+2
-2
@@ -9,8 +9,8 @@ const buttonVariants = cva(
9
variants: {
10
variant: {
11
default: "bg-primary text-black hover:bg-primary/90",
12
- secondary: "bg-border-dark text-white hover:bg-[#2d3f58]",
13
- ghost: "hover:bg-[#2d3f58] text-white",
12
+ secondary: "bg-border text-foreground hover:bg-border/80",
13
+ ghost: "hover:bg-border/80 text-foreground",
14
},
15
size: {
16
default: "h-10 px-4",
cmd/relay-server/frontend/src/components/ui/input.tsx
+1
-1
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
10
<input
11
type={type}
12
className={cn(
13
- "flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-r-lg text-white focus:outline-0 focus:ring-2 focus:ring-primary/50 border-none bg-border-dark h-full placeholder:text-text-muted px-4 pl-2 text-base font-normal leading-normal",
13
+ "flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-r-lg text-foreground focus:outline-0 focus:ring-2 focus:ring-primary/50 border-none bg-border h-full placeholder:text-text-muted px-4 pl-2 text-base font-normal leading-normal",
14
className
15
)}
16
ref={ref}
cmd/relay-server/frontend/src/components/ui/select.tsx
+5
-5
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
16
<SelectPrimitive.Trigger
17
ref={ref}
18
className={cn(
19
- "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-lg bg-[#233348] px-3 py-2 text-sm text-white shadow-sm ring-offset-background placeholder:text-[#92a9c9] focus:outline-none focus:ring-2 focus:ring-primary/50 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
19
+ "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-lg bg-border px-3 py-2 text-sm text-foreground shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
20
className
21
)}
22
{...props}
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
72
<SelectPrimitive.Content
73
ref={ref}
74
className={cn(
75
- "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg bg-[#192433] text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
75
+ "relative z-50 max-h-96 min-w-32 overflow-hidden rounded-lg bg-card text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
76
position === "popper" &&
77
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
78
className
@@ -85,7 +85,7 @@ const SelectContent = React.forwardRef<
85
className={cn(
86
"p-1",
87
position === "popper" &&
88
- "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
88
+ "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
89
)}
90
>
91
{children}
@@ -118,7 +118,7 @@ const SelectItem = React.forwardRef<
118
<SelectPrimitive.Item
119
ref={ref}
120
className={cn(
121
- "relative flex w-full cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-[#233348] focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
121
+ "relative flex w-full cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-border focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
122
className
123
)}
124
{...props}
@@ -139,7 +139,7 @@ const SelectSeparator = React.forwardRef<
139
>(({ className, ...props }, ref) => (
140
<SelectPrimitive.Separator
141
ref={ref}
142
- className={cn("-mx-1 my-1 h-px bg-[#233348]", className)}
142
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
143
{...props}
144
/>
145
));
cmd/relay-server/frontend/src/index.css
+88
-33
@@ -1,15 +1,9 @@
1
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;700;900&display=swap');
2
@import "tailwindcss";
3
4
-@theme {
5
- /* ServerShare Custom Colors - Dark Mode Only */
6
- --color-primary: #47cdff;
7
- --color-background-dark: #0f1e23;
8
- --color-green-status: #50e3c2;
9
- --color-card-dark: #192433;
10
- --color-border-dark: #233348;
11
- --color-text-muted: #92a9c9;
4
+@custom-variant dark (&:where(.dark, .dark *));
5
6
+@theme {
7
/* Font families */
8
--font-family-display: "Public Sans", sans-serif;
9
@@ -17,44 +11,105 @@
11
--radius: 0.5rem;
12
--radius-lg: 0.75rem;
13
--radius-xl: 1rem;
14
+
15
+ --color-background: var(--background);
16
+ --color-foreground: var(--foreground);
17
+ --color-card: var(--card);
18
+ --color-card-foreground: var(--card-foreground);
19
+ --color-popover: var(--popover);
20
+ --color-popover-foreground: var(--popover-foreground);
21
+ --color-primary: var(--primary);
22
+ --color-primary-foreground: var(--primary-foreground);
23
+ --color-secondary: var(--secondary);
24
+ --color-secondary-foreground: var(--secondary-foreground);
25
+ --color-muted: var(--muted);
26
+ --color-muted-foreground: var(--muted-foreground);
27
+ --color-accent: var(--accent);
28
+ --color-accent-foreground: var(--accent-foreground);
29
+ --color-destructive: var(--destructive);
30
+ --color-destructive-foreground: var(--destructive-foreground);
31
+ --color-border: var(--border);
32
+ --color-input: var(--input);
33
+ --color-ring: var(--ring);
34
+ --color-green-status: var(--green-status);
35
+ --color-text-muted: var(--text-muted);
36
}
37
38
@layer base {
39
:root {
24
- /* Dark mode only - using ServerShare color palette */
25
- --background: 197 56% 9%; /* #0f1e23 */
26
- --foreground: 210 40% 98%; /* white text */
27
- --card: 200 48% 12%; /* #192433 */
28
- --card-foreground: 210 40% 98%;
29
- --popover: 200 48% 12%;
30
- --popover-foreground: 210 40% 98%;
31
- --primary: 195 100% 64%; /* #47cdff */
32
- --primary-foreground: 0 0% 0%;
33
- --secondary: 200 34% 18%; /* #233348 */
34
- --secondary-foreground: 210 40% 98%;
35
- --muted: 200 34% 18%;
36
- --muted-foreground: 207 39% 68%; /* #92a9c9 */
37
- --accent: 200 34% 18%;
38
- --accent-foreground: 210 40% 98%;
39
- --destructive: 0 84.2% 60.2%;
40
- --destructive-foreground: 210 40% 98%;
41
- --border: 207 28% 21%; /* #233348 */
42
- --input: 207 28% 21%;
43
- --ring: 195 100% 64%; /* #47cdff */
40
+ /* Light Theme - Default (OKLCH) */
41
+ --background: oklch(98% 0.005 200); /* #f5f8f8 */
42
+ --foreground: oklch(35% 0.02 280); /* gray-800 */
43
+ --card: oklch(100% 0 0); /* white */
44
+ --card-foreground: oklch(35% 0.02 280); /* gray-800 */
45
+ --popover: oklch(100% 0 0); /* white */
46
+ --popover-foreground: oklch(35% 0.02 280); /* gray-800 */
47
+ --primary: oklch(75% 0.15 210); /* #47cdff */
48
+ --primary-foreground: oklch(100% 0 0); /* white */
49
+ --secondary: oklch(96% 0.005 240); /* gray-100 */
50
+ --secondary-foreground: oklch(30% 0.02 280); /* gray-700 */
51
+ --muted: oklch(96% 0.005 240); /* gray-100 */
52
+ --muted-foreground: oklch(52% 0.02 260); /* gray-500 #92a9c9 */
53
+ --accent: oklch(96% 0.005 240); /* gray-100 */
54
+ --accent-foreground: oklch(35% 0.02 280); /* gray-800 */
55
+ --destructive: oklch(62% 0.22 25); /* red-500 */
56
+ --destructive-foreground: oklch(100% 0 0); /* white */
57
+ --border: oklch(88% 0.005 240); /* gray-200 */
58
+ --input: oklch(88% 0.005 240); /* gray-200 */
59
+ --ring: oklch(75% 0.15 210); /* #47cdff */
60
+
61
+ /* Custom light theme colors */
62
+ --green-status: oklch(78% 0.16 170); /* #50E3C2 */
63
+ --text-muted: oklch(52% 0.02 260); /* #92a9c9 */
64
+ }
65
+
66
+ .dark {
67
+ /* Dark Theme (OKLCH) */
68
+ --background: oklch(18% 0.04 210); /* #0f1e23 */
69
+ --foreground: oklch(98% 0.01 240); /* white text */
70
+ --card: oklch(22% 0.05 215); /* #192433 */
71
+ --card-foreground: oklch(98% 0.01 240);
72
+ --popover: oklch(22% 0.05 215);
73
+ --popover-foreground: oklch(98% 0.01 240);
74
+ --primary: oklch(75% 0.15 210); /* #47cdff */
75
+ --primary-foreground: oklch(0% 0 0);
76
+ --secondary: oklch(28% 0.04 215); /* #233348 */
77
+ --secondary-foreground: oklch(98% 0.01 240);
78
+ --muted: oklch(28% 0.04 215);
79
+ --muted-foreground: oklch(70% 0.05 230); /* #92a9c9 */
80
+ --accent: oklch(28% 0.04 215);
81
+ --accent-foreground: oklch(98% 0.01 240);
82
+ --destructive: oklch(62% 0.22 25);
83
+ --destructive-foreground: oklch(98% 0.01 240);
84
+ --border: oklch(26% 0.04 215); /* #233348 */
85
+ --input: oklch(26% 0.04 215);
86
+ --ring: oklch(75% 0.15 210); /* #47cdff */
87
+
88
+ /* Custom dark theme colors */
89
+ --green-status: oklch(78% 0.16 170);
90
+ --text-muted: oklch(70% 0.05 230);
91
}
92
93
* {
47
- border-color: hsl(var(--border));
94
+ border-color: var(--border);
95
}
96
97
body {
51
- background-color: var(--color-background-dark);
52
- color: rgb(255 255 255);
98
+ background-color: var(--background);
99
+ color: var(--foreground);
100
font-family: var(--font-family-display);
101
}
102
56
- /* Force dark mode always */
57
- html {
103
+ /* Default to dark mode */
104
+ body {
105
+ color-scheme: dark;
106
+ }
107
+
108
+ body.dark {
109
color-scheme: dark;
110
}
111
+
112
+ body.light {
113
+ color-scheme: light;
114
+ }
115
}
cmd/relay-server/frontend/src/main.tsx
+22
-4
@@ -1,10 +1,28 @@
1
-import React from "react";
1
import ReactDOM from "react-dom/client";
2
+import { BrowserRouter } from "react-router-dom";
3
import App from "./App.tsx";
4
import "./index.css";
5
+import { Ssgoi } from "@ssgoi/react";
6
+import { hero } from "@ssgoi/react/view-transitions";
7
8
ReactDOM.createRoot(document.getElementById("root")!).render(
7
- <React.StrictMode>
8
- <App />
9
- </React.StrictMode>
9
+ <Ssgoi
10
+ config={{
11
+ transitions: [
12
+ {
13
+ from: "/",
14
+ to: "/server/*",
15
+ transition: hero(),
16
+ symmetric: true,
17
+ },
18
+ ],
19
+ }}
20
+ >
21
+ {/* ⚠️ 중요: position: relative 필수! */}
22
+ <div style={{ position: "relative", minHeight: "100vh" }}>
23
+ <BrowserRouter>
24
+ <App />
25
+ </BrowserRouter>
26
+ </div>
27
+ </Ssgoi>
28
);
cmd/relay-server/frontend/src/pages/ServerDetail.tsx
new
+122
@@ -0,0 +1,122 @@
1
+import { SsgoiTransition } from "@ssgoi/react";
2
+import { useEffect, useState } from "react";
3
+import { useLocation, useNavigate } from "react-router-dom";
4
+
5
+interface ServerDetailState {
6
+ id: number;
7
+ name: string;
8
+ description: string;
9
+ tags: string[];
10
+ thumbnail: string;
11
+ owner: string;
12
+ online: boolean;
13
+ serverUrl: string;
14
+}
15
+
16
+export function ServerDetail() {
17
+ const location = useLocation();
18
+ const navigate = useNavigate();
19
+ const server = location.state as ServerDetailState;
20
+
21
+ const [isPush, setIsPush] = useState(false);
22
+ const [isBlocked, setIsBlocked] = useState(false);
23
+ useEffect(() => {
24
+ console.log("isPush", isPush);
25
+ console.log("isBlocked", isBlocked);
26
+ // If no server data, redirect to home
27
+ if (!server) {
28
+ navigate("/");
29
+ return;
30
+ }
31
+
32
+ // Redirect to actual server URL after animation
33
+ const timer = setTimeout(() => {
34
+ // Replace current history entry before redirecting
35
+ if (isPush) {
36
+ if (!isBlocked) {
37
+ setIsBlocked(true);
38
+ } else {
39
+ navigate("/");
40
+ }
41
+ } else {
42
+ setIsPush(true);
43
+ window.location.href = server.serverUrl;
44
+ }
45
+ }, 500);
46
+
47
+ return () => clearTimeout(timer);
48
+ }, [server, isPush, setIsPush, isBlocked, setIsBlocked, navigate]);
49
+
50
+ // If no server data, show nothing (will redirect)
51
+ if (!server) {
52
+ return null;
53
+ }
54
+
55
+ const { id, thumbnail, name, online, description, tags, owner } = server;
56
+
57
+ const defaultThumbnail =
58
+ "https://cdn.jsdelivr.net/gh/gosuda/portal@main/portal.jpg";
59
+
60
+ return (
61
+ <SsgoiTransition id={`/server/${id}`}>
62
+ <div
63
+ data-hero-key={`server-bg-${id}`}
64
+ className="fixed inset-0 bg-center bg-no-repeat bg-cover w-screen h-screen"
65
+ style={{ backgroundImage: `url(${thumbnail || defaultThumbnail})` }}
66
+ >
67
+ {/* Hero element - just the background image */}
68
+ {/* <img
69
+ src={thumbnail || defaultThumbnail}
70
+ alt={name}
71
+ className="absolute inset-0 w-full h-full object-cover"
72
+ /> */}
73
+ {/* Content overlay - not part of hero transition */}
74
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background/70 rounded-xl flex flex-col gap-4 p-4 items-start text-start z-1">
75
+ <div className="w-full flex flex-1 flex-col justify-between gap-4">
76
+ <div className="flex flex-col gap-2">
77
+ <div className="flex items-center gap-2">
78
+ <div
79
+ className={`w-2.5 h-2.5 rounded-full ${
80
+ online ? "bg-green-status" : "bg-red-500"
81
+ }`}
82
+ />
83
+ <p
84
+ className={`text-sm font-medium leading-normal ${
85
+ online ? "text-green-status" : "text-red-500"
86
+ }`}
87
+ >
88
+ {online ? "Online" : "Offline"}
89
+ </p>
90
+ </div>
91
+ <p className="text-foreground text-lg font-bold leading-tight truncate max-w-full">
92
+ {name}
93
+ </p>
94
+ {description && (
95
+ <p className="text-text-muted text-sm font-normal leading-normal truncate max-w-full">
96
+ {description}
97
+ </p>
98
+ )}
99
+ {tags && tags.length > 0 && (
100
+ <div className="flex flex-wrap gap-1.5 mt-1">
101
+ {tags.map((tag, index) => (
102
+ <span
103
+ key={index}
104
+ className="px-2 py-1 bg-secondary text-primary text-xs font-medium rounded truncate max-w-[120px]"
105
+ >
106
+ {tag}
107
+ </span>
108
+ ))}
109
+ </div>
110
+ )}
111
+ {owner && (
112
+ <p className="text-text-muted text-xs font-normal leading-normal truncate max-w-full">
113
+ by {owner}
114
+ </p>
115
+ )}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </SsgoiTransition>
121
+ );
122
+}
cmd/relay-server/frontend/src/pages/ServerList.tsx
new
+195
@@ -0,0 +1,195 @@
1
+import { useState, useMemo } 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";
6
+import { useSSRData } from "@/hooks/useSSRData";
7
+import type { ServerData, Metadata } from "@/hooks/useSSRData";
8
+import { SsgoiTransition } from "@ssgoi/react";
9
+
10
+const ITEMS_PER_PAGE = 6;
11
+
12
+// Helper function to convert SSR ServerData to frontend format
13
+function convertSSRDataToServers(ssrData: ServerData[]) {
14
+ return ssrData.map((row, index) => {
15
+ // Parse metadata JSON string
16
+ let metadata: Metadata = {
17
+ description: "",
18
+ tags: [],
19
+ thumbnail: "",
20
+ owner: "",
21
+ hide: false,
22
+ };
23
+
24
+ try {
25
+ if (row.Metadata) {
26
+ metadata = JSON.parse(row.Metadata);
27
+ }
28
+ } catch (err) {
29
+ console.error("[App] Failed to parse metadata:", err, row.Metadata);
30
+ }
31
+
32
+ return {
33
+ id: index + 1,
34
+ name: row.Name || row.DNS || "(unnamed)",
35
+ description: metadata.description || "",
36
+ tags: Array.isArray(metadata.tags) ? metadata.tags : [],
37
+ thumbnail: metadata.thumbnail || "",
38
+ owner: metadata.owner || "",
39
+ online: row.Connected,
40
+ dns: row.DNS || "",
41
+ link: row.Link,
42
+ };
43
+ });
44
+}
45
+
46
+export function ServerList() {
47
+ const [currentPage, setCurrentPage] = useState(1);
48
+ const [searchQuery, setSearchQuery] = useState("");
49
+ const [status, setStatus] = useState("all");
50
+ const [sortBy, setSortBy] = useState("default");
51
+
52
+ // Get SSR data
53
+ const ssrData = useSSRData();
54
+
55
+ // Use SSR data if available, otherwise fall back to sample servers
56
+ const servers = useMemo(() => {
57
+ console.log("[App] SSR data length:", ssrData.length);
58
+ if (ssrData.length > 0) {
59
+ console.log("[App] Using SSR data");
60
+ const converted = convertSSRDataToServers(ssrData);
61
+ console.log("[App] Converted servers:", converted);
62
+ return converted;
63
+ }
64
+ console.log("[App] Using sample servers");
65
+ return [];
66
+ }, [ssrData]);
67
+
68
+ // Filter and sort servers
69
+ const filteredServers = useMemo(() => {
70
+ let filtered = servers.filter((server) => {
71
+ // Search filter - searches name, description, and tags
72
+ 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
81
+ const matchesStatus =
82
+ status === "all" ||
83
+ (status === "online" && server.online) ||
84
+ (status === "offline" && !server.online);
85
+
86
+ return matchesSearch && matchesStatus;
87
+ });
88
+
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));
102
+ }
103
+
104
+ return filtered;
105
+ }, [servers, searchQuery, status, sortBy]);
106
+
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]);
113
+
114
+ // Reset to page 1 when filters change
115
+ const handleSearchChange = (value: string) => {
116
+ setSearchQuery(value);
117
+ setCurrentPage(1);
118
+ };
119
+
120
+ const handleStatusChange = (value: string) => {
121
+ setStatus(value);
122
+ setCurrentPage(1);
123
+ };
124
+
125
+ const handleSortByChange = (value: string) => {
126
+ setSortBy(value);
127
+ setCurrentPage(1);
128
+ };
129
+
130
+ return (
131
+ <SsgoiTransition id="/">
132
+ <div className="relative flex h-auto min-h-screen w-full flex-col overflow-x-hidden">
133
+ <div className="flex h-full grow flex-col">
134
+ <div className="flex flex-1 justify-center py-5">
135
+ <div className="flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
136
+ <Header />
137
+ <main className="flex-1 mt-6">
138
+ <SearchBar
139
+ searchQuery={searchQuery}
140
+ onSearchChange={handleSearchChange}
141
+ status={status}
142
+ onStatusChange={handleStatusChange}
143
+ sortBy={sortBy}
144
+ onSortByChange={handleSortByChange}
145
+ />
146
+ <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) => (
149
+ <ServerCard
150
+ key={server.id}
151
+ serverId={server.id}
152
+ name={server.name}
153
+ description={server.description}
154
+ tags={server.tags}
155
+ thumbnail={server.thumbnail}
156
+ owner={server.owner}
157
+ online={server.online}
158
+ dns={server.dns}
159
+ serverUrl={server.link}
160
+ navigationPath={`/server/${server.id}`}
161
+ navigationState={{
162
+ id: server.id,
163
+ name: server.name,
164
+ description: server.description,
165
+ tags: server.tags,
166
+ thumbnail: server.thumbnail,
167
+ owner: server.owner,
168
+ online: server.online,
169
+ serverUrl: server.link,
170
+ }}
171
+ />
172
+ ))
173
+ ) : (
174
+ <div className="col-span-full text-center py-12">
175
+ <p className="text-text-muted text-lg">
176
+ No servers found matching your criteria
177
+ </p>
178
+ </div>
179
+ )}
180
+ </div>
181
+ {totalPages > 0 && (
182
+ <Pagination
183
+ currentPage={currentPage}
184
+ totalPages={totalPages}
185
+ onPageChange={setCurrentPage}
186
+ />
187
+ )}
188
+ </main>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </SsgoiTransition>
194
+ );
195
+}