frontend: restore dark / light mode
Kim committed
Mar 19, 2026 at 22:59 UTC
0a60cebc65ce80c4aebd420084b09064a1545b4b
10 files changed
+420
-161
frontend/index.html
+21
-3
@@ -1,9 +1,9 @@
1
<!DOCTYPE html>
2
-<html lang="en" class="dark">
2
+<html lang="en">
3
<head>
4
<meta charset="UTF-8" />
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="color-scheme" content="dark" />
6
+ <meta name="color-scheme" content="light dark" />
7
<meta
8
name="description"
9
content="Transform your local services into web-accessible endpoints. Instant access from anywhere."
@@ -18,9 +18,27 @@
18
<meta name="portal-release-version" content="[%RELEASE_VERSION%]" />
19
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
20
<title>Portal - Local to web. Instant access.</title>
21
+ <script>
22
+ (() => {
23
+ const defaultTheme = "light";
24
+
25
+ try {
26
+ const storedTheme = localStorage.getItem("theme");
27
+ const theme =
28
+ storedTheme === "light" || storedTheme === "dark"
29
+ ? storedTheme
30
+ : defaultTheme;
31
+
32
+ document.documentElement.classList.toggle("dark", theme === "dark");
33
+ document.documentElement.style.colorScheme = theme;
34
+ } catch {
35
+ document.documentElement.style.colorScheme = defaultTheme;
36
+ }
37
+ })();
38
+ </script>
39
</head>
40
23
- <body class="dark">
41
+ <body>
42
<div id="root"></div>
43
<script type="module" src="/src/main.tsx"></script>
44
</body>
frontend/src/components/Header.tsx
+19
-4
@@ -1,6 +1,7 @@
1
import { useId } from "react";
2
import { LogOut } from "lucide-react";
3
import { Button } from "@/components/ui/button";
4
+import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5
import {
6
Tooltip,
7
TooltipContent,
@@ -44,9 +45,18 @@ export function Header({
45
x2="100%"
46
y2="100%"
47
>
47
- <stop offset="0%" stopColor="#22c55e" />
48
- <stop offset="55%" stopColor="#14b8a6" />
49
- <stop offset="100%" stopColor="#0ea5e9" />
48
+ <stop
49
+ offset="0%"
50
+ style={{ stopColor: "var(--accent-orange)" }}
51
+ />
52
+ <stop
53
+ offset="55%"
54
+ style={{ stopColor: "var(--neon-cyan)" }}
55
+ />
56
+ <stop
57
+ offset="100%"
58
+ style={{ stopColor: "var(--accent-magenta)" }}
59
+ />
60
</linearGradient>
61
</defs>
62
<path
@@ -73,7 +83,10 @@ export function Header({
83
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
84
{!isAdmin && (
85
<nav className="hidden items-center gap-6 text-[15px] font-medium text-text-muted md:flex">
76
- <a href="#live-servers" className="transition-colors hover:text-foreground">
86
+ <a
87
+ href="#live-servers"
88
+ className="transition-colors hover:text-foreground"
89
+ >
90
Live apps
91
</a>
92
<a
@@ -118,6 +131,8 @@ export function Header({
131
</a>
132
)}
133
134
+ <ThemeToggleButton />
135
+
136
{isAdmin && onLogout && (
137
<TooltipProvider>
138
<Tooltip>
frontend/src/components/LandingHero.tsx
+28
-5
@@ -11,7 +11,13 @@ export function LandingHero() {
11
aria-hidden="true"
12
className="pointer-events-none absolute inset-0"
13
>
14
- <div className="absolute inset-0 opacity-45 [background-image:radial-gradient(rgba(99,102,241,0.12)_0.8px,transparent_0.8px)] [background-size:14px_14px] [mask-image:linear-gradient(to_bottom,white,transparent_82%)]" />
14
+ <div
15
+ className="absolute inset-0 opacity-45 [background-size:14px_14px] [mask-image:linear-gradient(to_bottom,white,transparent_82%)]"
16
+ style={{
17
+ backgroundImage:
18
+ "radial-gradient(var(--hero-grid-dot) 0.8px, transparent 0.8px)",
19
+ }}
20
+ />
21
</div>
22
23
<a
@@ -27,7 +33,13 @@ export function LandingHero() {
33
className="text-5xl font-extrabold tracking-tight text-foreground sm:text-6xl lg:text-7xl"
34
>
35
<span className="block">Expose Local Apps</span>
30
- <span className="mt-2 block bg-[linear-gradient(90deg,#7c6cff_0%,#6c8fff_46%,#57c6ff_100%)] bg-clip-text text-transparent">
36
+ <span
37
+ className="mt-2 block bg-clip-text text-transparent"
38
+ style={{
39
+ backgroundImage:
40
+ "linear-gradient(90deg, var(--hero-gradient-start) 0%, var(--hero-gradient-mid) 46%, var(--hero-gradient-end) 100%)",
41
+ }}
42
+ >
43
To The Public Internet
44
</span>
45
</h1>
@@ -37,12 +49,23 @@ export function LandingHero() {
49
</p>
50
</div>
51
40
- <div className="relative mx-auto mt-10 max-w-[560px] rounded-[1.75rem] border border-white/10 bg-slate-950 p-5 text-white shadow-[0_30px_72px_rgba(15,23,42,0.22)] sm:p-6">
52
+ <div
53
+ className="relative mx-auto mt-10 max-w-[560px] rounded-[1.75rem] border p-5 sm:p-6"
54
+ style={{
55
+ background: "var(--hero-terminal-bg)",
56
+ borderColor: "var(--hero-terminal-border)",
57
+ color: "var(--hero-terminal-foreground)",
58
+ boxShadow: "0 30px 72px var(--hero-terminal-shadow)",
59
+ }}
60
+ >
61
<div className="mb-5 flex items-center gap-3">
42
- <Terminal className="h-5 w-5 text-green-status" />
62
+ <Terminal
63
+ className="h-5 w-5"
64
+ style={{ color: "var(--hero-terminal-accent)" }}
65
+ />
66
<h2
67
id="tunnel-preview"
45
- className="text-2xl font-bold tracking-tight text-white"
68
+ className="text-2xl font-bold tracking-tight"
69
>
70
Run this command
71
</h2>
frontend/src/components/ServerCard.tsx
+10
-10
@@ -1,6 +1,6 @@
1
+import { useMemo, useState } from "react";
2
import { Link } from "react-router-dom";
3
import clsx from "clsx";
3
-import { useMemo, useState } from "react";
4
import {
5
Dialog,
6
DialogContent,
@@ -233,13 +233,16 @@ export function ServerCard({
233
style={{
234
backgroundImage: thumbnail
235
? `linear-gradient(rgba(255,255,255,0.08), rgba(255,255,255,0.08)), url(${thumbnail})`
236
- : "linear-gradient(135deg, oklch(99.4% 0.004 85) 0%, oklch(94.6% 0.008 85) 100%)",
236
+ : "linear-gradient(135deg, var(--card-media-fallback-start) 0%, var(--card-media-fallback-end) 100%)",
237
backgroundSize: "cover",
238
backgroundPosition: "center",
239
}}
240
>
241
{!thumbnail && (
242
- <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_oklch(68%_0.19_34_/_0.24),_transparent_36%),linear-gradient(180deg,_transparent,_oklch(100%_0_0_/_0.72))]" />
242
+ <div
243
+ className="absolute inset-0"
244
+ style={{ background: "var(--card-media-overlay)" }}
245
+ />
246
)}
247
248
<div className="absolute inset-x-0 top-0 flex items-start justify-between p-4">
@@ -313,7 +316,7 @@ export function ServerCard({
316
<div className="flex flex-1 flex-col justify-between gap-4 p-5">
317
<div className="space-y-4">
318
<div className="space-y-2">
316
- <h3 className="text-2xl font-bold tracking-tight text-foreground truncate">
319
+ <h3 className="truncate text-2xl font-bold tracking-tight text-foreground">
320
{name}
321
</h3>
322
{description && (
@@ -353,10 +356,7 @@ export function ServerCard({
356
{onBPSChange && (
357
<div className="flex items-center justify-between gap-4">
358
<span className="text-xs text-text-muted">
356
- BPS:{" "}
357
- <span className="font-medium text-foreground">
358
- {formatBPS(bps)}
359
- </span>
359
+ BPS: <span className="font-medium text-foreground">{formatBPS(bps)}</span>
360
</span>
361
<button
362
onClick={handleBPSSettingsClick}
@@ -408,8 +408,8 @@ export function ServerCard({
408
? "Unban IP"
409
: "Ban IP"
410
: isBanned
411
- ? "Unban"
412
- : "Ban"}
411
+ ? "Unban"
412
+ : "Ban"}
413
</button>
414
)}
415
</div>
frontend/src/components/ThemeProvider.tsx
new
+96
@@ -0,0 +1,96 @@
1
+import {
2
+ createContext,
3
+ startTransition,
4
+ useContext,
5
+ useLayoutEffect,
6
+ useState,
7
+ type PropsWithChildren,
8
+} from "react";
9
+
10
+export type Theme = "light" | "dark";
11
+
12
+const DEFAULT_THEME: Theme = "light";
13
+const THEME_STORAGE_KEY = "theme";
14
+
15
+interface ThemeContextValue {
16
+ theme: Theme;
17
+ toggleTheme: () => void;
18
+}
19
+
20
+const ThemeContext = createContext<ThemeContextValue | null>(null);
21
+
22
+function isTheme(value: string | null): value is Theme {
23
+ return value === "light" || value === "dark";
24
+}
25
+
26
+function readStoredTheme(): Theme | null {
27
+ if (typeof window === "undefined") {
28
+ return null;
29
+ }
30
+
31
+ try {
32
+ const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
33
+ return isTheme(storedTheme) ? storedTheme : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+}
38
+
39
+function getInitialTheme(): Theme {
40
+ if (typeof document === "undefined") {
41
+ return DEFAULT_THEME;
42
+ }
43
+
44
+ const storedTheme = readStoredTheme();
45
+ if (storedTheme) {
46
+ return storedTheme;
47
+ }
48
+
49
+ return document.documentElement.classList.contains("dark")
50
+ ? "dark"
51
+ : DEFAULT_THEME;
52
+}
53
+
54
+function applyTheme(theme: Theme) {
55
+ const root = document.documentElement;
56
+ root.classList.toggle("dark", theme === "dark");
57
+ root.style.colorScheme = theme;
58
+}
59
+
60
+export function ThemeProvider({ children }: PropsWithChildren) {
61
+ const [theme, setThemeState] = useState<Theme>(getInitialTheme);
62
+
63
+ useLayoutEffect(() => {
64
+ applyTheme(theme);
65
+ }, [theme]);
66
+
67
+ const updateTheme = (nextTheme: Theme) => {
68
+ try {
69
+ window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
70
+ } catch {
71
+ // Ignore storage failures and still apply the theme locally.
72
+ }
73
+
74
+ startTransition(() => {
75
+ setThemeState(nextTheme);
76
+ });
77
+ };
78
+
79
+ const toggleTheme = () => {
80
+ updateTheme(theme === "dark" ? "light" : "dark");
81
+ };
82
+
83
+ return (
84
+ <ThemeContext.Provider value={{ theme, toggleTheme }}>
85
+ {children}
86
+ </ThemeContext.Provider>
87
+ );
88
+}
89
+
90
+export function useTheme() {
91
+ const value = useContext(ThemeContext);
92
+ if (!value) {
93
+ throw new Error("useTheme must be used within ThemeProvider");
94
+ }
95
+ return value;
96
+}
frontend/src/components/ThemeToggleButton.tsx
new
+34
@@ -0,0 +1,34 @@
1
+import { Moon, Sun } from "lucide-react";
2
+import clsx from "clsx";
3
+import { Button } from "@/components/ui/button";
4
+import { useTheme } from "@/components/ThemeProvider";
5
+
6
+interface ThemeToggleButtonProps {
7
+ className?: string;
8
+}
9
+
10
+export function ThemeToggleButton({ className }: ThemeToggleButtonProps) {
11
+ const { theme, toggleTheme } = useTheme();
12
+ const nextTheme = theme === "dark" ? "light" : "dark";
13
+
14
+ return (
15
+ <Button
16
+ type="button"
17
+ variant="outline"
18
+ size="icon"
19
+ onClick={toggleTheme}
20
+ className={clsx(
21
+ "h-11 w-11 cursor-pointer rounded-full border-border bg-card/95 text-foreground shadow-none hover:bg-secondary",
22
+ className
23
+ )}
24
+ aria-label={`Switch to ${nextTheme} theme`}
25
+ title={theme === "dark" ? "Light theme" : "Dark theme"}
26
+ >
27
+ {theme === "dark" ? (
28
+ <Sun className="h-5 w-5" />
29
+ ) : (
30
+ <Moon className="h-5 w-5" />
31
+ )}
32
+ </Button>
33
+ );
34
+}
frontend/src/components/TunnelCommandForm.tsx
+85
-93
@@ -6,7 +6,7 @@ import {
6
type ChangeEvent,
7
type KeyboardEvent,
8
} from "react";
9
-import { Check, Copy, X } from "lucide-react";
9
+import { Check, Copy, RefreshCw, X } from "lucide-react";
10
import { Input } from "@/components/ui/input";
11
import { apiClient } from "@/lib/apiClient";
12
import { API_PATHS } from "@/lib/apiPaths";
@@ -46,13 +46,13 @@ export function TunnelCommandForm({
46
const isTerminal = theme === "terminal";
47
const isHero = mode === "hero";
48
49
- const currentOrigin = useMemo(() => {
49
+ const [currentOrigin] = useState(() => {
50
if (typeof window !== "undefined") {
51
return window.location.origin;
52
}
53
return "https://localhost:4017";
54
- }, []);
55
- const nameSeed = useMemo(() => {
54
+ });
55
+ const [nameSeed] = useState(() => {
56
if (typeof window === "undefined") {
57
return "web_portal";
58
}
@@ -73,7 +73,7 @@ export function TunnelCommandForm({
73
} catch {
74
return "web_portal";
75
}
76
- }, []);
76
+ });
77
78
const [target, setTarget] = useState(defaultHost);
79
const [name, setName] = useState("");
@@ -86,6 +86,7 @@ export function TunnelCommandForm({
86
const [os, setOs] = useState<TunnelCommandOS>("unix");
87
const [thumbnailURL, setThumbnailURL] = useState("");
88
const [tunnelStatus, setTunnelStatus] = useState<TunnelStatus>("waiting");
89
+
90
const resolvedNameSeed = useMemo(
91
() => `${nameSeed}:${nameShuffleKey}`,
92
[nameSeed, nameShuffleKey]
@@ -95,12 +96,10 @@ export function TunnelCommandForm({
96
[resolvedNameSeed, target]
97
);
98
const effectiveName = isAutoName ? generatedName : name;
98
-
99
const normalizedThumbnailURL = useMemo(
100
() => normalizeAbsoluteHTTPURL(thumbnailURL),
101
[thumbnailURL]
102
);
103
-
103
const thumbnailError = useMemo(() => {
104
if (thumbnailURL.trim() === "" || normalizedThumbnailURL !== "") {
105
return "";
@@ -108,55 +107,43 @@ export function TunnelCommandForm({
107
108
return "Thumbnail must be an absolute http:// or https:// URL.";
109
}, [thumbnailURL, normalizedThumbnailURL]);
111
-
112
- const copyCommand = useMemo(
113
- () =>
114
- buildTunnelCommand({
115
- currentOrigin,
116
- target,
117
- name: effectiveName,
118
- nameSeed,
119
- relayUrls: isHero ? [currentOrigin] : relayUrls,
120
- defaultRelays: isHero ? true : defaultRelays,
121
- thumbnailURL: normalizedThumbnailURL,
122
- os,
123
- }),
124
- [
110
+ const resolvedRelayUrls = useMemo(
111
+ () => (isHero ? [currentOrigin] : relayUrls),
112
+ [currentOrigin, isHero, relayUrls]
113
+ );
114
+ const includeDefaultRelays = isHero || defaultRelays;
115
+ const resolvedThumbnailURL = isHero ? "" : normalizedThumbnailURL;
116
+ const commandOptions = useMemo(
117
+ () => ({
118
currentOrigin,
126
- defaultRelays,
127
- effectiveName,
128
- isHero,
119
+ target,
120
+ name: effectiveName,
121
nameSeed,
130
- normalizedThumbnailURL,
122
+ relayUrls: resolvedRelayUrls,
123
+ defaultRelays: includeDefaultRelays,
124
+ thumbnailURL: resolvedThumbnailURL,
125
os,
132
- relayUrls,
133
- target,
134
- ]
135
- );
136
- const displayCommand = useMemo(
137
- () =>
138
- buildTunnelDisplayCommand({
139
- currentOrigin,
140
- target,
141
- name: effectiveName,
142
- nameSeed,
143
- relayUrls: isHero ? [currentOrigin] : relayUrls,
144
- defaultRelays: isHero ? true : defaultRelays,
145
- thumbnailURL: normalizedThumbnailURL,
146
- os,
147
- }),
126
+ }),
127
[
128
currentOrigin,
150
- defaultRelays,
129
effectiveName,
152
- isHero,
130
+ includeDefaultRelays,
131
nameSeed,
154
- normalizedThumbnailURL,
132
os,
156
- relayUrls,
133
+ resolvedRelayUrls,
134
+ resolvedThumbnailURL,
135
target,
136
]
137
);
138
+
139
+ const copyCommand = useMemo(
140
+ () => buildTunnelCommand(commandOptions),
141
+ [commandOptions]
142
+ );
143
+ const displayCommand = useMemo(
144
+ () => buildTunnelDisplayCommand(commandOptions),
145
+ [commandOptions]
146
+ );
147
const previewURL = useMemo(
148
() => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed),
149
[currentOrigin, effectiveName, nameSeed, target]
@@ -202,6 +189,7 @@ export function TunnelCommandForm({
189
setTunnelStatus("waiting");
190
return;
191
}
192
+
193
setTunnelStatus(statusResponse.service_alive ? "alive" : "registered");
194
} catch {
195
if (!cancelled) {
@@ -285,24 +273,18 @@ export function TunnelCommandForm({
273
setIsAutoName(true);
274
setNameShuffleKey(next);
275
};
288
- const tunnelStatusTone =
289
- tunnelStatus === "alive"
290
- ? isTerminal
291
- ? "bg-green-400"
292
- : "bg-green-600"
293
- : tunnelStatus === "registered"
294
- ? isTerminal
295
- ? "bg-sky-400"
296
- : "bg-sky-600"
297
- : isTerminal
298
- ? "bg-slate-500"
299
- : "bg-slate-400";
300
- const tunnelStatusHeadline =
301
- tunnelStatus === "alive"
302
- ? "This URL is live now"
303
- : tunnelStatus === "registered"
304
- ? "URL reserved"
305
- : "Waiting";
276
+
277
+ const tunnelStatusTone = {
278
+ alive: isTerminal ? "bg-green-400" : "bg-green-600",
279
+ registered: isTerminal ? "bg-sky-400" : "bg-sky-600",
280
+ waiting: isTerminal ? "bg-slate-500" : "bg-slate-400",
281
+ }[tunnelStatus];
282
+ const tunnelStatusHeadline = {
283
+ alive: "This URL is live now",
284
+ registered: "URL reserved",
285
+ waiting: "Waiting",
286
+ }[tunnelStatus];
287
+ const isPreviewURLDisabled = tunnelStatus === "waiting";
288
const heroSectionLabelClass = cn(
289
"text-xs font-semibold uppercase tracking-[0.24em]",
290
isTerminal ? "text-slate-400" : "text-text-muted"
@@ -311,9 +293,7 @@ export function TunnelCommandForm({
293
const commandSection = (
294
<div className={cn("space-y-2", isHero && "space-y-3")}>
295
{isHero ? (
314
- <label className={heroSectionLabelClass}>
315
- Command
316
- </label>
296
+ <label className={heroSectionLabelClass}>Command</label>
297
) : (
298
<label
299
className={cn(
@@ -373,24 +353,37 @@ export function TunnelCommandForm({
353
)}
354
355
{isHero && (
376
- <>
377
- <div className="space-y-1.5 pt-1">
378
- <p className={heroSectionLabelClass}>Public URL</p>
356
+ <div className="space-y-1.5 pt-1">
357
+ <p className={heroSectionLabelClass}>Public URL</p>
358
+ <div
359
+ className={cn(
360
+ "space-y-4 rounded-xl border px-4 py-3",
361
+ isTerminal ? "border-white/10 bg-white/5" : "border-border bg-white"
362
+ )}
363
+ >
364
<div
365
className={cn(
381
- "space-y-4 rounded-xl border px-4 py-3",
382
- isTerminal ? "border-white/10 bg-white/5" : "border-border bg-white"
366
+ "flex items-center gap-2 text-sm font-semibold",
367
+ isTerminal ? "text-slate-200" : "text-foreground"
368
)}
369
>
385
- <div
370
+ <span
371
+ className={cn("h-2 w-2 rounded-full", tunnelStatusTone)}
372
+ aria-hidden="true"
373
+ />
374
+ <span>{tunnelStatusHeadline}</span>
375
+ </div>
376
+ {isPreviewURLDisabled ? (
377
+ <span
378
+ aria-disabled="true"
379
className={cn(
387
- "flex items-center gap-2 text-sm font-semibold",
388
- isTerminal ? "text-slate-200" : "text-foreground"
380
+ "block cursor-not-allowed overflow-x-auto whitespace-nowrap font-mono text-base font-medium opacity-60 sm:text-lg",
381
+ isTerminal ? "text-green-status" : "text-primary"
382
)}
383
>
391
- <span className={cn("h-2 w-2 rounded-full", tunnelStatusTone)} aria-hidden="true" />
392
- <span>{tunnelStatusHeadline}</span>
393
- </div>
384
+ {previewURL}
385
+ </span>
386
+ ) : (
387
<a
388
href={previewURL}
389
target="_blank"
@@ -402,9 +395,9 @@ export function TunnelCommandForm({
395
>
396
{previewURL}
397
</a>
405
- </div>
398
+ )}
399
</div>
407
- </>
400
+ </div>
401
)}
402
</div>
403
);
@@ -413,26 +406,23 @@ export function TunnelCommandForm({
406
"text-[11px] font-semibold uppercase tracking-[0.2em]",
407
isTerminal ? "text-slate-400" : "text-text-muted"
408
);
416
-
409
const heroInputClass = cn(
418
- "h-10 rounded-lg border text-sm shadow-none",
410
+ "h-12 rounded-xl border px-4 text-[15px] shadow-none",
411
isTerminal
412
? "border-white/8 bg-black/20 text-slate-100 placeholder:text-slate-500"
413
: "border-border bg-white"
414
);
415
const nameInputValue = isAutoName ? generatedName : name;
416
const shuffleButtonClass = cn(
425
- "shrink-0 rounded-lg border px-3 text-xs font-semibold transition-colors",
426
- isHero ? "h-10" : "h-12",
417
+ "inline-flex shrink-0 items-center justify-center border px-3 text-xs font-semibold transition-colors",
418
+ isHero ? "h-12 rounded-xl px-4" : "h-12 rounded-lg",
419
isTerminal
420
? "border-white/8 bg-black/20 text-slate-300 hover:text-white"
421
: "border-border bg-white text-text-muted hover:text-foreground"
422
);
423
const optionSectionLabel = isHero ? (
424
<div className="pt-1">
433
- <p className={heroSectionLabelClass}>
434
- Option
435
- </p>
425
+ <p className={heroSectionLabelClass}>OPTIONS</p>
426
</div>
427
) : null;
428
@@ -442,8 +432,8 @@ export function TunnelCommandForm({
432
{optionSectionLabel}
433
434
{isHero ? (
445
- <div className="grid gap-3 sm:grid-cols-2">
446
- <div className="space-y-1.5">
435
+ <div className="grid gap-4 sm:grid-cols-2">
436
+ <div className="space-y-2">
437
<label htmlFor={`${inputId}-host`} className={heroFieldLabelClass}>
438
Host
439
</label>
@@ -457,7 +447,7 @@ export function TunnelCommandForm({
447
/>
448
</div>
449
460
- <div className="space-y-1.5">
450
+ <div className="space-y-2">
451
<label htmlFor={`${inputId}-name`} className={heroFieldLabelClass}>
452
Public Name
453
</label>
@@ -474,8 +464,9 @@ export function TunnelCommandForm({
464
onClick={handleShuffleName}
465
className={shuffleButtonClass}
466
aria-label="Shuffle public name"
467
+ title="Shuffle public name"
468
>
478
- 🎲
469
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
470
</button>
471
</div>
472
</div>
@@ -535,8 +526,9 @@ export function TunnelCommandForm({
526
onClick={handleShuffleName}
527
className={shuffleButtonClass}
528
aria-label="Shuffle public name"
529
+ title="Shuffle public name"
530
>
539
- 🎲
531
+ <RefreshCw className="h-4 w-4" aria-hidden="true" />
532
</button>
533
</div>
534
</div>
@@ -668,7 +660,7 @@ export function TunnelCommandForm({
660
onClick={() => setOs("unix")}
661
className={cn(
662
"flex-1 rounded-lg px-3 transition-colors",
671
- isHero ? "py-1.5 text-xs font-semibold" : "py-2 text-sm font-medium",
663
+ isHero ? "py-2 text-sm font-semibold" : "py-2 text-sm font-medium",
664
os === "unix"
665
? isTerminal
666
? "bg-white text-slate-950 shadow-sm"
@@ -685,7 +677,7 @@ export function TunnelCommandForm({
677
onClick={() => setOs("windows")}
678
className={cn(
679
"flex-1 rounded-lg px-3 transition-colors",
688
- isHero ? "py-1.5 text-xs font-semibold" : "py-2 text-sm font-medium",
680
+ isHero ? "py-2 text-sm font-semibold" : "py-2 text-sm font-medium",
681
os === "windows"
682
? isTerminal
683
? "bg-white text-slate-950 shadow-sm"
frontend/src/index.css
+102
-25
@@ -1,6 +1,8 @@
1
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=Public+Sans:wght@400;500;600;700&display=swap");
2
@import "tailwindcss";
3
4
+@custom-variant dark (&:where(.dark, .dark *));
5
+
6
@theme {
7
--font-family-display: "Manrope", sans-serif;
8
--font-family-sans: "Public Sans", sans-serif;
@@ -30,34 +32,97 @@
32
--color-ring: var(--ring);
33
--color-green-status: var(--green-status);
34
--color-text-muted: var(--text-muted);
33
- --color-brand-coral: var(--brand-coral);
35
}
36
37
@layer base {
38
:root {
38
- --background: oklch(99.1% 0.001 0);
39
- --foreground: oklch(22% 0.01 260);
39
+ --background: oklch(97% 0.005 250);
40
+ --foreground: oklch(25% 0.02 250);
41
--card: oklch(100% 0 0);
41
- --card-foreground: oklch(22% 0.01 260);
42
+ --card-foreground: oklch(25% 0.02 250);
43
--popover: oklch(100% 0 0);
43
- --popover-foreground: oklch(22% 0.01 260);
44
- --primary: oklch(21% 0.008 260);
45
- --primary-foreground: oklch(99.4% 0.001 0);
46
- --secondary: oklch(96.7% 0.002 0);
47
- --secondary-foreground: oklch(33% 0.012 260);
48
- --muted: oklch(97.4% 0.001 0);
49
- --muted-foreground: oklch(50% 0.01 260);
50
- --accent: oklch(95.8% 0.002 0);
51
- --accent-foreground: oklch(22% 0.01 260);
52
- --destructive: oklch(60% 0.21 26);
53
- --destructive-foreground: oklch(99.4% 0.001 0);
54
- --border: oklch(90.8% 0.003 0);
55
- --input: oklch(100% 0 0);
56
- --ring: oklch(68% 0.19 149);
57
-
58
- --green-status: oklch(68% 0.19 149);
59
- --text-muted: oklch(49% 0.012 260);
60
- --brand-coral: oklch(68% 0.19 149);
44
+ --popover-foreground: oklch(25% 0.02 250);
45
+ --primary: oklch(75% 0.14 195);
46
+ --primary-foreground: oklch(100% 0 0);
47
+ --secondary: oklch(92% 0.01 250);
48
+ --secondary-foreground: oklch(45% 0.02 250);
49
+ --muted: oklch(95% 0.005 250);
50
+ --muted-foreground: oklch(55% 0.03 250);
51
+ --accent: oklch(92% 0.01 250);
52
+ --accent-foreground: oklch(25% 0.02 250);
53
+ --destructive: oklch(55% 0.2 25);
54
+ --destructive-foreground: oklch(100% 0 0);
55
+ --border: oklch(90% 0.01 250);
56
+ --input: oklch(95% 0.005 250);
57
+ --ring: oklch(75% 0.14 195);
58
+
59
+ --green-status: oklch(55% 0.16 150);
60
+ --text-muted: oklch(55% 0.03 250);
61
+ --accent-orange: oklch(60% 0.2 35);
62
+ --accent-magenta: oklch(50% 0.22 350);
63
+ --neon-cyan: oklch(75% 0.14 195);
64
+
65
+ --hero-grid-dot: oklch(75% 0.14 195 / 0.18);
66
+ --hero-gradient-start: var(--accent-orange);
67
+ --hero-gradient-mid: var(--neon-cyan);
68
+ --hero-gradient-end: var(--accent-magenta);
69
+ --hero-terminal-bg:
70
+ linear-gradient(160deg, oklch(19% 0.005 250 / 0.98), oklch(14% 0.008 250 / 0.98));
71
+ --hero-terminal-border: oklch(75% 0.14 195 / 0.22);
72
+ --hero-terminal-foreground: oklch(98% 0.01 0);
73
+ --hero-terminal-accent: var(--neon-cyan);
74
+ --hero-terminal-shadow: oklch(15% 0 0 / 0.18);
75
+
76
+ --card-media-fallback-start: oklch(97% 0.015 220);
77
+ --card-media-fallback-end: oklch(91% 0.04 195);
78
+ --card-media-overlay:
79
+ radial-gradient(circle at top left, oklch(75% 0.14 195 / 0.18), transparent 36%),
80
+ linear-gradient(180deg, transparent, oklch(100% 0 0 / 0.72));
81
+ }
82
+
83
+ .dark {
84
+ --background: oklch(15% 0 0);
85
+ --foreground: oklch(98% 0.01 0);
86
+ --card: oklch(20% 0 0);
87
+ --card-foreground: oklch(98% 0.01 0);
88
+ --popover: oklch(20% 0 0);
89
+ --popover-foreground: oklch(98% 0.01 0);
90
+ --primary: oklch(80% 0.14 195);
91
+ --primary-foreground: oklch(10% 0 0);
92
+ --secondary: oklch(25% 0.02 195);
93
+ --secondary-foreground: oklch(98% 0.01 0);
94
+ --muted: oklch(25% 0.01 0);
95
+ --muted-foreground: oklch(65% 0.02 0);
96
+ --accent: oklch(25% 0.03 195);
97
+ --accent-foreground: oklch(98% 0.01 0);
98
+ --destructive: oklch(62% 0.22 25);
99
+ --destructive-foreground: oklch(98% 0 0);
100
+ --border: oklch(100% 0 0 / 0.1);
101
+ --input: oklch(22% 0.01 0);
102
+ --ring: oklch(80% 0.14 195);
103
+
104
+ --green-status: oklch(75% 0.16 150);
105
+ --text-muted: oklch(65% 0.02 0);
106
+ --accent-orange: oklch(65% 0.2 35);
107
+ --accent-magenta: oklch(55% 0.25 350);
108
+ --neon-cyan: oklch(80% 0.14 195);
109
+
110
+ --hero-grid-dot: oklch(80% 0.14 195 / 0.22);
111
+ --hero-gradient-start: var(--neon-cyan);
112
+ --hero-gradient-mid: var(--accent-magenta);
113
+ --hero-gradient-end: var(--accent-orange);
114
+ --hero-terminal-bg:
115
+ linear-gradient(160deg, oklch(19% 0 0 / 0.78), oklch(12% 0.01 250 / 0.88));
116
+ --hero-terminal-border: oklch(80% 0.14 195 / 0.3);
117
+ --hero-terminal-foreground: oklch(98% 0.01 0);
118
+ --hero-terminal-accent: var(--neon-cyan);
119
+ --hero-terminal-shadow: oklch(0% 0 0 / 0.28);
120
+
121
+ --card-media-fallback-start: oklch(26% 0.03 235);
122
+ --card-media-fallback-end: oklch(18% 0.02 260);
123
+ --card-media-overlay:
124
+ radial-gradient(circle at top left, oklch(80% 0.14 195 / 0.26), transparent 36%),
125
+ linear-gradient(180deg, transparent, oklch(10% 0 0 / 0.78));
126
}
127
128
* {
@@ -66,6 +131,7 @@
131
132
html {
133
background-color: var(--background);
134
+ color-scheme: light;
135
}
136
137
@media (prefers-reduced-motion: no-preference) {
@@ -77,13 +143,24 @@
143
body {
144
min-height: 100vh;
145
background:
80
- radial-gradient(circle at top center, oklch(91% 0.055 149 / 0.16), transparent 44%),
81
- linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(99.2% 0.001 0) 54%, oklch(98.5% 0.002 0) 100%);
146
+ radial-gradient(circle at top center, oklch(75% 0.14 195 / 0.16), transparent 36%),
147
+ radial-gradient(circle at 12% 18%, oklch(60% 0.2 35 / 0.08), transparent 22%),
148
+ linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(97% 0.005 250) 54%, oklch(95% 0.005 250) 100%);
149
color: var(--foreground);
83
- color-scheme: light;
150
font-family: var(--font-family-sans);
151
}
152
153
+ .dark {
154
+ color-scheme: dark;
155
+ }
156
+
157
+ .dark body {
158
+ background:
159
+ radial-gradient(circle at top center, oklch(80% 0.14 195 / 0.16), transparent 32%),
160
+ radial-gradient(circle at 82% 10%, oklch(55% 0.25 350 / 0.12), transparent 20%),
161
+ linear-gradient(180deg, oklch(16% 0 0) 0%, oklch(13% 0.01 250) 54%, oklch(11% 0.01 250) 100%);
162
+ }
163
+
164
h1,
165
h2,
166
h3,
frontend/src/main.tsx
+23
-21
@@ -1,28 +1,30 @@
1
+import { Ssgoi } from "@ssgoi/react";
2
+import { hero } from "@ssgoi/react/view-transitions";
3
import ReactDOM from "react-dom/client";
4
import { BrowserRouter } from "react-router-dom";
5
+import { ThemeProvider } from "@/components/ThemeProvider";
6
import App from "./App.tsx";
7
import "./index.css";
5
-import { Ssgoi } from "@ssgoi/react";
6
-import { hero } from "@ssgoi/react/view-transitions";
8
9
ReactDOM.createRoot(document.getElementById("root")!).render(
9
- <Ssgoi
10
- config={{
11
- transitions: [
12
- {
13
- from: "/",
14
- to: "/server/*",
15
- transition: hero(),
16
- symmetric: true,
17
- },
18
- ],
19
- }}
20
- >
21
- {/* ⚠️ 중요: position: relative 필수! */}
22
- <div style={{ position: "relative", minHeight: "100vh" }}>
23
- <BrowserRouter>
24
- <App />
25
- </BrowserRouter>
26
- </div>
27
- </Ssgoi>
10
+ <ThemeProvider>
11
+ <Ssgoi
12
+ config={{
13
+ transitions: [
14
+ {
15
+ from: "/",
16
+ to: "/server/*",
17
+ transition: hero(),
18
+ symmetric: true,
19
+ },
20
+ ],
21
+ }}
22
+ >
23
+ <div style={{ position: "relative", minHeight: "100vh" }}>
24
+ <BrowserRouter>
25
+ <App />
26
+ </BrowserRouter>
27
+ </div>
28
+ </Ssgoi>
29
+ </ThemeProvider>
30
);
frontend/src/pages/AdminLogin.tsx
+2
@@ -1,6 +1,7 @@
1
import { useState, useEffect, FormEvent } from "react";
2
import { useNavigate } from "react-router-dom";
3
import { KeyRound, ShieldCheck } from "lucide-react";
4
+import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5
import { getReleaseVersion } from "@/lib/releaseVersion";
6
import { useAuth } from "@/hooks/useAuth";
7
@@ -92,6 +93,7 @@ export function AdminLogin() {
93
)}
94
</div>
95
</div>
96
+ <ThemeToggleButton />
97
</header>
98
99
{/* Main Content */}