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 +}