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 */}