feat: v2 dashboard — React + Vite parallel implementation
New modern dashboard at /v2 running alongside the existing UI at /. Same backend, same APIs, same WebSocket — zero backend changes. Stack: React 19 + Vite + TypeScript + Recharts Source: frontend/ — build output: static/v2/ Phase 1 delivers: - Character overview cards in a responsive CSS Grid - Live HP/Stamina/Mana bars via WebSocket vitals - Kills/hr, total kills, deaths, session uptime - VTank state badge (Combat/Nav/Idle) - Location coordinates - Click to expand: combat stats, prismatic count, CPU/RAM - Global stats header: active chars, total kills, total rares, server health - WebSocket hook with auto-reconnect - HTTP poll fallback for initial load + server health - Mobile responsive (single column on narrow screens) - Dark theme matching the MosswartOverlord palette Build: cd frontend && npm run build Access: /v2 (served by existing NoCacheStaticFiles mount) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee30ad2636
commit
e58c05c895
24 changed files with 3213 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
__pycache__
|
||||||
|
static/v2/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
|
||||||
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mosswart Overlord v2</title>
|
||||||
|
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2209
frontend/package-lock.json
generated
Normal file
2209
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "mosswart-overlord-v2",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"recharts": "^2.15.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/src/App.tsx
Normal file
21
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import { GlobalStats } from './components/GlobalStats';
|
||||||
|
import { CharacterGrid } from './components/CharacterGrid';
|
||||||
|
import { useLiveData } from './hooks/useLiveData';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { characters, serverHealth, totalRares, totalKills } = useLiveData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<GlobalStats
|
||||||
|
activeChars={characters.size}
|
||||||
|
totalKills={totalKills}
|
||||||
|
totalRares={totalRares}
|
||||||
|
serverHealth={serverHealth}
|
||||||
|
/>
|
||||||
|
<CharacterGrid characters={characters} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/api/client.ts
Normal file
15
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// In production the browser hits /api/* and Nginx strips the prefix.
|
||||||
|
// In dev, Vite's proxy does the same stripping.
|
||||||
|
// So we always use /api/ as prefix — works both environments.
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
export async function apiFetch<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wsUrl(): string {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${proto}//${location.host}/api/ws/live`;
|
||||||
|
}
|
||||||
22
frontend/src/api/endpoints.ts
Normal file
22
frontend/src/api/endpoints.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { apiFetch } from './client';
|
||||||
|
import type { TelemetrySnapshot, CombatStatsMessage, ServerHealth } from '../types';
|
||||||
|
|
||||||
|
interface LiveResponse {
|
||||||
|
players: TelemetrySnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombatStatsResponse {
|
||||||
|
stats: CombatStatsMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountResponse {
|
||||||
|
total_rares?: number;
|
||||||
|
total_kills?: number;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLive = () => apiFetch<LiveResponse>('/live');
|
||||||
|
export const getCombatStats = () => apiFetch<CombatStatsResponse>('/combat-stats');
|
||||||
|
export const getServerHealth = () => apiFetch<ServerHealth>('/server-health');
|
||||||
|
export const getTotalRares = () => apiFetch<CountResponse>('/total-rares');
|
||||||
|
export const getTotalKills = () => apiFetch<CountResponse>('/total-kills');
|
||||||
89
frontend/src/components/CharacterCard.tsx
Normal file
89
frontend/src/components/CharacterCard.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { VitalBar } from './VitalBar';
|
||||||
|
import type { CharacterState } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
character: CharacterState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vtankBadge = (state: string) => {
|
||||||
|
const s = (state || 'idle').toLowerCase();
|
||||||
|
if (s === 'combat') return { label: 'Combat', cls: 'badge-combat' };
|
||||||
|
if (s === 'nav' || s === 'navigation') return { label: 'Nav', cls: 'badge-nav' };
|
||||||
|
return { label: 'Idle', cls: 'badge-idle' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CharacterCard: React.FC<Props> = React.memo(({ character }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const { telemetry: t, vitals: v, combat: c } = character;
|
||||||
|
const badge = vtankBadge(t?.vt_state ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="char-card" onClick={() => setExpanded(!expanded)}>
|
||||||
|
<div className="char-header">
|
||||||
|
<span className="char-name">{character.name}</span>
|
||||||
|
<span className={`char-badge ${badge.cls}`}>{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{v ? (
|
||||||
|
<div className="char-vitals">
|
||||||
|
<VitalBar label="HP" current={v.health_current} max={v.health_max}
|
||||||
|
color="linear-gradient(90deg, #ff4444, #ff6666)" bgColor="#330000" />
|
||||||
|
<VitalBar label="ST" current={v.stamina_current} max={v.stamina_max}
|
||||||
|
color="linear-gradient(90deg, #ffaa00, #ffcc44)" bgColor="#331a00" />
|
||||||
|
<VitalBar label="MN" current={v.mana_current} max={v.mana_max}
|
||||||
|
color="linear-gradient(90deg, #4488ff, #66aaff)" bgColor="#001433" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="char-vitals-placeholder">Awaiting vitals...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="char-stats-row">
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-value">{t?.kills_per_hour ?? '--'}</span>
|
||||||
|
<span className="stat-label">kills/hr</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-value">{t?.kills?.toLocaleString() ?? '--'}</span>
|
||||||
|
<span className="stat-label">kills</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-value">{t?.deaths ?? '0'}</span>
|
||||||
|
<span className="stat-label">deaths</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="stat-value">{t?.onlinetime?.replace(/^00\./, '') ?? '--'}</span>
|
||||||
|
<span className="stat-label">uptime</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t && (
|
||||||
|
<div className="char-location">
|
||||||
|
{t.ns?.toFixed(1)}N, {t.ew?.toFixed(1)}E
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="char-expanded">
|
||||||
|
{v?.vitae ? <div className="vitae-warn">Vitae: {v.vitae}%</div> : null}
|
||||||
|
<div className="expanded-row">
|
||||||
|
<span>Prismatics: {t?.prismatic_taper_count ?? '--'}</span>
|
||||||
|
<span>Total Deaths: {t?.total_deaths ?? '--'}</span>
|
||||||
|
</div>
|
||||||
|
{c?.session && (
|
||||||
|
<div className="expanded-row">
|
||||||
|
<span>Session Dmg: {c.session.total_damage_given?.toLocaleString()}</span>
|
||||||
|
<span>Session Kills: {c.session.total_kills}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="expanded-row">
|
||||||
|
<span>RAM: {t?.mem_mb ? (t.mem_mb / 1048576).toFixed(0) + ' MB' : '--'}</span>
|
||||||
|
<span>CPU: {t?.cpu_pct?.toFixed(1) ?? '--'}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CharacterCard.displayName = 'CharacterCard';
|
||||||
27
frontend/src/components/CharacterGrid.tsx
Normal file
27
frontend/src/components/CharacterGrid.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { CharacterCard } from './CharacterCard';
|
||||||
|
import type { CharacterState } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
characters: Map<string, CharacterState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CharacterGrid: React.FC<Props> = ({ characters }) => {
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return Array.from(characters.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
}, [characters]);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return <div className="grid-empty">No active characters</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="char-grid">
|
||||||
|
{sorted.map(ch => (
|
||||||
|
<CharacterCard key={ch.name} character={ch} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
frontend/src/components/GlobalStats.tsx
Normal file
36
frontend/src/components/GlobalStats.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
import type { ServerHealth } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeChars: number;
|
||||||
|
totalKills: number;
|
||||||
|
totalRares: number;
|
||||||
|
serverHealth: ServerHealth | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlobalStats: React.FC<Props> = ({ activeChars, totalKills, totalRares, serverHealth }) => {
|
||||||
|
const serverStatus = serverHealth?.status?.toLowerCase() ?? 'unknown';
|
||||||
|
const isOnline = serverStatus === 'online' || serverStatus === 'up';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="global-stats">
|
||||||
|
<div className="global-stat">
|
||||||
|
<span className="global-value">{activeChars}</span>
|
||||||
|
<span className="global-label">Active Characters</span>
|
||||||
|
</div>
|
||||||
|
<div className="global-stat">
|
||||||
|
<span className="global-value">{totalKills.toLocaleString()}</span>
|
||||||
|
<span className="global-label">Total Kills</span>
|
||||||
|
</div>
|
||||||
|
<div className="global-stat">
|
||||||
|
<span className="global-value">{totalRares}</span>
|
||||||
|
<span className="global-label">Total Rares</span>
|
||||||
|
</div>
|
||||||
|
<div className="global-stat">
|
||||||
|
<span className={`server-dot ${isOnline ? 'online' : 'offline'}`} />
|
||||||
|
<span className="global-value">{serverHealth?.latency_ms ?? '--'}ms</span>
|
||||||
|
<span className="global-label">Coldeve</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
frontend/src/components/Layout.tsx
Normal file
23
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Layout: React.FC<Props> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<h1 className="dashboard-title">Mosswart Overlord</h1>
|
||||||
|
<nav className="dashboard-nav">
|
||||||
|
<a href="/" className="nav-link">Classic View</a>
|
||||||
|
<a href="/inventory.html" className="nav-link">Inventory</a>
|
||||||
|
<a href="/suitbuilder.html" className="nav-link">Suitbuilder</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main className="dashboard-main">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
frontend/src/components/VitalBar.tsx
Normal file
24
frontend/src/components/VitalBar.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VitalBar: React.FC<Props> = React.memo(({ label, current, max, color, bgColor }) => {
|
||||||
|
const pct = max > 0 ? Math.min(100, Math.max(0, (current / max) * 100)) : 0;
|
||||||
|
return (
|
||||||
|
<div className="vital-bar">
|
||||||
|
<span className="vital-label">{label}</span>
|
||||||
|
<div className="vital-track" style={{ backgroundColor: bgColor }}>
|
||||||
|
<div className="vital-fill" style={{ width: `${pct}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
<span className="vital-text">{current}/{max}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
VitalBar.displayName = 'VitalBar';
|
||||||
133
frontend/src/hooks/useLiveData.ts
Normal file
133
frontend/src/hooks/useLiveData.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useWebSocket } from './useWebSocket';
|
||||||
|
import { getLive, getCombatStats, getServerHealth, getTotalRares, getTotalKills } from '../api/endpoints';
|
||||||
|
import type {
|
||||||
|
CharacterState, TelemetrySnapshot, VitalsMessage, CombatStatsMessage,
|
||||||
|
RareMessage, ServerHealth, WSMessage,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export interface DashboardState {
|
||||||
|
characters: Map<string, CharacterState>;
|
||||||
|
serverHealth: ServerHealth | null;
|
||||||
|
totalRares: number;
|
||||||
|
totalKills: number;
|
||||||
|
recentRares: RareMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveData(): DashboardState {
|
||||||
|
const [characters, setCharacters] = useState<Map<string, CharacterState>>(new Map());
|
||||||
|
const [serverHealth, setServerHealth] = useState<ServerHealth | null>(null);
|
||||||
|
const [totalRares, setTotalRares] = useState(0);
|
||||||
|
const [totalKills, setTotalKills] = useState(0);
|
||||||
|
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
|
||||||
|
const charsRef = useRef(characters);
|
||||||
|
charsRef.current = characters;
|
||||||
|
|
||||||
|
// Helper to update a single character's state
|
||||||
|
const updateChar = useCallback((name: string, updater: (prev: CharacterState) => CharacterState) => {
|
||||||
|
setCharacters(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const existing = next.get(name) ?? { name, telemetry: null, vitals: null, combat: null, lastUpdate: 0 };
|
||||||
|
next.set(name, updater(existing));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// WebSocket message handler
|
||||||
|
const handleWS = useCallback((msg: WSMessage) => {
|
||||||
|
if (!msg.type) return;
|
||||||
|
|
||||||
|
if (msg.type === 'telemetry') {
|
||||||
|
const t = msg as TelemetrySnapshot & { type: string };
|
||||||
|
updateChar(t.character_name, s => ({ ...s, telemetry: t, lastUpdate: Date.now() }));
|
||||||
|
} else if (msg.type === 'vitals') {
|
||||||
|
const v = msg as VitalsMessage;
|
||||||
|
updateChar(v.character_name, s => ({ ...s, vitals: v, lastUpdate: Date.now() }));
|
||||||
|
} else if (msg.type === 'combat_stats') {
|
||||||
|
const c = msg as CombatStatsMessage;
|
||||||
|
updateChar(c.character_name, s => ({ ...s, combat: c, lastUpdate: Date.now() }));
|
||||||
|
} else if (msg.type === 'rare') {
|
||||||
|
const r = msg as RareMessage;
|
||||||
|
setRecentRares(prev => [r, ...prev].slice(0, 50));
|
||||||
|
}
|
||||||
|
}, [updateChar]);
|
||||||
|
|
||||||
|
useWebSocket(handleWS);
|
||||||
|
|
||||||
|
// HTTP polls as fallback/initial load
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLive = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getLive();
|
||||||
|
setCharacters(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const p of data.players ?? []) {
|
||||||
|
const existing = next.get(p.character_name);
|
||||||
|
next.set(p.character_name, {
|
||||||
|
name: p.character_name,
|
||||||
|
telemetry: p,
|
||||||
|
vitals: existing?.vitals ?? null,
|
||||||
|
combat: existing?.combat ?? null,
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Remove stale characters not in /live response
|
||||||
|
for (const key of next.keys()) {
|
||||||
|
if (!data.players?.some(p => p.character_name === key)) {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLive();
|
||||||
|
const id = setInterval(fetchLive, 5000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Combat stats poll
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getCombatStats();
|
||||||
|
for (const s of data.stats ?? []) {
|
||||||
|
updateChar(s.character_name, prev => ({
|
||||||
|
...prev,
|
||||||
|
combat: { ...s, type: 'combat_stats' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
fetch();
|
||||||
|
const id = setInterval(fetch, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [updateChar]);
|
||||||
|
|
||||||
|
// Server health poll
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
try { setServerHealth(await getServerHealth()); } catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
fetch();
|
||||||
|
const id = setInterval(fetch, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global counters poll
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
try {
|
||||||
|
const [rares, kills] = await Promise.all([getTotalRares(), getTotalKills()]);
|
||||||
|
setTotalRares(rares.total_rares ?? rares.count ?? 0);
|
||||||
|
setTotalKills(kills.total_kills ?? kills.count ?? 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
fetch();
|
||||||
|
const id = setInterval(fetch, 300000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { characters, serverHealth, totalRares, totalKills, recentRares };
|
||||||
|
}
|
||||||
44
frontend/src/hooks/useWebSocket.ts
Normal file
44
frontend/src/hooks/useWebSocket.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { wsUrl } from '../api/client';
|
||||||
|
import type { WSMessage } from '../types';
|
||||||
|
|
||||||
|
type MessageHandler = (msg: WSMessage) => void;
|
||||||
|
|
||||||
|
export function useWebSocket(onMessage: MessageHandler) {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimer = useRef<number>(0);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl());
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.addEventListener('message', (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data) as WSMessage;
|
||||||
|
onMessageRef.current(msg);
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
wsRef.current = null;
|
||||||
|
reconnectTimer.current = window.setTimeout(connect, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(reconnectTimer.current);
|
||||||
|
wsRef.current?.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
}
|
||||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
304
frontend/src/styles/global.css
Normal file
304
frontend/src/styles/global.css
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/* ── Reset & Variables ─────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-body: #0d0d0d;
|
||||||
|
--bg-card: #1a1a1a;
|
||||||
|
--bg-card-hover: #222;
|
||||||
|
--bg-header: #111;
|
||||||
|
--border: #333;
|
||||||
|
--text: #ddd;
|
||||||
|
--text-muted: #888;
|
||||||
|
--text-dim: #555;
|
||||||
|
--accent: #4488ff;
|
||||||
|
--hp: linear-gradient(90deg, #ff4444, #ff6666);
|
||||||
|
--hp-bg: #330000;
|
||||||
|
--sta: linear-gradient(90deg, #ffaa00, #ffcc44);
|
||||||
|
--sta-bg: #331a00;
|
||||||
|
--mana: linear-gradient(90deg, #4488ff, #66aaff);
|
||||||
|
--mana-bg: #001433;
|
||||||
|
--badge-combat: #44cc44;
|
||||||
|
--badge-nav: #ffaa00;
|
||||||
|
--badge-idle: #666;
|
||||||
|
--radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-body);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard Layout ─────────────────────────────────── */
|
||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 12px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-link:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.dashboard-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 24px;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Global Stats Bar ─────────────────────────────────── */
|
||||||
|
.global-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.server-dot.online { background: #44cc44; }
|
||||||
|
.server-dot.offline { background: #cc4444; }
|
||||||
|
|
||||||
|
/* ── Character Grid ───────────────────────────────────── */
|
||||||
|
.char-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 48px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Character Card ───────────────────────────────────── */
|
||||||
|
.char-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.char-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.badge-combat { background: rgba(68,204,68,0.2); color: var(--badge-combat); }
|
||||||
|
.badge-nav { background: rgba(255,170,0,0.2); color: var(--badge-nav); }
|
||||||
|
.badge-idle { background: rgba(100,100,100,0.2); color: var(--badge-idle); }
|
||||||
|
|
||||||
|
/* ── Vital Bars ───────────────────────────────────────── */
|
||||||
|
.char-vitals {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-vitals-placeholder {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vital-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vital-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vital-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vital-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vital-text {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 65px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats Row ────────────────────────────────────────── */
|
||||||
|
.char-stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Location ─────────────────────────────────────────── */
|
||||||
|
.char-location {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Expanded Details ─────────────────────────────────── */
|
||||||
|
.char-expanded {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vitae-warn {
|
||||||
|
color: #ff6666;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile Responsive ────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
.dashboard-main {
|
||||||
|
padding: 12px 12px;
|
||||||
|
}
|
||||||
|
.global-stats {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.char-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.char-stats-row {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.dashboard-nav {
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.char-card {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
frontend/src/types/index.ts
Normal file
109
frontend/src/types/index.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// Matches the telemetry payload from the plugin + /live endpoint join
|
||||||
|
export interface TelemetrySnapshot {
|
||||||
|
character_name: string;
|
||||||
|
char_tag: string;
|
||||||
|
session_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
ew: number;
|
||||||
|
ns: number;
|
||||||
|
z: number;
|
||||||
|
kills: number;
|
||||||
|
kills_per_hour: string;
|
||||||
|
onlinetime: string;
|
||||||
|
deaths: string;
|
||||||
|
total_deaths: string;
|
||||||
|
prismatic_taper_count: string;
|
||||||
|
vt_state: string;
|
||||||
|
mem_mb: number;
|
||||||
|
cpu_pct: number;
|
||||||
|
// Joined from DB in /live
|
||||||
|
total_rares?: number;
|
||||||
|
session_rares?: number;
|
||||||
|
total_kills?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VitalsMessage {
|
||||||
|
type: 'vitals';
|
||||||
|
character_name: string;
|
||||||
|
health_current: number;
|
||||||
|
health_max: number;
|
||||||
|
health_percentage: number;
|
||||||
|
stamina_current: number;
|
||||||
|
stamina_max: number;
|
||||||
|
stamina_percentage: number;
|
||||||
|
mana_current: number;
|
||||||
|
mana_max: number;
|
||||||
|
mana_percentage: number;
|
||||||
|
vitae: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RareMessage {
|
||||||
|
type: 'rare';
|
||||||
|
character_name: string;
|
||||||
|
name: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatStatsMessage {
|
||||||
|
type: 'combat_stats';
|
||||||
|
character_name: string;
|
||||||
|
session_id: string;
|
||||||
|
session: CombatSessionState | null;
|
||||||
|
lifetime: CombatSessionState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatSessionState {
|
||||||
|
total_damage_given: number;
|
||||||
|
total_damage_received: number;
|
||||||
|
total_kills: number;
|
||||||
|
total_aetheria_surges: number;
|
||||||
|
total_cloak_surges: number;
|
||||||
|
session_start: string;
|
||||||
|
monsters: Record<string, MonsterRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonsterRecord {
|
||||||
|
name: string;
|
||||||
|
kill_count: number;
|
||||||
|
damage_given: number;
|
||||||
|
damage_received: number;
|
||||||
|
aetheria_surges: number;
|
||||||
|
cloak_surges: number;
|
||||||
|
offense: Record<string, Record<string, DamageStats>>;
|
||||||
|
defense: Record<string, Record<string, DamageStats>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DamageStats {
|
||||||
|
total_attacks: number;
|
||||||
|
failed_attacks: number;
|
||||||
|
crits: number;
|
||||||
|
total_normal_damage: number;
|
||||||
|
max_normal_damage: number;
|
||||||
|
total_crit_damage: number;
|
||||||
|
max_crit_damage: number;
|
||||||
|
damage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerHealth {
|
||||||
|
status: string;
|
||||||
|
latency_ms: number | null;
|
||||||
|
player_count: number | null;
|
||||||
|
uptime_seconds: number | null;
|
||||||
|
last_check: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged live state for each character shown in the dashboard
|
||||||
|
export interface CharacterState {
|
||||||
|
name: string;
|
||||||
|
telemetry: TelemetrySnapshot | null;
|
||||||
|
vitals: VitalsMessage | null;
|
||||||
|
combat: CombatStatsMessage | null;
|
||||||
|
lastUpdate: number; // timestamp ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WSMessage =
|
||||||
|
| (TelemetrySnapshot & { type: 'telemetry' })
|
||||||
|
| VitalsMessage
|
||||||
|
| CombatStatsMessage
|
||||||
|
| RareMessage
|
||||||
|
| { type: string; [key: string]: unknown };
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/charactercard.tsx","./src/components/charactergrid.tsx","./src/components/globalstats.tsx","./src/components/layout.tsx","./src/components/vitalbar.tsx","./src/hooks/uselivedata.ts","./src/hooks/usewebsocket.ts","./src/types/index.ts"],"version":"5.8.3"}
|
||||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/v2/',
|
||||||
|
build: {
|
||||||
|
outDir: '../static/v2',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8765',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1
static/v2/assets/index-Ba_QIbRB.css
Normal file
1
static/v2/assets/index-Ba_QIbRB.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--bg-body: #0d0d0d;--bg-card: #1a1a1a;--bg-card-hover: #222;--bg-header: #111;--border: #333;--text: #ddd;--text-muted: #888;--text-dim: #555;--accent: #4488ff;--hp: linear-gradient(90deg, #ff4444, #ff6666);--hp-bg: #330000;--sta: linear-gradient(90deg, #ffaa00, #ffcc44);--sta-bg: #331a00;--mana: linear-gradient(90deg, #4488ff, #66aaff);--mana-bg: #001433;--badge-combat: #44cc44;--badge-nav: #ffaa00;--badge-idle: #666;--radius: 6px}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:var(--bg-body);color:var(--text);line-height:1.4;min-height:100vh}.dashboard{display:flex;flex-direction:column;min-height:100vh}.dashboard-header{background:var(--bg-header);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}.dashboard-title{font-size:1.2rem;font-weight:600;color:var(--accent)}.dashboard-nav{display:flex;gap:16px}.nav-link{color:var(--text-muted);text-decoration:none;font-size:.85rem;transition:color .2s}.nav-link:hover{color:var(--text)}.dashboard-main{flex:1;padding:16px 24px;max-width:1600px;margin:0 auto;width:100%}.global-stats{display:flex;gap:24px;padding:12px 0;margin-bottom:16px;border-bottom:1px solid var(--border);flex-wrap:wrap}.global-stat{display:flex;align-items:center;gap:6px}.global-value{font-size:1.1rem;font-weight:600;color:var(--text)}.global-label{font-size:.75rem;color:var(--text-muted)}.server-dot{width:8px;height:8px;border-radius:50%;display:inline-block}.server-dot.online{background:#4c4}.server-dot.offline{background:#c44}.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}.grid-empty{text-align:center;color:var(--text-muted);padding:48px;font-size:1rem}.char-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;cursor:pointer;transition:background .15s,border-color .15s}.char-card:hover{background:var(--bg-card-hover);border-color:#444}.char-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.char-name{font-size:.9rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.char-badge{font-size:.65rem;font-weight:700;text-transform:uppercase;padding:2px 8px;border-radius:3px;letter-spacing:.5px}.badge-combat{background:#4c43;color:var(--badge-combat)}.badge-nav{background:#fa03;color:var(--badge-nav)}.badge-idle{background:#64646433;color:var(--badge-idle)}.char-vitals{display:flex;flex-direction:column;gap:3px;margin-bottom:8px}.char-vitals-placeholder{font-size:.75rem;color:var(--text-dim);margin-bottom:8px;font-style:italic}.vital-bar{display:flex;align-items:center;gap:6px}.vital-label{font-size:.65rem;color:var(--text-muted);width:20px;text-align:right}.vital-track{flex:1;height:6px;border-radius:3px;overflow:hidden}.vital-fill{height:100%;border-radius:3px;transition:width .3s ease-out}.vital-text{font-size:.65rem;color:var(--text-muted);width:65px;text-align:right;font-variant-numeric:tabular-nums}.char-stats-row{display:flex;gap:12px;margin-bottom:4px}.stat{display:flex;flex-direction:column;align-items:center}.stat-value{font-size:.85rem;font-weight:600;font-variant-numeric:tabular-nums}.stat-label{font-size:.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px}.char-location{font-size:.7rem;color:var(--text-dim);text-align:right}.char-expanded{margin-top:8px;padding-top:8px;border-top:1px solid var(--border)}.expanded-row{display:flex;justify-content:space-between;font-size:.75rem;color:var(--text-muted);margin-bottom:2px}.vitae-warn{color:#f66;font-size:.8rem;font-weight:600;margin-bottom:4px}@media(max-width:768px){.dashboard-header{flex-direction:column;gap:8px;padding:10px 16px}.dashboard-main{padding:12px}.global-stats{gap:16px}.char-grid{grid-template-columns:1fr}.char-stats-row{gap:8px}}@media(max-width:480px){.dashboard-nav{gap:10px;font-size:.8rem}.char-card{padding:10px}}
|
||||||
49
static/v2/assets/index-Nz88Zp1N.js
Normal file
49
static/v2/assets/index-Nz88Zp1N.js
Normal file
File diff suppressed because one or more lines are too long
14
static/v2/index.html
Normal file
14
static/v2/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mosswart Overlord v2</title>
|
||||||
|
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
||||||
|
<script type="module" crossorigin src="/v2/assets/index-Nz88Zp1N.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/v2/assets/index-Ba_QIbRB.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue