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
+59
View File
@@ -0,0 +1,59 @@
# =============================================================
# urlshort-qr-gen – Apache-Konfiguration fuer den Webroot
# =============================================================
<IfModule mod_rewrite.c>
RewriteEngine On
# Falls die Anwendung in einem Unterverzeichnis liegt, ermittelt
# Apache die Basis in der Regel selbst. Bei Problemen hier setzen:
# RewriteBase /
# Vorhandene Dateien und Verzeichnisse direkt ausliefern
# (assets/, favicon.ico, ...).
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Alles Uebrige an den Front Controller.
RewriteRule ^ index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
# Ohne mod_rewrite funktionieren nur Aufrufe mit index.php/... –
# bitte mod_rewrite beim Hoster aktivieren.
ErrorDocument 404 /index.php
</IfModule>
# Authorization-Header durchreichen (manche CGI/FastCGI-Setups)
<IfModule mod_setenvif.c>
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
</IfModule>
# Verzeichnislisting abschalten
Options -Indexes
# Standard-Zeichensatz
AddDefaultCharset UTF-8
# Sicherheits-Header fuer statische Dateien.
# "setifempty" statt "set", weil die Anwendung dieselben Header fuer
# PHP-Antworten selbst setzt - sonst wuerden sie doppelt ausgeliefert.
<IfModule mod_headers.c>
Header setifempty X-Content-Type-Options "nosniff"
Header setifempty X-Frame-Options "DENY"
Header setifempty Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# Statische Assets duerfen gecacht werden
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 7 days"
ExpiresByType application/javascript "access plus 7 days"
ExpiresByType image/svg+xml "access plus 7 days"
</IfModule>
# Kein Zugriff auf Konfigurations- und Versionsdateien
<FilesMatch "^(\.env.*|composer\.(json|lock)|.*\.md|.*\.ya?ml|Dockerfile)$">
Require all denied
</FilesMatch>
+470
View File
@@ -0,0 +1,470 @@
/* =============================================================
urlshort-qr-gen – Stylesheet (kein Framework, kein Build-Schritt)
============================================================= */
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--border: #e2e5ea;
--border-strong: #cbd2dc;
--text: #1b2029;
--muted: #667085;
--primary: #2c5cc5;
--primary-dark: #244da6;
--primary-soft: #eaf0fd;
--danger: #b3261e;
--danger-soft: #fdeceb;
--warn: #8a5a00;
--warn-soft: #fff6e3;
--success: #1a6b45;
--success-soft: #e8f6ef;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 8px 24px -12px rgba(16, 24, 40, .18);
--mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171c;
--surface: #1c2027;
--border: #2c323b;
--border-strong: #3b434f;
--text: #e7eaef;
--muted: #98a2b3;
--primary: #6c96f0;
--primary-dark: #82a8f7;
--primary-soft: #1e2a3f;
--danger: #f28b82;
--danger-soft: #3a2320;
--warn: #f0c36d;
--warn-soft: #38301d;
--success: #7ad0a5;
--success-soft: #1b2f26;
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 24px -12px rgba(0, 0, 0, .6);
}
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--sans);
font-size: 16px;
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { line-height: 1.25; margin: 0 0 .4em; font-weight: 650; letter-spacing: -.01em; }
h1 { font-size: 1.6rem; }
h2 { font-size: 1.1rem; }
p { margin: 0 0 1rem; }
p:last-child { margin-bottom: 0; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code, .mono { font-family: var(--mono); font-size: .92em; }
code { background: var(--primary-soft); padding: .1em .35em; border-radius: 4px; }
.container { width: min(1140px, 100% - 2.5rem); margin-inline: auto; }
.muted { color: var(--muted); }
.small { font-size: .875rem; }
.center { text-align: center; }
.nowrap { white-space: nowrap; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.truncate {
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
/* ---------- Kopfzeile ---------- */
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
}
.topbar__inner {
display: flex; align-items: center; gap: 1.25rem;
min-height: 62px; flex-wrap: wrap;
}
.brand { display: inline-flex; align-items: center; gap: .55rem; color: var(--text); font-weight: 650; }
.brand:hover { text-decoration: none; }
.brand__mark {
width: 22px; height: 22px; border-radius: 6px; display: inline-block;
background:
linear-gradient(135deg, var(--primary), color-mix(in srgb, var(--primary) 55%, #14b8a6));
}
.brand__mark--lg { width: 34px; height: 34px; border-radius: 9px; margin-bottom: .5rem; }
.mainnav { display: flex; align-items: center; gap: .35rem; margin-right: auto; flex-wrap: wrap; }
.mainnav > a {
padding: .4rem .7rem; border-radius: var(--radius-sm); color: var(--muted); font-weight: 500;
}
.mainnav > a:hover { background: var(--bg); color: var(--text); text-decoration: none; }
.mainnav > a.is-active { color: var(--text); background: var(--primary-soft); }
.mainnav > a.btn { color: #fff; }
.mainnav > a.btn:hover { color: #fff; }
.logout { display: flex; align-items: center; gap: .6rem; margin: 0; }
.logout__user { color: var(--muted); font-size: .875rem; }
.main { padding: 1.75rem 0 3rem; }
.footer { border-top: 1px solid var(--border); padding: 1.25rem 0; color: var(--muted); font-size: .8125rem; }
/* ---------- Zentriertes Layout (Login/Setup/Fehler) ---------- */
.body--centered { display: grid; place-items: center; min-height: 100vh; padding: 1.5rem; }
.centered { width: min(460px, 100%); }
.card--narrow { padding: 2rem; }
.card__head { text-align: center; margin-bottom: 1.5rem; }
.card__head h1 { font-size: 1.35rem; }
.bignum { font-size: 3rem; font-weight: 700; color: var(--muted); opacity: .35; line-height: 1; }
.trace {
text-align: left; font-family: var(--mono); font-size: .75rem; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .75rem;
overflow: auto; max-height: 320px;
}
/* ---------- Karten & Raster ---------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.35rem;
box-shadow: var(--shadow);
margin-bottom: 1.25rem;
}
.card--flush { padding: 0; overflow: hidden; }
.card--form { max-width: 720px; }
.card__head-row {
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
flex-wrap: wrap; margin-bottom: .75rem;
}
.card__head-row h2 { margin: 0; }
.grid { display: grid; gap: 1.25rem; }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.grid.tight { gap: .85rem; }
.page-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem;
}
.page-head h1 { margin-bottom: .15rem; }
.page-head p { margin: 0; }
.page-head__actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.empty { text-align: center; padding: 3rem 1.5rem; }
.empty .btn { margin-top: .5rem; }
/* ---------- Kennzahlen ---------- */
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem; margin-bottom: 1.25rem;
}
.stat {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1rem 1.15rem; display: flex; flex-direction: column; gap: .25rem;
}
.stat__label { color: var(--muted); font-size: .8125rem; text-transform: uppercase; letter-spacing: .04em; }
.stat__value { font-size: 1.75rem; font-weight: 650; line-height: 1.1; }
.stat__value--sm { font-size: 1.1rem; font-weight: 600; }
/* ---------- Buttons ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
padding: .5rem .9rem; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--surface); color: var(--text);
font: inherit; font-size: .9375rem; font-weight: 500; cursor: pointer;
text-decoration: none; transition: background .12s ease, border-color .12s ease;
}
.btn:hover { background: var(--bg); text-decoration: none; }
.btn:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
outline: 2px solid var(--primary); outline-offset: 2px;
}
.btn--primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn--primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn--ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn--ghost:hover { background: var(--bg); color: var(--text); }
.btn--danger { color: var(--danger); border-color: transparent; background: transparent; }
.btn--danger:hover { background: var(--danger-soft); }
.btn--sm { padding: .3rem .6rem; font-size: .8125rem; }
.btn--block { width: 100%; }
.inline { display: inline; margin: 0; }
/* ---------- Formulare ---------- */
.form { display: flex; flex-direction: column; gap: 1.1rem; }
.form .grid { margin: 0; }
.field { display: flex; flex-direction: column; gap: .35rem; margin: 0; }
.field label, .label { font-weight: 550; font-size: .9375rem; }
.field__hint { color: var(--muted); font-size: .8125rem; margin: 0; }
.field__hint.warn { color: var(--warn); }
.field__error { color: var(--danger); font-size: .8125rem; margin: 0; }
.field--error input, .field--error select { border-color: var(--danger); }
.req { color: var(--danger); }
input[type="text"], input[type="password"], input[type="search"], input[type="url"],
input[type="email"], input[type="number"], input[type="file"], select, textarea {
width: 100%; padding: .55rem .7rem; font: inherit; font-size: .9375rem;
color: var(--text); background: var(--surface);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
}
input::placeholder { color: var(--muted); opacity: .8; }
input[type="file"] { padding: .4rem; }
select { appearance: none; padding-right: 2rem;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: right 1rem center, right .7rem center;
background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
}
input[type="range"] { width: 100%; accent-color: var(--primary); }
input[type="color"] {
width: 46px; height: 38px; padding: 2px; border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); background: var(--surface); cursor: pointer;
}
.check { display: flex; align-items: center; gap: .5rem; font-weight: 500; cursor: pointer; }
.check input { width: 1.05rem; height: 1.05rem; accent-color: var(--primary); }
.form__actions { display: flex; gap: .6rem; flex-wrap: wrap; }
.input-prefix { display: flex; align-items: stretch; }
.input-prefix__label {
display: inline-flex; align-items: center; padding: 0 .6rem;
background: var(--bg); border: 1px solid var(--border-strong); border-right: 0;
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
color: var(--muted); font-size: .8125rem; white-space: nowrap;
max-width: 55%; overflow: hidden; text-overflow: ellipsis;
}
.input-prefix input { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
.toolbar {
display: flex; gap: .6rem; flex-wrap: wrap; align-items: center; margin-bottom: 1.25rem;
}
.toolbar input[type="search"] { flex: 1 1 260px; }
.toolbar select { flex: 0 1 190px; }
/* ---------- Hinweise ---------- */
.alert {
padding: .7rem .9rem; border-radius: var(--radius-sm); margin-bottom: 1rem;
font-size: .9375rem; border: 1px solid transparent;
}
.alert--success { background: var(--success-soft); color: var(--success); border-color: color-mix(in srgb, var(--success) 25%, transparent); }
.alert--error { background: var(--danger-soft); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
.alert--warn { background: var(--warn-soft); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 30%, transparent); }
.badge {
display: inline-block; padding: .12rem .5rem; border-radius: 999px;
background: var(--primary-soft); color: var(--primary); font-size: .75rem; font-weight: 600;
white-space: nowrap;
}
.badge--muted { background: var(--bg); color: var(--muted); border: 1px solid var(--border); }
/* ---------- Tabellen ---------- */
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: .9375rem; }
.table th, .table td { padding: .7rem .9rem; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
.table thead th {
font-size: .75rem; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); font-weight: 600; background: var(--bg); border-bottom: 1px solid var(--border);
}
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background: color-mix(in srgb, var(--primary-soft) 45%, transparent); }
.table .num { text-align: right; font-variant-numeric: tabular-nums; }
.table--compact th, .table--compact td { padding: .45rem .7rem; font-size: .875rem; }
.table tr.is-inactive { opacity: .6; }
.truncate-cell { max-width: 320px; }
.truncate-cell a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-stack { display: flex; flex-direction: column; gap: .15rem; align-items: flex-start; }
.actions { display: flex; gap: .25rem; flex-wrap: wrap; justify-content: flex-end; }
.linkcopy {
border: 0; background: transparent; padding: 0; color: var(--primary); font: inherit;
font-family: var(--mono); font-size: .9em; cursor: pointer; text-align: left;
}
.linkcopy:hover { text-decoration: underline; }
.linkcopy.is-copied::after { content: " ✓ kopiert"; color: var(--success); font-family: var(--sans); font-size: .8em; }
.linkcopy__domain { color: var(--muted); }
.badges { display: inline-flex; gap: .3rem; flex-wrap: wrap; }
.steps { margin: 0 0 1rem; padding-left: 1.25rem; display: flex; flex-direction: column; gap: .5rem; }
.steps li { padding-left: .2rem; }
/* Nummerierte Schrittanleitung mit eigenen Marken */
.steps--numbered { counter-reset: step; list-style: none; padding-left: 0; gap: 1.75rem; }
.steps--numbered > li { position: relative; padding-left: 2.6rem; }
.steps--numbered > li::before {
counter-increment: step; content: counter(step);
position: absolute; left: 0; top: .05rem;
width: 1.85rem; height: 1.85rem; border-radius: 50%;
display: grid; place-items: center;
background: var(--primary-soft); color: var(--primary);
font-weight: 650; font-size: .875rem; font-variant-numeric: tabular-nums;
}
.steps--numbered h3 { font-size: 1rem; margin: .1rem 0 .4rem; }
.steps--numbered p { margin-bottom: .5rem; }
.steps--numbered .table-wrap { margin-bottom: .5rem; }
.steps--numbered .table { border: 1px solid var(--border); border-radius: var(--radius-sm); }
details {
border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: .5rem .75rem; margin: .5rem 0; background: var(--bg);
}
details + details { margin-top: .4rem; }
details > summary {
cursor: pointer; font-weight: 550; font-size: .9375rem; list-style: none;
display: flex; align-items: center; gap: .45rem;
}
details > summary::-webkit-details-marker { display: none; }
details > summary::before {
content: "▸"; color: var(--muted); font-size: .8em; transition: transform .12s ease;
}
details[open] > summary::before { transform: rotate(90deg); }
details > summary:hover { color: var(--primary); }
details > *:not(summary) { margin-top: .6rem; }
.code-block {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: .7rem .85rem; overflow-x: auto; font-size: .8125rem; line-height: 1.5; margin: 0 0 .5rem;
}
.code-block code { background: none; padding: 0; font-size: inherit; white-space: pre; }
/* ---------- Ergebnis der Domain-Pruefung ---------- */
.check-result { border-left: 4px solid var(--border-strong); }
.check-result--ok { border-left-color: var(--success); }
.check-result--warn { border-left-color: var(--warn); }
.check-result--error { border-left-color: var(--danger); }
.check-result__head { display: flex; gap: .85rem; align-items: flex-start; margin-bottom: 1rem; }
.check-result__head h2 { margin: 0 0 .15rem; }
.check-result__summary { margin: 0; color: var(--muted); }
.check-result__icon {
flex: 0 0 auto; width: 2rem; height: 2rem; border-radius: 50%;
display: grid; place-items: center; font-weight: 700; font-size: 1.05rem; line-height: 1;
}
.check-result--ok .check-result__icon { background: var(--success-soft); color: var(--success); }
.check-result--warn .check-result__icon { background: var(--warn-soft); color: var(--warn); }
.check-result--error .check-result__icon { background: var(--danger-soft); color: var(--danger); }
.check-list { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-direction: column; gap: .55rem; }
.check-list__item {
display: grid; grid-template-columns: 1.25rem 5.5rem 1fr; gap: .5rem;
align-items: baseline; font-size: .9375rem;
}
.check-list__icon { font-weight: 700; text-align: center; }
.check-list__item--ok .check-list__icon { color: var(--success); }
.check-list__item--warn .check-list__icon { color: var(--warn); }
.check-list__item--error .check-list__icon { color: var(--danger); }
.check-list__label { color: var(--muted); font-size: .8125rem; text-transform: uppercase; letter-spacing: .04em; }
@media (max-width: 640px) {
.check-list__item { grid-template-columns: 1.25rem 1fr; }
.check-list__label { grid-column: 2; }
.check-list__text { grid-column: 2; }
}
.checklist { list-style: none; padding-left: 0; display: flex; flex-direction: column; gap: .45rem; margin: 0; }
.checklist li { padding-left: 1.1rem; position: relative; }
.checklist li::before { content: "–"; position: absolute; left: 0; color: var(--muted); }
.list { list-style: none; margin: 0; padding: 0; }
.list__item {
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: .6rem 0; border-bottom: 1px solid var(--border);
}
.list__item:last-child { border-bottom: 0; }
.list__main { display: flex; flex-direction: column; min-width: 0; }
/* ---------- Diagramme & Balken ---------- */
.chart { position: relative; min-height: 140px; }
.chart canvas { width: 100% !important; }
.segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.segmented a {
padding: .3rem .7rem; font-size: .8125rem; color: var(--muted);
border-right: 1px solid var(--border-strong);
}
.segmented a:last-child { border-right: 0; }
.segmented a:hover { background: var(--bg); text-decoration: none; }
.segmented a.is-active { background: var(--primary); color: #fff; }
.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .5rem; }
.bars__row { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; gap: .6rem; align-items: center; }
.bars__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .875rem; }
.bars__track { background: var(--bg); border-radius: 999px; height: 9px; overflow: hidden; border: 1px solid var(--border); }
.bars__fill { display: block; height: 100%; background: var(--primary); border-radius: 999px; min-width: 2px; }
.bars__value { font-variant-numeric: tabular-nums; font-size: .875rem; color: var(--muted); }
/* ---------- QR-Bereich ---------- */
.qr-layout { display: grid; grid-template-columns: minmax(300px, 420px) 1fr; gap: 1.25rem; align-items: start; }
@media (max-width: 900px) { .qr-layout { grid-template-columns: 1fr; } }
.qr-preview { position: sticky; top: 82px; }
.qr-preview__stage {
position: relative; display: grid; place-items: center; padding: 1rem;
background:
conic-gradient(from 45deg, #00000008 25%, transparent 0 50%, #00000008 0 75%, transparent 0) 0 0/20px 20px,
var(--bg);
border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 1rem;
}
.qr-preview__stage img {
display: block; width: 100%; max-width: 340px; height: auto; border-radius: 6px;
image-rendering: pixelated;
}
.qr-preview__spinner {
position: absolute; inset: 0; display: grid; place-items: center;
background: color-mix(in srgb, var(--surface) 60%, transparent); border-radius: var(--radius);
}
.qr-preview__spinner::after {
content: ""; width: 26px; height: 26px; border-radius: 50%;
border: 3px solid var(--border-strong); border-top-color: var(--primary);
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .qr-preview__spinner::after { animation: none; } }
.qr-download { display: flex; gap: .6rem; flex-wrap: wrap; justify-content: center; margin: .75rem 0 .35rem; }
.qr-controls { display: flex; flex-direction: column; }
.colorpair { display: flex; gap: .5rem; align-items: center; }
.colorpair input[type="text"] { flex: 1; }
.swatches { display: flex; gap: .5rem; flex-wrap: wrap; }
.swatch {
width: 34px; height: 34px; border-radius: 8px; cursor: pointer; padding: 0;
border: 1px solid var(--border-strong);
background: linear-gradient(135deg, var(--fg) 0 50%, var(--bg) 50% 100%);
}
.swatch:hover { transform: scale(1.06); }
.logo-current { display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
.logo-current__img {
width: 64px; height: 64px; object-fit: contain; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .35rem;
}
.logo-current__meta { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; }
@media (max-width: 640px) {
.topbar__inner { padding-block: .6rem; }
.logout__user { display: none; }
.actions { justify-content: flex-start; }
.page-head { flex-direction: column; align-items: stretch; }
}
+383
View File
@@ -0,0 +1,383 @@
/* =============================================================
urlshort-qr-gen – Vanilla JavaScript, kein Build-Schritt.
Alles hier ist optionale Politur: ohne JS bleibt die Anwendung
vollstaendig bedienbar (serverseitig gerenderte Formulare).
============================================================= */
(function () {
'use strict';
/* ---------- Kopieren in die Zwischenablage ---------- */
document.addEventListener('click', function (event) {
var button = event.target.closest('[data-copy]');
if (!button) {
return;
}
var value = button.getAttribute('data-copy');
var done = function () {
button.classList.add('is-copied');
window.setTimeout(function () { button.classList.remove('is-copied'); }, 1600);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(value).then(done, function () { fallbackCopy(value, done); });
} else {
fallbackCopy(value, done);
}
});
function fallbackCopy(value, done) {
var field = document.createElement('textarea');
field.value = value;
field.setAttribute('readonly', '');
field.style.position = 'fixed';
field.style.opacity = '0';
document.body.appendChild(field);
field.select();
try { document.execCommand('copy'); done(); } catch (e) { /* still */ }
document.body.removeChild(field);
}
/* ---------- Rueckfrage vor destruktiven Aktionen ---------- */
document.addEventListener('submit', function (event) {
var form = event.target.closest('form[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
/* ---------- Liniendiagramme (Chart.js vom CDN) ---------- */
var chartContainers = document.querySelectorAll('[data-chart="line"]');
if (chartContainers.length > 0) {
loadChartJs(function (ok) {
chartContainers.forEach(function (container) {
if (ok) {
renderLineChart(container);
} else {
renderFallbackChart(container);
}
});
});
}
function loadChartJs(callback) {
if (window.Chart) {
callback(true);
return;
}
var script = document.createElement('script');
// Feste Version mit Subresource Integrity: der Browser laedt die
// Datei nur, wenn ihr Hash exakt stimmt. Selbst ein manipuliertes
// CDN koennte hier also keinen fremden Code einschleusen.
// Ist das CDN nicht erreichbar (oder der Hash falsch), greift
// renderFallbackChart() mit einer eigenen Darstellung.
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';
script.integrity = 'sha384-9nhczxUqK87bcKHh20fSQcTGD4qq5GhayNYSYWqwBkINBhOfQLg/P5HG5lF1urn4';
script.crossOrigin = 'anonymous';
script.referrerPolicy = 'no-referrer';
script.onload = function () { callback(true); };
script.onerror = function () { callback(false); };
document.head.appendChild(script);
}
function seriesOf(container) {
try {
return JSON.parse(container.getAttribute('data-series') || '[]');
} catch (e) {
return [];
}
}
function cssVar(name, fallback) {
var value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function formatDay(iso) {
var parts = String(iso).split('-');
return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' : iso;
}
function renderLineChart(container) {
var canvas = container.querySelector('canvas');
var series = seriesOf(container);
if (!canvas || series.length === 0) {
return;
}
var primary = cssVar('--primary', '#2c5cc5');
var muted = cssVar('--muted', '#667085');
var border = cssVar('--border', '#e2e5ea');
if (container._chart) {
container._chart.destroy();
}
container._chart = new window.Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: series.map(function (point) { return formatDay(point.date); }),
datasets: [{
label: 'Klicks',
data: series.map(function (point) { return point.clicks; }),
borderColor: primary,
backgroundColor: 'rgba(44, 92, 197, .12)',
borderWidth: 2,
pointRadius: series.length > 60 ? 0 : 2,
pointHoverRadius: 4,
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (item) { return item.parsed.y + ' Klick(s)'; }
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: muted, maxTicksLimit: 10, autoSkip: true, maxRotation: 0 }
},
y: {
beginAtZero: true,
grid: { color: border },
border: { display: false },
ticks: { color: muted, precision: 0, maxTicksLimit: 6 }
}
}
}
});
}
/* Wenn das CDN nicht erreichbar ist: schlichte Balken als Ersatz. */
function renderFallbackChart(container) {
var series = seriesOf(container);
var canvas = container.querySelector('canvas');
if (canvas) {
canvas.remove();
}
if (series.length === 0) {
return;
}
var max = Math.max.apply(null, series.map(function (point) { return point.clicks; })) || 1;
var wrapper = document.createElement('div');
wrapper.className = 'chart-fallback';
wrapper.style.cssText = 'display:flex;align-items:flex-end;gap:2px;height:130px;';
series.forEach(function (point) {
var bar = document.createElement('div');
bar.title = point.date + ': ' + point.clicks + ' Klick(s)';
bar.style.cssText = 'flex:1;background:var(--primary);border-radius:2px 2px 0 0;min-height:2px;height:'
+ Math.round(point.clicks / max * 100) + '%;opacity:.85;';
wrapper.appendChild(bar);
});
container.appendChild(wrapper);
}
/* ---------- Kurzlink-Formular: Domain-Prefix ---------- */
var domainSelect = document.querySelector('[data-domain-select]');
var slugPrefix = document.querySelector('[data-slug-prefix]');
if (domainSelect && slugPrefix) {
domainSelect.addEventListener('change', function () {
var option = domainSelect.options[domainSelect.selectedIndex];
if (option) {
slugPrefix.textContent = (option.getAttribute('data-base-url') || '') + '/';
}
});
}
/* ---------- QR-Code: Live-Vorschau ---------- */
var qr = document.querySelector('[data-qr]');
if (qr) {
initQrPreview(qr);
}
function initQrPreview(root) {
var form = document.getElementById('qr-form');
var image = document.getElementById('qr-image');
var spinner = root.querySelector('[data-qr-spinner]');
var warningBox = root.querySelector('[data-qr-warning]');
var warningText = root.querySelector('[data-qr-warning-text]');
var stage = root.querySelector('.qr-preview__stage');
var previewUrl = root.getAttribute('data-preview-url');
var downloadUrl = root.getAttribute('data-download-url');
var timer = null;
if (!form || !image) {
return;
}
/* Hex-Feld und Farbwaehler synchron halten. */
root.querySelectorAll('[data-color-sync]').forEach(function (input) {
input.addEventListener('input', function () {
var partner = document.getElementById(input.getAttribute('data-color-sync'));
if (!partner) {
return;
}
var value = input.value.trim();
if (input.type === 'text') {
if (!/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)) {
return;
}
partner.value = expandHex(value);
} else {
partner.value = value;
}
schedule();
});
});
/* Farbvoreinstellungen */
root.querySelectorAll('[data-preset]').forEach(function (button) {
button.addEventListener('click', function () {
var pair = button.getAttribute('data-preset').split(',');
setColor('foreground', pair[0]);
setColor('background', pair[1]);
schedule();
});
});
/* Regler-Anzeigen */
root.querySelectorAll('[data-output]').forEach(function (input) {
var output = document.getElementById(input.getAttribute('data-output'));
if (output) {
input.addEventListener('input', function () { output.textContent = input.value; });
}
});
root.querySelectorAll('[data-qr-input]').forEach(function (input) {
input.addEventListener('input', schedule);
input.addEventListener('change', schedule);
});
var resetButton = root.querySelector('[data-qr-reset]');
if (resetButton) {
resetButton.addEventListener('click', function () {
window.setTimeout(function () {
root.querySelectorAll('[data-color-sync]').forEach(function (input) {
var partner = document.getElementById(input.getAttribute('data-color-sync'));
if (partner && input.type === 'color') {
partner.value = input.value;
}
});
root.querySelectorAll('[data-output]').forEach(function (input) {
var output = document.getElementById(input.getAttribute('data-output'));
if (output) { output.textContent = input.value; }
});
update();
}, 0);
});
}
function setColor(name, value) {
var picker = document.getElementById(name);
var text = document.getElementById(name + '_text');
if (picker) { picker.value = value; }
if (text) { text.value = value; }
}
function expandHex(value) {
var hex = value.replace('#', '');
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
return '#' + hex.toLowerCase();
}
function params() {
var data = new FormData(form);
var query = new URLSearchParams();
['foreground', 'background', 'size', 'margin', 'error_correction', 'logo_scale'].forEach(function (key) {
var value = data.get(key);
if (value !== null) {
query.set(key, String(value));
}
});
query.set('logo_punchout', data.get('logo_punchout') ? '1' : '0');
return query;
}
function schedule() {
window.clearTimeout(timer);
timer = window.setTimeout(update, 260);
}
function update() {
var query = params();
if (spinner) { spinner.hidden = false; }
var next = new Image();
next.onload = function () {
image.src = next.src;
if (spinner) { spinner.hidden = true; }
};
next.onerror = function () {
if (spinner) { spinner.hidden = true; }
};
next.src = previewUrl + '?' + query.toString();
root.querySelectorAll('[data-qr-download]').forEach(function (anchor) {
var format = anchor.getAttribute('data-qr-download');
anchor.href = downloadUrl + '?' + query.toString() + '&format=' + format;
});
if (stage) {
stage.style.setProperty('--qr-bg', query.get('background') || '#ffffff');
}
checkContrast(query.get('foreground'), query.get('background'));
}
/* Kontrastpruefung nach WCAG – identische Regeln wie serverseitig. */
function luminance(hex) {
var value = expandHex(hex || '#000000').substring(1);
var channels = [0, 2, 4].map(function (offset) {
var channel = parseInt(value.substr(offset, 2), 16) / 255;
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function checkContrast(foreground, background) {
if (!warningBox || !warningText) {
return;
}
var lf = luminance(foreground);
var lb = luminance(background);
var ratio = (Math.max(lf, lb) + 0.05) / (Math.min(lf, lb) + 0.05);
var message = null;
if (ratio < 4) {
message = 'Zu geringer Kontrast (' + ratio.toFixed(1)
+ ':1, empfohlen mindestens 4.0:1). Der QR-Code ist möglicherweise nicht zuverlässig scannbar.';
} else if (lf > lb) {
message = 'Heller Vordergrund auf dunklem Hintergrund: viele Scanner erkennen invertierte '
+ 'QR-Codes nicht. Besser dunkle Module auf hellem Grund.';
}
warningText.textContent = message || '';
warningBox.hidden = message === null;
}
update();
}
})();
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Kurzlinks">
<rect width="32" height="32" rx="7" fill="#2c5cc5"/>
<g fill="#ffffff">
<rect x="6" y="6" width="8" height="8" rx="1.5"/>
<rect x="18" y="6" width="8" height="8" rx="1.5"/>
<rect x="6" y="18" width="8" height="8" rx="1.5"/>
<rect x="18" y="18" width="3.5" height="3.5"/>
<rect x="22.5" y="22.5" width="3.5" height="3.5"/>
</g>
<g fill="#2c5cc5">
<rect x="8.5" y="8.5" width="3" height="3"/>
<rect x="20.5" y="8.5" width="3" height="3"/>
<rect x="8.5" y="20.5" width="3" height="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 674 B

+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* Einziger Einstiegspunkt der Anwendung.
*
* Verzeichnisstruktur (Standard):
* /public <- Webroot (diese Datei)
* /src, /templates, /migrations, /data, /vendor <- ausserhalb des Webroots
*
* Fuer Shared Hosting ohne eigenen Webroot siehe README (Variante B):
* dort liegt zusaetzlich eine .htaccess im Stammverzeichnis, die alle
* Anfragen an public/index.php weiterreicht.
*/
declare(strict_types=1);
use App\App;
use App\Config;
$rootDir = dirname(__DIR__);
$autoload = $rootDir . '/vendor/autoload.php';
if (!is_file($autoload)) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><meta charset="utf-8"><title>Installation unvollständig</title>'
. '<h1>Abhängigkeiten fehlen</h1>'
. '<p>Der Ordner <code>vendor/</code> wurde nicht gefunden. '
. 'Bitte <code>composer install</code> ausführen oder das fertige Release-Paket hochladen.</p>';
exit;
}
require $autoload;
$config = Config::load($rootDir);
if ($config->isDevelopment()) {
ini_set('display_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
}
(new App($config))->run();
+37
View File
@@ -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>
+103
View File
@@ -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>
+90
View File
@@ -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>
+314
View File
@@ -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 &amp; 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 &amp; 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>&lt;VirtualHost *:443&gt;
ServerName short.example.de
ServerAlias qr.example.de
DocumentRoot /var/www/urlshort/public
…
&lt;/VirtualHost&gt;
sudo apachectl configtest &amp;&amp; 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 &amp;&amp; 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>
+22
View File
@@ -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>
+16
View File
@@ -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>
+13
View File
@@ -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>
+68
View File
@@ -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>
+31
View File
@@ -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>
+118
View File
@@ -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>
+140
View File
@@ -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; ?>
+211
View File
@@ -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>
+85
View File
@@ -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>
+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>