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,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; ?>
|
||||
Reference in New Issue
Block a user