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