remove og image

rabbitprincess committed Mar 21, 2026 at 23:48 UTC 6ea9acfe2f00e4aa58d8129e12bf74351490c7a2
6 files changed +687 -613
cmd/relay-server/frontend.go
+2 -11
@@ -173,7 +173,7 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter) {
173
174 htmlContent := string(f.cachedPortalHTML)
175 htmlContent = f.injectServerData(htmlContent)
176 - htmlContent = f.injectOGMetadata(htmlContent, "", "", "")
176 + htmlContent = f.injectOGMetadata(htmlContent, "", "")
177
178 w.Header().Set("Content-Type", "text/html; charset=utf-8")
179 w.Header().Set("Cache-Control", "no-cache, must-revalidate")
@@ -217,25 +217,17 @@ func (f *Frontend) serveTunnelStatus(w http.ResponseWriter, r *http.Request) {
217 utils.WriteAPIData(w, http.StatusOK, resp)
218 }
219
220 -func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL string) string {
220 +func (f *Frontend) injectOGMetadata(htmlContent, title, description string) string {
221 if title == "" {
222 title = "Portal Proxy Gateway"
223 }
224 if description == "" {
225 description = "Transform your local services into web-accessible endpoints. Instant access from anywhere."
226 }
227 - if imageURL == "" {
228 - base := strings.TrimSuffix(f.server.PortalURL(), "/")
229 - if !strings.HasPrefix(base, "http") {
230 - base = "https://" + base
231 - }
232 - imageURL = base + "/portal.jpg"
233 - }
227
228 replacer := strings.NewReplacer(
229 "[%OG_TITLE%]", html.EscapeString(title),
230 "[%OG_DESCRIPTION%]", html.EscapeString(description),
238 - "[%OG_IMAGE_URL%]", html.EscapeString(imageURL),
231 "[%RELEASE_VERSION%]", html.EscapeString(types.ReleaseVersion),
232 )
233 return replacer.Replace(htmlContent)
@@ -328,6 +320,5 @@ func frontendRootAssetPaths() []string {
320 "/apple-touch-icon.png",
321 "/web-app-manifest-192x192.png",
322 "/web-app-manifest-512x512.png",
331 - "/portal.jpg",
323 }
324 }
frontend/AGENTS.md
+1 -1
@@ -23,7 +23,7 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
23 - Why: any tooling or script assuming `index.html` post-build will fail.
24
25 5. **HTML metadata placeholders must match between HTML and Go.**
26 - `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%OG_IMAGE_URL%]`, `[%RELEASE_VERSION%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
26 + `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%RELEASE_VERSION%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
27 - Why: renaming a placeholder in one place without the other leaves raw placeholder strings in production HTML.
28
29 6. **Admin state reads are aggregated through `/admin/snapshot`.**
frontend/index.html
+1 -3
@@ -10,11 +10,9 @@
10 />
11 <meta property="og:title" content="[%OG_TITLE%]" />
12 <meta property="og:description" content="[%OG_DESCRIPTION%]" />
13 - <meta property="og:image" content="[%OG_IMAGE_URL%]" />
14 - <meta name="twitter:card" content="summary_large_image" />
13 + <meta name="twitter:card" content="summary" />
14 <meta name="twitter:title" content="[%OG_TITLE%]" />
15 <meta name="twitter:description" content="[%OG_DESCRIPTION%]" />
17 - <meta name="twitter:image" content="[%OG_IMAGE_URL%]" />
16 <meta name="portal-release-version" content="[%RELEASE_VERSION%]" />
17 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
18 <title>Portal - Local to web. Instant access.</title>
frontend/public/portal.jpg
Binary files a/frontend/public/portal.jpg and /dev/null differ
frontend/src/components/ServerListView.tsx
+72 -85
@@ -26,7 +26,7 @@ type ListServer = ClientServer | AdminServer;
26
27 interface OfficialRegistryRelay {
28 url: string;
29 - status: "online" | "unreachable" | "unknown";
29 + status: "online" | "unreachable";
30 version?: string;
31 }
32
@@ -42,6 +42,46 @@ const OFFICIAL_REGISTRY_SOURCE_URL =
42 "https://raw.githubusercontent.com/gosuda/portal/main/registry.json";
43 const REPOSITORY_URL = "https://github.com/gosuda/portal";
44
45 +async function loadOfficialRegistryRelay(
46 + relayURL: string
47 +): Promise<OfficialRegistryRelay> {
48 + const domainURL = new URL(API_PATHS.sdk.domain, relayURL).toString();
49 +
50 + try {
51 + const domain = await apiClient.get<RelayDomainResponse>(domainURL);
52 + return {
53 + url: relayURL,
54 + status: "online",
55 + version: typeof domain?.version === "string" ? domain.version.trim() : "",
56 + };
57 + } catch {
58 + return { url: relayURL, status: "unreachable", version: "" };
59 + }
60 +}
61 +
62 +async function loadOfficialRegistryRelays(
63 + sourceURL: string
64 +): Promise<OfficialRegistryRelay[]> {
65 + const response = await fetch(sourceURL, {
66 + headers: { Accept: "application/json" },
67 + });
68 + if (!response.ok) {
69 + throw new Error(`registry request failed with status ${response.status}`);
70 + }
71 +
72 + const document = (await response.json()) as OfficialRegistryDocument;
73 + const relayURLs = Array.isArray(document.relays)
74 + ? document.relays.filter(
75 + (relay): relay is string =>
76 + typeof relay === "string" && relay.trim().length > 0
77 + )
78 + : [];
79 +
80 + return Promise.all(
81 + relayURLs.map((relayURL) => loadOfficialRegistryRelay(relayURL.trim()))
82 + );
83 +}
84 +
85 interface ServerListViewProps {
86 title?: string;
87 searchQuery: string;
@@ -123,8 +163,9 @@ export function ServerListView({
163 onLogout,
164 }: ServerListViewProps) {
165 const [showFilterModal, setShowFilterModal] = useState(false);
126 - const [officialRegistryRelays, setOfficialRegistryRelays] = useState<OfficialRegistryRelay[] | null>(null);
127 - const [officialRegistryFailed, setOfficialRegistryFailed] = useState(false);
166 + const [officialRegistryRelays, setOfficialRegistryRelays] = useState<
167 + OfficialRegistryRelay[] | null
168 + >(null);
169 const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
170 new Set()
171 );
@@ -205,65 +246,20 @@ export function ServerListView({
246 }
247
248 let cancelled = false;
249 + setOfficialRegistryRelays(null);
250
209 - const loadRelayVersion = async (
210 - relayURL: string
211 - ): Promise<OfficialRegistryRelay> => {
212 - const trimmedURL = relayURL.trim();
213 - if (trimmedURL === "") {
214 - return { url: relayURL, status: "unknown", version: "" };
215 - }
216 -
217 - try {
218 - const domainURL = new URL(API_PATHS.sdk.domain, trimmedURL).toString();
219 - const domain = await apiClient.get<RelayDomainResponse>(domainURL);
220 - return {
221 - url: trimmedURL,
222 - status: "online",
223 - version:
224 - typeof domain?.version === "string" ? domain.version.trim() : "",
225 - };
226 - } catch {
227 - return { url: trimmedURL, status: "unreachable", version: "" };
228 - }
229 - };
230 -
231 - const loadOfficialRegistry = async () => {
232 - try {
233 - const response = await fetch(OFFICIAL_REGISTRY_SOURCE_URL, {
234 - headers: { Accept: "application/json" },
235 - });
236 - if (!response.ok) {
237 - throw new Error(`registry request failed with status ${response.status}`);
238 - }
239 - const document = (await response.json()) as OfficialRegistryDocument;
240 - if (cancelled) {
241 - return;
242 - }
243 -
244 - const relayURLs = Array.isArray(document.relays)
245 - ? document.relays.filter(
246 - (relay): relay is string =>
247 - typeof relay === "string" && relay.trim().length > 0
248 - )
249 - : [];
250 - const relays = await Promise.all(relayURLs.map(loadRelayVersion));
251 - if (cancelled) {
252 - return;
251 + void loadOfficialRegistryRelays(OFFICIAL_REGISTRY_SOURCE_URL)
252 + .then((relays) => {
253 + if (!cancelled) {
254 + setOfficialRegistryRelays(relays);
255 }
254 -
255 - setOfficialRegistryFailed(false);
256 - setOfficialRegistryRelays(relays);
257 - } catch (error) {
256 + })
257 + .catch((error) => {
258 if (!cancelled) {
259 console.error("Failed to load official registry", error);
260 - setOfficialRegistryFailed(true);
260 setOfficialRegistryRelays([]);
261 }
263 - }
264 - };
265 -
266 - void loadOfficialRegistry();
262 + });
263
264 return () => {
265 cancelled = true;
@@ -273,9 +269,7 @@ export function ServerListView({
269 const isAllSelected =
270 allLeaseIds.length > 0 &&
271 allLeaseIds.every((id) => selectedLeaseIds.has(id));
276 - const officialRegistryURL = OFFICIAL_REGISTRY_SOURCE_URL;
277 - const officialRegistryAvailable =
278 - officialRegistryRelays !== null && officialRegistryRelays.length > 0;
272 + const officialRegistryAvailable = (officialRegistryRelays?.length ?? 0) > 0;
273
274 const handleSelectAll = () => {
275 if (isAllSelected) {
@@ -466,19 +460,13 @@ export function ServerListView({
460
461 const gridClasses =
462 "grid grid-cols-1 gap-6 p-4 min-[500px]:grid-cols-2 min-[500px]:p-6 md:grid-cols-3";
469 -
470 - const serverGrid = (
471 - <div className={gridClasses}>
472 - {serverRows.length > 0 ? (
473 - serverRows.map(renderServerCard)
474 - ) : (
475 - <div className="col-span-full py-12 text-center">
476 - <p className="text-lg text-text-muted">
477 - No servers match these filters
478 - </p>
479 - </div>
480 - )}
481 - </div>
463 + const serverCards = serverRows.map(renderServerCard);
464 + const serverGrid =
465 + serverCards.length > 0 ? (
466 + <div className={gridClasses}>{serverCards}</div>
467 + ) : null;
468 + const noMatchingServersMessage = (
469 + <p className="text-lg text-text-muted">No servers match these filters</p>
470 );
471
472 const searchBar = (
@@ -560,7 +548,13 @@ export function ServerListView({
548 </div>
549 </div>
550 <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-0 md:px-8">
563 - <main className="z-0 flex-1">{serverGrid}</main>
551 + <main className="z-0 flex-1">
552 + {serverGrid ?? (
553 + <div className="py-12 text-center">
554 + {noMatchingServersMessage}
555 + </div>
556 + )}
557 + </main>
558 </div>
559 </>
560 ) : (
@@ -612,9 +606,7 @@ export function ServerListView({
606 0 services visible
607 </div>
608 <div className="flex flex-1 items-center justify-center py-12 text-center">
615 - <p className="text-lg text-text-muted">
616 - No servers match these filters
617 - </p>
609 + {noMatchingServersMessage}
610 </div>
611 </div>
612 )}
@@ -639,7 +631,7 @@ export function ServerListView({
631 </p>
632 </div>
633 <a
642 - href={officialRegistryURL}
634 + href={OFFICIAL_REGISTRY_SOURCE_URL}
635 target="_blank"
636 rel="noopener noreferrer"
637 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"
@@ -648,7 +640,7 @@ export function ServerListView({
640 </a>
641 </div>
642
651 - {officialRegistryRelays === null && !officialRegistryFailed ? (
643 + {officialRegistryRelays === null ? (
644 <p className="mt-6 text-sm text-text-muted">
645 Loading official registry...
646 </p>
@@ -658,15 +650,10 @@ export function ServerListView({
650 const statusLabel = {
651 online: "ONLINE",
652 unreachable: "OFFLINE",
661 - unknown: "UNKNOWN",
653 }[relay.status];
654 const statusClass = {
664 - online:
665 - "bg-primary/12 text-primary",
666 - unreachable:
667 - "bg-secondary text-text-muted",
668 - unknown:
669 - "bg-secondary text-text-muted",
655 + online: "bg-primary/12 text-primary",
656 + unreachable: "bg-secondary text-text-muted",
657 }[relay.status];
658
659 return (
frontend/src/components/TunnelCommandForm.tsx
+611 -513
@@ -35,58 +35,80 @@ interface TunnelStatusResponse {
35 service_alive: boolean;
36 }
37
38 +const DEFAULT_HOST = "3000";
39 +const FALLBACK_ORIGIN = "https://localhost:4017";
40 +const TUNNEL_NAME_SEED_STORAGE_KEY = "portal:tunnel-name-seed";
41 +
42 +function readCurrentOrigin(): string {
43 + if (typeof window !== "undefined") {
44 + return window.location.origin;
45 + }
46 +
47 + return FALLBACK_ORIGIN;
48 +}
49 +
50 +function readTunnelNameSeed(): string {
51 + if (typeof window === "undefined") {
52 + return "web_portal";
53 + }
54 +
55 + try {
56 + const existing = window.localStorage.getItem(TUNNEL_NAME_SEED_STORAGE_KEY);
57 + if (existing && existing.trim() !== "") {
58 + return existing;
59 + }
60 +
61 + const next =
62 + typeof window.crypto?.randomUUID === "function"
63 + ? `web_${window.crypto.randomUUID()}`
64 + : `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
65 +
66 + window.localStorage.setItem(TUNNEL_NAME_SEED_STORAGE_KEY, next);
67 + return next;
68 + } catch {
69 + return "web_portal";
70 + }
71 +}
72 +
73 +function nextTunnelNameShuffleKey(): string {
74 + if (
75 + typeof window !== "undefined" &&
76 + typeof window.crypto?.randomUUID === "function"
77 + ) {
78 + return window.crypto.randomUUID();
79 + }
80 +
81 + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
82 +}
83 +
84 export function TunnelCommandForm({
85 className,
86 theme = "light",
87 mode = "full",
88 }: TunnelCommandFormProps) {
43 - const defaultHost = "3000";
44 - const tunnelNameSeedStorageKey = "portal:tunnel-name-seed";
45 - const inputId = useId();
46 - const isTerminal = theme === "terminal";
47 - const isHero = mode === "hero";
89 + if (mode === "hero") {
90 + return <HeroTunnelCommandForm className={className} theme={theme} />;
91 + }
92
49 - const [currentOrigin] = useState(() => {
50 - if (typeof window !== "undefined") {
51 - return window.location.origin;
52 - }
53 - return "https://localhost:4017";
54 - });
55 - const [nameSeed] = useState(() => {
56 - if (typeof window === "undefined") {
57 - return "web_portal";
58 - }
59 -
60 - try {
61 - const existing = window.localStorage.getItem(tunnelNameSeedStorageKey);
62 - if (existing && existing.trim() !== "") {
63 - return existing;
64 - }
65 -
66 - const next =
67 - typeof window.crypto?.randomUUID === "function"
68 - ? `web_${window.crypto.randomUUID()}`
69 - : `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
93 + return <FullTunnelCommandForm className={className} theme={theme} />;
94 +}
95
71 - window.localStorage.setItem(tunnelNameSeedStorageKey, next);
72 - return next;
73 - } catch {
74 - return "web_portal";
75 - }
76 - });
96 +function HeroTunnelCommandForm({
97 + className,
98 + theme,
99 +}: Required<Pick<TunnelCommandFormProps, "theme">> &
100 + Pick<TunnelCommandFormProps, "className">) {
101 + const inputId = useId();
102 + const isTerminal = theme === "terminal";
103 + const currentOrigin = useMemo(readCurrentOrigin, []);
104 + const nameSeed = useMemo(readTunnelNameSeed, []);
105
78 - const [target, setTarget] = useState(defaultHost);
106 + const [target, setTarget] = useState(DEFAULT_HOST);
107 const [name, setName] = useState("");
108 const [isAutoName, setIsAutoName] = useState(true);
109 const [nameShuffleKey, setNameShuffleKey] = useState("default");
82 - const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
83 - const [defaultRelays, setDefaultRelays] = useState(true);
84 - const [urlInput, setUrlInput] = useState("");
110 const [copied, setCopied] = useState(false);
111 const [os, setOs] = useState<TunnelCommandOS>("unix");
87 - const [enableUDP, setEnableUDP] = useState(false);
88 - const [udpPort, setUDPPort] = useState("");
89 - const [thumbnailURL, setThumbnailURL] = useState("");
112 const [tunnelStatus, setTunnelStatus] = useState<TunnelStatus>("waiting");
113
114 const resolvedNameSeed = useMemo(
@@ -98,50 +120,21 @@ export function TunnelCommandForm({
120 [resolvedNameSeed, target]
121 );
122 const effectiveName = isAutoName ? generatedName : name;
101 - const normalizedThumbnailURL = useMemo(
102 - () => normalizeAbsoluteHTTPURL(thumbnailURL),
103 - [thumbnailURL]
104 - );
105 - const thumbnailError = useMemo(() => {
106 - if (thumbnailURL.trim() === "" || normalizedThumbnailURL !== "") {
107 - return "";
108 - }
109 -
110 - return "Thumbnail must be an absolute http:// or https:// URL.";
111 - }, [thumbnailURL, normalizedThumbnailURL]);
112 - const resolvedRelayUrls = useMemo(
113 - () => (isHero ? [currentOrigin] : relayUrls),
114 - [currentOrigin, isHero, relayUrls]
115 - );
116 - const includeDefaultRelays = isHero || defaultRelays;
117 - const resolvedThumbnailURL = isHero ? "" : normalizedThumbnailURL;
123 const commandOptions = useMemo(
124 () => ({
125 currentOrigin,
126 target,
127 name: effectiveName,
128 nameSeed,
124 - relayUrls: resolvedRelayUrls,
125 - defaultRelays: includeDefaultRelays,
126 - thumbnailURL: resolvedThumbnailURL,
127 - enableUDP: !isHero && enableUDP,
128 - udpPort: isHero ? "" : udpPort,
129 + relayUrls: [currentOrigin],
130 + defaultRelays: true,
131 + thumbnailURL: "",
132 + enableUDP: false,
133 + udpPort: "",
134 os,
135 }),
131 - [
132 - currentOrigin,
133 - effectiveName,
134 - enableUDP,
135 - includeDefaultRelays,
136 - nameSeed,
137 - os,
138 - resolvedRelayUrls,
139 - resolvedThumbnailURL,
140 - target,
141 - udpPort,
142 - ]
136 + [currentOrigin, effectiveName, nameSeed, os, target]
137 );
144 -
138 const copyCommand = useMemo(
139 () => buildTunnelCommand(commandOptions),
140 [commandOptions]
@@ -175,7 +168,7 @@ export function TunnelCommandForm({
168 }, [copied]);
169
170 useEffect(() => {
178 - if (!isHero || statusHostname === "") {
171 + if (statusHostname === "") {
172 return;
173 }
174
@@ -214,38 +207,7 @@ export function TunnelCommandForm({
207 cancelled = true;
208 window.clearInterval(interval);
209 };
217 - }, [isHero, statusHostname]);
218 -
219 - const addRelayURL = (url: string) => {
220 - const trimmed = url.trim();
221 - if (!trimmed || relayUrls.includes(trimmed)) {
222 - return;
223 - }
224 -
225 - try {
226 - new URL(trimmed);
227 - setRelayUrls((prev) => [...prev, trimmed]);
228 - setUrlInput("");
229 - } catch {
230 - // Ignore invalid relay URL input.
231 - }
232 - };
233 -
234 - const removeRelayURL = (url: string) => {
235 - setRelayUrls((prev) => prev.filter((candidate) => candidate !== url));
236 - };
237 -
238 - const handleURLKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
239 - if (event.key === "Enter") {
240 - event.preventDefault();
241 - addRelayURL(urlInput);
242 - return;
243 - }
244 -
245 - if (event.key === "Backspace" && urlInput === "" && relayUrls.length > 0) {
246 - setRelayUrls((prev) => prev.slice(0, -1));
247 - }
248 - };
210 + }, [statusHostname]);
211
212 const handleCopy = async () => {
213 try {
@@ -269,15 +231,9 @@ export function TunnelCommandForm({
231 };
232
233 const handleShuffleName = () => {
272 - const next =
273 - typeof window !== "undefined" &&
274 - typeof window.crypto?.randomUUID === "function"
275 - ? window.crypto.randomUUID()
276 - : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
277 -
234 setName("");
235 setIsAutoName(true);
280 - setNameShuffleKey(next);
236 + setNameShuffleKey(nextTunnelNameShuffleKey());
237 };
238
239 const tunnelStatusTone = {
@@ -299,53 +255,37 @@ export function TunnelCommandForm({
255 "block overflow-x-auto whitespace-nowrap font-mono text-[15px] font-medium sm:text-base",
256 isTerminal ? "text-sky-300" : "text-primary"
257 );
258 + const heroInputClass = cn(
259 + "h-10 rounded-lg border px-3 text-sm shadow-none",
260 + isTerminal
261 + ? "border-white/6 bg-white/[0.035] text-slate-200 placeholder:text-slate-500"
262 + : "border-border bg-white"
263 + );
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",
267 + 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"
270 + );
271
303 - const commandSection = (
304 - <div className={cn("space-y-2", isHero && "space-y-4")}>
305 - {isHero ? (
272 + return (
273 + <div className={cn("space-y-4", className)}>
274 + <div className="space-y-4">
275 <label className={heroSectionLabelClass}>Command</label>
307 - ) : (
308 - <label
309 - className={cn(
310 - "text-sm font-medium",
311 - isTerminal ? "text-slate-200" : "text-foreground"
312 - )}
313 - >
314 - Generated Command
315 - </label>
316 - )}
317 - <div className="relative">
318 - <pre
319 - className={cn(
320 - "overflow-x-auto whitespace-pre-wrap break-all font-mono",
321 - isTerminal
322 - ? isHero
323 - ? "min-h-[148px] rounded-xl border border-primary/20 bg-black/50 px-4 py-4 text-sm leading-7 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
324 - : "rounded-xl border border-white/10 bg-black/30 p-4 pr-12 text-sm leading-7 text-white"
325 - : "rounded-xl bg-border p-4 pr-12 text-sm leading-7 text-foreground"
326 - )}
327 - >
328 - {displayCommand}
329 - </pre>
330 - {!isHero && (
331 - <button
332 - type="button"
333 - onClick={handleCopy}
276 + <div className="relative">
277 + <pre
278 className={cn(
335 - "absolute right-2 top-2 rounded-md p-2 transition-colors",
336 - isTerminal ? "hover:bg-white/10" : "hover:bg-background/70"
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 )}
338 - aria-label="Copy command"
284 >
340 - {copied ? (
341 - <Check className="h-4 w-4 text-green-600" />
342 - ) : (
343 - <Copy className="h-4 w-4 text-text-muted" />
344 - )}
345 - </button>
346 - )}
347 - </div>
348 - {isHero && (
285 + {displayCommand}
286 + </pre>
287 + </div>
288 +
289 <button
290 type="button"
291 onClick={handleCopy}
@@ -360,9 +300,7 @@ export function TunnelCommandForm({
300 {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
301 <span>{copied ? "Copied" : "Copy command"}</span>
302 </button>
363 - )}
303
365 - {isHero && (
304 <div className="space-y-1.5 pt-0.5">
305 <p className={heroSectionLabelClass}>Public URL</p>
306 <div
@@ -388,10 +326,7 @@ export function TunnelCommandForm({
326 {isPreviewURLDisabled ? (
327 <span
328 aria-disabled="true"
391 - className={cn(
392 - heroURLClass,
393 - "cursor-not-allowed opacity-70"
394 - )}
329 + className={cn(heroURLClass, "cursor-not-allowed opacity-70")}
330 >
331 {previewURL}
332 </span>
@@ -400,422 +335,585 @@ export function TunnelCommandForm({
335 href={previewURL}
336 target="_blank"
337 rel="noopener noreferrer"
403 - className={cn(
404 - heroURLClass,
405 - "underline-offset-4 hover:underline"
406 - )}
338 + className={cn(heroURLClass, "underline-offset-4 hover:underline")}
339 >
340 {previewURL}
341 </a>
342 )}
343 </div>
344 </div>
413 - )}
414 - </div>
415 - );
345 + </div>
346
417 - const heroInputClass = cn(
418 - "h-10 rounded-lg border px-3 text-sm shadow-none",
419 - isTerminal
420 - ? "border-white/6 bg-white/[0.035] text-slate-200 placeholder:text-slate-500"
421 - : "border-border bg-white"
422 - );
423 - const nameInputValue = isAutoName ? generatedName : name;
424 - const shuffleButtonClass = cn(
425 - "inline-flex shrink-0 items-center justify-center border px-3 text-xs font-semibold transition-colors",
426 - isHero ? "h-10 rounded-lg px-3" : "h-12 rounded-lg",
427 - isTerminal
428 - ? "border-white/6 bg-white/[0.035] text-slate-400 hover:bg-white/5 hover:text-white"
429 - : "border-border bg-white text-text-muted hover:text-foreground"
430 - );
431 - const heroControlsSection = isHero ? (
432 - <div
433 - className={cn(
434 - "space-y-1.5 border-t pt-3",
435 - isTerminal ? "border-white/6" : "border-border/80"
436 - )}
437 - >
347 <div
348 className={cn(
440 - "grid grid-cols-[62px_minmax(0,1fr)_140px] gap-2 px-1 text-[9px] font-semibold uppercase tracking-[0.16em]",
441 - isTerminal ? "text-slate-500" : "text-text-muted"
349 + "space-y-1.5 border-t pt-3",
350 + isTerminal ? "border-white/6" : "border-border/80"
351 )}
352 >
444 - <span>Port</span>
445 - <span>Name</span>
446 - <span>Platform</span>
447 - </div>
448 - <div className="grid grid-cols-[62px_minmax(0,1fr)_140px] items-center gap-2">
449 - <Input
450 - id={`${inputId}-host`}
451 - type="text"
452 - value={target}
453 - onChange={(event) => setTarget(event.target.value)}
454 - placeholder={defaultHost}
455 - aria-label="Port"
456 - className={cn(heroInputClass, "w-full px-2.5 text-[13px] font-mono")}
457 - />
458 - <div className="flex min-w-0 flex-1 items-center gap-2">
459 - <Input
460 - id={`${inputId}-name`}
461 - type="text"
462 - value={nameInputValue}
463 - onChange={handleNameChange}
464 - aria-label="Public name"
465 - className={cn(heroInputClass, "min-w-0 flex-1 px-2.5 text-[13px]")}
466 - />
467 - <button
468 - type="button"
469 - onClick={handleShuffleName}
470 - className={cn(shuffleButtonClass, "w-10 rounded-lg px-0")}
471 - aria-label="Shuffle public name"
472 - title="Shuffle public name"
473 - >
474 - <RefreshCw className="h-4 w-4" aria-hidden="true" />
475 - </button>
476 - </div>
353 <div
354 className={cn(
479 - "flex w-full shrink-0 rounded-lg border p-0.5",
480 - isTerminal ? "border-white/6 bg-white/[0.035]" : "border-border bg-border"
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 >
483 - <div className="flex w-full">
484 - <button
485 - type="button"
486 - onClick={() => setOs("unix")}
487 - className={cn(
488 - "flex-1 whitespace-nowrap rounded-md px-1.5 py-1.5 text-[11px] font-semibold transition-colors",
489 - os === "unix"
490 - ? isTerminal
491 - ? "bg-white text-slate-950 shadow-sm"
492 - : "bg-background text-foreground shadow-sm"
493 - : isTerminal
494 - ? "text-slate-500 hover:text-white"
495 - : "text-text-muted hover:text-foreground"
496 - )}
497 - >
498 - Linux
499 - </button>
500 - <button
501 - type="button"
502 - onClick={() => setOs("windows")}
503 - className={cn(
504 - "flex-1 whitespace-nowrap rounded-md px-1.5 py-1.5 text-[11px] font-semibold transition-colors",
505 - os === "windows"
506 - ? isTerminal
507 - ? "bg-white text-slate-950 shadow-sm"
508 - : "bg-background text-foreground shadow-sm"
509 - : isTerminal
510 - ? "text-slate-500 hover:text-white"
511 - : "text-text-muted hover:text-foreground"
512 - )}
513 - >
514 - Windows
515 - </button>
516 - </div>
359 + <span>Port</span>
360 + <span>Name</span>
361 + <span>Platform</span>
362 </div>
518 - </div>
519 - </div>
520 - ) : null;
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
522 - return (
523 - <div className={cn(isHero ? "space-y-4" : "space-y-5", className)}>
524 - {isHero && commandSection}
525 - {heroControlsSection}
526 -
527 - {!isHero && (
528 - <>
529 - <div className="space-y-2">
530 - <label
531 - htmlFor={`${inputId}-host`}
532 - className={cn(
533 - "text-sm font-medium",
534 - isTerminal ? "text-slate-200" : "text-foreground"
535 - )}
536 - >
537 - Host
538 - </label>
374 + <div className="flex min-w-0 flex-1 items-center gap-2">
375 <Input
540 - id={`${inputId}-host`}
376 + id={`${inputId}-name`}
377 type="text"
542 - value={target}
543 - onChange={(event) => setTarget(event.target.value)}
544 - placeholder={defaultHost}
545 - className={cn(
546 - "h-12 rounded-xl",
547 - isTerminal
548 - ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
549 - : "border-border bg-white"
550 - )}
378 + value={nameInputValue}
379 + onChange={handleNameChange}
380 + aria-label="Public name"
381 + className={cn(heroInputClass, "min-w-0 flex-1 px-2.5 text-[13px]")}
382 />
552 - </div>
553 -
554 - <div className="space-y-2">
555 - <label
556 - htmlFor={`${inputId}-name`}
557 - className={cn(
558 - "text-sm font-medium",
559 - isTerminal ? "text-slate-200" : "text-foreground"
560 - )}
561 - >
562 - Service Name
563 - </label>
564 - <div className="flex items-center gap-2">
565 - <Input
566 - id={`${inputId}-name`}
567 - type="text"
568 - value={nameInputValue}
569 - onChange={handleNameChange}
570 - className={cn(
571 - "h-12 flex-1 rounded-xl",
572 - isTerminal
573 - ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
574 - : "border-border bg-white"
575 - )}
576 - />
577 - <button
578 - type="button"
579 - onClick={handleShuffleName}
580 - className={shuffleButtonClass}
581 - aria-label="Shuffle public name"
582 - title="Shuffle public name"
583 - >
584 - <RefreshCw className="h-4 w-4" aria-hidden="true" />
585 - </button>
586 - </div>
587 - </div>
588 - </>
589 - )}
590 -
591 - {!isHero && (
592 - <div className="space-y-2">
593 - <label
594 - className={cn(
595 - "text-sm font-medium",
596 - isTerminal ? "text-slate-200" : "text-foreground"
597 - )}
598 - >
599 - Relay URLs
600 - </label>
601 -
602 - <div className="flex items-center justify-between gap-3">
603 - <label
604 - className={cn(
605 - "ml-auto flex items-center gap-2 text-xs",
606 - isTerminal ? "text-slate-400" : "text-text-muted"
607 - )}
383 + <button
384 + type="button"
385 + onClick={handleShuffleName}
386 + className={shuffleButtonClass}
387 + aria-label="Shuffle public name"
388 + title="Shuffle public name"
389 >
609 - <input
610 - type="checkbox"
611 - checked={defaultRelays}
612 - onChange={(event) => setDefaultRelays(event.target.checked)}
613 - className="h-4 w-4"
614 - />
615 - <span>Include default registry</span>
616 - </label>
390 + <RefreshCw className="h-4 w-4" aria-hidden="true" />
391 + </button>
392 </div>
393
394 <div
395 className={cn(
621 - "flex min-h-12 flex-wrap items-center gap-2 rounded-xl px-2.5 py-2",
396 + "flex w-full shrink-0 rounded-lg border p-0.5",
397 isTerminal
623 - ? "border border-white/10 bg-white/5"
624 - : "border border-border bg-white"
398 + ? "border-white/6 bg-white/[0.035]"
399 + : "border-border bg-border"
400 )}
401 >
627 - {relayUrls.map((url) => (
628 - <span
629 - key={url}
402 + <div className="flex w-full">
403 + <button
404 + type="button"
405 + onClick={() => setOs("unix")}
406 className={cn(
631 - "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium",
632 - isTerminal
633 - ? "bg-white/10 text-slate-100"
634 - : "bg-secondary text-secondary-foreground"
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 >
637 - {url}
638 - <button
639 - type="button"
640 - onClick={() => removeRelayURL(url)}
641 - className={cn(
642 - "ml-1 rounded-sm p-0.5",
643 - isTerminal ? "hover:bg-white/10" : "hover:bg-destructive/15"
644 - )}
645 - aria-label={`Remove ${url}`}
646 - >
647 - <X className="h-3 w-3" />
648 - </button>
649 - </span>
650 - ))}
651 -
652 - <input
653 - type="text"
654 - value={urlInput}
655 - onChange={(event) => setUrlInput(event.target.value)}
656 - onKeyDown={handleURLKeyDown}
657 - placeholder="Add relay URL..."
658 - className={cn(
659 - "min-w-[140px] flex-1 bg-transparent text-sm outline-none",
660 - isTerminal
661 - ? "text-white placeholder:text-slate-500"
662 - : "text-foreground placeholder:text-muted-foreground"
663 - )}
664 - />
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>
436 </div>
437 </div>
667 - )}
438 + </div>
439 + </div>
440 + );
441 +}
442
669 - {!isHero && (
670 - <div className="space-y-2">
671 - <label
672 - className={cn(
673 - "text-sm font-medium",
674 - isTerminal ? "text-slate-200" : "text-foreground"
675 - )}
676 - >
677 - UDP Transport
678 - </label>
443 +function FullTunnelCommandForm({
444 + className,
445 + theme,
446 +}: Required<Pick<TunnelCommandFormProps, "theme">> &
447 + Pick<TunnelCommandFormProps, "className">) {
448 + const inputId = useId();
449 + const isTerminal = theme === "terminal";
450 + const currentOrigin = useMemo(readCurrentOrigin, []);
451 + const nameSeed = useMemo(readTunnelNameSeed, []);
452 +
453 + const [target, setTarget] = useState(DEFAULT_HOST);
454 + const [name, setName] = useState("");
455 + const [isAutoName, setIsAutoName] = useState(true);
456 + const [nameShuffleKey, setNameShuffleKey] = useState("default");
457 + const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
458 + const [defaultRelays, setDefaultRelays] = useState(true);
459 + const [urlInput, setUrlInput] = useState("");
460 + const [copied, setCopied] = useState(false);
461 + const [os, setOs] = useState<TunnelCommandOS>("unix");
462 + const [enableUDP, setEnableUDP] = useState(false);
463 + const [udpPort, setUDPPort] = useState("");
464 + const [thumbnailURL, setThumbnailURL] = useState("");
465 +
466 + const resolvedNameSeed = useMemo(
467 + () => `${nameSeed}:${nameShuffleKey}`,
468 + [nameSeed, nameShuffleKey]
469 + );
470 + const generatedName = useMemo(
471 + () => buildDefaultTunnelName(target, resolvedNameSeed),
472 + [resolvedNameSeed, target]
473 + );
474 + const effectiveName = isAutoName ? generatedName : name;
475 + const normalizedThumbnailURL = useMemo(
476 + () => normalizeAbsoluteHTTPURL(thumbnailURL),
477 + [thumbnailURL]
478 + );
479 + const thumbnailError = useMemo(() => {
480 + if (thumbnailURL.trim() === "" || normalizedThumbnailURL !== "") {
481 + return "";
482 + }
483 +
484 + return "Thumbnail must be an absolute http:// or https:// URL.";
485 + }, [normalizedThumbnailURL, thumbnailURL]);
486 + const commandOptions = useMemo(
487 + () => ({
488 + currentOrigin,
489 + target,
490 + name: effectiveName,
491 + nameSeed,
492 + relayUrls,
493 + defaultRelays,
494 + thumbnailURL: normalizedThumbnailURL,
495 + enableUDP,
496 + udpPort,
497 + os,
498 + }),
499 + [
500 + currentOrigin,
501 + defaultRelays,
502 + effectiveName,
503 + enableUDP,
504 + nameSeed,
505 + normalizedThumbnailURL,
506 + os,
507 + relayUrls,
508 + target,
509 + udpPort,
510 + ]
511 + );
512 + const copyCommand = useMemo(
513 + () => buildTunnelCommand(commandOptions),
514 + [commandOptions]
515 + );
516 + const displayCommand = useMemo(
517 + () => buildTunnelDisplayCommand(commandOptions),
518 + [commandOptions]
519 + );
520 +
521 + useEffect(() => {
522 + if (!copied) {
523 + return;
524 + }
525 +
526 + const timer = window.setTimeout(() => {
527 + setCopied(false);
528 + }, 2000);
529 +
530 + return () => {
531 + window.clearTimeout(timer);
532 + };
533 + }, [copied]);
534 +
535 + const addRelayURL = (url: string) => {
536 + const trimmed = url.trim();
537 + if (!trimmed || relayUrls.includes(trimmed)) {
538 + return;
539 + }
540 +
541 + try {
542 + new URL(trimmed);
543 + setRelayUrls((prev) => [...prev, trimmed]);
544 + setUrlInput("");
545 + } catch {
546 + // Ignore invalid relay URL input.
547 + }
548 + };
549 +
550 + const removeRelayURL = (url: string) => {
551 + setRelayUrls((prev) => prev.filter((candidate) => candidate !== url));
552 + };
553 +
554 + const handleURLKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
555 + if (event.key === "Enter") {
556 + event.preventDefault();
557 + addRelayURL(urlInput);
558 + return;
559 + }
560 +
561 + if (event.key === "Backspace" && urlInput === "" && relayUrls.length > 0) {
562 + setRelayUrls((prev) => prev.slice(0, -1));
563 + }
564 + };
565 +
566 + const handleCopy = async () => {
567 + try {
568 + await navigator.clipboard.writeText(copyCommand);
569 + setCopied(true);
570 + } catch (error) {
571 + console.error("Failed to copy tunnel command", error);
572 + }
573 + };
574 +
575 + 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);
585 + };
586 +
587 + const handleShuffleName = () => {
588 + setName("");
589 + setIsAutoName(true);
590 + setNameShuffleKey(nextTunnelNameShuffleKey());
591 + };
592 +
593 + const nameInputValue = isAutoName ? generatedName : name;
594 + const shuffleButtonClass = cn(
595 + "inline-flex h-12 shrink-0 items-center justify-center rounded-lg border px-3 text-xs font-semibold transition-colors",
596 + isTerminal
597 + ? "border-white/10 bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white"
598 + : "border-border bg-white text-text-muted hover:text-foreground"
599 + );
600 +
601 + return (
602 + <div className={cn("space-y-5", className)}>
603 + <div className="space-y-2">
604 + <label
605 + htmlFor={`${inputId}-host`}
606 + className={cn(
607 + "text-sm font-medium",
608 + isTerminal ? "text-slate-200" : "text-foreground"
609 + )}
610 + >
611 + Host
612 + </label>
613 + <Input
614 + id={`${inputId}-host`}
615 + type="text"
616 + value={target}
617 + onChange={(event) => setTarget(event.target.value)}
618 + placeholder={DEFAULT_HOST}
619 + className={cn(
620 + "h-12 rounded-xl",
621 + isTerminal
622 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
623 + : "border-border bg-white"
624 + )}
625 + />
626 + </div>
627 +
628 + <div className="space-y-2">
629 + <label
630 + htmlFor={`${inputId}-name`}
631 + className={cn(
632 + "text-sm font-medium",
633 + isTerminal ? "text-slate-200" : "text-foreground"
634 + )}
635 + >
636 + Service Name
637 + </label>
638 + <div className="flex items-center gap-2">
639 + <Input
640 + id={`${inputId}-name`}
641 + type="text"
642 + value={nameInputValue}
643 + onChange={handleNameChange}
644 + className={cn(
645 + "h-12 flex-1 rounded-xl",
646 + isTerminal
647 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
648 + : "border-border bg-white"
649 + )}
650 + />
651 + <button
652 + type="button"
653 + onClick={handleShuffleName}
654 + className={shuffleButtonClass}
655 + aria-label="Shuffle public name"
656 + title="Shuffle public name"
657 + >
658 + <RefreshCw className="h-4 w-4" aria-hidden="true" />
659 + </button>
660 + </div>
661 + </div>
662 +
663 + <div className="space-y-2">
664 + <label
665 + className={cn(
666 + "text-sm font-medium",
667 + isTerminal ? "text-slate-200" : "text-foreground"
668 + )}
669 + >
670 + Relay URLs
671 + </label>
672 +
673 + <div className="flex items-center justify-between gap-3">
674 <label
675 className={cn(
681 - "flex items-center gap-2 text-sm",
682 - isTerminal ? "text-slate-300" : "text-muted-foreground"
676 + "ml-auto flex items-center gap-2 text-xs",
677 + isTerminal ? "text-slate-400" : "text-text-muted"
678 )}
679 >
680 <input
681 type="checkbox"
687 - checked={enableUDP}
688 - onChange={(event) => {
689 - const nextEnabled = event.target.checked;
690 - setEnableUDP(nextEnabled);
691 - if (!nextEnabled) {
692 - setUDPPort("");
693 - }
694 - }}
682 + checked={defaultRelays}
683 + onChange={(event) => setDefaultRelays(event.target.checked)}
684 className="h-4 w-4"
685 />
697 - <span>Enable UDP transport</span>
686 + <span>Include default registry</span>
687 </label>
688 + </div>
689
700 - {enableUDP && (
701 - <div className="space-y-1.5">
702 - <Input
703 - id={`${inputId}-udp-port`}
704 - type="text"
705 - value={udpPort}
706 - onChange={(event) => setUDPPort(event.target.value)}
707 - placeholder={target.trim() || defaultHost}
708 - className={cn(
709 - "h-12 rounded-xl",
710 - isTerminal
711 - ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
712 - : "border-border bg-white"
713 - )}
714 - />
715 - <p
690 + <div
691 + className={cn(
692 + "flex min-h-12 flex-wrap items-center gap-2 rounded-xl px-2.5 py-2",
693 + isTerminal
694 + ? "border border-white/10 bg-white/5"
695 + : "border border-border bg-white"
696 + )}
697 + >
698 + {relayUrls.map((url) => (
699 + <span
700 + key={url}
701 + className={cn(
702 + "inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium",
703 + isTerminal
704 + ? "bg-white/10 text-slate-100"
705 + : "bg-secondary text-secondary-foreground"
706 + )}
707 + >
708 + {url}
709 + <button
710 + type="button"
711 + onClick={() => removeRelayURL(url)}
712 className={cn(
717 - "text-xs",
718 - isTerminal ? "text-slate-400" : "text-muted-foreground"
713 + "ml-1 rounded-sm p-0.5",
714 + isTerminal ? "hover:bg-white/10" : "hover:bg-destructive/15"
715 )}
716 + aria-label={`Remove ${url}`}
717 >
721 - Local UDP port to forward. Defaults to the same as Host.
722 - </p>
723 - </div>
724 - )}
725 - </div>
726 - )}
718 + <X className="h-3 w-3" />
719 + </button>
720 + </span>
721 + ))}
722
728 - {!isHero && (
729 - <div className="space-y-2">
730 - <label
731 - htmlFor={`${inputId}-thumbnail`}
732 - className={cn(
733 - "text-sm font-medium",
734 - isTerminal ? "text-slate-200" : "text-foreground"
735 - )}
736 - >
737 - Thumbnail URL
738 - </label>
739 - <Input
740 - id={`${inputId}-thumbnail`}
741 - type="url"
742 - value={thumbnailURL}
743 - onChange={(event) => setThumbnailURL(event.target.value)}
744 - placeholder="https://cdn.example.com/thumb.png"
723 + <input
724 + type="text"
725 + value={urlInput}
726 + onChange={(event) => setUrlInput(event.target.value)}
727 + onKeyDown={handleURLKeyDown}
728 + placeholder="Add relay URL..."
729 className={cn(
746 - "h-12 rounded-xl",
730 + "min-w-[140px] flex-1 bg-transparent text-sm outline-none",
731 isTerminal
748 - ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
749 - : "border-border bg-white"
732 + ? "text-white placeholder:text-slate-500"
733 + : "text-foreground placeholder:text-muted-foreground"
734 )}
735 />
752 - {normalizedThumbnailURL && (
753 - <div
736 + </div>
737 + </div>
738 +
739 + <div className="space-y-2">
740 + <label
741 + className={cn(
742 + "text-sm font-medium",
743 + isTerminal ? "text-slate-200" : "text-foreground"
744 + )}
745 + >
746 + UDP Transport
747 + </label>
748 + <label
749 + className={cn(
750 + "flex items-center gap-2 text-sm",
751 + isTerminal ? "text-slate-300" : "text-muted-foreground"
752 + )}
753 + >
754 + <input
755 + type="checkbox"
756 + checked={enableUDP}
757 + onChange={(event) => {
758 + const nextEnabled = event.target.checked;
759 + setEnableUDP(nextEnabled);
760 + if (!nextEnabled) {
761 + setUDPPort("");
762 + }
763 + }}
764 + className="h-4 w-4"
765 + />
766 + <span>Enable UDP transport</span>
767 + </label>
768 +
769 + {enableUDP && (
770 + <div className="space-y-1.5">
771 + <Input
772 + id={`${inputId}-udp-port`}
773 + type="text"
774 + value={udpPort}
775 + onChange={(event) => setUDPPort(event.target.value)}
776 + placeholder={target.trim() || DEFAULT_HOST}
777 className={cn(
755 - "flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border",
778 + "h-12 rounded-xl",
779 isTerminal
757 - ? "border-white/10 bg-white/5"
758 - : "border-border bg-background"
780 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
781 + : "border-border bg-white"
782 + )}
783 + />
784 + <p
785 + className={cn(
786 + "text-xs",
787 + isTerminal ? "text-slate-400" : "text-muted-foreground"
788 )}
789 >
761 - <img
762 - src={normalizedThumbnailURL}
763 - alt="Thumbnail preview"
764 - className="h-full w-full object-cover"
765 - />
766 - </div>
790 + Local UDP port to forward. Defaults to the same as Host.
791 + </p>
792 + </div>
793 + )}
794 + </div>
795 +
796 + <div className="space-y-2">
797 + <label
798 + htmlFor={`${inputId}-thumbnail`}
799 + className={cn(
800 + "text-sm font-medium",
801 + isTerminal ? "text-slate-200" : "text-foreground"
802 )}
768 - {thumbnailError && (
769 - <p className="text-xs text-destructive">{thumbnailError}</p>
803 + >
804 + Thumbnail URL
805 + </label>
806 + <Input
807 + id={`${inputId}-thumbnail`}
808 + type="url"
809 + value={thumbnailURL}
810 + onChange={(event) => setThumbnailURL(event.target.value)}
811 + placeholder="https://cdn.example.com/thumb.png"
812 + className={cn(
813 + "h-12 rounded-xl",
814 + isTerminal
815 + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500"
816 + : "border-border bg-white"
817 )}
771 - </div>
772 - )}
773 -
774 - {!isHero && (
775 - <div className="space-y-2">
818 + />
819 + {normalizedThumbnailURL && (
820 <div
821 className={cn(
778 - "flex rounded-xl p-1",
779 - isTerminal ? "bg-white/8" : "bg-border"
822 + "flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border",
823 + isTerminal
824 + ? "border-white/10 bg-white/5"
825 + : "border-border bg-background"
826 )}
827 >
782 - <button
783 - type="button"
784 - onClick={() => setOs("unix")}
785 - className={cn(
786 - "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
787 - os === "unix"
788 - ? isTerminal
789 - ? "bg-white text-slate-950 shadow-sm"
790 - : "bg-background text-foreground shadow-sm"
791 - : isTerminal
792 - ? "text-slate-400 hover:text-white"
793 - : "text-text-muted hover:text-foreground"
794 - )}
795 - >
796 - Linux / macOS
797 - </button>
798 - <button
799 - type="button"
800 - onClick={() => setOs("windows")}
801 - className={cn(
802 - "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
803 - os === "windows"
804 - ? isTerminal
805 - ? "bg-white text-slate-950 shadow-sm"
806 - : "bg-background text-foreground shadow-sm"
807 - : isTerminal
808 - ? "text-slate-400 hover:text-white"
809 - : "text-text-muted hover:text-foreground"
810 - )}
811 - >
812 - Windows (PowerShell)
813 - </button>
828 + <img
829 + src={normalizedThumbnailURL}
830 + alt="Thumbnail preview"
831 + className="h-full w-full object-cover"
832 + />
833 </div>
834 + )}
835 + {thumbnailError && <p className="text-xs text-destructive">{thumbnailError}</p>}
836 + </div>
837 +
838 + <div className="space-y-2">
839 + <div
840 + className={cn(
841 + "flex rounded-xl p-1",
842 + isTerminal ? "bg-white/8" : "bg-border"
843 + )}
844 + >
845 + <button
846 + type="button"
847 + onClick={() => setOs("unix")}
848 + className={cn(
849 + "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
850 + os === "unix"
851 + ? isTerminal
852 + ? "bg-white text-slate-950 shadow-sm"
853 + : "bg-background text-foreground shadow-sm"
854 + : isTerminal
855 + ? "text-slate-400 hover:text-white"
856 + : "text-text-muted hover:text-foreground"
857 + )}
858 + >
859 + Linux / macOS
860 + </button>
861 + <button
862 + type="button"
863 + onClick={() => setOs("windows")}
864 + className={cn(
865 + "flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
866 + os === "windows"
867 + ? isTerminal
868 + ? "bg-white text-slate-950 shadow-sm"
869 + : "bg-background text-foreground shadow-sm"
870 + : isTerminal
871 + ? "text-slate-400 hover:text-white"
872 + : "text-text-muted hover:text-foreground"
873 + )}
874 + >
875 + Windows (PowerShell)
876 + </button>
877 </div>
816 - )}
878 + </div>
879
818 - {!isHero && commandSection}
880 + <div className="space-y-2">
881 + <label
882 + className={cn(
883 + "text-sm font-medium",
884 + isTerminal ? "text-slate-200" : "text-foreground"
885 + )}
886 + >
887 + Generated Command
888 + </label>
889 + <div className="relative">
890 + <pre
891 + className={cn(
892 + "overflow-x-auto whitespace-pre-wrap break-all rounded-xl p-4 pr-12 font-mono text-sm leading-7",
893 + isTerminal
894 + ? "border border-white/10 bg-black/30 text-white"
895 + : "bg-border text-foreground"
896 + )}
897 + >
898 + {displayCommand}
899 + </pre>
900 + <button
901 + type="button"
902 + onClick={handleCopy}
903 + className={cn(
904 + "absolute right-2 top-2 rounded-md p-2 transition-colors",
905 + isTerminal ? "hover:bg-white/10" : "hover:bg-background/70"
906 + )}
907 + aria-label="Copy command"
908 + >
909 + {copied ? (
910 + <Check className="h-4 w-4 text-green-600" />
911 + ) : (
912 + <Copy className="h-4 w-4 text-text-muted" />
913 + )}
914 + </button>
915 + </div>
916 + </div>
917 </div>
918 );
919 }