feat(v2): remove old dashboard, add vitae + resizable windows

- Removed old Recharts dashboard view entirely (no more viewMode
  toggle, DashboardView lazy import, Ctrl+D shortcut)
- Recharts chunk eliminated from build — bundle size reduced
- Player Dashboard window: added Vitae column (red when > 0%)
- ALL windows now resizable: drag bottom-right corner handle
  (min 300×200px). Subtle diagonal line grip indicator.
- Sidebar: removed 📊 Dashboard toggle link, removed broken
  /quest-status.html external link (replaced by 📜 Quests window)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 15:33:07 +02:00
parent 938421999a
commit a5bd659876
37 changed files with 168 additions and 178 deletions

View file

@ -1,45 +1,10 @@
import { useState, lazy, Suspense, useEffect } from 'react';
import { useEffect } from 'react';
import { MapLayout } from './components/map/MapLayout';
import { useLiveData } from './hooks/useLiveData';
import './styles/global.css';
import './styles/map-layout.css';
// Lazy-load dashboard view (contains Recharts ~400KB) — only loaded when user switches to dashboard
const DashboardView = lazy(() => import('./DashboardView'));
type ViewMode = 'map' | 'dashboard';
export default function App() {
const [viewMode, setViewMode] = useState<ViewMode>(
() => (localStorage.getItem('v2-view') as ViewMode) || 'map'
);
const data = useLiveData();
// Ctrl+D toggles map/dashboard
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'd') {
e.preventDefault();
toggleView();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const toggleView = () => {
const next = viewMode === 'map' ? 'dashboard' : 'map';
setViewMode(next);
localStorage.setItem('v2-view', next);
};
if (viewMode === 'map') {
return <MapLayout data={data} onViewToggle={toggleView} />;
}
return (
<Suspense fallback={<div style={{ background: '#0d0d0d', color: '#888', padding: 40, textAlign: 'center' }}>Loading dashboard...</div>}>
<DashboardView data={data} onViewToggle={toggleView} />
</Suspense>
);
return <MapLayout data={data} />;
}

View file

@ -10,10 +10,9 @@ import type { DashboardState } from '../../hooks/useLiveData';
interface Props {
data: DashboardState;
onViewToggle: () => void;
}
export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
export const MapLayout: React.FC<Props> = ({ data }) => {
const getColor = usePlayerColors();
const [showHeatmap, setShowHeatmap] = useState(false);
const [showPortals, setShowPortals] = useState(false);
@ -49,7 +48,6 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
totalKills={data.totalKills}
getColor={getColor}
onSelectPlayer={handleSelectPlayer}
onViewToggle={onViewToggle}
showHeatmap={showHeatmap}
showPortals={showPortals}
onToggleHeatmap={setShowHeatmap}
@ -57,6 +55,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
version={version}
selectedPlayer={selectedPlayer}
/>
<MapView
players={players}
getColor={getColor}

View file

@ -12,7 +12,6 @@ interface Props {
totalKills: number;
getColor: (name: string) => string;
onSelectPlayer: (name: string) => void;
onViewToggle: () => void;
showHeatmap: boolean;
showPortals: boolean;
onToggleHeatmap: (v: boolean) => void;
@ -22,9 +21,9 @@ interface Props {
}
export const Sidebar: React.FC<Props> = ({
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle,
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer,
showHeatmap, showPortals, onToggleHeatmap, onTogglePortals, version, selectedPlayer,
}) => {
}: Props) => {
const [sortKey, setSortKey] = useState<SortKey>('name');
const [filter, setFilter] = useState('');
@ -81,7 +80,6 @@ export const Sidebar: React.FC<Props> = ({
{/* Tool links */}
<div className="ml-tool-links">
<span className="ml-tool-link" style={{ cursor: 'pointer' }} onClick={onViewToggle}>📊 Dashboard</span>
<a href="/inventory.html" target="_blank" className="ml-tool-link">🔍 Inv Search</a>
<a href="/suitbuilder.html" target="_blank" className="ml-tool-link">🛡 Suitbuilder</a>
<a href="/debug.html" target="_blank" className="ml-tool-link">🐛 Debug</a>

View file

@ -1,4 +1,4 @@
import React, { useRef, useCallback, useEffect } from 'react';
import React, { useRef, useCallback, useEffect, useState } from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
interface Props {
@ -14,7 +14,9 @@ export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 70
const { closeWindow, bringToFront } = useWindowManager();
const winRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 });
const resizeRef = useRef({ resizing: false, sx: 0, sy: 0, sw: 0, sh: 0 });
const posRef = useRef({ x: 420, y: 10 + Math.random() * 40 });
const [size, setSize] = useState({ w: width, h: height });
const onHeaderDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@ -24,16 +26,34 @@ export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 70
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, ox: rect.left, oy: rect.top };
}, [id, bringToFront]);
const onResizeDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { resizing: true, sx: e.clientX, sy: e.clientY, sw: size.w, sh: size.h };
}, [size.w, size.h]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
// Drag
const d = dragRef.current;
if (!d.dragging || !winRef.current) return;
posRef.current.x = d.ox + (e.clientX - d.sx);
posRef.current.y = d.oy + (e.clientY - d.sy);
winRef.current.style.left = `${posRef.current.x}px`;
winRef.current.style.top = `${posRef.current.y}px`;
if (d.dragging && winRef.current) {
posRef.current.x = d.ox + (e.clientX - d.sx);
posRef.current.y = d.oy + (e.clientY - d.sy);
winRef.current.style.left = `${posRef.current.x}px`;
winRef.current.style.top = `${posRef.current.y}px`;
}
// Resize
const r = resizeRef.current;
if (r.resizing) {
const newW = Math.max(300, r.sw + (e.clientX - r.sx));
const newH = Math.max(200, r.sh + (e.clientY - r.sy));
setSize({ w: newW, h: newH });
}
};
const onUp = () => {
dragRef.current.dragging = false;
resizeRef.current.resizing = false;
};
const onUp = () => { dragRef.current.dragging = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
@ -43,7 +63,7 @@ export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 70
<div
ref={winRef}
className="ml-window"
style={{ zIndex, width, height, left: posRef.current.x, top: posRef.current.y }}
style={{ zIndex, width: size.w, height: size.h, left: posRef.current.x, top: posRef.current.y }}
onMouseDown={() => bringToFront(id)}
>
<div className="ml-window-header" onMouseDown={onHeaderDown}>
@ -53,6 +73,8 @@ export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 70
<div className="ml-window-content">
{children}
</div>
{/* Resize handle */}
<div className="ml-window-resize" onMouseDown={onResizeDown} />
</div>
);
};

View file

@ -26,6 +26,7 @@ export const PlayerDashboardWindow: React.FC<Props> = ({ id, zIndex, characters
state: t.vt_state ?? 'idle',
tapers: parseInt(t.prismatic_taper_count as string) || 0,
hp: c.vitals?.health_percentage ?? 0,
vitae: c.vitals?.vitae ?? 0,
};
});
@ -74,6 +75,7 @@ export const PlayerDashboardWindow: React.FC<Props> = ({ id, zIndex, characters
<th style={{ ...thStyle('deaths'), textAlign: 'right' }} onClick={() => toggleSort('deaths')}>Deaths{arrow('deaths')}</th>
<th style={{ ...thStyle('uptime'), textAlign: 'right' }} onClick={() => toggleSort('uptime')}>Uptime{arrow('uptime')}</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>HP%</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Vitae</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Tapers</th>
</tr>
</thead>
@ -98,6 +100,8 @@ export const PlayerDashboardWindow: React.FC<Props> = ({ id, zIndex, characters
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.uptime}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', fontVariantNumeric: 'tabular-nums',
color: p.hp > 80 ? '#4c4' : p.hp > 40 ? '#ca0' : '#c44' }}>{p.hp.toFixed(0)}%</td>
<td style={{ textAlign: 'right', padding: '3px 6px', fontVariantNumeric: 'tabular-nums',
color: p.vitae > 0 ? '#f66' : '#333' }}>{p.vitae > 0 ? `${p.vitae}%` : ''}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.tapers.toLocaleString()}</td>
</tr>
);

View file

@ -528,6 +528,18 @@
flex-direction: column;
}
.ml-window-resize {
position: absolute;
bottom: 0;
right: 0;
width: 14px;
height: 14px;
cursor: nwse-resize;
opacity: 0.3;
background: linear-gradient(135deg, transparent 50%, #888 50%, transparent 52%, #888 65%, transparent 67%, #888 80%);
}
.ml-window-resize:hover { opacity: 0.6; }
/* ── Stats window (Grafana iframes) ───────────────────── */
.ml-stats-controls {
display: flex;