feat: useTheme 훅 + 테마 토글 버튼 구현

- useTheme: localStorage 기반 다크/라이트 테마 전환
- data-theme 속성으로 CSS 변수 자동 전환
- Topbar에 Light/Dark 토글 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-17 06:22:49 +09:00
부모 2adcbc9a93
커밋 a132c7eaf8
4개의 변경된 파일64개의 추가작업 그리고 1개의 파일을 삭제

파일 보기

@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useAuth } from "../../shared/auth";
import { useTheme } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
@ -58,6 +59,7 @@ function useLegacyIndex(data: LegacyVesselDataset | null) {
export function DashboardPage() {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const uid = user?.id ?? null;
// ── Data fetching ──
@ -259,6 +261,8 @@ export function DashboardPage() {
onLogoClick={onLogoClick}
userName={user?.name}
onLogout={logout}
theme={theme}
onToggleTheme={toggleTheme}
/>
<DashboardSidebar

파일 보기

@ -1 +1,2 @@
export { usePersistedState } from './usePersistedState';
export { useTheme } from './useTheme';

파일 보기

@ -0,0 +1,47 @@
import { useState, useEffect, useCallback } from 'react';
type Theme = 'dark' | 'light';
const STORAGE_KEY = 'wing:theme';
const DEFAULT_THEME: Theme = 'dark';
function readTheme(): Theme {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'light' || raw === 'dark') return raw;
} catch {
// storage unavailable
}
return DEFAULT_THEME;
}
function applyTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
const t = readTheme();
applyTheme(t);
return t;
});
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
// quota exceeded or unavailable
}
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}, [theme, setTheme]);
return { theme, setTheme, toggleTheme } as const;
}

파일 보기

@ -11,9 +11,11 @@ type Props = {
onLogoClick?: () => void;
userName?: string;
onLogout?: () => void;
theme?: "dark" | "light";
onToggleTheme?: () => void;
};
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme }: Props) {
const statusColor =
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
return (
@ -55,6 +57,15 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStat
</div>
</div>
<div className="ml-2.5 whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</div>
{onToggleTheme && (
<button
className="ml-1 cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
onClick={onToggleTheme}
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
>
{theme === "dark" ? "Light" : "Dark"}
</button>
)}
{userName && (
<div className="ml-2.5 flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[10px] font-medium text-wing-text">{userName}</span>