update('depthFontSize', fs.value)}
>
{fs.label}
diff --git a/apps/web/src/features/mapToggles/MapToggles.tsx b/apps/web/src/features/mapToggles/MapToggles.tsx
index cb4e550..1ee204b 100644
--- a/apps/web/src/features/mapToggles/MapToggles.tsx
+++ b/apps/web/src/features/mapToggles/MapToggles.tsx
@@ -1,3 +1,5 @@
+import { ToggleButton } from '@wing/ui';
+
export type MapToggleState = {
pairLines: boolean;
pairRange: boolean;
@@ -27,11 +29,16 @@ export function MapToggles({ value, onToggle }: Props) {
];
return (
-
+
{items.map((t) => (
-
onToggle(t.id)}>
+ onToggle(t.id)}
+ className="flex-[1_1_calc(25%-4px)] overflow-hidden text-center text-ellipsis whitespace-nowrap"
+ >
{t.label}
-
+
))}
);
diff --git a/apps/web/src/features/typeFilter/TypeFilterGrid.tsx b/apps/web/src/features/typeFilter/TypeFilterGrid.tsx
index 085acbe..d947e09 100644
--- a/apps/web/src/features/typeFilter/TypeFilterGrid.tsx
+++ b/apps/web/src/features/typeFilter/TypeFilterGrid.tsx
@@ -9,26 +9,26 @@ type Props = {
onToggleAll: () => void;
};
+const TB = "cursor-pointer rounded-[5px] border p-1 text-center transition-all duration-150 select-none";
+const TB_ON = "border-wing-accent bg-wing-accent/10";
+const TB_OFF = "border-transparent bg-wing-card hover:border-wing-border";
+
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
return (
-
-
-
- 전체
-
-
{totalCount}척
+
+
{VESSEL_TYPE_ORDER.map((code) => {
const t = VESSEL_TYPES[code];
const cnt = countsByType[code] ?? 0;
return (
-
onToggle(code)}>
-
- {code}
-
-
{cnt}척
+
onToggle(code)}>
+
{code}
+
{cnt}척
);
})}
diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx
index 8ab706c..ae15cae 100644
--- a/apps/web/src/pages/dashboard/DashboardPage.tsx
+++ b/apps/web/src/pages/dashboard/DashboardPage.tsx
@@ -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 ──
@@ -245,7 +247,7 @@ export function DashboardPage() {
// ── Render ──
return (
-
+
-
+
{showMapLoader ? (
diff --git a/apps/web/src/pages/dashboard/DashboardSidebar.tsx b/apps/web/src/pages/dashboard/DashboardSidebar.tsx
index 82ef6b3..cc6991f 100644
--- a/apps/web/src/pages/dashboard/DashboardSidebar.tsx
+++ b/apps/web/src/pages/dashboard/DashboardSidebar.tsx
@@ -1,3 +1,4 @@
+import { ToggleButton } from '@wing/ui';
import type { AisTarget } from '../../entities/aisTarget/model/types';
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
@@ -94,12 +95,12 @@ export function DashboardSidebar({
} = state;
return (
-
+
업종 필터
-
-
+ {
setShowTargets((v) => {
const next = !v;
@@ -110,10 +111,10 @@ export function DashboardSidebar({
title="레거시(CN permit) 대상 선박 표시"
>
대상 선박
-
-
setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
+
+ setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
기타 AIS
-
+
지도 표시 설정
- setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영: 드래그로 회전, 휠로 확대/축소'}
- style={{ fontSize: 9, padding: '2px 8px', opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? 'not-allowed' : 'pointer' }}
+ className="px-2 py-0.5 text-[9px]"
+ style={{ opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? 'not-allowed' : 'pointer' }}
>
3D
-
+
setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
diff --git a/apps/web/src/shared/hooks/index.ts b/apps/web/src/shared/hooks/index.ts
index 2f9fca0..77617d0 100644
--- a/apps/web/src/shared/hooks/index.ts
+++ b/apps/web/src/shared/hooks/index.ts
@@ -1 +1,2 @@
export { usePersistedState } from './usePersistedState';
+export { useTheme } from './useTheme';
diff --git a/apps/web/src/shared/hooks/useTheme.ts b/apps/web/src/shared/hooks/useTheme.ts
new file mode 100644
index 0000000..9556057
--- /dev/null
+++ b/apps/web/src/shared/hooks/useTheme.ts
@@ -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
(() => {
+ 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;
+}
diff --git a/apps/web/src/widgets/topbar/Topbar.tsx b/apps/web/src/widgets/topbar/Topbar.tsx
index da1230a..833f1ad 100644
--- a/apps/web/src/widgets/topbar/Topbar.tsx
+++ b/apps/web/src/widgets/topbar/Topbar.tsx
@@ -11,49 +11,69 @@ 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 (
-
-
- 🛰
WING 조업감시·선단연관 {adminMode ?
(ADMIN) : null}
+
+
+ 🛰 WING 조업감시·선단연관{" "}
+ {adminMode ? (ADMIN) : null}
-
-
+
+
DATA API
-
+
POLL{" "}
{pollingStatus.toUpperCase()}
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
-
- 전체
{total}척
+
+ 전체 {total}척
-
+
조업 {fishing}
-
+
항해 {transit}
-
+
쌍연결 {pairLinks}
-
-
{clock}
+
{clock}
+ {onToggleTheme && (
+
+ )}
{userName && (
-
-
{userName}
+
+
{userName}
{onLogout && (
-