add landing page

Kim committed Mar 19, 2026 at 18:43 UTC 66d1c0e301e90dc2016863d076b46dbdf5e1ef3e
24 files changed +1418 -1153
Dockerfile
+1
@@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
8 make && rm -rf /var/lib/apt/lists/*
9
10 COPY frontend ./frontend
11 +COPY utils ./utils
12 COPY Makefile ./
13
14 RUN --mount=type=cache,target=/root/.npm \
extensions/vscode/CHANGELOG.md
+1 -1
@@ -9,7 +9,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how
9 - Split quick start and advanced commands
10 - Make `Portal: Start Tunnel` prompt only for the local host
11 - Enforce `https://` relay URLs
12 -- Allow empty service names so the CLI can auto-generate them
12 +- Generate a stable default service name in the extension when the name is empty
13 - Use the installed Portal binary path after installer execution
14
15 ## [0.0.1]
extensions/vscode/README.md
+3 -3
@@ -4,7 +4,7 @@ Expose your local service to the internet via a [Portal](https://github.com/gosu
4
5 ## Features
6
7 -- `Portal: Start Tunnel` prompts only for the local host:port, then starts the tunnel with configured relay URLs or the default public registry
7 +- `Portal: Start Tunnel` prompts only for the local host:port, then starts the tunnel with a stable generated name plus configured relay URLs or the default public registry
8 - `Portal: Start Tunnel (Advanced)` prompts for host, optional service name, relay source, and optional thumbnail
9 - `Portal: Stop Tunnel` stops the active tunnel terminal
10 - Persisted settings for relay URLs, default local host, and default service name
@@ -22,7 +22,7 @@ Expose your local service to the internet via a [Portal](https://github.com/gosu
22 |---|---|---|
23 | `portal.relayUrls` | `[]` | Relay server URLs (`https://` only). If empty, the extension uses `https://raw.githubusercontent.com/gosuda/portal/main/registry.json`. |
24 | `portal.defaultHost` | `"localhost:3000"` | Default local host:port shown by `Portal: Start Tunnel`. |
25 -| `portal.defaultName` | `""` | Default tunnel service name suggestion. If empty, the advanced prompt starts blank. |
25 +| `portal.defaultName` | `""` | Default tunnel service name suggestion. If empty, Portal generates a stable default name from the local host and machine seed. |
26
27 Example `settings.json`:
28
@@ -70,5 +70,5 @@ If you want Linux behavior from WSL, open the folder with `Remote - WSL` first s
70 - Enforce `https://` relay URLs
71 - Prompt only for the local host in `Portal: Start Tunnel`
72 - Add `Portal: Start Tunnel (Advanced)` for host, name, relay, and thumbnail overrides
73 -- Allow empty service names so the CLI can auto-generate them
73 +- Generate a stable default service name in the extension when the name is empty
74 - Use the installed Portal binary path after installer execution
extensions/vscode/package.json
+1 -1
@@ -49,7 +49,7 @@
49 "portal.defaultName": {
50 "type": "string",
51 "default": "",
52 - "description": "Default tunnel service name suggestion. Leave empty to start with a blank prompt."
52 + "description": "Default tunnel service name suggestion. Leave empty to let Portal generate a stable default name."
53 }
54 }
55 }
extensions/vscode/src/command.ts
+5 -4
@@ -1,4 +1,5 @@
1 import * as os from "os";
2 +import { resolveExposeName } from "../../../utils/exposeName";
3
4 export type ShellTarget = "unix" | "windows";
5
@@ -7,6 +8,7 @@ export const defaultRelayRegistryURL = "https://raw.githubusercontent.com/gosuda
8 export interface TunnelCommandOptions {
9 host: string;
10 name: string;
11 + nameSeed: string;
12 relayList: string;
13 relayUrl: string;
14 thumbnail: string;
@@ -35,14 +37,13 @@ export function shellTargetForPlatform(platform = os.platform()): ShellTarget {
37 }
38
39 export function buildCommand(opts: TunnelCommandOptions, target = shellTargetForPlatform()): string {
38 - const { host, name, relayList, relayUrl, thumbnail, isLocal } = opts;
40 + const { host, name, nameSeed, relayList, relayUrl, thumbnail, isLocal } = opts;
41 const installShellUrl = `${relayUrl}/install.sh`;
42 const installPowerShellUrl = `${relayUrl}/install.ps1`;
43 const exposeArgs: string[] = [];
44 + const resolvedName = resolveExposeName(name, host, nameSeed);
45
43 - if (name.trim()) {
44 - exposeArgs.push(`--name ${formatToken(name.trim(), target)}`);
45 - }
46 + exposeArgs.push(`--name ${formatToken(resolvedName, target)}`);
47 if (relayList.trim()) {
48 exposeArgs.push(`--relays ${formatToken(relayList, target)}`);
49 }
extensions/vscode/src/extension.ts
+2 -1
@@ -77,6 +77,7 @@ function runTunnelCommand(args: {
77 const command = buildCommand({
78 host: args.host,
79 name: args.name,
80 + nameSeed: vscode.env.machineId,
81 relayList: args.relaySelection.relayUrls.join(","),
82 relayUrl: args.relaySelection.installRelayUrl,
83 thumbnail: args.thumbnail,
@@ -117,7 +118,7 @@ async function promptName(): Promise<string | undefined> {
118 const defaultName = config.get<string>("defaultName") ?? "";
119 return vscode.window.showInputBox({
120 title: "Portal: Service Name",
120 - prompt: "Optional public hostname prefix. Leave empty to let the CLI auto-generate one.",
121 + prompt: "Optional public hostname prefix. Leave empty to auto-generate a stable default name.",
122 value: defaultName,
123 });
124 }
extensions/vscode/src/test/extension.test.ts
+15 -4
@@ -1,6 +1,7 @@
1 import * as assert from "assert";
2
3 import { buildCommand, validateRelayUrl } from "../command";
4 +import { buildDefaultExposeName } from "../../../../utils/exposeName";
5
6 suite("Extension Test Suite", () => {
7 test("validateRelayUrl accepts only https URLs", () => {
@@ -9,10 +10,12 @@ suite("Extension Test Suite", () => {
10 assert.strictEqual(validateRelayUrl("not-a-url"), "Enter a valid https:// URL");
11 });
12
12 - test("buildCommand omits --name when empty and resolves unix portal binary after install", () => {
13 + test("buildCommand generates --name when empty and resolves unix portal binary after install", () => {
14 + const generatedName = buildDefaultExposeName("localhost:3000", "machine-seed");
15 const command = buildCommand({
16 host: "localhost:3000",
17 name: "",
18 + nameSeed: "machine-seed",
19 relayList: "https://relay.example.com",
20 relayUrl: "https://relay.example.com",
21 thumbnail: "",
@@ -21,14 +24,18 @@ suite("Extension Test Suite", () => {
24
25 assert.match(command, /curl -fsSL https:\/\/relay\.example\.com\/install\.sh \| bash/);
26 assert.match(command, /PORTAL_BIN="\$\(command -v portal 2>\/dev\/null \|\| true\)"/);
24 - assert.match(command, /"\$PORTAL_BIN" expose --relays https:\/\/relay\.example\.com localhost:3000/);
25 - assert.ok(!command.includes("--name"));
27 + assert.match(
28 + command,
29 + new RegExp(`"\\$PORTAL_BIN" expose --name ${generatedName} --relays https://relay\\.example\\.com localhost:3000`)
30 + );
31 });
32
33 test("buildCommand can use the default public registry without --relays", () => {
34 + const generatedName = buildDefaultExposeName("localhost:3000", "machine-seed");
35 const command = buildCommand({
36 host: "localhost:3000",
37 name: "",
38 + nameSeed: "machine-seed",
39 relayList: "",
40 relayUrl: "",
41 thumbnail: "",
@@ -38,13 +45,17 @@ suite("Extension Test Suite", () => {
45 assert.ok(!command.includes("/install.sh"));
46 assert.ok(!command.includes("--relays"));
47 assert.match(command, /portal CLI not found\. Install from a relay first or configure portal\.relayUrls\./);
41 - assert.match(command, /"\$PORTAL_BIN" expose localhost:3000/);
48 + assert.match(
49 + command,
50 + new RegExp(`"\\$PORTAL_BIN" expose --name ${generatedName} localhost:3000`)
51 + );
52 });
53
54 test("buildCommand uses explicit portal.exe path on windows", () => {
55 const command = buildCommand({
56 host: "localhost:3000",
57 name: "my-app",
58 + nameSeed: "machine-seed",
59 relayList: "https://relay.example.com",
60 relayUrl: "https://relay.example.com",
61 thumbnail: "https://example.com/thumb.png",
extensions/vscode/tsconfig.json
-1
@@ -6,7 +6,6 @@
6 "ES2022"
7 ],
8 "sourceMap": true,
9 - "rootDir": "src",
9 "strict": true, /* enable all strict type-checking options */
10 /* Additional Checks */
11 // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
frontend/src/components/Header.tsx
+67 -78
@@ -1,5 +1,4 @@
1 -import { useEffect, useState } from "react";
2 -import { LogOut, Moon, Sun } from "lucide-react";
1 +import { LogOut } from "lucide-react";
2 import { Button } from "@/components/ui/button";
3 import {
4 Tooltip,
@@ -15,103 +14,93 @@ interface HeaderProps {
14 title?: string;
15 isAdmin?: boolean;
16 onLogout?: () => void;
17 + ctaLabel?: string;
18 }
19
20 -export function Header({ title = "PORTAL", isAdmin, onLogout }: HeaderProps) {
21 - const [theme, setTheme] = useState<"light" | "dark">("dark");
22 - const releaseVersion = getReleaseVersion();
23 -
24 - useEffect(() => {
25 - // Check localStorage for saved theme
26 - const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null;
27 - if (savedTheme) {
28 - setTheme(savedTheme);
29 - document.documentElement.classList.remove("light", "dark");
30 - document.documentElement.classList.add(savedTheme);
31 - document.body.classList.remove("light", "dark");
32 - document.body.classList.add(savedTheme);
33 - } else {
34 - // Default to dark mode
35 - document.documentElement.classList.add("dark");
36 - document.body.classList.add("dark");
37 - }
38 - }, []);
20 +const repoURL = "https://github.com/gosuda/portal";
21 +const architectureURL = `${repoURL}/blob/main/docs/architecture.md`;
22
40 - const toggleTheme = () => {
41 - const newTheme = theme === "dark" ? "light" : "dark";
42 - setTheme(newTheme);
43 - localStorage.setItem("theme", newTheme);
44 - document.documentElement.classList.remove("light", "dark");
45 - document.documentElement.classList.add(newTheme);
46 - document.body.classList.remove("light", "dark");
47 - document.body.classList.add(newTheme);
48 - };
23 +export function Header({
24 + title = "PORTAL",
25 + isAdmin,
26 + onLogout,
27 + ctaLabel = "Add Your Server",
28 +}: HeaderProps) {
29 + const releaseVersion = getReleaseVersion();
30
31 return (
51 - <header className="flex items-center justify-between whitespace-nowrap border-b border-solid border-b-border px-4 sm:px-6 py-3">
52 - <div className="flex items-center gap-1 sm:gap-4 text-foreground">
53 - <div className="text-primary size-6">
32 + <header className="flex flex-wrap items-center justify-between gap-4 px-1 py-2 sm:px-2">
33 + <div className="flex items-center gap-4 text-foreground">
34 + <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-background text-primary">
35 <svg
36 xmlns="http://www.w3.org/2000/svg"
56 - width="24"
57 - height="24"
37 + width="26"
38 + height="26"
39 viewBox="0 0 906.26 1457.543"
40 + className="text-primary"
41 >
42 <path
43 fill="currentColor"
44 d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
63 - ></path>
45 + />
46 </svg>
47 </div>
66 - <div className="flex flex-wrap items-center gap-2">
67 - <h2 className="text-foreground text-lg font-bold leading-tight tracking-[0.3em]">
68 - {title}
69 - </h2>
70 - {releaseVersion && (
71 - <span className="rounded-full border border-border bg-secondary px-2 py-0.5 text-xs font-medium text-text-muted">
72 - {releaseVersion}
73 - </span>
74 - )}
48 +
49 + <div className="space-y-1">
50 + <div className="flex flex-wrap items-center gap-2">
51 + <h2 className="text-xl font-extrabold tracking-tight text-foreground">
52 + {title}
53 + </h2>
54 + {releaseVersion && (
55 + <span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs font-semibold text-text-muted">
56 + {releaseVersion}
57 + </span>
58 + )}
59 + </div>
60 + <p className="text-sm text-text-muted">
61 + Instant public URLs for local and private apps.
62 + </p>
63 </div>
64 </div>
77 - <div className="flex items-center gap-1 sm:gap-3">
78 - <a
79 - href="https://github.com/gosuda/portal"
80 - target="_blank"
81 - rel="noopener noreferrer"
82 - className="text-foreground hover:text-primary transition-colors"
83 - aria-label="View source on GitHub"
84 - >
85 - <svg
86 - height="32"
87 - width="32"
88 - viewBox="0 0 24 24"
89 - fill="currentColor"
90 - className="opacity-80 hover:opacity-100"
91 - >
92 - <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>
93 - </svg>
94 - </a>
95 - <button
96 - onClick={toggleTheme}
97 - className="cursor-pointer text-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-secondary"
98 - aria-label="Toggle theme"
99 - >
100 - {theme === "dark" ? (
101 - <Sun className="w-6 h-6" />
102 - ) : (
103 - <Moon className="w-6 h-6" />
104 - )}
105 - </button>
65 +
66 + <div className="flex flex-wrap items-center gap-3 sm:gap-4">
67 + {!isAdmin && (
68 + <nav className="hidden items-center gap-5 text-sm font-medium text-text-muted md:flex">
69 + <a href="#live-servers" className="transition-colors hover:text-foreground">
70 + Discover
71 + </a>
72 + <a
73 + href={repoURL}
74 + target="_blank"
75 + rel="noopener noreferrer"
76 + className="transition-colors hover:text-foreground"
77 + >
78 + Docs
79 + </a>
80 + <a
81 + href={architectureURL}
82 + target="_blank"
83 + rel="noopener noreferrer"
84 + className="transition-colors hover:text-foreground"
85 + >
86 + Architecture
87 + </a>
88 + </nav>
89 + )}
90 +
91 <TunnelCommandModal
92 trigger={
93 <Button
109 - className={clsx(isAdmin && "hidden sm:block", "cursor-pointer")}
94 + className={clsx(
95 + "h-11 cursor-pointer rounded-full px-5 text-base font-semibold shadow-none",
96 + isAdmin && "hidden sm:inline-flex"
97 + )}
98 >
111 - <span className="truncate">Add Your Server</span>
99 + <span className="truncate">{ctaLabel}</span>
100 </Button>
101 }
102 />
103 +
104 {isAdmin && onLogout && (
105 <TooltipProvider>
106 <Tooltip>
@@ -120,10 +109,10 @@ export function Header({ title = "PORTAL", isAdmin, onLogout }: HeaderProps) {
109 variant="outline"
110 size="icon"
111 onClick={onLogout}
123 - className="cursor-pointer text-foreground hover:text-destructive"
112 + className="cursor-pointer rounded-full text-foreground hover:text-destructive"
113 aria-label="Logout"
114 >
126 - <LogOut className="w-5 h-5" />
115 + <LogOut className="h-5 w-5" />
116 </Button>
117 </TooltipTrigger>
118 <TooltipContent>
frontend/src/components/LandingHero.tsx new
+44
@@ -0,0 +1,44 @@
1 +import { Terminal } from "lucide-react";
2 +import { TunnelCommandForm } from "@/components/TunnelCommandForm";
3 +
4 +export function LandingHero() {
5 + return (
6 + <section
7 + aria-labelledby="landing-title"
8 + className="px-0 py-8 sm:py-10 lg:py-12"
9 + >
10 + <a
11 + href="#live-servers"
12 + className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-6 focus:z-20 focus:rounded-full focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground"
13 + >
14 + Skip to live servers
15 + </a>
16 +
17 + <div className="mx-auto max-w-4xl text-center">
18 + <h1
19 + id="landing-title"
20 + className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-6xl"
21 + >
22 + Expose localhost instantly
23 + </h1>
24 + <p className="mx-auto mt-5 max-w-2xl text-lg leading-8 text-text-muted">
25 + No signup. No config. Public URL in seconds.
26 + </p>
27 + </div>
28 +
29 + <div className="mx-auto mt-10 max-w-[560px] rounded-[1.75rem] border border-white/10 bg-slate-950 p-5 text-white shadow-[0_30px_72px_rgba(15,23,42,0.22)] sm:p-6">
30 + <div className="mb-5 flex items-center gap-3">
31 + <Terminal className="h-5 w-5 text-green-status" />
32 + <h2
33 + id="tunnel-preview"
34 + className="text-2xl font-bold tracking-tight text-white"
35 + >
36 + Run this command
37 + </h2>
38 + </div>
39 +
40 + <TunnelCommandForm theme="terminal" mode="hero" />
41 + </div>
42 + </section>
43 + );
44 +}
frontend/src/components/SearchBar.tsx
+4 -5
@@ -2,7 +2,7 @@ import { Search, Settings } from "lucide-react";
2 import { Input } from "@/components/ui/input";
3 import type { SortOption, StatusFilter } from "@/types/filters";
4 import { TagCombobox } from "@/components/TagCombobox";
5 -import { Dispatch, SetStateAction } from "react";
5 +import type { Dispatch, SetStateAction } from "react";
6 import { StatusSelect } from "@/components/select/StatusSelect";
7 import { SortbySelect } from "@/components/select/SortbySelect";
8
@@ -18,7 +18,7 @@ interface SearchBarProps {
18 onAddTag: (tag: string) => void;
19 onRemoveTag: (tag: string) => void;
20 hideFiltersOnMobile?: boolean;
21 - setShowFilterModal: Dispatch<SetStateAction<boolean>>;
21 + setShowFilterModal?: Dispatch<SetStateAction<boolean>>;
22 }
23
24 export function SearchBar({
@@ -43,14 +43,13 @@ export function SearchBar({
43 <Search className="w-4 h-4" />
44 </div>
45 <Input
46 - placeholder="Search by server name..."
46 + placeholder="Search live apps..."
47 value={searchQuery}
48 onChange={(e) => onSearchChange(e.target.value)}
49 className="h-10 pl-12 border border-border rounded-md"
50 />
51 </label>
52 - {/* Mobile filter button - only show for admin */}
53 - {hideFiltersOnMobile && (
52 + {hideFiltersOnMobile && setShowFilterModal && (
53 <button
54 onClick={() => setShowFilterModal(true)}
55 className="sm:hidden flex items-center justify-center w-10 h-10 rounded-lg bg-secondary hover:bg-secondary/80 transition-colors"
frontend/src/components/ServerCard.tsx
+155 -166
@@ -1,14 +1,13 @@
1 import { Link } from "react-router-dom";
2 -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
2 import clsx from "clsx";
4 -import { useState, useMemo } from "react";
3 +import { useMemo, useState } from "react";
4 import {
5 Dialog,
6 DialogContent,
8 - DialogHeader,
9 - DialogTitle,
7 DialogDescription,
8 DialogFooter,
9 + DialogHeader,
10 + DialogTitle,
11 } from "@/components/ui/dialog";
12 import { Button } from "@/components/ui/button";
13
@@ -22,7 +21,6 @@ interface ServerCardProps {
21 online: boolean;
22 firstSeen?: string;
23 dns: string;
25 - serverUrl: string;
24 navigationPath: string;
25 navigationState: any;
26 isFavorite?: boolean;
@@ -59,6 +57,7 @@ export function ServerCard({
57 owner,
58 online,
59 firstSeen,
60 + dns,
61 navigationPath,
62 navigationState,
63 isFavorite = false,
@@ -118,55 +117,56 @@ export function ServerCard({
117 const idx = bpsToSliderIndex(value);
118 setSliderIndex(idx);
119 };
121 - const handleFavoriteClick = (e: React.MouseEvent) => {
122 - e.preventDefault();
123 - e.stopPropagation();
120 +
121 + const handleFavoriteClick = (event: React.MouseEvent) => {
122 + event.preventDefault();
123 + event.stopPropagation();
124 onToggleFavorite?.(serverId);
125 };
126
127 - const handleSelectClick = (e: React.MouseEvent) => {
128 - e.preventDefault();
129 - e.stopPropagation();
127 + const handleSelectClick = (event: React.MouseEvent) => {
128 + event.preventDefault();
129 + event.stopPropagation();
130 if (leaseId && onToggleSelect) {
131 onToggleSelect(leaseId);
132 }
133 };
134
135 - const handleBanClick = (e: React.MouseEvent) => {
136 - e.preventDefault();
137 - e.stopPropagation();
135 + const handleBanClick = (event: React.MouseEvent) => {
136 + event.preventDefault();
137 + event.stopPropagation();
138 if (leaseId) {
139 runAsyncAdminAction(() => onBanStatusChange?.(leaseId, !isBanned));
140 }
141 };
142
143 - const handleApproveClick = (e: React.MouseEvent) => {
144 - e.preventDefault();
145 - e.stopPropagation();
143 + const handleApproveClick = (event: React.MouseEvent) => {
144 + event.preventDefault();
145 + event.stopPropagation();
146 if (leaseId) {
147 runAsyncAdminAction(() => onApproveStatusChange?.(leaseId, !isApproved));
148 }
149 };
150
151 - const handleDenyClick = (e: React.MouseEvent) => {
152 - e.preventDefault();
153 - e.stopPropagation();
151 + const handleDenyClick = (event: React.MouseEvent) => {
152 + event.preventDefault();
153 + event.stopPropagation();
154 if (leaseId) {
155 runAsyncAdminAction(() => onDenyStatusChange?.(leaseId, !isDenied));
156 }
157 };
158
159 - const handleIPBanClick = (e: React.MouseEvent) => {
160 - e.preventDefault();
161 - e.stopPropagation();
159 + const handleIPBanClick = (event: React.MouseEvent) => {
160 + event.preventDefault();
161 + event.stopPropagation();
162 if (ip) {
163 runAsyncAdminAction(() => onIPBanStatusChange?.(ip, !isIPBanned));
164 }
165 };
166
167 - const handleBPSSettingsClick = (e: React.MouseEvent) => {
168 - e.preventDefault();
169 - e.stopPropagation();
167 + const handleBPSSettingsClick = (event: React.MouseEvent) => {
168 + event.preventDefault();
169 + event.stopPropagation();
170 setSliderIndex(bpsToSliderIndex(bps));
171 setBpsInput(bps.toString());
172 setShowBPSModal(true);
@@ -224,38 +224,33 @@ export function ServerCard({
224 <article
225 data-hero-key={`server-bg-${serverId}`}
226 className={clsx(
227 - "relative w-full overflow-hidden rounded-3xl group border border-white/10 shadow-lg",
228 - showAdminControls ? "h-[286px]" : "h-[174.5px]"
227 + "group flex h-full flex-col overflow-hidden rounded-[1.75rem] border border-border bg-card shadow-[0_18px_42px_oklch(0%_0_0_/_0.06)] transition-transform duration-200 hover:-translate-y-1 hover:shadow-[0_24px_56px_oklch(0%_0_0_/_0.08)]",
228 + showAdminControls ? "min-h-[360px]" : "min-h-[310px]"
229 )}
230 >
231 <div
232 - className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
232 + className="relative h-40 overflow-hidden border-b border-border bg-secondary"
233 style={{
234 backgroundImage: thumbnail
235 - ? `url(${thumbnail})`
236 - : "linear-gradient(135deg, var(--card) 0%, var(--background) 100%)",
235 + ? `linear-gradient(rgba(255,255,255,0.08), rgba(255,255,255,0.08)), url(${thumbnail})`
236 + : "linear-gradient(135deg, oklch(99.4% 0.004 85) 0%, oklch(94.6% 0.008 85) 100%)",
237 + backgroundSize: "cover",
238 + backgroundPosition: "center",
239 }}
238 - />
239 -
240 - <div className="absolute inset-0 bg-linear-to-t from-black via-black/60 to-transparent" />
240 + >
241 + {!thumbnail && (
242 + <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_oklch(68%_0.19_34_/_0.24),_transparent_36%),linear-gradient(180deg,_transparent,_oklch(100%_0_0_/_0.72))]" />
243 + )}
244
242 - <div className="relative z-10 flex h-full flex-col justify-between p-5">
243 - <div className="flex items-start justify-between">
244 - <div className="flex items-center gap-2 rounded-full bg-black/40 px-3 py-1 backdrop-blur-sm border border-white/5">
245 - <div
246 - className={clsx(
247 - "size-2 rounded-full",
248 - online
249 - ? "bg-primary shadow-[0_0_8px_rgba(0,219,219,0.8)] animate-pulse"
250 - : "bg-gray-500"
251 - )}
252 - />
245 + <div className="absolute inset-x-0 top-0 flex items-start justify-between p-4">
246 + <div className="inline-flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground shadow-[0_8px_18px_oklch(0%_0_0_/_0.05)]">
247 <span
248 className={clsx(
255 - "text-[10px] font-bold uppercase tracking-wider",
256 - online ? "text-white" : "text-white/60"
249 + "h-2.5 w-2.5 rounded-full",
250 + online ? "bg-green-status" : "bg-muted-foreground"
251 )}
258 - >
252 + />
253 + <span>
254 {online ? "Online" : "Offline"}
255 {formattedDuration && online && ` · ${formattedDuration}`}
256 </span>
@@ -265,17 +260,17 @@ export function ServerCard({
260 <button
261 onClick={handleSelectClick}
262 className={clsx(
268 - "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
263 + "flex size-9 items-center justify-center rounded-full border border-border bg-card/95 text-text-muted shadow-[0_8px_18px_oklch(0%_0_0_/_0.05)] transition-colors cursor-pointer",
264 isSelected
270 - ? "bg-primary text-black"
271 - : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
265 + ? "bg-primary text-primary-foreground"
266 + : "hover:text-foreground"
267 )}
268 aria-label={isSelected ? "Deselect" : "Select"}
269 >
270 <svg
271 xmlns="http://www.w3.org/2000/svg"
272 viewBox="0 0 24 24"
278 - className="w-[18px] h-[18px]"
273 + className="h-[18px] w-[18px]"
274 fill="none"
275 stroke="currentColor"
276 strokeWidth="3"
@@ -289,10 +284,10 @@ export function ServerCard({
284 <button
285 onClick={handleFavoriteClick}
286 className={clsx(
292 - "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
287 + "flex size-9 items-center justify-center rounded-full border border-border bg-card/95 text-text-muted shadow-[0_8px_18px_oklch(0%_0_0_/_0.05)] transition-colors cursor-pointer",
288 isFavorite
294 - ? "bg-primary text-black"
295 - : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
289 + ? "bg-primary text-primary-foreground"
290 + : "hover:text-foreground"
291 )}
292 aria-label={
293 isFavorite ? "Remove from favorites" : "Add to favorites"
@@ -301,7 +296,7 @@ export function ServerCard({
296 <svg
297 xmlns="http://www.w3.org/2000/svg"
298 viewBox="0 0 24 24"
304 - className="w-[18px] h-[18px]"
299 + className="h-[18px] w-[18px]"
300 fill={isFavorite ? "currentColor" : "none"}
301 stroke="currentColor"
302 strokeWidth="2"
@@ -313,118 +308,112 @@ export function ServerCard({
308 </button>
309 )}
310 </div>
311 + </div>
312
317 - <div className="flex flex-col gap-3">
318 - <div className="flex items-end justify-between gap-3">
319 - <div className="flex flex-col gap-1.5 flex-1 min-w-0">
320 - <h3 className="font-display text-xl font-bold leading-tight text-white truncate">
321 - {name}
322 - </h3>
323 -
324 - {description && (
325 - <p className="text-xs text-white/70 line-clamp-1 font-medium">
326 - {description}
327 - </p>
328 - )}
329 -
330 - {tags && tags.length > 0 && (
331 - <ScrollArea className="w-full mt-1">
332 - <div className="flex gap-1.5 min-w-max">
333 - {tags.map((tag, index) => (
334 - <span
335 - key={index}
336 - className="rounded bg-primary/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary border border-primary/30 whitespace-nowrap"
337 - >
338 - #{tag}
339 - </span>
340 - ))}
341 - </div>
342 - <ScrollBar orientation="horizontal" />
343 - </ScrollArea>
344 - )}
313 + <div className="flex flex-1 flex-col justify-between gap-4 p-5">
314 + <div className="space-y-4">
315 + <div className="space-y-2">
316 + <h3 className="text-2xl font-bold tracking-tight text-foreground truncate">
317 + {name}
318 + </h3>
319 + {description && (
320 + <p className="line-clamp-2 text-sm leading-6 text-text-muted">
321 + {description}
322 + </p>
323 + )}
324 + </div>
325
346 - {owner && (
347 - <span className="text-[10px] font-medium text-white/50">
348 - by {owner}
349 - </span>
350 - )}
326 + {tags.length > 0 && (
327 + <div className="w-full overflow-x-auto">
328 + <div className="flex min-w-max gap-2 pb-1">
329 + {tags.map((tag, index) => (
330 + <span
331 + key={index}
332 + className="whitespace-nowrap rounded-full border border-border bg-secondary px-3 py-1 text-xs font-medium text-text-muted"
333 + >
334 + #{tag}
335 + </span>
336 + ))}
337 + </div>
338 </div>
339 + )}
340
353 - {!showAdminControls && thumbnail && (
354 - <div className="shrink-0">
355 - <div className="size-10 overflow-hidden rounded-xl border border-white/20 shadow-lg">
356 - <img
357 - alt={`${name} avatar`}
358 - className="h-full w-full object-cover"
359 - src={thumbnail}
360 - />
361 - </div>
362 - </div>
341 + <div className="flex flex-wrap items-center gap-3 text-sm text-text-muted">
342 + {owner && <span>by {owner}</span>}
343 + {dns && (
344 + <span className="rounded-full bg-secondary px-3 py-1 font-mono text-[11px] text-secondary-foreground">
345 + {dns}
346 + </span>
347 )}
348 </div>
349 + </div>
350
366 - {showAdminControls && leaseId && (
367 - <div className="flex flex-col gap-2 w-full mt-2">
368 - {onBPSChange && (
369 - <div className="flex items-center justify-between w-full">
370 - <span className="text-xs text-white/60">
371 - BPS: <span className="font-medium text-white">{formatBPS(bps)}</span>
351 + {showAdminControls && leaseId && (
352 + <div className="flex flex-col gap-3 rounded-[1.5rem] border border-border bg-secondary/70 p-4">
353 + {onBPSChange && (
354 + <div className="flex items-center justify-between gap-4">
355 + <span className="text-xs text-text-muted">
356 + BPS:{" "}
357 + <span className="font-medium text-foreground">
358 + {formatBPS(bps)}
359 </span>
373 - <button
374 - onClick={handleBPSSettingsClick}
375 - className="px-3 py-1 text-[10px] rounded-full bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
376 - >
377 - Settings
378 - </button>
379 - </div>
380 - )}
360 + </span>
361 + <button
362 + onClick={handleBPSSettingsClick}
363 + className="rounded-full border border-border bg-card px-3 py-1 text-[11px] font-semibold text-foreground transition-colors hover:bg-secondary cursor-pointer"
364 + >
365 + Settings
366 + </button>
367 + </div>
368 + )}
369
382 - {isApproved && ip && (
383 - <div className="text-[10px] text-white/50">
384 - IP: <span className="font-mono">{ip}</span>
385 - {isIPBanned && (
386 - <span className="ml-2 text-red-400">(Banned)</span>
387 - )}
388 - </div>
389 - )}
370 + {isApproved && ip && (
371 + <div className="text-[11px] text-text-muted">
372 + IP: <span className="font-mono text-foreground">{ip}</span>
373 + {isIPBanned && (
374 + <span className="ml-2 font-medium text-destructive">
375 + (Banned)
376 + </span>
377 + )}
378 + </div>
379 + )}
380
391 - {!isApproved && !isDenied ? (
392 - <div className="flex gap-2 w-full">
393 - <button
394 - onClick={handleApproveClick}
395 - className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-green-600/80 hover:bg-green-600 backdrop-blur-sm"
396 - >
397 - Approve
398 - </button>
399 - <button
400 - onClick={handleDenyClick}
401 - className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-red-600/80 hover:bg-red-600 backdrop-blur-sm"
402 - >
403 - Deny
404 - </button>
405 - </div>
406 - ) : (
381 + {!isApproved && !isDenied ? (
382 + <div className="flex gap-2">
383 <button
408 - onClick={ip ? handleIPBanClick : handleBanClick}
409 - className={clsx(
410 - "w-full px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white backdrop-blur-sm",
411 - (ip ? isIPBanned : isBanned)
412 - ? "bg-green-600/80 hover:bg-green-600"
413 - : "bg-red-600/80 hover:bg-red-600"
414 - )}
384 + onClick={handleApproveClick}
385 + className="flex-1 rounded-xl bg-primary px-4 py-2 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90 cursor-pointer"
386 >
416 - {ip
417 - ? isIPBanned
418 - ? "Unban IP"
419 - : "Ban IP"
420 - : isBanned
421 - ? "Unban"
422 - : "Ban"}
387 + Approve
388 </button>
424 - )}
425 - </div>
426 - )}
427 - </div>
389 + <button
390 + onClick={handleDenyClick}
391 + className="flex-1 rounded-xl bg-destructive px-4 py-2 text-xs font-semibold text-destructive-foreground transition-opacity hover:opacity-90 cursor-pointer"
392 + >
393 + Deny
394 + </button>
395 + </div>
396 + ) : (
397 + <button
398 + onClick={ip ? handleIPBanClick : handleBanClick}
399 + className={clsx(
400 + "w-full rounded-xl px-4 py-2 text-xs font-semibold transition-opacity hover:opacity-90 cursor-pointer",
401 + (ip ? isIPBanned : isBanned)
402 + ? "bg-green-status text-white"
403 + : "bg-destructive text-destructive-foreground"
404 + )}
405 + >
406 + {ip
407 + ? isIPBanned
408 + ? "Unban IP"
409 + : "Ban IP"
410 + : isBanned
411 + ? "Unban"
412 + : "Ban"}
413 + </button>
414 + )}
415 + </div>
416 + )}
417 </div>
418 </article>
419 );
@@ -437,7 +426,7 @@ export function ServerCard({
426 <Link
427 to={navigationPath}
428 state={navigationState}
440 - className="relative cursor-pointer block"
429 + className="relative block h-full cursor-pointer"
430 >
431 {cardBody}
432 </Link>
@@ -459,19 +448,19 @@ export function ServerCard({
448 min="0"
449 max={bpsSteps.length - 1}
450 value={sliderIndex}
462 - onChange={(e) => {
463 - const idx = parseInt(e.target.value, 10);
451 + onChange={(event) => {
452 + const idx = parseInt(event.target.value, 10);
453 handleSliderChange(idx);
454 }}
466 - className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer"
455 + className="h-2 w-full cursor-pointer appearance-none rounded-md bg-secondary"
456 />
457 <div className="flex justify-between text-xs text-text-muted">
458 {bpsSteps.map((step, idx) => (
459 <span
460 key={idx}
461 className={clsx(
473 - "cursor-pointer hover:text-foreground transition-colors",
474 - sliderIndex === idx && "text-primary font-medium"
462 + "cursor-pointer transition-colors hover:text-foreground",
463 + sliderIndex === idx && "font-medium text-primary"
464 )}
465 onClick={() => handleSliderChange(idx)}
466 >
@@ -480,17 +469,17 @@ export function ServerCard({
469 ))}
470 </div>
471 <div>
483 - <label className="text-xs text-text-muted mb-1 block">
472 + <label className="mb-1 block text-xs text-text-muted">
473 Custom value (B/s)
474 </label>
475 <input
476 type="number"
477 value={bpsInput}
489 - onChange={(e) => {
490 - setBpsInput(e.target.value);
491 - syncSliderFromInput(parseInt(e.target.value, 10) || 0);
478 + onChange={(event) => {
479 + setBpsInput(event.target.value);
480 + syncSliderFromInput(parseInt(event.target.value, 10) || 0);
481 }}
493 - className="w-full px-3 py-2 border border-foreground/20 rounded bg-background text-foreground"
482 + className="w-full rounded border border-foreground/20 bg-background px-3 py-2 text-foreground"
483 placeholder="Enter BPS limit"
484 min="0"
485 />
frontend/src/components/ServerListView.tsx
+229 -137
@@ -1,5 +1,6 @@
1 import { useEffect, useMemo, useState } from "react";
2 import { Header } from "@/components/Header";
3 +import { LandingHero } from "@/components/LandingHero";
4 import { SearchBar } from "@/components/SearchBar";
5 import { ServerCard } from "@/components/ServerCard";
6 import { TagCombobox } from "@/components/TagCombobox";
@@ -128,6 +129,8 @@ export function ServerListView({
129 })),
130 [serverItems]
131 );
132 + const featuredPublicRows = serverRows.slice(0, 3);
133 + const remainingPublicRows = serverRows.slice(3);
134
135 const allLeaseIds = useMemo(
136 () => [
@@ -234,160 +237,249 @@ export function ServerListView({
237 </>
238 );
239
240 + const renderServerCard = ({
241 + server,
242 + adminServer,
243 + }: {
244 + server: ListServer;
245 + adminServer?: AdminServer;
246 + }) => {
247 + const isSelected = adminServer
248 + ? selectedLeaseIds.has(adminServer.peerId)
249 + : false;
250 +
251 + return (
252 + <ServerCard
253 + key={server.id}
254 + serverId={server.id}
255 + name={server.name}
256 + description={server.description}
257 + tags={server.tags}
258 + thumbnail={server.thumbnail}
259 + owner={server.owner}
260 + online={server.online}
261 + dns={server.dns}
262 + navigationPath={server.link || "#"}
263 + navigationState={{
264 + id: server.id,
265 + name: server.name,
266 + description: server.description,
267 + tags: server.tags,
268 + thumbnail: server.thumbnail,
269 + owner: server.owner,
270 + online: server.online,
271 + serverUrl: server.link,
272 + }}
273 + firstSeen={server.firstSeen}
274 + isFavorite={favoriteIds.has(server.id)}
275 + onToggleFavorite={onToggleFavorite}
276 + showAdminControls={isAdmin && !!adminServer}
277 + leaseId={adminServer?.peerId}
278 + isBanned={adminServer?.isBanned}
279 + isApproved={adminServer?.isApproved}
280 + isDenied={adminServer?.isDenied}
281 + bps={adminServer?.bps}
282 + ip={adminServer?.ip}
283 + isIPBanned={adminServer?.isIPBanned}
284 + onBanStatusChange={onBanStatusChange}
285 + onBPSChange={onBPSChange}
286 + onApproveStatusChange={onApproveStatusChange}
287 + onDenyStatusChange={onDenyStatusChange}
288 + onIPBanStatusChange={onIPBanStatusChange}
289 + isSelected={isSelected}
290 + onToggleSelect={handleToggleSelect}
291 + />
292 + );
293 + };
294 +
295 + const gridClasses =
296 + "grid grid-cols-1 gap-6 p-4 min-[500px]:grid-cols-2 min-[500px]:p-6 md:grid-cols-3";
297 +
298 + const serverGrid = (
299 + <div className={gridClasses}>
300 + {serverRows.length > 0 ? (
301 + serverRows.map(renderServerCard)
302 + ) : (
303 + <div className="col-span-full py-12 text-center">
304 + <p className="text-lg text-text-muted">
305 + No servers match these filters
306 + </p>
307 + </div>
308 + )}
309 + </div>
310 + );
311 +
312 + const searchBar = (
313 + <SearchBar
314 + searchQuery={searchQuery}
315 + onSearchChange={onSearchChange}
316 + status={status}
317 + onStatusChange={onStatusChange}
318 + sortBy={sortBy}
319 + onSortByChange={onSortByChange}
320 + availableTags={availableTags}
321 + selectedTags={selectedTags}
322 + onAddTag={onTagToggle}
323 + onRemoveTag={onTagToggle}
324 + hideFiltersOnMobile={isAdmin}
325 + setShowFilterModal={isAdmin ? setShowFilterModal : undefined}
326 + />
327 + );
328 +
329 return (
330 <div className="relative flex h-auto min-h-screen w-full flex-col">
331 <div className="flex h-full grow flex-col">
332 <div className="flex flex-1 justify-center">
241 - <div className="flex flex-col w-full max-w-6xl flex-1 px-0 md:px-8">
242 - <div className="sticky top-0 z-10 bg-background pb-4 pt-5">
243 - <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
244 - <div className="flex items-center gap-2">
245 - <div className="flex-1">
246 - <SearchBar
247 - searchQuery={searchQuery}
248 - onSearchChange={onSearchChange}
249 - status={status}
250 - onStatusChange={onStatusChange}
251 - sortBy={sortBy}
252 - onSortByChange={onSortByChange}
253 - availableTags={availableTags}
254 - selectedTags={selectedTags}
255 - onAddTag={onTagToggle}
256 - onRemoveTag={onTagToggle}
257 - hideFiltersOnMobile={isAdmin}
258 - setShowFilterModal={setShowFilterModal}
259 - />
260 - </div>
261 - </div>
262 - {isAdmin && (
263 - <div className="hidden sm:flex flex-wrap items-center gap-6 mt-4 px-4 sm:px-6">
264 - {adminFilterControls}
333 + <div className="flex w-full max-w-6xl flex-1 flex-col px-0 md:px-8">
334 + {isAdmin ? (
335 + <>
336 + <div className="sticky top-0 z-10 bg-background pb-4 pt-5">
337 + <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
338 + <div className="flex items-center gap-2">
339 + <div className="flex-1">{searchBar}</div>
340 + </div>
341 + <div className="hidden sm:flex flex-wrap items-center gap-6 mt-4 px-4 sm:px-6">
342 + {adminFilterControls}
343 + </div>
344 + {onApprovalModeChange && (
345 + <div className="sm:hidden flex items-center gap-3 mt-4 px-4">
346 + <span className="text-sm font-medium text-text-muted">
347 + Approval
348 + </span>
349 + <ApprovalModeToggle
350 + approvalMode={approvalMode}
351 + onApprovalModeChange={onApprovalModeChange}
352 + />
353 + </div>
354 + )}
355 </div>
266 - )}
267 - {isAdmin && onApprovalModeChange && (
268 - <div className="sm:hidden flex items-center gap-3 mt-4 px-4">
269 - <span className="text-sm font-medium text-text-muted">
270 - Approval
271 - </span>
272 - <ApprovalModeToggle
273 - approvalMode={approvalMode}
274 - onApprovalModeChange={onApprovalModeChange}
356 + <main className="z-0 flex-1">{serverGrid}</main>
357 + </>
358 + ) : (
359 + <>
360 + <div className="sticky top-0 z-20 bg-background/95 pt-5">
361 + <Header
362 + title={title}
363 + isAdmin={isAdmin}
364 + onLogout={onLogout}
365 + ctaLabel="Get Started"
366 />
367 </div>
277 - )}
278 - </div>
279 - <main className="flex-1 z-0">
280 - <div className="grid grid-cols-1 min-[500px]:grid-cols-2 md:grid-cols-3 gap-6 p-4 min-[500px]:p-6">
281 - {serverRows.length > 0 ? (
282 - serverRows.map(({ server, adminServer }) => {
283 - const isSelected = adminServer
284 - ? selectedLeaseIds.has(adminServer.peerId)
285 - : false;
286 - return (
287 - <ServerCard
288 - key={server.id}
289 - serverId={server.id}
290 - name={server.name}
291 - description={server.description}
292 - tags={server.tags}
293 - thumbnail={server.thumbnail}
294 - owner={server.owner}
295 - online={server.online}
296 - dns={server.dns}
297 - serverUrl={server.link}
298 - navigationPath={server.link || "#"}
299 - navigationState={{
300 - id: server.id,
301 - name: server.name,
302 - description: server.description,
303 - tags: server.tags,
304 - thumbnail: server.thumbnail,
305 - owner: server.owner,
306 - online: server.online,
307 - serverUrl: server.link,
308 - }}
309 - firstSeen={server.firstSeen}
310 - isFavorite={favoriteIds.has(server.id)}
311 - onToggleFavorite={onToggleFavorite}
312 - showAdminControls={isAdmin && !!adminServer}
313 - leaseId={adminServer?.peerId}
314 - isBanned={adminServer?.isBanned}
315 - isApproved={adminServer?.isApproved}
316 - isDenied={adminServer?.isDenied}
317 - bps={adminServer?.bps}
318 - ip={adminServer?.ip}
319 - isIPBanned={adminServer?.isIPBanned}
320 - onBanStatusChange={onBanStatusChange}
321 - onBPSChange={onBPSChange}
322 - onApproveStatusChange={onApproveStatusChange}
323 - onDenyStatusChange={onDenyStatusChange}
324 - onIPBanStatusChange={onIPBanStatusChange}
325 - isSelected={isSelected}
326 - onToggleSelect={handleToggleSelect}
327 - />
328 - );
329 - })
330 - ) : (
331 - <div className="col-span-full text-center py-12">
332 - <p className="text-text-muted text-lg">
333 - No servers match these filters
334 - </p>
335 - </div>
336 - )}
337 - </div>
338 - </main>
368 + <main className="z-0 flex-1 px-4 pb-14 pt-6 sm:px-6">
369 + <LandingHero />
370 +
371 + <section
372 + id="live-servers"
373 + aria-labelledby="live-servers-title"
374 + className="mt-8 border-t border-border pt-8"
375 + >
376 + <div className="space-y-2">
377 + <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
378 + Discovery
379 + </p>
380 + <h2
381 + id="live-servers-title"
382 + className="text-3xl font-semibold tracking-tight text-foreground"
383 + >
384 + Browse live apps
385 + </h2>
386 + <p className="max-w-2xl text-sm leading-6 text-text-muted">
387 + Explore public services exposed through Portal.
388 + </p>
389 + </div>
390 +
391 + {serverRows.length > 0 ? (
392 + <>
393 + <div className="mt-6">
394 + <div className={gridClasses}>
395 + {featuredPublicRows.map(renderServerCard)}
396 + </div>
397 + </div>
398 +
399 + <div className="mt-4 border-t border-border pt-6">
400 + {searchBar}
401 + <div className="px-1 pt-3 text-sm text-text-muted">
402 + {filteredServers.length.toLocaleString()} services
403 + visible
404 + </div>
405 + {remainingPublicRows.length > 0 && (
406 + <div className={gridClasses}>
407 + {remainingPublicRows.map(renderServerCard)}
408 + </div>
409 + )}
410 + </div>
411 + </>
412 + ) : (
413 + <div className="mt-6">
414 + {searchBar}
415 + <div className="px-1 pt-3 text-sm text-text-muted">
416 + 0 services visible
417 + </div>
418 + <div className="py-12 text-center">
419 + <p className="text-lg text-text-muted">
420 + No servers match these filters
421 + </p>
422 + </div>
423 + </div>
424 + )}
425 + </section>
426 + </main>
427 + </>
428 + )}
429 </div>
430 </div>
431 </div>
432
343 - <Dialog open={showFilterModal} onOpenChange={setShowFilterModal}>
344 - <DialogContent className="sm:hidden max-w-sm rounded-sm">
345 - <DialogHeader>
346 - <DialogTitle>Filters</DialogTitle>
347 - </DialogHeader>
348 - <div className="flex flex-col gap-4">
349 - <div className="flex flex-col gap-2">
350 - <span className="text-sm font-medium text-text-muted">
351 - Status
352 - </span>
353 - <StatusSelect
354 - status={status}
355 - onStatusChange={onStatusChange}
356 - className="w-full!"
357 - />
358 - </div>
359 - {isAdmin && onBanFilterChange && (
433 + {isAdmin && (
434 + <Dialog open={showFilterModal} onOpenChange={setShowFilterModal}>
435 + <DialogContent className="sm:hidden max-w-sm rounded-sm">
436 + <DialogHeader>
437 + <DialogTitle>Filters</DialogTitle>
438 + </DialogHeader>
439 + <div className="flex flex-col gap-4">
440 <div className="flex flex-col gap-2">
441 <span className="text-sm font-medium text-text-muted">
362 - Ban Status
442 + Status
443 </span>
364 - <BanStatusButtons
365 - className="[&>button]:w-full"
366 - banFilter={banFilter}
367 - onBanFilterChange={onBanFilterChange}
444 + <StatusSelect
445 + status={status}
446 + onStatusChange={onStatusChange}
447 + className="w-full!"
448 + />
449 + </div>
450 + {onBanFilterChange && (
451 + <div className="flex flex-col gap-2">
452 + <span className="text-sm font-medium text-text-muted">
453 + Ban Status
454 + </span>
455 + <BanStatusButtons
456 + className="[&>button]:w-full"
457 + banFilter={banFilter}
458 + onBanFilterChange={onBanFilterChange}
459 + />
460 + </div>
461 + )}
462 + <div className="flex flex-col gap-2">
463 + <span className="text-sm font-medium text-text-muted">Sort</span>
464 + <SortbySelect
465 + className="w-full!"
466 + sortBy={sortBy}
467 + onSortByChange={onSortByChange}
468 + />
469 + </div>
470 + <div className="flex flex-col gap-2">
471 + <span className="text-sm font-medium text-text-muted">Tags</span>
472 + <TagCombobox
473 + availableTags={availableTags}
474 + selectedTags={selectedTags}
475 + onAdd={onTagToggle}
476 + onRemove={onTagToggle}
477 />
478 </div>
370 - )}
371 - <div className="flex flex-col gap-2">
372 - <span className="text-sm font-medium text-text-muted">Sort</span>
373 - <SortbySelect
374 - className="w-full!"
375 - sortBy={sortBy}
376 - onSortByChange={onSortByChange}
377 - />
378 - </div>
379 - <div className="flex flex-col gap-2">
380 - <span className="text-sm font-medium text-text-muted">Tags</span>
381 - <TagCombobox
382 - availableTags={availableTags}
383 - selectedTags={selectedTags}
384 - onAdd={onTagToggle}
385 - onRemove={onTagToggle}
386 - />
479 </div>
388 - </div>
389 - </DialogContent>
390 - </Dialog>
480 + </DialogContent>
481 + </Dialog>
482 + )}
483
484 {isAdmin && (
485 <FloatingActionBar
frontend/src/components/TunnelCommandForm.tsx new
+523
@@ -0,0 +1,523 @@
1 +import { useEffect, useId, useMemo, useState } from "react";
2 +import { Check, Copy, X } from "lucide-react";
3 +import { Input } from "@/components/ui/input";
4 +import { cn } from "@/lib/utils";
5 +import {
6 + buildTunnelCommand,
7 + buildTunnelPreviewURL,
8 + normalizeAbsoluteHTTPURL,
9 + type TunnelCommandOS,
10 +} from "@/lib/tunnelCommand";
11 +
12 +interface TunnelCommandFormProps {
13 + className?: string;
14 + theme?: "light" | "terminal";
15 + mode?: "full" | "hero";
16 +}
17 +
18 +export function TunnelCommandForm({
19 + className,
20 + theme = "light",
21 + mode = "full",
22 +}: TunnelCommandFormProps) {
23 + const defaultHost = "3000";
24 + const tunnelNameSeedStorageKey = "portal:tunnel-name-seed";
25 + const inputId = useId();
26 + const isTerminal = theme === "terminal";
27 + const isHero = mode === "hero";
28 +
29 + const currentOrigin = useMemo(() => {
30 + if (typeof window !== "undefined") {
31 + return window.location.origin;
32 + }
33 + return "https://localhost:4017";
34 + }, []);
35 + const nameSeed = useMemo(() => {
36 + if (typeof window === "undefined") {
37 + return "web_portal";
38 + }
39 +
40 + try {
41 + const existing = window.localStorage.getItem(tunnelNameSeedStorageKey);
42 + if (existing && existing.trim() !== "") {
43 + return existing;
44 + }
45 +
46 + const next =
47 + typeof window.crypto?.randomUUID === "function"
48 + ? `web_${window.crypto.randomUUID()}`
49 + : `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
50 +
51 + window.localStorage.setItem(tunnelNameSeedStorageKey, next);
52 + return next;
53 + } catch {
54 + return "web_portal";
55 + }
56 + }, []);
57 +
58 + const [target, setTarget] = useState(defaultHost);
59 + const [name, setName] = useState("");
60 + const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
61 + const [defaultRelays, setDefaultRelays] = useState(true);
62 + const [urlInput, setUrlInput] = useState("");
63 + const [copied, setCopied] = useState(false);
64 + const [os, setOs] = useState<TunnelCommandOS>("unix");
65 + const [thumbnailURL, setThumbnailURL] = useState("");
66 +
67 + const normalizedThumbnailURL = useMemo(
68 + () => normalizeAbsoluteHTTPURL(thumbnailURL),
69 + [thumbnailURL]
70 + );
71 +
72 + const thumbnailError = useMemo(() => {
73 + if (thumbnailURL.trim() === "" || normalizedThumbnailURL !== "") {
74 + return "";
75 + }
76 +
77 + return "Thumbnail must be an absolute http:// or https:// URL.";
78 + }, [thumbnailURL, normalizedThumbnailURL]);
79 +
80 + const command = useMemo(
81 + () =>
82 + buildTunnelCommand({
83 + currentOrigin,
84 + target,
85 + name,
86 + nameSeed,
87 + relayUrls: isHero ? [] : relayUrls,
88 + defaultRelays: isHero ? true : defaultRelays,
89 + thumbnailURL: normalizedThumbnailURL,
90 + os,
91 + }),
92 + [
93 + currentOrigin,
94 + defaultRelays,
95 + isHero,
96 + name,
97 + nameSeed,
98 + normalizedThumbnailURL,
99 + os,
100 + relayUrls,
101 + target,
102 + ]
103 + );
104 + const previewURL = useMemo(
105 + () => buildTunnelPreviewURL(currentOrigin, name, target, nameSeed),
106 + [currentOrigin, name, nameSeed, target]
107 + );
108 +
109 + useEffect(() => {
110 + if (!copied) {
111 + return;
112 + }
113 +
114 + const timer = window.setTimeout(() => {
115 + setCopied(false);
116 + }, 2000);
117 +
118 + return () => {
119 + window.clearTimeout(timer);
120 + };
121 + }, [copied]);
122 +
123 + const addRelayURL = (url: string) => {
124 + const trimmed = url.trim();
125 + if (!trimmed || relayUrls.includes(trimmed)) {
126 + return;
127 + }
128 +
129 + try {
130 + new URL(trimmed);
131 + setRelayUrls((prev) => [...prev, trimmed]);
132 + setUrlInput("");
133 + } catch {
134 + // Ignore invalid relay URL input.
135 + }
136 + };
137 +
138 + const removeRelayURL = (url: string) => {
139 + setRelayUrls((prev) => prev.filter((candidate) => candidate !== url));
140 + };
141 +
142 + const handleURLKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
143 + if (event.key === "Enter") {
144 + event.preventDefault();
145 + addRelayURL(urlInput);
146 + return;
147 + }
148 +
149 + if (event.key === "Backspace" && urlInput === "" && relayUrls.length > 0) {
150 + setRelayUrls((prev) => prev.slice(0, -1));
151 + }
152 + };
153 +
154 + const handleCopy = async () => {
155 + try {
156 + await navigator.clipboard.writeText(command);
157 + setCopied(true);
158 + } catch (error) {
159 + console.error("Failed to copy tunnel command", error);
160 + }
161 + };
162 +
163 + const commandSection = (
164 + <div className="space-y-2">
165 + <label
166 + className={cn(
167 + "text-sm font-medium",
168 + isTerminal ? "text-slate-200" : "text-foreground"
169 + )}
170 + >
171 + {isHero ? "Command" : "Generated Command"}
172 + </label>
173 + <div className="relative">
174 + <pre
175 + className={cn(
176 + "overflow-x-auto whitespace-pre-wrap break-all rounded-xl p-4 pr-12 font-mono text-sm leading-7",
177 + isTerminal
178 + ? "border border-white/10 bg-black/30 text-white"
179 + : "bg-border text-foreground"
180 + )}
181 + >
182 + {command}
183 + </pre>
184 + <button
185 + type="button"
186 + onClick={handleCopy}
187 + className={cn(
188 + "absolute right-2 top-2 rounded-md p-2 transition-colors",
189 + isTerminal ? "hover:bg-white/10" : "hover:bg-background/70"
190 + )}
191 + aria-label="Copy command"
192 + >
193 + {copied ? (
194 + <Check className="h-4 w-4 text-green-600" />
195 + ) : (
196 + <Copy className="h-4 w-4 text-text-muted" />
197 + )}
198 + </button>
199 + </div>
200 +
201 + {isHero && (
202 + <div
203 + className={cn(
204 + "space-y-2 rounded-xl border px-4 py-3",
205 + isTerminal ? "border-white/10 bg-white/5" : "border-border bg-white"
206 + )}
207 + >
208 + <p
209 + className={cn(
210 + "text-xs font-semibold uppercase tracking-[0.24em]",
211 + isTerminal ? "text-slate-400" : "text-text-muted"
212 + )}
213 + >
214 + Public URL
215 + </p>
216 + <a
217 + href={previewURL}
218 + target="_blank"
219 + rel="noopener noreferrer"
220 + className={cn(
221 + "block overflow-x-auto whitespace-nowrap font-mono text-base font-medium underline-offset-4 hover:underline sm:text-lg",
222 + isTerminal ? "text-green-status" : "text-primary"
223 + )}
224 + >
225 + {previewURL}
226 + </a>
227 + </div>
228 + )}
229 + </div>
230 + );
231 +
232 + const customizationSectionLabel =
233 + isHero ? (
234 + <div className="pt-1">
235 + <p
236 + className={cn(
237 + "text-xs font-semibold uppercase tracking-[0.24em]",
238 + isTerminal ? "text-slate-400" : "text-text-muted"
239 + )}
240 + >
241 + Customize
242 + </p>
243 + </div>
244 + ) : null;
245 +
246 + const heroFieldLabelClass = cn(
247 + "text-[11px] font-semibold uppercase tracking-[0.2em]",
248 + isTerminal ? "text-slate-400" : "text-text-muted"
249 + );
250 +
251 + const heroInputClass = cn(
252 + "h-10 rounded-lg border text-sm shadow-none",
253 + isTerminal
254 + ? "border-white/8 bg-black/20 text-slate-100 placeholder:text-slate-500"
255 + : "border-border bg-white"
256 + );
257 +
258 + return (
259 + <div className={cn("space-y-5", className)}>
260 + {isHero && commandSection}
261 + {customizationSectionLabel}
262 +
263 + {isHero ? (
264 + <div className="grid gap-3 sm:grid-cols-2">
265 + <div className="space-y-1.5">
266 + <label htmlFor={`${inputId}-host`} className={heroFieldLabelClass}>
267 + Host
268 + </label>
269 + <Input
270 + id={`${inputId}-host`}
271 + type="text"
272 + value={target}
273 + onChange={(event) => setTarget(event.target.value)}
274 + placeholder={defaultHost}
275 + className={heroInputClass}
276 + />
277 + </div>
278 +
279 + <div className="space-y-1.5">
280 + <label htmlFor={`${inputId}-name`} className={heroFieldLabelClass}>
281 + Name
282 + </label>
283 + <Input
284 + id={`${inputId}-name`}
285 + type="text"
286 + value={name}
287 + onChange={(event) => setName(event.target.value)}
288 + placeholder="auto"
289 + className={heroInputClass}
290 + />
291 + </div>
292 + </div>
293 + ) : (
294 + <>
295 + <div className="space-y-2">
296 + <label
297 + htmlFor={`${inputId}-host`}
298 + className={cn(
299 + "text-sm font-medium",
300 + isTerminal ? "text-slate-200" : "text-foreground"
301 + )}
302 + >
303 + Host
304 + </label>
305 + <Input
306 + id={`${inputId}-host`}
307 + type="text"
308 + value={target}
309 + onChange={(event) => setTarget(event.target.value)}
310 + placeholder={defaultHost}
311 + className={cn(
312 + "h-12 rounded-xl",
313 + isTerminal
314 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
315 + : "border-border bg-white"
316 + )}
317 + />
318 + </div>
319 +
320 + <div className="space-y-2">
321 + <label
322 + htmlFor={`${inputId}-name`}
323 + className={cn(
324 + "text-sm font-medium",
325 + isTerminal ? "text-slate-200" : "text-foreground"
326 + )}
327 + >
328 + Service Name
329 + </label>
330 + <Input
331 + id={`${inputId}-name`}
332 + type="text"
333 + value={name}
334 + onChange={(event) => setName(event.target.value)}
335 + placeholder="auto-generated when empty"
336 + className={cn(
337 + "h-12 rounded-xl",
338 + isTerminal
339 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
340 + : "border-border bg-white"
341 + )}
342 + />
343 + </div>
344 + </>
345 + )}
346 +
347 + {!isHero && (
348 + <div className="space-y-2">
349 + <label
350 + className={cn(
351 + "text-sm font-medium",
352 + isTerminal ? "text-slate-200" : "text-foreground"
353 + )}
354 + >
355 + Relay URLs
356 + </label>
357 +
358 + <div className="flex items-center justify-between gap-3">
359 + <label
360 + className={cn(
361 + "ml-auto flex items-center gap-2 text-xs",
362 + isTerminal ? "text-slate-400" : "text-text-muted"
363 + )}
364 + >
365 + <input
366 + type="checkbox"
367 + checked={defaultRelays}
368 + onChange={(event) => setDefaultRelays(event.target.checked)}
369 + className="h-4 w-4"
370 + />
371 + <span>Include default registry</span>
372 + </label>
373 + </div>
374 +
375 + <div
376 + className={cn(
377 + "flex min-h-12 flex-wrap items-center gap-2 rounded-xl px-2.5 py-2",
378 + isTerminal
379 + ? "border border-white/10 bg-white/5"
380 + : "border border-border bg-white"
381 + )}
382 + >
383 + {relayUrls.map((url) => (
384 + <span
385 + key={url}
386 + className={cn(
387 + "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium",
388 + isTerminal
389 + ? "bg-white/10 text-slate-100"
390 + : "bg-secondary text-secondary-foreground"
391 + )}
392 + >
393 + {url}
394 + <button
395 + type="button"
396 + onClick={() => removeRelayURL(url)}
397 + className={cn(
398 + "ml-1 rounded-sm p-0.5",
399 + isTerminal ? "hover:bg-white/10" : "hover:bg-destructive/15"
400 + )}
401 + aria-label={`Remove ${url}`}
402 + >
403 + <X className="h-3 w-3" />
404 + </button>
405 + </span>
406 + ))}
407 +
408 + <input
409 + type="text"
410 + value={urlInput}
411 + onChange={(event) => setUrlInput(event.target.value)}
412 + onKeyDown={handleURLKeyDown}
413 + placeholder="Add relay URL..."
414 + className={cn(
415 + "min-w-[140px] flex-1 bg-transparent text-sm outline-none",
416 + isTerminal
417 + ? "text-white placeholder:text-slate-500"
418 + : "text-foreground placeholder:text-muted-foreground"
419 + )}
420 + />
421 + </div>
422 + </div>
423 + )}
424 +
425 + {!isHero && (
426 + <div className="space-y-2">
427 + <label
428 + htmlFor={`${inputId}-thumbnail`}
429 + className={cn(
430 + "text-sm font-medium",
431 + isTerminal ? "text-slate-200" : "text-foreground"
432 + )}
433 + >
434 + Thumbnail URL
435 + </label>
436 + <Input
437 + id={`${inputId}-thumbnail`}
438 + type="url"
439 + value={thumbnailURL}
440 + onChange={(event) => setThumbnailURL(event.target.value)}
441 + placeholder="https://cdn.example.com/thumb.png"
442 + className={cn(
443 + "h-12 rounded-xl",
444 + isTerminal
445 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
446 + : "border-border bg-white"
447 + )}
448 + />
449 + {thumbnailError && (
450 + <p className="text-xs text-destructive">{thumbnailError}</p>
451 + )}
452 + </div>
453 + )}
454 +
455 + <div className={cn("space-y-2", isHero && "pt-1")}>
456 + <label
457 + className={cn(
458 + isHero
459 + ? "text-[11px] font-semibold uppercase tracking-[0.2em]"
460 + : "text-sm font-medium",
461 + isTerminal
462 + ? isHero
463 + ? "text-slate-400"
464 + : "text-slate-200"
465 + : isHero
466 + ? "text-text-muted"
467 + : "text-foreground"
468 + )}
469 + >
470 + Operating System
471 + </label>
472 + <div
473 + className={cn(
474 + "flex p-1",
475 + isHero ? "rounded-lg" : "rounded-xl",
476 + isTerminal
477 + ? isHero
478 + ? "bg-black/20"
479 + : "bg-white/8"
480 + : "bg-border"
481 + )}
482 + >
483 + <button
484 + type="button"
485 + onClick={() => setOs("unix")}
486 + className={cn(
487 + "flex-1 rounded-lg px-3 transition-colors",
488 + isHero ? "py-1.5 text-xs font-semibold" : "py-2 text-sm font-medium",
489 + os === "unix"
490 + ? isTerminal
491 + ? "bg-white text-slate-950 shadow-sm"
492 + : "bg-background text-foreground shadow-sm"
493 + : isTerminal
494 + ? "text-slate-400 hover:text-white"
495 + : "text-text-muted hover:text-foreground"
496 + )}
497 + >
498 + Linux / macOS
499 + </button>
500 + <button
501 + type="button"
502 + onClick={() => setOs("windows")}
503 + className={cn(
504 + "flex-1 rounded-lg px-3 transition-colors",
505 + isHero ? "py-1.5 text-xs font-semibold" : "py-2 text-sm font-medium",
506 + os === "windows"
507 + ? isTerminal
508 + ? "bg-white text-slate-950 shadow-sm"
509 + : "bg-background text-foreground shadow-sm"
510 + : isTerminal
511 + ? "text-slate-400 hover:text-white"
512 + : "text-text-muted hover:text-foreground"
513 + )}
514 + >
515 + Windows (PowerShell)
516 + </button>
517 + </div>
518 + </div>
519 +
520 + {!isHero && commandSection}
521 + </div>
522 + );
523 +}
frontend/src/components/TunnelCommandModal.tsx
+12 -366
@@ -1,6 +1,5 @@
1 -import { useMemo, useState } from "react";
2 -import { Check, Copy, Terminal, X } from "lucide-react";
3 -import { cn } from "@/lib/utils";
1 +import type { ReactNode } from "react";
2 +import { Terminal } from "lucide-react";
3 import {
4 Dialog,
5 DialogContent,
@@ -9,387 +8,34 @@ import {
8 DialogTrigger,
9 } from "@/components/ui/dialog";
10 import { Button } from "@/components/ui/button";
12 -import { Input } from "@/components/ui/input";
13 -import { API_PATHS } from "@/lib/apiPaths";
11 +import { TunnelCommandForm } from "@/components/TunnelCommandForm";
12
13 interface TunnelCommandModalProps {
16 - trigger?: React.ReactNode;
14 + trigger?: ReactNode;
15 }
16
17 export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
20 - const defaultHost = "3000";
21 -
22 - // Get current host URL dynamically
23 - const currentOrigin = useMemo(() => {
24 - if (typeof window !== "undefined") {
25 - return window.location.origin;
26 - }
27 - return "https://localhost:4017";
28 - }, []);
29 -
30 - const [open, setOpen] = useState(false);
31 - const [target, setTarget] = useState(defaultHost);
32 - const [name, setName] = useState("");
33 - const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
34 - const [defaultRelays, setDefaultRelays] = useState(true);
35 - const [urlInput, setUrlInput] = useState("");
36 - const [copied, setCopied] = useState(false);
37 - const [os, setOs] = useState<"unix" | "windows">("unix");
38 - const [thumbnailURL, setThumbnailURL] = useState("");
39 - const normalizedThumbnailURL = useMemo(
40 - () => normalizeAbsoluteHTTPURL(thumbnailURL),
41 - [thumbnailURL]
42 - );
43 - const thumbnailError = useMemo(() => {
44 - if (thumbnailURL.trim() === "") {
45 - return "";
46 - }
47 - if (normalizedThumbnailURL !== "") {
48 - return "";
49 - }
50 - return "Thumbnail must be an absolute http:// or https:// URL.";
51 - }, [thumbnailURL, normalizedThumbnailURL]);
52 -
53 - const addRelayUrl = (url: string) => {
54 - const trimmed = url.trim();
55 - if (!trimmed || relayUrls.includes(trimmed)) return;
56 - // Basic URL validation
57 - try {
58 - new URL(trimmed);
59 - setRelayUrls([...relayUrls, trimmed]);
60 - setUrlInput("");
61 - } catch {
62 - // Invalid URL, ignore
63 - }
64 - };
65 -
66 - const removeRelayUrl = (url: string) => {
67 - setRelayUrls(relayUrls.filter((u) => u !== url));
68 - };
69 -
70 - const handleUrlKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
71 - if (e.key === "Enter") {
72 - e.preventDefault();
73 - addRelayUrl(urlInput);
74 - } else if (
75 - e.key === "Backspace" &&
76 - urlInput === "" &&
77 - relayUrls.length > 0
78 - ) {
79 - // Remove last URL when backspace on empty input
80 - setRelayUrls(relayUrls.slice(0, -1));
81 - }
82 - };
83 -
84 - // Generate the tunnel command
85 - const command = useMemo(() => {
86 - const targetVal = target.trim() === "" ? defaultHost : target.trim();
87 - const nameVal = name.trim();
88 - const relayUrlVal =
89 - relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
90 - const installScriptURL = new URL(
91 - API_PATHS.install.shell,
92 - currentOrigin
93 - ).toString();
94 - const installPowerShellURL = new URL(
95 - API_PATHS.install.powershell,
96 - currentOrigin
97 - ).toString();
98 - const localhostRelay = isLocalRelayOrigin(currentOrigin);
99 -
100 - const exposeArgs: string[] = [];
101 -
102 - if (nameVal !== "") {
103 - exposeArgs.push(`--name ${formatToken(nameVal, os)}`);
104 - }
105 - if (relayUrls.length > 0) {
106 - exposeArgs.push(`--relays ${formatToken(relayUrlVal, os)}`);
107 - }
108 - if (!defaultRelays) {
109 - exposeArgs.push("--default-relays=false");
110 - }
111 - if (normalizedThumbnailURL) {
112 - exposeArgs.push(`--thumbnail ${formatToken(normalizedThumbnailURL, os)}`);
113 - }
114 -
115 - if (os === "windows") {
116 - return [
117 - `$ProgressPreference = 'SilentlyContinue'`,
118 - `irm ${formatToken(installPowerShellURL, os)} | iex`,
119 - `portal expose ${[...exposeArgs, formatToken(targetVal, os)].join(" ")}`,
120 - ].join("\n");
121 - }
122 -
123 - const curlFlags = localhostRelay ? "-ksSL" : "-sSL";
124 - return [
125 - `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`,
126 - `portal expose ${[...exposeArgs, formatToken(targetVal, os)].join(" ")}`,
127 - ].join("\n");
128 - }, [
129 - currentOrigin,
130 - defaultRelays,
131 - name,
132 - normalizedThumbnailURL,
133 - os,
134 - relayUrls,
135 - target,
136 - ]);
137 -
138 - const handleCopy = async () => {
139 - try {
140 - await navigator.clipboard.writeText(command);
141 - setCopied(true);
142 - setTimeout(() => setCopied(false), 2000);
143 - } catch (err) {
144 - console.error("Failed to copy:", err);
145 - }
146 - };
147 -
148 - const handleOpenChange = (nextOpen: boolean) => {
149 - setOpen(nextOpen);
150 - if (!nextOpen) {
151 - return;
152 - }
153 - setTarget(defaultHost);
154 - setName("");
155 - setRelayUrls([currentOrigin]);
156 - setDefaultRelays(true);
157 - setUrlInput("");
158 - setCopied(false);
159 - setOs("unix");
160 - setThumbnailURL("");
161 - };
162 -
18 return (
164 - <Dialog open={open} onOpenChange={handleOpenChange}>
19 + <Dialog>
20 <DialogTrigger asChild>
21 {trigger || (
22 <Button className="cursor-pointer">
168 - <span className="truncate">Add Your Server</span>
23 + <span className="truncate">Get Started</span>
24 </Button>
25 )}
26 </DialogTrigger>
172 - <DialogContent className="sm:max-w-[520px] rounded-sm max-h-[85vh] overflow-y-auto">
173 - <DialogHeader>
174 - <DialogTitle className="flex items-center gap-2">
175 - <Terminal className="w-5 h-5" />
27 + <DialogContent className="sm:max-w-[560px] max-h-[85vh] overflow-y-auto rounded-[1.5rem] border border-border bg-card p-0">
28 + <DialogHeader className="border-b border-border px-5 py-4 text-left">
29 + <DialogTitle className="flex items-center gap-2 text-xl font-bold">
30 + <Terminal className="h-5 w-5" />
31 Tunnel Setup Command
32 </DialogTitle>
33 </DialogHeader>
34
180 - <div className="space-y-4 py-4 [&>div]:flex [&>div]:flex-col">
181 - {/* Host Input */}
182 - <div className="space-y-2">
183 - <label
184 - htmlFor="target"
185 - className="text-sm font-medium text-foreground"
186 - >
187 - Host
188 - </label>
189 - <Input
190 - id="target"
191 - type="text"
192 - value={target}
193 - onChange={(e) => setTarget(e.target.value)}
194 - placeholder={defaultHost}
195 - />
196 - </div>
197 -
198 - {/* Name Input */}
199 - <div className="space-y-2">
200 - <label
201 - htmlFor="name"
202 - className="text-sm font-medium text-foreground"
203 - >
204 - Service Name
205 - </label>
206 - <Input
207 - id="name"
208 - type="text"
209 - value={name}
210 - onChange={(e) => setName(e.target.value)}
211 - placeholder="auto-generated when empty"
212 - />
213 - </div>
214 -
215 - {/* Relay URLs Input */}
216 - <div className="space-y-2">
217 - <div className="flex items-center justify-between gap-3">
218 - <label className="text-sm font-medium text-foreground">
219 - Relay URLs
220 - </label>
221 - <label className="flex items-center gap-2 text-xs text-muted-foreground">
222 - <input
223 - type="checkbox"
224 - checked={defaultRelays}
225 - onChange={(e) => setDefaultRelays(e.target.checked)}
226 - className="h-4 w-4"
227 - />
228 - <span>Include default registry</span>
229 - </label>
230 - </div>
231 - <div className="flex flex-wrap items-center gap-2 rounded-md border border-input bg-transparent p-2 min-h-10">
232 - {relayUrls.map((url) => (
233 - <span
234 - key={url}
235 - className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
236 - >
237 - {url}
238 - <button
239 - type="button"
240 - onClick={() => removeRelayUrl(url)}
241 - className="ml-1 rounded-sm hover:bg-destructive/20 p-0.5"
242 - aria-label={`Remove ${url}`}
243 - >
244 - <X className="h-3 w-3" />
245 - </button>
246 - </span>
247 - ))}
248 - <input
249 - type="text"
250 - value={urlInput}
251 - onChange={(e) => setUrlInput(e.target.value)}
252 - onKeyDown={handleUrlKeyDown}
253 - placeholder="Add relay URL..."
254 - className="min-w-[140px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
255 - />
256 - </div>
257 - </div>
258 -
259 - <div className="space-y-2">
260 - <label
261 - htmlFor="thumbnail-url"
262 - className="text-sm font-medium text-foreground"
263 - >
264 - Thumbnail URL
265 - </label>
266 - <Input
267 - id="thumbnail-url"
268 - type="url"
269 - value={thumbnailURL}
270 - onChange={(e) => setThumbnailURL(e.target.value)}
271 - placeholder="https://cdn.example.com/thumb.png"
272 - />
273 - {normalizedThumbnailURL && (
274 - <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-input bg-background">
275 - <img
276 - src={normalizedThumbnailURL}
277 - alt="Thumbnail preview"
278 - className="h-full w-full object-cover"
279 - />
280 - </div>
281 - )}
282 - {thumbnailError && (
283 - <p className="text-xs text-destructive">{thumbnailError}</p>
284 - )}
285 - </div>
286 -
287 - {/* OS Selection */}
288 - <div className="space-y-2">
289 - <label className="text-sm font-medium text-foreground">
290 - Operating System
291 - </label>
292 - <div className="flex p-1 bg-border rounded-md">
293 - <button
294 - onClick={() => setOs("unix")}
295 - className={cn(
296 - "flex-1 px-3 py-1.5 text-sm font-medium rounded-sm transition-all",
297 - os === "unix"
298 - ? "bg-background text-foreground shadow-sm"
299 - : "text-muted-foreground hover:text-foreground"
300 - )}
301 - >
302 - Linux / macOS
303 - </button>
304 - <button
305 - onClick={() => setOs("windows")}
306 - className={cn(
307 - "flex-1 px-3 py-1.5 text-sm font-medium rounded-sm transition-all",
308 - os === "windows"
309 - ? "bg-background text-foreground shadow-sm"
310 - : "text-muted-foreground hover:text-foreground"
311 - )}
312 - >
313 - Windows (PowerShell)
314 - </button>
315 - </div>
316 - </div>
317 -
318 - {/* Generated Command */}
319 - <div className="space-y-2">
320 - <label className="text-sm font-medium text-foreground">
321 - Generated Command
322 - </label>
323 - <div className="relative">
324 - <pre className="p-3 pr-12 rounded-md bg-border text-sm text-foreground overflow-x-auto whitespace-pre-wrap break-all font-mono">
325 - {command}
326 - </pre>
327 - <button
328 - onClick={handleCopy}
329 - className="cursor-pointer absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-md hover:bg-background/50 transition-colors"
330 - aria-label="Copy command"
331 - >
332 - {copied ? (
333 - <Check className="w-4 h-4 text-green-500" />
334 - ) : (
335 - <Copy className="w-4 h-4 text-text-muted" />
336 - )}
337 - </button>
338 - </div>
339 - <p className="text-xs text-muted-foreground">
340 - After installation, run <code>portal list</code> to inspect the
341 - configured public relays.
342 - </p>
343 - </div>
35 + <div className="px-5 pb-5 pt-4">
36 + <TunnelCommandForm />
37 </div>
38 </DialogContent>
39 </Dialog>
40 );
41 }
349 -
350 -function isLocalRelayOrigin(origin: string): boolean {
351 - try {
352 - const parsed = new URL(origin);
353 - const host = parsed.hostname.trim().toLowerCase();
354 - return (
355 - host === "localhost" ||
356 - host === "127.0.0.1" ||
357 - host === "::1" ||
358 - host.endsWith(".localhost")
359 - );
360 - } catch {
361 - return false;
362 - }
363 -}
364 -
365 -function quoteShellValue(value: string): string {
366 - return "'" + value.replace(/'/g, `'"'"'`) + "'";
367 -}
368 -
369 -function quotePowerShellValue(value: string): string {
370 - return `'${value.replace(/'/g, "''")}'`;
371 -}
372 -
373 -function formatToken(value: string, os: "unix" | "windows"): string {
374 - if (/^[A-Za-z0-9:/.=_-]+$/.test(value)) {
375 - return value;
376 - }
377 - return os === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
378 -}
379 -
380 -function normalizeAbsoluteHTTPURL(raw: string): string {
381 - const trimmed = raw.trim();
382 - if (trimmed === "") {
383 - return "";
384 - }
385 -
386 - try {
387 - const parsed = new URL(trimmed);
388 - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
389 - return "";
390 - }
391 - return parsed.toString();
392 - } catch {
393 - return "";
394 - }
395 -}
frontend/src/components/ui/scroll-area.tsx deleted
-46
@@ -1,46 +0,0 @@
1 -import * as React from "react"
2 -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
3 -
4 -import { cn } from "@/lib/utils"
5 -
6 -const ScrollArea = React.forwardRef<
7 - React.ElementRef<typeof ScrollAreaPrimitive.Root>,
8 - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
9 ->(({ className, children, ...props }, ref) => (
10 - <ScrollAreaPrimitive.Root
11 - ref={ref}
12 - className={cn("relative overflow-hidden", className)}
13 - {...props}
14 - >
15 - <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
16 - {children}
17 - </ScrollAreaPrimitive.Viewport>
18 - <ScrollBar />
19 - <ScrollAreaPrimitive.Corner />
20 - </ScrollAreaPrimitive.Root>
21 -))
22 -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
23 -
24 -const ScrollBar = React.forwardRef<
25 - React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
26 - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
27 ->(({ className, orientation = "vertical", ...props }, ref) => (
28 - <ScrollAreaPrimitive.ScrollAreaScrollbar
29 - ref={ref}
30 - orientation={orientation}
31 - className={cn(
32 - "flex touch-none select-none transition-colors",
33 - orientation === "vertical" &&
34 - "h-full w-2.5 border-l border-l-transparent p-[1px]",
35 - orientation === "horizontal" &&
36 - "h-2.5 flex-col border-t border-t-transparent p-[1px]",
37 - className
38 - )}
39 - {...props}
40 - >
41 - <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
42 - </ScrollAreaPrimitive.ScrollAreaScrollbar>
43 -))
44 -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
45 -
46 -export { ScrollArea, ScrollBar }
frontend/src/hooks/useAdmin.ts
-2
@@ -345,7 +345,6 @@ export function useAdmin() {
345 const handleBulkBan = (peerIds: string[]) => handleBulkAction(peerIds, "ban");
346
347 return {
348 - serverData,
348 servers,
349 ...listState,
350 banFilter,
@@ -362,6 +361,5 @@ export function useAdmin() {
361 handleBulkApprove,
362 handleBulkDeny,
363 handleBulkBan,
365 - refresh: fetchData,
364 };
365 }
frontend/src/hooks/useSSRData.ts
+9 -19
@@ -33,27 +33,17 @@ export function useSSRData(): ServerData[] {
33 const [data, setData] = useState<ServerData[]>([]);
34
35 useEffect(() => {
36 - // Try to read SSR data from the script tag
36 const ssrScript = document.getElementById("__SSR_DATA__");
38 - console.log("[SSR] Script tag found:", !!ssrScript);
37 + if (!ssrScript?.textContent) {
38 + return;
39 + }
40
40 - if (ssrScript && ssrScript.textContent) {
41 - console.log(
42 - "[SSR] Script content:",
43 - ssrScript.textContent.substring(0, 200)
44 - );
45 - try {
46 - const parsed = JSON.parse(ssrScript.textContent);
47 - console.log("[SSR] Parsed data:", parsed);
48 - console.log("[SSR] Is array:", Array.isArray(parsed));
49 - console.log("[SSR] Length:", Array.isArray(parsed) ? parsed.length : 0);
50 - setData(Array.isArray(parsed) ? parsed : []);
51 - } catch (err) {
52 - console.error("[SSR] Failed to parse SSR data:", err);
53 - setData([]);
54 - }
55 - } else {
56 - console.log("[SSR] No script tag or content found");
41 + try {
42 + const parsed = JSON.parse(ssrScript.textContent);
43 + setData(Array.isArray(parsed) ? parsed : []);
44 + } catch (error) {
45 + console.error("Failed to parse SSR data", error);
46 + setData([]);
47 }
48 }, []);
49
frontend/src/hooks/useServerList.ts
+5 -29
@@ -2,11 +2,8 @@ import { useMemo } from "react";
2 import { useSSRData } from "@/hooks/useSSRData";
3 import type { ServerData } from "@/hooks/useSSRData";
4 import { useList, type BaseServer } from "@/hooks/useList";
5 -import { generateRandomServers } from "@/lib/testUtils";
5 import { parseLeaseMetadata } from "@/lib/metadata";
6
8 -const useDebug = false;
9 -
7 export type ClientServer = BaseServer;
8
9 function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
@@ -31,36 +28,15 @@ function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
28 }
29
30 export function useServerList() {
34 - // Get SSR data
31 const ssrData = useSSRData();
32
37 - // Convert SSR data to servers
38 - const servers: ClientServer[] = useMemo(() => {
39 - console.log("[App] SSR data length:", ssrData.length);
40 -
41 - if (useDebug) {
42 - return generateRandomServers(100);
43 - }
44 - if (ssrData.length > 0) {
45 - console.log("[App] Using SSR data");
46 - const converted = convertSSRDataToServers(ssrData);
47 - console.log("[App] Converted servers:", converted);
48 - return converted;
49 - }
50 - console.log("[App] Using sample servers");
51 - return [];
52 - }, [ssrData]);
33 + const servers: ClientServer[] = useMemo(
34 + () => convertSSRDataToServers(ssrData),
35 + [ssrData]
36 + );
37
54 - // Use common list logic
55 - const listState = useList({
38 + return useList({
39 servers,
40 storageKey: "serverFavorites",
41 });
59 -
60 - return {
61 - // Raw servers (before filtering)
62 - servers,
63 - // All list state and handlers from useList
64 - ...listState,
65 - };
42 }
frontend/src/index.css
+46 -153
@@ -1,22 +1,14 @@
1 -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Noto+Sans:wght@400;500;600;700&display=swap');
1 +@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=Public+Sans:wght@400;500;600;700&display=swap");
2 @import "tailwindcss";
3
4 -@custom-variant dark (&:where(.dark, .dark *));
5 -
4 @theme {
7 - /* Font families */
8 - --font-family-display: "Space Grotesk", sans-serif;
9 - --font-family-sans: "Noto Sans", sans-serif;
10 -
11 - /* Border radius */
12 - --radius: 0.5rem;
13 - --radius-lg: 0.75rem;
14 - --radius-xl: 1rem;
5 + --font-family-display: "Manrope", sans-serif;
6 + --font-family-sans: "Public Sans", sans-serif;
7
16 - /* Glass effect tokens */
17 - --glass-blur: 12px;
8 + --radius: 0.875rem;
9 + --radius-lg: 1.25rem;
10 + --radius-xl: 1.75rem;
11
19 - /* Color mappings */
12 --color-background: var(--background);
13 --color-foreground: var(--foreground);
14 --color-card: var(--card);
@@ -38,164 +30,65 @@
30 --color-ring: var(--ring);
31 --color-green-status: var(--green-status);
32 --color-text-muted: var(--text-muted);
41 -
42 - /* Cyberpunk accent colors */
43 - --color-accent-orange: var(--accent-orange);
44 - --color-accent-magenta: var(--accent-magenta);
45 - --color-neon-cyan: var(--neon-cyan);
33 + --color-brand-coral: var(--brand-coral);
34 }
35
36 @layer base {
37 :root {
50 - /* Light Theme - Clean slate-based design */
51 - --background: oklch(97% 0.005 250); /* #F5F7FA */
52 - --foreground: oklch(25% 0.02 250); /* Slate 800 #1e293b */
53 - --card: oklch(100% 0 0); /* White */
54 - --card-foreground: oklch(25% 0.02 250);
38 + --background: oklch(99.1% 0.001 0);
39 + --foreground: oklch(22% 0.01 260);
40 + --card: oklch(100% 0 0);
41 + --card-foreground: oklch(22% 0.01 260);
42 --popover: oklch(100% 0 0);
56 - --popover-foreground: oklch(25% 0.02 250);
57 - --primary: oklch(75% 0.14 195); /* #00dbdb cyan - slightly darker for light bg */
58 - --primary-foreground: oklch(100% 0 0);
59 - --secondary: oklch(92% 0.01 250); /* Slate 200 */
60 - --secondary-foreground: oklch(45% 0.02 250); /* Slate 600 */
61 - --muted: oklch(95% 0.005 250); /* Slate 100 */
62 - --muted-foreground: oklch(55% 0.03 250); /* Slate 500 #64748b */
63 - --accent: oklch(92% 0.01 250);
64 - --accent-foreground: oklch(25% 0.02 250);
65 - --destructive: oklch(55% 0.20 25);
66 - --destructive-foreground: oklch(100% 0 0);
67 - --border: oklch(90% 0.01 250); /* Slate 200 border */
68 - --input: oklch(95% 0.005 250); /* Slate 100 */
69 - --ring: oklch(75% 0.14 195);
70 -
71 - /* Custom light theme colors */
72 - --green-status: oklch(55% 0.16 150); /* Green 600 */
73 - --text-muted: oklch(55% 0.03 250); /* Slate 500 */
74 - --accent-orange: oklch(60% 0.20 35); /* Orange */
75 - --accent-magenta: oklch(50% 0.22 350); /* Magenta */
76 - --neon-cyan: oklch(75% 0.14 195);
77 -
78 - /* Glass effects - light mode */
79 - --glass-bg: oklch(100% 0 0 / 0.7);
80 - --glass-bg-hover: oklch(100% 0 0 / 0.9);
81 - --glass-border: oklch(100% 0 0 / 0.5);
82 - --glass-border-hover: oklch(75% 0.14 195 / 0.3);
83 - --shadow-neon: 0 0 10px oklch(75% 0.14 195 / 0.4), 0 0 20px oklch(75% 0.14 195 / 0.2);
84 - --shadow-glass: 0 4px 30px oklch(0% 0 0 / 0.05);
85 - }
86 -
87 - .dark {
88 - /* Dark Theme - Cyberpunk Neon */
89 - --background: oklch(15% 0 0); /* #121212 deep charcoal */
90 - --foreground: oklch(98% 0.01 0);
91 - --card: oklch(20% 0 0); /* #1E1E1E surface dark */
92 - --card-foreground: oklch(98% 0.01 0);
93 - --popover: oklch(20% 0 0);
94 - --popover-foreground: oklch(98% 0.01 0);
95 - --primary: oklch(80% 0.14 195); /* #00dbdb cyan */
96 - --primary-foreground: oklch(10% 0 0);
97 - --secondary: oklch(25% 0.02 195);
98 - --secondary-foreground: oklch(98% 0.01 0);
99 - --muted: oklch(25% 0.01 0);
100 - --muted-foreground: oklch(65% 0.02 0);
101 - --accent: oklch(25% 0.03 195);
102 - --accent-foreground: oklch(98% 0.01 0);
103 - --destructive: oklch(62% 0.22 25);
104 - --destructive-foreground: oklch(98% 0 0);
105 - --border: oklch(100% 0 0 / 0.1); /* white/10 for dark mode */
106 - --input: oklch(22% 0.01 0);
107 - --ring: oklch(80% 0.14 195);
108 -
109 - /* Custom dark theme colors - Cyberpunk */
110 - --green-status: oklch(75% 0.16 150);
111 - --text-muted: oklch(65% 0.02 0);
112 - --accent-orange: oklch(65% 0.20 35); /* #FF5733 */
113 - --accent-magenta: oklch(55% 0.25 350); /* #E6007A */
114 - --neon-cyan: oklch(80% 0.14 195);
115 -
116 - /* Glass effects - dark mode */
117 - --glass-bg: oklch(20% 0 0 / 0.4);
118 - --glass-bg-hover: oklch(20% 0 0 / 0.6);
119 - --glass-border: oklch(100% 0 0 / 0.08);
120 - --glass-border-hover: oklch(80% 0.14 195 / 0.3);
121 - --shadow-neon: 0 0 10px oklch(80% 0.14 195 / 0.5), 0 0 20px oklch(80% 0.14 195 / 0.3);
122 - --shadow-glass: 0 4px 30px oklch(0% 0 0 / 0.1);
43 + --popover-foreground: oklch(22% 0.01 260);
44 + --primary: oklch(21% 0.008 260);
45 + --primary-foreground: oklch(99.4% 0.001 0);
46 + --secondary: oklch(96.7% 0.002 0);
47 + --secondary-foreground: oklch(33% 0.012 260);
48 + --muted: oklch(97.4% 0.001 0);
49 + --muted-foreground: oklch(50% 0.01 260);
50 + --accent: oklch(95.8% 0.002 0);
51 + --accent-foreground: oklch(22% 0.01 260);
52 + --destructive: oklch(60% 0.21 26);
53 + --destructive-foreground: oklch(99.4% 0.001 0);
54 + --border: oklch(90.8% 0.003 0);
55 + --input: oklch(100% 0 0);
56 + --ring: oklch(68% 0.19 149);
57 +
58 + --green-status: oklch(68% 0.19 149);
59 + --text-muted: oklch(49% 0.012 260);
60 + --brand-coral: oklch(68% 0.19 149);
61 }
62
63 * {
64 border-color: var(--border);
65 }
66
129 - body {
67 + html {
68 background-color: var(--background);
131 - color: var(--foreground);
132 - font-family: var(--font-family-sans);
133 - }
134 -
135 - h1, h2, h3, h4, h5, h6 {
136 - font-family: var(--font-family-display);
69 }
70
139 - /* Default to dark mode */
71 body {
141 - color-scheme: dark;
142 - }
143 -
144 - body.dark {
145 - color-scheme: dark;
146 - }
147 -
148 - body.light {
72 + min-height: 100vh;
73 + background:
74 + radial-gradient(circle at top center, oklch(91% 0.055 149 / 0.16), transparent 44%),
75 + linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(99.2% 0.001 0) 54%, oklch(98.5% 0.002 0) 100%);
76 + color: var(--foreground);
77 color-scheme: light;
78 + font-family: var(--font-family-sans);
79 }
80
152 - /* Hide scrollbar for horizontal scroll */
153 - .scrollbar-hide {
154 - -ms-overflow-style: none;
155 - scrollbar-width: none;
156 - }
157 -
158 - .scrollbar-hide::-webkit-scrollbar {
159 - display: none;
160 - }
161 -}
162 -
163 -@layer components {
164 - /* Glass card effect */
165 - .glass-card {
166 - background: var(--glass-bg);
167 - backdrop-filter: blur(var(--glass-blur));
168 - -webkit-backdrop-filter: blur(var(--glass-blur));
169 - border: 1px solid var(--glass-border);
170 - box-shadow: var(--shadow-glass);
171 - transition: all 0.3s ease;
172 - }
173 -
174 - .glass-card:hover {
175 - border-color: var(--glass-border-hover);
176 - background: var(--glass-bg-hover);
177 - }
178 -
179 - /* Neon glow effects */
180 - .neon-glow {
181 - box-shadow: var(--shadow-neon);
182 - }
183 -
184 - .neon-text {
185 - text-shadow: 0 0 10px oklch(80% 0.14 195 / 0.7);
186 - }
187 -
188 - .dark .neon-text {
189 - text-shadow: 0 0 10px oklch(80% 0.14 195 / 0.7);
190 - }
191 -
192 - :root .neon-text {
193 - text-shadow: 0 0 10px oklch(45% 0.14 195 / 0.5);
81 + h1,
82 + h2,
83 + h3,
84 + h4,
85 + h5,
86 + h6 {
87 + font-family: var(--font-family-display);
88 + letter-spacing: -0.03em;
89 }
90
196 - /* Neon border on focus/hover */
197 - .neon-border {
198 - border-color: var(--neon-cyan);
199 - box-shadow: 0 0 5px var(--neon-cyan);
91 + a {
92 + color: inherit;
93 }
94 }
frontend/src/lib/testUtils.ts deleted
-136
@@ -1,136 +0,0 @@
1 -import type { ClientServer } from "@/hooks/useServerList";
2 -
3 -// Generate random sample servers
4 -export const generateRandomServers = (
5 - count: number,
6 - startId: number = 1
7 -): ClientServer[] => {
8 - const serverNames = [
9 - "Atlas Network",
10 - "Phoenix Hub",
11 - "Nebula Station",
12 - "Quantum Gateway",
13 - "Crystal Core",
14 - "Thunder Relay",
15 - "Skyline Portal",
16 - "Velocity Node",
17 - "Horizon Link",
18 - "Apex Server",
19 - "Nova Cluster",
20 - "Titan Network",
21 - "Eclipse Gateway",
22 - "Zenith Hub",
23 - "Aurora Station",
24 - "Pulse Center",
25 - "Matrix Core",
26 - "Frontier Node",
27 - "Summit Link",
28 - "Vortex Portal",
29 - "Cipher Network",
30 - "Nexus Hub",
31 - "Prism Station",
32 - "Radiant Gateway",
33 - "Omega Core",
34 - "Spectrum Relay",
35 - "Cascade Portal",
36 - "Infinity Node",
37 - ];
38 -
39 - const descriptions = [
40 - "High-performance gaming server with low latency",
41 - "Community hub for developers and creators",
42 - "Private cloud infrastructure for enterprises",
43 - "Media streaming and content delivery platform",
44 - "Real-time collaboration workspace",
45 - "Secure data processing and analytics",
46 - "Educational platform for online learning",
47 - "Social network for creative professionals",
48 - "E-commerce platform with global reach",
49 - "Healthcare data management system",
50 - "Financial trading and analytics hub",
51 - "IoT device management platform",
52 - "AI/ML model training infrastructure",
53 - "Video conferencing and webinar service",
54 - "Open source project hosting",
55 - "Blockchain node and validator",
56 - ];
57 -
58 - const tagOptions = [
59 - "Gaming",
60 - "Development",
61 - "Cloud",
62 - "Media",
63 - "Collaboration",
64 - "Analytics",
65 - "Education",
66 - "Social",
67 - "Commerce",
68 - "Healthcare",
69 - "Finance",
70 - "IoT",
71 - "AI/ML",
72 - "Video",
73 - "Open Source",
74 - "Blockchain",
75 - "Security",
76 - "High-Performance",
77 - "Low-Latency",
78 - "Global",
79 - ];
80 -
81 - const owners = [
82 - "TechCorp",
83 - "DevTeam",
84 - "CloudNet",
85 - "MediaHub",
86 - "DataLabs",
87 - "SecureOps",
88 - "GlobalTech",
89 - "InnovateCo",
90 - "NextGen",
91 - "AlphaSystems",
92 - "BetaWorks",
93 - "GammaNet",
94 - "DeltaCloud",
95 - "EpsilonLabs",
96 - "ZetaTech",
97 - ];
98 -
99 - const thumbnails = [
100 - "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400",
101 - "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=400",
102 - "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=400",
103 - "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=400",
104 - "https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=400",
105 - "https://images.unsplash.com/photo-1518770660439-4636190af475?w=400",
106 - "https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?w=400",
107 - "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=400",
108 - ];
109 -
110 - return Array.from({ length: count }, (_, i) => {
111 - const id = startId + i;
112 - const online = Math.random() > 0.3; // 70% online
113 - const numTags = Math.floor(Math.random() * 4) + 1; // 1-4 tags
114 - const selectedTags = Array.from(
115 - { length: numTags },
116 - () => tagOptions[Math.floor(Math.random() * tagOptions.length)]
117 - ).filter((tag, index, self) => self.indexOf(tag) === index); // Remove duplicates
118 -
119 - return {
120 - id,
121 - name:
122 - serverNames[Math.floor(Math.random() * serverNames.length)] + ` #${id}`,
123 - description:
124 - descriptions[Math.floor(Math.random() * descriptions.length)],
125 - tags: selectedTags,
126 - thumbnail: thumbnails[Math.floor(Math.random() * thumbnails.length)],
127 - owner: owners[Math.floor(Math.random() * owners.length)],
128 - online,
129 - dns: `server-${id}.example.com`,
130 - link: `https://server-${id}.example.com`,
131 - lastUpdated: new Date(
132 - Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000
133 - ).toISOString(),
134 - };
135 - });
136 -};
frontend/src/lib/tunnelCommand.ts new
+144
@@ -0,0 +1,144 @@
1 +import { API_PATHS } from "@/lib/apiPaths";
2 +import { resolveExposeName } from "../../../utils/exposeName";
3 +
4 +export type TunnelCommandOS = "unix" | "windows";
5 +
6 +export interface TunnelCommandOptions {
7 + currentOrigin: string;
8 + target: string;
9 + name: string;
10 + nameSeed: string;
11 + relayUrls: string[];
12 + defaultRelays: boolean;
13 + thumbnailURL: string;
14 + os: TunnelCommandOS;
15 +}
16 +
17 +const DEFAULT_PREVIEW_HOST = "portal.run";
18 +
19 +export function buildTunnelCommand({
20 + currentOrigin,
21 + defaultRelays,
22 + name,
23 + nameSeed,
24 + os,
25 + relayUrls,
26 + target,
27 + thumbnailURL,
28 +}: TunnelCommandOptions): string {
29 + const targetValue = target.trim() === "" ? "3000" : target.trim();
30 + const nameValue = resolveExposeName(name, targetValue, nameSeed);
31 + const relayURLValue =
32 + relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
33 + const installScriptURL = new URL(
34 + API_PATHS.install.shell,
35 + currentOrigin
36 + ).toString();
37 + const installPowerShellURL = new URL(
38 + API_PATHS.install.powershell,
39 + currentOrigin
40 + ).toString();
41 +
42 + const exposeArgs: string[] = [];
43 +
44 + exposeArgs.push(`--name ${formatToken(nameValue, os)}`);
45 + if (relayUrls.length > 0) {
46 + exposeArgs.push(`--relays ${formatToken(relayURLValue, os)}`);
47 + }
48 + if (!defaultRelays) {
49 + exposeArgs.push("--default-relays=false");
50 + }
51 +
52 + const normalizedThumbnailURL = normalizeAbsoluteHTTPURL(thumbnailURL);
53 + if (normalizedThumbnailURL !== "") {
54 + exposeArgs.push(`--thumbnail ${formatToken(normalizedThumbnailURL, os)}`);
55 + }
56 +
57 + if (os === "windows") {
58 + return [
59 + `$ProgressPreference = 'SilentlyContinue'`,
60 + `irm ${formatToken(installPowerShellURL, os)} | iex`,
61 + `portal expose ${[...exposeArgs, formatToken(targetValue, os)].join(" ")}`,
62 + ].join("\n");
63 + }
64 +
65 + const curlFlags = isLocalRelayOrigin(currentOrigin) ? "-ksSL" : "-sSL";
66 + return [
67 + `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`,
68 + `portal expose ${[...exposeArgs, formatToken(targetValue, os)].join(" ")}`,
69 + ].join("\n");
70 +}
71 +
72 +export function buildTunnelPreviewURL(
73 + origin: string,
74 + name: string,
75 + target: string,
76 + nameSeed: string
77 +): string {
78 + const baseHost = getTunnelBaseHost(origin);
79 + const subdomain = resolveExposeName(name, target, nameSeed);
80 + return `https://${subdomain}.${baseHost}`;
81 +}
82 +
83 +export function normalizeAbsoluteHTTPURL(raw: string): string {
84 + const trimmed = raw.trim();
85 + if (trimmed === "") {
86 + return "";
87 + }
88 +
89 + try {
90 + const parsed = new URL(trimmed);
91 + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
92 + return "";
93 + }
94 + return parsed.toString();
95 + } catch {
96 + return "";
97 + }
98 +}
99 +
100 +function getTunnelBaseHost(origin: string): string {
101 + try {
102 + const parsed = new URL(origin);
103 + const hostname = parsed.hostname.trim().toLowerCase();
104 + if (isLocalRelayHostname(hostname)) {
105 + return DEFAULT_PREVIEW_HOST;
106 + }
107 + return hostname;
108 + } catch {
109 + return DEFAULT_PREVIEW_HOST;
110 + }
111 +}
112 +
113 +function isLocalRelayOrigin(origin: string): boolean {
114 + try {
115 + const parsed = new URL(origin);
116 + return isLocalRelayHostname(parsed.hostname.trim().toLowerCase());
117 + } catch {
118 + return false;
119 + }
120 +}
121 +
122 +function isLocalRelayHostname(hostname: string): boolean {
123 + return (
124 + hostname === "localhost" ||
125 + hostname === "127.0.0.1" ||
126 + hostname === "::1" ||
127 + hostname.endsWith(".localhost")
128 + );
129 +}
130 +
131 +function quoteShellValue(value: string): string {
132 + return "'" + value.replace(/'/g, `'"'"'`) + "'";
133 +}
134 +
135 +function quotePowerShellValue(value: string): string {
136 + return `'${value.replace(/'/g, "''")}'`;
137 +}
138 +
139 +function formatToken(value: string, os: TunnelCommandOS): string {
140 + if (/^[A-Za-z0-9:/.=_-]+$/.test(value)) {
141 + return value;
142 + }
143 + return os === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
144 +}
frontend/tsconfig.json
+1 -1
@@ -23,6 +23,6 @@
23 "noUnusedParameters": true,
24 "noFallthroughCasesInSwitch": true
25 },
26 - "include": ["src"],
26 + "include": ["src", "../utils/exposeName.ts"],
27 "references": [{ "path": "./tsconfig.node.json" }]
28 }
utils/exposeName.ts new
+151
@@ -0,0 +1,151 @@
1 +const DEFAULT_TARGET_PORT = "3000";
2 +const DEFAULT_TARGET_HOST = "127.0.0.1";
3 +
4 +const exposeNameOpeners = [
5 + "arcade", "bouncy", "bravo", "bubble", "candy", "cosmic", "dapper", "electric",
6 + "fancy", "fizzy", "flashy", "fuzzy", "gentle", "glitter", "golden", "happy",
7 + "hyper", "jazzy", "jolly", "lively", "lucky", "magic", "mellow", "minty",
8 + "misty", "moonlit", "mystic", "neon", "nova", "peppy", "pixel", "playful",
9 + "poppy", "rapid", "rocket", "rowdy", "snappy", "snazzy", "sparkly", "spicy",
10 + "sprightly", "starry", "sunny", "swift", "tangy", "tidy", "toasty", "turbo",
11 + "velvet", "vivid", "wavy", "whimsy", "wild", "wonky", "zany", "zesty",
12 +] as const;
13 +
14 +const exposeNameCenters = [
15 + "alpaca", "badger", "banjo", "beacon", "biscuit", "capybara", "comet", "cricket",
16 + "dragon", "falcon", "feather", "fjord", "fox", "gadget", "gecko", "gizmo",
17 + "harbor", "heron", "iguana", "jelly", "koala", "lemur", "mango", "narwhal",
18 + "nebula", "noodle", "octopus", "otter", "panda", "pepper", "phoenix", "pickle",
19 + "puffin", "quokka", "radar", "ranger", "rocket", "scooter", "seahorse", "skylark",
20 + "sprocket", "starling", "sunbeam", "taco", "thimble", "tiger", "toucan", "triton",
21 + "walrus", "widget", "willow", "wombat", "yeti", "zeppelin", "zigzag", "zinnia",
22 +] as const;
23 +
24 +const exposeNameClosers = [
25 + "arcade", "beacon", "boogie", "bounce", "burst", "cascade", "chorus", "dash",
26 + "disco", "drift", "echo", "fiesta", "flare", "flash", "flight", "flip",
27 + "glow", "groove", "jam", "jive", "launch", "loop", "march", "orbit",
28 + "parade", "party", "pulse", "quest", "rally", "riot", "ripple", "rodeo",
29 + "roll", "rush", "serenade", "shuffle", "signal", "sketch", "spark", "sprint",
30 + "starlight", "stride", "sway", "swoop", "twirl", "uplift", "vibe", "voyage",
31 + "whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone",
32 +] as const;
33 +
34 +export function resolveExposeName(
35 + inputName: string,
36 + target: string,
37 + clientSeed: string
38 +): string {
39 + const normalized = normalizeExposeName(inputName);
40 + if (normalized !== "") {
41 + return normalized;
42 + }
43 + return buildDefaultExposeName(target, clientSeed);
44 +}
45 +
46 +export function buildDefaultExposeName(
47 + target: string,
48 + clientSeed: string
49 +): string {
50 + const seed = normalizeSeed(clientSeed);
51 + const normalizedTarget = normalizeExposeTarget(target);
52 + const [first, second, third] = pickNameIndexes(`${seed}|${normalizedTarget}`);
53 + const label = [
54 + exposeNameOpeners[first],
55 + exposeNameCenters[second],
56 + exposeNameClosers[third],
57 + ].join("-");
58 +
59 + return normalizeExposeName(label);
60 +}
61 +
62 +export function normalizeExposeName(value: string): string {
63 + return value
64 + .trim()
65 + .toLowerCase()
66 + .replace(/[^a-z0-9-]+/g, "-")
67 + .replace(/^-+|-+$/g, "")
68 + .replace(/-{2,}/g, "-")
69 + .slice(0, 63);
70 +}
71 +
72 +export function normalizeExposeTarget(raw: string): string {
73 + const trimmed = raw.trim();
74 + const candidate = trimmed === "" ? DEFAULT_TARGET_PORT : trimmed;
75 +
76 + if (/^\d+$/.test(candidate)) {
77 + return `${DEFAULT_TARGET_HOST}:${candidate}`;
78 + }
79 +
80 + if (candidate.includes("://")) {
81 + try {
82 + const parsed = new URL(candidate);
83 + if (
84 + (parsed.protocol === "http:" || parsed.protocol === "https:") &&
85 + parsed.host !== "" &&
86 + (parsed.pathname === "" || parsed.pathname === "/") &&
87 + parsed.search === "" &&
88 + parsed.hash === ""
89 + ) {
90 + return parsed.host;
91 + }
92 + } catch {
93 + return candidate;
94 + }
95 + }
96 +
97 + try {
98 + const parsed = new URL(`tcp://${candidate}`);
99 + if (parsed.hostname === "") {
100 + return candidate;
101 + }
102 + return formatHostPort(parsed.hostname, parsed.port || "80");
103 + } catch {
104 + return candidate;
105 + }
106 +}
107 +
108 +function normalizeSeed(clientSeed: string): string {
109 + const trimmed = clientSeed.trim();
110 + if (trimmed === "") {
111 + return "portal";
112 + }
113 + if (trimmed.startsWith("cli_")) {
114 + return trimmed.slice(4) || "portal";
115 + }
116 + return trimmed;
117 +}
118 +
119 +function pickNameIndexes(input: string): [number, number, number] {
120 + const [first, second, third] = hashBytes(input);
121 + return [
122 + first % exposeNameOpeners.length,
123 + second % exposeNameCenters.length,
124 + third % exposeNameClosers.length,
125 + ];
126 +}
127 +
128 +function hashBytes(input: string): [number, number, number] {
129 + const bytes = new TextEncoder().encode(input);
130 + const first = fnv1a32(bytes, 0x811c9dc5);
131 + const second = fnv1a32(bytes, 0x9e3779b9);
132 + const third = fnv1a32(bytes, 0x85ebca6b);
133 +
134 + return [first & 0xff, second & 0xff, third & 0xff];
135 +}
136 +
137 +function fnv1a32(bytes: Uint8Array, seed: number): number {
138 + let hash = seed >>> 0;
139 + for (const value of bytes) {
140 + hash ^= value;
141 + hash = Math.imul(hash, 0x01000193) >>> 0;
142 + }
143 + return hash >>> 0;
144 +}
145 +
146 +function formatHostPort(hostname: string, port: string): string {
147 + if (hostname.includes(":")) {
148 + return `[${hostname}]:${port}`;
149 + }
150 + return `${hostname}:${port}`;
151 +}