diff --git a/apps/web/index.html b/apps/web/index.html index 8dd863c..d8ad38a 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,9 @@ + + + WING 조업감시 데모 diff --git a/apps/web/package.json b/apps/web/package.json index 629c7e4..ad99a55 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,21 +12,23 @@ "dependencies": { "@deck.gl/aggregation-layers": "^9.2.7", "@deck.gl/core": "^9.2.7", + "@deck.gl/extensions": "^9.2.7", "@deck.gl/geo-layers": "^9.2.7", "@deck.gl/layers": "^9.2.7", "@deck.gl/mapbox": "^9.2.7", "@maptiler/weather": "^3.1.1", "@react-oauth/google": "^0.13.4", + "@stomp/stompjs": "^7.2.1", + "@wing/ui": "*", "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.13.0", - "@deck.gl/extensions": "^9.2.7", - "@stomp/stompjs": "^7.2.1", "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -35,6 +37,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1" diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index a57393e..063b98e 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -1,12 +1,13 @@ /* ── Wing Fleet Dashboard – Style Entry Point ─────────────────── */ -@import "./styles/base.css"; -@import "./styles/layout.css"; +@import "tailwindcss"; +@import "@wing/ui/theme/tokens.css"; +@source "../../../../packages/ui/src/**/*.{ts,tsx}"; -/* Components */ -@import "./styles/components/topbar.css"; +@import "./styles/base.css"; + +/* Components (layout/topbar/toggles → inline Tailwind + @wing/ui) */ @import "./styles/components/panels.css"; -@import "./styles/components/toggles.css"; @import "./styles/components/speed.css"; @import "./styles/components/vessel-list.css"; @import "./styles/components/ais-list.css"; diff --git a/apps/web/src/app/styles/base.css b/apps/web/src/app/styles/base.css index 1902d59..2165e4c 100644 --- a/apps/web/src/app/styles/base.css +++ b/apps/web/src/app/styles/base.css @@ -1,23 +1,6 @@ -@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap"); - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --bg: #020617; - --panel: #0f172a; - --card: #1e293b; - --border: #1e3a5f; - --text: #e2e8f0; - --muted: #64748b; - --accent: #3b82f6; - - --crit: #ef4444; - --high: #f59e0b; -} +/* Google Fonts: loaded via index.html */ +/* CSS Reset: handled by Tailwind preflight */ +/* CSS Variables: defined in @wing/ui/theme/tokens.css */ html, body { diff --git a/apps/web/src/app/styles/components/ais-list.css b/apps/web/src/app/styles/components/ais-list.css index 3897468..79dd881 100644 --- a/apps/web/src/app/styles/components/ais-list.css +++ b/apps/web/src/app/styles/components/ais-list.css @@ -5,13 +5,13 @@ padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border); - background: rgba(30, 41, 59, 0.75); + background: var(--wing-card-alpha); color: var(--text); outline: none; } .ais-q::placeholder { - color: rgba(100, 116, 139, 0.9); + color: var(--wing-muted); } .ais-mode { @@ -55,8 +55,8 @@ } .ais-row:hover { - background: rgba(30, 41, 59, 0.6); - border-color: rgba(30, 58, 95, 0.8); + background: var(--wing-card-alpha); + border-color: var(--wing-border); } .ais-row.sel { @@ -118,8 +118,8 @@ .ais-badge.pn { color: var(--muted); - background: rgba(30, 41, 59, 0.55); - border-color: rgba(30, 58, 95, 0.9); + background: var(--wing-card-alpha); + border-color: var(--wing-border); font-weight: 700; } @@ -155,5 +155,5 @@ .ais-ts { font-size: 9px; - color: rgba(100, 116, 139, 0.9); + color: var(--wing-muted); } diff --git a/apps/web/src/app/styles/components/alarms.css b/apps/web/src/app/styles/components/alarms.css index 0b98003..2a94aa3 100644 --- a/apps/web/src/app/styles/components/alarms.css +++ b/apps/web/src/app/styles/components/alarms.css @@ -36,7 +36,7 @@ padding: 2px 8px; border-radius: 6px; border: 1px solid var(--border); - background: rgba(30, 41, 59, 0.55); + background: var(--wing-card-alpha); color: var(--text); font-size: 8px; font-weight: 700; @@ -58,7 +58,7 @@ padding: 6px; border-radius: 10px; border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.98); + background: var(--wing-glass-dense); box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55); } @@ -90,6 +90,6 @@ .alarm-filter__sep { height: 1px; - background: rgba(30, 58, 95, 0.85); + background: var(--wing-border); margin: 4px 0; } diff --git a/apps/web/src/app/styles/components/auth.css b/apps/web/src/app/styles/components/auth.css index 930b931..ef6b08e 100644 --- a/apps/web/src/app/styles/components/auth.css +++ b/apps/web/src/app/styles/components/auth.css @@ -5,7 +5,7 @@ display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, #020617 0%, #0f172a 50%, #020617 100%); + background: linear-gradient(135deg, var(--wing-bg) 0%, var(--wing-surface) 50%, var(--wing-bg) 100%); } .auth-card { diff --git a/apps/web/src/app/styles/components/map-panels.css b/apps/web/src/app/styles/components/map-panels.css index a2824cd..77cad02 100644 --- a/apps/web/src/app/styles/components/map-panels.css +++ b/apps/web/src/app/styles/components/map-panels.css @@ -5,7 +5,7 @@ bottom: 44px; right: 12px; z-index: 800; - background: rgba(15, 23, 42, 0.92); + background: var(--wing-glass); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; @@ -41,7 +41,7 @@ top: 12px; right: 12px; z-index: 800; - background: rgba(15, 23, 42, 0.95); + background: var(--wing-glass-dense); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; @@ -54,7 +54,7 @@ justify-content: space-between; font-size: 10px; padding: 2px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--wing-subtle); } .map-info .il { @@ -72,13 +72,13 @@ display: flex; align-items: center; justify-content: center; - background: rgba(2, 6, 23, 0.42); + background: var(--wing-overlay); pointer-events: auto; } .map-loader-overlay__panel { width: min(72vw, 320px); - background: rgba(15, 23, 42, 0.94); + background: var(--wing-glass-dense); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; @@ -185,7 +185,7 @@ .maplibregl-ctrl-group { border: 1px solid var(--border) !important; - background: rgba(15, 23, 42, 0.92) !important; + background: var(--wing-glass) !important; backdrop-filter: blur(8px); } @@ -197,14 +197,20 @@ border-top: 1px solid var(--border) !important; } -.maplibregl-ctrl-group button span { +:root .maplibregl-ctrl-group button span, +[data-theme='dark'] .maplibregl-ctrl-group button span { filter: invert(1); opacity: 0.9; } +[data-theme='light'] .maplibregl-ctrl-group button span { + filter: none; + opacity: 0.7; +} + .maplibregl-ctrl-attrib { font-size: 10px !important; - background: rgba(15, 23, 42, 0.75) !important; + background: var(--wing-glass) !important; color: var(--text) !important; border: 1px solid var(--border) !important; border-radius: 8px; diff --git a/apps/web/src/app/styles/components/map-settings.css b/apps/web/src/app/styles/components/map-settings.css index 23a8147..dc348f8 100644 --- a/apps/web/src/app/styles/components/map-settings.css +++ b/apps/web/src/app/styles/components/map-settings.css @@ -9,7 +9,7 @@ height: 29px; border-radius: 4px; border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.92); + background: var(--wing-glass); backdrop-filter: blur(8px); color: var(--muted); cursor: pointer; @@ -37,7 +37,7 @@ top: 10px; left: 48px; z-index: 850; - background: rgba(15, 23, 42, 0.95); + background: var(--wing-glass-dense); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; @@ -148,7 +148,7 @@ bottom: 44px; left: 10px; z-index: 800; - background: rgba(15, 23, 42, 0.92); + background: var(--wing-glass); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; diff --git a/apps/web/src/app/styles/components/relations.css b/apps/web/src/app/styles/components/relations.css index 3cc2596..de42ac7 100644 --- a/apps/web/src/app/styles/components/relations.css +++ b/apps/web/src/app/styles/components/relations.css @@ -53,7 +53,7 @@ /* Fleet network */ .fleet-card { - background: rgba(30, 41, 59, 0.8); + background: var(--wing-card-alpha); border: 1px solid var(--border); border-radius: 6px; padding: 8px; diff --git a/apps/web/src/app/styles/components/toggles.css b/apps/web/src/app/styles/components/toggles.css deleted file mode 100644 index 328818c..0000000 --- a/apps/web/src/app/styles/components/toggles.css +++ /dev/null @@ -1,75 +0,0 @@ -/* Type grid */ -.tg { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 3px; -} - -.tb { - background: var(--card); - border: 1px solid transparent; - border-radius: 5px; - padding: 4px; - cursor: pointer; - text-align: center; - transition: all 0.15s; - user-select: none; -} - -.tb:hover { - border-color: var(--border); -} - -.tb.on { - border-color: var(--accent); - background: rgba(59, 130, 246, 0.1); -} - -.tb .c { - font-size: 11px; - font-weight: 800; -} - -.tb .n { - font-size: 8px; - color: var(--muted); -} - -/* Toggles */ -.tog { - display: flex; - gap: 3px; - flex-wrap: wrap; - margin-bottom: 6px; -} - -.tog.tog-map { - /* Keep "지도 표시 설정" buttons in a predictable 2-row layout (4 columns). */ - gap: 4px; -} - -.tog.tog-map .tog-btn { - flex: 1 1 calc(25% - 4px); - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.tog-btn { - font-size: 8px; - padding: 2px 6px; - border-radius: 3px; - border: 1px solid var(--border); - background: var(--card); - color: var(--muted); - cursor: pointer; - transition: all 0.15s; - user-select: none; -} - -.tog-btn.on { - background: var(--accent); - color: #fff; - border-color: var(--accent); -} diff --git a/apps/web/src/app/styles/components/topbar.css b/apps/web/src/app/styles/components/topbar.css deleted file mode 100644 index dcb52c8..0000000 --- a/apps/web/src/app/styles/components/topbar.css +++ /dev/null @@ -1,84 +0,0 @@ -.topbar { - grid-column: 1/-1; - background: var(--panel); - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - padding: 0 14px; - gap: 10px; - z-index: 1000; -} - -.topbar .logo { - font-size: 14px; - font-weight: 800; - display: flex; - align-items: center; - gap: 6px; - white-space: nowrap; -} - -.topbar .logo span { - color: var(--accent); -} - -.topbar .stats { - display: flex; - gap: 14px; - margin-left: auto; - flex-wrap: wrap; - justify-content: flex-end; -} - -.topbar .stat { - font-size: 10px; - color: var(--muted); - display: flex; - align-items: center; - gap: 4px; -} - -.topbar .stat b { - color: var(--text); - font-size: 12px; -} - -.topbar .time { - font-size: 10px; - color: var(--accent); - font-weight: 600; - margin-left: 10px; - white-space: nowrap; -} - -.topbar-user { - display: flex; - align-items: center; - gap: 8px; - margin-left: 10px; - flex-shrink: 0; -} - -.topbar-user__name { - font-size: 10px; - color: var(--text); - font-weight: 500; - white-space: nowrap; -} - -.topbar-user__logout { - font-size: 9px; - color: var(--muted); - background: none; - border: 1px solid var(--border); - border-radius: 3px; - padding: 2px 6px; - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; -} - -.topbar-user__logout:hover { - color: var(--text); - border-color: var(--accent); -} diff --git a/apps/web/src/app/styles/components/weather-overlay.css b/apps/web/src/app/styles/components/weather-overlay.css index 3a3e5b7..74c528c 100644 --- a/apps/web/src/app/styles/components/weather-overlay.css +++ b/apps/web/src/app/styles/components/weather-overlay.css @@ -9,7 +9,7 @@ height: 29px; border-radius: 4px; border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.92); + background: var(--wing-glass); backdrop-filter: blur(8px); color: var(--muted); cursor: pointer; @@ -73,7 +73,7 @@ } .wo-panel { - background: rgba(15, 23, 42, 0.95); + background: var(--wing-glass-dense); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; @@ -123,7 +123,7 @@ padding: 6px 4px; border-radius: 6px; border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.03); + background: var(--wing-subtle); color: var(--muted); cursor: pointer; transition: all 0.15s; @@ -329,7 +329,7 @@ /* ── Weather Legend ── */ .wo-legend { - background: rgba(15, 23, 42, 0.85); + background: var(--wing-glass); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 6px; diff --git a/apps/web/src/app/styles/components/weather.css b/apps/web/src/app/styles/components/weather.css index f957d7a..54d34f4 100644 --- a/apps/web/src/app/styles/components/weather.css +++ b/apps/web/src/app/styles/components/weather.css @@ -9,7 +9,7 @@ height: 29px; border-radius: 4px; border: 1px solid var(--border); - background: rgba(15, 23, 42, 0.92); + background: var(--wing-glass); backdrop-filter: blur(8px); color: var(--muted); cursor: pointer; @@ -37,7 +37,7 @@ top: 130px; left: 48px; z-index: 850; - background: rgba(15, 23, 42, 0.95); + background: var(--wing-glass-dense); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; @@ -87,7 +87,7 @@ padding: 6px 8px; margin-bottom: 6px; border-radius: 0 4px 4px 0; - background: rgba(255, 255, 255, 0.03); + background: var(--wing-subtle); transition: background 0.15s; } diff --git a/apps/web/src/app/styles/layout.css b/apps/web/src/app/styles/layout.css deleted file mode 100644 index 18b1ec4..0000000 --- a/apps/web/src/app/styles/layout.css +++ /dev/null @@ -1,30 +0,0 @@ -.app { - display: grid; - grid-template-columns: 310px 1fr; - grid-template-rows: 44px 1fr; - height: 100vh; -} - -.sidebar { - background: var(--panel); - border-right: 1px solid var(--border); - overflow-y: auto; - display: flex; - flex-direction: column; -} - -.map-area { - position: relative; - background: #010610; -} - -@media (max-width: 920px) { - .app { - grid-template-columns: 1fr; - grid-template-rows: 44px 1fr; - } - - .sidebar { - display: none; - } -} diff --git a/apps/web/src/features/map3dSettings/Map3DSettingsToggles.tsx b/apps/web/src/features/map3dSettings/Map3DSettingsToggles.tsx index 6c8b0cc..d657f58 100644 --- a/apps/web/src/features/map3dSettings/Map3DSettingsToggles.tsx +++ b/apps/web/src/features/map3dSettings/Map3DSettingsToggles.tsx @@ -1,3 +1,4 @@ +import { ToggleButton } from '@wing/ui'; import type { Map3DSettings } from "../../widgets/map3d/Map3D"; type Props = { @@ -13,11 +14,11 @@ export function Map3DSettingsToggles({ value, onToggle }: Props) { ]; return ( -
+
{items.map((t) => ( -
onToggle(t.id)}> + onToggle(t.id)}> {t.label} -
+ ))}
); diff --git a/apps/web/src/features/mapSettings/MapSettingsPanel.tsx b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx index 592fb60..95dcb0c 100644 --- a/apps/web/src/features/mapSettings/MapSettingsPanel.tsx +++ b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx @@ -146,8 +146,7 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
수심 구간 색상 @@ -176,11 +175,11 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) { {/* ── Depth font size ───────────────────────────── */}
수심 폰트 크기
-
+
{FONT_SIZES.map((fs) => (
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}척
+
+
+
전체
+
{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}
-
+
경고 {alarms}
-
{clock}
+
{clock}
+ {onToggleTheme && ( + + )} {userName && ( -
- {userName} +
+ {userName} {onLogout && ( - )} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7ff51b7..a609140 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,4 @@ +import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; import { defineConfig, loadEnv } from "vite"; @@ -15,7 +16,7 @@ export default defineConfig(({ mode }) => { const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041"; return { - plugins: [react()], + plugins: [tailwindcss(), react()], resolve: { alias: { // deck.gl (via loaders.gl) contains a few Node-only helper modules. diff --git a/package-lock.json b/package-lock.json index 84a7523..cff75b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "wing-fleet-dashboard", "workspaces": [ - "apps/*" + "apps/*", + "packages/*" ], "devDependencies": { "xlsx": "^0.18.5" @@ -38,6 +39,7 @@ "@maptiler/weather": "^3.1.1", "@react-oauth/google": "^0.13.4", "@stomp/stompjs": "^7.2.1", + "@wing/ui": "*", "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -46,6 +48,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -54,6 +57,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1" @@ -2473,6 +2477,278 @@ "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", "license": "Apache-2.0" }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@turf/boolean-clockwise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", @@ -2970,6 +3246,10 @@ "resolved": "apps/api", "link": true }, + "node_modules/@wing/ui": { + "resolved": "packages/ui", + "link": true + }, "node_modules/@wing/web": { "resolved": "apps/web", "link": true @@ -3315,6 +3595,15 @@ "node": "*" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -3487,6 +3776,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -3506,6 +3805,20 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -4174,6 +4487,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/h3-js": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", @@ -4350,6 +4670,16 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-base64": { "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", @@ -4548,6 +4878,267 @@ ], "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4603,6 +5194,16 @@ "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", "license": "BSD-2-Clause" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/maplibre-gl": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz", @@ -5469,6 +6070,37 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz", + "integrity": "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/texture-compressor": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", @@ -5967,6 +6599,18 @@ "optional": true } } + }, + "packages/ui": { + "name": "@wing/ui", + "version": "0.0.0", + "dependencies": { + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } } } } diff --git a/package.json b/package.json index ba381ba..8862abd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wing-fleet-dashboard", "private": true, - "workspaces": ["apps/*"], + "workspaces": ["apps/*", "packages/*"], "scripts": { "dev": "npm run dev:web & npm run dev:api", "dev:web": "npm -w @wing/web run dev", diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..dafff89 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@wing/ui", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./theme/tokens.css": "./src/theme/tokens.css" + }, + "dependencies": { + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/ui/src/components/Badge.tsx b/packages/ui/src/components/Badge.tsx new file mode 100644 index 0000000..05baa96 --- /dev/null +++ b/packages/ui/src/components/Badge.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../utils/cn.ts'; + +interface BadgeProps extends HTMLAttributes { + variant?: 'default' | 'accent' | 'danger' | 'warning' | 'success' | 'muted'; + children: ReactNode; +} + +export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..4493aa9 --- /dev/null +++ b/packages/ui/src/components/Button.tsx @@ -0,0 +1,30 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { cn } from '../utils/cn.ts'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'ghost' | 'primary' | 'danger'; + size?: 'sm' | 'md'; + children: ReactNode; +} + +export function Button({ variant = 'ghost', size = 'sm', className, children, ...props }: ButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/IconButton.tsx b/packages/ui/src/components/IconButton.tsx new file mode 100644 index 0000000..585005c --- /dev/null +++ b/packages/ui/src/components/IconButton.tsx @@ -0,0 +1,27 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { cn } from '../utils/cn.ts'; + +interface IconButtonProps extends ButtonHTMLAttributes { + active?: boolean; + size?: 'sm' | 'md'; + children: ReactNode; +} + +export function IconButton({ active, size = 'md', className, children, ...props }: IconButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/ListItem.tsx b/packages/ui/src/components/ListItem.tsx new file mode 100644 index 0000000..852414b --- /dev/null +++ b/packages/ui/src/components/ListItem.tsx @@ -0,0 +1,26 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../utils/cn.ts'; + +interface ListItemProps extends HTMLAttributes { + selected?: boolean; + highlighted?: boolean; + children: ReactNode; +} + +export function ListItem({ selected, highlighted, className, children, ...props }: ListItemProps) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/components/Panel.tsx b/packages/ui/src/components/Panel.tsx new file mode 100644 index 0000000..1fac0ae --- /dev/null +++ b/packages/ui/src/components/Panel.tsx @@ -0,0 +1,24 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../utils/cn.ts'; + +interface PanelProps extends HTMLAttributes { + glass?: boolean; + children: ReactNode; +} + +export function Panel({ glass = true, className, children, ...props }: PanelProps) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/components/Section.tsx b/packages/ui/src/components/Section.tsx new file mode 100644 index 0000000..542008c --- /dev/null +++ b/packages/ui/src/components/Section.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { cn } from '../utils/cn.ts'; + +interface SectionProps extends Omit, 'title'> { + title: ReactNode; + actions?: ReactNode; + defaultOpen?: boolean; + children: ReactNode; +} + +export function Section({ title, actions, defaultOpen = true, className, children, ...props }: SectionProps) { + return ( +
+ + + {title} + + {actions && ( + e.preventDefault()} className="flex items-center gap-1.5"> + {actions} + + )} + +
+ {children} +
+
+ ); +} diff --git a/packages/ui/src/components/TextInput.tsx b/packages/ui/src/components/TextInput.tsx new file mode 100644 index 0000000..b389dbf --- /dev/null +++ b/packages/ui/src/components/TextInput.tsx @@ -0,0 +1,18 @@ +import type { InputHTMLAttributes } from 'react'; +import { cn } from '../utils/cn.ts'; + +type TextInputProps = InputHTMLAttributes; + +export function TextInput({ className, ...props }: TextInputProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/ToggleButton.tsx b/packages/ui/src/components/ToggleButton.tsx new file mode 100644 index 0000000..19108f5 --- /dev/null +++ b/packages/ui/src/components/ToggleButton.tsx @@ -0,0 +1,24 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { cn } from '../utils/cn.ts'; + +interface ToggleButtonProps extends ButtonHTMLAttributes { + on?: boolean; + children: ReactNode; +} + +export function ToggleButton({ on, className, children, ...props }: ToggleButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts new file mode 100644 index 0000000..e254c67 --- /dev/null +++ b/packages/ui/src/components/index.ts @@ -0,0 +1,8 @@ +export { Button } from './Button.tsx'; +export { IconButton } from './IconButton.tsx'; +export { ToggleButton } from './ToggleButton.tsx'; +export { Badge } from './Badge.tsx'; +export { Panel } from './Panel.tsx'; +export { Section } from './Section.tsx'; +export { ListItem } from './ListItem.tsx'; +export { TextInput } from './TextInput.tsx'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..3550f9b --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,2 @@ +export { cn } from './utils/cn.ts'; +export * from './components/index.ts'; diff --git a/packages/ui/src/theme/tokens.css b/packages/ui/src/theme/tokens.css new file mode 100644 index 0000000..ccbe0e3 --- /dev/null +++ b/packages/ui/src/theme/tokens.css @@ -0,0 +1,79 @@ +/* ── Wing Design Tokens ──────────────────────────────────────────── */ + +/* Dark theme (default) */ +:root, +[data-theme='dark'] { + --wing-bg: #020617; + --wing-surface: #0f172a; + --wing-card: #1e293b; + --wing-border: #1e3a5f; + --wing-text: #e2e8f0; + --wing-muted: #64748b; + --wing-accent: #3b82f6; + --wing-danger: #ef4444; + --wing-warning: #f59e0b; + --wing-success: #22c55e; + --wing-glass: rgba(15, 23, 42, 0.92); + --wing-glass-dense: rgba(15, 23, 42, 0.95); + --wing-overlay: rgba(2, 6, 23, 0.42); + --wing-card-alpha: rgba(30, 41, 59, 0.55); + --wing-subtle: rgba(255, 255, 255, 0.03); + + /* Legacy aliases (backward compatibility) */ + --bg: var(--wing-bg); + --panel: var(--wing-surface); + --card: var(--wing-card); + --border: var(--wing-border); + --text: var(--wing-text); + --muted: var(--wing-muted); + --accent: var(--wing-accent); + --crit: var(--wing-danger); + --high: var(--wing-warning); +} + +/* Light theme */ +[data-theme='light'] { + --wing-bg: #e2e8f0; + --wing-surface: #ffffff; + --wing-card: #f1f5f9; + --wing-border: #94a3b8; + --wing-text: #0f172a; + --wing-muted: #64748b; + --wing-accent: #2563eb; + --wing-danger: #dc2626; + --wing-warning: #d97706; + --wing-success: #16a34a; + --wing-glass: rgba(255, 255, 255, 0.92); + --wing-glass-dense: rgba(255, 255, 255, 0.95); + --wing-overlay: rgba(0, 0, 0, 0.25); + --wing-card-alpha: rgba(226, 232, 240, 0.6); + --wing-subtle: rgba(0, 0, 0, 0.03); + + --bg: var(--wing-bg); + --panel: var(--wing-surface); + --card: var(--wing-card); + --border: var(--wing-border); + --text: var(--wing-text); + --muted: var(--wing-muted); + --accent: var(--wing-accent); + --crit: var(--wing-danger); + --high: var(--wing-warning); +} + +/* ── Tailwind Theme Mapping ──────────────────────────────────────── */ + +@theme { + --color-wing-bg: var(--wing-bg); + --color-wing-surface: var(--wing-surface); + --color-wing-card: var(--wing-card); + --color-wing-border: var(--wing-border); + --color-wing-text: var(--wing-text); + --color-wing-muted: var(--wing-muted); + --color-wing-accent: var(--wing-accent); + --color-wing-danger: var(--wing-danger); + --color-wing-warning: var(--wing-warning); + --color-wing-success: var(--wing-success); + --color-wing-glass: var(--wing-glass); + + --font-sans: 'Noto Sans KR', sans-serif; +} diff --git a/packages/ui/src/utils/cn.ts b/packages/ui/src/utils/cn.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/packages/ui/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..0dcffa5 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "skipLibCheck": true + }, + "include": ["src"] +}