Compare commits

..

5 Commits

Author SHA1 Message Date
xziino
3726a35a72 Analyse+Planer: Phys/Mag-Badge für Schadenstyp (abilityType)
- analysis.php: masterData.abilities.type im GQL-Query + abilityType in AoE-Events
- analysis.js + planner.js: dmgTypeBadge() zeigt [Phys]/[Mag] neben Ability-Namen
- cache.php: Version v8 → v9 (Cache-Invalidierung)
- components.css + planner.css: .dmg-type-badge Styles (orange=phys, blau=mag)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:29:52 +02:00
Akurosia Kamo
1e881be482 add also merge branch function to admin 2026-05-27 14:44:05 +02:00
Akurosia Kamo
e18252b491 add commit and push commands from web 2026-05-27 14:41:35 +02:00
Akurosia Kamo
c2cf4db458 fix branch names 2026-05-27 14:20:12 +02:00
Akurosia Kamo
0f4d5a98d4 fflogs-ids > admin 2026-05-27 14:16:26 +02:00
12 changed files with 496 additions and 6 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
DEV_MODE=true
CLIENT_ID=
REDIRECT_URI=http://localhost:8080/auth/callback.php
AUTHORIZE_URI=https://www.fflogs.com/oauth/authorize
TOKEN_URI=https://www.fflogs.com/oauth/token
GRAPHQL_URI=https://www.fflogs.com/api/v2/user
ADMIN_USER_IDS=

330
admin.php Normal file
View File

@ -0,0 +1,330 @@
<?php
require_once __DIR__ . '/config.php';
session_start_safe();
if (empty($_SESSION['access_token']) || (($_SESSION['token_expires'] ?? 0) <= time())) {
header('Location: ' . auth_start_href(current_return_path()));
exit;
}
$user = current_fflogs_user();
if (!$user) {
http_response_code(502);
}
$allowed = $user && is_admin_user();
if (!$allowed && $user) {
http_response_code(403);
}
if (empty($_SESSION['admin_csrf'])) {
$_SESSION['admin_csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['admin_csrf'];
function admin_h(?string $value): string {
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function git_run(array $args): array {
$cmd = 'git -C ' . escapeshellarg(__DIR__);
foreach ($args as $arg) {
$cmd .= ' ' . escapeshellarg($arg);
}
$cmd .= ' 2>&1';
exec($cmd, $lines, $code);
return ['code' => $code, 'output' => trim(implode("\n", $lines))];
}
function git_output(array $args): string {
return git_run($args)['output'];
}
function git_local_branches(): array {
$out = git_output(['branch', '--list', '--no-color']);
$branches = array_values(array_filter(array_map(function ($line) {
return trim(ltrim(trim($line), '*'));
}, explode("\n", $out)), fn($v) => $v !== ''));
sort($branches, SORT_NATURAL | SORT_FLAG_CASE);
return $branches;
}
function git_remote_branches(): array {
$out = git_output(['branch', '-r', '--no-color']);
$branches = array_values(array_filter(array_map('trim', explode("\n", $out)), function ($branch) {
return $branch !== '' && !str_contains($branch, ' -> ') && !str_ends_with($branch, '/HEAD');
}));
sort($branches, SORT_NATURAL | SORT_FLAG_CASE);
return $branches;
}
function branch_from_remote(string $remoteBranch): string {
$parts = explode('/', $remoteBranch, 2);
return $parts[1] ?? $remoteBranch;
}
function git_last_commit(): string {
$out = git_output(['log', '-1', '--oneline', '--decorate=short']);
return $out !== '' ? $out : '(unknown)';
}
function resolve_selected_branch_ref(string $ref, array $localBranches, array $remoteBranches, bool $preferLocalForRemote): ?string {
if (str_starts_with($ref, 'local:')) {
$branch = substr($ref, 6);
return in_array($branch, $localBranches, true) ? $branch : null;
}
if (str_starts_with($ref, 'remote:')) {
$remoteBranch = substr($ref, 7);
if (!in_array($remoteBranch, $remoteBranches, true)) return null;
$localBranch = branch_from_remote($remoteBranch);
return $preferLocalForRemote && in_array($localBranch, $localBranches, true) ? $localBranch : $remoteBranch;
}
return null;
}
$message = null;
$result = null;
if ($allowed && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($csrf, $_POST['csrf'] ?? '')) {
http_response_code(400);
$message = 'Invalid CSRF token.';
} else {
$action = $_POST['action'] ?? '';
if ($action === 'fetch') {
$result = git_run(['fetch', '--prune']);
$message = 'git fetch --prune';
} elseif ($action === 'pull') {
$result = git_run(['pull', '--ff-only']);
$message = 'git pull --ff-only';
} elseif ($action === 'push') {
$result = git_run(['push']);
$message = 'git push';
} elseif ($action === 'commit') {
$commitMessage = trim((string)($_POST['message'] ?? ''));
if ($commitMessage === '') {
$result = ['code' => 1, 'output' => 'Commit message is required.'];
} else {
$add = git_run(['add', '-A']);
if ($add['code'] !== 0) {
$result = $add;
$message = 'git add -A';
} else {
$result = git_run(['commit', '-m', $commitMessage]);
$message = 'git commit';
}
}
} elseif ($action === 'merge') {
$ref = trim((string)($_POST['merge_branch'] ?? ''));
$localBranches = git_local_branches();
$remoteBranches = git_remote_branches();
$branch = resolve_selected_branch_ref($ref, $localBranches, $remoteBranches, false);
if ($branch === null) {
$result = ['code' => 1, 'output' => 'Unknown branch selection.'];
} else {
$result = git_run(['merge', '--no-edit', $branch]);
$message = 'git merge --no-edit ' . $branch;
}
} elseif ($action === 'switch') {
$ref = trim((string)($_POST['branch'] ?? ''));
$localBranches = git_local_branches();
$remoteBranches = git_remote_branches();
if (str_starts_with($ref, 'remote:')) {
$remoteBranch = substr($ref, 7);
if (!in_array($remoteBranch, $remoteBranches, true)) {
$result = ['code' => 1, 'output' => 'Unknown remote branch: ' . $remoteBranch];
} else {
$branch = branch_from_remote($remoteBranch);
if (in_array($branch, $localBranches, true)) {
$result = git_run(['switch', $branch]);
$message = 'git switch ' . $branch;
} else {
$result = git_run(['switch', '--track', $remoteBranch]);
$message = 'git switch --track ' . $remoteBranch;
}
}
} else {
$branch = resolve_selected_branch_ref($ref, $localBranches, $remoteBranches, true);
if ($branch === null) {
$result = ['code' => 1, 'output' => 'Unknown branch selection.'];
} else {
$result = git_run(['switch', $branch]);
$message = 'git switch ' . $branch;
}
}
}
}
}
$currentBranch = '';
$status = '';
$branches = [];
$remoteBranches = [];
$lastCommit = '';
if ($allowed) {
$currentBranch = git_output(['rev-parse', '--abbrev-ref', 'HEAD']);
$status = git_output(['status', '-sb']);
$branches = git_local_branches();
$remoteBranches = git_remote_branches();
$lastCommit = git_last_commit();
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · 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">
<style>
.admin-shell { max-width: 980px; margin: 24px auto; width: min(92%, 980px); }
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
.admin-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; }
.admin-pre {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 14px;
overflow: auto;
white-space: pre-wrap;
color: var(--t2);
font-size: 13px;
line-height: 1.5;
}
.admin-meta { color: var(--t2); line-height: 1.8; font-size: 14px; }
.admin-deny { max-width: 720px; margin: 24px auto; width: min(92%, 720px); }
@media (max-width: 800px) { .admin-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div id="app">
<header id="topbar">
<a class="logo" href="index.php" style="text-decoration:none">REPORT VIEWER <span>Admin</span></a>
<div class="topbar-actions">
<a class="topbar-link" href="index.php">Back</a>
<div class="topbar-user">
<?= $user ? admin_h($user['name'] ?: 'FFLogs User') . ' #' . admin_h($user['id']) : 'FFLogs user unavailable' ?>
</div>
</div>
</header>
<?php if (!$user): ?>
<main class="admin-deny">
<div class="card">
<div class="card-title">User nicht verfügbar</div>
<p class="admin-meta">Ich konnte deine FFLogs User-ID nicht laden. <?= admin_h($_SESSION['fflogs_user_error'] ?? '') ?></p>
</div>
</main>
<?php elseif (!$allowed): ?>
<main class="admin-deny">
<div class="card">
<div class="card-title">Kein Zugriff</div>
<p class="admin-meta">
Deine FFLogs User-ID ist <strong><?= admin_h($user['id']) ?></strong>.
Füge diese ID in deiner <code>.env</code> zu <code>ADMIN_USER_IDS</code> hinzu, um diese Seite freizuschalten.
</p>
</div>
</main>
<?php else: ?>
<main class="admin-shell">
<div class="card">
<div class="card-title">Git Admin</div>
<div class="admin-meta">
Branch: <strong><?= admin_h($currentBranch) ?></strong><br>
Last commit: <?= admin_h($lastCommit) ?>
</div>
</div>
<div class="admin-grid section-gap">
<div class="card">
<div class="card-title">Aktionen</div>
<form method="post" class="admin-actions">
<input type="hidden" name="csrf" value="<?= admin_h($csrf) ?>">
<button class="btn" name="action" value="fetch" type="submit">Fetch</button>
<button class="btn btn-gold" name="action" value="pull" type="submit">Pull</button>
<button class="btn" name="action" value="push" type="submit">Push</button>
</form>
<form method="post" class="admin-actions section-gap">
<input type="hidden" name="csrf" value="<?= admin_h($csrf) ?>">
<input type="hidden" name="action" value="switch">
<div class="fg">
<label for="branch">Branch</label>
<select id="branch" name="branch">
<?php foreach ($branches as $branch): ?>
<option value="local:<?= admin_h($branch) ?>" <?= $branch === $currentBranch ? 'selected' : '' ?>>
<?= admin_h($branch) ?>
</option>
<?php endforeach; ?>
<?php if ($remoteBranches): ?>
<optgroup label="Remote branches">
<?php foreach ($remoteBranches as $branch): ?>
<option value="remote:<?= admin_h($branch) ?>">
<?= admin_h($branch) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select>
</div>
<button class="btn" type="submit">Switch</button>
</form>
<form method="post" class="section-gap">
<input type="hidden" name="csrf" value="<?= admin_h($csrf) ?>">
<input type="hidden" name="action" value="commit">
<div class="fg">
<label for="commit-message">Commit message</label>
<input id="commit-message" name="message" type="text" placeholder="Describe the change…" required>
</div>
<button class="btn btn-gold section-gap" type="submit">Commit all changes</button>
</form>
<form method="post" class="admin-actions section-gap">
<input type="hidden" name="csrf" value="<?= admin_h($csrf) ?>">
<input type="hidden" name="action" value="merge">
<div class="fg">
<label for="merge-branch">Merge from branch</label>
<select id="merge-branch" name="merge_branch">
<?php foreach ($branches as $branch): ?>
<?php if ($branch === $currentBranch) continue; ?>
<option value="local:<?= admin_h($branch) ?>">
<?= admin_h($branch) ?>
</option>
<?php endforeach; ?>
<?php if ($remoteBranches): ?>
<optgroup label="Remote branches">
<?php foreach ($remoteBranches as $branch): ?>
<option value="remote:<?= admin_h($branch) ?>">
<?= admin_h($branch) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endif; ?>
</select>
</div>
<button class="btn" type="submit">Merge</button>
</form>
</div>
<div class="card">
<div class="card-title">Status</div>
<pre class="admin-pre"><?= admin_h($status ?: 'Clean') ?></pre>
</div>
</div>
<?php if ($result): ?>
<div class="card section-gap">
<div class="card-title"><?= admin_h($message ?? 'Output') ?> · Exit <?= (int)$result['code'] ?></div>
<pre class="admin-pre"><?= admin_h($result['output'] ?: '(no output)') ?></pre>
</div>
<?php endif; ?>
</main>
<?php endif; ?>
</div>
</body>
</html>

View File

@ -251,6 +251,7 @@ $pdResult = fflogs_gql(<<<GQL
abilities {
gameID
name
type
}
}
}
@ -261,10 +262,12 @@ 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/statusID → display name
// abilityGameID/statusID → display name + damage type
$abilityNames = [];
$abilityTypes = [];
foreach ($pdResult['data']['reportData']['report']['masterData']['abilities'] ?? [] as $ab) {
$abilityNames[(int)$ab['gameID']] = $ab['name'];
if (isset($ab['type'])) $abilityTypes[(int)$ab['gameID']] = (int)$ab['type'];
}
// gameID → mitigation meta: primary from masterData, statusId fallback for
@ -524,6 +527,7 @@ foreach ($allEvents as $ev) {
'maxHp' => (int)($ev['targetResources']['maxHitPoints'] ?? 0),
'buffs' => $ev['buffs'] ?? '',
'name' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId),
'abilityType' => isset($ev['ability']['type']) ? (int)$ev['ability']['type'] : ($abilityTypes[$abId] ?? null),
];
}
@ -543,6 +547,7 @@ foreach ($byAbility as $abId => $events) {
'timestamp' => (int)$ev['ts'],
'abilityId' => $abId,
'abilityName' => $ev['name'],
'abilityType' => $ev['abilityType'] ?? null,
'targets' => [],
];
}
@ -633,6 +638,7 @@ foreach ($clusters as $group) {
'timestamp' => $group['timestamp'],
'abilityId' => $group['abilityId'],
'abilityName' => $group['abilityName'],
'abilityType' => $group['abilityType'] ?? null,
'targets' => $targets,
'totalDamage' => array_sum(array_column($targets, 'amount')),
'isHeavyTankbuster' => $isHeavyTankbuster,

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
const CACHED_LOG_DIR = __DIR__ . '/../cached_logs';
const CACHED_LOG_VERSION = 'v8';
const CACHED_LOG_VERSION = 'v9';
function cache_language(string $language): string {
$language = strtolower(trim($language));

View File

@ -67,6 +67,7 @@ if ($curlError || $status !== 200 || empty($data['access_token'])) {
$_SESSION['access_token'] = $data['access_token'];
$_SESSION['token_expires'] = time() + ($data['expires_in'] ?? 3600);
$_SESSION['fflogs_user'] = fetch_current_fflogs_user($data['access_token']);
header('Location: ' . $returnPath);
exit;

View File

@ -38,6 +38,11 @@ function env_bool(string $key, bool $default = false): bool {
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
function env_list(string $key): array {
$value = env_value($key, '');
return array_values(array_filter(array_map('trim', explode(',', $value)), fn($v) => $v !== ''));
}
load_env_file(__DIR__ . '/.env');
define('DEV_MODE', env_bool('DEV_MODE'));
@ -46,6 +51,7 @@ define('REDIRECT_URI', env_value('REDIRECT_URI'));
define('AUTHORIZE_URI', env_value('AUTHORIZE_URI'));
define('TOKEN_URI', env_value('TOKEN_URI'));
define('GRAPHQL_URI', env_value('GRAPHQL_URI'));
define('ADMIN_USER_IDS', env_list('ADMIN_USER_IDS'));
function session_start_safe(): void {
if (session_status() === PHP_SESSION_NONE) {
@ -94,3 +100,90 @@ function current_return_path(): string {
function auth_start_href(?string $returnPath = null): string {
return 'auth/start.php?return=' . rawurlencode($returnPath ?? current_return_path());
}
function fflogs_graphql_user_query(string $query, string $token): array {
$payload = json_encode(['query' => $query]);
if ($payload === false) {
return ['error' => 'Could not encode GraphQL payload'];
}
if (function_exists('curl_init')) {
$ch = curl_init(GRAPHQL_URI);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
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 ['error' => $err, 'status' => $code];
return ['body' => $body, 'status' => $code];
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'content' => $payload,
'header' => implode("\r\n", [
'Content-Type: application/json',
'Authorization: Bearer ' . $token,
]),
'timeout' => 30,
],
'ssl' => [
'verify_peer' => !DEV_MODE,
'verify_peer_name' => !DEV_MODE,
],
]);
$body = file_get_contents(GRAPHQL_URI, false, $context);
if ($body === false) return ['error' => 'GraphQL request failed'];
return ['body' => $body, 'status' => 200];
}
function fetch_current_fflogs_user(string $token): ?array {
$result = fflogs_graphql_user_query('query { userData { currentUser { id name } } }', $token);
if (!empty($result['error'])) {
$_SESSION['fflogs_user_error'] = $result['error'];
return null;
}
$data = json_decode((string)($result['body'] ?? ''), true);
$user = $data['data']['userData']['currentUser'] ?? null;
if (!is_array($user) || empty($user['id'])) {
$_SESSION['fflogs_user_error'] = $data['errors'][0]['message'] ?? 'Could not read current FFLogs user';
return null;
}
return [
'id' => (string)$user['id'],
'name' => (string)($user['name'] ?? ''),
];
}
function current_fflogs_user(bool $forceRefresh = false): ?array {
if (!$forceRefresh && !empty($_SESSION['fflogs_user']) && is_array($_SESSION['fflogs_user'])) {
return $_SESSION['fflogs_user'];
}
if (empty($_SESSION['access_token']) || (($_SESSION['token_expires'] ?? 0) <= time())) {
return null;
}
$user = fetch_current_fflogs_user($_SESSION['access_token']);
if ($user !== null) {
$_SESSION['fflogs_user'] = $user;
unset($_SESSION['fflogs_user_error']);
}
return $user;
}
function is_admin_user(): bool {
$user = current_fflogs_user();
return $user !== null && in_array((string)$user['id'], ADMIN_USER_IDS, true);
}

View File

@ -143,6 +143,10 @@ select option { background: var(--bg2); }
.badge-gold { background: var(--goldbg); border-color: rgba(200,168,75,.4); color: var(--gold); }
.badge-planned { opacity: 0.55; border-style: dashed; }
.dmg-type-badge { font-size: 11px; font-weight: 600; padding: 1px 5px; border-radius: 3px; letter-spacing: .03em; }
.dmg-type--phys { background: rgba(230,140,60,.15); color: var(--orange); border: 1px solid rgba(230,140,60,.35); }
.dmg-type--mag { background: rgba(74,158,255,.12); color: var(--blue); border: 1px solid rgba(74,158,255,.3); }
/* ── DR Bar ─────────────────────────────────────────────────────────────────── */
.dr-bar-wrap {
background: var(--bg2);

View File

@ -100,12 +100,29 @@
/* ── Topbar user badge ───────────────────────────────────────────────────────── */
.topbar-user {
margin-left: auto;
font-size: 13px;
color: var(--t2);
white-space: nowrap;
}
.topbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.topbar-link {
color: var(--gold);
font-size: 13px;
text-decoration: none;
border: 1px solid var(--borderem);
border-radius: var(--r);
padding: 5px 10px;
background: var(--bg2);
}
.topbar-link:hover { background: var(--bg3); }
/* ── Login Overlay ───────────────────────────────────────────────────────────── */
#login-overlay {
position: fixed;

View File

@ -276,6 +276,9 @@
}
.mechanic-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 15px;
color: var(--t1);
font-weight: 500;

View File

@ -1,5 +1,12 @@
(function () {
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE, abilityTypeIsPhysical, abilityTypeIsMagical } = window.FF14_DATA;
function dmgTypeBadge(abilityType) {
if (abilityType == null) return '';
if (abilityTypeIsPhysical(abilityType)) return '<span class="dmg-type-badge dmg-type--phys">Phys</span>';
if (abilityTypeIsMagical(abilityType)) return '<span class="dmg-type-badge dmg-type--mag">Mag</span>';
return '';
}
// Deduplicated list of all mitigations across all targets of a ref event
function collectRefMitigs(refEvent) {
@ -758,6 +765,7 @@
<div>
<div class="aoe-ability">
${ev.abilityName}
${dmgTypeBadge(ev.abilityType)}
<span class="aoe-total"> ${fmtDmg(ev.totalDamage)} total</span>
${debuffIconsHtml}
</div>

View File

@ -749,7 +749,7 @@ function renderMechanicListHtml(plan) {
<div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div>
<div class="mechanic-body">
${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''}
<div class="mechanic-name">${escHtml(m.name)}</div>
<div class="mechanic-name">${escHtml(m.name)}${dmgTypeBadge(m.abilityType)}</div>
${m.unmitigatedDamage
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert${avgHp ? ` <span class="mechanic-avg-hp">∅ ${fmtNumber(avgHp)} HP</span>` : ''}</div>`
: ''
@ -2338,7 +2338,16 @@ const {
MELEE_JOBS,
MITIG_ICONS,
TANK_JOBS,
abilityTypeIsPhysical,
abilityTypeIsMagical,
} = window.FF14_DATA;
function dmgTypeBadge(abilityType) {
if (abilityType == null) return '';
if (abilityTypeIsPhysical(abilityType)) return '<span class="dmg-type-badge dmg-type--phys">Phys</span>';
if (abilityTypeIsMagical(abilityType)) return '<span class="dmg-type-badge dmg-type--mag">Mag</span>';
return '';
}
// Groups of abilities that are functionally equivalent across different jobs.
// Used to suggest replacements when a job is missing from the composition.
const ABILITY_EQUIVALENTS = [
@ -2442,6 +2451,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
id: crypto.randomUUID(),
name: ev.abilityName,
abilityId: ev.abilityId,
abilityType: ev.abilityType ?? null,
timestamp: relTs,
phase: phase?.name ?? '',
unmitigatedDamage: avgUnmit,

View File

@ -5,5 +5,16 @@
<button class="tab" data-tab="analysis"> Analyse</button>
<button class="tab" data-tab="planner"> Planer</button>
</nav>
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
<?php $fflogsUser = current_fflogs_user(); ?>
<div class="topbar-actions">
<?php if (is_admin_user()): ?>
<a class="topbar-link" href="admin.php">Admin</a>
<?php endif; ?>
<div class="topbar-user">
<?php if ($fflogsUser): ?>
<?= htmlspecialchars($fflogsUser['name'] ?: 'FFLogs User') ?> #<?= htmlspecialchars($fflogsUser['id']) ?> ·
<?php endif; ?>
Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?>
</div>
</div>
</header>