feat(ui): Serverseitig gerenderte Oberflaeche ohne Build-Schritt

Eigenes CSS statt Framework, Vanilla-JavaScript, heller und dunkler
Modus. Ohne JavaScript bleibt alles bedienbar; das Liniendiagramm
faellt auf eine selbst gezeichnete Balkendarstellung zurueck, wenn das
CDN nicht erreichbar ist.

Chart.js ist auf eine Version festgelegt und per Subresource Integrity
an seinen Hash gebunden.

Die Domain-Seite enthaelt eine Schritt-fuer-Schritt-Anleitung fuer DNS,
Hosting-Panel und Zertifikat samt Fehlersuche.
This commit is contained in:
2026-08-03 18:11:40 +02:00
parent 7238e7a937
commit fabaef1f2b
19 changed files with 2378 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* @var array<string,mixed> $link
* @var string $shortUrl
* @var int $days
* @var int $totalVisits
* @var list<array{date:string,clicks:int}> $series
* @var list<array{label:string,clicks:int}> $referrers
* @var list<array{label:string,clicks:int}> $devices
* @var list<array{label:string,clicks:int}> $browsers
* @var list<array{label:string,clicks:int}> $platforms
* @var list<array<string,mixed>> $recent
*/
$id = (int) $link['id'];
$rangeLabels = [7 => '7 Tage', 30 => '30 Tage', 90 => '90 Tage', 365 => '1 Jahr'];
/** Balkenliste rendern. */
$bars = static function (array $rows) use ($e): string {
if ($rows === []) {
return '<p class="muted">Noch keine Daten.</p>';
}
$max = max(array_map(static fn (array $row): int => $row['clicks'], $rows)) ?: 1;
$html = '<ul class="bars">';
foreach ($rows as $row) {
$html .= sprintf(
'<li class="bars__row"><span class="bars__label" title="%1$s">%1$s</span>'
. '<span class="bars__track"><span class="bars__fill" style="width:%2$.1f%%"></span></span>'
. '<span class="bars__value">%3$d</span></li>',
$e($row['label']),
$row['clicks'] / $max * 100,
$row['clicks'],
);
}
return $html . '</ul>';
};
?>
<div class="page-head">
<div>
<h1>Statistik</h1>
<p class="muted">
<button type="button" class="linkcopy mono" data-copy="<?= $e($shortUrl) ?>"
title="In die Zwischenablage kopieren"><?= $e($shortUrl) ?></button>
→
<a href="<?= $e($link['target_url']) ?>" target="_blank" rel="noopener noreferrer nofollow"
class="small"><?= $e(mb_strimwidth((string) $link['target_url'], 0, 70, '…')) ?></a>
</p>
</div>
<div class="page-head__actions">
<a class="btn" href="<?= $e($url('/admin/links/' . $id . '/qr')) ?>">QR-Code</a>
<a class="btn btn--ghost" href="<?= $e($url('/admin/links/' . $id . '/edit')) ?>">Bearbeiten</a>
<a class="btn btn--ghost" href="<?= $e($url('/admin/links')) ?>">Zur Liste</a>
</div>
</div>
<div class="stats-grid">
<div class="stat">
<span class="stat__label">Klicks gesamt</span>
<span class="stat__value"><?= number_format((int) $link['click_count'], 0, ',', '.') ?></span>
</div>
<div class="stat">
<span class="stat__label">Im Zeitraum</span>
<span class="stat__value"><?= number_format(array_sum(array_column($series, 'clicks')), 0, ',', '.') ?></span>
</div>
<div class="stat">
<span class="stat__label">Erstellt</span>
<span class="stat__value stat__value--sm">
<?= $e(date('d.m.Y', strtotime((string) $link['created_at'] . ' UTC') ?: time())) ?>
</span>
</div>
<div class="stat">
<span class="stat__label">Letzter Klick</span>
<span class="stat__value stat__value--sm">
<?= $link['last_clicked_at'] !== null
? $e(date('d.m.Y H:i', strtotime((string) $link['last_clicked_at'] . ' UTC') ?: time()))
: '–' ?>
</span>
</div>
</div>
<section class="card">
<div class="card__head-row">
<h2>Klicks im Zeitverlauf</h2>
<div class="segmented" role="group" aria-label="Zeitraum">
<?php foreach ($rangeLabels as $value => $label): ?>
<a href="<?= $e($url('/admin/links/' . $id . '/stats?days=' . $value)) ?>"
class="<?= $days === $value ? 'is-active' : '' ?>"><?= $e($label) ?></a>
<?php endforeach; ?>
</div>
</div>
<div class="chart" data-chart="line" data-series="<?= $e(json_encode($series, JSON_UNESCAPED_SLASHES)) ?>">
<canvas height="110" aria-label="Liniendiagramm der Klicks pro Tag"></canvas>
<noscript><p class="muted">Für das Diagramm wird JavaScript benötigt.</p></noscript>
</div>
</section>
<div class="grid grid--2">
<section class="card">
<h2>Top-Referrer</h2>
<?= $bars($referrers) ?>
</section>
<section class="card">
<h2>Gerätetyp</h2>
<?= $bars($devices) ?>
</section>
<section class="card">
<h2>Browser</h2>
<?= $bars($browsers) ?>
</section>
<section class="card">
<h2>Plattform</h2>
<?= $bars($platforms) ?>
</section>
</div>
<section class="card">
<h2>Letzte Aufrufe</h2>
<?php if ($recent === []): ?>
<p class="muted">Noch keine Aufrufe erfasst.</p>
<?php else: ?>
<div class="table-wrap">
<table class="table table--compact">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Referrer</th>
<th scope="col">Gerät</th>
<th scope="col">Browser</th>
<th scope="col">Plattform</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent as $visit): ?>
<tr>
<td class="nowrap"><?= $e(date('d.m.Y H:i:s', strtotime((string) $visit['visited_at'] . ' UTC') ?: time())) ?></td>
<td class="muted"><?= $e($visit['referrer_host'] ?? 'direkt') ?></td>
<td><?= $e(App\Support\UserAgentParser::deviceLabel((string) $visit['device_type'])) ?></td>
<td><?= $e($visit['browser']) ?></td>
<td><?= $e($visit['platform']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<p class="muted small">
Es werden weder IP-Adressen noch personenbezogene Merkmale gespeichert – nur Zeitpunkt, Referrer
und die aus dem User-Agent abgeleiteten Kategorien.
</p>
</section>