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 +}