fixed for tailwindcss style and some refactor
Hee Sung Son committed
Mar 23, 2026 at 16:11 UTC
0ec1b3dc21744ea39f80540fbe7e26a881f54ac7
13 files changed
+271
-275
frontend/src/components/FloatingActionBar.tsx
+1
-1
@@ -78,7 +78,7 @@ export const FloatingActionBar = ({
78
value={selectedAction}
79
onValueChange={(v) => setSelectedAction(v as BulkAction)}
80
>
81
- <SelectTrigger className="w-[100px] h-10">
81
+ <SelectTrigger className="w-25 h-10">
82
<SelectValue />
83
</SelectTrigger>
84
<SelectContent>
frontend/src/components/Header.tsx
+2
-2
@@ -47,7 +47,7 @@ export function Header({
47
</div>
48
49
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
50
- <h2 className="min-w-0 break-words text-xl leading-none font-extrabold tracking-tight text-foreground sm:text-2xl">
50
+ <h2 className="min-w-0 wrap-break-word text-xl leading-none font-extrabold tracking-tight text-foreground sm:text-2xl">
51
{title}
52
</h2>
53
{releaseVersion && (
@@ -118,7 +118,7 @@ export function Header({
118
className="h-12 w-12 cursor-pointer rounded-full border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-destructive/40 hover:bg-background hover:text-destructive"
119
aria-label="Logout"
120
>
121
- <LogOut className="h-[22px] w-[22px]" />
121
+ <LogOut className="h-5.5 w-5.5" />
122
</Button>
123
</TooltipTrigger>
124
<TooltipContent>
frontend/src/components/LandingHero.tsx
+12
-12
@@ -287,7 +287,7 @@ export function LandingHero() {
287
className="pointer-events-none absolute inset-0"
288
>
289
<div
290
- className="absolute inset-0 opacity-45 [background-size:14px_14px] [mask-image:linear-gradient(to_bottom,white,transparent_82%)]"
290
+ className="absolute inset-0 opacity-45 bg-size-[14px_14px] mask-[linear-gradient(to_bottom,white,transparent_82%)]"
291
style={{
292
backgroundImage:
293
"radial-gradient(var(--hero-grid-dot) 0.8px, transparent 0.8px)",
@@ -327,10 +327,10 @@ export function LandingHero() {
327
<div className="pointer-events-none absolute inset-x-0 top-6 flex justify-center sm:top-8">
328
<div className="h-28 w-28 rounded-full bg-primary/16 blur-3xl dark:bg-primary/22" />
329
</div>
330
- <div className="pointer-events-none absolute inset-y-0 left-0 z-40 w-12 bg-gradient-to-r from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
331
- <div className="pointer-events-none absolute inset-y-0 right-0 z-40 w-12 bg-gradient-to-l from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
330
+ <div className="pointer-events-none absolute inset-y-0 left-0 z-40 w-12 bg-linear-to-r from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
331
+ <div className="pointer-events-none absolute inset-y-0 right-0 z-40 w-12 bg-linear-to-l from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
332
333
- <div className="relative h-[328px] sm:h-[360px]">
333
+ <div className="relative h-82 sm:h-90">
334
<div
335
onPointerDown={handlePointerDown}
336
onPointerMove={handlePointerMove}
@@ -353,12 +353,12 @@ export function LandingHero() {
353
return (
354
<article
355
key={`${card.key}-${index}`}
356
- className={`relative h-[244px] shrink-0 overflow-hidden rounded-[1.65rem] border px-5 py-5 text-left transition-[opacity,transform,box-shadow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] sm:h-[268px] sm:px-7 sm:py-6 ${
356
+ className={`relative h-61 shrink-0 overflow-hidden rounded-[1.65rem] border px-5 py-5 text-left transition-[opacity,transform,box-shadow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] sm:h-67 sm:px-7 sm:py-6 ${
357
isActive
358
- ? "border-primary/24 bg-white/92 opacity-100 shadow-[0_24px_54px_rgba(15,23,42,0.08)] dark:bg-white/[0.08] dark:shadow-[0_26px_60px_rgba(0,0,0,0.22)]"
358
+ ? "border-primary/24 bg-white/92 opacity-100 shadow-[0_24px_54px_rgba(15,23,42,0.08)] dark:bg-white/8 dark:shadow-[0_26px_60px_rgba(0,0,0,0.22)]"
359
: distance === 1
360
- ? "border-border/70 bg-background/78 opacity-62 shadow-[0_14px_32px_rgba(15,23,42,0.04)] dark:bg-white/[0.04]"
361
- : "border-border/60 bg-background/70 opacity-30 shadow-none dark:bg-white/[0.03]"
360
+ ? "border-border/70 bg-background/78 opacity-62 shadow-[0_14px_32px_rgba(15,23,42,0.04)] dark:bg-white/4"
361
+ : "border-border/60 bg-background/70 opacity-30 shadow-none dark:bg-white/3"
362
} ${isActive ? "translate-y-0 scale-100" : "translate-y-5 scale-[0.95]"}`}
363
style={{ width: `${slideSize}px` }}
364
aria-hidden={!isActive}
@@ -379,7 +379,7 @@ export function LandingHero() {
379
</p>
380
</div>
381
<div className="mt-auto pt-8">
382
- <div className="h-px w-16 bg-gradient-to-r from-primary/55 to-transparent" />
382
+ <div className="h-px w-16 bg-linear-to-r from-primary/55 to-transparent" />
383
</div>
384
</div>
385
</article>
@@ -394,7 +394,7 @@ export function LandingHero() {
394
<div className="relative -mx-4 w-auto sm:-mx-6 md:-mx-8">
395
<div className="overflow-hidden border-t border-border/80 bg-border/70">
396
<div className="grid gap-px sm:grid-cols-2 lg:grid-cols-3">
397
- <div className="flex min-h-[184px] bg-background/88 p-6 text-left sm:min-h-[196px] sm:p-7">
397
+ <div className="flex min-h-46 bg-background/88 p-6 text-left sm:min-h-49 sm:p-7">
398
<div className="space-y-2">
399
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
400
Core features
@@ -411,7 +411,7 @@ export function LandingHero() {
411
{heroFeatures.map(({ title, description }) => (
412
<article
413
key={title}
414
- className="flex min-h-[184px] bg-background/88 p-6 text-left transition-colors duration-200 hover:bg-background/92 sm:min-h-[196px] sm:p-7"
414
+ className="flex min-h-46 bg-background/88 p-6 text-left transition-colors duration-200 hover:bg-background/92 sm:min-h-49 sm:p-7"
415
>
416
<div className="space-y-2">
417
<h3 className="whitespace-nowrap text-[1.2rem] font-semibold tracking-tight text-foreground sm:text-[1.32rem] sm:leading-none">
@@ -439,7 +439,7 @@ export function LandingHero() {
439
</div>
440
441
<div
442
- className="relative mx-auto mt-4 w-full max-w-[520px] rounded-[1.75rem] border px-4 py-5 sm:px-5 sm:py-6"
442
+ className="relative mx-auto mt-4 w-full max-w-130 rounded-xl border px-4 py-5 sm:px-5 sm:py-6"
443
style={{
444
background: "var(--hero-terminal-bg)",
445
borderColor: "var(--hero-terminal-border)",
frontend/src/components/SearchBar.tsx
+1
-1
@@ -38,7 +38,7 @@ export function SearchBar({
38
return (
39
<div className="flex flex-wrap mt-4 sm:mt-6 items-center gap-3 px-4 sm:px-6">
40
<div className="flex gap-2 items-center w-full">
41
- <label className="flex min-w-[220px] flex-1 items-stretch h-10 relative">
41
+ <label className="flex min-w-55 flex-1 items-stretch h-10 relative">
42
<div className="left-0 h-full absolute text-text-muted flex items-center justify-center pl-3 pr-2 rounded-l-md bg-border border border-border">
43
<Search className="w-4 h-4" />
44
</div>
frontend/src/components/ServerCard.tsx
+8
-8
@@ -226,8 +226,8 @@ export function ServerCard({
226
<article
227
data-hero-key={`server-bg-${serverId}`}
228
className={clsx(
229
- "group flex h-full flex-col overflow-hidden rounded-[1.75rem] border border-border bg-card shadow-[0_18px_42px_oklch(0%_0_0_/_0.06)] transition-transform duration-200 hover:-translate-y-1 hover:shadow-[0_24px_56px_oklch(0%_0_0_/_0.08)]",
230
- showAdminControls ? "min-h-[360px]" : "min-h-[310px]"
229
+ "group flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card shadow-[0_18px_42px_oklch(0%_0_0/0.06)] transition-transform duration-200 hover:-translate-y-1 hover:shadow-[0_24px_56px_oklch(0%_0_0/0.08)]",
230
+ showAdminControls ? "min-h-90" : "min-h-77.5"
231
)}
232
>
233
<div
@@ -248,7 +248,7 @@ export function ServerCard({
248
)}
249
250
<div className="absolute inset-x-0 top-0 flex items-start justify-between p-4">
251
- <div className="inline-flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground shadow-[0_8px_18px_oklch(0%_0_0_/_0.05)]">
251
+ <div className="inline-flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground shadow-[0_8px_18px_oklch(0%_0_0/0.05)]">
252
<span
253
className={clsx(
254
"h-2.5 w-2.5 rounded-full",
@@ -270,7 +270,7 @@ export function ServerCard({
270
<button
271
onClick={handleSelectClick}
272
className={clsx(
273
- "flex size-9 items-center justify-center rounded-full border border-border bg-card/95 text-text-muted shadow-[0_8px_18px_oklch(0%_0_0_/_0.05)] transition-colors cursor-pointer",
273
+ "flex size-9 items-center justify-center rounded-full border border-border bg-card/95 text-text-muted shadow-[0_8px_18px_oklch(0%_0_0/0.05)] transition-colors cursor-pointer",
274
isSelected
275
? "bg-primary text-primary-foreground"
276
: "hover:text-foreground"
@@ -280,7 +280,7 @@ export function ServerCard({
280
<svg
281
xmlns="http://www.w3.org/2000/svg"
282
viewBox="0 0 24 24"
283
- className="h-[18px] w-[18px]"
283
+ className="h-4.5 w-4.5"
284
fill="none"
285
stroke="currentColor"
286
strokeWidth="3"
@@ -294,7 +294,7 @@ export function ServerCard({
294
<button
295
onClick={handleFavoriteClick}
296
className={clsx(
297
- "flex size-9 items-center justify-center rounded-full border border-border bg-card/95 text-text-muted shadow-[0_8px_18px_oklch(0%_0_0_/_0.05)] transition-colors cursor-pointer",
297
+ "flex size-9 items-center justify-center rounded-full border border-border bg-card/95 text-text-muted shadow-[0_8px_18px_oklch(0%_0_0/0.05)] transition-colors cursor-pointer",
298
isFavorite
299
? "bg-primary text-primary-foreground"
300
: "hover:text-foreground"
@@ -306,7 +306,7 @@ export function ServerCard({
306
<svg
307
xmlns="http://www.w3.org/2000/svg"
308
viewBox="0 0 24 24"
309
- className="h-[18px] w-[18px]"
309
+ className="h-4.5 w-4.5"
310
fill={isFavorite ? "currentColor" : "none"}
311
stroke="currentColor"
312
strokeWidth="2"
@@ -359,7 +359,7 @@ export function ServerCard({
359
</div>
360
361
{showAdminControls && leaseId && (
362
- <div className="flex flex-col gap-3 rounded-[1.5rem] border border-border bg-secondary/70 p-4">
362
+ <div className="flex flex-col gap-3 rounded-3xl border border-border bg-secondary/70 p-4">
363
{onBPSChange && (
364
<div className="flex items-center justify-between gap-4">
365
<span className="text-xs text-text-muted">
frontend/src/components/ServerListView.tsx
+4
-4
@@ -634,7 +634,7 @@ export function ServerListView({
634
</>
635
) : (
636
<>
637
- <div className="sticky top-0 z-20 w-full bg-background/95 py-5 backdrop-blur supports-[backdrop-filter]:bg-background/80">
637
+ <div className="sticky top-0 z-20 w-full bg-background/95 py-5 backdrop-blur supports-backdrop-filter:bg-background/80">
638
<div className="flex w-full flex-col px-6 sm:px-8 lg:px-10">
639
<Header
640
title={title}
@@ -655,7 +655,7 @@ export function ServerListView({
655
<section
656
id="live-servers"
657
aria-labelledby="live-servers-title"
658
- className="scroll-mt-24 min-h-[34rem] border-b border-border/80 px-4 py-8 sm:min-h-[36rem] sm:px-6 md:px-8"
658
+ className="scroll-mt-24 min-h-136 border-b border-border/80 px-4 py-8 sm:min-h-144 sm:px-6 md:px-8"
659
>
660
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
661
<div className="space-y-2">
@@ -681,7 +681,7 @@ export function ServerListView({
681
{serverGrid}
682
</div>
683
) : (
684
- <div className="mt-6 flex min-h-[22rem] flex-col">
684
+ <div className="mt-6 flex min-h-88 flex-col">
685
{searchBar}
686
<div className="px-1 pt-3 text-sm text-text-muted">
687
0 services visible
@@ -720,7 +720,7 @@ export function ServerListView({
720
</a>
721
</div>
722
723
- <div className="mt-6 rounded-[1.75rem] border border-border/80 bg-secondary/35 p-5 sm:p-6">
723
+ <div className="mt-6 rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
724
{officialRegistryRelays === null ? (
725
<p className="text-sm text-text-muted">
726
Loading official registry...
frontend/src/components/TagCombobox.tsx
+1
-1
@@ -125,7 +125,7 @@ export function TagCombobox({
125
onBlur={handleBlur}
126
onKeyDown={handleKeyDown}
127
placeholder="Add tag…"
128
- className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted"
128
+ className="min-w-30 flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted"
129
role="combobox"
130
aria-expanded={open}
131
aria-controls={listId}
frontend/src/components/ThemeToggleButton.tsx
+2
-2
@@ -25,9 +25,9 @@ export function ThemeToggleButton({ className }: ThemeToggleButtonProps) {
25
title={theme === "dark" ? "Light theme" : "Dark theme"}
26
>
27
{theme === "dark" ? (
28
- <Sun className="h-[22px] w-[22px]" />
28
+ <Sun className="h-5.5 w-5.5" />
29
) : (
30
- <Moon className="h-[22px] w-[22px]" />
30
+ <Moon className="h-5.5 w-5.5" />
31
)}
32
</Button>
33
);
frontend/src/components/TunnelCommandForm.tsx
+51
-241
@@ -3,7 +3,6 @@ import {
3
useId,
4
useMemo,
5
useState,
6
- type ChangeEvent,
6
type KeyboardEvent,
7
} from "react";
8
import { Check, Copy, RefreshCw, X } from "lucide-react";
@@ -12,15 +11,15 @@ import { apiClient } from "@/lib/apiClient";
11
import { API_PATHS } from "@/lib/apiPaths";
12
import { cn } from "@/lib/utils";
13
import {
15
- buildDefaultTunnelName,
16
- buildTunnelCommand,
17
- buildTunnelDisplayCommand,
14
buildTunnelPreviewURL,
15
buildTunnelStatusHostname,
20
- normalizeTunnelCommandName,
16
normalizeAbsoluteHTTPURL,
22
- type TunnelCommandOS,
17
} from "@/lib/tunnelCommand";
18
+import {
19
+ DEFAULT_HOST,
20
+ readCurrentOrigin,
21
+ useTunnelCommand,
22
+} from "@/hooks/useTunnelCommand";
23
24
interface TunnelCommandFormProps {
25
className?: string;
@@ -36,62 +35,6 @@ interface TunnelStatusResponse {
35
service_alive: boolean;
36
}
37
39
-const DEFAULT_HOST = "3000";
40
-const FALLBACK_ORIGIN = "https://localhost:4017";
41
-const TUNNEL_NAME_SEED_STORAGE_KEY = "portal:tunnel-name-seed";
42
-
43
-function readCurrentOrigin(): string {
44
- if (typeof window !== "undefined") {
45
- return window.location.origin;
46
- }
47
-
48
- return FALLBACK_ORIGIN;
49
-}
50
-
51
-function readTunnelNameSeed(): string {
52
- if (typeof window === "undefined") {
53
- return "web_portal";
54
- }
55
-
56
- try {
57
- const existing = window.localStorage.getItem(TUNNEL_NAME_SEED_STORAGE_KEY);
58
- if (existing && existing.trim() !== "") {
59
- return existing;
60
- }
61
-
62
- const next =
63
- typeof window.crypto?.randomUUID === "function"
64
- ? `web_${window.crypto.randomUUID()}`
65
- : `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
66
-
67
- window.localStorage.setItem(TUNNEL_NAME_SEED_STORAGE_KEY, next);
68
- return next;
69
- } catch {
70
- return "web_portal";
71
- }
72
-}
73
-
74
-function nextTunnelNameShuffleKey(): string {
75
- if (
76
- typeof window !== "undefined" &&
77
- typeof window.crypto?.randomUUID === "function"
78
- ) {
79
- return window.crypto.randomUUID();
80
- }
81
-
82
- return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
83
-}
84
-
85
-function splitDisplayCommand(command: string, os: TunnelCommandOS) {
86
- const lines = command.split("\n");
87
- const installLineCount = os === "windows" ? 2 : 1;
88
-
89
- return {
90
- installBlock: lines.slice(0, installLineCount).join("\n"),
91
- runBlock: lines.slice(installLineCount).join("\n"),
92
- };
93
-}
94
-
38
export function TunnelCommandForm({
39
className,
40
theme = "light",
@@ -110,56 +53,25 @@ function HeroTunnelCommandForm({
53
}: Required<Pick<TunnelCommandFormProps, "theme">> &
54
Pick<TunnelCommandFormProps, "className">) {
55
const isTerminal = theme === "terminal";
113
- const currentOrigin = useMemo(readCurrentOrigin, []);
114
- const nameSeed = useMemo(readTunnelNameSeed, []);
115
-
116
- const [target, setTarget] = useState(DEFAULT_HOST);
117
- const [name, setName] = useState("");
118
- const [nameShuffleKey, setNameShuffleKey] = useState("default");
119
- const [copied, setCopied] = useState(false);
120
- const [os, setOs] = useState<TunnelCommandOS>("unix");
56
+ const {
57
+ currentOrigin,
58
+ nameSeed,
59
+ target,
60
+ setTarget,
61
+ copied,
62
+ os,
63
+ setOs,
64
+ generatedName,
65
+ effectiveName,
66
+ installBlock,
67
+ runBlock,
68
+ handleCopy,
69
+ handleNameChange,
70
+ handleShuffleName,
71
+ } = useTunnelCommand();
72
+
73
const [tunnelStatus, setTunnelStatus] = useState<TunnelStatus>("waiting");
74
123
- const resolvedNameSeed = useMemo(
124
- () => `${nameSeed}:${nameShuffleKey}`,
125
- [nameSeed, nameShuffleKey]
126
- );
127
- const generatedName = useMemo(
128
- () => buildDefaultTunnelName(target, resolvedNameSeed),
129
- [resolvedNameSeed, target]
130
- );
131
- const normalizedName = useMemo(
132
- () => normalizeTunnelCommandName(name),
133
- [name]
134
- );
135
- const effectiveName = normalizedName === "" ? generatedName : normalizedName;
136
- const commandOptions = useMemo(
137
- () => ({
138
- currentOrigin,
139
- target,
140
- name: effectiveName,
141
- nameSeed,
142
- relayUrls: [currentOrigin],
143
- defaultRelays: true,
144
- thumbnailURL: "",
145
- enableUDP: false,
146
- udpPort: "",
147
- os,
148
- }),
149
- [currentOrigin, effectiveName, nameSeed, os, target]
150
- );
151
- const copyCommand = useMemo(
152
- () => buildTunnelCommand(commandOptions),
153
- [commandOptions]
154
- );
155
- const displayCommand = useMemo(
156
- () => buildTunnelDisplayCommand(commandOptions),
157
- [commandOptions]
158
- );
159
- const { installBlock, runBlock } = useMemo(
160
- () => splitDisplayCommand(displayCommand, os),
161
- [displayCommand, os]
162
- );
75
const previewURL = useMemo(
76
() => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed),
77
[currentOrigin, effectiveName, nameSeed, target]
@@ -170,20 +82,6 @@ function HeroTunnelCommandForm({
82
[currentOrigin, effectiveName, nameSeed, target]
83
);
84
173
- useEffect(() => {
174
- if (!copied) {
175
- return;
176
- }
177
-
178
- const timer = window.setTimeout(() => {
179
- setCopied(false);
180
- }, 2000);
181
-
182
- return () => {
183
- window.clearTimeout(timer);
184
- };
185
- }, [copied]);
186
-
85
useEffect(() => {
86
if (statusHostname === "") {
87
return;
@@ -226,24 +124,6 @@ function HeroTunnelCommandForm({
124
};
125
}, [statusHostname]);
126
229
- const handleCopy = async () => {
230
- try {
231
- await navigator.clipboard.writeText(copyCommand);
232
- setCopied(true);
233
- } catch (error) {
234
- console.error("Failed to copy tunnel command", error);
235
- }
236
- };
237
-
238
- const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
239
- setName(event.target.value);
240
- };
241
-
242
- const handleShuffleName = () => {
243
- setName("");
244
- setNameShuffleKey(nextTunnelNameShuffleKey());
245
- };
246
-
127
const tunnelStatusTone = {
128
alive: isTerminal ? "bg-green-400" : "bg-green-600",
129
registered: isTerminal ? "bg-sky-400" : "bg-sky-600",
@@ -352,14 +232,13 @@ function HeroTunnelCommandForm({
232
onChange={(event) => setTarget(event.target.value)}
233
placeholder={DEFAULT_HOST}
234
aria-label="Local port or address"
355
- className={cn(heroControlInputClass, "w-[4.75rem] font-mono")}
235
+ className={cn(heroControlInputClass, "w-[19 font-mono")}
236
/>
237
</div>
358
- <div className="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-[22rem]">
238
+ <div className="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-88">
239
<span className={heroControlLabelClass}>Name</span>
240
<Input
241
type="text"
362
- value={name}
242
onChange={handleNameChange}
243
placeholder={generatedName}
244
aria-label="Public name"
@@ -378,7 +257,7 @@ function HeroTunnelCommandForm({
257
</div>
258
<div
259
className={cn(
381
- "relative min-h-[148px] rounded-xl border px-4 py-4 pr-14 font-mono text-sm leading-7",
260
+ "relative min-h-37 rounded-xl border px-4 py-4 pr-14 font-mono text-sm leading-7",
261
isTerminal
262
? "border-white/10 bg-black/55 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
263
: "border-border/80 bg-border/90 text-foreground"
@@ -415,7 +294,7 @@ function HeroTunnelCommandForm({
294
className={cn(
295
"space-y-3 rounded-xl border px-3.5 py-3",
296
isTerminal
418
- ? "border-white/8 bg-white/[0.045]"
297
+ ? "border-white/8 bg-white/4.5"
298
: "border-border bg-white"
299
)}
300
>
@@ -461,34 +340,16 @@ function FullTunnelCommandForm({
340
Pick<TunnelCommandFormProps, "className">) {
341
const inputId = useId();
342
const isTerminal = theme === "terminal";
464
- const currentOrigin = useMemo(readCurrentOrigin, []);
465
- const nameSeed = useMemo(readTunnelNameSeed, []);
343
467
- const [target, setTarget] = useState(DEFAULT_HOST);
468
- const [name, setName] = useState("");
469
- const [nameShuffleKey, setNameShuffleKey] = useState("default");
470
- const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
344
+ const [relayUrls, setRelayUrls] = useState<string[]>(() => [
345
+ readCurrentOrigin(),
346
+ ]);
347
const [defaultRelays, setDefaultRelays] = useState(true);
348
const [urlInput, setUrlInput] = useState("");
473
- const [copied, setCopied] = useState(false);
474
- const [os, setOs] = useState<TunnelCommandOS>("unix");
349
const [enableUDP, setEnableUDP] = useState(false);
350
const [udpPort, setUDPPort] = useState("");
351
const [thumbnailURL, setThumbnailURL] = useState("");
352
479
- const resolvedNameSeed = useMemo(
480
- () => `${nameSeed}:${nameShuffleKey}`,
481
- [nameSeed, nameShuffleKey]
482
- );
483
- const generatedName = useMemo(
484
- () => buildDefaultTunnelName(target, resolvedNameSeed),
485
- [resolvedNameSeed, target]
486
- );
487
- const normalizedName = useMemo(
488
- () => normalizeTunnelCommandName(name),
489
- [name]
490
- );
491
- const effectiveName = normalizedName === "" ? generatedName : normalizedName;
353
const normalizedThumbnailURL = useMemo(
354
() => normalizeAbsoluteHTTPURL(thumbnailURL),
355
[thumbnailURL]
@@ -500,58 +361,26 @@ function FullTunnelCommandForm({
361
362
return "Thumbnail must be an absolute http:// or https:// URL.";
363
}, [normalizedThumbnailURL, thumbnailURL]);
503
- const commandOptions = useMemo(
504
- () => ({
505
- currentOrigin,
506
- target,
507
- name: effectiveName,
508
- nameSeed,
509
- relayUrls,
510
- defaultRelays,
511
- thumbnailURL: normalizedThumbnailURL,
512
- enableUDP,
513
- udpPort,
514
- os,
515
- }),
516
- [
517
- currentOrigin,
518
- defaultRelays,
519
- effectiveName,
520
- enableUDP,
521
- nameSeed,
522
- normalizedThumbnailURL,
523
- os,
524
- relayUrls,
525
- target,
526
- udpPort,
527
- ]
528
- );
529
- const copyCommand = useMemo(
530
- () => buildTunnelCommand(commandOptions),
531
- [commandOptions]
532
- );
533
- const displayCommand = useMemo(
534
- () => buildTunnelDisplayCommand(commandOptions),
535
- [commandOptions]
536
- );
537
- const { installBlock, runBlock } = useMemo(
538
- () => splitDisplayCommand(displayCommand, os),
539
- [displayCommand, os]
540
- );
364
542
- useEffect(() => {
543
- if (!copied) {
544
- return;
545
- }
546
-
547
- const timer = window.setTimeout(() => {
548
- setCopied(false);
549
- }, 2000);
550
-
551
- return () => {
552
- window.clearTimeout(timer);
553
- };
554
- }, [copied]);
365
+ const {
366
+ target,
367
+ setTarget,
368
+ copied,
369
+ os,
370
+ setOs,
371
+ generatedName,
372
+ installBlock,
373
+ runBlock,
374
+ handleCopy,
375
+ handleNameChange,
376
+ handleShuffleName,
377
+ } = useTunnelCommand({
378
+ relayUrls,
379
+ defaultRelays,
380
+ thumbnailURL: normalizedThumbnailURL,
381
+ enableUDP,
382
+ udpPort,
383
+ });
384
385
const addRelayURL = (url: string) => {
386
const trimmed = url.trim();
@@ -584,24 +413,6 @@ function FullTunnelCommandForm({
413
}
414
};
415
587
- const handleCopy = async () => {
588
- try {
589
- await navigator.clipboard.writeText(copyCommand);
590
- setCopied(true);
591
- } catch (error) {
592
- console.error("Failed to copy tunnel command", error);
593
- }
594
- };
595
-
596
- const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
597
- setName(event.target.value);
598
- };
599
-
600
- const handleShuffleName = () => {
601
- setName("");
602
- setNameShuffleKey(nextTunnelNameShuffleKey());
603
- };
604
-
416
const shuffleButtonClass = cn(
417
"inline-flex h-12 shrink-0 items-center justify-center rounded-lg border px-3 text-xs font-semibold transition-colors",
418
isTerminal
@@ -674,7 +485,6 @@ function FullTunnelCommandForm({
485
<Input
486
id={`${inputId}-name`}
487
type="text"
677
- value={name}
488
onChange={handleNameChange}
489
placeholder={generatedName}
490
className={cn(
@@ -763,7 +573,7 @@ function FullTunnelCommandForm({
573
onKeyDown={handleURLKeyDown}
574
placeholder="Add relay URL..."
575
className={cn(
766
- "min-w-[140px] flex-1 bg-transparent text-sm outline-none",
576
+ "min-w-35 flex-1 bg-transparent text-sm outline-none",
577
isTerminal
578
? "text-white placeholder:text-slate-500"
579
: "text-foreground placeholder:text-muted-foreground"
@@ -885,7 +695,7 @@ function FullTunnelCommandForm({
695
"flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
696
os === "unix"
697
? isTerminal
888
- ? "bg-white/[0.08] text-slate-200"
698
+ ? "bg-white/8 text-slate-200"
699
: "bg-background text-foreground/85"
700
: isTerminal
701
? "text-slate-400 hover:text-slate-300"
@@ -901,7 +711,7 @@ function FullTunnelCommandForm({
711
"flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
712
os === "windows"
713
? isTerminal
904
- ? "bg-white/[0.08] text-slate-200"
714
+ ? "bg-white/8 text-slate-200"
715
: "bg-background text-foreground/85"
716
: isTerminal
717
? "text-slate-400 hover:text-slate-300"
frontend/src/components/TunnelCommandModal.tsx
+1
-1
@@ -20,7 +20,7 @@ export function TunnelCommandModal() {
20
Add Your Server
21
</Button>
22
</DialogTrigger>
23
- <DialogContent className="sm:max-w-[560px] max-h-[85vh] overflow-y-auto rounded-[1.5rem] border border-border bg-card p-0">
23
+ <DialogContent className="sm:max-w-140 max-h-[85vh] overflow-y-auto rounded-3xl border border-border bg-card p-0">
24
<DialogHeader className="border-b border-border px-5 py-4 text-left">
25
<DialogTitle className="text-xl font-bold">Add Your Server</DialogTitle>
26
<DialogDescription className="pt-1 leading-6">
frontend/src/components/select/SortbySelect.tsx
+1
-1
@@ -24,7 +24,7 @@ export const SortbySelect = ({
24
<Select value={sortBy} onValueChange={onSortByChange}>
25
<SelectTrigger
26
className={clsx(
27
- "w-[150px] h-10 border-border!",
27
+ "w-37.5 h-10 border-border!",
28
hideFiltersOnMobile && "hidden sm:flex",
29
className
30
)}
frontend/src/components/select/StatusSelect.tsx
+1
-1
@@ -24,7 +24,7 @@ export const StatusSelect = ({
24
<Select value={status} onValueChange={onStatusChange}>
25
<SelectTrigger
26
className={clsx(
27
- "w-[130px] h-10 border-border!",
27
+ "w-32.5 h-10 border-border!",
28
hideFiltersOnMobile && "hidden sm:flex",
29
className
30
)}
frontend/src/hooks/useTunnelCommand.ts
new
+186
@@ -0,0 +1,186 @@
1
+import { useEffect, useMemo, useState, type ChangeEvent } from "react";
2
+import {
3
+ buildDefaultTunnelName,
4
+ buildTunnelCommand,
5
+ buildTunnelDisplayCommand,
6
+ normalizeTunnelCommandName,
7
+ type TunnelCommandOS,
8
+} from "@/lib/tunnelCommand";
9
+
10
+export const DEFAULT_HOST = "3000";
11
+
12
+const FALLBACK_ORIGIN = "https://localhost:4017";
13
+const TUNNEL_NAME_SEED_STORAGE_KEY = "portal:tunnel-name-seed";
14
+
15
+export function readCurrentOrigin(): string {
16
+ if (typeof window !== "undefined") {
17
+ return window.location.origin;
18
+ }
19
+
20
+ return FALLBACK_ORIGIN;
21
+}
22
+
23
+function readTunnelNameSeed(): string {
24
+ if (typeof window === "undefined") {
25
+ return "web_portal";
26
+ }
27
+
28
+ try {
29
+ const existing = window.localStorage.getItem(TUNNEL_NAME_SEED_STORAGE_KEY);
30
+ if (existing && existing.trim() !== "") {
31
+ return existing;
32
+ }
33
+
34
+ const next =
35
+ typeof window.crypto?.randomUUID === "function"
36
+ ? `web_${window.crypto.randomUUID()}`
37
+ : `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
38
+
39
+ window.localStorage.setItem(TUNNEL_NAME_SEED_STORAGE_KEY, next);
40
+ return next;
41
+ } catch {
42
+ return "web_portal";
43
+ }
44
+}
45
+
46
+function nextTunnelNameShuffleKey(): string {
47
+ if (
48
+ typeof window !== "undefined" &&
49
+ typeof window.crypto?.randomUUID === "function"
50
+ ) {
51
+ return window.crypto.randomUUID();
52
+ }
53
+
54
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
55
+}
56
+
57
+function splitDisplayCommand(command: string, os: TunnelCommandOS) {
58
+ const lines = command.split("\n");
59
+ const installLineCount = os === "windows" ? 2 : 1;
60
+
61
+ return {
62
+ installBlock: lines.slice(0, installLineCount).join("\n"),
63
+ runBlock: lines.slice(installLineCount).join("\n"),
64
+ };
65
+}
66
+
67
+interface TunnelCommandExtras {
68
+ relayUrls?: string[];
69
+ defaultRelays?: boolean;
70
+ thumbnailURL?: string;
71
+ enableUDP?: boolean;
72
+ udpPort?: string;
73
+}
74
+
75
+export function useTunnelCommand(extras: TunnelCommandExtras = {}) {
76
+ const currentOrigin = useMemo(readCurrentOrigin, []);
77
+ const nameSeed = useMemo(readTunnelNameSeed, []);
78
+
79
+ const [target, setTarget] = useState(DEFAULT_HOST);
80
+ const [name, setName] = useState("");
81
+ const [nameShuffleKey, setNameShuffleKey] = useState("default");
82
+ const [copied, setCopied] = useState(false);
83
+ const [os, setOs] = useState<TunnelCommandOS>("unix");
84
+
85
+ const resolvedNameSeed = useMemo(
86
+ () => `${nameSeed}:${nameShuffleKey}`,
87
+ [nameSeed, nameShuffleKey]
88
+ );
89
+ const generatedName = useMemo(
90
+ () => buildDefaultTunnelName(target, resolvedNameSeed),
91
+ [resolvedNameSeed, target]
92
+ );
93
+ const normalizedName = useMemo(
94
+ () => normalizeTunnelCommandName(name),
95
+ [name]
96
+ );
97
+ const effectiveName = normalizedName === "" ? generatedName : normalizedName;
98
+ const commandOptions = useMemo(
99
+ () => ({
100
+ currentOrigin,
101
+ target,
102
+ name: effectiveName,
103
+ nameSeed,
104
+ relayUrls: extras.relayUrls ?? [currentOrigin],
105
+ defaultRelays: extras.defaultRelays ?? true,
106
+ thumbnailURL: extras.thumbnailURL ?? "",
107
+ enableUDP: extras.enableUDP ?? false,
108
+ udpPort: extras.udpPort ?? "",
109
+ os,
110
+ }),
111
+ [
112
+ currentOrigin,
113
+ effectiveName,
114
+ extras.defaultRelays,
115
+ extras.enableUDP,
116
+ extras.relayUrls,
117
+ extras.thumbnailURL,
118
+ extras.udpPort,
119
+ nameSeed,
120
+ os,
121
+ target,
122
+ ]
123
+ );
124
+ const copyCommand = useMemo(
125
+ () => buildTunnelCommand(commandOptions),
126
+ [commandOptions]
127
+ );
128
+ const displayCommand = useMemo(
129
+ () => buildTunnelDisplayCommand(commandOptions),
130
+ [commandOptions]
131
+ );
132
+ const { installBlock, runBlock } = useMemo(
133
+ () => splitDisplayCommand(displayCommand, os),
134
+ [displayCommand, os]
135
+ );
136
+
137
+ useEffect(() => {
138
+ if (!copied) {
139
+ return;
140
+ }
141
+
142
+ const timer = window.setTimeout(() => {
143
+ setCopied(false);
144
+ }, 2000);
145
+
146
+ return () => {
147
+ window.clearTimeout(timer);
148
+ };
149
+ }, [copied]);
150
+
151
+ const handleCopy = async () => {
152
+ try {
153
+ await navigator.clipboard.writeText(copyCommand);
154
+ setCopied(true);
155
+ } catch (error) {
156
+ console.error("Failed to copy tunnel command", error);
157
+ }
158
+ };
159
+
160
+ const handleNameChange = (event: ChangeEvent<HTMLInputElement>) => {
161
+ setName(event.target.value);
162
+ };
163
+
164
+ const handleShuffleName = () => {
165
+ setName("");
166
+ setNameShuffleKey(nextTunnelNameShuffleKey());
167
+ };
168
+
169
+ return {
170
+ currentOrigin,
171
+ nameSeed,
172
+ target,
173
+ setTarget,
174
+ name,
175
+ copied,
176
+ os,
177
+ setOs,
178
+ generatedName,
179
+ effectiveName,
180
+ installBlock,
181
+ runBlock,
182
+ handleCopy,
183
+ handleNameChange,
184
+ handleShuffleName,
185
+ };
186
+}