179 lines
7.0 KiB
JavaScript
179 lines
7.0 KiB
JavaScript
// ── 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) ───────────────────────────
|
||
(async function () {
|
||
const cards = Array.from(document.querySelectorAll('.card[data-status-url]'));
|
||
if (cards.length === 0) return;
|
||
|
||
const endpoint = './server_status.php';
|
||
|
||
const normalize = (url) => {
|
||
if (!url) return '';
|
||
return String(url).replace(/\/+$/, '');
|
||
};
|
||
|
||
const parsePossiblyUtf16Json = (text) => {
|
||
if (!text) return null;
|
||
const cleaned = String(text)
|
||
.replace(/^?\uFEFF/, '')
|
||
.replace(/\u0000/g, '')
|
||
.trim();
|
||
return JSON.parse(cleaned);
|
||
};
|
||
|
||
const applyPayload = (payload) => {
|
||
const byUrl = payload && payload.byUrl ? payload.byUrl : {};
|
||
const byUrlNormalized = payload && payload.byUrlNormalized ? payload.byUrlNormalized : {};
|
||
|
||
let applied = 0;
|
||
for (const card of cards) {
|
||
const key = normalize(card.getAttribute('data-status-url'));
|
||
const svc = byUrl[key] || byUrl[key + '/'] || byUrlNormalized[key] || null;
|
||
if (!svc) continue;
|
||
|
||
const state = svc.state || 'unknown';
|
||
const badge = card.querySelector('[data-status-badge="1"]');
|
||
if (!badge) continue;
|
||
|
||
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 = 'Unbekannt';
|
||
badge.title = svc.detail || 'Unbekannt';
|
||
}
|
||
applied++;
|
||
}
|
||
|
||
console.debug('[server-status] applied badges:', applied);
|
||
};
|
||
|
||
const fetchAndApply = async () => {
|
||
const res = await fetch(endpoint, { cache: 'no-store' });
|
||
if (!res.ok) throw new Error('status_fetch_failed_' + res.status);
|
||
|
||
const text = await res.text();
|
||
let payload;
|
||
try {
|
||
payload = parsePossiblyUtf16Json(text);
|
||
} catch (e) {
|
||
console.warn('[server-status] JSON parse failed, first 120 chars:', text.slice(0, 120));
|
||
throw e;
|
||
}
|
||
if (!payload) throw new Error('status_json_empty');
|
||
|
||
applyPayload(payload);
|
||
};
|
||
|
||
const delays = [1000, 1000, 1500];
|
||
for (const d of delays) {
|
||
await new Promise(r => setTimeout(r, d));
|
||
try {
|
||
await fetchAndApply();
|
||
break;
|
||
} catch (e) {
|
||
console.warn('[server-status] update failed:', e);
|
||
}
|
||
}
|
||
})();
|