feat(v2): Quest Status + Player Dashboard as React windows
Quest Status window (📜 Quests in sidebar): - Fetches GET /quest-status API (polls every 30s) - Grid: characters as rows × all unique quests as columns - "READY" shown in green, countdowns in yellow, missing as dash - Quest names shortened (removes "Timer", "Pickup" suffixes) - Sticky header row, scrollable body - Replaces broken quest-status.html link Player Dashboard window (👥 Dashboard in sidebar): - Sortable table of all online characters - Columns: Character, State, KPH, Session kills, Total kills, Rares (total + session), Deaths, Uptime, HP%, Tapers - Click column headers to sort (ascending/descending toggle) - State badges: green=combat/hunt, red=other, gray=idle - KPH in green, rares in gold, deaths in red (if > 0) - HP% color-coded: green >80%, yellow >40%, red below Sidebar changes: - Removed broken /quest-status.html external link - Added 👥 Dashboard + 📜 Quests as window opener buttons - Both lazy-loaded (only fetched when first opened) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27caa21a56
commit
938421999a
31 changed files with 345 additions and 21 deletions
|
|
@ -85,7 +85,6 @@ export const Sidebar: React.FC<Props> = ({
|
|||
<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>
|
||||
<a href="/quest-status.html" target="_blank" className="ml-tool-link">📜 Quests</a>
|
||||
</div>
|
||||
<SidebarWindowButtons />
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ export const SidebarWindowButtons: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className="ml-tool-links">
|
||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||
onClick={() => openWindow('playerdash', 'Player Dashboard')}>👥 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' }}
|
||||
onClick={() => openWindow('issues', 'Issues Board')}>📋 Issues</span>
|
||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||
|
|
|
|||
113
frontend/src/components/windows/PlayerDashboardWindow.tsx
Normal file
113
frontend/src/components/windows/PlayerDashboardWindow.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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>; }
|
||||
|
||||
type SortCol = 'name' | 'kills' | 'kph' | 'rares' | 'deaths' | 'uptime' | 'state';
|
||||
|
||||
export const PlayerDashboardWindow: React.FC<Props> = ({ id, zIndex, characters }) => {
|
||||
const [sortCol, setSortCol] = useState<SortCol>('kph');
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
const players = useMemo(() => {
|
||||
const list = Array.from(characters.values()).filter(c => c.telemetry).map(c => {
|
||||
const t = c.telemetry!;
|
||||
return {
|
||||
name: c.name,
|
||||
kills: t.kills ?? 0,
|
||||
kph: parseInt(t.kills_per_hour) || 0,
|
||||
totalKills: t.total_kills ?? 0,
|
||||
rares: t.total_rares ?? 0,
|
||||
sessionRares: t.session_rares ?? 0,
|
||||
deaths: parseInt(t.deaths as string) || 0,
|
||||
totalDeaths: parseInt(t.total_deaths as string) || 0,
|
||||
uptime: t.onlinetime?.replace(/^00\./, '') ?? '',
|
||||
state: t.vt_state ?? 'idle',
|
||||
tapers: parseInt(t.prismatic_taper_count as string) || 0,
|
||||
hp: c.vitals?.health_percentage ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
list.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortCol) {
|
||||
case 'name': cmp = a.name.localeCompare(b.name); break;
|
||||
case 'kills': cmp = a.kills - b.kills; break;
|
||||
case 'kph': cmp = a.kph - b.kph; break;
|
||||
case 'rares': cmp = a.rares - b.rares; break;
|
||||
case 'deaths': cmp = a.totalDeaths - b.totalDeaths; break;
|
||||
case 'uptime': cmp = a.uptime.localeCompare(b.uptime); break;
|
||||
case 'state': cmp = a.state.localeCompare(b.state); break;
|
||||
}
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
return list;
|
||||
}, [characters, sortCol, sortAsc]);
|
||||
|
||||
const toggleSort = (col: SortCol) => {
|
||||
if (sortCol === col) setSortAsc(!sortAsc);
|
||||
else { setSortCol(col); setSortAsc(false); }
|
||||
};
|
||||
|
||||
const thStyle = (col: SortCol): React.CSSProperties => ({
|
||||
padding: '4px 6px', cursor: 'pointer', userSelect: 'none',
|
||||
color: sortCol === col ? '#6af' : '#888',
|
||||
fontSize: '0.65rem', fontWeight: 600, whiteSpace: 'nowrap',
|
||||
borderBottom: '1px solid #444',
|
||||
});
|
||||
|
||||
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' }}>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', 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>
|
||||
);
|
||||
};
|
||||
84
frontend/src/components/windows/QuestStatusWindow.tsx
Normal file
84
frontend/src/components/windows/QuestStatusWindow.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props { id: string; zIndex: number; }
|
||||
|
||||
interface QuestData {
|
||||
quest_data: Record<string, Record<string, string>>;
|
||||
tracked_quests: string[];
|
||||
player_count: number;
|
||||
}
|
||||
|
||||
export const QuestStatusWindow: React.FC<Props> = ({ id, zIndex }) => {
|
||||
const [data, setData] = useState<QuestData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
try { setData(await apiFetch<QuestData>('/quest-status')); } catch {}
|
||||
};
|
||||
fetch();
|
||||
const iv = setInterval(fetch, 30000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const characters = data ? Object.keys(data.quest_data).sort() : [];
|
||||
// Collect ALL unique quest names across all characters
|
||||
const allQuests = new Set<string>();
|
||||
if (data) {
|
||||
for (const quests of Object.values(data.quest_data)) {
|
||||
for (const q of Object.keys(quests)) allQuests.add(q);
|
||||
}
|
||||
}
|
||||
const questNames = Array.from(allQuests).sort();
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title="Quest Status" zIndex={zIndex} width={780} height={500}>
|
||||
<div style={{ flex: 1, overflow: 'auto', fontSize: '0.72rem' }}>
|
||||
{!data ? (
|
||||
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>Loading quest data...</div>
|
||||
) : characters.length === 0 ? (
|
||||
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>No quest data available</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', borderBottom: '1px solid #444', color: '#888', fontSize: '0.65rem', fontWeight: 600, minWidth: 140 }}>Character</th>
|
||||
{questNames.map(q => (
|
||||
<th key={q} style={{ textAlign: 'center', padding: '4px 6px', borderBottom: '1px solid #444', color: '#888', fontSize: '0.6rem', fontWeight: 600, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={q}>
|
||||
{q.replace(' Timer', '').replace(' Pickup', '')}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{characters.map(char => {
|
||||
const quests = data.quest_data[char] || {};
|
||||
return (
|
||||
<tr key={char} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={{ padding: '3px 8px', color: '#ccc', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 160 }}>{char}</td>
|
||||
{questNames.map(q => {
|
||||
const val = quests[q];
|
||||
const isReady = val === 'READY';
|
||||
return (
|
||||
<td key={q} style={{
|
||||
textAlign: 'center', padding: '3px 6px',
|
||||
color: isReady ? '#4c4' : val ? '#ca0' : '#333',
|
||||
fontWeight: isReady ? 600 : 400,
|
||||
fontSize: isReady ? '0.7rem' : '0.68rem',
|
||||
}}>
|
||||
{val || '\u2014'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,6 +9,8 @@ const CombatStatsWindow = lazy(() => import('./CombatStatsWindow').then(m => ({
|
|||
const CombatPickerWindow = lazy(() => import('./CombatPickerWindow').then(m => ({ default: m.CombatPickerWindow })));
|
||||
const IssuesWindow = lazy(() => import('./IssuesWindow').then(m => ({ default: m.IssuesWindow })));
|
||||
const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => ({ default: m.VitalSharingWindow })));
|
||||
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
|
||||
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
|
||||
import type { CharacterState } from '../../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -52,6 +54,10 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
|
|||
return <IssuesWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||
case 'vitalsharing':
|
||||
return <VitalSharingWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||
case 'queststatus':
|
||||
return <QuestStatusWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||
case 'playerdash':
|
||||
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue