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

+