tidy frontend page
rabbitprincess committed
Mar 21, 2026 at 23:47 UTC
8b83b6609315a47823c71a2a557caad78b3c1776
14 files changed
+545
-318
cmd/portal-tunnel/README.md
+3
-3
@@ -19,13 +19,13 @@ portal list
19
Custom relay and metadata example:
20
21
```text
22
-portal expose --name myapp \
22
+portal expose localhost:8080 \
23
+ --name myapp \
24
--relays https://portal.example.com \
25
--description "Service description" \
26
--tags tag1,tag2 \
27
--thumbnail https://example.com/thumb.png \
27
- --owner "Portal Operator" \
28
- localhost:8080
28
+ --owner "Portal Operator"
29
```
30
31
## Commands
cmd/portal-tunnel/main.go
+5
-5
@@ -239,8 +239,8 @@ func printRootUsage(w io.Writer) {
239
},
240
[]string{
241
"portal expose 3000",
242
- "portal expose --name my-app localhost:8080",
243
- "portal expose --udp --udp-addr 127.0.0.1:5353 3000",
242
+ "portal expose localhost:8080 --name my-app",
243
+ "portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
244
"portal list",
245
},
246
)
@@ -253,9 +253,9 @@ func printExposeUsage(w io.Writer) {
253
},
254
[]string{
255
"portal expose 3000",
256
- "portal expose --name my-app localhost:8080",
257
- "portal expose --udp --udp-addr 127.0.0.1:5353 3000",
258
- "portal expose --relays https://portal.example.com --default-relays=false 3000",
256
+ "portal expose localhost:8080 --name my-app",
257
+ "portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
258
+ "portal expose 3000 --relays https://portal.example.com --default-relays=false",
259
},
260
)
261
}
cmd/relay-server/tunnel.go
+2
-2
@@ -126,7 +126,7 @@ case ":$PATH:" in
126
esac
127
128
echo "Next step:" >&2
129
-echo " portal expose --relays $BASE_URL 3000" >&2
129
+echo " portal expose 3000 --relays $BASE_URL" >&2
130
`
131
132
const installPowerShellTemplate = `$ErrorActionPreference = "Stop"
@@ -191,7 +191,7 @@ try {
191
192
Write-Host "Installed portal to $InstallPath"
193
Write-Host "Next step:"
194
- Write-Host " portal expose --relays $BaseUrl 3000"
194
+ Write-Host " portal expose 3000 --relays $BaseUrl"
195
} finally {
196
[System.Net.ServicePointManager]::SecurityProtocol = $OriginalSecurityProtocol
197
if ($WorkDir -and (Test-Path $WorkDir)) {
extensions/vscode/src/command.ts
+1
-1
@@ -51,7 +51,7 @@ export function buildCommand(opts: TunnelCommandOptions, target = shellTargetFor
51
exposeArgs.push(`--thumbnail ${formatToken(thumbnail.trim(), target)}`);
52
}
53
54
- const exposeCommand = `expose ${[...exposeArgs, formatToken(host, target)].join(" ")}`;
54
+ const exposeCommand = `expose ${[formatToken(host, target), ...exposeArgs].join(" ")}`;
55
56
if (target === "windows") {
57
const commandLines = [`$ProgressPreference = 'SilentlyContinue'`];
extensions/vscode/src/test/extension.test.ts
+3
-3
@@ -26,7 +26,7 @@ suite("Extension Test Suite", () => {
26
assert.match(command, /PORTAL_BIN="\$\(command -v portal 2>\/dev\/null \|\| true\)"/);
27
assert.match(
28
command,
29
- new RegExp(`"\\$PORTAL_BIN" expose --name ${generatedName} --relays https://relay\\.example\\.com localhost:3000`)
29
+ new RegExp(`"\\$PORTAL_BIN" expose localhost:3000 --name ${generatedName} --relays https://relay\\.example\\.com`)
30
);
31
});
32
@@ -47,7 +47,7 @@ suite("Extension Test Suite", () => {
47
assert.match(command, /portal CLI not found\. Install from a relay first or configure portal\.relayUrls\./);
48
assert.match(
49
command,
50
- new RegExp(`"\\$PORTAL_BIN" expose --name ${generatedName} localhost:3000`)
50
+ new RegExp(`"\\$PORTAL_BIN" expose localhost:3000 --name ${generatedName}`)
51
);
52
});
53
@@ -64,6 +64,6 @@ suite("Extension Test Suite", () => {
64
65
assert.match(command, /irm https:\/\/relay\.example\.com\/install\.ps1 \| iex/);
66
assert.match(command, /\$PortalBin = Join-Path \$env:LOCALAPPDATA 'portal\\bin\\portal\.exe'/);
67
- assert.match(command, /& \$PortalBin expose --name my-app --relays https:\/\/relay\.example\.com --thumbnail https:\/\/example\.com\/thumb\.png localhost:3000/);
67
+ assert.match(command, /& \$PortalBin expose localhost:3000 --name my-app --relays https:\/\/relay\.example\.com --thumbnail https:\/\/example\.com\/thumb\.png/);
68
});
69
});
frontend/src/components/Header.tsx
+23
-46
@@ -1,4 +1,3 @@
1
-import { useId } from "react";
1
import { LogOut } from "lucide-react";
2
import { Button } from "@/components/ui/button";
3
import { ThemeToggleButton } from "@/components/ThemeToggleButton";
@@ -25,50 +24,28 @@ export function Header({
24
onLogout,
25
}: HeaderProps) {
26
const releaseVersion = getReleaseVersion();
28
- const logoGradientId = useId();
27
28
return (
31
- <header className="flex flex-wrap items-center justify-between gap-4 px-1 py-2 sm:px-2">
32
- <div className="flex items-center gap-5 text-foreground">
33
- <div className="flex h-12 w-12 items-center justify-center">
34
- <svg
35
- xmlns="http://www.w3.org/2000/svg"
36
- width="27"
37
- height="27"
38
- viewBox="0 0 906.26 1457.543"
39
- >
40
- <defs>
41
- <linearGradient
42
- id={logoGradientId}
43
- x1="0%"
44
- y1="0%"
45
- x2="100%"
46
- y2="100%"
47
- >
48
- <stop
49
- offset="0%"
50
- style={{ stopColor: "var(--accent-orange)" }}
51
- />
52
- <stop
53
- offset="55%"
54
- style={{ stopColor: "var(--neon-cyan)" }}
55
- />
56
- <stop
57
- offset="100%"
58
- style={{ stopColor: "var(--accent-magenta)" }}
59
- />
60
- </linearGradient>
61
- </defs>
62
- <path
63
- fill={`url(#${logoGradientId})`}
64
- 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"
65
- />
66
- </svg>
67
- </div>
29
+ <header className="flex flex-wrap items-center justify-between gap-4 py-2">
30
+ <div className="flex min-w-0 flex-wrap items-center gap-5 text-foreground sm:gap-8">
31
+ <div className="flex min-w-0 items-center gap-1.5 text-foreground sm:gap-2">
32
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center">
33
+ <svg
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ width="27"
36
+ height="27"
37
+ viewBox="0 0 906.26 1457.543"
38
+ className="h-6 w-6 text-primary"
39
+ >
40
+ <path
41
+ fill="currentColor"
42
+ 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"
43
+ />
44
+ </svg>
45
+ </div>
46
69
- <div className="space-y-1">
70
- <div className="flex flex-wrap items-center gap-2">
71
- <h2 className="text-2xl font-extrabold tracking-tight text-foreground">
47
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
48
+ <h2 className="min-w-0 break-words text-xl font-extrabold tracking-tight text-foreground sm:text-2xl">
49
{title}
50
</h2>
51
{releaseVersion && (
@@ -78,11 +55,8 @@ export function Header({
55
)}
56
</div>
57
</div>
81
- </div>
82
-
83
- <div className="flex flex-wrap items-center gap-3 sm:gap-4">
58
{!isAdmin && (
85
- <nav className="hidden items-center gap-6 text-[15px] font-medium text-text-muted md:flex">
59
+ <nav className="hidden items-center gap-10 pl-3 text-base font-semibold text-text-muted md:flex lg:pl-5">
60
<a
61
href="#live-servers"
62
className="transition-colors hover:text-foreground"
@@ -97,6 +71,9 @@ export function Header({
71
</a>
72
</nav>
73
)}
74
+ </div>
75
+
76
+ <div className="flex flex-wrap items-center gap-3 sm:gap-4">
77
78
{isAdmin ? (
79
<TunnelCommandModal
frontend/src/components/LandingHero.tsx
+69
-7
@@ -1,11 +1,39 @@
1
import { Terminal } from "lucide-react";
2
import { TunnelCommandForm } from "@/components/TunnelCommandForm";
3
4
+const heroFeatures = [
5
+ {
6
+ title: "No setup. No port forwarding.",
7
+ description:
8
+ "Works instantly, even behind NAT and firewalls.",
9
+ },
10
+ {
11
+ title: "End-to-end TLS",
12
+ description:
13
+ "Traffic is routed via SNI with keyless TLS, while TLS still terminates on your app.",
14
+ },
15
+ {
16
+ title: "Permissionless hosting",
17
+ description:
18
+ "Attach to arbitrary relays - no accounts, no approval, no trust required.",
19
+ },
20
+ {
21
+ title: "UDP support",
22
+ description:
23
+ "Expose web apps and arbitrary protocols through the same tunnel.",
24
+ },
25
+ {
26
+ title: "One command. Done.",
27
+ description:
28
+ "Install and expose your app in a single copy-paste.",
29
+ },
30
+] as const;
31
+
32
export function LandingHero() {
33
return (
34
<section
35
aria-labelledby="landing-title"
8
- className="relative overflow-hidden px-0 py-10 sm:py-12 lg:py-16"
36
+ className="relative pt-10 sm:pt-12 lg:pt-16"
37
>
38
<div
39
aria-hidden="true"
@@ -27,10 +55,11 @@ export function LandingHero() {
55
Skip to live servers
56
</a>
57
30
- <div className="relative mx-auto max-w-4xl text-center">
58
+ <div className="relative mx-auto max-w-4xl px-2 text-center sm:px-4">
59
<h1
60
id="landing-title"
33
- className="text-5xl font-extrabold tracking-tight text-foreground sm:text-6xl lg:text-7xl"
61
+ className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-7xl"
62
+ style={{ lineHeight: 0.96 }}
63
>
64
<span className="block">Expose Local Apps</span>
65
<span
@@ -50,7 +79,7 @@ export function LandingHero() {
79
</div>
80
81
<div
53
- className="relative mx-auto mt-10 max-w-[560px] rounded-[1.75rem] border p-5 sm:p-6"
82
+ className="relative mx-auto mt-10 w-full max-w-[550px] rounded-[1.75rem] border px-4 py-5 sm:px-5 sm:py-6"
83
style={{
84
background: "var(--hero-terminal-bg)",
85
borderColor: "var(--hero-terminal-border)",
@@ -58,14 +87,14 @@ export function LandingHero() {
87
boxShadow: "0 30px 72px var(--hero-terminal-shadow)",
88
}}
89
>
61
- <div className="mb-5 flex items-center gap-3">
90
+ <div className="mb-5 flex min-w-0 items-center gap-3">
91
<Terminal
63
- className="h-5 w-5"
92
+ className="h-5 w-5 shrink-0"
93
style={{ color: "var(--hero-terminal-accent)" }}
94
/>
95
<h2
96
id="tunnel-preview"
68
- className="text-2xl font-bold tracking-tight"
97
+ className="min-w-0 text-xl font-bold tracking-tight sm:text-2xl"
98
>
99
Run this command
100
</h2>
@@ -73,6 +102,39 @@ export function LandingHero() {
102
103
<TunnelCommandForm theme="terminal" mode="hero" />
104
</div>
105
+
106
+ <div className="relative mt-18 -mx-4 w-auto sm:mt-20 sm:-mx-6 md:-mx-8">
107
+ <div className="overflow-hidden border-t border-border/80 bg-border/70">
108
+ <div className="grid gap-px sm:grid-cols-2 lg:grid-cols-3">
109
+ <div className="flex min-h-[184px] bg-background/88 p-6 text-left sm:min-h-[196px] sm:p-7">
110
+ <div className="space-y-2">
111
+ <h2 className="whitespace-nowrap text-[1.2rem] font-semibold tracking-tight text-foreground sm:text-[1.32rem] sm:leading-none">
112
+ Make localhost public
113
+ </h2>
114
+ <p className="max-w-[28ch] text-[0.95rem] leading-6 text-text-muted">
115
+ Turn any local app into a shareable HTTPS URL in seconds.
116
+ </p>
117
+ </div>
118
+ </div>
119
+
120
+ {heroFeatures.map(({ title, description }) => (
121
+ <article
122
+ key={title}
123
+ className="flex min-h-[184px] bg-background/88 p-6 text-left transition-colors duration-200 hover:bg-background/92 sm:min-h-[196px] sm:p-7"
124
+ >
125
+ <div className="space-y-2">
126
+ <h3 className="whitespace-nowrap text-[1.2rem] font-semibold tracking-tight text-foreground sm:text-[1.32rem] sm:leading-none">
127
+ {title}
128
+ </h3>
129
+ <p className="max-w-[28ch] text-[0.95rem] leading-6 text-text-muted">
130
+ {description}
131
+ </p>
132
+ </div>
133
+ </article>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ </div>
138
</section>
139
);
140
}
frontend/src/components/ServerListView.tsx
+228
-127
@@ -12,6 +12,8 @@ import { BanStatusButtons } from "@/components/button/BanStatusButtons";
12
import { SortbySelect } from "@/components/select/SortbySelect";
13
import { ApprovalModeToggle } from "@/components/button/ApprovalModeToggle";
14
import { FloatingActionBar } from "@/components/FloatingActionBar";
15
+import { apiClient } from "@/lib/apiClient";
16
+import { API_PATHS, ROUTE_PATHS } from "@/lib/apiPaths";
17
import {
18
Dialog,
19
DialogContent,
@@ -22,12 +24,23 @@ import {
24
export type BanFilter = "all" | "banned" | "active";
25
type ListServer = ClientServer | AdminServer;
26
27
+interface OfficialRegistryRelay {
28
+ url: string;
29
+ status: "online" | "unreachable" | "unknown";
30
+ version?: string;
31
+}
32
+
33
+interface RelayDomainResponse {
34
+ version?: string;
35
+}
36
+
37
interface OfficialRegistryDocument {
38
relays?: string[];
39
}
40
29
-const OFFICIAL_REGISTRY_URL =
41
+const OFFICIAL_REGISTRY_SOURCE_URL =
42
"https://raw.githubusercontent.com/gosuda/portal/main/registry.json";
43
+const REPOSITORY_URL = "https://github.com/gosuda/portal";
44
45
interface ServerListViewProps {
46
title?: string;
@@ -110,7 +123,7 @@ export function ServerListView({
123
onLogout,
124
}: ServerListViewProps) {
125
const [showFilterModal, setShowFilterModal] = useState(false);
113
- const [officialRegistryRelays, setOfficialRegistryRelays] = useState<string[] | null>(null);
126
+ const [officialRegistryRelays, setOfficialRegistryRelays] = useState<OfficialRegistryRelay[] | null>(null);
127
const [officialRegistryFailed, setOfficialRegistryFailed] = useState(false);
128
const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
129
new Set()
@@ -193,9 +206,31 @@ export function ServerListView({
206
207
let cancelled = false;
208
209
+ const loadRelayVersion = async (
210
+ relayURL: string
211
+ ): Promise<OfficialRegistryRelay> => {
212
+ const trimmedURL = relayURL.trim();
213
+ if (trimmedURL === "") {
214
+ return { url: relayURL, status: "unknown", version: "" };
215
+ }
216
+
217
+ try {
218
+ const domainURL = new URL(API_PATHS.sdk.domain, trimmedURL).toString();
219
+ const domain = await apiClient.get<RelayDomainResponse>(domainURL);
220
+ return {
221
+ url: trimmedURL,
222
+ status: "online",
223
+ version:
224
+ typeof domain?.version === "string" ? domain.version.trim() : "",
225
+ };
226
+ } catch {
227
+ return { url: trimmedURL, status: "unreachable", version: "" };
228
+ }
229
+ };
230
+
231
const loadOfficialRegistry = async () => {
232
try {
198
- const response = await fetch(OFFICIAL_REGISTRY_URL, {
233
+ const response = await fetch(OFFICIAL_REGISTRY_SOURCE_URL, {
234
headers: { Accept: "application/json" },
235
});
236
if (!response.ok) {
@@ -206,15 +241,19 @@ export function ServerListView({
241
return;
242
}
243
244
+ const relayURLs = Array.isArray(document.relays)
245
+ ? document.relays.filter(
246
+ (relay): relay is string =>
247
+ typeof relay === "string" && relay.trim().length > 0
248
+ )
249
+ : [];
250
+ const relays = await Promise.all(relayURLs.map(loadRelayVersion));
251
+ if (cancelled) {
252
+ return;
253
+ }
254
+
255
setOfficialRegistryFailed(false);
210
- setOfficialRegistryRelays(
211
- Array.isArray(document.relays)
212
- ? document.relays.filter(
213
- (relay): relay is string =>
214
- typeof relay === "string" && relay.trim().length > 0
215
- )
216
- : []
217
- );
256
+ setOfficialRegistryRelays(relays);
257
} catch (error) {
258
if (!cancelled) {
259
console.error("Failed to load official registry", error);
@@ -234,7 +273,7 @@ export function ServerListView({
273
const isAllSelected =
274
allLeaseIds.length > 0 &&
275
allLeaseIds.every((id) => selectedLeaseIds.has(id));
237
- const officialRegistryURL = OFFICIAL_REGISTRY_URL;
276
+ const officialRegistryURL = OFFICIAL_REGISTRY_SOURCE_URL;
277
const officialRegistryAvailable =
278
officialRegistryRelays !== null && officialRegistryRelays.length > 0;
279
@@ -458,92 +497,137 @@ export function ServerListView({
497
setShowFilterModal={isAdmin ? setShowFilterModal : undefined}
498
/>
499
);
500
+ const publicFooter = (
501
+ <footer className="w-full bg-secondary/35">
502
+ <div className="flex w-full flex-col gap-6 px-6 py-8 sm:px-8 md:flex-row md:items-end md:justify-between lg:px-10">
503
+ <div className="space-y-1.5">
504
+ <a
505
+ href={ROUTE_PATHS.home}
506
+ className="inline-block text-lg font-bold tracking-tight text-foreground transition-colors hover:text-primary"
507
+ >
508
+ PORTAL
509
+ </a>
510
+ <p className="text-sm text-text-muted">
511
+ Public relay index and localhost tunnel launcher.
512
+ </p>
513
+ </div>
514
+
515
+ <nav
516
+ aria-label="Footer"
517
+ className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-text-muted md:justify-end"
518
+ >
519
+ <a href={ROUTE_PATHS.admin} className="transition-colors hover:text-foreground">
520
+ Admin
521
+ </a>
522
+ <a
523
+ href={REPOSITORY_URL}
524
+ target="_blank"
525
+ rel="noopener noreferrer"
526
+ className="transition-colors hover:text-foreground"
527
+ >
528
+ Source
529
+ </a>
530
+ </nav>
531
+ </div>
532
+ </footer>
533
+ );
534
535
return (
536
<div className="relative flex h-auto min-h-screen w-full flex-col">
537
<div className="flex h-full grow flex-col">
465
- <div className="flex flex-1 justify-center">
466
- <div className="flex w-full max-w-6xl flex-1 flex-col px-0 md:px-8">
467
- {isAdmin ? (
468
- <>
469
- <div className="sticky top-0 z-10 bg-background pb-4 pt-5">
470
- <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
471
- <div className="flex items-center gap-2">
472
- <div className="flex-1">{searchBar}</div>
473
- </div>
474
- <div className="hidden sm:flex flex-wrap items-center gap-6 mt-4 px-4 sm:px-6">
475
- {adminFilterControls}
476
- </div>
477
- {onApprovalModeChange && (
478
- <div className="sm:hidden flex items-center gap-3 mt-4 px-4">
479
- <span className="text-sm font-medium text-text-muted">
480
- Approval
481
- </span>
482
- <ApprovalModeToggle
483
- approvalMode={approvalMode}
484
- onApprovalModeChange={onApprovalModeChange}
485
- />
486
- </div>
487
- )}
538
+ {isAdmin ? (
539
+ <>
540
+ <div className="sticky top-0 z-10 w-full bg-background pb-4 pt-5">
541
+ <div className="flex w-full flex-col px-4 sm:px-6 lg:px-8">
542
+ <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
543
+ <div className="flex items-center gap-2">
544
+ <div className="flex-1">{searchBar}</div>
545
</div>
489
- <main className="z-0 flex-1">{serverGrid}</main>
490
- </>
491
- ) : (
492
- <>
493
- <div className="sticky top-0 z-20 bg-background/95 pt-5">
494
- <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
546
+ <div className="mt-4 hidden flex-wrap items-center gap-6 px-4 sm:flex sm:px-6">
547
+ {adminFilterControls}
548
</div>
496
- <main className="z-0 flex-1 px-4 pb-14 pt-6 sm:px-6">
549
+ {onApprovalModeChange && (
550
+ <div className="mt-4 flex items-center gap-3 px-4 sm:hidden">
551
+ <span className="text-sm font-medium text-text-muted">
552
+ Approval
553
+ </span>
554
+ <ApprovalModeToggle
555
+ approvalMode={approvalMode}
556
+ onApprovalModeChange={onApprovalModeChange}
557
+ />
558
+ </div>
559
+ )}
560
+ </div>
561
+ </div>
562
+ <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-0 md:px-8">
563
+ <main className="z-0 flex-1">{serverGrid}</main>
564
+ </div>
565
+ </>
566
+ ) : (
567
+ <>
568
+ <div className="sticky top-0 z-20 w-full bg-background/95 pt-5 backdrop-blur supports-[backdrop-filter]:bg-background/80">
569
+ <div className="flex w-full flex-col px-6 sm:px-8 lg:px-10">
570
+ <Header
571
+ title={title}
572
+ isAdmin={isAdmin}
573
+ onLogout={onLogout}
574
+ />
575
+ </div>
576
+ </div>
577
+ <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col border-x border-border/80">
578
+ <main className="z-0 flex-1 pb-14">
579
+ <section className="border-b border-border/80 px-4 pt-6 sm:px-6 md:px-8">
580
<LandingHero />
581
+ </section>
582
+
583
+ <section
584
+ id="live-servers"
585
+ aria-labelledby="live-servers-title"
586
+ className="scroll-mt-24 min-h-[34rem] border-b border-border/80 px-4 py-8 sm:min-h-[36rem] sm:px-6 md:px-8"
587
+ >
588
+ <div className="space-y-2">
589
+ <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
590
+ Live apps
591
+ </p>
592
+ <h2
593
+ id="live-servers-title"
594
+ className="text-3xl font-semibold tracking-tight text-foreground"
595
+ >
596
+ Browse live apps
597
+ </h2>
598
+ </div>
599
499
- <section
500
- id="live-servers"
501
- aria-labelledby="live-servers-title"
502
- className="mt-8 scroll-mt-24 border-t border-border pt-8"
503
- >
504
- <div className="space-y-2">
505
- <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
506
- Live apps
507
- </p>
508
- <h2
509
- id="live-servers-title"
510
- className="text-3xl font-semibold tracking-tight text-foreground"
511
- >
512
- Browse live apps
513
- </h2>
600
+ {serverRows.length > 0 ? (
601
+ <div className="mt-6">
602
+ {searchBar}
603
+ <div className="px-1 pt-3 text-sm text-text-muted">
604
+ {filteredServers.length.toLocaleString()} services visible
605
+ </div>
606
+ {serverGrid}
607
</div>
515
-
516
- {serverRows.length > 0 ? (
517
- <div className="mt-6 border-t border-border pt-6">
518
- {searchBar}
519
- <div className="px-1 pt-3 text-sm text-text-muted">
520
- {filteredServers.length.toLocaleString()} services
521
- visible
522
- </div>
523
- {serverGrid}
608
+ ) : (
609
+ <div className="mt-6 flex min-h-[22rem] flex-col">
610
+ {searchBar}
611
+ <div className="px-1 pt-3 text-sm text-text-muted">
612
+ 0 services visible
613
</div>
525
- ) : (
526
- <div className="mt-6">
527
- {searchBar}
528
- <div className="px-1 pt-3 text-sm text-text-muted">
529
- 0 services visible
530
- </div>
531
- <div className="py-12 text-center">
532
- <p className="text-lg text-text-muted">
533
- No servers match these filters
534
- </p>
535
- </div>
614
+ <div className="flex flex-1 items-center justify-center py-12 text-center">
615
+ <p className="text-lg text-text-muted">
616
+ No servers match these filters
617
+ </p>
618
</div>
537
- )}
538
- </section>
539
-
540
- <section
541
- id="official-registry"
542
- aria-labelledby="official-registry-title"
543
- className="mt-8 scroll-mt-24 border-t border-border pt-8"
544
- >
619
+ </div>
620
+ )}
621
+ </section>
622
+
623
+ <section
624
+ id="official-registry"
625
+ aria-labelledby="official-registry-title"
626
+ className="scroll-mt-24 px-4 py-8 sm:px-6 md:px-8"
627
+ >
628
+ <div className="rounded-[1.75rem] border border-border/80 bg-secondary/35 p-5 sm:p-6">
629
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
546
- <div className="space-y-2">
630
+ <div className="space-y-1.5">
631
<h2
632
id="official-registry-title"
633
className="text-2xl font-semibold tracking-tight text-foreground"
@@ -551,64 +635,81 @@ export function ServerListView({
635
Official registry
636
</h2>
637
<p className="max-w-2xl text-sm leading-6 text-text-muted">
554
- Portal reads default public relays from this registry.
638
+ Trusted public relays provided by the community.
639
</p>
640
</div>
641
<a
642
href={officialRegistryURL}
643
target="_blank"
644
rel="noopener noreferrer"
561
- className="inline-flex h-11 items-center justify-center rounded-full border border-border px-4 text-sm font-semibold text-foreground transition-colors hover:border-foreground"
645
+ className="inline-flex h-10 items-center justify-center rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/20"
646
>
647
Open registry.json
648
</a>
649
</div>
650
567
- <div className="mt-5">
568
- <div className="rounded-2xl border border-border bg-background px-4 py-4">
569
- <div className="flex items-center justify-between gap-3">
570
- <p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-muted">
571
- Relays
572
- </p>
573
- {officialRegistryAvailable && (
574
- <span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-text-muted">
575
- {officialRegistryRelays.length}
576
- </span>
577
- )}
578
- </div>
579
-
580
- {officialRegistryRelays === null && !officialRegistryFailed ? (
581
- <p className="mt-3 text-sm text-text-muted">
582
- Loading official registry...
583
- </p>
584
- ) : officialRegistryAvailable &&
585
- officialRegistryRelays.length > 0 ? (
586
- <div className="mt-3 space-y-1.5">
587
- {officialRegistryRelays.map((relay) => (
651
+ {officialRegistryRelays === null && !officialRegistryFailed ? (
652
+ <p className="mt-6 text-sm text-text-muted">
653
+ Loading official registry...
654
+ </p>
655
+ ) : officialRegistryAvailable ? (
656
+ <div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
657
+ {officialRegistryRelays.map((relay) => {
658
+ const statusLabel = {
659
+ online: "ONLINE",
660
+ unreachable: "OFFLINE",
661
+ unknown: "UNKNOWN",
662
+ }[relay.status];
663
+ const statusClass = {
664
+ online:
665
+ "bg-primary/12 text-primary",
666
+ unreachable:
667
+ "bg-secondary text-text-muted",
668
+ unknown:
669
+ "bg-secondary text-text-muted",
670
+ }[relay.status];
671
+
672
+ return (
673
+ <div
674
+ key={relay.url}
675
+ className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
676
+ >
677
<a
589
- key={relay}
590
- href={relay}
678
+ href={relay.url}
679
target="_blank"
680
rel="noopener noreferrer"
593
- className="block overflow-x-auto whitespace-nowrap font-mono text-sm text-foreground underline-offset-4 hover:underline"
681
+ className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm"
682
>
595
- {relay}
683
+ {relay.url}
684
</a>
597
- ))}
598
- </div>
599
- ) : (
600
- <p className="mt-3 text-sm text-text-muted">
601
- Registry entries are unavailable right now.
602
- </p>
603
- )}
685
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
686
+ <span
687
+ className={`rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${statusClass}`}
688
+ >
689
+ {statusLabel}
690
+ </span>
691
+ {relay.version && (
692
+ <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
693
+ SDK {relay.version}
694
+ </span>
695
+ )}
696
+ </div>
697
+ </div>
698
+ );
699
+ })}
700
</div>
605
- </div>
606
- </section>
607
- </main>
608
- </>
609
- )}
610
- </div>
611
- </div>
701
+ ) : (
702
+ <p className="mt-6 text-sm text-text-muted">
703
+ Registry entries are unavailable right now.
704
+ </p>
705
+ )}
706
+ </div>
707
+ </section>
708
+ </main>
709
+ </div>
710
+ {publicFooter}
711
+ </>
712
+ )}
713
</div>
714
715
{isAdmin && (
frontend/src/components/TunnelCommandForm.tsx
+154
-112
@@ -295,9 +295,13 @@ export function TunnelCommandForm({
295
"text-xs font-semibold uppercase tracking-[0.24em]",
296
isTerminal ? "text-slate-400" : "text-text-muted"
297
);
298
+ const heroURLClass = cn(
299
+ "block overflow-x-auto whitespace-nowrap font-mono text-[15px] font-medium sm:text-base",
300
+ isTerminal ? "text-sky-300" : "text-primary"
301
+ );
302
303
const commandSection = (
300
- <div className={cn("space-y-2", isHero && "space-y-3")}>
304
+ <div className={cn("space-y-2", isHero && "space-y-4")}>
305
{isHero ? (
306
<label className={heroSectionLabelClass}>Command</label>
307
) : (
@@ -316,7 +320,7 @@ export function TunnelCommandForm({
320
"overflow-x-auto whitespace-pre-wrap break-all font-mono",
321
isTerminal
322
? isHero
319
- ? "min-h-[104px] rounded-xl border border-green-status/20 bg-black/45 p-4 text-sm leading-7 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
323
+ ? "min-h-[148px] rounded-xl border border-primary/20 bg-black/50 px-4 py-4 text-sm leading-7 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
324
: "rounded-xl border border-white/10 bg-black/30 p-4 pr-12 text-sm leading-7 text-white"
325
: "rounded-xl bg-border p-4 pr-12 text-sm leading-7 text-foreground"
326
)}
@@ -346,10 +350,10 @@ export function TunnelCommandForm({
350
type="button"
351
onClick={handleCopy}
352
className={cn(
349
- "inline-flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors",
353
+ "inline-flex w-full items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-semibold transition-all duration-200",
354
copied
351
- ? "bg-white text-slate-950"
352
- : "bg-green-status text-slate-950 hover:bg-green-300"
355
+ ? "border-sky-400/45 bg-linear-to-r from-sky-400 via-cyan-300 to-blue-400 text-slate-950 shadow-[0_10px_24px_rgba(37,99,235,0.18)]"
356
+ : "border-sky-400/45 bg-linear-to-r from-sky-500 via-cyan-400 to-blue-500 text-slate-950 shadow-[0_12px_30px_rgba(37,99,235,0.22)] hover:brightness-105 hover:saturate-125"
357
)}
358
aria-label="Copy command"
359
>
@@ -359,18 +363,20 @@ export function TunnelCommandForm({
363
)}
364
365
{isHero && (
362
- <div className="space-y-1.5 pt-1">
366
+ <div className="space-y-1.5 pt-0.5">
367
<p className={heroSectionLabelClass}>Public URL</p>
368
<div
369
className={cn(
366
- "space-y-4 rounded-xl border px-4 py-3",
367
- isTerminal ? "border-white/10 bg-white/5" : "border-border bg-white"
370
+ "space-y-3 rounded-xl border px-3.5 py-3",
371
+ isTerminal
372
+ ? "border-white/8 bg-white/[0.045]"
373
+ : "border-border bg-white"
374
)}
375
>
376
<div
377
className={cn(
372
- "flex items-center gap-2 text-sm font-semibold",
373
- isTerminal ? "text-slate-200" : "text-foreground"
378
+ "flex items-center gap-2 text-[13px] font-semibold",
379
+ isTerminal ? "text-slate-300" : "text-foreground"
380
)}
381
>
382
<span
@@ -383,8 +389,8 @@ export function TunnelCommandForm({
389
<span
390
aria-disabled="true"
391
className={cn(
386
- "block cursor-not-allowed overflow-x-auto whitespace-nowrap font-mono text-base font-medium opacity-60 sm:text-lg",
387
- isTerminal ? "text-green-status" : "text-primary"
392
+ heroURLClass,
393
+ "cursor-not-allowed opacity-70"
394
)}
395
>
396
{previewURL}
@@ -395,8 +401,8 @@ export function TunnelCommandForm({
401
target="_blank"
402
rel="noopener noreferrer"
403
className={cn(
398
- "block overflow-x-auto whitespace-nowrap font-mono text-base font-medium underline-offset-4 hover:underline sm:text-lg",
399
- isTerminal ? "text-green-status" : "text-primary"
404
+ heroURLClass,
405
+ "underline-offset-4 hover:underline"
406
)}
407
>
408
{previewURL}
@@ -408,76 +414,117 @@ export function TunnelCommandForm({
414
</div>
415
);
416
411
- const heroFieldLabelClass = cn(
412
- "text-[11px] font-semibold uppercase tracking-[0.2em]",
413
- isTerminal ? "text-slate-400" : "text-text-muted"
414
- );
417
const heroInputClass = cn(
416
- "h-12 rounded-xl border px-4 text-[15px] shadow-none",
418
+ "h-10 rounded-lg border px-3 text-sm shadow-none",
419
isTerminal
418
- ? "border-white/8 bg-black/20 text-slate-100 placeholder:text-slate-500"
420
+ ? "border-white/6 bg-white/[0.035] text-slate-200 placeholder:text-slate-500"
421
: "border-border bg-white"
422
);
423
const nameInputValue = isAutoName ? generatedName : name;
424
const shuffleButtonClass = cn(
425
"inline-flex shrink-0 items-center justify-center border px-3 text-xs font-semibold transition-colors",
424
- isHero ? "h-12 rounded-xl px-4" : "h-12 rounded-lg",
426
+ isHero ? "h-10 rounded-lg px-3" : "h-12 rounded-lg",
427
isTerminal
426
- ? "border-white/8 bg-black/20 text-slate-300 hover:text-white"
428
+ ? "border-white/6 bg-white/[0.035] text-slate-400 hover:bg-white/5 hover:text-white"
429
: "border-border bg-white text-text-muted hover:text-foreground"
430
);
429
- const optionSectionLabel = isHero ? (
430
- <div className="pt-1">
431
- <p className={heroSectionLabelClass}>OPTIONS</p>
431
+ const heroControlsSection = isHero ? (
432
+ <div
433
+ className={cn(
434
+ "space-y-1.5 border-t pt-3",
435
+ isTerminal ? "border-white/6" : "border-border/80"
436
+ )}
437
+ >
438
+ <div
439
+ className={cn(
440
+ "grid grid-cols-[62px_minmax(0,1fr)_140px] gap-2 px-1 text-[9px] font-semibold uppercase tracking-[0.16em]",
441
+ isTerminal ? "text-slate-500" : "text-text-muted"
442
+ )}
443
+ >
444
+ <span>Port</span>
445
+ <span>Name</span>
446
+ <span>Platform</span>
447
+ </div>
448
+ <div className="grid grid-cols-[62px_minmax(0,1fr)_140px] items-center gap-2">
449
+ <Input
450
+ id={`${inputId}-host`}
451
+ type="text"
452
+ value={target}
453
+ onChange={(event) => setTarget(event.target.value)}
454
+ placeholder={defaultHost}
455
+ aria-label="Port"
456
+ className={cn(heroInputClass, "w-full px-2.5 text-[13px] font-mono")}
457
+ />
458
+ <div className="flex min-w-0 flex-1 items-center gap-2">
459
+ <Input
460
+ id={`${inputId}-name`}
461
+ type="text"
462
+ value={nameInputValue}
463
+ onChange={handleNameChange}
464
+ aria-label="Public name"
465
+ className={cn(heroInputClass, "min-w-0 flex-1 px-2.5 text-[13px]")}
466
+ />
467
+ <button
468
+ type="button"
469
+ onClick={handleShuffleName}
470
+ className={cn(shuffleButtonClass, "w-10 rounded-lg px-0")}
471
+ aria-label="Shuffle public name"
472
+ title="Shuffle public name"
473
+ >
474
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
475
+ </button>
476
+ </div>
477
+ <div
478
+ className={cn(
479
+ "flex w-full shrink-0 rounded-lg border p-0.5",
480
+ isTerminal ? "border-white/6 bg-white/[0.035]" : "border-border bg-border"
481
+ )}
482
+ >
483
+ <div className="flex w-full">
484
+ <button
485
+ type="button"
486
+ onClick={() => setOs("unix")}
487
+ className={cn(
488
+ "flex-1 whitespace-nowrap rounded-md px-1.5 py-1.5 text-[11px] font-semibold transition-colors",
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-500 hover:text-white"
495
+ : "text-text-muted hover:text-foreground"
496
+ )}
497
+ >
498
+ Linux
499
+ </button>
500
+ <button
501
+ type="button"
502
+ onClick={() => setOs("windows")}
503
+ className={cn(
504
+ "flex-1 whitespace-nowrap rounded-md px-1.5 py-1.5 text-[11px] font-semibold transition-colors",
505
+ os === "windows"
506
+ ? isTerminal
507
+ ? "bg-white text-slate-950 shadow-sm"
508
+ : "bg-background text-foreground shadow-sm"
509
+ : isTerminal
510
+ ? "text-slate-500 hover:text-white"
511
+ : "text-text-muted hover:text-foreground"
512
+ )}
513
+ >
514
+ Windows
515
+ </button>
516
+ </div>
517
+ </div>
518
+ </div>
519
</div>
520
) : null;
521
522
return (
436
- <div className={cn("space-y-5", className)}>
523
+ <div className={cn(isHero ? "space-y-4" : "space-y-5", className)}>
524
{isHero && commandSection}
438
- {optionSectionLabel}
525
+ {heroControlsSection}
526
440
- {isHero ? (
441
- <div className="grid gap-4 sm:grid-cols-2">
442
- <div className="space-y-2">
443
- <label htmlFor={`${inputId}-host`} className={heroFieldLabelClass}>
444
- Host
445
- </label>
446
- <Input
447
- id={`${inputId}-host`}
448
- type="text"
449
- value={target}
450
- onChange={(event) => setTarget(event.target.value)}
451
- placeholder={defaultHost}
452
- className={heroInputClass}
453
- />
454
- </div>
455
-
456
- <div className="space-y-2">
457
- <label htmlFor={`${inputId}-name`} className={heroFieldLabelClass}>
458
- Public Name
459
- </label>
460
- <div className="flex items-center gap-2">
461
- <Input
462
- id={`${inputId}-name`}
463
- type="text"
464
- value={nameInputValue}
465
- onChange={handleNameChange}
466
- className={cn(heroInputClass, "flex-1")}
467
- />
468
- <button
469
- type="button"
470
- onClick={handleShuffleName}
471
- className={shuffleButtonClass}
472
- aria-label="Shuffle public name"
473
- title="Shuffle public name"
474
- >
475
- <RefreshCw className="h-4 w-4" aria-hidden="true" />
476
- </button>
477
- </div>
478
- </div>
479
- </div>
480
- ) : (
527
+ {!isHero && (
528
<>
529
<div className="space-y-2">
530
<label
@@ -724,54 +771,49 @@ export function TunnelCommandForm({
771
</div>
772
)}
773
727
- <div className={cn("space-y-2", isHero && "pt-1")}>
728
- <div
729
- className={cn(
730
- "flex p-1",
731
- isHero ? "rounded-lg" : "rounded-xl",
732
- isTerminal
733
- ? isHero
734
- ? "bg-black/20"
735
- : "bg-white/8"
736
- : "bg-border"
737
- )}
738
- >
739
- <button
740
- type="button"
741
- onClick={() => setOs("unix")}
742
- className={cn(
743
- "flex-1 rounded-lg px-3 transition-colors",
744
- isHero ? "py-2 text-sm font-semibold" : "py-2 text-sm font-medium",
745
- os === "unix"
746
- ? isTerminal
747
- ? "bg-white text-slate-950 shadow-sm"
748
- : "bg-background text-foreground shadow-sm"
749
- : isTerminal
750
- ? "text-slate-400 hover:text-white"
751
- : "text-text-muted hover:text-foreground"
752
- )}
753
- >
754
- Linux / macOS
755
- </button>
756
- <button
757
- type="button"
758
- onClick={() => setOs("windows")}
774
+ {!isHero && (
775
+ <div className="space-y-2">
776
+ <div
777
className={cn(
760
- "flex-1 rounded-lg px-3 transition-colors",
761
- isHero ? "py-2 text-sm font-semibold" : "py-2 text-sm font-medium",
762
- os === "windows"
763
- ? isTerminal
764
- ? "bg-white text-slate-950 shadow-sm"
765
- : "bg-background text-foreground shadow-sm"
766
- : isTerminal
767
- ? "text-slate-400 hover:text-white"
768
- : "text-text-muted hover:text-foreground"
778
+ "flex rounded-xl p-1",
779
+ isTerminal ? "bg-white/8" : "bg-border"
780
)}
781
>
771
- Windows (PowerShell)
772
- </button>
782
+ <button
783
+ type="button"
784
+ onClick={() => setOs("unix")}
785
+ className={cn(
786
+ "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
787
+ os === "unix"
788
+ ? isTerminal
789
+ ? "bg-white text-slate-950 shadow-sm"
790
+ : "bg-background text-foreground shadow-sm"
791
+ : isTerminal
792
+ ? "text-slate-400 hover:text-white"
793
+ : "text-text-muted hover:text-foreground"
794
+ )}
795
+ >
796
+ Linux / macOS
797
+ </button>
798
+ <button
799
+ type="button"
800
+ onClick={() => setOs("windows")}
801
+ className={cn(
802
+ "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
803
+ os === "windows"
804
+ ? isTerminal
805
+ ? "bg-white text-slate-950 shadow-sm"
806
+ : "bg-background text-foreground shadow-sm"
807
+ : isTerminal
808
+ ? "text-slate-400 hover:text-white"
809
+ : "text-text-muted hover:text-foreground"
810
+ )}
811
+ >
812
+ Windows (PowerShell)
813
+ </button>
814
+ </div>
815
</div>
774
- </div>
816
+ )}
817
818
{!isHero && commandSection}
819
</div>
frontend/src/index.css
+12
-2
@@ -1,11 +1,11 @@
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");
1
+@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=Open+Sans:wght@400;500;600;700&display=swap");
2
@import "tailwindcss";
3
4
@custom-variant dark (&:where(.dark, .dark *));
5
6
@theme {
7
--font-family-display: "Manrope", sans-serif;
8
- --font-family-sans: "Public Sans", sans-serif;
8
+ --font-family-sans: "Open Sans", sans-serif;
9
10
--radius: 0.875rem;
11
--radius-lg: 1.25rem;
@@ -150,6 +150,12 @@
150
font-family: var(--font-family-sans);
151
}
152
153
+ input,
154
+ textarea,
155
+ select {
156
+ font-family: var(--font-family-sans);
157
+ }
158
+
159
.dark {
160
color-scheme: dark;
161
}
@@ -171,6 +177,10 @@
177
letter-spacing: -0.03em;
178
}
179
180
+ button {
181
+ font-family: var(--font-family-display);
182
+ }
183
+
184
a {
185
color: inherit;
186
}
frontend/src/lib/tunnelCommand.test.ts
+18
-4
@@ -24,18 +24,18 @@ describe("tunnelCommand", () => {
24
expect(command).toBe(
25
[
26
"curl -ksSL https://localhost:4017/install.sh | bash",
27
- "portal expose --name my-app --relays https://localhost:4017 3000",
27
+ "portal expose 3000 --name my-app --relays https://localhost:4017",
28
].join("\n")
29
);
30
expect(command).not.toContain(" \\\n");
31
expect(command).not.toContain("\n --name");
32
});
33
34
- it("uses the same flat layout for display and copy", () => {
34
+ it("wraps display commands before relays while keeping copied commands flat", () => {
35
const options = {
36
currentOrigin: "https://relay.example.com",
37
target: "localhost:3000",
38
- name: "",
38
+ name: "my-app",
39
nameSeed: "web_portal",
40
relayUrls: ["https://relay.example.com"],
41
defaultRelays: false,
@@ -43,7 +43,21 @@ describe("tunnelCommand", () => {
43
os: "windows" as const,
44
};
45
46
- expect(buildTunnelDisplayCommand(options)).toBe(buildTunnelCommand(options));
46
+ expect(buildTunnelCommand(options)).toBe(
47
+ [
48
+ `$ProgressPreference = 'SilentlyContinue'`,
49
+ `irm https://relay.example.com/install.ps1 | iex`,
50
+ `portal expose localhost:3000 --name my-app --relays https://relay.example.com --default-relays=false --thumbnail https://example.com/thumb.png`,
51
+ ].join("\n")
52
+ );
53
+ expect(buildTunnelDisplayCommand(options)).toBe(
54
+ [
55
+ `$ProgressPreference = 'SilentlyContinue'`,
56
+ `irm https://relay.example.com/install.ps1 | iex`,
57
+ `portal expose localhost:3000 --name my-app`,
58
+ `--relays https://relay.example.com --default-relays=false --thumbnail https://example.com/thumb.png`,
59
+ ].join("\n")
60
+ );
61
});
62
63
it("uses the relay root host for preview URLs instead of a placeholder host", () => {
frontend/src/lib/tunnelCommand.ts
+24
-3
@@ -79,7 +79,7 @@ export function buildTunnelDisplayCommand({
79
udpPort,
80
});
81
82
- return joinTunnelCommand(installLine, exposeHead, exposeOptions);
82
+ return joinTunnelDisplayCommand(installLine, exposeHead, exposeOptions);
83
}
84
85
function buildTunnelCommandParts({
@@ -140,7 +140,7 @@ function buildTunnelCommandParts({
140
`irm ${formatToken(installPowerShellURL, os)} | iex`,
141
].join("\n"),
142
exposeHead: "portal expose",
143
- exposeOptions: [...exposeArgs, formatToken(targetValue, os)],
143
+ exposeOptions: [formatToken(targetValue, os), ...exposeArgs],
144
};
145
}
146
@@ -148,7 +148,7 @@ function buildTunnelCommandParts({
148
return {
149
installLine: `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`,
150
exposeHead: "portal expose",
151
- exposeOptions: [...exposeArgs, formatToken(targetValue, os)],
151
+ exposeOptions: [formatToken(targetValue, os), ...exposeArgs],
152
};
153
}
154
@@ -248,3 +248,24 @@ function joinTunnelCommand(
248
): string {
249
return [installLine, [exposeHead, ...exposeOptions].join(" ")].join("\n");
250
}
251
+
252
+function joinTunnelDisplayCommand(
253
+ installLine: string,
254
+ exposeHead: string,
255
+ exposeOptions: string[]
256
+): string {
257
+ const relayIndex = exposeOptions.findIndex((option) =>
258
+ option.startsWith("--relays ")
259
+ );
260
+
261
+ if (relayIndex < 0) {
262
+ return joinTunnelCommand(installLine, exposeHead, exposeOptions);
263
+ }
264
+
265
+ const exposeLines = [
266
+ [exposeHead, ...exposeOptions.slice(0, relayIndex)].join(" "),
267
+ exposeOptions.slice(relayIndex).join(" "),
268
+ ];
269
+
270
+ return [installLine, ...exposeLines].join("\n");
271
+}
portal/api_server.go
+2
@@ -156,6 +156,8 @@ func (s *Server) discover(_ context.Context, req types.DiscoverRequest) (types.D
156
}
157
158
func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
159
+ w.Header().Set("Access-Control-Allow-Origin", "*")
160
+
161
if r.Method != http.MethodGet {
162
utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
163
return
registry.json
+1
-3
@@ -3,8 +3,6 @@
3
"https://gosunuts.xyz/",
4
"https://portal.thumbgo.kr/",
5
"https://portal.rabbitson87.dev/",
6
- "https://s-h.day/",
7
- "https://portal.dawnfullstack.com/",
8
- "https://portal.hontoni.moe/"
6
+ "https://s-h.day/"
7
]
8
}
\ No newline at end of file