feat(ui): Serverseitig gerenderte Oberflaeche ohne Build-Schritt

Eigenes CSS statt Framework, Vanilla-JavaScript, heller und dunkler
Modus. Ohne JavaScript bleibt alles bedienbar; das Liniendiagramm
faellt auf eine selbst gezeichnete Balkendarstellung zurueck, wenn das
CDN nicht erreichbar ist.

Chart.js ist auf eine Version festgelegt und per Subresource Integrity
an seinen Hash gebunden.

Die Domain-Seite enthaelt eine Schritt-fuer-Schritt-Anleitung fuer DNS,
Hosting-Panel und Zertifikat samt Fehlersuche.
This commit is contained in:
2026-08-03 18:11:40 +02:00
parent 7238e7a937
commit fabaef1f2b
19 changed files with 2378 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
/* =============================================================
urlshort-qr-gen – Vanilla JavaScript, kein Build-Schritt.
Alles hier ist optionale Politur: ohne JS bleibt die Anwendung
vollstaendig bedienbar (serverseitig gerenderte Formulare).
============================================================= */
(function () {
'use strict';
/* ---------- Kopieren in die Zwischenablage ---------- */
document.addEventListener('click', function (event) {
var button = event.target.closest('[data-copy]');
if (!button) {
return;
}
var value = button.getAttribute('data-copy');
var done = function () {
button.classList.add('is-copied');
window.setTimeout(function () { button.classList.remove('is-copied'); }, 1600);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(value).then(done, function () { fallbackCopy(value, done); });
} else {
fallbackCopy(value, done);
}
});
function fallbackCopy(value, done) {
var field = document.createElement('textarea');
field.value = value;
field.setAttribute('readonly', '');
field.style.position = 'fixed';
field.style.opacity = '0';
document.body.appendChild(field);
field.select();
try { document.execCommand('copy'); done(); } catch (e) { /* still */ }
document.body.removeChild(field);
}
/* ---------- Rueckfrage vor destruktiven Aktionen ---------- */
document.addEventListener('submit', function (event) {
var form = event.target.closest('form[data-confirm]');
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
event.preventDefault();
}
});
/* ---------- Liniendiagramme (Chart.js vom CDN) ---------- */
var chartContainers = document.querySelectorAll('[data-chart="line"]');
if (chartContainers.length > 0) {
loadChartJs(function (ok) {
chartContainers.forEach(function (container) {
if (ok) {
renderLineChart(container);
} else {
renderFallbackChart(container);
}
});
});
}
function loadChartJs(callback) {
if (window.Chart) {
callback(true);
return;
}
var script = document.createElement('script');
// Feste Version mit Subresource Integrity: der Browser laedt die
// Datei nur, wenn ihr Hash exakt stimmt. Selbst ein manipuliertes
// CDN koennte hier also keinen fremden Code einschleusen.
// Ist das CDN nicht erreichbar (oder der Hash falsch), greift
// renderFallbackChart() mit einer eigenen Darstellung.
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';
script.integrity = 'sha384-9nhczxUqK87bcKHh20fSQcTGD4qq5GhayNYSYWqwBkINBhOfQLg/P5HG5lF1urn4';
script.crossOrigin = 'anonymous';
script.referrerPolicy = 'no-referrer';
script.onload = function () { callback(true); };
script.onerror = function () { callback(false); };
document.head.appendChild(script);
}
function seriesOf(container) {
try {
return JSON.parse(container.getAttribute('data-series') || '[]');
} catch (e) {
return [];
}
}
function cssVar(name, fallback) {
var value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function formatDay(iso) {
var parts = String(iso).split('-');
return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' : iso;
}
function renderLineChart(container) {
var canvas = container.querySelector('canvas');
var series = seriesOf(container);
if (!canvas || series.length === 0) {
return;
}
var primary = cssVar('--primary', '#2c5cc5');
var muted = cssVar('--muted', '#667085');
var border = cssVar('--border', '#e2e5ea');
if (container._chart) {
container._chart.destroy();
}
container._chart = new window.Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: series.map(function (point) { return formatDay(point.date); }),
datasets: [{
label: 'Klicks',
data: series.map(function (point) { return point.clicks; }),
borderColor: primary,
backgroundColor: 'rgba(44, 92, 197, .12)',
borderWidth: 2,
pointRadius: series.length > 60 ? 0 : 2,
pointHoverRadius: 4,
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (item) { return item.parsed.y + ' Klick(s)'; }
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: muted, maxTicksLimit: 10, autoSkip: true, maxRotation: 0 }
},
y: {
beginAtZero: true,
grid: { color: border },
border: { display: false },
ticks: { color: muted, precision: 0, maxTicksLimit: 6 }
}
}
}
});
}
/* Wenn das CDN nicht erreichbar ist: schlichte Balken als Ersatz. */
function renderFallbackChart(container) {
var series = seriesOf(container);
var canvas = container.querySelector('canvas');
if (canvas) {
canvas.remove();
}
if (series.length === 0) {
return;
}
var max = Math.max.apply(null, series.map(function (point) { return point.clicks; })) || 1;
var wrapper = document.createElement('div');
wrapper.className = 'chart-fallback';
wrapper.style.cssText = 'display:flex;align-items:flex-end;gap:2px;height:130px;';
series.forEach(function (point) {
var bar = document.createElement('div');
bar.title = point.date + ': ' + point.clicks + ' Klick(s)';
bar.style.cssText = 'flex:1;background:var(--primary);border-radius:2px 2px 0 0;min-height:2px;height:'
+ Math.round(point.clicks / max * 100) + '%;opacity:.85;';
wrapper.appendChild(bar);
});
container.appendChild(wrapper);
}
/* ---------- Kurzlink-Formular: Domain-Prefix ---------- */
var domainSelect = document.querySelector('[data-domain-select]');
var slugPrefix = document.querySelector('[data-slug-prefix]');
if (domainSelect && slugPrefix) {
domainSelect.addEventListener('change', function () {
var option = domainSelect.options[domainSelect.selectedIndex];
if (option) {
slugPrefix.textContent = (option.getAttribute('data-base-url') || '') + '/';
}
});
}
/* ---------- QR-Code: Live-Vorschau ---------- */
var qr = document.querySelector('[data-qr]');
if (qr) {
initQrPreview(qr);
}
function initQrPreview(root) {
var form = document.getElementById('qr-form');
var image = document.getElementById('qr-image');
var spinner = root.querySelector('[data-qr-spinner]');
var warningBox = root.querySelector('[data-qr-warning]');
var warningText = root.querySelector('[data-qr-warning-text]');
var stage = root.querySelector('.qr-preview__stage');
var previewUrl = root.getAttribute('data-preview-url');
var downloadUrl = root.getAttribute('data-download-url');
var timer = null;
if (!form || !image) {
return;
}
/* Hex-Feld und Farbwaehler synchron halten. */
root.querySelectorAll('[data-color-sync]').forEach(function (input) {
input.addEventListener('input', function () {
var partner = document.getElementById(input.getAttribute('data-color-sync'));
if (!partner) {
return;
}
var value = input.value.trim();
if (input.type === 'text') {
if (!/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)) {
return;
}
partner.value = expandHex(value);
} else {
partner.value = value;
}
schedule();
});
});
/* Farbvoreinstellungen */
root.querySelectorAll('[data-preset]').forEach(function (button) {
button.addEventListener('click', function () {
var pair = button.getAttribute('data-preset').split(',');
setColor('foreground', pair[0]);
setColor('background', pair[1]);
schedule();
});
});
/* Regler-Anzeigen */
root.querySelectorAll('[data-output]').forEach(function (input) {
var output = document.getElementById(input.getAttribute('data-output'));
if (output) {
input.addEventListener('input', function () { output.textContent = input.value; });
}
});
root.querySelectorAll('[data-qr-input]').forEach(function (input) {
input.addEventListener('input', schedule);
input.addEventListener('change', schedule);
});
var resetButton = root.querySelector('[data-qr-reset]');
if (resetButton) {
resetButton.addEventListener('click', function () {
window.setTimeout(function () {
root.querySelectorAll('[data-color-sync]').forEach(function (input) {
var partner = document.getElementById(input.getAttribute('data-color-sync'));
if (partner && input.type === 'color') {
partner.value = input.value;
}
});
root.querySelectorAll('[data-output]').forEach(function (input) {
var output = document.getElementById(input.getAttribute('data-output'));
if (output) { output.textContent = input.value; }
});
update();
}, 0);
});
}
function setColor(name, value) {
var picker = document.getElementById(name);
var text = document.getElementById(name + '_text');
if (picker) { picker.value = value; }
if (text) { text.value = value; }
}
function expandHex(value) {
var hex = value.replace('#', '');
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
return '#' + hex.toLowerCase();
}
function params() {
var data = new FormData(form);
var query = new URLSearchParams();
['foreground', 'background', 'size', 'margin', 'error_correction', 'logo_scale'].forEach(function (key) {
var value = data.get(key);
if (value !== null) {
query.set(key, String(value));
}
});
query.set('logo_punchout', data.get('logo_punchout') ? '1' : '0');
return query;
}
function schedule() {
window.clearTimeout(timer);
timer = window.setTimeout(update, 260);
}
function update() {
var query = params();
if (spinner) { spinner.hidden = false; }
var next = new Image();
next.onload = function () {
image.src = next.src;
if (spinner) { spinner.hidden = true; }
};
next.onerror = function () {
if (spinner) { spinner.hidden = true; }
};
next.src = previewUrl + '?' + query.toString();
root.querySelectorAll('[data-qr-download]').forEach(function (anchor) {
var format = anchor.getAttribute('data-qr-download');
anchor.href = downloadUrl + '?' + query.toString() + '&format=' + format;
});
if (stage) {
stage.style.setProperty('--qr-bg', query.get('background') || '#ffffff');
}
checkContrast(query.get('foreground'), query.get('background'));
}
/* Kontrastpruefung nach WCAG – identische Regeln wie serverseitig. */
function luminance(hex) {
var value = expandHex(hex || '#000000').substring(1);
var channels = [0, 2, 4].map(function (offset) {
var channel = parseInt(value.substr(offset, 2), 16) / 255;
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function checkContrast(foreground, background) {
if (!warningBox || !warningText) {
return;
}
var lf = luminance(foreground);
var lb = luminance(background);
var ratio = (Math.max(lf, lb) + 0.05) / (Math.min(lf, lb) + 0.05);
var message = null;
if (ratio < 4) {
message = 'Zu geringer Kontrast (' + ratio.toFixed(1)
+ ':1, empfohlen mindestens 4.0:1). Der QR-Code ist möglicherweise nicht zuverlässig scannbar.';
} else if (lf > lb) {
message = 'Heller Vordergrund auf dunklem Hintergrund: viele Scanner erkennen invertierte '
+ 'QR-Codes nicht. Besser dunkle Module auf hellem Grund.';
}
warningText.textContent = message || '';
warningBox.hidden = message === null;
}
update();
}
})();