From fabaef1f2b2b7f8f2a503be112553cffcf4bbdde Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:40 +0200 Subject: [PATCH] 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. --- public/.htaccess | 59 +++++ public/assets/app.css | 470 ++++++++++++++++++++++++++++++++++ public/assets/app.js | 383 +++++++++++++++++++++++++++ public/assets/favicon.svg | 15 ++ public/index.php | 45 ++++ templates/auth/login.php | 37 +++ templates/dashboard/index.php | 103 ++++++++ templates/domains/form.php | 90 +++++++ templates/domains/index.php | 314 +++++++++++++++++++++++ templates/errors/404.php | 22 ++ templates/errors/500.php | 16 ++ templates/errors/error.php | 13 + templates/layout.php | 68 +++++ templates/layout_plain.php | 31 +++ templates/links/form.php | 118 +++++++++ templates/links/index.php | 140 ++++++++++ templates/qr/show.php | 211 +++++++++++++++ templates/setup/index.php | 85 ++++++ templates/stats/show.php | 158 ++++++++++++ 19 files changed, 2378 insertions(+) create mode 100644 public/.htaccess create mode 100644 public/assets/app.css create mode 100644 public/assets/app.js create mode 100644 public/assets/favicon.svg create mode 100644 public/index.php create mode 100644 templates/auth/login.php create mode 100644 templates/dashboard/index.php create mode 100644 templates/domains/form.php create mode 100644 templates/domains/index.php create mode 100644 templates/errors/404.php create mode 100644 templates/errors/500.php create mode 100644 templates/errors/error.php create mode 100644 templates/layout.php create mode 100644 templates/layout_plain.php create mode 100644 templates/links/form.php create mode 100644 templates/links/index.php create mode 100644 templates/qr/show.php create mode 100644 templates/setup/index.php create mode 100644 templates/stats/show.php 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.

+
+ + + + + +
+ field() ?> + +
+ + +
+ +
+ + +
+ + +
+
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 + +
+
+ davon aktiv + +
+
+ Klicks gesamt + +
+
+ Klicks (30 Tage) + +
+
+ Domains + + + +
+
+ +
+

Klicks der letzten 30 Tage

+
+ + +
+
+ +
+
+

Zuletzt angelegt

+ +

Noch keine Kurzlinks vorhanden. + Jetzt den ersten anlegen.

+ + + +
+ +
+

Meistgeklickt

+ +

Noch keine Klicks erfasst.

+ + + +
+
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; +?> +
+
+

+ +

+ +

Eine weitere Domain, unter der Kurzlinks ausgeliefert werden.

+ +
+ Zurück zur Liste +
+ +
+
+ field() ?> + +
+ + +

+ Genau so eintragen, wie die Domain im Browser aufgerufen wird. Ohne Schema wird + https:// ergänzt – solange noch kein Zertifikat eingerichtet ist, + bitte http:// voranstellen. Ein Unterverzeichnis ist erlaubt + (example.de/kurz), ein Port ebenfalls (localhost:8088). + Jede Domain darf nur einmal vorkommen. +

+ +

+ +
+ +
+ + +

Nur zur eigenen Orientierung in der Auswahlliste.

+
+ +
+ + + +

Dies ist bereits die Standarddomain.

+ +

Die Standarddomain ist im Formular vorausgewählt und fängt Aufrufe + über unbekannte Hostnamen ab.

+ +
+ + +
+ Wird der Hostname geändert, sind bereits verteilte Links und gedruckte QR-Codes + dieser Domain nicht mehr erreichbar. +
+ +

+ Damit die Domain auch wirklich hier ankommt, sind noch Einstellungen bei Domain-Anbieter + und Hoster nötig – die + Schritt-für-Schritt-Anleitung + steht auf der Domain-Übersicht. +

+ + +
+ + Abbrechen +
+
+
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 +
+ + +
+
+ +
+

Prüfung für

+

+
+
+ +
    + +
  • + + + +
  • + +
+ + +

+ + + +

Zur Schritt-für-Schritt-Anleitung

+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + +
DomainBezeichnungKurzlinksAktionen
+
+ + + + Standard + + + gerade aufgerufen + + +
+
+ + + + +
+ field() ?> + +
+ Kurzlink + Bearbeiten + + +
+ field() ?> + +
+ +
+ field() ?> + +
+ +
+
+
+ +
+

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. +

+ +
    +
  1. +

    DNS: Domain auf den Server zeigen lassen

    +

    Beim Domain-Anbieter (dort, wo die Domain registriert ist) in der DNS- bzw. + Zonenverwaltung einen Eintrag anlegen:

    +
    + + + + + + + + + +
    TypNameWertWofür
    Aqr203.0.113.10IPv4 des Servers
    AAAAqr2001:db8::10IPv6, falls vorhanden
    CNAMEqrshort.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. +

    +
    +
  2. + +
  3. +

    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. +

    +
    +
  4. + +
  5. +

    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. +

    +
  6. + +
  7. +

    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. +

    +
  8. + +
  9. +

    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.
    • +
    +
    +
  10. +
+ +

+ 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 @@ + +
+ +

Dieser Kurzlink führt ins Leere

+

+ + Für / 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 @@ + +
+ +

Da ist etwas schiefgelaufen

+

Die Anfrage konnte nicht verarbeitet werden. Details stehen im Fehlerprotokoll des Servers.

+ + +
+ +
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 @@ + + 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 + + + + +
+
+ + + Kurzlinks + + + + +
+ field() ?> + currentUser() ?? '') ?> + +
+
+
+ +
+ +
+ + + +
+ +
+
+ Basis-URL: baseUrl(), '/')) ?> +
+
+ + + + + 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 + + + + +
+ +
+ + + +
+ + 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; + } +} +?> +
+
+

+ +

+ +

Ziel-URL eintragen – der Slug wird automatisch erzeugt, wenn du keinen angibst.

+ +
+ Zurück zur Liste +
+ +
+
+ field() ?> + +
+ + +

+ Nur http:// und https://. Fehlt das Schema, wird https:// ergänzt. + Umlaut-Domains (müller.de), Umlaute im Pfad, IPv6-Adressen sowie interne Hostnamen + ohne Punkt (intranet, nas:5000) sind zulässig. +

+ +

+ +
+ +
+ + +

+ Domains verwalten – derselbe Slug kann auf + verschiedenen Domains auf unterschiedliche Ziele zeigen. +

+ +

+ +
+ +
+ +
+ / + > +
+

Erlaubt sind Kleinbuchstaben, Ziffern und Bindestriche.

+ +

+ + +

Achtung: Beim Ändern von Slug oder Domain funktionieren bereits + verteilte Links und gedruckte QR-Codes nicht mehr.

+ +
+ +
+ + +

Nur zur eigenen Orientierung in der Liste – wird nirgends öffentlich angezeigt.

+
+ +
+ +

Deaktivierte Links liefern eine 404-Seite aus, bleiben aber samt Statistik erhalten.

+
+ +
+ + Abbrechen +
+
+
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

+

Eintrag/Einträge.

+
+ Neuer Kurzlink +
+ +
+ + + + + + + + + + Zurücksetzen + +
+ + +
+

Noch nichts hier

+

Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.

+ Kurzlink anlegen +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
KurzlinkZielKlicksErstelltAktionen
+
+ + + + + + deaktiviert + +
+
+ + + + + + + + + QR-Code + Statistik + Bearbeiten + +
+ field() ?> + +
+ +
+ field() ?> + +
+
+
+
+ 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

+

+ +

+
+ +
+ +
+ +
+
+ Vorschau des QR-Codes + +
+ +

Der QR-Code verweist auf den Kurzlink – die Ziel-URL kannst du also + später ändern, ohne den Code neu drucken zu müssen.

+ +
> +
+
+ + +

SVG ist vektorbasiert und damit für den Druck in jeder Größe geeignet.

+
+ +
+
+ field() ?> +

Gestaltung

+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ +
+ Voreinstellungen +
+ + + + + + +
+
+ +
+
+ + +

Gilt für den PNG-Download; SVG skaliert verlustfrei.

+
+ +
+ + + hasLogo()): ?> + +

Wegen des Logos fest auf H gesetzt.

+ +

Höher = robuster gegen Beschädigung, aber dichteres Muster.

+ +
+
+ +
+ + +

Die Spezifikation empfiehlt mindestens 4 Module ruhige Fläche rundherum.

+
+ + hasLogo()): ?> +
+ + +
+ +
+ +
+ + +
+ + +
+

Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte.

+
+ +
+

Logo in der Mitte

+ + hasLogo()): ?> +
+ Aktuelles Logo +
+ logoPath) ?> +
+ field() ?> + +
+
+
+ + +
+ 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. +
+ + + +
+ field() ?> +
+ + +

PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert; + die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.

+
+ +
+
+
+
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. +

+
+ +
+ field() ?> + +
+ + +

+ Damit niemand anderes diese Installation übernimmt, steht das Token auf dem Server: +

+
+ Wo finde ich das Token? +
    +
  • Per FTP/SFTP oder Dateimanager: in der Datei +
  • +
  • Docker: docker compose logs app | grep Einrichtungs-Token + oder docker compose exec app cat data/setup-token.txt
  • +
  • Mit Shell-Zugang: cat
  • +
+

+ Nach abgeschlossener Einrichtung wird die Datei automatisch gelöscht. +

+
+ +

+ +
+ +
+ + + +

+ +
+ +
+ + +

Mindestens Zeichen.

+ +

+ +
+ +
+ + + +

+ +
+ + +
+ +

+ 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 . '
'; +}; +?> +
+
+

Statistik

+

+ + → + +

+
+ +
+ +
+
+ Klicks gesamt + +
+
+ Im Zeitraum + +
+
+ Erstellt + + + +
+
+ Letzter Klick + + + +
+
+ +
+
+

Klicks im Zeitverlauf

+
+ $label): ?> + + +
+
+ +
+ + +
+
+ +
+
+

Top-Referrer

+ +
+ +
+

Gerätetyp

+ +
+ +
+

Browser

+ +
+ +
+

Plattform

+ +
+
+ +
+

Letzte Aufrufe

+ +

Noch keine Aufrufe erfasst.

+ +
+ + + + + + + + + + + + + + + + + + + + + +
ZeitpunktReferrerGerätBrowserPlattform
+
+ +

+ Es werden weder IP-Adressen noch personenbezogene Merkmale gespeichert – nur Zeitpunkt, Referrer + und die aus dem User-Agent abgeleiteten Kategorien. +

+