/* ============================================================= 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(); } })();