fix frontend
Kim committed
Mar 23, 2026 at 13:38 UTC
bd8dedf0f007794c9c143732f7798366417f7a2a
16 files changed
+786
-300
.env.example
+1
-1
@@ -4,7 +4,6 @@ PORTAL_URL=https://localhost:4017
4
# Listener ports
5
API_PORT=4017
6
SNI_PORT=443
7
-
7
# UDP transport (0 = disabled). Set count > 0 to enable QUIC tunnel + allocate UDP ports starting from 50000.
8
# e.g., UDP_PORT_COUNT=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
9
UDP_PORT_COUNT=0
@@ -27,6 +26,7 @@ AWS_HOSTED_ZONE_ID=
26
27
# Admin/auth configuration
28
ADMIN_SECRET_KEY=
29
+LANDING_PAGE_ENABLED=false
30
# Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
31
# Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
32
TRUST_PROXY_HEADERS=false
README.md
+6
@@ -69,6 +69,12 @@ For architecture decisions, see [docs/adr/README.md](docs/adr/README.md).
69
70
## Public Relay Registry
71
72
+Portal's official public relay registry is:
73
+
74
+`https://raw.githubusercontent.com/gosuda/portal/main/registry.json`
75
+
76
+Portal tunnel clients can include this registry by default, and the relay UI also reads from the same path to show the official relay list.
77
+
78
If you operate a public Portal relay, open a Pull Request to add your relay URL to `registry.json`. Keeping the registry updated makes public relays easier for the community to discover.
79
80
## Contributing
cmd/relay-server/admin.go
+5
-2
@@ -465,8 +465,11 @@ func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool)
465
}
466
}
467
468
-func (s persistedAdminState) landingPageEnabled() bool {
469
- return s.LandingPageEnabled == nil || *s.LandingPageEnabled
468
+func (s persistedAdminState) landingPageEnabled(defaultEnabled bool) bool {
469
+ if s.LandingPageEnabled == nil {
470
+ return defaultEnabled
471
+ }
472
+ return *s.LandingPageEnabled
473
}
474
475
func (s persistedAdminState) apply(runtime *policy.Runtime) error {
cmd/relay-server/frontend.go
+3
-3
@@ -39,7 +39,7 @@ type Frontend struct {
39
landingPageEnabled atomic.Bool
40
}
41
42
-func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string) (*Frontend, error) {
42
+func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string, defaultLandingPageEnabled bool) (*Frontend, error) {
43
if server == nil {
44
return nil, errors.New("frontend requires portal server")
45
}
@@ -58,7 +58,7 @@ func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath st
58
auth: newAdminAuth(adminSecret),
59
adminSettingsPath: strings.TrimSpace(adminSettingsPath),
60
}
61
- frontend.setLandingPageEnabled(state.landingPageEnabled())
61
+ frontend.setLandingPageEnabled(state.landingPageEnabled(defaultLandingPageEnabled))
62
return frontend, nil
63
}
64
@@ -243,7 +243,7 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description string) stri
243
244
func (f *Frontend) isLandingPageEnabled() bool {
245
if f == nil {
246
- return true
246
+ return false
247
}
248
return f.landingPageEnabled.Load()
249
}
cmd/relay-server/main.go
+5
-1
@@ -35,6 +35,7 @@ type relayServerConfig struct {
35
APIPort int
36
SNIPort int
37
UDPPortCount int
38
+ LandingPageEnabled bool
39
Bootstraps string
40
DiscoveryEnabled bool
41
OwnerPrivateKey string
@@ -60,6 +61,7 @@ func runServeCommand(args []string) error {
61
utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT")
62
utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT")
63
utils.IntFlagEnv(fs, &cfg.UDPPortCount, "udp-port-count", 0, utils.ParseNonNegativeInt, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled)", "UDP_PORT_COUNT")
64
+ utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
65
utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
66
utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY_ENABLED")
67
utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
@@ -92,6 +94,7 @@ func runServeCommand(args []string) error {
94
Str("release_version", types.ReleaseVersion).
95
Str("portal_url", cfg.PortalURL).
96
Str("admin_settings_path", cfg.AdminSettingsPath).
97
+ Bool("landing_page_enabled", cfg.LandingPageEnabled).
98
Bool("discovery_enabled", cfg.DiscoveryEnabled).
99
Bool("udp_enabled", cfg.UDPPortCount > 0).
100
Msg("configured relay server")
@@ -128,7 +131,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
131
return fmt.Errorf("create relay server: %w", err)
132
}
133
131
- frontend, err := NewFrontend(server, cfg.AdminSecretKey, cfg.AdminSettingsPath)
134
+ frontend, err := NewFrontend(server, cfg.AdminSecretKey, cfg.AdminSettingsPath, cfg.LandingPageEnabled)
135
if err != nil {
136
return fmt.Errorf("create frontend: %w", err)
137
}
@@ -186,6 +189,7 @@ func printRootUsage(w io.Writer) {
189
"relay-server serve",
190
"relay-server --portal-url https://portal.example.com",
191
"relay-server --discovery --udp-port-count 100",
192
+ "relay-server --landing-page-enabled",
193
"relay-server help",
194
},
195
)
docker-compose.yml
+1
@@ -24,6 +24,7 @@ services:
24
25
# Admin/auth configuration
26
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
27
+ LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
28
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
29
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
30
frontend/src/components/Header.tsx
+11
-23
@@ -8,9 +8,7 @@ import {
8
TooltipProvider,
9
TooltipTrigger,
10
} from "@/components/ui/tooltip";
11
-import { TunnelCommandModal } from "@/components/TunnelCommandModal";
11
import { getReleaseVersion } from "@/lib/releaseVersion";
13
-import clsx from "clsx";
12
13
interface HeaderProps {
14
title?: string;
@@ -54,16 +52,6 @@ export function Header({
52
const releaseVersion = getReleaseVersion();
53
const serverOwnerAddress = getServerOwnerAddress();
54
const [ownerAddressCopied, setOwnerAddressCopied] = useState(false);
57
- const addYourServerTrigger = (
58
- <Button
59
- className={clsx(
60
- "h-11 cursor-pointer rounded-full px-5 text-base font-semibold shadow-none",
61
- "hidden sm:inline-flex"
62
- )}
63
- >
64
- <span className="truncate">Add Your Server</span>
65
- </Button>
66
- );
55
const displayOwnerAddress = formatOwnerAddress(serverOwnerAddress);
56
57
useEffect(() => {
@@ -126,7 +114,13 @@ export function Header({
114
</div>
115
</div>
116
{!isAdmin && (
129
- <nav className="hidden items-center gap-6 pl-2 text-base font-semibold text-text-muted md:flex lg:pl-3">
117
+ <nav className="hidden items-center gap-6 pl-2 text-base font-semibold text-text-muted xl:flex xl:pl-3">
118
+ <a
119
+ href="#quick-start"
120
+ className="transition-colors hover:text-foreground"
121
+ >
122
+ Quick Start
123
+ </a>
124
<a
125
href="#live-servers"
126
className="transition-colors hover:text-foreground"
@@ -147,7 +141,7 @@ export function Header({
141
{serverOwnerAddress && (
142
<div
143
title={serverOwnerAddress}
150
- className="hidden h-11 items-center gap-2 rounded-full border border-sky-500/20 bg-background/85 pl-1.5 pr-1 shadow-[0_10px_28px_rgba(15,23,42,0.08)] backdrop-blur lg:inline-flex"
144
+ className="inline-flex h-11 max-w-full items-center gap-2 rounded-full border border-sky-500/20 bg-background/85 pl-1.5 pr-1 shadow-[0_10px_28px_rgba(15,23,42,0.08)] backdrop-blur"
145
>
146
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-sky-400 via-cyan-300 to-blue-500 shadow-[0_8px_20px_rgba(56,189,248,0.28)]">
147
<svg
@@ -164,10 +158,7 @@ export function Header({
158
</svg>
159
</div>
160
167
- <span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-600 dark:text-sky-300">
168
- Address
169
- </span>
170
- <span className="max-w-[8.5rem] truncate font-mono text-[13px] font-semibold tracking-tight text-foreground">
161
+ <span className="max-w-[6.75rem] truncate font-mono text-[13px] font-semibold tracking-tight text-foreground sm:max-w-[8.5rem]">
162
{displayOwnerAddress}
163
</span>
164
@@ -187,15 +178,12 @@ export function Header({
178
</button>
179
</div>
180
)}
190
-
191
- <TunnelCommandModal trigger={addYourServerTrigger} />
192
-
181
{!isAdmin && (
182
<a
183
href={repoURL}
184
target="_blank"
185
rel="noopener noreferrer"
198
- className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card/95 text-foreground transition-colors hover:bg-secondary hover:text-primary"
186
+ className="hidden h-11 w-11 items-center justify-center text-foreground transition-transform transition-colors hover:-translate-y-0.5 hover:text-primary xl:inline-flex"
187
aria-label="View source on GitHub"
188
>
189
<svg
@@ -210,7 +198,7 @@ export function Header({
198
</a>
199
)}
200
213
- <ThemeToggleButton />
201
+ <ThemeToggleButton className="hidden xl:inline-flex" />
202
203
{isAdmin && onLogout && (
204
<TooltipProvider>
frontend/src/components/LandingHero.tsx
+350
-27
@@ -1,6 +1,36 @@
1
-import { Terminal } from "lucide-react";
1
+import {
2
+ startTransition,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type PointerEvent as ReactPointerEvent,
8
+} from "react";
9
import { TunnelCommandForm } from "@/components/TunnelCommandForm";
10
11
+const heroDifferentiatorCards = [
12
+ {
13
+ key: "login",
14
+ title: "No Login",
15
+ description: "Run the command immediately without accounts or auth flows.",
16
+ },
17
+ {
18
+ key: "billing",
19
+ title: "No Billing",
20
+ description: "No credit card, no plan gate, and no billing step before go-live.",
21
+ },
22
+ {
23
+ key: "cloud",
24
+ title: "No Cloud SaaS",
25
+ description: "No dashboard, region picker, or managed cloud setup to get started.",
26
+ },
27
+ {
28
+ key: "permissionless",
29
+ title: "Permissionless",
30
+ description: "Use the public registry or attach your own relay. No approval required.",
31
+ },
32
+] as const;
33
+
34
const heroFeatures = [
35
{
36
title: "No setup. No port forwarding.",
@@ -30,6 +60,224 @@ const heroFeatures = [
60
] as const;
61
62
export function LandingHero() {
63
+ const carouselCardCount = heroDifferentiatorCards.length;
64
+ const carouselLoopBoundaryIndex = carouselCardCount + 1;
65
+ const carouselTransitionDurationMs = 700;
66
+ const [reduceMotion, setReduceMotion] = useState(false);
67
+ const [isHovered, setIsHovered] = useState(false);
68
+ const [isDragging, setIsDragging] = useState(false);
69
+ const [trackIndex, setTrackIndex] = useState(1);
70
+ const [transitionEnabled, setTransitionEnabled] = useState(true);
71
+ const [slideSize, setSlideSize] = useState(328);
72
+ const [dragOffset, setDragOffset] = useState(0);
73
+ const dragStartXRef = useRef<number | null>(null);
74
+ const dragOffsetRef = useRef(0);
75
+ const pointerIdRef = useRef<number | null>(null);
76
+
77
+ const slideGap = 16;
78
+ const carouselSlides = useMemo(
79
+ () => [
80
+ heroDifferentiatorCards[carouselCardCount - 1],
81
+ ...heroDifferentiatorCards,
82
+ heroDifferentiatorCards[0],
83
+ ],
84
+ [carouselCardCount]
85
+ );
86
+ const renderedTrackIndex = Math.min(
87
+ Math.max(trackIndex, 0),
88
+ carouselSlides.length - 1
89
+ );
90
+ const trackTranslateX = `calc(50% - ${slideSize / 2}px - ${
91
+ renderedTrackIndex * (slideSize + slideGap)
92
+ }px ${dragOffset >= 0 ? "+" : "-"} ${Math.abs(dragOffset)}px)`;
93
+
94
+ useEffect(() => {
95
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
96
+ return;
97
+ }
98
+
99
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)");
100
+ const syncReduceMotion = () => {
101
+ setReduceMotion(media.matches);
102
+ };
103
+
104
+ syncReduceMotion();
105
+
106
+ if (typeof media.addEventListener === "function") {
107
+ media.addEventListener("change", syncReduceMotion);
108
+ return () => media.removeEventListener("change", syncReduceMotion);
109
+ }
110
+
111
+ media.addListener(syncReduceMotion);
112
+ return () => media.removeListener(syncReduceMotion);
113
+ }, []);
114
+
115
+ useEffect(() => {
116
+ if (typeof window === "undefined") {
117
+ return;
118
+ }
119
+
120
+ const updateSlideSize = () => {
121
+ if (window.innerWidth >= 1024) {
122
+ setSlideSize(560);
123
+ return;
124
+ }
125
+
126
+ if (window.innerWidth >= 640) {
127
+ setSlideSize(472);
128
+ return;
129
+ }
130
+
131
+ const maxMobileWidth = Math.min(window.innerWidth - 48, 368);
132
+ setSlideSize(Math.max(maxMobileWidth, 288));
133
+ };
134
+
135
+ updateSlideSize();
136
+ window.addEventListener("resize", updateSlideSize);
137
+
138
+ return () => {
139
+ window.removeEventListener("resize", updateSlideSize);
140
+ };
141
+ }, []);
142
+
143
+ useEffect(() => {
144
+ if (reduceMotion || isHovered || isDragging) {
145
+ return;
146
+ }
147
+
148
+ const interval = window.setInterval(() => {
149
+ startTransition(() => {
150
+ setTransitionEnabled(true);
151
+ setTrackIndex((current) =>
152
+ current >= carouselLoopBoundaryIndex
153
+ ? carouselLoopBoundaryIndex
154
+ : current + 1
155
+ );
156
+ });
157
+ }, 2200);
158
+
159
+ return () => {
160
+ window.clearInterval(interval);
161
+ };
162
+ }, [carouselLoopBoundaryIndex, isDragging, isHovered, reduceMotion]);
163
+
164
+ useEffect(() => {
165
+ if (transitionEnabled) {
166
+ return;
167
+ }
168
+
169
+ if (typeof window === "undefined") {
170
+ return;
171
+ }
172
+
173
+ const frame = window.requestAnimationFrame(() => {
174
+ window.requestAnimationFrame(() => {
175
+ setTransitionEnabled(true);
176
+ });
177
+ });
178
+
179
+ return () => {
180
+ window.cancelAnimationFrame(frame);
181
+ };
182
+ }, [transitionEnabled]);
183
+
184
+ useEffect(() => {
185
+ if (isDragging) {
186
+ return;
187
+ }
188
+
189
+ if (trackIndex !== 0 && trackIndex !== carouselLoopBoundaryIndex) {
190
+ return;
191
+ }
192
+
193
+ const timer = window.setTimeout(() => {
194
+ setTransitionEnabled(false);
195
+ setTrackIndex(trackIndex === 0 ? carouselCardCount : 1);
196
+ }, carouselTransitionDurationMs);
197
+
198
+ return () => {
199
+ window.clearTimeout(timer);
200
+ };
201
+ }, [
202
+ carouselCardCount,
203
+ carouselLoopBoundaryIndex,
204
+ carouselTransitionDurationMs,
205
+ isDragging,
206
+ trackIndex,
207
+ ]);
208
+
209
+ const finishDrag = (shouldAdvance: boolean, direction: "next" | "prev" | null) => {
210
+ dragStartXRef.current = null;
211
+ dragOffsetRef.current = 0;
212
+ pointerIdRef.current = null;
213
+ setIsDragging(false);
214
+ setTransitionEnabled(true);
215
+ setDragOffset(0);
216
+
217
+ if (!shouldAdvance || !direction) {
218
+ return;
219
+ }
220
+
221
+ setTrackIndex((current) => {
222
+ if (direction === "next") {
223
+ return current >= carouselLoopBoundaryIndex
224
+ ? carouselLoopBoundaryIndex
225
+ : current + 1;
226
+ }
227
+
228
+ return current <= 0 ? 0 : current - 1;
229
+ });
230
+ };
231
+
232
+ const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
233
+ if (event.pointerType === "mouse" && event.button !== 0) {
234
+ return;
235
+ }
236
+
237
+ dragStartXRef.current = event.clientX;
238
+ dragOffsetRef.current = 0;
239
+ pointerIdRef.current = event.pointerId;
240
+ setIsDragging(true);
241
+ setTransitionEnabled(false);
242
+ setDragOffset(0);
243
+ event.currentTarget.setPointerCapture(event.pointerId);
244
+ };
245
+
246
+ const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
247
+ if (
248
+ !isDragging ||
249
+ dragStartXRef.current === null ||
250
+ pointerIdRef.current !== event.pointerId
251
+ ) {
252
+ return;
253
+ }
254
+
255
+ const nextOffset = event.clientX - dragStartXRef.current;
256
+ dragOffsetRef.current = nextOffset;
257
+ setDragOffset(nextOffset);
258
+ };
259
+
260
+ const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
261
+ if (!isDragging || pointerIdRef.current !== event.pointerId) {
262
+ return;
263
+ }
264
+
265
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
266
+ event.currentTarget.releasePointerCapture(event.pointerId);
267
+ }
268
+
269
+ const threshold = Math.min(88, slideSize * 0.16);
270
+ const shouldAdvance = Math.abs(dragOffsetRef.current) > threshold;
271
+ const direction =
272
+ dragOffsetRef.current < 0
273
+ ? "next"
274
+ : dragOffsetRef.current > 0
275
+ ? "prev"
276
+ : null;
277
+
278
+ finishDrag(shouldAdvance, direction);
279
+ };
280
+
281
return (
282
<section
283
aria-labelledby="landing-title"
@@ -72,42 +320,92 @@ export function LandingHero() {
320
To The Public Internet
321
</span>
322
</h1>
75
- <p className="mx-auto mt-5 max-w-2xl text-lg leading-8 text-text-muted">
76
- Portal turns localhost into a public HTTPS URL. No port forwarding,
77
- NAT setup, or DNS configuration.
78
- </p>
323
</div>
324
81
- <div
82
- className="relative mx-auto mt-10 w-full max-w-[520px] 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)",
86
- color: "var(--hero-terminal-foreground)",
87
- boxShadow: "0 30px 72px var(--hero-terminal-shadow)",
88
- }}
89
- >
90
- <div className="mb-5 flex min-w-0 items-center gap-3">
91
- <Terminal
92
- className="h-5 w-5 shrink-0"
93
- style={{ color: "var(--hero-terminal-accent)" }}
94
- />
95
- <h2
96
- id="tunnel-preview"
97
- className="min-w-0 text-xl font-bold tracking-tight sm:text-2xl"
325
+ <div className="relative mt-10 -mx-4 w-auto sm:-mx-6 md:-mx-8">
326
+ <div className="overflow-hidden border-b border-border/80 bg-transparent">
327
+ <div
328
+ className="relative mx-auto max-w-7xl px-3 py-6 sm:px-6 sm:py-8"
329
+ onMouseEnter={() => setIsHovered(true)}
330
+ onMouseLeave={() => setIsHovered(false)}
331
+ onFocusCapture={() => setIsHovered(true)}
332
+ onBlurCapture={() => setIsHovered(false)}
333
>
99
- Run this command
100
- </h2>
101
- </div>
334
+ <div className="pointer-events-none absolute inset-x-0 top-6 flex justify-center sm:top-8">
335
+ <div className="h-28 w-28 rounded-full bg-primary/16 blur-3xl dark:bg-primary/22" />
336
+ </div>
337
+ <div className="pointer-events-none absolute inset-y-0 left-0 z-40 w-12 bg-gradient-to-r from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
338
+ <div className="pointer-events-none absolute inset-y-0 right-0 z-40 w-12 bg-gradient-to-l from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
339
+
340
+ <div className="relative h-[328px] sm:h-[360px]">
341
+ <div
342
+ onPointerDown={handlePointerDown}
343
+ onPointerMove={handlePointerMove}
344
+ onPointerUp={handlePointerEnd}
345
+ onPointerCancel={handlePointerEnd}
346
+ className={`flex h-full items-start gap-4 px-1 pt-6 sm:px-4 sm:pt-8 ${
347
+ transitionEnabled && !isDragging
348
+ ? "transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
349
+ : "transition-none"
350
+ } ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
351
+ style={{
352
+ transform: `translateX(${trackTranslateX})`,
353
+ touchAction: "pan-y",
354
+ }}
355
+ >
356
+ {carouselSlides.map((card, index) => {
357
+ const distance = Math.abs(index - trackIndex);
358
+ const isActive = index === trackIndex;
359
103
- <TunnelCommandForm theme="terminal" mode="hero" />
360
+ return (
361
+ <article
362
+ key={`${card.key}-${index}`}
363
+ className={`relative h-[244px] shrink-0 overflow-hidden rounded-[1.65rem] border px-5 py-5 text-left transition-[opacity,transform,box-shadow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] sm:h-[268px] sm:px-7 sm:py-6 ${
364
+ isActive
365
+ ? "border-primary/24 bg-white/92 opacity-100 shadow-[0_24px_54px_rgba(15,23,42,0.08)] dark:bg-white/[0.08] dark:shadow-[0_26px_60px_rgba(0,0,0,0.22)]"
366
+ : distance === 1
367
+ ? "border-border/70 bg-background/78 opacity-62 shadow-[0_14px_32px_rgba(15,23,42,0.04)] dark:bg-white/[0.04]"
368
+ : "border-border/60 bg-background/70 opacity-30 shadow-none dark:bg-white/[0.03]"
369
+ } ${isActive ? "translate-y-0 scale-100" : "translate-y-5 scale-[0.95]"}`}
370
+ style={{ width: `${slideSize}px` }}
371
+ aria-hidden={!isActive}
372
+ >
373
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.12),transparent_36%)] dark:bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.14),transparent_36%)]" />
374
+ <div className="relative flex h-full flex-col">
375
+ <div className="flex items-center gap-4">
376
+ <span className="inline-flex rounded-full bg-primary/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
377
+ Portal
378
+ </span>
379
+ </div>
380
+ <div className="mt-10 space-y-3">
381
+ <h3 className="max-w-[12ch] text-[1.9rem] leading-[0.92] font-semibold tracking-tight text-foreground sm:text-[2.2rem]">
382
+ {card.title}
383
+ </h3>
384
+ <p className="max-w-[34ch] text-[0.98rem] leading-6 text-text-muted sm:text-[1rem]">
385
+ {card.description}
386
+ </p>
387
+ </div>
388
+ <div className="mt-auto pt-8">
389
+ <div className="h-px w-16 bg-gradient-to-r from-primary/55 to-transparent" />
390
+ </div>
391
+ </div>
392
+ </article>
393
+ );
394
+ })}
395
+ </div>
396
+ </div>
397
+ </div>
398
+ </div>
399
</div>
400
106
- <div className="relative mt-18 -mx-4 w-auto sm:mt-20 sm:-mx-6 md:-mx-8">
401
+ <div className="relative -mx-4 w-auto sm:-mx-6 md:-mx-8">
402
<div className="overflow-hidden border-t border-border/80 bg-border/70">
403
<div className="grid gap-px sm:grid-cols-2 lg:grid-cols-3">
404
<div className="flex min-h-[184px] bg-background/88 p-6 text-left sm:min-h-[196px] sm:p-7">
405
<div className="space-y-2">
406
+ <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
407
+ Core features
408
+ </p>
409
<h2 className="whitespace-nowrap text-[1.2rem] font-semibold tracking-tight text-foreground sm:text-[1.32rem] sm:leading-none">
410
Make localhost public
411
</h2>
@@ -135,6 +433,31 @@ export function LandingHero() {
433
</div>
434
</div>
435
</div>
436
+
437
+ <div id="quick-start" className="relative mt-8 scroll-mt-24 sm:mt-10">
438
+ <div className="mx-auto w-full max-w-6xl text-left">
439
+ <div className="space-y-2">
440
+ <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
441
+ Quick Start
442
+ </p>
443
+ <h2 className="text-3xl font-semibold tracking-tight text-foreground">
444
+ Expose service
445
+ </h2>
446
+ </div>
447
+
448
+ <div
449
+ className="relative mx-auto mt-4 w-full max-w-[520px] rounded-[1.75rem] border px-4 py-5 sm:px-5 sm:py-6"
450
+ style={{
451
+ background: "var(--hero-terminal-bg)",
452
+ borderColor: "var(--hero-terminal-border)",
453
+ color: "var(--hero-terminal-foreground)",
454
+ boxShadow: "0 30px 72px var(--hero-terminal-shadow)",
455
+ }}
456
+ >
457
+ <TunnelCommandForm theme="terminal" mode="hero" />
458
+ </div>
459
+ </div>
460
+ </div>
461
</section>
462
);
463
}
frontend/src/components/ServerListView.tsx
+37
-33
@@ -4,6 +4,7 @@ import { LandingHero } from "@/components/LandingHero";
4
import { SearchBar } from "@/components/SearchBar";
5
import { ServerCard } from "@/components/ServerCard";
6
import { TagCombobox } from "@/components/TagCombobox";
7
+import { TunnelCommandModal } from "@/components/TunnelCommandModal";
8
import type { ClientServer } from "@/hooks/useServerList";
9
import type { AdminServer, ApprovalMode, UDPSettings } from "@/hooks/useAdmin";
10
import type { SortOption, StatusFilter } from "@/types/filters";
@@ -655,16 +656,19 @@ export function ServerListView({
656
aria-labelledby="live-servers-title"
657
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"
658
>
658
- <div className="space-y-2">
659
- <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
660
- Live apps
661
- </p>
662
- <h2
663
- id="live-servers-title"
664
- className="text-3xl font-semibold tracking-tight text-foreground"
665
- >
666
- Browse live apps
667
- </h2>
659
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
660
+ <div className="space-y-2">
661
+ <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
662
+ Live apps
663
+ </p>
664
+ <h2
665
+ id="live-servers-title"
666
+ className="text-3xl font-semibold tracking-tight text-foreground"
667
+ >
668
+ Browse live apps
669
+ </h2>
670
+ </div>
671
+ <TunnelCommandModal />
672
</div>
673
674
{serverRows.length > 0 ? (
@@ -693,35 +697,35 @@ export function ServerListView({
697
aria-labelledby="official-registry-title"
698
className="scroll-mt-24 px-4 py-8 sm:px-6 md:px-8"
699
>
696
- <div className="rounded-[1.75rem] border border-border/80 bg-secondary/35 p-5 sm:p-6">
697
- <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
698
- <div className="space-y-1.5">
699
- <h2
700
- id="official-registry-title"
701
- className="text-2xl font-semibold tracking-tight text-foreground"
702
- >
703
- Official registry
704
- </h2>
705
- <p className="max-w-2xl text-sm leading-6 text-text-muted">
706
- Trusted public relays provided by the community.
707
- </p>
708
- </div>
709
- <a
710
- href={OFFICIAL_REGISTRY_SOURCE_URL}
711
- target="_blank"
712
- rel="noopener noreferrer"
713
- 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"
700
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
701
+ <div className="space-y-2">
702
+ <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
703
+ Official registry
704
+ </p>
705
+ <h2
706
+ id="official-registry-title"
707
+ className="text-3xl font-semibold tracking-tight text-foreground"
708
>
715
- Open registry.json
716
- </a>
709
+ Public relays
710
+ </h2>
711
</div>
712
+ <a
713
+ href={OFFICIAL_REGISTRY_SOURCE_URL}
714
+ target="_blank"
715
+ rel="noopener noreferrer"
716
+ 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"
717
+ >
718
+ Open registry.json
719
+ </a>
720
+ </div>
721
722
+ <div className="mt-6 rounded-[1.75rem] border border-border/80 bg-secondary/35 p-5 sm:p-6">
723
{officialRegistryRelays === null ? (
720
- <p className="mt-6 text-sm text-text-muted">
724
+ <p className="text-sm text-text-muted">
725
Loading official registry...
726
</p>
727
) : officialRegistryAvailable ? (
724
- <div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
728
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
729
{officialRegistryRelays.map((relay) => {
730
return (
731
<div
@@ -752,7 +756,7 @@ export function ServerListView({
756
})}
757
</div>
758
) : (
755
- <p className="mt-6 text-sm text-text-muted">
759
+ <p className="text-sm text-text-muted">
760
Registry entries are unavailable right now.
761
</p>
762
)}
frontend/src/components/ThemeToggleButton.tsx
+2
-2
@@ -14,11 +14,11 @@ export function ThemeToggleButton({ className }: ThemeToggleButtonProps) {
14
return (
15
<Button
16
type="button"
17
- variant="outline"
17
+ variant="ghost"
18
size="icon"
19
onClick={toggleTheme}
20
className={clsx(
21
- "h-11 w-11 cursor-pointer rounded-full border-border bg-card/95 text-foreground shadow-none hover:bg-secondary",
21
+ "h-11 w-11 cursor-pointer rounded-full text-foreground shadow-none hover:bg-transparent hover:-translate-y-0.5 hover:text-primary",
22
className
23
)}
24
aria-label={`Switch to ${nextTheme} theme`}
frontend/src/components/TunnelCommandForm.tsx
+237
-182
@@ -17,6 +17,7 @@ import {
17
buildTunnelDisplayCommand,
18
buildTunnelPreviewURL,
19
buildTunnelStatusHostname,
20
+ normalizeTunnelCommandName,
21
normalizeAbsoluteHTTPURL,
22
type TunnelCommandOS,
23
} from "@/lib/tunnelCommand";
@@ -81,6 +82,16 @@ function nextTunnelNameShuffleKey(): string {
82
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
83
}
84
85
+function splitDisplayCommand(command: string, os: TunnelCommandOS) {
86
+ const lines = command.split("\n");
87
+ const installLineCount = os === "windows" ? 2 : 1;
88
+
89
+ return {
90
+ installBlock: lines.slice(0, installLineCount).join("\n"),
91
+ runBlock: lines.slice(installLineCount).join("\n"),
92
+ };
93
+}
94
+
95
export function TunnelCommandForm({
96
className,
97
theme = "light",
@@ -98,14 +109,12 @@ function HeroTunnelCommandForm({
109
theme,
110
}: Required<Pick<TunnelCommandFormProps, "theme">> &
111
Pick<TunnelCommandFormProps, "className">) {
101
- const inputId = useId();
112
const isTerminal = theme === "terminal";
113
const currentOrigin = useMemo(readCurrentOrigin, []);
114
const nameSeed = useMemo(readTunnelNameSeed, []);
115
116
const [target, setTarget] = useState(DEFAULT_HOST);
117
const [name, setName] = useState("");
108
- const [isAutoName, setIsAutoName] = useState(true);
118
const [nameShuffleKey, setNameShuffleKey] = useState("default");
119
const [copied, setCopied] = useState(false);
120
const [os, setOs] = useState<TunnelCommandOS>("unix");
@@ -119,7 +128,11 @@ function HeroTunnelCommandForm({
128
() => buildDefaultTunnelName(target, resolvedNameSeed),
129
[resolvedNameSeed, target]
130
);
122
- const effectiveName = isAutoName ? generatedName : name;
131
+ const normalizedName = useMemo(
132
+ () => normalizeTunnelCommandName(name),
133
+ [name]
134
+ );
135
+ const effectiveName = normalizedName === "" ? generatedName : normalizedName;
136
const commandOptions = useMemo(
137
() => ({
138
currentOrigin,
@@ -143,6 +156,10 @@ function HeroTunnelCommandForm({
156
() => buildTunnelDisplayCommand(commandOptions),
157
[commandOptions]
158
);
159
+ const { installBlock, runBlock } = useMemo(
160
+ () => splitDisplayCommand(displayCommand, os),
161
+ [displayCommand, os]
162
+ );
163
const previewURL = useMemo(
164
() => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed),
165
[currentOrigin, effectiveName, nameSeed, target]
@@ -219,20 +236,11 @@ function HeroTunnelCommandForm({
236
};
237
238
const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
222
- const next = event.target.value;
223
- if (next.trim() === "") {
224
- setName("");
225
- setIsAutoName(true);
226
- return;
227
- }
228
-
229
- setName(next);
230
- setIsAutoName(false);
239
+ setName(event.target.value);
240
};
241
242
const handleShuffleName = () => {
243
setName("");
235
- setIsAutoName(true);
244
setNameShuffleKey(nextTunnelNameShuffleKey());
245
};
246
@@ -244,196 +252,220 @@ function HeroTunnelCommandForm({
252
const tunnelStatusHeadline = {
253
alive: "This URL is live now",
254
registered: "URL reserved",
247
- waiting: "Waiting",
255
+ waiting: "Waiting for Connection",
256
}[tunnelStatus];
257
const isPreviewURLDisabled = tunnelStatus === "waiting";
258
const heroSectionLabelClass = cn(
251
- "text-xs font-semibold uppercase tracking-[0.24em]",
252
- isTerminal ? "text-slate-400" : "text-text-muted"
259
+ "text-[13px] font-semibold tracking-[0.04em] sm:text-sm",
260
+ isTerminal ? "text-slate-100" : "text-foreground/85"
261
+ );
262
+ const heroCommandTitleClass = cn(
263
+ "min-w-0 text-[15px] font-bold tracking-tight sm:text-base",
264
+ isTerminal ? "text-slate-50" : "text-foreground"
265
);
266
const heroURLClass = cn(
267
"block overflow-x-auto whitespace-nowrap font-mono text-[15px] font-medium sm:text-base",
268
isTerminal ? "text-sky-300" : "text-primary"
269
);
258
- const heroInputClass = cn(
259
- "h-10 rounded-lg border px-3 text-sm shadow-none",
270
+ const platformButtonGroupClass = cn(
271
+ "flex shrink-0 rounded-lg border p-0.5",
272
isTerminal
261
- ? "border-white/6 bg-white/[0.035] text-slate-200 placeholder:text-slate-500"
262
- : "border-border bg-white"
273
+ ? "border-white/6 bg-white/[0.035]"
274
+ : "border-border bg-border"
275
);
264
- const nameInputValue = isAutoName ? generatedName : name;
265
- const shuffleButtonClass = cn(
266
- "inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border px-0 text-xs font-semibold transition-colors",
276
+ const platformButtonClass = (selected: boolean) =>
277
+ cn(
278
+ "min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
279
+ selected
280
+ ? isTerminal
281
+ ? "bg-white/[0.08] text-slate-200"
282
+ : "bg-background text-foreground/85"
283
+ : isTerminal
284
+ ? "text-slate-500 hover:text-slate-300"
285
+ : "text-text-muted hover:text-foreground"
286
+ );
287
+ const heroControlLabelClass = cn(
288
+ "shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em]",
289
+ isTerminal ? "text-slate-500" : "text-text-muted"
290
+ );
291
+ const heroControlInputClass = cn(
292
+ "h-auto border-0 bg-transparent px-0 py-0 text-[13px] shadow-none focus-visible:ring-0",
293
isTerminal
268
- ? "border-white/6 bg-white/[0.035] text-slate-400 hover:bg-white/5 hover:text-white"
269
- : "border-border bg-white text-text-muted hover:text-foreground"
294
+ ? "text-slate-200 placeholder:text-slate-600"
295
+ : "text-foreground/85 placeholder:text-muted-foreground"
296
+ );
297
+ const heroShuffleButtonClass = cn(
298
+ "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors",
299
+ isTerminal
300
+ ? "text-slate-500 hover:bg-white/[0.06] hover:text-slate-200"
301
+ : "text-text-muted hover:bg-foreground/5 hover:text-foreground"
302
);
271
-
303
return (
273
- <div className={cn("space-y-4", className)}>
274
- <div className="space-y-4">
275
- <label className={heroSectionLabelClass}>Command</label>
276
- <div className="relative">
277
- <pre
278
- className={cn(
279
- "min-h-[148px] overflow-x-auto whitespace-pre-wrap break-all rounded-xl border px-4 py-4 font-mono text-sm leading-7",
280
- isTerminal
281
- ? "border-primary/20 bg-black/50 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
282
- : "bg-border text-foreground"
283
- )}
284
- >
285
- {displayCommand}
286
- </pre>
287
- </div>
288
-
289
- <button
290
- type="button"
291
- onClick={handleCopy}
292
- className={cn(
293
- "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",
294
- copied
295
- ? "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)]"
296
- : "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"
297
- )}
298
- aria-label="Copy command"
299
- >
300
- {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
301
- <span>{copied ? "Copied" : "Copy command"}</span>
302
- </button>
303
-
304
- <div className="space-y-1.5 pt-0.5">
305
- <p className={heroSectionLabelClass}>Public URL</p>
306
- <div
307
- className={cn(
308
- "space-y-3 rounded-xl border px-3.5 py-3",
309
- isTerminal
310
- ? "border-white/8 bg-white/[0.045]"
311
- : "border-border bg-white"
312
- )}
313
- >
314
- <div
304
+ <div className={cn("space-y-5", className)}>
305
+ <div className="space-y-2">
306
+ <div className="space-y-1.5">
307
+ <p className={heroSectionLabelClass}>
308
+ 1. Start your local app
309
+ <span
310
className={cn(
316
- "flex items-center gap-2 text-[13px] font-semibold",
317
- isTerminal ? "text-slate-300" : "text-foreground"
311
+ "ml-1 normal-case tracking-normal",
312
+ isTerminal ? "text-slate-400" : "text-text-muted"
313
)}
314
>
315
+ (e.g.
316
<span
321
- className={cn("h-2 w-2 rounded-full", tunnelStatusTone)}
322
- aria-hidden="true"
323
- />
324
- <span>{tunnelStatusHeadline}</span>
325
- </div>
326
- {isPreviewURLDisabled ? (
327
- <span
328
- aria-disabled="true"
329
- className={cn(heroURLClass, "cursor-not-allowed opacity-70")}
317
+ className={cn(
318
+ "mx-1 font-mono",
319
+ isTerminal ? "text-slate-200" : "text-foreground"
320
+ )}
321
>
331
- {previewURL}
322
+ localhost:3000
323
</span>
333
- ) : (
334
- <a
335
- href={previewURL}
336
- target="_blank"
337
- rel="noopener noreferrer"
338
- className={cn(heroURLClass, "underline-offset-4 hover:underline")}
339
- >
340
- {previewURL}
341
- </a>
342
- )}
343
- </div>
324
+ )
325
+ </span>
326
+ </p>
327
</div>
328
</div>
329
347
- <div
348
- className={cn(
349
- "space-y-1.5 border-t pt-3",
350
- isTerminal ? "border-white/6" : "border-border/80"
351
- )}
352
- >
353
- <div
354
- className={cn(
355
- "grid grid-cols-[62px_minmax(0,1fr)_140px] gap-2 px-1 text-[9px] font-semibold uppercase tracking-[0.16em]",
356
- isTerminal ? "text-slate-500" : "text-text-muted"
357
- )}
358
- >
359
- <span>Port</span>
360
- <span>Name</span>
361
- <span>Platform</span>
330
+ <div className="space-y-3">
331
+ <div className="flex flex-wrap items-center justify-between gap-3">
332
+ <div className="flex min-w-0 items-center gap-3">
333
+ <span
334
+ aria-hidden="true"
335
+ className={cn(
336
+ "shrink-0 font-mono text-lg leading-none",
337
+ !isTerminal && "text-primary"
338
+ )}
339
+ style={isTerminal ? { color: "var(--hero-terminal-accent)" } : undefined}
340
+ >
341
+ {">"}
342
+ </span>
343
+ <h3 id="tunnel-preview" className={heroCommandTitleClass}>
344
+ 2. Run this command
345
+ </h3>
346
+ </div>
347
+ <div className={platformButtonGroupClass}>
348
+ <button
349
+ type="button"
350
+ onClick={() => setOs("unix")}
351
+ className={platformButtonClass(os === "unix")}
352
+ >
353
+ Linux
354
+ </button>
355
+ <button
356
+ type="button"
357
+ onClick={() => setOs("windows")}
358
+ className={platformButtonClass(os === "windows")}
359
+ >
360
+ Windows
361
+ </button>
362
+ </div>
363
</div>
363
- <div className="grid grid-cols-[62px_minmax(0,1fr)_140px] items-center gap-2">
364
- <Input
365
- id={`${inputId}-host`}
366
- type="text"
367
- value={target}
368
- onChange={(event) => setTarget(event.target.value)}
369
- placeholder={DEFAULT_HOST}
370
- aria-label="Port"
371
- className={cn(heroInputClass, "w-full px-2.5 text-[13px] font-mono")}
372
- />
373
-
374
- <div className="flex min-w-0 flex-1 items-center gap-2">
364
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 sm:flex-nowrap">
365
+ <div className="flex shrink-0 items-center gap-2">
366
+ <span className={heroControlLabelClass}>Port</span>
367
+ <Input
368
+ type="text"
369
+ value={target}
370
+ onChange={(event) => setTarget(event.target.value)}
371
+ placeholder={DEFAULT_HOST}
372
+ aria-label="Local port or address"
373
+ className={cn(heroControlInputClass, "w-[4.75rem] font-mono")}
374
+ />
375
+ </div>
376
+ <div className="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-[22rem]">
377
+ <span className={heroControlLabelClass}>Name</span>
378
<Input
376
- id={`${inputId}-name`}
379
type="text"
378
- value={nameInputValue}
380
+ value={name}
381
onChange={handleNameChange}
382
+ placeholder={generatedName}
383
aria-label="Public name"
381
- className={cn(heroInputClass, "min-w-0 flex-1 px-2.5 text-[13px]")}
384
+ className={cn(heroControlInputClass, "min-w-0 flex-1")}
385
/>
386
<button
387
type="button"
388
onClick={handleShuffleName}
386
- className={shuffleButtonClass}
389
+ className={heroShuffleButtonClass}
390
aria-label="Shuffle public name"
391
title="Shuffle public name"
392
>
393
<RefreshCw className="h-4 w-4" aria-hidden="true" />
394
</button>
395
</div>
396
+ </div>
397
+ <div
398
+ className={cn(
399
+ "relative min-h-[148px] rounded-xl border px-4 py-4 pr-14 font-mono text-sm leading-7",
400
+ isTerminal
401
+ ? "border-white/10 bg-black/55 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
402
+ : "border-border/80 bg-border/90 text-foreground"
403
+ )}
404
+ >
405
+ <button
406
+ type="button"
407
+ onClick={handleCopy}
408
+ className={cn(
409
+ "absolute top-4 right-4 inline-flex h-8 w-8 items-center justify-center rounded-lg transition-colors",
410
+ isTerminal
411
+ ? "text-emerald-300/75 hover:bg-emerald-400/10 hover:text-emerald-200"
412
+ : "text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700"
413
+ )}
414
+ aria-label="Copy command"
415
+ title={copied ? "Copied" : "Copy"}
416
+ >
417
+ {copied ? (
418
+ <Check className="h-4 w-4" />
419
+ ) : (
420
+ <Copy className="h-4 w-4" />
421
+ )}
422
+ </button>
423
+ <pre className="overflow-x-auto whitespace-pre-wrap break-all">
424
+ <span className="block">{installBlock}</span>
425
+ <span className="mt-2 block">{runBlock}</span>
426
+ </pre>
427
+ </div>
428
+ </div>
429
430
+ <div className="space-y-2 pt-1">
431
+ <p className={heroSectionLabelClass}>3. Open this public URL</p>
432
+ <div
433
+ className={cn(
434
+ "space-y-3 rounded-xl border px-3.5 py-3",
435
+ isTerminal
436
+ ? "border-white/8 bg-white/[0.045]"
437
+ : "border-border bg-white"
438
+ )}
439
+ >
440
<div
441
className={cn(
396
- "flex w-full shrink-0 rounded-lg border p-0.5",
397
- isTerminal
398
- ? "border-white/6 bg-white/[0.035]"
399
- : "border-border bg-border"
442
+ "flex items-center gap-2 text-[13px] font-semibold",
443
+ isTerminal ? "text-slate-300" : "text-foreground"
444
)}
445
>
402
- <div className="flex w-full">
403
- <button
404
- type="button"
405
- onClick={() => setOs("unix")}
406
- className={cn(
407
- "flex-1 whitespace-nowrap rounded-md px-1.5 py-1.5 text-[11px] font-semibold transition-colors",
408
- os === "unix"
409
- ? isTerminal
410
- ? "bg-white text-slate-950 shadow-sm"
411
- : "bg-background text-foreground shadow-sm"
412
- : isTerminal
413
- ? "text-slate-500 hover:text-white"
414
- : "text-text-muted hover:text-foreground"
415
- )}
416
- >
417
- Linux
418
- </button>
419
- <button
420
- type="button"
421
- onClick={() => setOs("windows")}
422
- className={cn(
423
- "flex-1 whitespace-nowrap rounded-md px-1.5 py-1.5 text-[11px] font-semibold transition-colors",
424
- os === "windows"
425
- ? isTerminal
426
- ? "bg-white text-slate-950 shadow-sm"
427
- : "bg-background text-foreground shadow-sm"
428
- : isTerminal
429
- ? "text-slate-500 hover:text-white"
430
- : "text-text-muted hover:text-foreground"
431
- )}
432
- >
433
- Windows
434
- </button>
435
- </div>
446
+ <span
447
+ className={cn("h-2 w-2 rounded-full", tunnelStatusTone)}
448
+ aria-hidden="true"
449
+ />
450
+ <span>{tunnelStatusHeadline}</span>
451
</div>
452
+ {isPreviewURLDisabled ? (
453
+ <span
454
+ aria-disabled="true"
455
+ className={cn(heroURLClass, "cursor-not-allowed opacity-70")}
456
+ >
457
+ {previewURL}
458
+ </span>
459
+ ) : (
460
+ <a
461
+ href={previewURL}
462
+ target="_blank"
463
+ rel="noopener noreferrer"
464
+ className={cn(heroURLClass, "underline-offset-4 hover:underline")}
465
+ >
466
+ {previewURL}
467
+ </a>
468
+ )}
469
</div>
470
</div>
471
</div>
@@ -452,7 +484,6 @@ function FullTunnelCommandForm({
484
485
const [target, setTarget] = useState(DEFAULT_HOST);
486
const [name, setName] = useState("");
455
- const [isAutoName, setIsAutoName] = useState(true);
487
const [nameShuffleKey, setNameShuffleKey] = useState("default");
488
const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
489
const [defaultRelays, setDefaultRelays] = useState(true);
@@ -471,7 +502,11 @@ function FullTunnelCommandForm({
502
() => buildDefaultTunnelName(target, resolvedNameSeed),
503
[resolvedNameSeed, target]
504
);
474
- const effectiveName = isAutoName ? generatedName : name;
505
+ const normalizedName = useMemo(
506
+ () => normalizeTunnelCommandName(name),
507
+ [name]
508
+ );
509
+ const effectiveName = normalizedName === "" ? generatedName : normalizedName;
510
const normalizedThumbnailURL = useMemo(
511
() => normalizeAbsoluteHTTPURL(thumbnailURL),
512
[thumbnailURL]
@@ -517,6 +552,10 @@ function FullTunnelCommandForm({
552
() => buildTunnelDisplayCommand(commandOptions),
553
[commandOptions]
554
);
555
+ const { installBlock, runBlock } = useMemo(
556
+ () => splitDisplayCommand(displayCommand, os),
557
+ [displayCommand, os]
558
+ );
559
560
useEffect(() => {
561
if (!copied) {
@@ -573,24 +612,14 @@ function FullTunnelCommandForm({
612
};
613
614
const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
576
- const next = event.target.value;
577
- if (next.trim() === "") {
578
- setName("");
579
- setIsAutoName(true);
580
- return;
581
- }
582
-
583
- setName(next);
584
- setIsAutoName(false);
615
+ setName(event.target.value);
616
};
617
618
const handleShuffleName = () => {
619
setName("");
589
- setIsAutoName(true);
620
setNameShuffleKey(nextTunnelNameShuffleKey());
621
};
622
593
- const nameInputValue = isAutoName ? generatedName : name;
623
const shuffleButtonClass = cn(
624
"inline-flex h-12 shrink-0 items-center justify-center rounded-lg border px-3 text-xs font-semibold transition-colors",
625
isTerminal
@@ -601,6 +630,22 @@ function FullTunnelCommandForm({
630
return (
631
<div className={cn("space-y-5", className)}>
632
<div className="space-y-2">
633
+ <p
634
+ className={cn(
635
+ "text-sm leading-6",
636
+ isTerminal ? "text-slate-300" : "text-text-muted"
637
+ )}
638
+ >
639
+ Start your local app, then point Portal at it with a port like
640
+ <span className={cn("mx-1 font-mono", isTerminal ? "text-white" : "text-foreground")}>
641
+ 3000
642
+ </span>
643
+ or an address like
644
+ <span className={cn("mx-1 font-mono", isTerminal ? "text-white" : "text-foreground")}>
645
+ localhost:3000
646
+ </span>
647
+ .
648
+ </p>
649
<label
650
htmlFor={`${inputId}-host`}
651
className={cn(
@@ -608,7 +653,7 @@ function FullTunnelCommandForm({
653
isTerminal ? "text-slate-200" : "text-foreground"
654
)}
655
>
611
- Host
656
+ Local App
657
</label>
658
<Input
659
id={`${inputId}-host`}
@@ -623,6 +668,14 @@ function FullTunnelCommandForm({
668
: "border-border bg-white"
669
)}
670
/>
671
+ <p
672
+ className={cn(
673
+ "text-xs",
674
+ isTerminal ? "text-slate-400" : "text-muted-foreground"
675
+ )}
676
+ >
677
+ Use a local port or address that is already running.
678
+ </p>
679
</div>
680
681
<div className="space-y-2">
@@ -639,8 +692,9 @@ function FullTunnelCommandForm({
692
<Input
693
id={`${inputId}-name`}
694
type="text"
642
- value={nameInputValue}
695
+ value={name}
696
onChange={handleNameChange}
697
+ placeholder={generatedName}
698
className={cn(
699
"h-12 flex-1 rounded-xl",
700
isTerminal
@@ -849,10 +903,10 @@ function FullTunnelCommandForm({
903
"flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
904
os === "unix"
905
? isTerminal
852
- ? "bg-white text-slate-950 shadow-sm"
853
- : "bg-background text-foreground shadow-sm"
906
+ ? "bg-white/[0.08] text-slate-200"
907
+ : "bg-background text-foreground/85"
908
: isTerminal
855
- ? "text-slate-400 hover:text-white"
909
+ ? "text-slate-400 hover:text-slate-300"
910
: "text-text-muted hover:text-foreground"
911
)}
912
>
@@ -865,10 +919,10 @@ function FullTunnelCommandForm({
919
"flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
920
os === "windows"
921
? isTerminal
868
- ? "bg-white text-slate-950 shadow-sm"
869
- : "bg-background text-foreground shadow-sm"
922
+ ? "bg-white/[0.08] text-slate-200"
923
+ : "bg-background text-foreground/85"
924
: isTerminal
871
- ? "text-slate-400 hover:text-white"
925
+ ? "text-slate-400 hover:text-slate-300"
926
: "text-text-muted hover:text-foreground"
927
)}
928
>
@@ -895,7 +949,8 @@ function FullTunnelCommandForm({
949
: "bg-border text-foreground"
950
)}
951
>
898
- {displayCommand}
952
+ <span className="block">{installBlock}</span>
953
+ <span className="mt-2 block">{runBlock}</span>
954
</pre>
955
<button
956
type="button"
frontend/src/components/TunnelCommandModal.tsx
+16
-16
@@ -1,8 +1,7 @@
1
-import type { ReactNode } from "react";
2
-import { Terminal } from "lucide-react";
1
import {
2
Dialog,
3
DialogContent,
4
+ DialogDescription,
5
DialogHeader,
6
DialogTitle,
7
DialogTrigger,
@@ -10,26 +9,27 @@ import {
9
import { Button } from "@/components/ui/button";
10
import { TunnelCommandForm } from "@/components/TunnelCommandForm";
11
13
-interface TunnelCommandModalProps {
14
- trigger?: ReactNode;
15
-}
16
-
17
-export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
12
+export function TunnelCommandModal() {
13
return (
14
<Dialog>
15
<DialogTrigger asChild>
21
- {trigger || (
22
- <Button className="cursor-pointer">
23
- <span className="truncate">Add Your Server</span>
24
- </Button>
25
- )}
16
+ <Button
17
+ type="button"
18
+ className="h-10 cursor-pointer rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary shadow-none transition-colors hover:bg-primary/20"
19
+ >
20
+ Add Your Server
21
+ </Button>
22
</DialogTrigger>
23
<DialogContent className="sm:max-w-[560px] max-h-[85vh] overflow-y-auto rounded-[1.5rem] border border-border bg-card p-0">
24
<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>
25
+ <DialogTitle className="text-xl font-bold">Add Your Server</DialogTitle>
26
+ <DialogDescription className="pt-1 leading-6">
27
+ Start your local app, for example on
28
+ <span className="mx-1 font-mono text-foreground">
29
+ localhost:3000
30
+ </span>
31
+ , then copy and run the generated command.
32
+ </DialogDescription>
33
</DialogHeader>
34
35
<div className="px-5 pb-5 pt-4">
frontend/src/lib/tunnelCommand.ts
+6
-1
@@ -1,6 +1,7 @@
1
import { API_PATHS } from "@/lib/apiPaths";
2
import {
3
buildDefaultExposeName,
4
+ normalizeExposeName,
5
resolveExposeName,
6
} from "../../../utils/exposeName";
7
@@ -26,6 +27,10 @@ export function buildDefaultTunnelName(
27
return buildDefaultExposeName(target, nameSeed);
28
}
29
30
+export function normalizeTunnelCommandName(value: string): string {
31
+ return normalizeExposeName(value);
32
+}
33
+
34
export function buildTunnelCommand({
35
currentOrigin,
36
defaultRelays,
@@ -267,5 +272,5 @@ function joinTunnelDisplayCommand(
272
exposeOptions.slice(relayIndex).join(" "),
273
];
274
270
- return [installLine, ...exposeLines].join("\n");
275
+ return [installLine, exposeLines.join("\n")].join("\n");
276
}
frontend/src/pages/ServerList.tsx
+5
-1
@@ -19,7 +19,11 @@ function readLandingPageEnabled(doc?: Document): boolean {
19
?.content.trim()
20
.toLowerCase() || "";
21
22
- return value !== "false" && value !== "0" && value !== "no";
22
+ if (value === "" || value === "[%landing_page_enabled%]") {
23
+ return true;
24
+ }
25
+
26
+ return value === "true" || value === "1" || value === "yes";
27
}
28
29
export function ServerList() {
utils/exposeName.ts
+53
-7
@@ -60,13 +60,20 @@ export function buildDefaultExposeName(
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);
63
+ const cleaned = sanitizeExposeNameInput(value);
64
+ if (cleaned === "") {
65
+ return "";
66
+ }
67
+
68
+ if (/^[a-z0-9-]+$/.test(cleaned)) {
69
+ return cleaned.slice(0, 63);
70
+ }
71
+
72
+ const ascii = toASCIILabel(cleaned);
73
+ if (ascii === "" || ascii.length > 63) {
74
+ return "";
75
+ }
76
+ return ascii;
77
}
78
79
export function normalizeExposeTarget(raw: string): string {
@@ -116,6 +123,45 @@ function normalizeSeed(clientSeed: string): string {
123
return trimmed;
124
}
125
126
+function sanitizeExposeNameInput(value: string): string {
127
+ const input = value.trim().toLowerCase().normalize("NFC");
128
+ if (input === "") {
129
+ return "";
130
+ }
131
+
132
+ let output = "";
133
+ let previousHyphen = false;
134
+
135
+ for (const char of input) {
136
+ if (char === "-" || /[\p{L}\p{N}]/u.test(char)) {
137
+ output += char;
138
+ previousHyphen = false;
139
+ continue;
140
+ }
141
+
142
+ if (!previousHyphen) {
143
+ output += "-";
144
+ previousHyphen = true;
145
+ }
146
+ }
147
+
148
+ return output.replace(/^-+|-+$/g, "");
149
+}
150
+
151
+function toASCIILabel(label: string): string {
152
+ const suffix = ".example.test";
153
+
154
+ try {
155
+ const hostname = new URL(`https://${label}${suffix}`).hostname;
156
+ if (!hostname.endsWith(suffix)) {
157
+ return "";
158
+ }
159
+ return hostname.slice(0, -suffix.length);
160
+ } catch {
161
+ return "";
162
+ }
163
+}
164
+
165
function pickNameIndexes(input: string): [number, number, number] {
166
const [first, second, third] = hashBytes(input);
167
return [
utils/utils.go
+48
-1
@@ -14,6 +14,9 @@ import (
14
"net/url"
15
"strings"
16
"time"
17
+ "unicode"
18
+
19
+ "golang.org/x/net/idna"
20
)
21
22
// Input parsing and normalization.
@@ -57,10 +60,18 @@ func ParseCIDRs(raw string) ([]*net.IPNet, error) {
60
}
61
62
func NormalizeDNSLabel(raw string) (string, error) {
60
- label := NormalizeHostname(raw)
63
+ label := sanitizeDNSLabelInput(raw)
64
if label == "" {
65
return "", errors.New("name is required")
66
}
67
+
68
+ if !isPlainDNSLabel(label) {
69
+ ascii, err := idna.Lookup.ToASCII(label)
70
+ if err != nil {
71
+ return "", errors.New("name is invalid")
72
+ }
73
+ label = NormalizeHostname(ascii)
74
+ }
75
if strings.Contains(label, ".") {
76
return "", errors.New("name must be a single dns label")
77
}
@@ -79,6 +90,42 @@ func NormalizeDNSLabel(raw string) (string, error) {
90
return label, nil
91
}
92
93
+func sanitizeDNSLabelInput(raw string) string {
94
+ input := strings.TrimSpace(strings.ToLower(raw))
95
+ if input == "" {
96
+ return ""
97
+ }
98
+
99
+ var b strings.Builder
100
+ b.Grow(len(input))
101
+ previousHyphen := false
102
+
103
+ for _, r := range input {
104
+ if r == '-' || unicode.IsLetter(r) || unicode.IsDigit(r) {
105
+ b.WriteRune(r)
106
+ previousHyphen = false
107
+ continue
108
+ }
109
+ if previousHyphen {
110
+ continue
111
+ }
112
+ b.WriteByte('-')
113
+ previousHyphen = true
114
+ }
115
+
116
+ return strings.Trim(b.String(), "-")
117
+}
118
+
119
+func isPlainDNSLabel(label string) bool {
120
+ for _, r := range label {
121
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
122
+ continue
123
+ }
124
+ return false
125
+ }
126
+ return true
127
+}
128
+
129
func NormalizeRelayURL(raw string) (string, error) {
130
trimmed := strings.TrimSpace(raw)
131
if trimmed == "" {