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