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 == "" {