develop #23
@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||||
<title>WING 조업감시 데모</title>
|
<title>WING 조업감시 데모</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -12,21 +12,23 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deck.gl/aggregation-layers": "^9.2.7",
|
"@deck.gl/aggregation-layers": "^9.2.7",
|
||||||
"@deck.gl/core": "^9.2.7",
|
"@deck.gl/core": "^9.2.7",
|
||||||
|
"@deck.gl/extensions": "^9.2.7",
|
||||||
"@deck.gl/geo-layers": "^9.2.7",
|
"@deck.gl/geo-layers": "^9.2.7",
|
||||||
"@deck.gl/layers": "^9.2.7",
|
"@deck.gl/layers": "^9.2.7",
|
||||||
"@deck.gl/mapbox": "^9.2.7",
|
"@deck.gl/mapbox": "^9.2.7",
|
||||||
"@maptiler/weather": "^3.1.1",
|
"@maptiler/weather": "^3.1.1",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@stomp/stompjs": "^7.2.1",
|
||||||
|
"@wing/ui": "*",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"@deck.gl/extensions": "^9.2.7",
|
|
||||||
"@stomp/stompjs": "^7.2.1",
|
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@ -35,6 +37,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
/* ── Wing Fleet Dashboard – Style Entry Point ─────────────────── */
|
/* ── Wing Fleet Dashboard – Style Entry Point ─────────────────── */
|
||||||
|
|
||||||
@import "./styles/base.css";
|
@import "tailwindcss";
|
||||||
@import "./styles/layout.css";
|
@import "@wing/ui/theme/tokens.css";
|
||||||
|
@source "../../../../packages/ui/src/**/*.{ts,tsx}";
|
||||||
|
|
||||||
/* Components */
|
@import "./styles/base.css";
|
||||||
@import "./styles/components/topbar.css";
|
|
||||||
|
/* Components (layout/topbar/toggles → inline Tailwind + @wing/ui) */
|
||||||
@import "./styles/components/panels.css";
|
@import "./styles/components/panels.css";
|
||||||
@import "./styles/components/toggles.css";
|
|
||||||
@import "./styles/components/speed.css";
|
@import "./styles/components/speed.css";
|
||||||
@import "./styles/components/vessel-list.css";
|
@import "./styles/components/vessel-list.css";
|
||||||
@import "./styles/components/ais-list.css";
|
@import "./styles/components/ais-list.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");
|
/* Google Fonts: loaded via index.html <link> */
|
||||||
|
/* CSS Reset: handled by Tailwind preflight */
|
||||||
* {
|
/* CSS Variables: defined in @wing/ui/theme/tokens.css */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
|||||||
@ -5,13 +5,13 @@
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(30, 41, 59, 0.75);
|
background: var(--wing-card-alpha);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ais-q::placeholder {
|
.ais-q::placeholder {
|
||||||
color: rgba(100, 116, 139, 0.9);
|
color: var(--wing-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ais-mode {
|
.ais-mode {
|
||||||
@ -55,8 +55,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ais-row:hover {
|
.ais-row:hover {
|
||||||
background: rgba(30, 41, 59, 0.6);
|
background: var(--wing-card-alpha);
|
||||||
border-color: rgba(30, 58, 95, 0.8);
|
border-color: var(--wing-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ais-row.sel {
|
.ais-row.sel {
|
||||||
@ -118,8 +118,8 @@
|
|||||||
|
|
||||||
.ais-badge.pn {
|
.ais-badge.pn {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: rgba(30, 41, 59, 0.55);
|
background: var(--wing-card-alpha);
|
||||||
border-color: rgba(30, 58, 95, 0.9);
|
border-color: var(--wing-border);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,5 +155,5 @@
|
|||||||
|
|
||||||
.ais-ts {
|
.ais-ts {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: rgba(100, 116, 139, 0.9);
|
color: var(--wing-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,7 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(30, 41, 59, 0.55);
|
background: var(--wing-card-alpha);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -58,7 +58,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--border);
|
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);
|
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +90,6 @@
|
|||||||
|
|
||||||
.alarm-filter__sep {
|
.alarm-filter__sep {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(30, 58, 95, 0.85);
|
background: var(--wing-border);
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.auth-card {
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
bottom: 44px;
|
bottom: 44px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
z-index: 800;
|
z-index: 800;
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: var(--wing-glass);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -41,7 +41,7 @@
|
|||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
z-index: 800;
|
z-index: 800;
|
||||||
background: rgba(15, 23, 42, 0.95);
|
background: var(--wing-glass-dense);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -54,7 +54,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
border-bottom: 1px solid var(--wing-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-info .il {
|
.map-info .il {
|
||||||
@ -72,13 +72,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(2, 6, 23, 0.42);
|
background: var(--wing-overlay);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-loader-overlay__panel {
|
.map-loader-overlay__panel {
|
||||||
width: min(72vw, 320px);
|
width: min(72vw, 320px);
|
||||||
background: rgba(15, 23, 42, 0.94);
|
background: var(--wing-glass-dense);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
@ -185,7 +185,7 @@
|
|||||||
|
|
||||||
.maplibregl-ctrl-group {
|
.maplibregl-ctrl-group {
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
background: rgba(15, 23, 42, 0.92) !important;
|
background: var(--wing-glass) !important;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,14 +197,20 @@
|
|||||||
border-top: 1px solid var(--border) !important;
|
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);
|
filter: invert(1);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .maplibregl-ctrl-group button span {
|
||||||
|
filter: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.maplibregl-ctrl-attrib {
|
.maplibregl-ctrl-attrib {
|
||||||
font-size: 10px !important;
|
font-size: 10px !important;
|
||||||
background: rgba(15, 23, 42, 0.75) !important;
|
background: var(--wing-glass) !important;
|
||||||
color: var(--text) !important;
|
color: var(--text) !important;
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
height: 29px;
|
height: 29px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: var(--wing-glass);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -37,7 +37,7 @@
|
|||||||
top: 10px;
|
top: 10px;
|
||||||
left: 48px;
|
left: 48px;
|
||||||
z-index: 850;
|
z-index: 850;
|
||||||
background: rgba(15, 23, 42, 0.95);
|
background: var(--wing-glass-dense);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -148,7 +148,7 @@
|
|||||||
bottom: 44px;
|
bottom: 44px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
z-index: 800;
|
z-index: 800;
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: var(--wing-glass);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@ -1,24 +1,3 @@
|
|||||||
.sb {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sb-t {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 1.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sb-t-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relation-sort {
|
.relation-sort {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
/* Fleet network */
|
/* Fleet network */
|
||||||
.fleet-card {
|
.fleet-card {
|
||||||
background: rgba(30, 41, 59, 0.8);
|
background: var(--wing-card-alpha);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -9,7 +9,7 @@
|
|||||||
height: 29px;
|
height: 29px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: var(--wing-glass);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -73,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wo-panel {
|
.wo-panel {
|
||||||
background: rgba(15, 23, 42, 0.95);
|
background: var(--wing-glass-dense);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -123,7 +123,7 @@
|
|||||||
padding: 6px 4px;
|
padding: 6px 4px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--wing-subtle);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@ -329,7 +329,7 @@
|
|||||||
|
|
||||||
/* ── Weather Legend ── */
|
/* ── Weather Legend ── */
|
||||||
.wo-legend {
|
.wo-legend {
|
||||||
background: rgba(15, 23, 42, 0.85);
|
background: var(--wing-glass);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
height: 29px;
|
height: 29px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: var(--wing-glass);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -37,7 +37,7 @@
|
|||||||
top: 130px;
|
top: 130px;
|
||||||
left: 48px;
|
left: 48px;
|
||||||
z-index: 850;
|
z-index: 850;
|
||||||
background: rgba(15, 23, 42, 0.95);
|
background: var(--wing-glass-dense);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -87,7 +87,7 @@
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
border-radius: 0 4px 4px 0;
|
border-radius: 0 4px 4px 0;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--wing-subtle);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { ToggleButton } from '@wing/ui';
|
||||||
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
|
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -13,11 +14,11 @@ export function Map3DSettingsToggles({ value, onToggle }: Props) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tog">
|
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
<ToggleButton key={t.id} on={value[t.id]} onClick={() => onToggle(t.id)}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</div>
|
</ToggleButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -146,8 +146,7 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
|||||||
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
수심 구간 색상
|
수심 구간 색상
|
||||||
<span
|
<span
|
||||||
className={`tog-btn${autoGradient ? ' on' : ''}`}
|
className={`ml-2 cursor-pointer rounded border px-1.5 py-px text-[8px] transition-all duration-150 select-none ${autoGradient ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||||
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
|
|
||||||
onClick={toggleAutoGradient}
|
onClick={toggleAutoGradient}
|
||||||
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
|
||||||
>
|
>
|
||||||
@ -176,11 +175,11 @@ export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
|
|||||||
{/* ── Depth font size ───────────────────────────── */}
|
{/* ── Depth font size ───────────────────────────── */}
|
||||||
<div className="ms-section">
|
<div className="ms-section">
|
||||||
<div className="ms-label">수심 폰트 크기</div>
|
<div className="ms-label">수심 폰트 크기</div>
|
||||||
<div className="tog" style={{ gap: 3 }}>
|
<div className="flex flex-wrap gap-0.75">
|
||||||
{FONT_SIZES.map((fs) => (
|
{FONT_SIZES.map((fs) => (
|
||||||
<div
|
<div
|
||||||
key={fs.value}
|
key={fs.value}
|
||||||
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
|
className={`cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none ${value.depthFontSize === fs.value ? 'border-wing-accent bg-wing-accent text-white' : 'border-wing-border bg-wing-card text-wing-muted'}`}
|
||||||
onClick={() => update('depthFontSize', fs.value)}
|
onClick={() => update('depthFontSize', fs.value)}
|
||||||
>
|
>
|
||||||
{fs.label}
|
{fs.label}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { ToggleButton } from '@wing/ui';
|
||||||
|
|
||||||
export type MapToggleState = {
|
export type MapToggleState = {
|
||||||
pairLines: boolean;
|
pairLines: boolean;
|
||||||
pairRange: boolean;
|
pairRange: boolean;
|
||||||
@ -27,11 +29,16 @@ export function MapToggles({ value, onToggle }: Props) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tog tog-map">
|
<div className="flex flex-wrap gap-1">
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
|
<ToggleButton
|
||||||
|
key={t.id}
|
||||||
|
on={value[t.id]}
|
||||||
|
onClick={() => onToggle(t.id)}
|
||||||
|
className="flex-[1_1_calc(25%-4px)] overflow-hidden text-center text-ellipsis whitespace-nowrap"
|
||||||
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</div>
|
</ToggleButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,26 +9,26 @@ type Props = {
|
|||||||
onToggleAll: () => void;
|
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) {
|
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
|
||||||
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
|
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tg">
|
<div className="grid grid-cols-3 gap-0.75">
|
||||||
<div className={`tb ${allOn ? "on" : ""}`} onClick={onToggleAll} style={{ gridColumn: "1/-1" }}>
|
<div className={`col-span-full ${TB} ${allOn ? TB_ON : TB_OFF}`} onClick={onToggleAll}>
|
||||||
<div className="c" style={{ color: "var(--accent)" }}>
|
<div className="text-[11px] font-extrabold text-wing-accent">전체</div>
|
||||||
전체
|
<div className="text-[8px] text-wing-muted">{totalCount}척</div>
|
||||||
</div>
|
|
||||||
<div className="n">{totalCount}척</div>
|
|
||||||
</div>
|
</div>
|
||||||
{VESSEL_TYPE_ORDER.map((code) => {
|
{VESSEL_TYPE_ORDER.map((code) => {
|
||||||
const t = VESSEL_TYPES[code];
|
const t = VESSEL_TYPES[code];
|
||||||
const cnt = countsByType[code] ?? 0;
|
const cnt = countsByType[code] ?? 0;
|
||||||
return (
|
return (
|
||||||
<div key={code} className={`tb ${enabled[code] ? "on" : ""}`} onClick={() => onToggle(code)}>
|
<div key={code} className={`${TB} ${enabled[code] ? TB_ON : TB_OFF}`} onClick={() => onToggle(code)}>
|
||||||
<div className="c" style={{ color: t.color }}>
|
<div className="text-[11px] font-extrabold" style={{ color: t.color }}>{code}</div>
|
||||||
{code}
|
<div className="text-[8px] text-wing-muted">{cnt}척</div>
|
||||||
</div>
|
|
||||||
<div className="n">{cnt}척</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useAuth } from "../../shared/auth";
|
import { useAuth } from "../../shared/auth";
|
||||||
|
import { useTheme } from "../../shared/hooks";
|
||||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||||
@ -58,6 +59,8 @@ function useLegacyIndex(data: LegacyVesselDataset | null) {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const uid = user?.id ?? null;
|
const uid = user?.id ?? null;
|
||||||
|
|
||||||
// ── Data fetching ──
|
// ── Data fetching ──
|
||||||
@ -245,23 +248,27 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="grid h-screen grid-cols-[310px_1fr] grid-rows-[44px_1fr] max-md:grid-cols-[1fr] max-md:grid-rows-[auto_1fr]">
|
||||||
<Topbar
|
<Topbar
|
||||||
total={legacyVesselsAll.length}
|
total={legacyVesselsAll.length}
|
||||||
fishing={fishingCount}
|
fishing={fishingCount}
|
||||||
transit={transitCount}
|
transit={transitCount}
|
||||||
pairLinks={pairLinksAll.length}
|
pairLinks={pairLinksAll.length}
|
||||||
alarms={alarms.length}
|
alarms={alarms.length}
|
||||||
pollingStatus={snapshot.status}
|
|
||||||
lastFetchMinutes={snapshot.lastFetchMinutes}
|
|
||||||
clock={clock}
|
clock={clock}
|
||||||
adminMode={adminMode}
|
adminMode={adminMode}
|
||||||
onLogoClick={onLogoClick}
|
onLogoClick={onLogoClick}
|
||||||
userName={user?.name}
|
userName={user?.name}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
|
isSidebarOpen={isSidebarOpen}
|
||||||
|
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardSidebar
|
<DashboardSidebar
|
||||||
|
isOpen={isSidebarOpen}
|
||||||
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
state={state}
|
state={state}
|
||||||
legacyVesselsAll={legacyVesselsAll}
|
legacyVesselsAll={legacyVesselsAll}
|
||||||
legacyVesselsFiltered={legacyVesselsFiltered}
|
legacyVesselsFiltered={legacyVesselsFiltered}
|
||||||
@ -285,7 +292,7 @@ export function DashboardPage() {
|
|||||||
legacyIndex={legacyIndex}
|
legacyIndex={legacyIndex}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="map-area">
|
<div className="relative bg-[#010610]">
|
||||||
{showMapLoader ? (
|
{showMapLoader ? (
|
||||||
<div className="map-loader-overlay" role="status" aria-live="polite">
|
<div className="map-loader-overlay" role="status" aria-live="polite">
|
||||||
<div className="map-loader-overlay__panel">
|
<div className="map-loader-overlay__panel">
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { ToggleButton, Section } from '@wing/ui';
|
||||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
|
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
|
||||||
import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
import type { LegacyVesselDataset, LegacyVesselInfo } from '../../entities/legacyVessel/model/types';
|
||||||
@ -27,8 +29,9 @@ function fmtBbox(b: Bbox | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardSidebarProps {
|
interface DashboardSidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
state: ReturnType<typeof useDashboardState>;
|
state: ReturnType<typeof useDashboardState>;
|
||||||
// Derived data
|
|
||||||
legacyVesselsAll: DerivedLegacyVessel[];
|
legacyVesselsAll: DerivedLegacyVessel[];
|
||||||
legacyVesselsFiltered: DerivedLegacyVessel[];
|
legacyVesselsFiltered: DerivedLegacyVessel[];
|
||||||
legacyCounts: Record<VesselTypeCode, number>;
|
legacyCounts: Record<VesselTypeCode, number>;
|
||||||
@ -42,7 +45,6 @@ interface DashboardSidebarProps {
|
|||||||
alarmFilterSummary: string;
|
alarmFilterSummary: string;
|
||||||
speedPanelType: VesselTypeCode;
|
speedPanelType: VesselTypeCode;
|
||||||
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
||||||
// Data fetching (admin panels)
|
|
||||||
snapshot: AisPollingSnapshot;
|
snapshot: AisPollingSnapshot;
|
||||||
legacyError: string | null;
|
legacyError: string | null;
|
||||||
legacyData: LegacyVesselDataset | null;
|
legacyData: LegacyVesselDataset | null;
|
||||||
@ -53,6 +55,8 @@ interface DashboardSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardSidebar({
|
export function DashboardSidebar({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
state,
|
state,
|
||||||
legacyVesselsAll,
|
legacyVesselsAll,
|
||||||
legacyVesselsFiltered,
|
legacyVesselsFiltered,
|
||||||
@ -93,13 +97,33 @@ export function DashboardSidebar({
|
|||||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<>
|
||||||
<div className="sb">
|
{isOpen && (
|
||||||
<div className="sb-t">업종 필터</div>
|
<div className="fixed inset-0 z-[1100] bg-black/50 md:hidden" onClick={onClose} aria-hidden />
|
||||||
<div className="tog">
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`tog-btn ${showTargets ? 'on' : ''}`}
|
className={`
|
||||||
|
fixed inset-y-0 left-0 z-[1200] w-[310px] max-w-[100vw] transform overflow-y-auto
|
||||||
|
border-r border-wing-border bg-wing-surface transition-transform duration-200
|
||||||
|
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
|
md:static md:z-auto md:translate-x-0 md:transition-none
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="h-[44px] md:hidden" />
|
||||||
|
|
||||||
|
<Section title="업종 필터">
|
||||||
|
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||||
|
<ToggleButton
|
||||||
|
on={showTargets}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowTargets((v) => {
|
setShowTargets((v) => {
|
||||||
const next = !v;
|
const next = !v;
|
||||||
@ -110,10 +134,10 @@ export function DashboardSidebar({
|
|||||||
title="레거시(CN permit) 대상 선박 표시"
|
title="레거시(CN permit) 대상 선박 표시"
|
||||||
>
|
>
|
||||||
대상 선박
|
대상 선박
|
||||||
</div>
|
</ToggleButton>
|
||||||
<div className={`tog-btn ${showOthers ? 'on' : ''}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
<ToggleButton on={showOthers} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
|
||||||
기타 AIS
|
기타 AIS
|
||||||
</div>
|
</ToggleButton>
|
||||||
</div>
|
</div>
|
||||||
<TypeFilterGrid
|
<TypeFilterGrid
|
||||||
enabled={typeEnabled}
|
enabled={typeEnabled}
|
||||||
@ -127,42 +151,42 @@ export function DashboardSidebar({
|
|||||||
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
|
||||||
const nextVal = !allOn;
|
const nextVal = !allOn;
|
||||||
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
|
||||||
setTypeEnabled({
|
setTypeEnabled({ PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal });
|
||||||
PT: nextVal, 'PT-S': nextVal, GN: nextVal, OT: nextVal, PS: nextVal, FC: nextVal,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb">
|
<Section
|
||||||
<div className="sb-t" style={{ display: 'flex', alignItems: 'center' }}>
|
title="지도 표시 설정"
|
||||||
지도 표시 설정
|
actions={
|
||||||
<div style={{ flex: 1 }} />
|
<ToggleButton
|
||||||
<div
|
on={projection === 'globe'}
|
||||||
className={`tog-btn ${projection === 'globe' ? 'on' : ''}${isProjectionToggleDisabled ? ' disabled' : ''}`}
|
|
||||||
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
||||||
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영: 드래그로 회전, 휠로 확대/축소'}
|
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
|
3D
|
||||||
</div>
|
</ToggleButton>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb">
|
<Section title="속도 프로파일" defaultOpen={false}>
|
||||||
<div className="sb-t">속도 프로파일</div>
|
|
||||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb" style={{ maxHeight: 260, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<Section
|
||||||
<div className="sb-t sb-t-row">
|
title={
|
||||||
<div>
|
<>
|
||||||
선단 연관관계{' '}
|
선단 연관관계{' '}
|
||||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">
|
||||||
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
|
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : '(선박 클릭 시 표시)'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
<div className="relation-sort">
|
<div className="relation-sort">
|
||||||
<label className="relation-sort__option">
|
<label className="relation-sort__option">
|
||||||
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
|
<input type="radio" name="fleet-relation-sort" checked={fleetRelationSortMode === 'count'} onChange={() => setFleetRelationSortMode('count')} />
|
||||||
@ -173,8 +197,9 @@ export function DashboardSidebar({
|
|||||||
범위
|
범위
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div style={{ overflowY: 'auto', minHeight: 0 }}>
|
className="max-h-[260px] flex flex-col overflow-hidden [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0"
|
||||||
|
>
|
||||||
<RelationsPanel
|
<RelationsPanel
|
||||||
selectedVessel={selectedLegacyVessel}
|
selectedVessel={selectedLegacyVessel}
|
||||||
vessels={legacyVesselsAll}
|
vessels={legacyVesselsAll}
|
||||||
@ -198,16 +223,17 @@ export function DashboardSidebar({
|
|||||||
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
hoveredFleetMmsiSet={hoveredFleetMmsiSet}
|
||||||
onContextMenuFleet={onFleetContextMenu}
|
onContextMenuFleet={onFleetContextMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
<Section
|
||||||
<div className="sb-t">
|
title={
|
||||||
|
<>
|
||||||
선박 목록{' '}
|
선박 목록{' '}
|
||||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length}척)</span>
|
||||||
({legacyVesselsFiltered.length}척)
|
</>
|
||||||
</span>
|
}
|
||||||
</div>
|
className="flex-1 min-h-0 flex flex-col [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||||
|
>
|
||||||
<VesselList
|
<VesselList
|
||||||
vessels={legacyVesselsFiltered}
|
vessels={legacyVesselsFiltered}
|
||||||
selectedMmsi={selectedMmsi}
|
selectedMmsi={selectedMmsi}
|
||||||
@ -217,35 +243,28 @@ export function DashboardSidebar({
|
|||||||
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
onHoverMmsi={(mmsi) => setHoveredMmsiSet([mmsi])}
|
||||||
onClearHover={() => setHoveredMmsiSet([])}
|
onClearHover={() => setHoveredMmsiSet([])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb" style={{ maxHeight: 130, display: 'flex', flexDirection: 'column', overflow: 'visible' }}>
|
<Section
|
||||||
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
|
title={
|
||||||
<div>
|
<>
|
||||||
실시간 경고{' '}
|
실시간 경고{' '}
|
||||||
<span style={{ color: 'var(--accent)', fontSize: 8 }}>
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({filteredAlarms.length}/{alarms.length})</span>
|
||||||
({filteredAlarms.length}/{alarms.length})
|
</>
|
||||||
</span>
|
}
|
||||||
</div>
|
actions={
|
||||||
|
LEGACY_ALARM_KINDS.length <= 3 ? (
|
||||||
{LEGACY_ALARM_KINDS.length <= 3 ? (
|
<div className="flex gap-1.5 items-center">
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
||||||
{LEGACY_ALARM_KINDS.map((k) => (
|
{LEGACY_ALARM_KINDS.map((k) => (
|
||||||
<label key={k} style={{ display: 'inline-flex', gap: 4, alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}>
|
<label key={k} className="inline-flex gap-1 items-center cursor-pointer select-none">
|
||||||
<input
|
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
type="checkbox"
|
<span className="text-[8px] text-wing-muted whitespace-nowrap">{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
||||||
checked={!!alarmKindEnabled[k]}
|
|
||||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 8, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<details className="alarm-filter">
|
<details className="alarm-filter">
|
||||||
<summary className="alarm-filter__summary" title="경고 종류 필터">
|
<summary className="alarm-filter__summary" title="경고 종류 필터">{alarmFilterSummary}</summary>
|
||||||
{alarmFilterSummary}
|
|
||||||
</summary>
|
|
||||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
||||||
<label className="alarm-filter__row">
|
<label className="alarm-filter__row">
|
||||||
<input
|
<input
|
||||||
@ -254,7 +273,7 @@ export function DashboardSidebar({
|
|||||||
onChange={() =>
|
onChange={() =>
|
||||||
setAlarmKindEnabled((prev) => {
|
setAlarmKindEnabled((prev) => {
|
||||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
||||||
const nextVal = allOn ? false : true;
|
const nextVal = !allOn;
|
||||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -265,29 +284,23 @@ export function DashboardSidebar({
|
|||||||
<div className="alarm-filter__sep" />
|
<div className="alarm-filter__sep" />
|
||||||
{LEGACY_ALARM_KINDS.map((k) => (
|
{LEGACY_ALARM_KINDS.map((k) => (
|
||||||
<label key={k} className="alarm-filter__row">
|
<label key={k} className="alarm-filter__row">
|
||||||
<input
|
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
type="checkbox"
|
|
||||||
checked={!!alarmKindEnabled[k]}
|
|
||||||
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
|
|
||||||
/>
|
|
||||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
{LEGACY_ALARM_KIND_LABEL[k]}
|
||||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
|
className="max-h-[130px] flex flex-col overflow-visible [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||||
<div style={{ overflowY: 'auto', minHeight: 0, flex: 1 }}>
|
>
|
||||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||||
</div>
|
</Section>
|
||||||
</div>
|
|
||||||
|
|
||||||
{adminMode ? (
|
{adminMode ? (
|
||||||
<>
|
<>
|
||||||
<div className="sb">
|
<Section title="ADMIN · AIS Target Polling" defaultOpen={false}>
|
||||||
<div className="sb-t">ADMIN · AIS Target Polling</div>
|
|
||||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
||||||
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||||
@ -308,10 +321,9 @@ export function DashboardSidebar({
|
|||||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>메시지</div>
|
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>메시지</div>
|
||||||
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
|
<div style={{ color: 'var(--text)', fontSize: 10 }}>{snapshot.lastMessage ?? '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb">
|
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false}>
|
||||||
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
|
|
||||||
{legacyError ? (
|
{legacyError ? (
|
||||||
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
|
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
|
||||||
) : (
|
) : (
|
||||||
@ -327,10 +339,9 @@ export function DashboardSidebar({
|
|||||||
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
|
<div style={{ fontSize: 10, color: 'var(--text)' }}>{legacyData?.generatedAt ? fmtIsoFull(legacyData.generatedAt) : 'loading...'}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb">
|
<Section title="ADMIN · Viewport / BBox" defaultOpen={false}>
|
||||||
<div className="sb-t">ADMIN · Viewport / BBox</div>
|
|
||||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
||||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||||
@ -347,7 +358,6 @@ export function DashboardSidebar({
|
|||||||
>
|
>
|
||||||
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
|
Viewport filter {useViewportFilter ? 'ON' : 'OFF'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!viewBbox) return;
|
if (!viewBbox) return;
|
||||||
@ -365,24 +375,17 @@ export function DashboardSidebar({
|
|||||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
||||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||||
}}
|
}}
|
||||||
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
|
title="서버에서 bbox로 필터링해서 내려받기"
|
||||||
disabled={!viewBbox}
|
disabled={!viewBbox}
|
||||||
>
|
>
|
||||||
API bbox {useApiBbox ? 'ON' : 'OFF'}
|
API bbox {useApiBbox ? 'ON' : 'OFF'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { if (!viewBbox) return; setApiBbox(fmtBbox(viewBbox)); setUseApiBbox(true); }}
|
||||||
if (!viewBbox) return;
|
|
||||||
setApiBbox(fmtBbox(viewBbox));
|
|
||||||
setUseApiBbox(true);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
fontSize: 10, padding: '4px 8px', borderRadius: 6,
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)', background: 'var(--card)',
|
||||||
background: 'var(--card)',
|
color: viewBbox ? 'var(--text)' : 'var(--muted)', cursor: viewBbox ? 'pointer' : 'not-allowed',
|
||||||
color: viewBbox ? 'var(--text)' : 'var(--muted)',
|
|
||||||
cursor: viewBbox ? 'pointer' : 'not-allowed',
|
|
||||||
}}
|
}}
|
||||||
disabled={!viewBbox}
|
disabled={!viewBbox}
|
||||||
title="현재 view bbox로 API bbox를 갱신"
|
title="현재 view bbox로 API bbox를 갱신"
|
||||||
@ -395,36 +398,31 @@ export function DashboardSidebar({
|
|||||||
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
|
<b style={{ color: 'var(--text)' }}>{snapshot.total}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb">
|
<Section title="ADMIN · Map (Extras)" defaultOpen={false}>
|
||||||
<div className="sb-t">ADMIN · Map (Extras)</div>
|
|
||||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className="sb" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
<Section
|
||||||
<div className="sb-t">ADMIN · AIS Targets (All)</div>
|
title="ADMIN · AIS Targets (All)"
|
||||||
<AisTargetList
|
defaultOpen={false}
|
||||||
targets={targetsInScope}
|
className="flex-1 min-h-0 flex flex-col [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||||
selectedMmsi={selectedMmsi}
|
>
|
||||||
onSelectMmsi={setSelectedMmsi}
|
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
|
||||||
legacyIndex={legacyIndex}
|
</Section>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sb" style={{ maxHeight: 130, overflowY: 'auto' }}>
|
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="max-h-[130px] overflow-y-auto">
|
||||||
<div className="sb-t">ADMIN · 수역 데이터</div>
|
|
||||||
{zonesError ? (
|
{zonesError ? (
|
||||||
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
|
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>
|
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
|
||||||
{zones ? `loaded (${zones.features.length} features)` : 'loading...'}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export { usePersistedState } from './usePersistedState';
|
export { usePersistedState } from './usePersistedState';
|
||||||
|
export { useTheme } from './useTheme';
|
||||||
|
|||||||
47
apps/web/src/shared/hooks/useTheme.ts
Normal file
47
apps/web/src/shared/hooks/useTheme.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -1,10 +1,23 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||||
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||||
|
|
||||||
export function MapLegend() {
|
export function MapLegend() {
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-legend">
|
<div className="map-legend">
|
||||||
<div className="lt">수역</div>
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between border-none bg-transparent p-0 text-left"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="lt" style={{ marginBottom: 0 }}>범례</span>
|
||||||
|
<span className="text-[9px] text-wing-muted">{isOpen ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div className="lt" style={{ marginTop: 6 }}>수역</div>
|
||||||
{ZONE_IDS.map((z) => (
|
{ZONE_IDS.map((z) => (
|
||||||
<div key={z} className="li">
|
<div key={z} className="li">
|
||||||
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
|
||||||
@ -12,9 +25,7 @@ export function MapLegend() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(속도)</div>
|
||||||
기타 AIS 선박(속도)
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
||||||
SOG ≥ 10 kt
|
SOG ≥ 10 kt
|
||||||
@ -32,12 +43,10 @@ export function MapLegend() {
|
|||||||
SOG unknown
|
SOG unknown
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
||||||
CN Permit(업종)
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
|
||||||
PT 본선 (ring + 색상)
|
PT 본선
|
||||||
</div>
|
</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
|
||||||
@ -64,16 +73,12 @@ export function MapLegend() {
|
|||||||
C21
|
C21
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>밀도(3D)</div>
|
||||||
밀도(3D)
|
|
||||||
</div>
|
|
||||||
<div className="li" style={{ color: "var(--muted)" }}>
|
<div className="li" style={{ color: "var(--muted)" }}>
|
||||||
Hexagon: 화면 내 AIS 포인트 집계
|
Hexagon: 화면 내 AIS 포인트 집계
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>
|
<div className="lt" style={{ marginTop: 8 }}>연결선</div>
|
||||||
연결선
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
<div className="li">
|
||||||
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
|
||||||
PT↔PT-S 쌍 (정상)
|
PT↔PT-S 쌍 (정상)
|
||||||
@ -109,6 +114,8 @@ export function MapLegend() {
|
|||||||
/>
|
/>
|
||||||
예측 벡터 (15분)
|
예측 벡터 (15분)
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,64 +1,134 @@
|
|||||||
type Props = {
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
total: number;
|
total: number;
|
||||||
fishing: number;
|
fishing: number;
|
||||||
transit: number;
|
transit: number;
|
||||||
pairLinks: number;
|
pairLinks: number;
|
||||||
alarms: number;
|
alarms: number;
|
||||||
pollingStatus: "idle" | "loading" | "ready" | "error";
|
|
||||||
lastFetchMinutes: number | null;
|
|
||||||
clock: string;
|
clock: string;
|
||||||
adminMode?: boolean;
|
adminMode?: boolean;
|
||||||
onLogoClick?: () => void;
|
onLogoClick?: () => void;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
};
|
theme?: "dark" | "light";
|
||||||
|
onToggleTheme?: () => void;
|
||||||
|
isSidebarOpen?: boolean;
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout }: Props) {
|
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
|
||||||
const statusColor =
|
|
||||||
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
|
|
||||||
return (
|
return (
|
||||||
<div className="topbar">
|
<>
|
||||||
<div className="logo" onClick={onLogoClick} style={{ cursor: onLogoClick ? "pointer" : undefined }} title={adminMode ? "ADMIN" : undefined}>
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
🛰 <span>WING</span> 조업감시·선단연관 {adminMode ? <span style={{ fontSize: 10, color: "#F59E0B" }}>(ADMIN)</span> : null}
|
<b className="text-xs text-wing-text">{total}</b>척
|
||||||
</div>
|
</div>
|
||||||
<div className="stats">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
<div className="stat">
|
|
||||||
DATA <b style={{ color: "#22C55E" }}>API</b>
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
|
||||||
POLL{" "}
|
|
||||||
<b style={{ color: statusColor }}>
|
|
||||||
{pollingStatus.toUpperCase()}
|
|
||||||
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
|
|
||||||
</b>
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
|
||||||
전체 <b>{total}</b>척
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
|
||||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
|
||||||
|
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-full relative z-[1000]">
|
||||||
|
<div className="flex h-[44px] items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5">
|
||||||
|
{/* 햄버거 메뉴 (모바일) */}
|
||||||
|
{onMenuToggle && (
|
||||||
|
<button
|
||||||
|
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
|
||||||
|
onClick={onMenuToggle}
|
||||||
|
aria-label={isSidebarOpen ? "메뉴 닫기" : "메뉴 열기"}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
{isSidebarOpen ? (
|
||||||
|
<>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로고 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold"
|
||||||
|
onClick={onLogoClick}
|
||||||
|
style={{ cursor: onLogoClick ? "pointer" : undefined }}
|
||||||
|
title={adminMode ? "ADMIN" : undefined}
|
||||||
|
>
|
||||||
|
<span className="text-wing-accent">WING</span>
|
||||||
|
<span className="hidden sm:inline">조업감시·선단연관</span>
|
||||||
|
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="time">{clock}</div>
|
|
||||||
|
{/* 데스크톱: 인라인 통계 */}
|
||||||
|
<div className="ml-auto hidden items-center gap-3.5 md:flex">
|
||||||
|
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2.5 md:ml-2.5">
|
||||||
|
<span className="whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</span>
|
||||||
|
{onToggleTheme && (
|
||||||
|
<button
|
||||||
|
className="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 && (
|
{userName && (
|
||||||
<div className="topbar-user">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<span className="topbar-user__name">{userName}</span>
|
<span className="hidden whitespace-nowrap text-[10px] font-medium text-wing-text sm:inline">{userName}</span>
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<button className="topbar-user__logout" onClick={onLogout}>
|
<button
|
||||||
|
className="cursor-pointer whitespace-nowrap 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={onLogout}
|
||||||
|
>
|
||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 통계 바 (펼침 시) — 레이아웃 흐름에 포함, 지도 영역 밀어내기 */}
|
||||||
|
{isStatsOpen && (
|
||||||
|
<div className="flex items-center justify-center gap-4 border-b border-wing-border bg-wing-surface px-3.5 py-2 md:hidden">
|
||||||
|
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모바일 통계 토글 탭 — topbar 하단 우측에 걸침 */}
|
||||||
|
<button
|
||||||
|
className="absolute bottom-0 right-3.5 z-10 translate-y-full cursor-pointer rounded-b-md border border-t-0 border-wing-border bg-wing-surface px-2 py-0.5 text-[8px] text-wing-muted transition-colors hover:text-wing-text md:hidden"
|
||||||
|
onClick={() => setIsStatsOpen((v) => !v)}
|
||||||
|
aria-label={isStatsOpen ? "통계 닫기" : "통계 열기"}
|
||||||
|
>
|
||||||
|
{isStatsOpen ? "▴" : "▾ 통계"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
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";
|
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [tailwindcss(), react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// deck.gl (via loaders.gl) contains a few Node-only helper modules.
|
// deck.gl (via loaders.gl) contains a few Node-only helper modules.
|
||||||
|
|||||||
646
package-lock.json
generated
646
package-lock.json
generated
@ -6,7 +6,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "wing-fleet-dashboard",
|
"name": "wing-fleet-dashboard",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*"
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
@ -38,6 +39,7 @@
|
|||||||
"@maptiler/weather": "^3.1.1",
|
"@maptiler/weather": "^3.1.1",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
"@stomp/stompjs": "^7.2.1",
|
"@stomp/stompjs": "^7.2.1",
|
||||||
|
"@wing/ui": "*",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@ -46,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@ -54,6 +57,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
@ -2473,6 +2477,278 @@
|
|||||||
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@turf/boolean-clockwise": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz",
|
||||||
@ -2970,6 +3246,10 @@
|
|||||||
"resolved": "apps/api",
|
"resolved": "apps/api",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@wing/ui": {
|
||||||
|
"resolved": "packages/ui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@wing/web": {
|
"node_modules/@wing/web": {
|
||||||
"resolved": "apps/web",
|
"resolved": "apps/web",
|
||||||
"link": true
|
"link": true
|
||||||
@ -3315,6 +3595,15 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/codepage": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
@ -3487,6 +3776,16 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/draco3d": {
|
||||||
"version": "1.5.7",
|
"version": "1.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||||
@ -3506,6 +3805,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@ -4174,6 +4487,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/h3-js": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz",
|
||||||
@ -4350,6 +4670,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/js-base64": {
|
||||||
"version": "3.7.8",
|
"version": "3.7.8",
|
||||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||||
@ -4548,6 +4878,267 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@ -4603,6 +5194,16 @@
|
|||||||
"integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==",
|
"integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==",
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/maplibre-gl": {
|
||||||
"version": "5.18.0",
|
"version": "5.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz",
|
||||||
@ -5469,6 +6070,37 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/texture-compressor": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
|
||||||
@ -5967,6 +6599,18 @@
|
|||||||
"optional": true
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wing-fleet-dashboard",
|
"name": "wing-fleet-dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["apps/*"],
|
"workspaces": ["apps/*", "packages/*"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:web & npm run dev:api",
|
"dev": "npm run dev:web & npm run dev:api",
|
||||||
"dev:web": "npm -w @wing/web run dev",
|
"dev:web": "npm -w @wing/web run dev",
|
||||||
|
|||||||
18
packages/ui/package.json
Normal file
18
packages/ui/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/ui/src/components/Badge.tsx
Normal file
27
packages/ui/src/components/Badge.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
|
variant?: 'default' | 'accent' | 'danger' | 'warning' | 'success' | 'muted';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-[5px] border px-1.5 py-px text-[8px] font-extrabold leading-none tracking-wide',
|
||||||
|
variant === 'default' && 'border-white/8 bg-wing-muted/22 text-white',
|
||||||
|
variant === 'accent' && 'border-wing-accent/70 bg-wing-accent/22 text-white',
|
||||||
|
variant === 'danger' && 'border-wing-danger/70 bg-wing-danger/22 text-white',
|
||||||
|
variant === 'warning' && 'border-wing-warning/70 bg-wing-warning/22 text-white',
|
||||||
|
variant === 'success' && 'border-wing-success/70 bg-wing-success/22 text-white',
|
||||||
|
variant === 'muted' && 'border-wing-border/90 bg-wing-card/55 text-wing-muted font-bold',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
packages/ui/src/components/Button.tsx
Normal file
30
packages/ui/src/components/Button.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'ghost' | 'primary' | 'danger';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = 'ghost', size = 'sm', className, children, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded border transition-all duration-150 select-none whitespace-nowrap',
|
||||||
|
size === 'sm' && 'px-1.5 py-0.5 text-[9px]',
|
||||||
|
size === 'md' && 'px-2.5 py-1.5 text-xs rounded-lg',
|
||||||
|
variant === 'ghost' &&
|
||||||
|
'border-wing-border bg-transparent text-wing-muted hover:text-wing-text hover:border-wing-accent',
|
||||||
|
variant === 'primary' &&
|
||||||
|
'border-wing-accent bg-wing-accent text-white hover:brightness-110',
|
||||||
|
variant === 'danger' &&
|
||||||
|
'border-wing-danger bg-transparent text-wing-danger hover:bg-wing-danger/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
packages/ui/src/components/IconButton.tsx
Normal file
27
packages/ui/src/components/IconButton.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
active?: boolean;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({ active, size = 'md', className, children, ...props }: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 cursor-pointer items-center justify-center rounded border p-0 transition-all duration-150 select-none',
|
||||||
|
'border-wing-border bg-wing-glass text-wing-muted backdrop-blur-lg',
|
||||||
|
'hover:text-wing-text hover:border-wing-accent',
|
||||||
|
size === 'sm' && 'h-[22px] w-[22px] rounded text-sm',
|
||||||
|
size === 'md' && 'h-[29px] w-[29px] rounded text-base',
|
||||||
|
active && 'text-wing-accent border-wing-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
packages/ui/src/components/ListItem.tsx
Normal file
26
packages/ui/src/components/ListItem.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface ListItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
selected?: boolean;
|
||||||
|
highlighted?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListItem({ selected, highlighted, className, children, ...props }: ListItemProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-1 text-[10px] transition-colors duration-100 select-none',
|
||||||
|
'hover:bg-wing-card',
|
||||||
|
selected && 'bg-[rgba(14,234,255,0.16)] border-[rgba(14,234,255,0.55)]',
|
||||||
|
highlighted && !selected && 'bg-[rgba(245,158,11,0.16)] border border-[rgba(245,158,11,0.4)]',
|
||||||
|
selected && highlighted && 'bg-gradient-to-r from-[rgba(14,234,255,0.16)] to-[rgba(245,158,11,0.16)] border-[rgba(14,234,255,0.7)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/ui/src/components/Panel.tsx
Normal file
24
packages/ui/src/components/Panel.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
glass?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Panel({ glass = true, className, children, ...props }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-wing-border p-3',
|
||||||
|
glass
|
||||||
|
? 'bg-wing-glass backdrop-blur-lg'
|
||||||
|
: 'bg-wing-surface',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
packages/ui/src/components/Section.tsx
Normal file
29
packages/ui/src/components/Section.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface SectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||||
|
title: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ title, actions, defaultOpen = true, className, children, ...props }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<details open={defaultOpen || undefined} className={cn('border-b border-wing-border', className)} {...props}>
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2.5 select-none [&::-webkit-details-marker]:hidden">
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-[1.5px] text-wing-muted">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{actions && (
|
||||||
|
<span onClick={(e) => e.preventDefault()} className="flex items-center gap-1.5">
|
||||||
|
{actions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<div className="px-3 pb-2.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/ui/src/components/TextInput.tsx
Normal file
18
packages/ui/src/components/TextInput.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
export function TextInput({ className, ...props }: TextInputProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-md border border-wing-border bg-wing-card/75 px-2 py-1.5 text-[10px] text-wing-text outline-none',
|
||||||
|
'placeholder:text-wing-muted/90',
|
||||||
|
'focus:border-wing-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/ui/src/components/ToggleButton.tsx
Normal file
24
packages/ui/src/components/ToggleButton.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
|
interface ToggleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
on?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleButton({ on, className, children, ...props }: ToggleButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded border px-1.5 py-0.5 text-[8px] transition-all duration-150 select-none',
|
||||||
|
on
|
||||||
|
? 'border-wing-accent bg-wing-accent text-white'
|
||||||
|
: 'border-wing-border bg-wing-card text-wing-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
packages/ui/src/components/index.ts
Normal file
8
packages/ui/src/components/index.ts
Normal file
@ -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';
|
||||||
2
packages/ui/src/index.ts
Normal file
2
packages/ui/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { cn } from './utils/cn.ts';
|
||||||
|
export * from './components/index.ts';
|
||||||
79
packages/ui/src/theme/tokens.css
Normal file
79
packages/ui/src/theme/tokens.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
6
packages/ui/src/utils/cn.ts
Normal file
6
packages/ui/src/utils/cn.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
19
packages/ui/tsconfig.json
Normal file
19
packages/ui/tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user