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
}