diff --git a/api/players.php b/api/players.php new file mode 100644 index 0000000..263ce13 --- /dev/null +++ b/api/players.php @@ -0,0 +1,95 @@ + '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 params']); + exit; +} + +$token = $_SESSION['access_token']; + +function pl_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]; +} + +// Single query: playerDetails + first page of DamageTaken with resources to get maxHp +$result = pl_gql(<< true]); exit; } +if (isset($result['_curl_error'])) { http_response_code(502); echo json_encode(['error' => $result['_curl_error']]); exit; } + +// Parse player details: id → {name, type, role, maxHp} +$pdRaw = $result['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']] = [ + 'name' => $p['name'], + 'type' => $p['type'] ?? '', + 'role' => $role, + 'maxHp' => 0, + ]; + } +} + +// Extract maxHp from first damage events (skip DoT ticks — they may lack resources) +foreach ($result['data']['reportData']['report']['events']['data'] ?? [] as $ev) { + if ($ev['tick'] ?? false) continue; + $tid = (int)($ev['targetID'] ?? 0); + $maxHp = (int)($ev['targetResources']['maxHitPoints'] ?? 0); + if (isset($players[$tid]) && $players[$tid]['maxHp'] === 0 && $maxHp > 0) { + $players[$tid]['maxHp'] = $maxHp; + } +} + +echo json_encode(['players' => array_values($players)]); diff --git a/css/planner.css b/css/planner.css index a21f166..dd7f516 100644 --- a/css/planner.css +++ b/css/planner.css @@ -159,6 +159,63 @@ .job-slot--healer select { border-left-color: var(--green); } .job-slot--dps select { border-left-color: rgba(200,168,75,.5); } +/* ── Job Slot Player Names ───────────────────────────────────────────────────── */ +.job-slot-name { + font-size: 11px; + color: var(--t2); + text-align: center; + margin-top: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* ── Name Import Modal ───────────────────────────────────────────────────────── */ +.name-import-input-row { + display: flex; + gap: 8px; +} +.name-import-input-row input { flex: 1; } + +.name-import-preview { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--r); + overflow: hidden; + margin-bottom: 4px; + max-height: 300px; + overflow-y: auto; +} + +.name-import-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); +} +.name-import-row:last-child { border-bottom: none; } + +.name-import-name { + font-size: 14px; + color: var(--t1); + flex: 1; +} + +.name-import-name--none { + font-size: 13px; + color: var(--t3); + font-style: italic; + flex: 1; +} + +.name-import-disambig { + flex: 1; + font-size: 13px; + padding: 3px 6px; +} + /* ── Mechanic Cards ──────────────────────────────────────────────────────────── */ .mechanic-card { display: grid; diff --git a/js/analysis.js b/js/analysis.js index 6fdede0..4f54dc4 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -321,7 +321,7 @@ if (!json.error && !json.reauth) { refEvents = json.aoe_events ?? []; refFightStart = json.fight_start ?? fight.startTime; - refPlayers = []; + refPlayers = json.players ?? []; window.App.setUrlState?.({ compareReportCode: '', compareFightId: refId, diff --git a/js/planner.js b/js/planner.js index 6acb9ad..d3a11a4 100644 --- a/js/planner.js +++ b/js/planner.js @@ -325,7 +325,10 @@ function renderPlanDetail(plan) {
-
Jobaufstellung
+
+
Jobaufstellung
+ +
${renderJobSlotsHtml(plan)}
@@ -345,6 +348,9 @@ function renderPlanDetail(plan) { startRename(plan.id, plan.name); }); initJobSlots(plan.id); + document.getElementById('name-import-open-btn')?.addEventListener('click', () => { + showNameImportModal(plan.id); + }); initMechanicClicks(plan.id); renderInfoPanel(plan); } @@ -417,9 +423,11 @@ function renderMechanicListHtml(plan) { // ── Job Slots ───────────────────────────────────────────────────────────────── function renderJobSlotsHtml(plan) { + const roster = plan.playerRoster ?? []; return Array.from({ length: 8 }, (_, i) => { - const job = plan.jobComposition[i] ?? ''; - const role = JOB_ROLE[job] ?? ''; + const job = plan.jobComposition[i] ?? ''; + const role = JOB_ROLE[job] ?? ''; + const playerName = roster[i]?.name ?? ''; return `
+ ${playerName ? `
${escHtml(playerName)}
` : ''}
`; }).join(''); } @@ -1009,12 +1018,36 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga // ── Merge + Create plan from import ────────────────────────────────────────── -function extractJobComp(players) { - const order = { tank: 0, healer: 1, dps: 2 }; +function buildPlayerRoster(players, aoeEvents) { + const maxHpByName = {}; + for (const ev of aoeEvents ?? []) { + for (const t of ev.targets ?? []) { + if (t.name && t.maxHp > 0 && !(t.name in maxHpByName)) { + maxHpByName[t.name] = t.maxHp; + } + } + } + const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...(players ?? [])] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { - const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2); + const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); + return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); + }); + const roster = sorted.slice(0, 8).map(p => ({ + name: p.name, + maxHp: maxHpByName[p.name] ?? 0, + })); + while (roster.length < 8) roster.push({ name: '', maxHp: 0 }); + return roster; +} + +function extractJobComp(players) { + const order = { healer: 0, dps: 1, tank: 2 }; + const sorted = [...(players ?? [])] + .filter(p => JOB_FROM_TYPE[p.type]) + .sort((a, b) => { + const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8); @@ -1034,6 +1067,7 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) { source, mitigationNames, jobComposition: extractJobComp(players), + playerRoster: buildPlayerRoster(players, aoeEvents), }); } @@ -1334,6 +1368,195 @@ function initImportModal() { }); } +// ── Name Import Modal ───────────────────────────────────────────────────────── + +let nameImportPlanId = null; +let nameImportFights = []; +let nameImportReportCode = ''; +let nameImportMatchData = []; // length-8 array: null | {job, matched:[{name,type,role,maxHp}], selected:0} + +function showNameImportModal(planId) { + nameImportPlanId = planId; + nameImportFights = []; + nameImportReportCode = ''; + nameImportMatchData = []; + + document.getElementById('name-import-report-input').value = ''; + document.getElementById('name-import-fight-section').style.display = 'none'; + document.getElementById('name-import-fight-select').innerHTML = ''; + document.getElementById('name-import-preview').style.display = 'none'; + document.getElementById('name-import-preview').innerHTML = ''; + document.getElementById('name-import-confirm-btn').style.display = 'none'; + + document.getElementById('planner-name-import-modal').style.display = 'flex'; + document.getElementById('name-import-report-input').focus(); +} + +function hideNameImportModal() { + document.getElementById('planner-name-import-modal').style.display = 'none'; + nameImportPlanId = null; +} + +function refreshJobSlots(planId) { + const plan = getPlan(planId); + if (!plan) return; + const grid = document.getElementById('job-slots-grid'); + if (grid) grid.innerHTML = renderJobSlotsHtml(plan); +} + +function renderNameImportPreview(_plan, fetchedPlayers) { + const order = { healer: 0, dps: 1, tank: 2 }; + const sorted = [...fetchedPlayers] + .filter(p => JOB_FROM_TYPE[p.type]) + .sort((a, b) => { + const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); + return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); + }); + + nameImportMatchData = sorted.slice(0, 8); + + const preview = document.getElementById('name-import-preview'); + + const rows = Array.from({ length: 8 }, (_, i) => { + const p = sorted[i]; + if (!p) { + return ` +
+ + Leer +
`; + } + const job = JOB_FROM_TYPE[p.type] ?? p.type; + const role = JOB_ROLE[job] ?? 'dps'; + return ` +
+ ${escHtml(job)} + ${escHtml(p.name)} +
`; + }).join(''); + + preview.innerHTML = rows; + preview.style.display = ''; +} + +function initNameImportModal() { + const modal = document.getElementById('planner-name-import-modal'); + const reportInput = document.getElementById('name-import-report-input'); + const loadBtn = document.getElementById('name-import-load-btn'); + const fightSection = document.getElementById('name-import-fight-section'); + const fightSelect = document.getElementById('name-import-fight-select'); + const preview = document.getElementById('name-import-preview'); + const confirmBtn = document.getElementById('name-import-confirm-btn'); + const cancelBtn = document.getElementById('name-import-cancel-btn'); + if (!modal) return; + + cancelBtn.addEventListener('click', hideNameImportModal); + modal.addEventListener('click', e => { if (e.target === modal) hideNameImportModal(); }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && nameImportPlanId) hideNameImportModal(); + }); + + reportInput.addEventListener('input', () => { + const match = reportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/); + if (match) reportInput.value = match[1]; + }); + + loadBtn.addEventListener('click', async () => { + const code = reportInput.value.trim(); + if (!code) return; + + loadBtn.disabled = true; + loadBtn.textContent = 'Lädt…'; + fightSection.style.display = 'none'; + preview.style.display = 'none'; + confirmBtn.style.display = 'none'; + + try { + const res = await fetch('api/fight.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ report_code: code, language: 'en' }), + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } + + nameImportFights = json?.data?.reportData?.report?.fights ?? []; + nameImportReportCode = code; + + fightSelect.innerHTML = ''; + nameImportFights.forEach(f => { + const ms = f.endTime - f.startTime; + const dur = `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`; + const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?'); + const opt = document.createElement('option'); + opt.value = f.id; + opt.textContent = `${f.name} — ${dur} — ${hp}`; + fightSelect.appendChild(opt); + }); + fightSection.style.display = ''; + } catch { } + + loadBtn.disabled = false; + loadBtn.textContent = 'Laden'; + }); + + fightSelect.addEventListener('change', async () => { + const fightId = parseInt(fightSelect.value, 10); + if (!fightId) { + preview.style.display = 'none'; + confirmBtn.style.display = 'none'; + return; + } + const fight = nameImportFights.find(f => f.id === fightId); + if (!fight) return; + + fightSelect.disabled = true; + preview.innerHTML = '
Lädt…
'; + preview.style.display = ''; + confirmBtn.style.display = 'none'; + + try { + const res = await fetch('api/players.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + report_code: nameImportReportCode, + fight_id: fightId, + start_time: fight.startTime, + end_time: fight.endTime, + }), + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } + if (json.error) { preview.innerHTML = `
${escHtml(json.error)}
`; return; } + + const plan = getPlan(nameImportPlanId); + if (!plan) return; + renderNameImportPreview(plan, json.players ?? []); + confirmBtn.style.display = ''; + } catch { } + + fightSelect.disabled = false; + }); + + confirmBtn.addEventListener('click', () => { + if (!nameImportPlanId) return; + + const jobComposition = Array.from({ length: 8 }, (_, i) => { + const p = nameImportMatchData[i]; + return p ? (JOB_FROM_TYPE[p.type] ?? '') : ''; + }); + const playerRoster = Array.from({ length: 8 }, (_, i) => { + const p = nameImportMatchData[i]; + return p ? { name: p.name, maxHp: p.maxHp ?? 0 } : { name: '', maxHp: 0 }; + }); + + updatePlan(nameImportPlanId, { jobComposition, playerRoster }); + openPlan(nameImportPlanId); + hideNameImportModal(); + }); +} + // ── window.plannerTab (hooks for other tabs) ────────────────────────────────── window.plannerTab = { @@ -1362,6 +1585,7 @@ document.addEventListener('DOMContentLoaded', () => { initNewFolderForm(); initImportModal(); initAbilityModal(); + initNameImportModal(); activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); renderPlanList(); if (activePlanId && getPlan(activePlanId)) { diff --git a/templates/page.php b/templates/page.php index 17e3bc9..249c768 100644 --- a/templates/page.php +++ b/templates/page.php @@ -80,6 +80,35 @@
+ + +