fix(v2): all reported issues — missing windows, broken features, layouts

Missing features (now added):
1. Vital Sharing window — polls /vital-sharing/peers, shows peer vitals
   with HP/STA/MANA bars, connection status, position, tags
2. Combat Stats window — full Mag-Tools style with monster list (left),
   damage breakdown grid (right), session/lifetime toggle, element matrix
3. Issues Board window — CRUD with categories, resolve/reopen, comments
4. Quest Status — links to /quest-status.html (separate page like v1)
5. Sidebar: added Issues + Vitals buttons, Quest link, Combat button
   per player row (6 buttons now: Chat/Stats/Inv/Char/Combat/Radar)

Fixed functionality:
6. Radar — fixed command to "start_radar"/"stop_radar" (was wrong path)
7. Character window — redesigned with v1-style tabbed layout:
   Left tabs: Attributes (vitals bars + attribute grid) | Skills
   (specialized/trained grouped) | Titles
   Right tabs: Augs | Ratings | Other (allegiance)
   Header: level, race, gender, XP, luminance, deaths, skill credits
8. Stats window — proper Grafana iframe grid (4 panels 2x2) with
   time range selector (1H/6H/24H/7D)

Color palette: expanded to 60 distinct colors (was 30)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:49:16 +02:00
parent b77450b6eb
commit 52e1bcd6b8
12 changed files with 710 additions and 226 deletions

View file

@ -6,85 +6,167 @@ interface Props { id: string; charName: string; zIndex: number; }
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [data, setData] = useState<any>(null);
const [tab, setTab] = useState<'attr' | 'skills' | 'titles'>('attr');
const [rightTab, setRightTab] = useState<'augs' | 'ratings' | 'other'>('augs');
useEffect(() => {
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`)
.then(setData).catch(() => {});
.then(d => setData(d)).catch(() => {});
}, [charName]);
const sd = data?.stats_data;
if (!data) {
return (
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={600} height={500}>
<div style={{ padding: 20, color: '#666' }}>Loading character data...</div>
</DraggableWindow>
);
}
const sd = data.stats_data || data;
const attrs = sd.attributes || {};
const skills = sd.skills || {};
const vitals = sd.vitals || {};
const titles = sd.titles || [];
const properties = sd.properties || {};
const specSkills = Object.entries(skills).filter(([, v]: [string, any]) => v?.training === 'Specialized');
const trainedSkills = Object.entries(skills).filter(([, v]: [string, any]) => v?.training === 'Trained');
return (
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={550} height={500}>
<div style={{ padding: 10, fontSize: '0.78rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
{!sd ? (
<div style={{ color: '#666' }}>Loading character data...</div>
) : (
<>
{/* Level + XP header */}
<div style={{ display: 'flex', gap: 16, marginBottom: 10, flexWrap: 'wrap' }}>
{data?.level && <span><strong>Level:</strong> {data.level}</span>}
{data?.total_xp != null && <span><strong>Total XP:</strong> {Number(data.total_xp).toLocaleString()}</span>}
{data?.unassigned_xp != null && <span><strong>Unassigned:</strong> {Number(data.unassigned_xp).toLocaleString()}</span>}
{data?.luminance_earned != null && <span><strong>Luminance:</strong> {Number(data.luminance_earned).toLocaleString()}</span>}
{data?.deaths != null && <span><strong>Deaths:</strong> {data.deaths}</span>}
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={620} height={520}>
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.75rem', color: '#ccc' }}>
{/* Header: Level + Race + XP */}
<div style={{ padding: '8px 10px', borderBottom: '1px solid #333', display: 'flex', flexWrap: 'wrap', gap: '12px', fontSize: '0.78rem' }}>
{data.level && <span><strong>Lv {data.level}</strong></span>}
{data.race && <span>{data.race}</span>}
{data.gender && <span>{data.gender}</span>}
{data.total_xp != null && <span>XP: {Number(data.total_xp).toLocaleString()}</span>}
{data.unassigned_xp != null && <span>Unasgn: {Number(data.unassigned_xp).toLocaleString()}</span>}
{data.luminance_earned != null && <span>Lum: {Number(data.luminance_earned).toLocaleString()}</span>}
{data.deaths != null && <span>Deaths: {data.deaths}</span>}
{sd.skill_credits != null && <span>Skill Credits: {sd.skill_credits}</span>}
</div>
<div style={{ display: 'flex', minHeight: 350 }}>
{/* Left panel */}
<div style={{ flex: 1, borderRight: '1px solid #333' }}>
<div style={{ display: 'flex', gap: 2, padding: '4px 6px', borderBottom: '1px solid #333' }}>
{(['attr', 'skills', 'titles'] as const).map(t => (
<button key={t} className={`ml-stats-range-btn ${tab === t ? 'active' : ''}`}
onClick={() => setTab(t)} style={{ flex: 1 }}>
{t === 'attr' ? 'Attributes' : t === 'skills' ? 'Skills' : 'Titles'}
</button>
))}
</div>
{/* Attributes */}
{sd.attributes && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Attributes</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
{Object.entries(sd.attributes).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#888' }}>{k}</span>
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
</div>
))}
</div>
</div>
)}
{/* Skills */}
{sd.skills && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Skills</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px 12px', fontSize: '0.72rem' }}>
{Object.entries(sd.skills)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#888' }}>{k}</span>
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
<div style={{ padding: 8 }}>
{tab === 'attr' && (
<>
{/* Vital bars */}
{Object.entries(vitals).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
<span style={{ width: 55, color: '#888', fontSize: '0.7rem' }}>{k}</span>
<div style={{ flex: 1, height: 6, background: '#222', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: '100%', height: '100%', background: k === 'health' ? '#c44' : k === 'stamina' ? '#ca0' : '#48f', borderRadius: 3 }} />
</div>
))}
</div>
</div>
)}
{/* Vitals */}
{sd.vitals && (
<div>
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Vitals</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
{Object.entries(sd.vitals).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#888' }}>{k}</span>
<span>{typeof v === 'object' ? (v.current ?? JSON.stringify(v)) : v}</span>
<span style={{ fontSize: '0.68rem', color: '#aaa', width: 40, textAlign: 'right' }}>{v?.base ?? v}</span>
</div>
))}
</div>
</div>
)}
{/* Attributes table */}
<div style={{ marginTop: 8 }}>
<div style={{ fontWeight: 600, color: '#6aadff', marginBottom: 4, fontSize: '0.72rem' }}>Attributes</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px 16px' }}>
{Object.entries(attrs).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
<span style={{ color: '#888', textTransform: 'capitalize' }}>{k}</span>
<span>{v?.base ?? v} {v?.creation != null ? `(${v.creation})` : ''}</span>
</div>
))}
</div>
</div>
</>
)}
{/* Raw data fallback if no structured sections */}
{!sd.attributes && !sd.skills && !sd.vitals && (
<pre style={{ fontSize: '0.68rem', color: '#888', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(sd, null, 2)}
</pre>
)}
</>
)}
{tab === 'skills' && (
<>
{specSkills.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontWeight: 600, color: '#6aadff', marginBottom: 3, fontSize: '0.7rem' }}>Specialized</div>
{specSkills.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
<span style={{ color: '#ccc' }}>{k}</span>
<span style={{ color: '#8f8' }}>{v?.base ?? v}</span>
</div>
))}
</div>
)}
{trainedSkills.length > 0 && (
<div>
<div style={{ fontWeight: 600, color: '#888', marginBottom: 3, fontSize: '0.7rem' }}>Trained</div>
{trainedSkills.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]: [string, any]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
<span style={{ color: '#999' }}>{k}</span>
<span>{v?.base ?? v}</span>
</div>
))}
</div>
)}
{specSkills.length === 0 && trainedSkills.length === 0 && (
<div style={{ color: '#555' }}>No skill data available</div>
)}
</>
)}
{tab === 'titles' && (
titles.length > 0 ? (
<ul style={{ paddingLeft: 16, margin: 0 }}>
{titles.map((t: string, i: number) => <li key={i} style={{ padding: '1px 0' }}>{t}</li>)}
</ul>
) : <div style={{ color: '#555' }}>No titles</div>
)}
</div>
</div>
{/* Right panel */}
<div style={{ width: 220 }}>
<div style={{ display: 'flex', gap: 2, padding: '4px 4px', borderBottom: '1px solid #333' }}>
{(['augs', 'ratings', 'other'] as const).map(t => (
<button key={t} className={`ml-stats-range-btn ${rightTab === t ? 'active' : ''}`}
onClick={() => setRightTab(t)} style={{ flex: 1, fontSize: '0.6rem' }}>
{t === 'augs' ? 'Augs' : t === 'ratings' ? 'Ratings' : 'Other'}
</button>
))}
</div>
<div style={{ padding: 6, fontSize: '0.7rem' }}>
{rightTab === 'augs' && (
Object.keys(properties).length > 0 ? (
Object.entries(properties).slice(0, 20).map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
<span style={{ color: '#888' }}>{k}</span>
<span>{String(v)}</span>
</div>
))
) : <div style={{ color: '#555' }}>No augmentation data</div>
)}
{rightTab === 'ratings' && (
<div style={{ color: '#555' }}>Rating data will appear here from character_stats events</div>
)}
{rightTab === 'other' && (
<>
{data.allegiance && (
<div>
<div style={{ fontWeight: 600, color: '#6aadff', marginBottom: 3 }}>Allegiance</div>
{data.allegiance.name && <div>Name: {data.allegiance.name}</div>}
{data.allegiance.monarch?.name && <div>Monarch: {data.allegiance.monarch.name}</div>}
{data.allegiance.patron?.name && <div>Patron: {data.allegiance.patron.name}</div>}
{data.allegiance.rank != null && <div>Rank: {data.allegiance.rank}</div>}
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
</DraggableWindow>
);