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:
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var App\Security\Csrf $csrf
|
||||
* @var string $username
|
||||
* @var string|null $error
|
||||
*/
|
||||
|
||||
?>
|
||||
<div class="card card--narrow">
|
||||
<div class="card__head">
|
||||
<span class="brand__mark brand__mark--lg" aria-hidden="true"></span>
|
||||
<h1>Anmelden</h1>
|
||||
<p class="muted">Die Verwaltung der Kurzlinks ist nur für angemeldete Benutzer zugänglich.</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error !== null): ?>
|
||||
<div class="alert alert--error" role="alert"><?= $e($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?= $e($url('/login')) ?>" class="form">
|
||||
<?= $csrf->field() ?>
|
||||
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" value="<?= $e($username) ?>"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn--primary btn--block">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array{links:int,clicks:int,active:int} $summary
|
||||
* @var list<array<string,mixed>> $recentLinks
|
||||
* @var list<array<string,mixed>> $topLinks
|
||||
* @var list<array{date:string,clicks:int}> $series
|
||||
* @var int $domainCount
|
||||
* @var App\Config $config
|
||||
*/
|
||||
|
||||
use App\Repository\LinkRepository;
|
||||
use App\Support\ShortDomain;
|
||||
|
||||
$fallbackBase = rtrim($config->baseUrl(), '/');
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Übersicht</h1>
|
||||
<p class="muted">Alle Kurzlinks und Klicks auf einen Blick.</p>
|
||||
</div>
|
||||
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Neuer Kurzlink</a>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat__label">Kurzlinks</span>
|
||||
<span class="stat__value"><?= (int) $summary['links'] ?></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">davon aktiv</span>
|
||||
<span class="stat__value"><?= (int) $summary['active'] ?></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Klicks gesamt</span>
|
||||
<span class="stat__value"><?= number_format((int) $summary['clicks'], 0, ',', '.') ?></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Klicks (30 Tage)</span>
|
||||
<span class="stat__value"><?= number_format(array_sum(array_column($series, 'clicks')), 0, ',', '.') ?></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Domains</span>
|
||||
<span class="stat__value">
|
||||
<a href="<?= $e($url('/admin/domains')) ?>"><?= (int) $domainCount ?></a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Klicks der letzten 30 Tage</h2>
|
||||
<div class="chart" data-chart="line" data-series="<?= $e(json_encode($series, JSON_UNESCAPED_SLASHES)) ?>">
|
||||
<canvas height="120" 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>Zuletzt angelegt</h2>
|
||||
<?php if ($recentLinks === []): ?>
|
||||
<p class="muted">Noch keine Kurzlinks vorhanden.
|
||||
<a href="<?= $e($url('/admin/links/new')) ?>">Jetzt den ersten anlegen.</a></p>
|
||||
<?php else: ?>
|
||||
<ul class="list">
|
||||
<?php foreach ($recentLinks as $link): ?>
|
||||
<li class="list__item">
|
||||
<div class="list__main">
|
||||
<a class="mono" href="<?= $e($url('/admin/links/' . $link['id'] . '/stats')) ?>"><?php
|
||||
if ($domainCount > 1 && $link['domain_base_url'] !== null): ?><span class="linkcopy__domain"><?=
|
||||
$e(ShortDomain::displayName((string) $link['domain_base_url'])) ?></span><?php
|
||||
endif; ?>/<?= $e($link['slug']) ?></a>
|
||||
<span class="muted small truncate"><?= $e($link['target_url']) ?></span>
|
||||
</div>
|
||||
<span class="badge"><?= (int) $link['click_count'] ?> Klicks</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Meistgeklickt</h2>
|
||||
<?php if ($topLinks === [] || (int) $summary['clicks'] === 0): ?>
|
||||
<p class="muted">Noch keine Klicks erfasst.</p>
|
||||
<?php else: ?>
|
||||
<ul class="list">
|
||||
<?php foreach ($topLinks as $link): ?>
|
||||
<li class="list__item">
|
||||
<div class="list__main">
|
||||
<a class="mono" href="<?= $e($url('/admin/links/' . $link['id'] . '/stats')) ?>"><?php
|
||||
if ($domainCount > 1 && $link['domain_base_url'] !== null): ?><span class="linkcopy__domain"><?=
|
||||
$e(ShortDomain::displayName((string) $link['domain_base_url'])) ?></span><?php
|
||||
endif; ?>/<?= $e($link['slug']) ?></a>
|
||||
<span class="muted small truncate"><?= $e($link['target_url']) ?></span>
|
||||
</div>
|
||||
<span class="badge"><?= (int) $link['click_count'] ?> Klicks</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array<string,mixed>|null $domain
|
||||
* @var array{base_url:string,label:string,is_default:string} $values
|
||||
* @var array<string,string> $errors
|
||||
* @var App\Security\Csrf $csrf
|
||||
*/
|
||||
|
||||
$isEdit = $domain !== null;
|
||||
$action = $isEdit
|
||||
? $url('/admin/domains/' . (int) $domain['id'] . '/edit')
|
||||
: $url('/admin/domains/new');
|
||||
$isDefault = $isEdit && (int) $domain['is_default'] === 1;
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1><?= $isEdit ? 'Domain bearbeiten' : 'Neue Domain' ?></h1>
|
||||
<?php if ($isEdit): ?>
|
||||
<p class="muted mono"><?= $e($domain['host']) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Eine weitere Domain, unter der Kurzlinks ausgeliefert werden.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a href="<?= $e($url('/admin/domains')) ?>" class="btn btn--ghost">Zurück zur Liste</a>
|
||||
</div>
|
||||
|
||||
<div class="card card--form">
|
||||
<form method="post" action="<?= $e($action) ?>" class="form">
|
||||
<?= $csrf->field() ?>
|
||||
|
||||
<div class="field <?= isset($errors['base_url']) ? 'field--error' : '' ?>">
|
||||
<label for="base_url">Domain <span class="req">*</span></label>
|
||||
<input type="text" id="base_url" name="base_url" value="<?= $e($values['base_url']) ?>"
|
||||
placeholder="qr.example.de" required autofocus spellcheck="false" autocapitalize="off">
|
||||
<p class="field__hint">
|
||||
Genau so eintragen, wie die Domain im Browser aufgerufen wird. Ohne Schema wird
|
||||
<code>https://</code> ergänzt – solange noch kein Zertifikat eingerichtet ist,
|
||||
bitte <code>http://</code> voranstellen. Ein Unterverzeichnis ist erlaubt
|
||||
(<code>example.de/kurz</code>), ein Port ebenfalls (<code>localhost:8088</code>).
|
||||
Jede Domain darf nur einmal vorkommen.
|
||||
</p>
|
||||
<?php if (isset($errors['base_url'])): ?>
|
||||
<p class="field__error"><?= $e($errors['base_url']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="label">Bezeichnung (optional)</label>
|
||||
<input type="text" id="label" name="label" value="<?= $e($values['label']) ?>" maxlength="255"
|
||||
placeholder="z. B. QR-Codes für Print">
|
||||
<p class="field__hint">Nur zur eigenen Orientierung in der Auswahlliste.</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--check">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="is_default" value="1"
|
||||
<?= $values['is_default'] === '1' ? 'checked' : '' ?>
|
||||
<?= $isDefault ? 'disabled' : '' ?>>
|
||||
<span>Als Standarddomain verwenden</span>
|
||||
</label>
|
||||
<?php if ($isDefault): ?>
|
||||
<input type="hidden" name="is_default" value="1">
|
||||
<p class="field__hint">Dies ist bereits die Standarddomain.</p>
|
||||
<?php else: ?>
|
||||
<p class="field__hint">Die Standarddomain ist im Formular vorausgewählt und fängt Aufrufe
|
||||
über unbekannte Hostnamen ab.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($isEdit): ?>
|
||||
<div class="alert alert--warn">
|
||||
Wird der Hostname geändert, sind bereits verteilte Links und gedruckte QR-Codes
|
||||
dieser Domain nicht mehr erreichbar.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="field__hint">
|
||||
Damit die Domain auch wirklich hier ankommt, sind noch Einstellungen bei Domain-Anbieter
|
||||
und Hoster nötig – die
|
||||
<a href="<?= $e($url('/admin/domains#anleitung')) ?>">Schritt-für-Schritt-Anleitung</a>
|
||||
steht auf der Domain-Übersicht.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form__actions">
|
||||
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Änderungen speichern' : 'Domain anlegen' ?></button>
|
||||
<a href="<?= $e($url('/admin/domains')) ?>" class="btn btn--ghost">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var list<array<string,mixed>> $domains
|
||||
* @var string $requestHost
|
||||
* @var App\Security\Csrf $csrf
|
||||
*/
|
||||
|
||||
use App\Service\DomainChecker;
|
||||
use App\Support\ShortDomain;
|
||||
|
||||
$statusClass = static fn (string $status): string => match ($status) {
|
||||
DomainChecker::OK => 'ok',
|
||||
DomainChecker::WARNING => 'warn',
|
||||
default => 'error',
|
||||
};
|
||||
$statusSymbol = static fn (string $status): string => match ($status) {
|
||||
DomainChecker::OK => '✓',
|
||||
DomainChecker::WARNING => '!',
|
||||
default => '×',
|
||||
};
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Domains</h1>
|
||||
<p class="muted">Unter diesen Domains werden Kurzlinks ausgeliefert. Ein Slug muss nur
|
||||
innerhalb seiner Domain eindeutig sein.</p>
|
||||
</div>
|
||||
<a href="<?= $e($url('/admin/domains/new')) ?>" class="btn btn--primary">Neue Domain</a>
|
||||
</div>
|
||||
|
||||
<?php if ($checkResult !== null):
|
||||
$result = $checkResult['result'];
|
||||
?>
|
||||
<section class="card check-result check-result--<?= $e($statusClass($result['status'])) ?>">
|
||||
<div class="check-result__head">
|
||||
<span class="check-result__icon" aria-hidden="true"><?= $e($statusSymbol($result['status'])) ?></span>
|
||||
<div>
|
||||
<h2>Prüfung für <span class="mono"><?= $e($checkResult['domain']) ?></span></h2>
|
||||
<p class="check-result__summary"><?= $e($result['summary']) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="check-list">
|
||||
<?php foreach ($result['checks'] as $check): ?>
|
||||
<li class="check-list__item check-list__item--<?= $e($statusClass($check['status'])) ?>">
|
||||
<span class="check-list__icon" aria-hidden="true"><?= $e($statusSymbol($check['status'])) ?></span>
|
||||
<span class="check-list__label"><?= $e($check['label']) ?></span>
|
||||
<span class="check-list__text"><?= $e($check['text']) ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
<?php if ($result['hint'] !== null): ?>
|
||||
<p class="muted small"><?= $e($result['hint']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($result['status'] !== DomainChecker::OK): ?>
|
||||
<p class="small"><a href="#anleitung">Zur Schritt-für-Schritt-Anleitung</a></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Domain</th>
|
||||
<th scope="col">Bezeichnung</th>
|
||||
<th scope="col" class="num">Kurzlinks</th>
|
||||
<th scope="col"><span class="sr-only">Aktionen</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($domains as $domain):
|
||||
$isDefault = (int) $domain['is_default'] === 1;
|
||||
$isCurrent = $requestHost !== '' && $requestHost === (string) $domain['host'];
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cell-stack">
|
||||
<span class="mono"><?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?></span>
|
||||
<span class="badges">
|
||||
<?php if ($isDefault): ?>
|
||||
<span class="badge">Standard</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($isCurrent): ?>
|
||||
<span class="badge badge--muted">gerade aufgerufen</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="muted small"><?= $e($domain['label'] ?? '–') ?></td>
|
||||
<td class="num">
|
||||
<a href="<?= $e($url('/admin/links?domain=' . (int) $domain['id'])) ?>">
|
||||
<?= (int) $domain['link_count'] ?>
|
||||
</a>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/check')) ?>" class="inline">
|
||||
<?= $csrf->field() ?>
|
||||
<button type="submit" class="btn btn--sm" title="DNS und Zuordnung dieser Domain prüfen">Prüfen</button>
|
||||
</form>
|
||||
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/links/new?domain=' . (int) $domain['id'])) ?>">Kurzlink</a>
|
||||
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/edit')) ?>">Bearbeiten</a>
|
||||
|
||||
<?php if (!$isDefault): ?>
|
||||
<form method="post" action="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/default')) ?>" class="inline">
|
||||
<?= $csrf->field() ?>
|
||||
<button type="submit" class="btn btn--sm btn--ghost">Als Standard</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/delete')) ?>" class="inline"
|
||||
data-confirm="Domain <?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?> wirklich löschen?">
|
||||
<?= $csrf->field() ?>
|
||||
<button type="submit" class="btn btn--sm btn--danger">Löschen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card" id="anleitung">
|
||||
<h2>Was beim Provider zu tun ist</h2>
|
||||
<p class="muted">
|
||||
Eine weitere Domain hier einzutragen genügt nicht – sie muss beim Domain-Anbieter und beim
|
||||
Hoster auf <strong>diese</strong> Installation zeigen. Vier Schritte, danach einmal prüfen.
|
||||
</p>
|
||||
|
||||
<div class="alert alert--warn">
|
||||
<strong>Wichtig:</strong> Die neue Domain darf <em>keine</em> zweite Installation bekommen und
|
||||
<em>keine</em> „Domain-Weiterleitung“ des Registrars sein. Beides funktioniert nicht: eine
|
||||
Weiterleitung ersetzt die Adresse im Browser, der Slug ginge dabei verloren. Die Domain muss
|
||||
vom <strong>selben Webroot</strong> beantwortet werden wie die bestehende Installation.
|
||||
</div>
|
||||
|
||||
<p class="muted small">
|
||||
Nach jedem Schritt lässt sich über <strong>Prüfen</strong> in der Liste oben kontrollieren,
|
||||
wie weit die Einrichtung gediehen ist – die Prüfung ruft die Domain tatsächlich auf und
|
||||
meldet, woran es noch hakt.
|
||||
</p>
|
||||
|
||||
<ol class="steps steps--numbered">
|
||||
<li>
|
||||
<h3>DNS: Domain auf den Server zeigen lassen</h3>
|
||||
<p>Beim Domain-Anbieter (dort, wo die Domain registriert ist) in der DNS- bzw.
|
||||
Zonenverwaltung einen Eintrag anlegen:</p>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead>
|
||||
<tr><th scope="col">Typ</th><th scope="col">Name</th><th scope="col">Wert</th><th scope="col">Wofür</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">A</td><td class="mono">qr</td><td class="mono">203.0.113.10</td><td class="muted small">IPv4 des Servers</td></tr>
|
||||
<tr><td class="mono">AAAA</td><td class="mono">qr</td><td class="mono">2001:db8::10</td><td class="muted small">IPv6, falls vorhanden</td></tr>
|
||||
<tr><td class="mono">CNAME</td><td class="mono">qr</td><td class="mono">short.example.de.</td><td class="muted small">Alternative zu A/AAAA – zeigt auf den vorhandenen Hostnamen</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
Entweder A (+ AAAA) <em>oder</em> CNAME – nicht beides. Die richtige IP steht im
|
||||
Hosting-Panel oder im bereits vorhandenen Eintrag der Hauptdomain. Änderungen brauchen
|
||||
je nach TTL wenige Minuten bis einige Stunden.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Wo finde ich das bei meinem Anbieter?</summary>
|
||||
<p class="muted small">
|
||||
Die Menüs heißen unterschiedlich, gemeint ist immer dasselbe:
|
||||
<strong>IONOS</strong> „Domains & SSL → Domain → DNS“ ·
|
||||
<strong>Strato</strong> „Domainverwaltung → Domain verwalten → DNS-Einstellungen“ ·
|
||||
<strong>Netcup</strong> „Domains → DNS“ ·
|
||||
<strong>Hetzner</strong> „DNS Console → Zone → Records“ ·
|
||||
<strong>Cloudflare</strong> „DNS → Records“ ·
|
||||
<strong>All-Inkl</strong> „Domains → Einstellungen → DNS“.
|
||||
Liegen Domain und Hosting beim selben Anbieter, wird der Eintrag beim Anlegen der
|
||||
Subdomain oft automatisch gesetzt – Schritt 1 entfällt dann.
|
||||
</p>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>Hosting: Domain auf dasselbe Verzeichnis legen</h3>
|
||||
<p>Der Webserver muss die neue Domain beantworten und dabei denselben Webroot verwenden
|
||||
wie die bestehende Installation.</p>
|
||||
|
||||
<details open>
|
||||
<summary>Shared Hosting mit Plesk</summary>
|
||||
<p class="muted small">
|
||||
„Websites & Domains → Subdomain hinzufügen“. Plesk schlägt ein neues,
|
||||
eigenes Verzeichnis vor – dieses bei <strong>Dokumentenstamm</strong> auf das
|
||||
Verzeichnis der vorhandenen Installation ändern (dort, wo <code>index.php</code>
|
||||
liegt, in der Regel <code>…/httpdocs/public</code> bzw. <code>…/httpdocs</code>).
|
||||
Kein neues Hosting-Paket anlegen.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Shared Hosting mit cPanel</summary>
|
||||
<p class="muted small">
|
||||
„Domains → Create A New Domain“. Den Haken bei <em>„Share document root“</em>
|
||||
setzen bzw. den <strong>Document Root</strong> manuell auf das Verzeichnis der
|
||||
vorhandenen Installation zeigen lassen.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Anderes Hosting-Panel (IONOS, Strato, All-Inkl …)</summary>
|
||||
<p class="muted small">
|
||||
Gesucht ist die Funktion „Subdomain anlegen“ oder „Domain zuordnen“ mit der
|
||||
Möglichkeit, das <strong>Zielverzeichnis</strong> zu wählen. Dieses auf den
|
||||
Ordner der bestehenden Installation setzen. Manche Anbieter nennen das
|
||||
„Alias-Domain“ oder „Addon-Domain“ – geeignet ist jede Variante, bei der sich
|
||||
das Verzeichnis frei wählen lässt. <strong>Nicht</strong> geeignet ist
|
||||
„Domain-Weiterleitung“ oder „Frame-Weiterleitung“.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Eigener Server mit Apache</summary>
|
||||
<p class="muted small">Im vorhandenen VirtualHost ergänzen und Apache neu laden:</p>
|
||||
<pre class="code-block"><code><VirtualHost *:443>
|
||||
ServerName short.example.de
|
||||
ServerAlias qr.example.de
|
||||
DocumentRoot /var/www/urlshort/public
|
||||
…
|
||||
</VirtualHost>
|
||||
|
||||
sudo apachectl configtest && sudo systemctl reload apache2</code></pre>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Eigener Server mit Nginx</summary>
|
||||
<p class="muted small">Die Domain in <code>server_name</code> aufnehmen und neu laden:</p>
|
||||
<pre class="code-block"><code>server {
|
||||
server_name short.example.de qr.example.de;
|
||||
root /var/www/urlshort/public;
|
||||
…
|
||||
}
|
||||
|
||||
sudo nginx -t && sudo systemctl reload nginx</code></pre>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Docker</summary>
|
||||
<p class="muted small">
|
||||
Am Container ist nichts zu tun – er beantwortet jeden Hostnamen. Steht ein
|
||||
Reverse Proxy davor (Traefik, Nginx Proxy Manager, Caddy …), muss die neue Domain
|
||||
dort als weiterer Host auf denselben Dienst zeigen.
|
||||
</p>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>HTTPS: Zertifikat für die neue Domain</h3>
|
||||
<p class="muted small">
|
||||
Im Hosting-Panel bei der neuen (Sub-)Domain „Let’s Encrypt“ bzw. „SSL/TLS“ aktivieren –
|
||||
bei cPanel erledigt das AutoSSL meist von selbst. Auf einem eigenen Server die Domain
|
||||
in das bestehende Zertifikat aufnehmen:
|
||||
</p>
|
||||
<pre class="code-block"><code>sudo certbot --expand -d short.example.de -d qr.example.de</code></pre>
|
||||
<p class="muted small">
|
||||
Solange noch kein Zertifikat vorhanden ist, die Domain hier mit
|
||||
<code>http://</code> eintragen – sonst zeigen QR-Codes und Kurzlinks auf eine
|
||||
Adresse, die der Browser nicht öffnen kann.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>Domain hier eintragen</h3>
|
||||
<p class="muted small">
|
||||
Über <a href="<?= $e($url('/admin/domains/new')) ?>">Neue Domain</a> – exakt so, wie sie
|
||||
im Browser aufgerufen wird (gleiches Schema, gleicher Port). Erst danach ordnet die
|
||||
Anwendung Aufrufe dieser Domain zu.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h3>Prüfen</h3>
|
||||
<p class="muted small">
|
||||
In der Liste oben auf <strong>Prüfen</strong> klicken. Die Anwendung löst den
|
||||
Hostnamen auf und ruft die Domain anschließend von außen auf – kommt der Aufruf
|
||||
hier an, ist die ganze Kette in Ordnung.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Es klappt nicht – woran liegt es?</summary>
|
||||
<ul class="muted small checklist">
|
||||
<li><strong>Zeitüberschreitung oder „Server nicht gefunden“:</strong> DNS zeigt noch
|
||||
nicht auf den Server oder ist noch nicht verteilt. Prüfen mit
|
||||
<code>nslookup qr.example.de</code> – die Antwort muss die IP des Servers sein.</li>
|
||||
<li><strong>Fremde Seite, Hoster-Platzhalter oder „Website nicht gefunden“:</strong>
|
||||
Die Domain landet in einem anderen Verzeichnis. Dokumentenstamm in Schritt 2 prüfen.</li>
|
||||
<li><strong>Die Adresse im Browser ändert sich:</strong> Es ist eine Domain-Weiterleitung
|
||||
eingerichtet statt einer echten Zuordnung. Beim Anbieter umstellen.</li>
|
||||
<li><strong>404 statt Weiterleitung:</strong> Der Kurzlink hängt an einer anderen Domain.
|
||||
Slugs gelten je Domain – in der Liste oben die Domain des Links prüfen.</li>
|
||||
<li><strong>Es kommt das Ziel der Standarddomain:</strong> Die Domain ist hier noch nicht
|
||||
eingetragen oder weicht in Schreibweise, Schema oder Port ab.</li>
|
||||
<li><strong>Zertifikatswarnung:</strong> Schritt 3 fehlt noch.</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="muted small">
|
||||
Aufrufe über einen nicht eingetragenen Host (z. B. direkt über die IP-Adresse oder hinter einem
|
||||
Proxy) werden der <strong>Standarddomain</strong> zugeordnet. So bleiben bestehende Links auch
|
||||
dann erreichbar, wenn sich am Hostnamen etwas ändert.
|
||||
</p>
|
||||
</section>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Freundliche 404-Seite fuer unbekannte oder deaktivierte Kurzlinks.
|
||||
*
|
||||
* @var string $slug
|
||||
*/
|
||||
|
||||
?>
|
||||
<div class="card card--narrow center">
|
||||
<div class="bignum" aria-hidden="true">404</div>
|
||||
<h1>Dieser Kurzlink führt ins Leere</h1>
|
||||
<p class="muted">
|
||||
<?php if (($slug ?? '') !== ''): ?>
|
||||
Für <code>/<?= $e($slug) ?></code> gibt es kein Ziel – der Link wurde entfernt, deaktiviert
|
||||
oder enthält einen Tippfehler.
|
||||
<?php else: ?>
|
||||
Die aufgerufene Adresse gibt es hier nicht.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="muted small">Wenn du den Link von jemandem bekommen hast, frag am besten dort noch einmal nach.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var string|null $detail Nur im Entwicklungsmodus gefuellt.
|
||||
*/
|
||||
|
||||
?>
|
||||
<div class="card card--narrow center">
|
||||
<div class="bignum" aria-hidden="true">500</div>
|
||||
<h1>Da ist etwas schiefgelaufen</h1>
|
||||
<p class="muted">Die Anfrage konnte nicht verarbeitet werden. Details stehen im Fehlerprotokoll des Servers.</p>
|
||||
|
||||
<?php if (($detail ?? null) !== null): ?>
|
||||
<pre class="trace"><?= $e($detail) ?></pre>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var string $title
|
||||
* @var string $message
|
||||
*/
|
||||
|
||||
?>
|
||||
<div class="card card--narrow center">
|
||||
<h1><?= $e($title ?? 'Fehler') ?></h1>
|
||||
<p class="muted"><?= $e($message ?? 'Es ist ein Fehler aufgetreten.') ?></p>
|
||||
<p><a class="btn" href="<?= $e($url('/admin/links')) ?>">Zur Übersicht</a></p>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Layout des Admin-Bereichs.
|
||||
*
|
||||
* @var App\View $view
|
||||
* @var App\Config $config
|
||||
* @var App\Security\Csrf $csrf
|
||||
* @var App\Security\Auth $auth
|
||||
* @var string $content
|
||||
* @var list<array{type:string,message:string}> $flashes
|
||||
* @var string $currentPath
|
||||
*/
|
||||
|
||||
$title = $pageTitle ?? 'Verwaltung';
|
||||
$isActive = static fn (string $prefix): bool => str_starts_with($currentPath ?? '/', $prefix);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $e($title) ?> · Kurzlinks</title>
|
||||
<link rel="stylesheet" href="<?= $e($url('assets/app.css')) ?>">
|
||||
<link rel="icon" href="<?= $e($url('assets/favicon.svg')) ?>" type="image/svg+xml">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="container topbar__inner">
|
||||
<a class="brand" href="<?= $e($url('/admin')) ?>">
|
||||
<span class="brand__mark" aria-hidden="true"></span>
|
||||
<span class="brand__text">Kurzlinks</span>
|
||||
</a>
|
||||
|
||||
<nav class="mainnav" aria-label="Hauptnavigation">
|
||||
<a href="<?= $e($url('/admin')) ?>" class="<?= ($currentPath === '/admin' || $currentPath === '/') ? 'is-active' : '' ?>">Übersicht</a>
|
||||
<a href="<?= $e($url('/admin/links')) ?>" class="<?= $isActive('/admin/links') ? 'is-active' : '' ?>">Kurzlinks</a>
|
||||
<a href="<?= $e($url('/admin/domains')) ?>" class="<?= $isActive('/admin/domains') ? 'is-active' : '' ?>">Domains</a>
|
||||
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary btn--sm">Neuer Kurzlink</a>
|
||||
</nav>
|
||||
|
||||
<form class="logout" method="post" action="<?= $e($url('/logout')) ?>">
|
||||
<?= $csrf->field() ?>
|
||||
<span class="logout__user" title="Angemeldet als"><?= $e($auth->currentUser() ?? '') ?></span>
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container main">
|
||||
<?php foreach ($flashes ?? [] as $flash): ?>
|
||||
<div class="alert alert--<?= $e($flash['type']) ?>" role="status"><?= $e($flash['message']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?= $content ?>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<span>Basis-URL: <code><?= $e(rtrim($config->baseUrl(), '/')) ?></code></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="<?= $e($url('assets/app.js')) ?>" defer></script>
|
||||
<?= $pageScripts ?? '' ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Schlankes Layout ohne Navigation: Login, Setup, Fehlerseiten.
|
||||
*
|
||||
* @var string $content
|
||||
* @var list<array{type:string,message:string}> $flashes
|
||||
*/
|
||||
|
||||
$title = $pageTitle ?? 'Kurzlinks';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $e($title) ?> · Kurzlinks</title>
|
||||
<link rel="stylesheet" href="<?= $e($url('assets/app.css')) ?>">
|
||||
<link rel="icon" href="<?= $e($url('assets/favicon.svg')) ?>" type="image/svg+xml">
|
||||
</head>
|
||||
<body class="body--centered">
|
||||
<main class="centered">
|
||||
<?php foreach ($flashes ?? [] as $flash): ?>
|
||||
<div class="alert alert--<?= $e($flash['type']) ?>" role="status"><?= $e($flash['message']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?= $content ?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array<string,mixed>|null $link
|
||||
* @var list<array<string,mixed>> $domains
|
||||
* @var array{target_url:string,slug:string,title:string,is_active:string,domain_id:string} $values
|
||||
* @var array<string,string> $errors
|
||||
* @var App\Config $config
|
||||
* @var App\Security\Csrf $csrf
|
||||
*/
|
||||
|
||||
use App\Support\ShortDomain;
|
||||
|
||||
$isEdit = $link !== null;
|
||||
$action = $isEdit ? $url('/admin/links/' . $link['id'] . '/edit') : $url('/admin/links/new');
|
||||
|
||||
$selectedId = (int) $values['domain_id'];
|
||||
$selectedBase = $config->baseUrl();
|
||||
foreach ($domains as $domain) {
|
||||
if ((int) $domain['id'] === $selectedId) {
|
||||
$selectedBase = (string) $domain['base_url'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1><?= $isEdit ? 'Kurzlink bearbeiten' : 'Neuer Kurzlink' ?></h1>
|
||||
<?php if ($isEdit): ?>
|
||||
<p class="muted mono"><?= $e(rtrim($selectedBase, '/') . '/' . $link['slug']) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Ziel-URL eintragen – der Slug wird automatisch erzeugt, wenn du keinen angibst.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a href="<?= $e($url('/admin/links')) ?>" class="btn btn--ghost">Zurück zur Liste</a>
|
||||
</div>
|
||||
|
||||
<div class="card card--form">
|
||||
<form method="post" action="<?= $e($action) ?>" class="form">
|
||||
<?= $csrf->field() ?>
|
||||
|
||||
<div class="field <?= isset($errors['target_url']) ? 'field--error' : '' ?>">
|
||||
<label for="target_url">Ziel-URL <span class="req">*</span></label>
|
||||
<input type="text" id="target_url" name="target_url" value="<?= $e($values['target_url']) ?>"
|
||||
placeholder="https://www.beispiel.de/eine/lange/adresse" required autofocus
|
||||
inputmode="url" spellcheck="false">
|
||||
<p class="field__hint">
|
||||
Nur <code>http://</code> und <code>https://</code>. Fehlt das Schema, wird <code>https://</code> ergänzt.
|
||||
Umlaut-Domains (<code>müller.de</code>), Umlaute im Pfad, IPv6-Adressen sowie interne Hostnamen
|
||||
ohne Punkt (<code>intranet</code>, <code>nas:5000</code>) sind zulässig.
|
||||
</p>
|
||||
<?php if (isset($errors['target_url'])): ?>
|
||||
<p class="field__error"><?= $e($errors['target_url']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field <?= isset($errors['domain_id']) ? 'field--error' : '' ?>">
|
||||
<label for="domain_id">Kurzlink-Domain</label>
|
||||
<select id="domain_id" name="domain_id" data-domain-select>
|
||||
<?php foreach ($domains as $domain): ?>
|
||||
<option value="<?= (int) $domain['id'] ?>"
|
||||
data-base-url="<?= $e(rtrim((string) $domain['base_url'], '/')) ?>"
|
||||
<?= (int) $domain['id'] === $selectedId ? 'selected' : '' ?>>
|
||||
<?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?><?php
|
||||
if (($domain['label'] ?? '') !== ''): ?> – <?= $e($domain['label']) ?><?php endif;
|
||||
if ((int) $domain['is_default'] === 1): ?> (Standard)<?php endif; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="field__hint">
|
||||
<a href="<?= $e($url('/admin/domains')) ?>">Domains verwalten</a> – derselbe Slug kann auf
|
||||
verschiedenen Domains auf unterschiedliche Ziele zeigen.
|
||||
</p>
|
||||
<?php if (isset($errors['domain_id'])): ?>
|
||||
<p class="field__error"><?= $e($errors['domain_id']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field <?= isset($errors['slug']) ? 'field--error' : '' ?>">
|
||||
<label for="slug">Wunsch-Slug<?= $isEdit ? ' <span class="req">*</span>' : ' (optional)' ?></label>
|
||||
<div class="input-prefix">
|
||||
<span class="input-prefix__label mono" data-slug-prefix><?= $e(rtrim($selectedBase, '/')) ?>/</span>
|
||||
<input type="text" id="slug" name="slug" value="<?= $e($values['slug']) ?>"
|
||||
placeholder="<?= $isEdit ? '' : 'z. B. messe2026 (leer = zufällig)' ?>"
|
||||
pattern="[a-z0-9-]*" spellcheck="false" autocapitalize="off" autocomplete="off"
|
||||
<?= $isEdit ? 'required' : '' ?>>
|
||||
</div>
|
||||
<p class="field__hint">Erlaubt sind Kleinbuchstaben, Ziffern und Bindestriche.</p>
|
||||
<?php if (isset($errors['slug'])): ?>
|
||||
<p class="field__error"><?= $e($errors['slug']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($isEdit): ?>
|
||||
<p class="field__hint warn">Achtung: Beim Ändern von Slug oder Domain funktionieren bereits
|
||||
verteilte Links und gedruckte QR-Codes nicht mehr.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="title">Interne Bezeichnung (optional)</label>
|
||||
<input type="text" id="title" name="title" value="<?= $e($values['title']) ?>" maxlength="255"
|
||||
placeholder="z. B. Flyer Messestand Halle 3">
|
||||
<p class="field__hint">Nur zur eigenen Orientierung in der Liste – wird nirgends öffentlich angezeigt.</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--check">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="is_active" value="1" <?= $values['is_active'] === '1' ? 'checked' : '' ?>>
|
||||
<span>Kurzlink ist aktiv</span>
|
||||
</label>
|
||||
<p class="field__hint">Deaktivierte Links liefern eine 404-Seite aus, bleiben aber samt Statistik erhalten.</p>
|
||||
</div>
|
||||
|
||||
<div class="form__actions">
|
||||
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Änderungen speichern' : 'Kurzlink anlegen' ?></button>
|
||||
<a href="<?= $e($url('/admin/links')) ?>" class="btn btn--ghost">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var list<array<string,mixed>> $links
|
||||
* @var int $total
|
||||
* @var string $search
|
||||
* @var string $sort
|
||||
* @var list<array<string,mixed>> $domains
|
||||
* @var int|null $domainFilter
|
||||
* @var App\Config $config
|
||||
* @var App\Security\Csrf $csrf
|
||||
*/
|
||||
|
||||
use App\Repository\LinkRepository;
|
||||
use App\Support\ShortDomain;
|
||||
|
||||
$fallbackBase = rtrim($config->baseUrl(), '/');
|
||||
$multiDomain = count($domains) > 1;
|
||||
|
||||
$sortOptions = [
|
||||
'created_desc' => 'Neueste zuerst',
|
||||
'created_asc' => 'Älteste zuerst',
|
||||
'clicks_desc' => 'Meiste Klicks',
|
||||
'clicks_asc' => 'Wenigste Klicks',
|
||||
'slug_asc' => 'Slug (A–Z)',
|
||||
];
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Kurzlinks</h1>
|
||||
<p class="muted"><?= (int) $total ?> Eintrag/Einträge<?= $search !== '' ? ' für „' . $e($search) . '“' : '' ?>.</p>
|
||||
</div>
|
||||
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Neuer Kurzlink</a>
|
||||
</div>
|
||||
|
||||
<form class="toolbar" method="get" action="<?= $e($url('/admin/links')) ?>">
|
||||
<input type="search" name="q" value="<?= $e($search) ?>" placeholder="Slug, Titel oder Ziel-URL suchen…"
|
||||
aria-label="Suche">
|
||||
|
||||
<?php if ($multiDomain): ?>
|
||||
<select name="domain" aria-label="Domain">
|
||||
<option value="0">Alle Domains</option>
|
||||
<?php foreach ($domains as $domain): ?>
|
||||
<option value="<?= (int) $domain['id'] ?>" <?= $domainFilter === (int) $domain['id'] ? 'selected' : '' ?>>
|
||||
<?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
|
||||
<select name="sort" aria-label="Sortierung">
|
||||
<?php foreach ($sortOptions as $value => $label): ?>
|
||||
<option value="<?= $e($value) ?>" <?= $sort === $value ? 'selected' : '' ?>><?= $e($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="submit" class="btn">Anwenden</button>
|
||||
<?php if ($search !== '' || $sort !== 'created_desc' || $domainFilter !== null): ?>
|
||||
<a class="btn btn--ghost" href="<?= $e($url('/admin/links')) ?>">Zurücksetzen</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<?php if ($links === []): ?>
|
||||
<div class="card empty">
|
||||
<h2>Noch nichts hier</h2>
|
||||
<p class="muted">Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.</p>
|
||||
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Kurzlink anlegen</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card card--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Kurzlink</th>
|
||||
<th scope="col">Ziel</th>
|
||||
<th scope="col" class="num">Klicks</th>
|
||||
<th scope="col">Erstellt</th>
|
||||
<th scope="col"><span class="sr-only">Aktionen</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($links as $link):
|
||||
$shortUrl = LinkRepository::shortUrlOf($link, $fallbackBase);
|
||||
$active = (int) $link['is_active'] === 1;
|
||||
$domainName = $link['domain_base_url'] !== null
|
||||
? ShortDomain::displayName((string) $link['domain_base_url'])
|
||||
: '';
|
||||
?>
|
||||
<tr class="<?= $active ? '' : 'is-inactive' ?>">
|
||||
<td>
|
||||
<div class="cell-stack">
|
||||
<button type="button" class="linkcopy mono" data-copy="<?= $e($shortUrl) ?>"
|
||||
title="In die Zwischenablage kopieren"><?php
|
||||
if ($multiDomain && $domainName !== ''): ?><span class="linkcopy__domain"><?= $e($domainName) ?></span><?php
|
||||
endif; ?>/<?= $e($link['slug']) ?></button>
|
||||
<?php if (($link['title'] ?? '') !== ''): ?>
|
||||
<span class="muted small"><?= $e($link['title']) ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if (!$active): ?>
|
||||
<span class="badge badge--muted">deaktiviert</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="truncate-cell">
|
||||
<a href="<?= $e($link['target_url']) ?>" rel="noopener noreferrer nofollow" target="_blank"
|
||||
class="muted small"><?= $e($link['target_url']) ?></a>
|
||||
</td>
|
||||
<td class="num">
|
||||
<a href="<?= $e($url('/admin/links/' . $link['id'] . '/stats')) ?>">
|
||||
<?= number_format((int) $link['click_count'], 0, ',', '.') ?>
|
||||
</a>
|
||||
</td>
|
||||
<td class="muted small nowrap">
|
||||
<?= $e(date('d.m.Y', strtotime((string) $link['created_at'] . ' UTC') ?: time())) ?>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a class="btn btn--sm" href="<?= $e($url('/admin/links/' . $link['id'] . '/qr')) ?>">QR-Code</a>
|
||||
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/links/' . $link['id'] . '/stats')) ?>">Statistik</a>
|
||||
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/links/' . $link['id'] . '/edit')) ?>">Bearbeiten</a>
|
||||
|
||||
<form method="post" action="<?= $e($url('/admin/links/' . $link['id'] . '/toggle')) ?>" class="inline">
|
||||
<?= $csrf->field() ?>
|
||||
<button type="submit" class="btn btn--sm btn--ghost">
|
||||
<?= $active ? 'Deaktivieren' : 'Aktivieren' ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="<?= $e($url('/admin/links/' . $link['id'] . '/delete')) ?>" class="inline"
|
||||
data-confirm="Kurzlink <?= $e($shortUrl) ?> wirklich löschen? Die Statistik geht dabei verloren.">
|
||||
<?= $csrf->field() ?>
|
||||
<button type="submit" class="btn btn--sm btn--danger">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var array<string,mixed> $link
|
||||
* @var string $shortUrl
|
||||
* @var App\Qr\QrOptions $options
|
||||
* @var list<int> $sizes
|
||||
* @var list<string> $levels
|
||||
* @var string|null $warning
|
||||
* @var bool $logoIsSvg
|
||||
* @var bool $imagickAvailable
|
||||
* @var App\Security\Csrf $csrf
|
||||
*/
|
||||
|
||||
$id = (int) $link['id'];
|
||||
|
||||
$levelLabels = [
|
||||
'L' => 'L – niedrig (7 %)',
|
||||
'M' => 'M – mittel (15 %)',
|
||||
'Q' => 'Q – hoch (25 %)',
|
||||
'H' => 'H – sehr hoch (30 %)',
|
||||
];
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>QR-Code</h1>
|
||||
<p class="muted">
|
||||
<button type="button" class="linkcopy mono" data-copy="<?= $e($shortUrl) ?>"
|
||||
title="In die Zwischenablage kopieren"><?= $e($shortUrl) ?></button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn btn--ghost" href="<?= $e($url('/admin/links/' . $id . '/stats')) ?>">Statistik</a>
|
||||
<a class="btn btn--ghost" href="<?= $e($url('/admin/links')) ?>">Zur Liste</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-layout"
|
||||
data-qr
|
||||
data-preview-url="<?= $e($url('/admin/links/' . $id . '/qr/preview')) ?>"
|
||||
data-download-url="<?= $e($url('/admin/links/' . $id . '/qr/download')) ?>">
|
||||
|
||||
<section class="card qr-preview">
|
||||
<div class="qr-preview__stage" style="--qr-bg: <?= $e($options->background) ?>">
|
||||
<img id="qr-image" alt="Vorschau des QR-Codes"
|
||||
src="<?= $e($url('/admin/links/' . $id . '/qr/preview?' . http_build_query($options->toQueryParams()))) ?>">
|
||||
<div class="qr-preview__spinner" data-qr-spinner hidden aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<p class="muted small center">Der QR-Code verweist auf den Kurzlink – die Ziel-URL kannst du also
|
||||
später ändern, ohne den Code neu drucken zu müssen.</p>
|
||||
|
||||
<div class="qr-preview__warning" data-qr-warning <?= $warning === null ? 'hidden' : '' ?>>
|
||||
<div class="alert alert--warn" data-qr-warning-text><?= $e($warning ?? '') ?></div>
|
||||
</div>
|
||||
|
||||
<div class="qr-download">
|
||||
<a class="btn btn--primary" data-qr-download="png" href="#" download>PNG herunterladen</a>
|
||||
<a class="btn" data-qr-download="svg" href="#" download>SVG herunterladen</a>
|
||||
</div>
|
||||
<p class="muted small center">SVG ist vektorbasiert und damit für den Druck in jeder Größe geeignet.</p>
|
||||
</section>
|
||||
|
||||
<div class="qr-controls">
|
||||
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr')) ?>" class="card form" id="qr-form">
|
||||
<?= $csrf->field() ?>
|
||||
<h2>Gestaltung</h2>
|
||||
|
||||
<div class="grid grid--2 tight">
|
||||
<div class="field">
|
||||
<label for="foreground">Vordergrund</label>
|
||||
<div class="colorpair">
|
||||
<input type="color" id="foreground" name="foreground" value="<?= $e($options->foreground) ?>"
|
||||
data-qr-input data-color-sync="foreground_text">
|
||||
<input type="text" id="foreground_text" value="<?= $e($options->foreground) ?>"
|
||||
class="mono" maxlength="7" spellcheck="false" aria-label="Vordergrundfarbe als Hex-Wert"
|
||||
data-color-sync="foreground">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="background">Hintergrund</label>
|
||||
<div class="colorpair">
|
||||
<input type="color" id="background" name="background" value="<?= $e($options->background) ?>"
|
||||
data-qr-input data-color-sync="background_text">
|
||||
<input type="text" id="background_text" value="<?= $e($options->background) ?>"
|
||||
class="mono" maxlength="7" spellcheck="false" aria-label="Hintergrundfarbe als Hex-Wert"
|
||||
data-color-sync="background">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Voreinstellungen</span>
|
||||
<div class="swatches">
|
||||
<button type="button" class="swatch" data-preset="#000000,#ffffff"
|
||||
style="--fg:#000000;--bg:#ffffff" title="Schwarz auf Weiß"></button>
|
||||
<button type="button" class="swatch" data-preset="#1f2937,#f9fafb"
|
||||
style="--fg:#1f2937;--bg:#f9fafb" title="Anthrazit"></button>
|
||||
<button type="button" class="swatch" data-preset="#0b4f9e,#ffffff"
|
||||
style="--fg:#0b4f9e;--bg:#ffffff" title="Blau"></button>
|
||||
<button type="button" class="swatch" data-preset="#14532d,#ffffff"
|
||||
style="--fg:#14532d;--bg:#ffffff" title="Grün"></button>
|
||||
<button type="button" class="swatch" data-preset="#7c2d12,#fff7ed"
|
||||
style="--fg:#7c2d12;--bg:#fff7ed" title="Warmbraun"></button>
|
||||
<button type="button" class="swatch" data-preset="#4c1d95,#f5f3ff"
|
||||
style="--fg:#4c1d95;--bg:#f5f3ff" title="Violett"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2 tight">
|
||||
<div class="field">
|
||||
<label for="size">Größe</label>
|
||||
<select id="size" name="size" data-qr-input>
|
||||
<?php foreach ($sizes as $size): ?>
|
||||
<option value="<?= (int) $size ?>" <?= $options->size === $size ? 'selected' : '' ?>>
|
||||
<?= (int) $size ?> × <?= (int) $size ?> px
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="field__hint">Gilt für den PNG-Download; SVG skaliert verlustfrei.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="error_correction">Fehlerkorrektur</label>
|
||||
<select id="error_correction" name="error_correction" data-qr-input
|
||||
<?= $options->hasLogo() ? 'disabled' : '' ?>>
|
||||
<?php foreach ($levels as $level): ?>
|
||||
<option value="<?= $e($level) ?>" <?= $options->errorCorrection === $level ? 'selected' : '' ?>>
|
||||
<?= $e($levelLabels[$level] ?? $level) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($options->hasLogo()): ?>
|
||||
<input type="hidden" name="error_correction" value="H">
|
||||
<p class="field__hint">Wegen des Logos fest auf <strong>H</strong> gesetzt.</p>
|
||||
<?php else: ?>
|
||||
<p class="field__hint">Höher = robuster gegen Beschädigung, aber dichteres Muster.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="margin">Randbreite (Quiet Zone): <output id="margin_out"><?= (int) $options->margin ?></output> Module</label>
|
||||
<input type="range" id="margin" name="margin" min="0" max="10" step="1"
|
||||
value="<?= (int) $options->margin ?>" data-qr-input data-output="margin_out">
|
||||
<p class="field__hint">Die Spezifikation empfiehlt mindestens 4 Module ruhige Fläche rundherum.</p>
|
||||
</div>
|
||||
|
||||
<?php if ($options->hasLogo()): ?>
|
||||
<div class="field">
|
||||
<label for="logo_scale">Logogröße: <output id="logo_scale_out"><?= (int) $options->logoScale ?></output> % der Breite</label>
|
||||
<input type="range" id="logo_scale" name="logo_scale" min="10" max="30" step="1"
|
||||
value="<?= (int) $options->logoScale ?>" data-qr-input data-output="logo_scale_out">
|
||||
</div>
|
||||
|
||||
<div class="field field--check">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="logo_punchout" value="1" data-qr-input
|
||||
<?= $options->logoPunchout ? 'checked' : '' ?>>
|
||||
<span>Fläche hinter dem Logo freistellen</span>
|
||||
</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form__actions">
|
||||
<button type="submit" class="btn btn--primary">Einstellungen speichern</button>
|
||||
<button type="reset" class="btn btn--ghost" data-qr-reset>Zurücksetzen</button>
|
||||
</div>
|
||||
<p class="field__hint">Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte.</p>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<h2>Logo in der Mitte</h2>
|
||||
|
||||
<?php if ($options->hasLogo()): ?>
|
||||
<div class="logo-current">
|
||||
<img src="<?= $e($url('/admin/uploads/' . rawurlencode((string) $options->logoPath))) ?>"
|
||||
alt="Aktuelles Logo" class="logo-current__img">
|
||||
<div class="logo-current__meta">
|
||||
<span class="mono small"><?= $e($options->logoPath) ?></span>
|
||||
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo/delete')) ?>"
|
||||
data-confirm="Logo wirklich entfernen?">
|
||||
<?= $csrf->field() ?>
|
||||
<button type="submit" class="btn btn--sm btn--danger">Logo entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($logoIsSvg && !$imagickAvailable): ?>
|
||||
<div class="alert alert--warn">
|
||||
Dieses SVG-Logo kann nur im <strong>SVG-Download</strong> eingebettet werden. Für PNG-Ausgaben
|
||||
wird die PHP-Erweiterung <code>imagick</code> benötigt – alternativ ein PNG-Logo hochladen.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo')) ?>"
|
||||
enctype="multipart/form-data" class="form">
|
||||
<?= $csrf->field() ?>
|
||||
<div class="field">
|
||||
<label for="logo"><?= $options->hasLogo() ? 'Anderes Logo hochladen' : 'Logo hochladen' ?></label>
|
||||
<input type="file" id="logo" name="logo" accept="image/png,image/jpeg,image/svg+xml" required>
|
||||
<p class="field__hint">PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert;
|
||||
die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn">Hochladen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var App\Security\Csrf $csrf
|
||||
* @var string $username
|
||||
* @var array<string,string> $errors
|
||||
* @var int $minLength
|
||||
* @var string $tokenPath
|
||||
*/
|
||||
|
||||
?>
|
||||
<div class="card card--narrow">
|
||||
<div class="card__head">
|
||||
<span class="brand__mark brand__mark--lg" aria-hidden="true"></span>
|
||||
<h1>Ersteinrichtung</h1>
|
||||
<p class="muted">
|
||||
Es ist noch kein Administrator-Konto vorhanden. Lege jetzt Benutzername und Passwort fest –
|
||||
danach ist diese Seite gesperrt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= $e($url('/setup')) ?>" class="form">
|
||||
<?= $csrf->field() ?>
|
||||
|
||||
<div class="field <?= isset($errors['token']) ? 'field--error' : '' ?>">
|
||||
<label for="setup_token">Einrichtungs-Token <span class="req">*</span></label>
|
||||
<input type="text" id="setup_token" name="setup_token" required autofocus
|
||||
spellcheck="false" autocapitalize="characters" autocomplete="off"
|
||||
class="mono" placeholder="z. B. 3F9A1C0E7B2D4856">
|
||||
<p class="field__hint">
|
||||
Damit niemand anderes diese Installation übernimmt, steht das Token auf dem Server:
|
||||
</p>
|
||||
<details>
|
||||
<summary>Wo finde ich das Token?</summary>
|
||||
<ul class="checklist small muted">
|
||||
<li><strong>Per FTP/SFTP oder Dateimanager:</strong> in der Datei
|
||||
<code><?= $e($tokenPath) ?></code></li>
|
||||
<li><strong>Docker:</strong> <code>docker compose logs app | grep Einrichtungs-Token</code>
|
||||
oder <code>docker compose exec app cat data/setup-token.txt</code></li>
|
||||
<li><strong>Mit Shell-Zugang:</strong> <code>cat <?= $e($tokenPath) ?></code></li>
|
||||
</ul>
|
||||
<p class="muted small">
|
||||
Nach abgeschlossener Einrichtung wird die Datei automatisch gelöscht.
|
||||
</p>
|
||||
</details>
|
||||
<?php if (isset($errors['token'])): ?>
|
||||
<p class="field__error"><?= $e($errors['token']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field <?= isset($errors['username']) ? 'field--error' : '' ?>">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" value="<?= $e($username) ?>"
|
||||
autocomplete="username" required>
|
||||
<?php if (isset($errors['username'])): ?>
|
||||
<p class="field__error"><?= $e($errors['username']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field <?= isset($errors['password']) ? 'field--error' : '' ?>">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password" required
|
||||
minlength="<?= (int) $minLength ?>">
|
||||
<p class="field__hint">Mindestens <?= (int) $minLength ?> Zeichen.</p>
|
||||
<?php if (isset($errors['password'])): ?>
|
||||
<p class="field__error"><?= $e($errors['password']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="field <?= isset($errors['password_confirm']) ? 'field--error' : '' ?>">
|
||||
<label for="password_confirm">Passwort wiederholen</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" autocomplete="new-password" required>
|
||||
<?php if (isset($errors['password_confirm'])): ?>
|
||||
<p class="field__error"><?= $e($errors['password_confirm']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn--primary btn--block">Konto anlegen und anmelden</button>
|
||||
</form>
|
||||
|
||||
<p class="muted small">
|
||||
Alternativ kann das Konto auch fest in der <code>.env</code> hinterlegt werden
|
||||
(<code>ADMIN_USER</code> und <code>ADMIN_PASSWORD_HASH</code>). Details stehen in der README.
|
||||
</p>
|
||||
</div>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user