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:
parent
3cf6437617
commit
5bda2b64f4
21 changed files with 233 additions and 104 deletions
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
51
frontend/src/components/PlayerDashboardFullPage.tsx
Normal file
51
frontend/src/components/PlayerDashboardFullPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue