diff --git a/frontend/src/components/map/Sidebar.tsx b/frontend/src/components/map/Sidebar.tsx index e43a6d10..1db83daf 100644 --- a/frontend/src/components/map/Sidebar.tsx +++ b/frontend/src/components/map/Sidebar.tsx @@ -1,6 +1,7 @@ import React, { useState, useMemo } from 'react'; import { PlayerList } from '../sidebar/PlayerList'; import { SortButtons, type SortKey } from '../sidebar/SortButtons'; +import { SidebarWindowButtons } from '../sidebar/SidebarWindowButtons'; import type { TelemetrySnapshot, VitalsMessage, ServerHealth } from '../../types'; interface Props { @@ -74,10 +75,12 @@ export const Sidebar: React.FC = ({ {/* Tool links */}
- ๐Ÿ” Inventory Search + ๐Ÿ” Inv Search ๐Ÿ›ก๏ธ Suitbuilder - ๐Ÿ› Player Debug + ๐Ÿ› Debug + ๐Ÿ“œ Quests
+ {/* Map toggles */}
diff --git a/frontend/src/components/sidebar/PlayerRow.tsx b/frontend/src/components/sidebar/PlayerRow.tsx index b92705f9..d4f73178 100644 --- a/frontend/src/components/sidebar/PlayerRow.tsx +++ b/frontend/src/components/sidebar/PlayerRow.tsx @@ -52,6 +52,7 @@ export const PlayerRow: React.FC = React.memo(({ player: p, vitals: v, co +
diff --git a/frontend/src/components/sidebar/SidebarWindowButtons.tsx b/frontend/src/components/sidebar/SidebarWindowButtons.tsx new file mode 100644 index 00000000..691ffc45 --- /dev/null +++ b/frontend/src/components/sidebar/SidebarWindowButtons.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useWindowManager } from '../../contexts/WindowManagerContext'; + +export const SidebarWindowButtons: React.FC = () => { + const { openWindow } = useWindowManager(); + + return ( +
+ openWindow('issues', 'Issues Board')}>๐Ÿ“‹ Issues + openWindow('vitalsharing', 'Vital Sharing')}>๐Ÿค Vitals +
+ ); +}; diff --git a/frontend/src/components/windows/CharacterWindow.tsx b/frontend/src/components/windows/CharacterWindow.tsx index f536a9cc..5583af8c 100644 --- a/frontend/src/components/windows/CharacterWindow.tsx +++ b/frontend/src/components/windows/CharacterWindow.tsx @@ -6,85 +6,167 @@ interface Props { id: string; charName: string; zIndex: number; } export const CharacterWindow: React.FC = ({ id, charName, zIndex }) => { const [data, setData] = useState(null); + const [tab, setTab] = useState<'attr' | 'skills' | 'titles'>('attr'); + const [rightTab, setRightTab] = useState<'augs' | 'ratings' | 'other'>('augs'); useEffect(() => { apiFetch(`/character-stats/${encodeURIComponent(charName)}`) - .then(setData).catch(() => {}); + .then(d => setData(d)).catch(() => {}); }, [charName]); - const sd = data?.stats_data; + if (!data) { + return ( + +
Loading character data...
+
+ ); + } + + 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 ( - -
- {!sd ? ( -
Loading character data...
- ) : ( - <> - {/* Level + XP header */} -
- {data?.level && Level: {data.level}} - {data?.total_xp != null && Total XP: {Number(data.total_xp).toLocaleString()}} - {data?.unassigned_xp != null && Unassigned: {Number(data.unassigned_xp).toLocaleString()}} - {data?.luminance_earned != null && Luminance: {Number(data.luminance_earned).toLocaleString()}} - {data?.deaths != null && Deaths: {data.deaths}} + +
+ {/* Header: Level + Race + XP */} +
+ {data.level && Lv {data.level}} + {data.race && {data.race}} + {data.gender && {data.gender}} + {data.total_xp != null && XP: {Number(data.total_xp).toLocaleString()}} + {data.unassigned_xp != null && Unasgn: {Number(data.unassigned_xp).toLocaleString()}} + {data.luminance_earned != null && Lum: {Number(data.luminance_earned).toLocaleString()}} + {data.deaths != null && Deaths: {data.deaths}} + {sd.skill_credits != null && Skill Credits: {sd.skill_credits}} +
+ +
+ {/* Left panel */} +
+
+ {(['attr', 'skills', 'titles'] as const).map(t => ( + + ))}
- {/* Attributes */} - {sd.attributes && ( -
-
Attributes
-
- {Object.entries(sd.attributes).map(([k, v]: [string, any]) => ( -
- {k} - {typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v} -
- ))} -
-
- )} - - {/* Skills */} - {sd.skills && ( -
-
Skills
-
- {Object.entries(sd.skills) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([k, v]: [string, any]) => ( -
- {k} - {typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v} +
+ {tab === 'attr' && ( + <> + {/* Vital bars */} + {Object.entries(vitals).map(([k, v]: [string, any]) => ( +
+ {k} +
+
- ))} -
-
- )} - - {/* Vitals */} - {sd.vitals && ( -
-
Vitals
-
- {Object.entries(sd.vitals).map(([k, v]: [string, any]) => ( -
- {k} - {typeof v === 'object' ? (v.current ?? JSON.stringify(v)) : v} + {v?.base ?? v}
))} -
-
- )} + {/* Attributes table */} +
+
Attributes
+
+ {Object.entries(attrs).map(([k, v]: [string, any]) => ( +
+ {k} + {v?.base ?? v} {v?.creation != null ? `(${v.creation})` : ''} +
+ ))} +
+
+ + )} - {/* Raw data fallback if no structured sections */} - {!sd.attributes && !sd.skills && !sd.vitals && ( -
-                {JSON.stringify(sd, null, 2)}
-              
- )} - - )} + {tab === 'skills' && ( + <> + {specSkills.length > 0 && ( +
+
Specialized
+ {specSkills.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]: [string, any]) => ( +
+ {k} + {v?.base ?? v} +
+ ))} +
+ )} + {trainedSkills.length > 0 && ( +
+
Trained
+ {trainedSkills.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]: [string, any]) => ( +
+ {k} + {v?.base ?? v} +
+ ))} +
+ )} + {specSkills.length === 0 && trainedSkills.length === 0 && ( +
No skill data available
+ )} + + )} + + {tab === 'titles' && ( + titles.length > 0 ? ( +
    + {titles.map((t: string, i: number) =>
  • {t}
  • )} +
+ ) :
No titles
+ )} +
+
+ + {/* Right panel */} +
+
+ {(['augs', 'ratings', 'other'] as const).map(t => ( + + ))} +
+
+ {rightTab === 'augs' && ( + Object.keys(properties).length > 0 ? ( + Object.entries(properties).slice(0, 20).map(([k, v]) => ( +
+ {k} + {String(v)} +
+ )) + ) :
No augmentation data
+ )} + {rightTab === 'ratings' && ( +
Rating data will appear here from character_stats events
+ )} + {rightTab === 'other' && ( + <> + {data.allegiance && ( +
+
Allegiance
+ {data.allegiance.name &&
Name: {data.allegiance.name}
} + {data.allegiance.monarch?.name &&
Monarch: {data.allegiance.monarch.name}
} + {data.allegiance.patron?.name &&
Patron: {data.allegiance.patron.name}
} + {data.allegiance.rank != null &&
Rank: {data.allegiance.rank}
} +
+ )} + + )} +
+
+
); diff --git a/frontend/src/components/windows/CombatStatsWindow.tsx b/frontend/src/components/windows/CombatStatsWindow.tsx new file mode 100644 index 00000000..1dc29fca --- /dev/null +++ b/frontend/src/components/windows/CombatStatsWindow.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { DraggableWindow } from './DraggableWindow'; +import { apiFetch } from '../../api/client'; + +interface Props { id: string; charName: string; zIndex: number; } + +const ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric']; + +function getDmg(side: any, atkType: string, el: string): number { + return (side?.[atkType]?.[el]?.total_normal_damage ?? 0) + (side?.[atkType]?.[el]?.total_crit_damage ?? 0); +} + +function flatten(side: any) { + let r = { attacks: 0, failed: 0, crits: 0, normalDmg: 0, maxNormal: 0, critDmg: 0, maxCrit: 0 }; + if (!side) return r; + for (const byEl of Object.values(side) as any[]) { + for (const s of Object.values(byEl) as any[]) { + r.attacks += s.total_attacks ?? 0; + r.failed += s.failed_attacks ?? 0; + r.crits += s.crits ?? 0; + r.normalDmg += s.total_normal_damage ?? 0; + r.maxNormal = Math.max(r.maxNormal, s.max_normal_damage ?? 0); + r.critDmg += s.total_crit_damage ?? 0; + r.maxCrit = Math.max(r.maxCrit, s.max_crit_damage ?? 0); + } + } + return r; +} + +function flattenType(side: any, type: string) { + let r = { attacks: 0, failed: 0 }; + const byEl = side?.[type]; + if (!byEl) return r; + for (const s of Object.values(byEl) as any[]) { r.attacks += s.total_attacks ?? 0; r.failed += s.failed_attacks ?? 0; } + return r; +} + +export const CombatStatsWindow: React.FC = ({ id, charName, zIndex }) => { + const [data, setData] = useState(null); + const [mode, setMode] = useState<'session' | 'lifetime'>('session'); + const [selected, setSelected] = useState(null); + + useEffect(() => { + apiFetch(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {}); + const iv = setInterval(() => { + apiFetch(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {}); + }, 10000); + return () => clearInterval(iv); + }, [charName]); + + const state = data?.[mode]; + const monsters = state?.monsters ?? {}; + const names = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort(); + + // Aggregate for selected or all + const agg = useMemo(() => { + let offense: any = {}, defense: any = {}, aeth = 0, cloak = 0; + const list = selected ? [monsters[selected]].filter(Boolean) : names.map(n => monsters[n]); + for (const m of list) { + if (!m) continue; + for (const [at, byEl] of Object.entries(m.offense ?? {})) { + if (!offense[at]) offense[at] = {}; + for (const [el, s] of Object.entries(byEl as any)) { + if (!offense[at][el]) offense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 }; + const t = offense[at][el]; const src = s as any; + t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0; t.crits += src.crits ?? 0; + t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0); + t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0); + } + } + for (const [at, byEl] of Object.entries(m.defense ?? {})) { + if (!defense[at]) defense[at] = {}; + for (const [el, s] of Object.entries(byEl as any)) { + if (!defense[at][el]) defense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 }; + const t = defense[at][el]; const src = s as any; + t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0; + t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0); + t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0); + } + } + aeth += m.aetheria_surges ?? 0; + cloak += m.cloak_surges ?? 0; + } + if (monsters['__cloak_surges__'] && !selected) cloak += monsters['__cloak_surges__'].cloak_surges ?? 0; + return { offense, defense, aeth, cloak }; + }, [monsters, names, selected]); + + const off = flatten(agg.offense); + const defMM = flattenType(agg.defense, 'MeleeMissile'); + const defMag = flattenType(agg.defense, 'Magic'); + const hitRate = off.attacks > 0 ? ((off.attacks - off.failed) / off.attacks * 100).toFixed(0) : '0'; + const evadeRate = defMM.attacks > 0 ? (defMM.failed / defMM.attacks * 100).toFixed(0) : '0'; + const resistRate = defMag.attacks > 0 ? (defMag.failed / defMag.attacks * 100).toFixed(0) : '0'; + const hits = off.attacks - off.failed; + const normalHits = hits - off.crits; + const avgN = normalHits > 0 ? Math.round(off.normalDmg / normalHits) : 0; + const avgC = off.crits > 0 ? Math.round(off.critDmg / off.crits) : 0; + const critPct = hits > 0 ? (off.crits / hits * 100).toFixed(1) : '0'; + const fmtN = (n: number) => n === 0 ? '' : n.toLocaleString(); + + return ( + + {/* Toggle */} +
+ + +
+
+ {/* Monster list (left) */} +
+
+ Monster + KillsDmg +
+ {/* All row */} +
setSelected(null)}> + {selected === null ? '*' : ''} + All + {fmtN(state?.total_kills ?? 0)} + {fmtN(state?.total_damage_given ?? 0)} +
+ {names.map(n => { + const m = monsters[n]; + return ( +
setSelected(n)}> + {selected === n ? '*' : ''} + {n} + {fmtN(m.kill_count)} + {fmtN(m.damage_given)} +
+ ); + })} +
+ {/* Breakdown grid (right) */} +
+ + + + + + + + + + + + + + + + {ELEMENTS.map((el, i) => { + const stats = [ + ['Evades', defMM.attacks > 0 ? `${fmtN(defMM.attacks)} (${evadeRate}%)` : ''], + ['Resists', defMag.attacks > 0 ? `${fmtN(defMag.attacks)} (${resistRate}%)` : ''], + ['A.Surges', agg.aeth > 0 ? `${fmtN(agg.aeth)}` : ''], + ['C.Surges', agg.cloak > 0 ? `${fmtN(agg.cloak)}` : ''], + ['', ''], ['', ''], + ['Av/Mx', avgN > 0 ? `${fmtN(avgN)} / ${fmtN(off.maxNormal)}` : ''], + ['Crits', off.crits > 0 ? `${fmtN(off.crits)} (${critPct}%)` : ''], + ][i] ?? ['', '']; + return ( + + + + + + + + + + + + ); + })} + + + + + + + + + + + + + + + +
Given M/MGiven MagRecv M/MRecv MagStats
{el}{fmtN(getDmg(agg.offense, 'MeleeMissile', el))}{fmtN(getDmg(agg.offense, 'Magic', el))}{fmtN(getDmg(agg.defense, 'MeleeMissile', el))}{fmtN(getDmg(agg.defense, 'Magic', el))}{stats[0]}{stats[1]}
Total{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'MeleeMissile', e), 0))}{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'Magic', e), 0))}{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'MeleeMissile', e), 0))}{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'Magic', e), 0))}Total{fmtN(off.normalDmg + off.critDmg)}
+
+
+
+ ); +}; diff --git a/frontend/src/components/windows/IssuesWindow.tsx b/frontend/src/components/windows/IssuesWindow.tsx new file mode 100644 index 00000000..35d56c21 --- /dev/null +++ b/frontend/src/components/windows/IssuesWindow.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { DraggableWindow } from './DraggableWindow'; +import { apiFetch } from '../../api/client'; + +interface Issue { + id: number; title: string; description: string; category: string; + created: string; resolved: boolean; author: string; + comments?: Array<{ id: number; text: string; author: string; created: string }>; +} + +interface Props { id: string; zIndex: number; } + +const CAT_COLORS: Record = { + plugin: '#4488ff', overlord: '#44cc44', nav: '#ffaa00', macro: '#cc44cc', other: '#888', +}; + +export const IssuesWindow: React.FC = ({ id, zIndex }) => { + const [issues, setIssues] = useState([]); + const [title, setTitle] = useState(''); + const [desc, setDesc] = useState(''); + const [category, setCategory] = useState('plugin'); + + const refresh = useCallback(async () => { + try { + const data = await apiFetch<{ issues: Issue[] }>('/issues'); + setIssues((data.issues ?? []).sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0))); + } catch { /* ignore */ } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const addIssue = async () => { + if (!title.trim()) return; + await fetch('/api/issues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: JSON.stringify({ title: title.trim(), description: desc.trim(), category }) }); + setTitle(''); setDesc(''); + refresh(); + }; + + const toggleResolve = async (issue: Issue) => { + await fetch(`/api/issues/${issue.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: JSON.stringify({ resolved: !issue.resolved }) }); + refresh(); + }; + + return ( + + {/* Issue list */} +
+ {issues.length === 0 ? ( +
No issues
+ ) : issues.map(issue => ( +
+
+ + {issue.category} + + {issue.title} + +
+ {issue.description &&
{issue.description}
} +
+ by {issue.author} · {new Date(issue.created).toLocaleDateString()} +
+
+ ))} +
+ {/* Add issue form */} +
+
+ setTitle(e.target.value)} placeholder="Issue title..." + style={{ flex: 1, padding: '3px 6px', fontSize: '0.75rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3 }} /> + +
+
+