Initial commit: FFLogs mitigation analyzer

Two-tab app: report viewer + analysis tab with AoE timeline,
per-player mitigation icons (local XIVAPI PNGs), and fight-wide
buff/debuff window tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-20 10:42:38 +02:00
parent cf188c1727
commit d792d5b718
37 changed files with 1477 additions and 227 deletions

148
CLAUDE.md
View File

@ -1,23 +1,145 @@
# ff14-auth — FFLogs Report Viewer # ff14-mitigator — FFLogs Mitigation Analyzer
## Projekt ## Projekt
Einfache PHP/HTML/JS-Seite zum Verbinden mit FFLogs via OAuth2 PKCE und Abrufen von Report-Daten über die GraphQL-API. Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting. PHP/HTML/JS-Tool zum Analysieren von FFXIV-Raidlogs via FFLogs OAuth2 PKCE + GraphQL API.
Kein Framework, kein Composer, kein npm — Plain PHP für Shared Hosting.
Zwei Tabs:
- **Report-Tab**: Report-Code eingeben, Fight auswählen, Raw-JSON-Ausgabe
- **Analyse-Tab**: Spielerübersicht + AoE-Timeline mit Mitigation-Tracking
## Architektur & Konventionen
### Trennung von PHP, HTML und JS
- **PHP-Logik** gehört ausschließlich in `index.php` (und API/Auth-Endpunkte). Keine Geschäftslogik in Templates.
- **HTML** gehört in `templates/`. Jede logisch in sich geschlossene Komponente ist eine eigene Datei.
- **CSS** gehört in `css/`. Jede CSS-Datei hat einen klar abgegrenzten Scope (base, layout, components, analysis).
- **JavaScript** gehört in `js/`. Keine Inline-Scripts in Templates außer dem `<script src="...">` Tag in `page.php`.
### Template-System
`index.php` setzt alle Variablen und ruft dann `require templates/page.php` auf. Templates sind reine Ausgabe — sie lesen Variablen aus dem Scope, setzen aber keine.
### Geteilter JS-State
`window.App` in `app.js` hält den gemeinsamen State für alle Tabs:
```js
window.App = { reportCode, fightId, fightStart, fightEnd }
```
`window.analysisTab` (definiert in `analysis.js`) stellt Hooks bereit:
- `onFightSelected()` — wird von `app.js` aufgerufen wenn ein Fight gewählt wird
- `onTabOpen()` — wird von `tabs.js` aufgerufen wenn der Analyse-Tab geöffnet wird
- `reset()` — wird von `app.js` aufgerufen wenn ein neuer Report geladen wird
## Dateistruktur ## Dateistruktur
``` ```
index.php — PHP-Logik: Auth-Check, Variablen, require page.php
config.php — Konstanten (CLIENT_ID, URIs) + session_start_safe() config.php — Konstanten (CLIENT_ID, URIs) + session_start_safe()
index.php — Haupt-UI: Connect-Button / Report-Formular / Terminal-Output templates/
page.php — HTML-Skeleton (head, body), routet zu login oder app
login.php — Login-Overlay (nicht authentifiziert / Token abgelaufen)
topbar.php — Topbar mit Logo + Tab-Navigation + Token-Ablaufzeit
tab-report.php — Report-Tab: includes report-form, fight-select, output-card
tab-analysis.php — Analyse-Tab: Spieler-Grid + AoE-Timeline HTML
report-form.php — Report-Code-Eingabe Card
fight-select.php — Fight-Auswahl Dropdown Card
output-card.php — Terminal-Ausgabe Card + Initial-Hint
css/
base.css — CSS-Variablen, Reset, Basis-Styles, Feedback-Klassen
layout.css — App-Shell, Topbar, Tabs, Login-Overlay, Form-Helpers
components.css — Cards, Inputs, Buttons, Badges, Terminal
analysis.css — Spieler-Grid, AoE-Timeline, Mitigation-Icons
js/
app.js — Formular, Fight-Dropdown, Fetch, window.App State
tabs.js — Tab-Switching, ruft window.analysisTab.onTabOpen() auf
analysis.js — Analyse-Tab: Daten laden, Spieler rendern, Timeline rendern
auth/ auth/
start.php — PKCE generieren, Session speichern, Redirect zu FFLogs start.php — PKCE generieren, Session speichern, Redirect zu FFLogs
callback.php — Code gegen Token tauschen, Token in Session speichern callback.php — Code gegen Token tauschen, Token in Session speichern
api/ api/
fight.php — POST-Endpunkt: GraphQL-Query → JSON fight.php — POST-Endpunkt: Fight-Liste via GraphQL → JSON
js/ analysis.php — POST-Endpunkt: Spieler + AoE-Events + Mitigations → JSON
app.js — Formular, Dropdown, Fetch, Ausgabe assets/
icons/mitigation/ — Lokal gespeicherte Ability-Icons (PNG, von XIVAPI)
debug/ debug/
schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen) schema.php — Einmaliges Schema-Explorer Tool (nicht produktiv deployen)
``` ```
## Design-System
CSS-Variablen in `css/base.css`:
- Hintergründe: `--bg0` (#08090d) bis `--bg3` (#1c2130), `--bgcard` (#121620)
- Akzentfarbe: `--gold` (#c8a84b)
- Text: `--t1` (hell) / `--t2` (gedimmt) / `--t3` (sehr gedimmt)
- Farben: `--blue`, `--green`, `--red`, `--orange`
- Fonts: `--font-d` Cinzel (Titel/Logo), `--font-b` Inter (Body)
- Border-Radius: `--r` (klein), `--rl` (groß)
## Analyse-Tab — Konzepte & Entscheidungen
### AoE-Erkennung
- Nur `calculateddamage` Events (post-Mitigation Snapshot) — **nicht** `damage` (Application), da sonst doppelte Events
- Gruppierung: 300ms-Zeitfenster × `abilityGameID` → Bucket
- AoE = Bucket mit ≥ 3 unterschiedlichen `targetID`s
- Auto-Attacks und Fähigkeiten mit `abilityGameID ≤ 7` werden gefiltert
- Tick-Schaden (`ev['tick'] = true`) wird ignoriert
### Spielernamen statt IDs
- `masterData.abilities` (gameID → Name) wird zusammen mit `playerDetails` in einem einzigen Query abgerufen
- `$mitigIdMap` (gameID → Mitigation-Meta) wird nur für Abilities gebaut, die tatsächlich im Report vorkommen
### Mitigation-Tracking
Getrackte party-wide Buffs + Boss-Debuffs (definiert in `MITIGATION_ABILITIES` in `api/analysis.php`):
| Ability | DR | Typ |
|---|---|---|
| Passage of Arms | 15% | buff |
| Divine Veil | Barrier | buff |
| Shake It Off | Barrier | buff |
| Dark Missionary | 10% | buff |
| Heart of Light | 10% | buff |
| Temperance | 10% | buff |
| Sacred Soil | 10% | buff |
| Expedient | 10% | buff |
| Fey Illumination | 5% | buff |
| Collective Unconscious | 10% | buff |
| Holos | 10% | buff |
| Kerachole | 10% | buff |
| Panhaima | Barrier | buff |
| Troubadour | 15% | buff |
| Tactician | 15% | buff |
| Shield Samba | 15% | buff |
| Reprisal | 10% | debuff |
| Feint | 10% | debuff |
| Addle | 10% | debuff |
**Fenster-Tracking:** `applybuff`/`applydebuff` öffnet Fenster (nur erstes pro `abilityId_sourceId`-Key, da party-wide Buffs einmal pro Partymitglied feuern). `removebuff`/`removedebuff` schließt das Fenster. Noch offene Fenster am Fight-Ende werden mit `endTime` geschlossen.
### Mitigation-Icons
- Icons lokal gespeichert in `assets/icons/mitigation/` als PNG
- Quelle: XIVAPI v2 (`https://v2.xivapi.com/api/asset?path=...&format=png`)
- Icon-Pfade per Action-Row-ID abgerufen: `https://v2.xivapi.com/api/sheet/Action/{id}?fields=Name,Icon`
- Dateinamen: kebab-case des Ability-Namens (z.B. `passage-of-arms.png`)
- Mapping in `analysis.js`: `MITIG_ICONS` Objekt (Ability-Name → lokaler Pfad)
- Darstellung: 16×16px Icon unter jedem Spieler-Target, kein Text, `title`-Tooltip mit Name + DR% + Caster
## Geplante Features
### HP-Balken pro Spieler (nächstes Feature)
Für jeden Spieler in der AoE-Timeline einen 3-Segment-Balken anzeigen, der den HP-Stand im Kontext des Treffers zeigt:
```
[████████████▓▓▓░░░░░░░░]
^verbleibend ^Schaden ^vorher schon fehlend
```
- **Grün** (links): HP nach dem Treffer (als % von MaxHP)
- **Rot/Orange** (Mitte): erlittener Schaden (als % von MaxHP)
- **Dunkelgrau** (rechts): HP die bereits vor dem Treffer fehlten
- Grünton dynamisch: >50% grün, 2550% gelb/amber, <25% rot
- Balken sitzt direkt unter Name+Schaden-Zeile in der Spieler-Box
**Datenbedarf:** FFLogs `calculateddamage` Events enthalten `hitPoints` (HP vor dem Treffer) und `maxHitPoints`. Diese müssen in `api/analysis.php` pro Target mitgegeben werden.
**Backend-Änderung:** In `$buckets[$key]['targets'][$tgtId]` zusätzlich `hp` und `maxHp` aus dem letzten Event speichern und in der Response durchreichen.
## Konfiguration ## Konfiguration
- `config.php`: `CLIENT_ID`, `REDIRECT_URI`, `DEV_MODE` anpassen - `config.php`: `CLIENT_ID`, `REDIRECT_URI`, `DEV_MODE` anpassen
- `DEV_MODE = true` deaktiviert SSL-Verifizierung (nur lokal, nie in Produktion) - `DEV_MODE = true` deaktiviert SSL-Verifizierung (nur lokal, nie in Produktion)
@ -29,6 +151,19 @@ debug/
- GraphQL Endpoint (user-scoped): `https://www.fflogs.com/api/v2/user` - GraphQL Endpoint (user-scoped): `https://www.fflogs.com/api/v2/user`
- Token Endpoint: `https://www.fflogs.com/oauth/token` - Token Endpoint: `https://www.fflogs.com/oauth/token`
- Kein Refresh Token für öffentliche Clients — abgelaufene Sessions starten PKCE neu - Kein Refresh Token für öffentliche Clients — abgelaufene Sessions starten PKCE neu
- Event-Typen: `calculateddamage` (Snapshot nach Mitigation, den wir nutzen) vs. `damage` (Application, ignorieren)
## XIVAPI
- Basis-URL: `https://v2.xivapi.com`
- Action-Lookup per Row-ID: `/api/sheet/Action/{id}?fields=Name,Icon`
- Asset-Download: `/api/asset?path={tex_path}&format=png`
- Icons nicht hotlinken — lokal speichern (Community-Service, kein SLA)
- XIVAPI-Suche (`/api/search`) gibt bei manchen Abilities ClassJob-Daten statt Action-Daten zurück → direkt per Row-ID abrufen
## Repository
- Remote: `https://git.epow0.org/xziino/ff14-mitigator`
- Platform: Gitea (git.epow0.org)
- Branch: `main`
## Lokale Entwicklung ## Lokale Entwicklung
``` ```
@ -46,3 +181,4 @@ Vollständiges Schema: siehe `debug/schema.php` oder `fflogs-schema.json`
- `DEV_MODE` auf `false` setzen - `DEV_MODE` auf `false` setzen
- `REDIRECT_URI` auf produktive HTTPS-URL anpassen - `REDIRECT_URI` auf produktive HTTPS-URL anpassen
- `debug/` Ordner nicht deployen - `debug/` Ordner nicht deployen
- `assets/` Ordner deployen (enthält lokale Icons)

342
api/analysis.php Normal file
View File

@ -0,0 +1,342 @@
<?php
ini_set('display_errors', '0');
require_once __DIR__ . '/../config.php';
session_start_safe();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
if (empty($_SESSION['access_token'])) {
echo json_encode(['reauth' => true]);
exit;
}
if (($_SESSION['token_expires'] ?? 0) <= time()) {
echo json_encode(['reauth' => true]);
exit;
}
$reportCode = preg_replace('/[^a-zA-Z0-9]/', '', $_POST['report_code'] ?? '');
$fightId = (int)($_POST['fight_id'] ?? 0);
$startTime = (float)($_POST['start_time'] ?? 0);
$endTime = (float)($_POST['end_time'] ?? 0);
if (!$reportCode || !$fightId || !$endTime) {
http_response_code(400);
echo json_encode(['error' => 'Missing required parameters']);
exit;
}
$token = $_SESSION['access_token'];
function fflogs_gql(string $query): array {
global $token;
$ch = curl_init(GRAPHQL_URI);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['query' => $query]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $token,
],
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($err) return ['_curl_error' => $err];
if ($code === 401) return ['_reauth' => true];
return json_decode($body, true) ?? ['_parse_error' => true];
}
// ── Party-wide mitigation + boss debuffs to track ─────────────────────────
// Barriers (dr = 0) are shown without a percentage.
const MITIGATION_ABILITIES = [
'Passage of Arms' => ['dr' => 15, 'buffType' => 'buff'],
'Divine Veil' => ['dr' => 0, 'buffType' => 'buff'],
'Shake It Off' => ['dr' => 0, 'buffType' => 'buff'],
'Dark Missionary' => ['dr' => 10, 'buffType' => 'buff'],
'Heart of Light' => ['dr' => 10, 'buffType' => 'buff'],
'Temperance' => ['dr' => 10, 'buffType' => 'buff'],
'Sacred Soil' => ['dr' => 10, 'buffType' => 'buff'],
'Expedient' => ['dr' => 10, 'buffType' => 'buff'],
'Fey Illumination' => ['dr' => 5, 'buffType' => 'buff'],
'Collective Unconscious' => ['dr' => 10, 'buffType' => 'buff'],
'Holos' => ['dr' => 10, 'buffType' => 'buff'],
'Kerachole' => ['dr' => 10, 'buffType' => 'buff'],
'Panhaima' => ['dr' => 0, 'buffType' => 'buff'],
'Troubadour' => ['dr' => 15, 'buffType' => 'buff'],
'Tactician' => ['dr' => 15, 'buffType' => 'buff'],
'Shield Samba' => ['dr' => 15, 'buffType' => 'buff'],
'Reprisal' => ['dr' => 10, 'buffType' => 'debuff'],
'Feint' => ['dr' => 10, 'buffType' => 'debuff'],
'Addle' => ['dr' => 10, 'buffType' => 'debuff'],
];
// ── 1. Player details + masterData (ability names) ─────────────────────────
$pdResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
playerDetails(fightIDs: [$fightId])
masterData {
abilities {
gameID
name
}
}
}
}
}
GQL);
if (isset($pdResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($pdResult['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $pdResult['_curl_error']]); exit; }
// abilityGameID → display name
$abilityNames = [];
foreach ($pdResult['data']['reportData']['report']['masterData']['abilities'] ?? [] as $ab) {
$abilityNames[(int)$ab['gameID']] = $ab['name'];
}
// gameID → mitigation meta (only for abilities present in this report)
$mitigIdMap = [];
foreach ($abilityNames as $gameId => $name) {
if (isset(MITIGATION_ABILITIES[$name])) {
$mitigIdMap[$gameId] = array_merge(['name' => $name], MITIGATION_ABILITIES[$name]);
}
}
// player actorID → player data
$pdRaw = $pdResult['data']['reportData']['report']['playerDetails'] ?? null;
$pdParsed = is_string($pdRaw) ? json_decode($pdRaw, true) : $pdRaw;
$pdGroups = $pdParsed['data']['playerDetails'] ?? [];
$players = [];
$roleMap = ['tanks' => 'tank', 'healers' => 'healer', 'dps' => 'dps'];
foreach ($roleMap as $group => $role) {
foreach ($pdGroups[$group] ?? [] as $p) {
$players[(int)$p['id']] = [
'id' => (int)$p['id'],
'name' => $p['name'],
'type' => $p['type'] ?? '',
'role' => $role,
];
}
}
// ── 2. Buff / debuff events for mitigation tracking ────────────────────────
$mitigWindows = []; // [{name, dr, buffType, casterName, start, end}]
if (!empty($mitigIdMap)) {
$idsStr = implode(', ', array_keys($mitigIdMap));
$filterRaw = 'type in ("applybuff","removebuff","applydebuff","removedebuff","refreshbuff") and ability.id in (' . $idsStr . ')';
$filterEsc = str_replace('"', '\\"', $filterRaw);
$buffEvents = [];
$nextPage = $startTime;
for ($page = 0; $page < 5; $page++) {
$bResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
events(
fightIDs: [$fightId],
startTime: $nextPage,
endTime: $endTime,
filterExpression: "$filterEsc"
) {
data
nextPageTimestamp
}
}
}
}
GQL);
if (isset($bResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($bResult['_curl_error'])) break;
$bEv = $bResult['data']['reportData']['report']['events'] ?? [];
$buffEvents = array_merge($buffEvents, $bEv['data'] ?? []);
$nextPage = $bEv['nextPageTimestamp'] ?? null;
if ($nextPage === null || $nextPage >= $endTime) break;
}
// Build time windows per (abilityId, sourceId).
// Party-wide buffs fire one applybuff per party member — we only open the
// window on the FIRST applybuff for a given (ability, caster) pair.
$openWindows = []; // "$abId_$srcId" => [start, abilityId, sourceId]
foreach ($buffEvents as $ev) {
$type = $ev['type'] ?? '';
$abId = (int)($ev['abilityGameID'] ?? 0);
$srcId = (int)($ev['sourceID'] ?? 0);
$ts = (float)($ev['timestamp'] ?? 0);
if (!isset($mitigIdMap[$abId])) continue;
$key = $abId . '_' . $srcId;
if (in_array($type, ['applybuff', 'applydebuff'])) {
if (!isset($openWindows[$key])) {
$openWindows[$key] = ['start' => $ts, 'abilityId' => $abId, 'sourceId' => $srcId];
}
} elseif ($type === 'refreshbuff') {
// Buff was refreshed — keep window open (don't reset start)
if (!isset($openWindows[$key])) {
$openWindows[$key] = ['start' => $ts, 'abilityId' => $abId, 'sourceId' => $srcId];
}
} elseif (in_array($type, ['removebuff', 'removedebuff'])) {
if (isset($openWindows[$key])) {
$info = $mitigIdMap[$abId];
$mitigWindows[] = [
'name' => $info['name'],
'dr' => $info['dr'],
'buffType' => $info['buffType'],
'casterName' => $players[$srcId]['name'] ?? '?',
'abilityId' => $abId,
'start' => $openWindows[$key]['start'],
'end' => $ts,
];
unset($openWindows[$key]);
}
}
}
// Buffs still active at fight end
foreach ($openWindows as $win) {
$info = $mitigIdMap[$win['abilityId']];
$mitigWindows[] = [
'name' => $info['name'],
'dr' => $info['dr'],
'buffType' => $info['buffType'],
'casterName' => $players[$win['sourceId']]['name'] ?? '?',
'abilityId' => $win['abilityId'],
'start' => $win['start'],
'end' => $endTime,
];
}
}
// ── 3. Damage-taken events (paginated) ─────────────────────────────────────
$allEvents = [];
$nextPage = $startTime;
for ($page = 0; $page < 10; $page++) {
$evResult = fflogs_gql(<<<GQL
{
reportData {
report(code: "$reportCode") {
events(
fightIDs: [$fightId],
dataType: DamageTaken,
startTime: $nextPage,
endTime: $endTime
) {
data
nextPageTimestamp
}
}
}
}
GQL);
if (isset($evResult['_reauth'])) { echo json_encode(['reauth' => true]); exit; }
if (isset($evResult['_curl_error'])) break;
$ev = $evResult['data']['reportData']['report']['events'] ?? [];
$allEvents = array_merge($allEvents, $ev['data'] ?? []);
$nextPage = $ev['nextPageTimestamp'] ?? null;
if ($nextPage === null || $nextPage >= $endTime) break;
}
// ── 4. AoE detection ───────────────────────────────────────────────────────
$buckets = [];
foreach ($allEvents as $ev) {
if (!empty($ev['tick'])) continue;
if (($ev['type'] ?? '') !== 'calculateddamage') continue;
$abId = (int)($ev['abilityGameID'] ?? 0);
$tgtId = (int)($ev['targetID'] ?? 0);
if (!$abId || !$tgtId || $abId <= 7) continue;
$ts = (float)($ev['timestamp'] ?? 0);
$bucket = (int)floor($ts / 300) * 300;
$key = $bucket . '_' . $abId;
if (!isset($buckets[$key])) {
$buckets[$key] = [
'timestamp' => (int)$ts,
'abilityId' => $abId,
'abilityName' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId),
'targets' => [],
];
}
if (!isset($buckets[$key]['targets'][$tgtId])) {
$buckets[$key]['targets'][$tgtId] = ['id' => $tgtId, 'amount' => 0];
}
$buckets[$key]['targets'][$tgtId]['amount'] += (int)($ev['amount'] ?? 0);
}
$aoeEvents = [];
foreach ($buckets as $group) {
if (count($group['targets']) < 3) continue;
$targets = [];
foreach ($group['targets'] as $tgtId => $tgt) {
$p = $players[$tgtId] ?? null;
$targets[] = [
'id' => $tgtId,
'name' => $p['name'] ?? '?',
'type' => $p['type'] ?? '',
'role' => $p['role'] ?? 'dps',
'amount' => $tgt['amount'],
];
}
usort($targets, fn($a, $b) => $b['amount'] <=> $a['amount']);
// Collect active mitigations at this timestamp
$evTs = $group['timestamp'];
$activeMitig = [];
$seen = [];
foreach ($mitigWindows as $win) {
if ($win['start'] <= $evTs && $win['end'] > $evTs) {
$dedupeKey = $win['abilityId'] . '_' . $win['casterName'];
if (!isset($seen[$dedupeKey])) {
$seen[$dedupeKey] = true;
$activeMitig[] = [
'name' => $win['name'],
'dr' => $win['dr'],
'buffType' => $win['buffType'],
'casterName' => $win['casterName'],
];
}
}
}
$aoeEvents[] = [
'timestamp' => $evTs,
'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'],
'targets' => $targets,
'totalDamage' => array_sum(array_column($targets, 'amount')),
'mitigations' => $activeMitig,
];
}
usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']);
echo json_encode([
'players' => array_values($players),
'aoe_events' => $aoeEvents,
'fight_start' => (int)$startTime,
]);

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

127
css/analysis.css Normal file
View File

@ -0,0 +1,127 @@
/* ── Player Grid ─────────────────────────────────────────────────────────── */
.player-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
}
.player-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
}
.player-job-icon {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
font-family: var(--font-d);
letter-spacing: 0.03em;
flex-shrink: 0;
}
.player-job-icon.role-tank { background: rgba(74,158,255,.15); color: var(--blue); border: 1px solid rgba(74,158,255,.35); }
.player-job-icon.role-healer { background: rgba(76,175,125,.15); color: var(--green); border: 1px solid rgba(76,175,125,.35); }
.player-job-icon.role-dps { background: rgba(224,92,92,.15); color: var(--red); border: 1px solid rgba(224,92,92,.35); }
.player-name {
font-size: 13px;
color: var(--t1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-type {
font-size: 11px;
color: var(--t2);
margin-top: 1px;
}
/* ── AoE Timeline ────────────────────────────────────────────────────────── */
.aoe-event {
display: grid;
grid-template-columns: 52px 1fr;
gap: 14px;
padding: 11px 0;
border-bottom: 1px solid var(--border);
align-items: start;
}
.aoe-event:last-child { border-bottom: none; }
.aoe-time {
font-family: var(--font-d);
font-size: 13px;
color: var(--gold);
padding-top: 2px;
letter-spacing: 0.03em;
}
.aoe-ability {
font-size: 13px;
color: var(--t1);
margin-bottom: 7px;
}
.aoe-total {
font-size: 12px;
color: var(--t2);
margin-left: 6px;
}
.aoe-targets {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.aoe-target-wrap {
display: flex;
flex-direction: column;
gap: 3px;
}
.aoe-target {
display: flex;
align-items: center;
gap: 5px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 2px 8px 2px 6px;
font-size: 11px;
}
.aoe-target-job {
font-family: var(--font-d);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
}
.aoe-target-job.role-tank { color: var(--blue); }
.aoe-target-job.role-healer { color: var(--green); }
.aoe-target-job.role-dps { color: var(--red); }
.aoe-target-name { color: var(--t1); }
.aoe-target-dmg { color: var(--t2); margin-left: 2px; }
.aoe-target-buffs {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 2px 4px;
}
.aoe-target-buff-icon {
width: 16px;
height: 16px;
border-radius: 2px;
object-fit: cover;
cursor: default;
}

148
css/base.css Normal file
View File

@ -0,0 +1,148 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=Inter:wght@300;400;500;600&display=swap');
/* ── CSS Custom Properties ─────────────────────────────────────────────────── */
:root {
/* Backgrounds */
--bg0: #08090d;
--bg1: #0f1117;
--bg2: #161a23;
--bg3: #1c2130;
--bgcard: #121620;
/* Borders */
--border: rgba(255, 255, 255, 0.07);
--borderem: rgba(255, 255, 255, 0.14);
/* Accent Colors */
--gold: #c8a84b;
--golddim: #7d6929;
--goldbg: rgba(200, 168, 75, 0.08);
--blue: #4a9eff;
--bluedim: rgba(74, 158, 255, 0.12);
--teal: #2ec4b6;
--red: #e05c5c;
--redbg: rgba(224, 92, 92, 0.10);
--green: #4caf7d;
--greenbg: rgba(76, 175, 125, 0.10);
--orange: #f5a623;
--orangebg: rgba(245, 166, 35, 0.10);
--purple: #9b72cf;
/* Text */
--t1: #eae6df;
--t2: #8a92a6;
--t3: #454d62;
/* Misc */
--r: 6px;
--rl: 10px;
/* Fonts */
--font-d: 'Cinzel', serif;
--font-b: 'Inter', sans-serif;
}
/* ── Reset ──────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base ───────────────────────────────────────────────────────────────────── */
body {
background: var(--bg0);
color: var(--t1);
font-family: var(--font-b);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
a { color: var(--blue); }
code {
background: var(--bg2);
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 14px 0;
}
ol li {
margin-left: 20px;
margin-bottom: 6px;
color: var(--t2);
line-height: 1.8;
}
/* ── Utility ────────────────────────────────────────────────────────────────── */
.section-gap { margin-bottom: 16px; }
/* ── Feedback ───────────────────────────────────────────────────────────────── */
.error {
background: var(--redbg);
border: 1px solid rgba(224, 92, 92, 0.3);
border-radius: var(--r);
padding: 10px 14px;
color: var(--red);
font-size: 13px;
margin-bottom: 14px;
}
.success {
background: var(--greenbg);
border: 1px solid rgba(76, 175, 125, 0.3);
border-radius: var(--r);
padding: 10px 14px;
color: var(--green);
font-size: 13px;
margin-bottom: 14px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px;
color: var(--t2);
}
.spinner {
width: 26px;
height: 26px;
border: 2px solid var(--borderem);
border-top-color: var(--gold);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty {
text-align: center;
padding: 50px 20px;
color: var(--t2);
}
.empty-icon {
font-size: 36px;
opacity: 0.25;
margin-bottom: 10px;
}
.empty h3 {
font-size: 14px;
color: var(--t1);
margin-bottom: 4px;
}

189
css/components.css Normal file
View File

@ -0,0 +1,189 @@
/* ── Card ───────────────────────────────────────────────────────────────────── */
.card {
background: var(--bgcard);
border: 1px solid var(--border);
border-radius: var(--rl);
padding: 18px;
}
.card + .card { margin-top: 16px; }
.card-title {
font-family: var(--font-d);
font-size: 12px;
color: var(--gold);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title::before {
content: '';
display: block;
width: 3px;
height: 13px;
background: var(--gold);
border-radius: 2px;
flex-shrink: 0;
}
/* ── Inputs ─────────────────────────────────────────────────────────────────── */
input, select, textarea {
background: var(--bg2);
border: 1px solid var(--borderem);
border-radius: var(--r);
color: var(--t1);
font-family: var(--font-b);
font-size: 13px;
padding: 7px 11px;
outline: none;
transition: border-color 0.15s;
width: 100%;
}
input:focus, select:focus, textarea:focus { border-color: var(--blue); }
input::placeholder { color: var(--t3); }
select option { background: var(--bg2); }
/* ── Buttons ────────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border-radius: var(--r);
border: 1px solid var(--borderem);
background: var(--bg2);
color: var(--t1);
font-size: 13px;
font-family: var(--font-b);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover { background: var(--bg3); }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-gold {
background: var(--golddim);
border-color: var(--gold);
color: var(--gold);
}
.btn-gold:hover { background: #4a3510; }
.btn-red {
border-color: var(--red);
color: var(--red);
}
.btn-red:hover { background: var(--redbg); }
.btn-sm { padding: 4px 11px; font-size: 12px; }
/* ── Stats row ──────────────────────────────────────────────────────────────── */
.stats-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.stat {
flex: 1;
min-width: 130px;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 12px 14px;
}
.stat-label {
font-size: 11px;
color: var(--t2);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 5px;
}
.stat-value { font-size: 20px; font-weight: 600; }
.stat-sub { font-size: 11px; color: var(--t3); margin-top: 2px; }
/* ── Badges ─────────────────────────────────────────────────────────────────── */
.badge {
padding: 1px 7px;
border-radius: 2px;
font-size: 11px;
font-weight: 500;
border: 1px solid transparent;
}
.badge-tank { background: rgba(74,158,255,.14); border-color: rgba(74,158,255,.4); color: var(--blue); }
.badge-heal { background: rgba(76,175,125,.14); border-color: rgba(76,175,125,.4); color: var(--green); }
.badge-dps { background: rgba(224,92,92,.14); border-color: rgba(224,92,92,.4); color: var(--red); }
.badge-gold { background: var(--goldbg); border-color: rgba(200,168,75,.4); color: var(--gold); }
.badge-planned { opacity: 0.55; border-style: dashed; }
/* ── DR Bar ─────────────────────────────────────────────────────────────────── */
.dr-bar-wrap {
background: var(--bg2);
border-radius: 4px;
height: 7px;
overflow: hidden;
margin-top: 5px;
}
.dr-bar {
height: 100%;
border-radius: 4px;
background: var(--green);
transition: width 0.3s;
}
/* ── Damage grid (in detail panel) ─────────────────────────────────────────── */
.dmg-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.dmg-box {
background: var(--bg2);
border-radius: var(--r);
padding: 10px 12px;
}
.dmg-box-label { font-size: 11px; color: var(--t2); margin-bottom: 3px; }
.dmg-box-val { font-size: 16px; font-weight: 600; }
/* ── Mitigation picker ──────────────────────────────────────────────────────── */
.mitig-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
gap: 5px;
}
.mitig-opt {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: var(--r);
cursor: pointer;
transition: all 0.12s;
background: var(--bg2);
user-select: none;
}
.mitig-opt:hover { border-color: var(--borderem); }
.mitig-opt.sel { border-color: var(--gold); background: var(--goldbg); }
.mitig-opt.active-log { opacity: 0.5; cursor: default; }
.mitig-opt .opt-name { font-size: 12px; flex: 1; }
.mitig-opt .opt-dr { font-size: 11px; color: var(--t2); }
/* ── Terminal output ────────────────────────────────────────────────────────── */
.terminal {
background: var(--bg0);
border: 1px solid var(--border);
border-radius: var(--r);
color: #a3e635;
padding: 16px;
min-height: 200px;
white-space: pre-wrap;
overflow-x: auto;
font-size: 13px;
line-height: 1.6;
font-family: 'Courier New', monospace;
}

170
css/layout.css Normal file
View File

@ -0,0 +1,170 @@
/* ── App Shell ──────────────────────────────────────────────────────────────── */
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ── Topbar ─────────────────────────────────────────────────────────────────── */
#topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px;
height: 52px;
background: var(--bg1);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
flex-shrink: 0;
}
.logo {
font-family: var(--font-d);
font-size: 15px;
color: var(--gold);
letter-spacing: 0.05em;
white-space: nowrap;
}
.logo span {
font-family: var(--font-b);
font-size: 11px;
color: var(--t3);
margin-left: 8px;
font-weight: 400;
}
/* ── Tabs ───────────────────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 2px;
margin-left: auto;
}
.tab {
padding: 5px 14px;
border: none;
background: transparent;
color: var(--t2);
font-size: 13px;
cursor: pointer;
border-radius: var(--r);
font-family: var(--font-b);
transition: all 0.15s;
border-bottom: 2px solid transparent;
}
.tab:hover { background: var(--bg3); color: var(--t1); }
.tab.active { background: var(--bg3); color: var(--gold); border-bottom-color: var(--gold); }
/* ── Main content ───────────────────────────────────────────────────────────── */
#main {
flex: 1;
padding: 20px;
max-width: 1360px;
margin: 0 auto;
width: 100%;
}
/* ── Two-column panel layout (timeline + detail) ────────────────────────────── */
.panel-layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) {
.panel-layout { grid-template-columns: 1fr; }
}
/* ── Form helpers ───────────────────────────────────────────────────────────── */
.form-row {
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
.fg {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
min-width: 120px;
}
.fg label {
font-size: 11px;
color: var(--t2);
}
/* ── Topbar user badge ───────────────────────────────────────────────────────── */
.topbar-user {
margin-left: auto;
font-size: 12px;
color: var(--t2);
white-space: nowrap;
}
/* ── Login Overlay ───────────────────────────────────────────────────────────── */
#login-overlay {
position: fixed;
inset: 0;
background: var(--bg0);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
padding: 20px;
}
.login-card {
background: var(--bgcard);
border: 1px solid var(--borderem);
border-radius: var(--rl);
padding: 48px 40px;
max-width: 420px;
width: 100%;
text-align: center;
}
.login-logo {
font-family: var(--font-d);
font-size: 18px;
color: var(--gold);
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.login-logo span {
display: block;
font-family: var(--font-b);
font-size: 11px;
color: var(--t3);
font-weight: 400;
margin-top: 4px;
letter-spacing: 0;
}
.login-desc {
font-size: 13px;
color: var(--t2);
line-height: 1.7;
margin: 24px 0 20px;
}
.btn-login {
width: 100%;
justify-content: center;
padding: 11px 20px;
font-size: 14px;
font-family: var(--font-d);
letter-spacing: 0.04em;
text-decoration: none;
}
.login-hint {
font-size: 11px;
color: var(--t3);
line-height: 1.6;
margin-top: 16px;
}

197
index.php
View File

@ -17,200 +17,5 @@ $errorMessages = [
$errorText = $error ? ($errorMessages[$error] ?? 'Unknown error: ' . htmlspecialchars($error)) : null; $errorText = $error ? ($errorMessages[$error] ?? 'Unknown error: ' . htmlspecialchars($error)) : null;
$tokenDebug = $_SESSION['token_debug'] ?? null; $tokenDebug = $_SESSION['token_debug'] ?? null;
unset($_SESSION['token_debug']); unset($_SESSION['token_debug']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FFLogs Report Viewer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { require __DIR__ . '/templates/page.php';
font-family: 'Courier New', Courier, monospace;
background: #1a1a1a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container {
width: 100%;
max-width: 860px;
}
h1 {
font-size: 1.2rem;
color: #a78bfa;
margin-bottom: 1.5rem;
letter-spacing: 0.05em;
}
.error-box {
background: #3b0f0f;
border: 1px solid #7f1d1d;
color: #fca5a5;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: 2px;
font-size: 0.9rem;
}
.info-box {
background: #1e2a1e;
border: 1px solid #365936;
color: #86efac;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: 2px;
font-size: 0.9rem;
}
.btn {
display: inline-block;
padding: 0.6rem 1.4rem;
background: #7c3aed;
color: #fff;
text-decoration: none;
border: none;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
border-radius: 2px;
letter-spacing: 0.03em;
transition: background 0.15s;
}
.btn:hover { background: #6d28d9; }
.btn-secondary {
background: #374151;
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
margin-left: 1rem;
}
.btn-secondary:hover { background: #4b5563; }
.form-row {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
input[type="text"], select {
flex: 1;
min-width: 180px;
padding: 0.55rem 0.75rem;
background: #111827;
border: 1px solid #374151;
color: #e0e0e0;
font-family: inherit;
font-size: 0.95rem;
border-radius: 2px;
outline: none;
}
input[type="text"]:focus, select:focus { border-color: #7c3aed; }
input[type="text"]::placeholder { color: #4b5563; }
select option { background: #111827; }
#fight-select-row { display: none; }
.terminal {
background: #0d0d0d;
border: 1px solid #2d2d2d;
color: #a3e635;
padding: 1.25rem;
min-height: 260px;
white-space: pre-wrap;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.55;
border-radius: 2px;
}
.footer-link {
margin-top: 1rem;
font-size: 0.8rem;
color: #4b5563;
}
.footer-link a { color: #6b7280; text-decoration: none; }
.footer-link a:hover { color: #9ca3af; }
.connect-area {
text-align: center;
}
.connect-area h1 { margin-bottom: 0.5rem; }
.connect-area p {
color: #6b7280;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<?php if ($errorText): ?>
<div class="error-box"><?= $errorText ?></div>
<?php endif; ?>
<?php if ($tokenDebug): ?>
<div class="error-box">
<strong>Debug FFLogs Token Response:</strong><br>
HTTP <?= (int)$tokenDebug['http_status'] ?><?= $tokenDebug['curl_error'] ? ' | curl: ' . htmlspecialchars($tokenDebug['curl_error']) : '' ?><br>
<pre style="margin-top:0.5rem;white-space:pre-wrap;font-size:0.8rem;"><?= htmlspecialchars($tokenDebug['response_body'] ?? '(empty)') ?></pre>
</div>
<?php endif; ?>
<?php if (!$authenticated && !$tokenExpired): ?>
<div class="connect-area">
<h1>// FFLogs Report Viewer</h1>
<p>Connect your FFLogs account to fetch report data.</p>
<a class="btn" href="auth/start.php">Connect to FFLogs</a>
</div>
<?php elseif ($tokenExpired): ?>
<div class="connect-area">
<h1>// FFLogs Report Viewer</h1>
<div class="info-box">Session expired. Please reconnect.</div>
<a class="btn" href="auth/start.php">Reconnect to FFLogs</a>
</div>
<?php else: ?>
<h1>// FFLogs Report Viewer</h1>
<form id="report-form">
<div class="form-row">
<input
type="text"
name="report_code"
placeholder="Report Code (e.g. aBcDeFgH1234)"
autocomplete="off"
spellcheck="false"
required
>
<button class="btn" type="submit">Fetch</button>
<a class="btn btn-secondary" href="auth/start.php">Reconnect</a>
</div>
</form>
<div class="form-row" id="fight-select-row">
<select id="fight-select">
<option value=""> Fight auswählen </option>
</select>
</div>
<pre id="output" class="terminal">// paste a report code above and hit Fetch</pre>
<div class="footer-link">
Token valid until: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?>
</div>
<script src="js/app.js"></script>
<?php endif; ?>
</div>
</body>
</html>

156
js/analysis.js Normal file
View File

@ -0,0 +1,156 @@
(function () {
const MITIG_ICONS = {
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
'Temperance': 'assets/icons/mitigation/temperance.png',
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
'Expedient': 'assets/icons/mitigation/expedient.png',
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
'Holos': 'assets/icons/mitigation/holos.png',
'Kerachole': 'assets/icons/mitigation/kerachole.png',
'Panhaima': 'assets/icons/mitigation/panhaima.png',
'Troubadour': 'assets/icons/mitigation/troubadour.png',
'Tactician': 'assets/icons/mitigation/tactician.png',
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
'Reprisal': 'assets/icons/mitigation/reprisal.png',
'Feint': 'assets/icons/mitigation/feint.png',
'Addle': 'assets/icons/mitigation/addle.png',
};
const JOB_ABBR = {
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
'Reaper': 'RPR', 'Viper': 'VPR',
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
};
function abbr(type) {
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
}
function fmtTime(ms, start) {
const rel = ms - start;
const min = Math.floor(rel / 60000);
const sec = String(Math.floor((rel % 60000) / 1000)).padStart(2, '0');
return `${min}:${sec}`;
}
function fmtDmg(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
if (n >= 1_000) return Math.round(n / 1_000) + 'k';
return String(n);
}
function renderPlayers(players) {
const grid = document.getElementById('player-grid');
const order = { tank: 0, healer: 1, dps: 2 };
players.sort((a, b) => (order[a.role] ?? 2) - (order[b.role] ?? 2));
grid.innerHTML = players.map(p => `
<div class="player-card">
<div class="player-job-icon role-${p.role}">${abbr(p.type)}</div>
<div>
<div class="player-name">${p.name}</div>
<div class="player-type">${p.type}</div>
</div>
</div>
`).join('');
}
function renderTimeline(events, fightStart) {
const el = document.getElementById('aoe-timeline');
if (!events.length) {
el.innerHTML = '<div class="empty" style="padding:30px 0"><h3>Keine AoE-Events gefunden</h3></div>';
return;
}
el.innerHTML = events.map(ev => {
const mitigIcons = (ev.mitigations ?? []).map(m => {
const iconSrc = MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} (${m.casterName})">`;
}).join('');
const targets = ev.targets.map(t => `
<div class="aoe-target-wrap">
<div class="aoe-target">
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
<span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${fmtDmg(t.amount)}</span>
</div>
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''}
</div>
`).join('');
return `
<div class="aoe-event">
<div class="aoe-time">${fmtTime(ev.timestamp, fightStart)}</div>
<div>
<div class="aoe-ability">
${ev.abilityName}
<span class="aoe-total"> ${fmtDmg(ev.totalDamage)} total</span>
</div>
<div class="aoe-targets">${targets}</div>
</div>
</div>
`;
}).join('');
}
function setEmpty(msg) {
document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'none';
document.getElementById('analysis-empty').style.display = 'block';
document.getElementById('analysis-empty-msg').textContent = msg;
}
let lastFightId = null;
async function load() {
const { reportCode, fightId, fightStart, fightEnd } = window.App ?? {};
if (!reportCode || !fightId) return;
if (lastFightId === fightId) return;
document.getElementById('analysis-loading').style.display = 'flex';
document.getElementById('analysis-empty').style.display = 'none';
document.getElementById('analysis-content').style.display = 'none';
let json;
try {
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ report_code: reportCode, fight_id: fightId, start_time: fightStart, end_time: fightEnd }),
});
json = await res.json();
} catch (err) {
setEmpty('Netzwerkfehler: ' + err.message);
return;
}
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
lastFightId = fightId;
renderPlayers(json.players ?? []);
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'block';
}
window.analysisTab = {
onFightSelected: load,
onTabOpen: load,
reset() { lastFightId = null; },
};
})();

View File

@ -1,7 +1,11 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0 };
const form = document.getElementById('report-form'); const form = document.getElementById('report-form');
const output = document.getElementById('output'); const output = document.getElementById('output');
const fightSelectRow = document.getElementById('fight-select-row'); const outputCard = document.getElementById('output-card');
const initialHint = document.getElementById('initial-hint');
const fightSelectCard = document.getElementById('fight-select-card');
const fightSelect = document.getElementById('fight-select'); const fightSelect = document.getElementById('fight-select');
let allFights = []; let allFights = [];
@ -22,31 +26,46 @@ document.addEventListener('DOMContentLoaded', () => {
function displayFight(fight) { function displayFight(fight) {
output.textContent = JSON.stringify(fight, null, 2); output.textContent = JSON.stringify(fight, null, 2);
outputCard.style.display = 'block';
} }
fightSelect.addEventListener('change', () => { fightSelect.addEventListener('change', () => {
if (!fightSelect.value) return;
const id = parseInt(fightSelect.value, 10); const id = parseInt(fightSelect.value, 10);
const fight = allFights.find(f => f.id === id); const fight = allFights.find(f => f.id === id);
if (fight) displayFight(fight); if (!fight) return;
window.App.fightId = id;
window.App.fightStart = fight.startTime;
window.App.fightEnd = fight.endTime;
displayFight(fight);
window.analysisTab?.onFightSelected?.();
}); });
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
initialHint.style.display = 'none';
outputCard.style.display = 'block';
output.textContent = '// fetching...'; output.textContent = '// fetching...';
fightSelectRow.style.display = 'none'; fightSelectCard.style.display = 'none';
fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>'; fightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
allFights = []; allFights = [];
const params = new URLSearchParams({ const reportCode = form.elements['report_code'].value.trim();
report_code: form.elements['report_code'].value.trim(), window.App.reportCode = reportCode;
}); window.App.fightId = null;
window.App.fightStart = 0;
window.App.fightEnd = 0;
window.analysisTab?.reset?.();
let response, json; let response, json;
try { try {
response = await fetch('api/fight.php', { response = await fetch('api/fight.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(), body: new URLSearchParams({ report_code: reportCode }),
}); });
json = await response.json(); json = await response.json();
} catch (err) { } catch (err) {
@ -78,18 +97,16 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// populate dropdown
allFights.forEach(fight => { allFights.forEach(fight => {
const duration = formatDuration(fight.endTime - fight.startTime); const duration = formatDuration(fight.endTime - fight.startTime);
const hp = formatBossHp(fight); const hp = formatBossHp(fight);
const label = `${fight.name}${duration}${hp}`;
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = fight.id; opt.value = fight.id;
opt.textContent = label; opt.textContent = `${fight.name}${duration}${hp}`;
fightSelect.appendChild(opt); fightSelect.appendChild(opt);
}); });
fightSelectRow.style.display = 'flex'; fightSelectCard.style.display = 'block';
output.textContent = '// Fight auswählen ↑'; output.textContent = '// Fight auswählen ↑';
}); });
}); });

19
js/tabs.js Normal file
View File

@ -0,0 +1,19 @@
document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.tabs .tab');
const contents = document.querySelectorAll('.tab-content');
function showTab(name) {
contents.forEach(el => el.style.display = 'none');
tabs.forEach(btn => btn.classList.remove('active'));
const content = document.getElementById('tab-' + name);
const btn = document.querySelector(`.tabs .tab[data-tab="${name}"]`);
if (content) content.style.display = 'block';
if (btn) btn.classList.add('active');
if (name === 'analysis') window.analysisTab?.onTabOpen?.();
}
tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab)));
});

View File

@ -0,0 +1,6 @@
<div class="card section-gap" id="fight-select-card" style="display:none">
<div class="card-title">Fight auswählen</div>
<select id="fight-select">
<option value=""> Fight auswählen </option>
</select>
</div>

33
templates/login.php Normal file
View File

@ -0,0 +1,33 @@
<div id="login-overlay">
<div class="login-card">
<div class="login-logo">REPORT VIEWER <span>Final Fantasy XIV</span></div>
<?php if ($errorText): ?>
<div class="error"><?= htmlspecialchars($errorText) ?></div>
<?php endif; ?>
<?php if ($tokenDebug): ?>
<div class="error" style="text-align:left">
<strong>Debug FFLogs Token Response:</strong><br>
HTTP <?= (int)$tokenDebug['http_status'] ?><?= $tokenDebug['curl_error'] ? ' | curl: ' . htmlspecialchars($tokenDebug['curl_error']) : '' ?><br>
<pre style="margin-top:0.5rem;white-space:pre-wrap;font-size:0.8rem;"><?= htmlspecialchars($tokenDebug['response_body'] ?? '(empty)') ?></pre>
</div>
<?php endif; ?>
<p class="login-desc">
<?php if ($tokenExpired): ?>
Session abgelaufen bitte erneut mit FFLogs verbinden.
<?php else: ?>
Verbinde deinen FFLogs-Account, um Report-Daten abzurufen und zu analysieren.
<?php endif; ?>
</p>
<a href="auth/start.php" class="btn btn-gold btn-login">
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
</a>
<p class="login-hint">
Du wirst zu FFLogs weitergeleitet, um den Zugriff zu autorisieren.<br>
Deine Zugangsdaten werden niemals an diese App übermittelt.
</p>
</div>
</div>

View File

@ -0,0 +1,9 @@
<div class="card" id="output-card" style="display:none">
<div class="card-title">Ausgabe</div>
<pre id="output" class="terminal"></pre>
</div>
<div id="initial-hint" class="empty">
<div class="empty-icon"></div>
<h3>Report Code eingeben und Fetch klicken</h3>
</div>

39
templates/page.php Normal file
View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FFLogs Report Viewer</title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/analysis.css">
</head>
<body>
<?php if (!$authenticated): ?>
<?php require __DIR__ . '/login.php'; ?>
<?php else: ?>
<div id="app">
<?php require __DIR__ . '/topbar.php'; ?>
<main id="main">
<?php if ($errorText): ?>
<div class="error section-gap"><?= htmlspecialchars($errorText) ?></div>
<?php endif; ?>
<div id="tab-report" class="tab-content">
<?php require __DIR__ . '/tab-report.php'; ?>
</div>
<div id="tab-analysis" class="tab-content" style="display:none">
<?php require __DIR__ . '/tab-analysis.php'; ?>
</div>
</main>
</div>
<script src="js/app.js"></script>
<script src="js/tabs.js"></script>
<script src="js/analysis.js"></script>
<?php endif; ?>
</body>
</html>

20
templates/report-form.php Normal file
View File

@ -0,0 +1,20 @@
<div class="card section-gap">
<div class="card-title">Report laden</div>
<form id="report-form">
<div class="form-row">
<div class="fg">
<label>Report Code</label>
<input
type="text"
name="report_code"
placeholder="z.B. aBcDeFgH1234"
autocomplete="off"
spellcheck="false"
required
>
</div>
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
<a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
</div>
</form>
</div>

View File

@ -0,0 +1,23 @@
<div id="analysis-loading" class="loading" style="display:none">
<div class="spinner"></div>
<span>Analysiere Fight-Daten...</span>
</div>
<div id="analysis-empty" class="empty">
<div class="empty-icon">📊</div>
<h3 id="analysis-empty-msg">Bitte zuerst einen Fight im Report-Tab auswählen</h3>
</div>
<div id="analysis-content" style="display:none">
<div class="card section-gap">
<div class="card-title">Spieler</div>
<div id="player-grid" class="player-grid"></div>
</div>
<div class="card">
<div class="card-title">AoE Timeline</div>
<div id="aoe-timeline"></div>
</div>
</div>

3
templates/tab-report.php Normal file
View File

@ -0,0 +1,3 @@
<?php require __DIR__ . '/report-form.php'; ?>
<?php require __DIR__ . '/fight-select.php'; ?>
<?php require __DIR__ . '/output-card.php'; ?>

8
templates/topbar.php Normal file
View File

@ -0,0 +1,8 @@
<header id="topbar">
<div class="logo">REPORT VIEWER <span>Final Fantasy XIV</span></div>
<nav class="tabs">
<button class="tab active" data-tab="report"> Report</button>
<button class="tab" data-tab="analysis">📊 Analyse</button>
</nav>
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
</header>