feat(dashboard): open Player Dashboard in a new tab

The 👥 Dashboard button used to open the player table as a draggable
in-app window, which competed for screen space with the map. It now
opens in a separate browser tab as a fullscreen page so users can put
the dashboard on a second monitor.

How:
- App.tsx branches on ?view=dashboard → renders PlayerDashboardFullPage
  (new file in components/) instead of the default MapLayout.
- SidebarWindowButtons.tsx: 👥 Dashboard onClick now does
  window.open('/?view=dashboard', '_blank', 'noopener'). Label shows
  '↗' so users know it's an external open.
- PlayerDashboardWindow.tsx refactored: extracted the sortable table
  body into a reusable PlayerDashboardContent component. The old window
  shell stays registered in WindowRenderer for backward compat — just
  no longer reachable from the default sidebar.
- map-layout.css: new .ml-dashboard-page rules for fullscreen layout.

Each tab gets its own useLiveData + WebSocket connection (server
already handles multiple browser clients). The new tab inherits the
session cookie from the original tab — no re-login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-23 19:31:26 +02:00
parent 3cf6437617
commit 5bda2b64f4
21 changed files with 233 additions and 104 deletions

View file

@ -1,8 +1,28 @@
import { MapLayout } from './components/map/MapLayout';
import { PlayerDashboardFullPage } from './components/PlayerDashboardFullPage';
import { useLiveData } from './hooks/useLiveData';
import './styles/map-layout.css';
/**
* Single SPA entry. Branches on `?view=` query param:
* /?view=dashboard fullscreen PlayerDashboardFullPage (new-tab target)
* / default map + sidebar layout
*
* We don't pull in react-router for one extra view when a third view
* appears, swap this for proper routing.
*/
export default function App() {
const view = new URLSearchParams(window.location.search).get('view');
if (view === 'dashboard') {
return <PlayerDashboardFullPage />;
}
// Default: full app with map + sidebar.
return <DefaultApp />;
}
/** Default map-and-sidebar layout. Split out so the dashboard tab doesn't
* spin up useLiveData twice for the same render. */
function DefaultApp() {
const data = useLiveData();
return <MapLayout data={data} />;
}

View file

@ -0,0 +1,51 @@
import React, { useEffect, useState } from 'react';
import { useLiveData } from '../hooks/useLiveData';
import { PlayerDashboardContent } from './windows/PlayerDashboardWindow';
/**
* Fullscreen "Player Dashboard" page rendered when the React app loads
* with `?view=dashboard` in the URL. Designed to be opened in a new tab
* by the sidebar's 👥 Dashboard button so users can put the dashboard on
* a second monitor / its own window without occupying the map view.
*
* Each tab is its own React app instance with its own useLiveData
* (and therefore its own WebSocket to /ws/live). Independent of the main
* tab's lifecycle.
*/
export const PlayerDashboardFullPage: React.FC = () => {
const data = useLiveData();
const [version, setVersion] = useState('');
// Set tab title.
useEffect(() => {
const prev = document.title;
document.title = 'Overlord Dashboard';
return () => { document.title = prev; };
}, []);
// Fetch version stamp the same way MapLayout does. /api-version returns
// {version: "..."} where "..." is the BUILD_VERSION baked into the
// tracker container at image build time.
useEffect(() => {
fetch('/api/api-version', { credentials: 'include' })
.then(r => r.json())
.then(d => setVersion(d.version ?? ''))
.catch(() => { /* version is cosmetic — ignore failures */ });
}, []);
const count = Array.from(data.characters.values()).filter(c => c.telemetry).length;
return (
<div className="ml-dashboard-page">
<header className="ml-dashboard-header">
<span className="ml-dashboard-title">👥 Player Dashboard</span>
<span className="ml-dashboard-count">{count} online</span>
<span style={{ flex: 1 }} />
{version && <span className="ml-dashboard-version">v{version}</span>}
</header>
<main className="ml-dashboard-main">
<PlayerDashboardContent characters={data.characters} />
</main>
</div>
);
};

View file

@ -18,7 +18,8 @@ export const SidebarWindowButtons: React.FC = () => {
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('agent', 'Overlord Assistant')}>🤖 Assistant</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('playerdash', 'Player Dashboard')}>👥 Dashboard</span>
title="Opens the player dashboard in a new tab"
onClick={() => window.open('/?view=dashboard', '_blank', 'noopener')}>👥 Dashboard </span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('queststatus', 'Quest Status')}>📜 Quests</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}

View file

@ -2,11 +2,17 @@ import React, { useState, useMemo } from 'react';
import { DraggableWindow } from './DraggableWindow';
import type { CharacterState } from '../../types';
interface Props { id: string; zIndex: number; characters: Map<string, CharacterState>; }
interface WindowProps { id: string; zIndex: number; characters: Map<string, CharacterState>; }
interface ContentProps { characters: Map<string, CharacterState>; }
type SortCol = 'name' | 'kills' | 'kph' | 'rares' | 'deaths' | 'uptime' | 'state';
export const PlayerDashboardWindow: React.FC<Props> = ({ id, zIndex, characters }) => {
/**
* The actual sortable-table view. Pure presentational pass in `characters`.
* Used by both the in-app draggable window AND the new-tab fullscreen page.
* Don't add window-chrome / sidebar concerns here.
*/
export const PlayerDashboardContent: React.FC<ContentProps> = ({ characters }) => {
const [sortCol, setSortCol] = useState<SortCol>('kph');
const [sortAsc, setSortAsc] = useState(false);
@ -61,57 +67,67 @@ export const PlayerDashboardWindow: React.FC<Props> = ({ id, zIndex, characters
const arrow = (col: SortCol) => sortCol === col ? (sortAsc ? ' ▲' : ' ▼') : '';
return (
<DraggableWindow id={id} title="Player Dashboard" zIndex={zIndex} width={850} height={500}>
<div style={{ flex: 1, overflow: 'auto', fontSize: '0.73rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<th style={{ ...thStyle('name'), textAlign: 'left' }} onClick={() => toggleSort('name')}>Character{arrow('name')}</th>
<th style={{ ...thStyle('state'), textAlign: 'center' }} onClick={() => toggleSort('state')}>State{arrow('state')}</th>
<th style={{ ...thStyle('kph'), textAlign: 'right' }} onClick={() => toggleSort('kph')}>KPH{arrow('kph')}</th>
<th style={{ ...thStyle('kills'), textAlign: 'right' }} onClick={() => toggleSort('kills')}>Session{arrow('kills')}</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Total</th>
<th style={{ ...thStyle('rares'), textAlign: 'right' }} onClick={() => toggleSort('rares')}>Rares{arrow('rares')}</th>
<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>
<tbody>
{players.map(p => {
const stateLC = p.state.toLowerCase();
const isActive = stateLC === 'combat' || stateLC === 'hunt';
return (
<tr key={p.name} style={{ borderBottom: '1px solid #1a1a1a' }}>
<td style={{ padding: '3px 6px', color: '#ccc', fontWeight: 500, maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ textAlign: 'center', padding: '3px 6px' }}>
<span style={{ fontSize: '0.6rem', padding: '1px 6px', borderRadius: 3,
background: isActive ? 'rgba(68,204,68,0.15)' : stateLC === 'idle' || stateLC === 'default' ? 'rgba(100,100,100,0.2)' : 'rgba(204,68,68,0.15)',
color: isActive ? '#4c4' : stateLC === 'idle' || stateLC === 'default' ? '#888' : '#c44',
}}>{p.state}</span>
</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#4c4', fontVariantNumeric: 'tabular-nums' }}>{p.kph.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#ccc', fontVariantNumeric: 'tabular-nums' }}>{p.kills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.totalKills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#fc0', fontVariantNumeric: 'tabular-nums' }}>{p.rares}{p.sessionRares > 0 ? ` (${p.sessionRares})` : ''}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: p.totalDeaths > 0 ? '#c66' : '#555', fontVariantNumeric: 'tabular-nums' }}>{p.totalDeaths}</td>
<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>
);
})}
</tbody>
</table>
{players.length === 0 && (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>No characters online</div>
)}
</div>
</DraggableWindow>
<div style={{ flex: 1, overflow: 'auto', fontSize: '0.73rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<th style={{ ...thStyle('name'), textAlign: 'left' }} onClick={() => toggleSort('name')}>Character{arrow('name')}</th>
<th style={{ ...thStyle('state'), textAlign: 'center' }} onClick={() => toggleSort('state')}>State{arrow('state')}</th>
<th style={{ ...thStyle('kph'), textAlign: 'right' }} onClick={() => toggleSort('kph')}>KPH{arrow('kph')}</th>
<th style={{ ...thStyle('kills'), textAlign: 'right' }} onClick={() => toggleSort('kills')}>Session{arrow('kills')}</th>
<th style={{ textAlign: 'right', padding: '4px 6px', color: '#888', fontSize: '0.65rem', fontWeight: 600, borderBottom: '1px solid #444' }}>Total</th>
<th style={{ ...thStyle('rares'), textAlign: 'right' }} onClick={() => toggleSort('rares')}>Rares{arrow('rares')}</th>
<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>
<tbody>
{players.map(p => {
const stateLC = p.state.toLowerCase();
const isActive = stateLC === 'combat' || stateLC === 'hunt';
return (
<tr key={p.name} style={{ borderBottom: '1px solid #1a1a1a' }}>
<td style={{ padding: '3px 6px', color: '#ccc', fontWeight: 500, maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</td>
<td style={{ textAlign: 'center', padding: '3px 6px' }}>
<span style={{ fontSize: '0.6rem', padding: '1px 6px', borderRadius: 3,
background: isActive ? 'rgba(68,204,68,0.15)' : stateLC === 'idle' || stateLC === 'default' ? 'rgba(100,100,100,0.2)' : 'rgba(204,68,68,0.15)',
color: isActive ? '#4c4' : stateLC === 'idle' || stateLC === 'default' ? '#888' : '#c44',
}}>{p.state}</span>
</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#4c4', fontVariantNumeric: 'tabular-nums' }}>{p.kph.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#ccc', fontVariantNumeric: 'tabular-nums' }}>{p.kills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>{p.totalKills.toLocaleString()}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: '#fc0', fontVariantNumeric: 'tabular-nums' }}>{p.rares}{p.sessionRares > 0 ? ` (${p.sessionRares})` : ''}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', color: p.totalDeaths > 0 ? '#c66' : '#555', fontVariantNumeric: 'tabular-nums' }}>{p.totalDeaths}</td>
<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>
);
})}
</tbody>
</table>
{players.length === 0 && (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>No characters online</div>
)}
</div>
);
};
/**
* In-app draggable window wrapper. Kept for backward compatibility the
* sidebar button now opens the dashboard in a new tab via
* PlayerDashboardFullPage, so this component is no longer reachable
* via the default UI but still registered in WindowRenderer.
*/
export const PlayerDashboardWindow: React.FC<WindowProps> = ({ id, zIndex, characters }) => (
<DraggableWindow id={id} title="Player Dashboard" zIndex={zIndex} width={850} height={500}>
<PlayerDashboardContent characters={characters} />
</DraggableWindow>
);

View file

@ -926,6 +926,48 @@
width: 160px;
}
/* ── Fullscreen Player Dashboard (new-tab variant) ───── */
.ml-dashboard-page {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: #111;
color: #ddd;
font-family: inherit;
}
.ml-dashboard-header {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px;
background: #1a1a1a;
border-bottom: 1px solid #333;
font-size: 0.9rem;
}
.ml-dashboard-title {
font-weight: 600;
color: #cfcfff;
font-size: 1rem;
}
.ml-dashboard-count {
color: #6af;
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
}
.ml-dashboard-version {
font-family: monospace;
font-size: 0.7rem;
color: #888;
}
.ml-dashboard-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8px 12px;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 768px) {
.ml-layout { flex-direction: column; }