diff --git a/public/.htaccess b/public/.htaccess
new file mode 100644
index 0000000..d8b5f4b
--- /dev/null
+++ b/public/.htaccess
@@ -0,0 +1,59 @@
+# =============================================================
+# urlshort-qr-gen – Apache-Konfiguration fuer den Webroot
+# =============================================================
+
+
+ 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]
+
+
+
+ # Ohne mod_rewrite funktionieren nur Aufrufe mit index.php/... –
+ # bitte mod_rewrite beim Hoster aktivieren.
+ ErrorDocument 404 /index.php
+
+
+# Authorization-Header durchreichen (manche CGI/FastCGI-Setups)
+
+ SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
+
+
+# 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.
+
+ Header setifempty X-Content-Type-Options "nosniff"
+ Header setifempty X-Frame-Options "DENY"
+ Header setifempty Referrer-Policy "strict-origin-when-cross-origin"
+
+
+# Statische Assets duerfen gecacht werden
+
+ ExpiresActive On
+ ExpiresByType text/css "access plus 7 days"
+ ExpiresByType application/javascript "access plus 7 days"
+ ExpiresByType image/svg+xml "access plus 7 days"
+
+
+# Kein Zugriff auf Konfigurations- und Versionsdateien
+
+ Require all denied
+
diff --git a/public/assets/app.css b/public/assets/app.css
new file mode 100644
index 0000000..71067c0
--- /dev/null
+++ b/public/assets/app.css
@@ -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; }
+}
diff --git a/public/assets/app.js b/public/assets/app.js
new file mode 100644
index 0000000..0c15720
--- /dev/null
+++ b/public/assets/app.js
@@ -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();
+ }
+})();
diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg
new file mode 100644
index 0000000..73e743a
--- /dev/null
+++ b/public/assets/favicon.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..87534d2
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,45 @@
+
Installation unvollständig '
+ . 'Abhängigkeiten fehlen '
+ . 'Der Ordner vendor/ wurde nicht gefunden. '
+ . 'Bitte composer install ausführen oder das fertige Release-Paket hochladen.
';
+ 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();
diff --git a/templates/auth/login.php b/templates/auth/login.php
new file mode 100644
index 0000000..749273e
--- /dev/null
+++ b/templates/auth/login.php
@@ -0,0 +1,37 @@
+
+
+
+
+
Anmelden
+
Die Verwaltung der Kurzlinks ist nur für angemeldete Benutzer zugänglich.
+
+
+
+
= $e($error) ?>
+
+
+
+
diff --git a/templates/dashboard/index.php b/templates/dashboard/index.php
new file mode 100644
index 0000000..72dad1c
--- /dev/null
+++ b/templates/dashboard/index.php
@@ -0,0 +1,103 @@
+> $recentLinks
+ * @var list> $topLinks
+ * @var list $series
+ * @var int $domainCount
+ * @var App\Config $config
+ */
+
+use App\Repository\LinkRepository;
+use App\Support\ShortDomain;
+
+$fallbackBase = rtrim($config->baseUrl(), '/');
+?>
+
+
+
Übersicht
+
Alle Kurzlinks und Klicks auf einen Blick.
+
+
Neuer Kurzlink
+
+
+
+
+ Kurzlinks
+ = (int) $summary['links'] ?>
+
+
+ davon aktiv
+ = (int) $summary['active'] ?>
+
+
+ Klicks gesamt
+ = number_format((int) $summary['clicks'], 0, ',', '.') ?>
+
+
+ Klicks (30 Tage)
+ = number_format(array_sum(array_column($series, 'clicks')), 0, ',', '.') ?>
+
+
+
+
+
+ Klicks der letzten 30 Tage
+
+
+
Für das Diagramm wird JavaScript benötigt.
+
+
+
+
+
+ Zuletzt angelegt
+
+ Noch keine Kurzlinks vorhanden.
+ Jetzt den ersten anlegen.
+
+
+
+
+
+ = (int) $link['click_count'] ?> Klicks
+
+
+
+
+
+
+
+ Meistgeklickt
+
+ Noch keine Klicks erfasst.
+
+
+
+
+
+ = (int) $link['click_count'] ?> Klicks
+
+
+
+
+
+
diff --git a/templates/domains/form.php b/templates/domains/form.php
new file mode 100644
index 0000000..642ab8f
--- /dev/null
+++ b/templates/domains/form.php
@@ -0,0 +1,90 @@
+|null $domain
+ * @var array{base_url:string,label:string,is_default:string} $values
+ * @var array $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;
+?>
+
+
+
= $isEdit ? 'Domain bearbeiten' : 'Neue Domain' ?>
+
+
= $e($domain['host']) ?>
+
+
Eine weitere Domain, unter der Kurzlinks ausgeliefert werden.
+
+
+
Zurück zur Liste
+
+
+
diff --git a/templates/domains/index.php b/templates/domains/index.php
new file mode 100644
index 0000000..f905d24
--- /dev/null
+++ b/templates/domains/index.php
@@ -0,0 +1,314 @@
+> $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 => '×',
+};
+?>
+
+
+
Domains
+
Unter diesen Domains werden Kurzlinks ausgeliefert. Ein Slug muss nur
+ innerhalb seiner Domain eindeutig sein.
+
+
Neue Domain
+
+
+
+
+
+
= $e($statusSymbol($result['status'])) ?>
+
+
Prüfung für = $e($checkResult['domain']) ?>
+
= $e($result['summary']) ?>
+
+
+
+
+
+
+ = $e($statusSymbol($check['status'])) ?>
+ = $e($check['label']) ?>
+ = $e($check['text']) ?>
+
+
+
+
+
+ = $e($result['hint']) ?>
+
+
+
+ Zur Schritt-für-Schritt-Anleitung
+
+
+
+
+
+
+
+
+
+ Domain
+ Bezeichnung
+ Kurzlinks
+ Aktionen
+
+
+
+
+
+
+
+ = $e(ShortDomain::displayName((string) $domain['base_url'])) ?>
+
+
+ Standard
+
+
+ gerade aufgerufen
+
+
+
+
+ = $e($domain['label'] ?? '–') ?>
+
+
+ = (int) $domain['link_count'] ?>
+
+
+
+
+ Kurzlink
+ Bearbeiten
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Was beim Provider zu tun ist
+
+ Eine weitere Domain hier einzutragen genügt nicht – sie muss beim Domain-Anbieter und beim
+ Hoster auf diese Installation zeigen. Vier Schritte, danach einmal prüfen.
+
+
+
+ Wichtig: Die neue Domain darf keine zweite Installation bekommen und
+ keine „Domain-Weiterleitung“ des Registrars sein. Beides funktioniert nicht: eine
+ Weiterleitung ersetzt die Adresse im Browser, der Slug ginge dabei verloren. Die Domain muss
+ vom selben Webroot beantwortet werden wie die bestehende Installation.
+
+
+
+ Nach jedem Schritt lässt sich über Prüfen 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.
+
+
+
+
+ DNS: Domain auf den Server zeigen lassen
+ Beim Domain-Anbieter (dort, wo die Domain registriert ist) in der DNS- bzw.
+ Zonenverwaltung einen Eintrag anlegen:
+
+
+
+ Typ Name Wert Wofür
+
+
+ A qr 203.0.113.10 IPv4 des Servers
+ AAAA qr 2001:db8::10 IPv6, falls vorhanden
+ CNAME qr short.example.de. Alternative zu A/AAAA – zeigt auf den vorhandenen Hostnamen
+
+
+
+
+ Entweder A (+ AAAA) oder 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.
+
+
+ Wo finde ich das bei meinem Anbieter?
+
+ Die Menüs heißen unterschiedlich, gemeint ist immer dasselbe:
+ IONOS „Domains & SSL → Domain → DNS“ ·
+ Strato „Domainverwaltung → Domain verwalten → DNS-Einstellungen“ ·
+ Netcup „Domains → DNS“ ·
+ Hetzner „DNS Console → Zone → Records“ ·
+ Cloudflare „DNS → Records“ ·
+ All-Inkl „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.
+
+
+
+
+
+ Hosting: Domain auf dasselbe Verzeichnis legen
+ Der Webserver muss die neue Domain beantworten und dabei denselben Webroot verwenden
+ wie die bestehende Installation.
+
+
+ Shared Hosting mit Plesk
+
+ „Websites & Domains → Subdomain hinzufügen“. Plesk schlägt ein neues,
+ eigenes Verzeichnis vor – dieses bei Dokumentenstamm auf das
+ Verzeichnis der vorhandenen Installation ändern (dort, wo index.php
+ liegt, in der Regel …/httpdocs/public bzw. …/httpdocs).
+ Kein neues Hosting-Paket anlegen.
+
+
+
+
+ Shared Hosting mit cPanel
+
+ „Domains → Create A New Domain“. Den Haken bei „Share document root“
+ setzen bzw. den Document Root manuell auf das Verzeichnis der
+ vorhandenen Installation zeigen lassen.
+
+
+
+
+ Anderes Hosting-Panel (IONOS, Strato, All-Inkl …)
+
+ Gesucht ist die Funktion „Subdomain anlegen“ oder „Domain zuordnen“ mit der
+ Möglichkeit, das Zielverzeichnis 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. Nicht geeignet ist
+ „Domain-Weiterleitung“ oder „Frame-Weiterleitung“.
+
+
+
+
+ Eigener Server mit Apache
+ Im vorhandenen VirtualHost ergänzen und Apache neu laden:
+ <VirtualHost *:443>
+ ServerName short.example.de
+ ServerAlias qr.example.de
+ DocumentRoot /var/www/urlshort/public
+ …
+</VirtualHost>
+
+sudo apachectl configtest && sudo systemctl reload apache2
+
+
+
+ Eigener Server mit Nginx
+ Die Domain in server_name aufnehmen und neu laden:
+ server {
+ server_name short.example.de qr.example.de;
+ root /var/www/urlshort/public;
+ …
+}
+
+sudo nginx -t && sudo systemctl reload nginx
+
+
+
+ Docker
+
+ 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.
+
+
+
+
+
+ HTTPS: Zertifikat für die neue Domain
+
+ 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:
+
+ sudo certbot --expand -d short.example.de -d qr.example.de
+
+ Solange noch kein Zertifikat vorhanden ist, die Domain hier mit
+ http:// eintragen – sonst zeigen QR-Codes und Kurzlinks auf eine
+ Adresse, die der Browser nicht öffnen kann.
+
+
+
+
+ Domain hier eintragen
+
+ Über Neue Domain – exakt so, wie sie
+ im Browser aufgerufen wird (gleiches Schema, gleicher Port). Erst danach ordnet die
+ Anwendung Aufrufe dieser Domain zu.
+
+
+
+
+ Prüfen
+
+ In der Liste oben auf Prüfen 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.
+
+
+ Es klappt nicht – woran liegt es?
+
+ Zeitüberschreitung oder „Server nicht gefunden“: DNS zeigt noch
+ nicht auf den Server oder ist noch nicht verteilt. Prüfen mit
+ nslookup qr.example.de – die Antwort muss die IP des Servers sein.
+ Fremde Seite, Hoster-Platzhalter oder „Website nicht gefunden“:
+ Die Domain landet in einem anderen Verzeichnis. Dokumentenstamm in Schritt 2 prüfen.
+ Die Adresse im Browser ändert sich: Es ist eine Domain-Weiterleitung
+ eingerichtet statt einer echten Zuordnung. Beim Anbieter umstellen.
+ 404 statt Weiterleitung: Der Kurzlink hängt an einer anderen Domain.
+ Slugs gelten je Domain – in der Liste oben die Domain des Links prüfen.
+ Es kommt das Ziel der Standarddomain: Die Domain ist hier noch nicht
+ eingetragen oder weicht in Schreibweise, Schema oder Port ab.
+ Zertifikatswarnung: Schritt 3 fehlt noch.
+
+
+
+
+
+
+ Aufrufe über einen nicht eingetragenen Host (z. B. direkt über die IP-Adresse oder hinter einem
+ Proxy) werden der Standarddomain zugeordnet. So bleiben bestehende Links auch
+ dann erreichbar, wenn sich am Hostnamen etwas ändert.
+
+
diff --git a/templates/errors/404.php b/templates/errors/404.php
new file mode 100644
index 0000000..36d6763
--- /dev/null
+++ b/templates/errors/404.php
@@ -0,0 +1,22 @@
+
+
+
404
+
Dieser Kurzlink führt ins Leere
+
+
+ Für /= $e($slug) ?> gibt es kein Ziel – der Link wurde entfernt, deaktiviert
+ oder enthält einen Tippfehler.
+
+ Die aufgerufene Adresse gibt es hier nicht.
+
+
+
Wenn du den Link von jemandem bekommen hast, frag am besten dort noch einmal nach.
+
diff --git a/templates/errors/500.php b/templates/errors/500.php
new file mode 100644
index 0000000..3c9b33f
--- /dev/null
+++ b/templates/errors/500.php
@@ -0,0 +1,16 @@
+
+
+
500
+
Da ist etwas schiefgelaufen
+
Die Anfrage konnte nicht verarbeitet werden. Details stehen im Fehlerprotokoll des Servers.
+
+
+
= $e($detail) ?>
+
+
diff --git a/templates/errors/error.php b/templates/errors/error.php
new file mode 100644
index 0000000..3138fcc
--- /dev/null
+++ b/templates/errors/error.php
@@ -0,0 +1,13 @@
+
+
+
= $e($title ?? 'Fehler') ?>
+
= $e($message ?? 'Es ist ein Fehler aufgetreten.') ?>
+
Zur Übersicht
+
diff --git a/templates/layout.php b/templates/layout.php
new file mode 100644
index 0000000..e0623c3
--- /dev/null
+++ b/templates/layout.php
@@ -0,0 +1,68 @@
+ $flashes
+ * @var string $currentPath
+ */
+
+$title = $pageTitle ?? 'Verwaltung';
+$isActive = static fn (string $prefix): bool => str_starts_with($currentPath ?? '/', $prefix);
+?>
+
+
+
+
+
+
+ = $e($title) ?> · Kurzlinks
+
+
+
+
+
+
+
+
+ = $e($flash['message']) ?>
+
+
+ = $content ?>
+
+
+
+
+
+= $pageScripts ?? '' ?>
+
+
diff --git a/templates/layout_plain.php b/templates/layout_plain.php
new file mode 100644
index 0000000..c27079b
--- /dev/null
+++ b/templates/layout_plain.php
@@ -0,0 +1,31 @@
+ $flashes
+ */
+
+$title = $pageTitle ?? 'Kurzlinks';
+?>
+
+
+
+
+
+
+ = $e($title) ?> · Kurzlinks
+
+
+
+
+
+
+ = $e($flash['message']) ?>
+
+
+ = $content ?>
+
+
+
diff --git a/templates/links/form.php b/templates/links/form.php
new file mode 100644
index 0000000..c4e2667
--- /dev/null
+++ b/templates/links/form.php
@@ -0,0 +1,118 @@
+|null $link
+ * @var list> $domains
+ * @var array{target_url:string,slug:string,title:string,is_active:string,domain_id:string} $values
+ * @var array $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;
+ }
+}
+?>
+
+
+
= $isEdit ? 'Kurzlink bearbeiten' : 'Neuer Kurzlink' ?>
+
+
= $e(rtrim($selectedBase, '/') . '/' . $link['slug']) ?>
+
+
Ziel-URL eintragen – der Slug wird automatisch erzeugt, wenn du keinen angibst.
+
+
+
Zurück zur Liste
+
+
+
diff --git a/templates/links/index.php b/templates/links/index.php
new file mode 100644
index 0000000..3f63ae4
--- /dev/null
+++ b/templates/links/index.php
@@ -0,0 +1,140 @@
+> $links
+ * @var int $total
+ * @var string $search
+ * @var string $sort
+ * @var list> $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)',
+];
+?>
+
+
+
Kurzlinks
+
= (int) $total ?> Eintrag/Einträge= $search !== '' ? ' für „' . $e($search) . '“' : '' ?>.
+
+
Neuer Kurzlink
+
+
+
+
+
+
+
Noch nichts hier
+
Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.
+
Kurzlink anlegen
+
+
+
+
diff --git a/templates/qr/show.php b/templates/qr/show.php
new file mode 100644
index 0000000..7ceab40
--- /dev/null
+++ b/templates/qr/show.php
@@ -0,0 +1,211 @@
+ $link
+ * @var string $shortUrl
+ * @var App\Qr\QrOptions $options
+ * @var list $sizes
+ * @var list $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 %)',
+];
+?>
+
+
+
QR-Code
+
+ = $e($shortUrl) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Der QR-Code verweist auf den Kurzlink – die Ziel-URL kannst du also
+ später ändern, ohne den Code neu drucken zu müssen.
+
+ >
+
= $e($warning ?? '') ?>
+
+
+
+ SVG ist vektorbasiert und damit für den Druck in jeder Größe geeignet.
+
+
+
+
+
+
+ Logo in der Mitte
+
+ hasLogo()): ?>
+
+
+
+ = $e($options->logoPath) ?>
+
+
+
+
+
+
+ Dieses SVG-Logo kann nur im SVG-Download eingebettet werden. Für PNG-Ausgaben
+ wird die PHP-Erweiterung imagick benötigt – alternativ ein PNG-Logo hochladen.
+
+
+
+
+
+
+
+
diff --git a/templates/setup/index.php b/templates/setup/index.php
new file mode 100644
index 0000000..e190f44
--- /dev/null
+++ b/templates/setup/index.php
@@ -0,0 +1,85 @@
+ $errors
+ * @var int $minLength
+ * @var string $tokenPath
+ */
+
+?>
+
+
+
+
Ersteinrichtung
+
+ Es ist noch kein Administrator-Konto vorhanden. Lege jetzt Benutzername und Passwort fest –
+ danach ist diese Seite gesperrt.
+
+
+
+
+
+
+ Alternativ kann das Konto auch fest in der .env hinterlegt werden
+ (ADMIN_USER und ADMIN_PASSWORD_HASH). Details stehen in der README.
+
+
diff --git a/templates/stats/show.php b/templates/stats/show.php
new file mode 100644
index 0000000..4ad9d84
--- /dev/null
+++ b/templates/stats/show.php
@@ -0,0 +1,158 @@
+ $link
+ * @var string $shortUrl
+ * @var int $days
+ * @var int $totalVisits
+ * @var list $series
+ * @var list $referrers
+ * @var list $devices
+ * @var list $browsers
+ * @var list $platforms
+ * @var list> $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 'Noch keine Daten.
';
+ }
+
+ $max = max(array_map(static fn (array $row): int => $row['clicks'], $rows)) ?: 1;
+ $html = '';
+ foreach ($rows as $row) {
+ $html .= sprintf(
+ '%1$s '
+ . ' '
+ . '%3$d ',
+ $e($row['label']),
+ $row['clicks'] / $max * 100,
+ $row['clicks'],
+ );
+ }
+
+ return $html . ' ';
+};
+?>
+
+
+
+
+ Klicks gesamt
+ = number_format((int) $link['click_count'], 0, ',', '.') ?>
+
+
+ Im Zeitraum
+ = number_format(array_sum(array_column($series, 'clicks')), 0, ',', '.') ?>
+
+
+ Erstellt
+
+ = $e(date('d.m.Y', strtotime((string) $link['created_at'] . ' UTC') ?: time())) ?>
+
+
+
+ Letzter Klick
+
+ = $link['last_clicked_at'] !== null
+ ? $e(date('d.m.Y H:i', strtotime((string) $link['last_clicked_at'] . ' UTC') ?: time()))
+ : '–' ?>
+
+
+
+
+
+
+
Klicks im Zeitverlauf
+
+
+
+
+
+
Für das Diagramm wird JavaScript benötigt.
+
+
+
+
+
+ Top-Referrer
+ = $bars($referrers) ?>
+
+
+
+ Gerätetyp
+ = $bars($devices) ?>
+
+
+
+ Browser
+ = $bars($browsers) ?>
+
+
+
+ Plattform
+ = $bars($platforms) ?>
+
+
+
+
+ Letzte Aufrufe
+
+ Noch keine Aufrufe erfasst.
+
+
+
+
+
+ Zeitpunkt
+ Referrer
+ Gerät
+ Browser
+ Plattform
+
+
+
+
+
+ = $e(date('d.m.Y H:i:s', strtotime((string) $visit['visited_at'] . ' UTC') ?: time())) ?>
+ = $e($visit['referrer_host'] ?? 'direkt') ?>
+ = $e(App\Support\UserAgentParser::deviceLabel((string) $visit['device_type'])) ?>
+ = $e($visit['browser']) ?>
+ = $e($visit['platform']) ?>
+
+
+
+
+
+
+
+ Es werden weder IP-Adressen noch personenbezogene Merkmale gespeichert – nur Zeitpunkt, Referrer
+ und die aus dem User-Agent abgeleiteten Kategorien.
+
+