// ── Typewriter ─────────────────────────────────────────── (function () { const el = document.getElementById('typewriter'); const words = ['Entwickler', 'Schüler', 'Macher', 'Selfhoster', 'Tüftler']; let wi = 0, ci = 0, deleting = false; function type() { if (!el) return; const word = words[wi]; el.textContent = deleting ? word.slice(0, ci--) : word.slice(0, ci++); let delay = deleting ? 60 : 110; if (!deleting && ci > word.length) { delay = 1400; deleting = true; } if (deleting && ci < 0) { deleting = false; wi = (wi + 1) % words.length; ci = 0; delay = 300; } setTimeout(type, delay); } setTimeout(type, 900); })(); // ── Particles ──────────────────────────────────────────── (function () { const canvas = document.getElementById('particle-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const COLORS = ['#6366f1', '#0ea5e9', '#a855f7', '#ec4899', '#22d3ee']; let W, H, particles = []; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } function rand(a, b) { return Math.random() * (b - a) + a; } function mkP() { return { x: rand(0,W), y: rand(0,H), r: rand(0.8,2.2), dx: rand(-0.25,0.25), dy: rand(-0.35,-0.08), alpha: rand(0.2,0.8), fade: rand(0.002,0.006), color: COLORS[Math.floor(Math.random()*COLORS.length)] }; } function init() { resize(); particles = Array.from({length:90}, mkP); } function draw() { ctx.clearRect(0, 0, W, H); for (let p of particles) { ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); ctx.fillStyle = p.color; ctx.globalAlpha = p.alpha; ctx.fill(); p.x += p.dx; p.y += p.dy; p.alpha -= p.fade; if (p.alpha <= 0 || p.y < -5) Object.assign(p, mkP(), { y: H+5, alpha: rand(0.2,0.7) }); } ctx.globalAlpha = 1; requestAnimationFrame(draw); } window.addEventListener('resize', resize); init(); draw(); })(); // ── 3D Tilt Cards ──────────────────────────────────────── document.querySelectorAll('.card').forEach(card => { card.addEventListener('mousemove', function (e) { const rect = this.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const dx = (e.clientX - cx) / (rect.width / 2); const dy = (e.clientY - cy) / (rect.height / 2); const rotX = (-dy * 8).toFixed(2); const rotY = ( dx * 8).toFixed(2); this.style.transform = `perspective(600px) rotateX(${rotX}deg) rotateY(${rotY}deg) translateY(-3px) scale(1.02)`; // glare let glare = this.querySelector('.card-glare'); if (!glare) { glare = document.createElement('div'); glare.className = 'card-glare'; this.appendChild(glare); } const gx = ((e.clientX - rect.left) / rect.width * 100).toFixed(1); const gy = ((e.clientY - rect.top) / rect.height * 100).toFixed(1); glare.style.background = `radial-gradient(circle at ${gx}% ${gy}%, rgba(255,255,255,0.12) 0%, transparent 65%)`; }); card.addEventListener('mouseleave', function () { this.style.transform = ''; this.querySelector('.card-glare')?.remove(); }); // Ripple card.addEventListener('click', function (e) { const circle = document.createElement('span'); const diameter = Math.max(this.clientWidth, this.clientHeight); const rect = this.getBoundingClientRect(); circle.classList.add('ripple'); circle.style.width = circle.style.height = diameter + 'px'; circle.style.left = (e.clientX - rect.left - diameter / 2) + 'px'; circle.style.top = (e.clientY - rect.top - diameter / 2) + 'px'; this.querySelector('.ripple')?.remove(); this.appendChild(circle); }); }); // ── Server Status Badges (async, no reload) ─────────────── (async function () { const cards = Array.from(document.querySelectorAll('.card[data-status-url]')); if (cards.length === 0) return; const endpoint = '/api/server_status.php'; const normalize = (url) => { if (!url) return ''; return String(url).replace(/\/+$/, ''); }; const safeJsonParse = (text) => { const cleaned = String(text || '') .replace(/^\uFEFF/, '') .replace(/\u0000/g, '') .trim(); if (!cleaned) return null; return JSON.parse(cleaned); }; const apply = (byUrl) => { for (const card of cards) { const key = normalize(card.getAttribute('data-status-url')); const svc = byUrl[key] || byUrl[key + '/'] || null; if (!svc) continue; const badge = card.querySelector('[data-status-badge="1"]'); if (!badge) continue; const state = svc.state || 'unknown'; badge.classList.remove('status-badge--unknown', 'status-badge--up', 'status-badge--down'); badge.classList.add('status-badge--' + state); if (state === 'up') { badge.textContent = 'Online'; badge.title = 'Online'; } else if (state === 'down') { badge.textContent = 'Offline'; badge.title = svc.detail || 'Offline'; } else { badge.textContent = 'Lädt...'; badge.title = svc.detail || 'Lädt...'; } } }; // Exactly ~1s delay, then fetch. await new Promise(r => setTimeout(r, 1000)); try { const res = await fetch(endpoint, { cache: 'no-store' }); if (!res.ok) return; const text = await res.text(); const payload = safeJsonParse(text); if (!payload || !payload.byUrlNormalized) return; apply(payload.byUrlNormalized); } catch (e) { // keep "Lädt..." } })();