Add service worker for push notifications, create calendar layout, and implement WLAN QR code page

- Implemented a service worker (sw.js) to handle push notifications with dynamic options and notification click events.
- Created a calendar layout in test.html with a grid system for displaying events across days and times.
- Developed a visually engaging WLAN QR code page (wlan.html) with animated backgrounds, particle effects, and tips for connecting to the network.
This commit is contained in:
2026-02-22 00:50:22 +01:00
parent 6b96cd2012
commit 038910e9f0
26 changed files with 32980 additions and 5 deletions

418
public/ausbildung_quiz.html Normal file
View File

@@ -0,0 +1,418 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Quiz: Ausbildung</title>
<script src="/lib/pocketbase.umd.js"></script>
<script defer src="https://analytics.fsae41.de/script.js" data-website-id="257da02e-d678-47b6-b036-e3bdabaf1405"></script>
<style>
:root {
--bg: #f7fafc;
--card: #ffffff;
--accent: #2563eb;
--muted: #6b7280;
--correct: #16a34a;
--wrong: #ef4444
}
body {
font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
background: var(--bg);
padding: 18px
}
.card {
background: var(--card);
border-radius: 12px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
padding: 18px;
max-width: 760px
}
.q-head {
display: flex;
gap: 12px;
align-items: flex-start
}
.q-id {
display: none
}
h2 {
margin: 0 0 8px 0;
font-size: 18px
}
p.meta {
margin: 0 0 14px 0;
color: var(--muted)
}
p.intro {
margin-bottom: 12px;
color: var(--muted)
}
ul.answers {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px
}
ul.answers li {
border: 1px solid #e6e9ef;
border-radius: 10px;
padding: 12px;
cursor: pointer;
transition: all .12s
}
ul.answers li.selected {
border-color: var(--accent);
background: rgba(37, 99, 235, 0.06)
}
ul.answers li:hover {
transform: translateY(-2px)
}
ul.answers li.correct {
border-color: var(--correct);
background: rgba(16, 185, 129, 0.06)
}
ul.answers li.incorrect {
border-color: var(--wrong);
background: rgba(239, 68, 68, 0.06)
}
.badge-container {
display: flex;
justify-content: flex-end;
margin-bottom: 4px
}
.badge {
font-size: 12px;
padding: 4px 8px;
border-radius: 999px;
background: #f1f5f9;
color: var(--muted)
}
.controls {
display: flex;
justify-content: space-between;
margin-top: 12px;
flex-wrap: wrap
}
.control-right {
display: flex;
gap: 8px
}
.btn {
padding: 8px 12px;
border-radius: 8px;
border: 0;
cursor: pointer
}
.btn.primary {
background: var(--accent);
color: #fff
}
.btn.ghost {
background: transparent;
border: 1px solid #e6e9ef
}
.btn.danger {
background: var(--wrong);
color: #fff
}
.hint {
margin-top: 10px;
font-size: 13px;
color: var(--muted)
}
#report-box {
display: none;
margin-top: 12px
}
#report-box textarea {
width: 100%;
min-height: 80px;
border: 1px solid #e6e9ef;
border-radius: 8px;
padding: 8px;
font-family: inherit
}
#report-box button {
margin-top: 8px
}
.dropdown-container {
margin-bottom: 12px
}
select#question-select {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #e6e9ef;
font-family: inherit
}
</style>
</head>
<body>
<div id="quiz-card" class="card" role="region" aria-label="Ausbildungsfrage">
<div class="dropdown-container">
<label for="question-select">Frage auswählen ID: </label>
<select id="question-select"></select>
</div>
<p class="intro" id="q-textIntro">Als Ausbilder setzen Sie zur Anleitung der Auszubildenden auch die
Vier-Stufen-Methode ein.</p>
<div class="q-head">
<div class="q-id" id="q-id">ID 344</div>
<div style="flex:1">
<h2 id="q-text">Welches der folgenden Merkmale trifft auf die erste Stufe zu?</h2>
<p class="meta" id="q-category">Kategorie: 3</p>
<div class="badge-container">
<p class="badge" id="q-type">1 Antwort richtig</p>
</div>
</div>
</div>
<p class="intro" id="answersIntro">Die Auszubildenden sollen…</p>
<ul id="answers" class="answers" role="list">
<!-- Antworten werden hier gerendert -->
</ul>
<div class="controls">
<button class="btn danger" id="report-error">Fehler melden</button>
<button class="btn" id="last-q">Zurück</button>
<div class="control-right">
<button class="btn" id="next-q">Weiter</button>
<button class="btn ghost" id="clear-selection">Auswahl zurücksetzen</button>
<button class="btn primary" id="show-correct">Korrekte Antwort anzeigen</button>
</div>
</div>
<div id="report-box" style="display: none;">
<textarea id="report-text" placeholder="Bitte beschreiben Sie den Fehler..."></textarea>
<button class="btn primary" id="send-report">Absenden</button>
</div>
</div>
<script>
const state = { item: null, selected: [], allQuestions: [] };
function populateDropdown() {
const select = document.getElementById('question-select');
select.innerHTML = '';
state.allQuestions.forEach(q => {
const option = document.createElement('option');
option.value = q.id;
option.textContent = `${q.id}`;
option.selected = (state.item && state.item.id === q.id);
select.appendChild(option);
});
select.addEventListener('change', () => {
const selectedId = select.value;
const question = state.allQuestions.find(q => q.id == selectedId);
if (question) window.updateQuiz(question);
});
}
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function render(data) {
state.item = data;
state.selected = [];
document.getElementById('q-id').textContent = 'ID ' + (data.id ?? '');
document.getElementById('q-text').textContent = data.text ?? '';
document.getElementById('q-textIntro').innerHTML = data.textIntro || '&nbsp;';
if (data.textIntro) {
document.getElementById('q-textIntro').style.display = 'block';
} else {
document.getElementById('q-textIntro').style.display = 'none';
}
document.getElementById('q-category').textContent = 'Kategorie: ' + (data.category ?? '');
document.getElementById('answersIntro').innerHTML = data.answersIntro || '&nbsp;';
if (data.answersIntro) {
document.getElementById('answersIntro').style.display = 'block';
} else {
document.getElementById('answersIntro').style.display = 'none';
}
const correctCount = (data.answers || []).filter(a => a.correct).length;
document.getElementById('q-type').textContent = `${correctCount} Antwort${correctCount !== 1 ? 'en' : ''} richtig`;
const answersEl = document.getElementById('answers');
answersEl.innerHTML = '';
let answers = [...(data.answers || [])];
//answers = shuffle(answers);
answers.forEach((a, i) => {
const li = document.createElement('li');
li.setAttribute('role', 'button');
li.tabIndex = 0;
li.dataset.index = i;
li.dataset.answerId = a.id;
li.innerHTML = `<div>${a.text}</div>`;
li.addEventListener('click', () => toggleAnswer(i));
li.addEventListener('keydown', (e) => { if (e.key === "Enter" || e.key === " ") toggleAnswer(i) });
answersEl.appendChild(li);
});
const lis = document.querySelectorAll('#answers li');
lis.forEach(li => { li.classList.remove('incorrect', 'correct', 'selected'); li.style.boxShadow = 'none' });
state.item.shuffledAnswers = answers;
}
function toggleAnswer(index) {
const li = document.querySelectorAll('#answers li')[index];
if (state.selected.includes(index)) {
state.selected = state.selected.filter(i => i !== index);
li.classList.remove('selected');
} else {
state.selected.push(index);
li.classList.add('selected');
}
}
window.updateQuiz = function (data) {
if (data && data.answers) {
data.answers = data.answers.map(a => ({ id: a.id, text: a.text, correct: !!a.correct }));
}
render(data);
};
window.showCorrect = function () {
if (!state.item) return;
const lis = document.querySelectorAll('#answers li');
lis.forEach((li, i) => {
const ans = state.item.answers[i];
if (ans && ans.correct) {
li.classList.add('correct');
if (state.selected.includes(i)) li.classList.add('selected');
} else if (state.selected.includes(i)) {
li.classList.add('incorrect');
}
});
};
window.setCorrect = function ({ index = null, answerId = null } = {}) {
if (!state.item) return;
const answers = state.item.answers;
if (index == null && answerId == null) return;
answers.forEach((a, i) => a.correct = ((index != null && i === index) || (answerId != null && a.id === answerId)));
render(state.item);
};
document.getElementById('show-correct').addEventListener('click', () => window.showCorrect());
document.getElementById('clear-selection').addEventListener('click', () => {
state.selected = [];
const lis = document.querySelectorAll('#answers li');
lis.forEach(li => { li.classList.remove('incorrect', 'correct', 'selected'); li.style.boxShadow = 'none' });
});
const nextbt = document.getElementById("next-q");
const lastbt = document.getElementById("last-q");
nextbt.addEventListener("click", () => {
const select = document.getElementById('question-select');
// Nur weitergehen, wenn es noch ein nächstes Element gibt
if (select.selectedIndex < select.options.length - 1) {
select.selectedIndex++;
const selectedId = select.value;
const question = state.allQuestions.find(q => q.id == selectedId);
if (question) window.updateQuiz(question);
}
})
lastbt.addEventListener("click", () => {
const select = document.getElementById('question-select');
if (select.selectedIndex > 0) {
select.selectedIndex--;
const selectedId = select.value;
const question = state.allQuestions.find(q => q.id == selectedId);
if (question) window.updateQuiz(question);
}
})
const reportBtn = document.getElementById('report-error');
const reportBox = document.getElementById('report-box');
const sendReport = document.getElementById('send-report');
reportBtn.addEventListener('click', () => {
reportBox.style.display = reportBox.style.display === 'none' ? 'block' : 'none';
});
sendReport.addEventListener('click', async () => {
const text = document.getElementById('report-text').value.trim();
if (text) {
console.log('Fehlerbericht gesendet:', text);
alert('Vielen Dank für Ihre Rückmeldung!');
const data = {
"question": state.item.id,
"text": text
};
const record = await pb.collection('ADA_report').create(data);
document.getElementById('report-text').value = '';
reportBox.style.display = 'none';
} else {
alert('Bitte geben Sie eine Fehlerbeschreibung ein.');
}
});
// Beispiel-Initialisierung mit mehreren Fragen
state.allQuestions = [];
let pb = new PocketBase();
(async () => {
const records = await pb.collection('ADA_question').getFullList();
console.log(records);
records.forEach(r => {
const item = r;
state.allQuestions.push(item);
});
let r = Math.floor(Math.random() * state.allQuestions.length);
window.updateQuiz(state.allQuestions[r]);
populateDropdown();
})();
</script>
</body>
</html>

Binary file not shown.

View File

@@ -1,11 +1,264 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebSite</title> <title>FSAE41.de</title>
<meta name="robots" content="noindex">
<meta name="robots" content="nofollow">
<link rel="icon" href="/schule.ico" type="image/x-icon">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<script defer src="https://analytics.fsae41.de/script.js"
data-website-id="257da02e-d678-47b6-b036-e3bdabaf1405"></script>
<link rel="stylesheet" href="https://www.w3schools.com/lib/w3-theme-teal.css">
<link rel="stylesheet" href="lib/css/mobiscroll.javascript.min.css">
<link rel="stylesheet/less" type="text/css" href="index.less" />
<script src="lib/pocketbase.umd.js"></script>
<script src="lib/mobiscroll.javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/less"></script>
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
<script defer src="index.js"></script>
</head> </head>
<body> <body>
<h1>MOIN</h1>
<!-- Header -->
<header class="w3-container w3-theme w3-padding">
<div class="w3-center">
<h1 class="w3-xxxlarge">FSAE41.de</h1>
<p>Informationen, Arbeiten & Hilfe</p>
<p>R * U * I = 0 =R = U * I</p>
</div>
</header>
<!-- Main content -->
<div class="w3-row-padding w3-margin-top">
<!-- Box 1: Linke Seite -->
<div class="w3-quarter">
<div class="w3-card w3-white w3-padding">
<h3 class="w3-text-teal">BBS</h3>
<ul class="w3-ul">
<li><a href="https://bbs-brinkstrasse.moodle-nds.de/">Moodle</a></li>
<li><a href="https://bbs-brinkstrasse.webuntis.com/WebUntis/?school=bbs-brinkstrasse">WebUntis</a></li>
<li><a href="/freigabe/Stundentafel-Abendform-2022.pdf">Stundentafel</a></li>
<li><a href="https://virtueller-stundenplan.org/page2/page-22/" target="_blank">Digit. Schülerausweis</a></li>
</ul>
</div>
<div class="w3-card w3-white w3-padding w3-margin-top">
<h3 class="w3-text-teal">AEVO</h3>
<ul class="w3-ul">
<!--<li><a href="/ausbildung_quiz.html">AEVO Fragen</a></li>-->
<li><a href="https://www.bibb.de/dienst/berufesuche/de/index_berufesuche.php" target="_blank">BIBB AEVO</a>
</li>
<li><a href="https://www.ihk.de/osnabrueck/" target="_blank">IHK-OSNA</a> / <a
href="https://www.hwk-osnabrueck.de/" target="_blank">HWK-OSNA</a></li>
</ul>
</div>
<div class="w3-card w3-white w3-padding w3-margin-top">
<h3 class="w3-text-teal">Services</h3>
<ul class="w3-ul">
<li><a href="/wlan.html" target="_blank">FSAE41 WLAN</a></li>
<li><a href="https://link.fsae41.de/" target="_blank">LinkShare</a></li>
<li><a href="https://etherpad.fsae41.de/" target="_blank">Etherpad</a></li>
<li><a href="https://analytics.fsae41.de/share/dGTijC1mnk4dU4iZ" target="_blank">Web Analytics</a></li>
</ul>
</div>
<div class="w3-card w3-white w3-padding w3-margin-top">
<h3 class="w3-text-teal">Nützliche Links</h3>
<ul class="w3-ul">
<li><a href="https://www.falstad.com/circuit/" target="_blank">falstad - Web "MultiSim"</a></li>
<li><a href="/old/index.html" target="_blank">Alte Website</a></li>
</ul>
</div>
</div>
<!-- Box 2: -->
<div class="w3-half">
<div class="w3-card w3-white w3-padding w3-margin-bottom">
<h1 id="main-heading" class="w3-text-teal w3-round-large w3-center w3-lime w3-padding-12"></h1>
<h3 id="target-info" class="w3-center"></h3>
<h2 id="countdown" class="w3-center"></h2>
<div id="totals" class="w3-center"></div>
<!--<iframe src="/countdown.html" frameborder="0" style="width:100%;"></iframe>-->
</div>
<div class="mainbox w3-margin-bottom">
<img src="https://fsae41.de/random/pic" style="width:100%">
</div>
</div>
<!-- Box 3: -->
<div class="w3-quarter">
<!--
<div class="mainbox w3-margin-bottom">
<img src="https://fsae41.de/random/pic" style="width:100%;max-width:600px">
</div>
-->
<div class="mainbox events" id="events">
<h3>Termine</h3>
</div>
<!--
<div class="w3-card w3-white w3-padding w3-margin-top" id="events">
<h3 class="w3-text-teal">Stundenplan</h3>
</div>
-->
<div class="mainbox w3-margin-top">
<h3 class="w3-text-teal">Sonder Funktionen</h3>
<button class="w3-button w3-teal w3-margin-bottom w3-round" onclick="openAuth()">Login</button>
<button class="w3-button w3-teal w3-margin-bottom w3-round" onclick="addPic()">Foto hochladen</button>
<button id="notify-btn" class="w3-button w3-teal w3-margin-bottom w3-round">Notify me!</button>
<script>
const VAPID_PUBLIC_KEY = 'BPPom1qN0L5NF90gxpABHTY2gjXAgCYVvPwXYdfrwxJ1O26Za9A80f7ZcCy6P8vwzbT8dhMvrFzWUjuYN136IFA';
// Button Event
document.getElementById('notify-btn').addEventListener('click', async () => {
if (!("Notification" in window)) {
alert("Dieser Browser unterstützt keine Desktop-Benachrichtigungen.");
return;
}
// Notification permission anfragen
const permission = await Notification.requestPermission();
if (permission !== "granted") {
alert("Benachrichtigungen wurden nicht erlaubt.");
return;
}
// Service Worker registrieren
if (!('serviceWorker' in navigator)) {
alert("Service Worker werden in diesem Browser nicht unterstützt.");
return;
}
const name = await prompt("Wie ist dein Name?") || "Gast";
if (name == "Gast") {
return;
}
try {
const registration = await navigator.serviceWorker.register('lib/sw.js')
console.log('Service Worker registriert:', registration);
// Push Subscription erstellen
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
console.log('Push Subscription:', subscription);
let payload = JSON.parse(JSON.stringify(subscription));
payload.username = name;
// Subscription zum Server senden
await fetch('/save-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
//Sofortige Notification als Test
new Notification(`Danke ${name}!`, {
body: "Du hast die Benachrichtigungen erfolgreich aktiviert."
});
} catch (err) {
console.error("Fehler bei Service Worker oder Push:", err);
}
});
// Helper: VAPID Key in Uint8Array konvertieren
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}
</script>
</div>
</div>
</div>
<!-- Footer -->
<footer class="w3-container w3-theme w3-center w3-padding-16 w3-margin-top">
<p>FSAE41.de Startseite<br>Danke an
<a href="https://lifab.de/OT" class="rainbow-text" style="font-size: 42px; text-decoration: underline;">die
OT</a>
und ChatGPT
</p>
</footer>
<div class="modul">
<!--Login/Sigup Page-->
<div id="auth-module">
<div class="auth-box" id="auth-box">
<button class="close-btn" onclick="closeAuth()">X</button>
<form id="loginForm">
<h2>Login</h2>
<input type="email" placeholder="E-Mail" required />
<input type="password" placeholder="Passwort" required />
<button type="submit">Einloggen</button>
<div class="switch">Noch kein Konto? <span onclick="toggleForms()">Registrieren</span></div>
</form>
<form id="signupForm" class="hidden">
<h2>Signup</h2>
<input type="text" placeholder="Name" required />
<input type="email" placeholder="E-Mail" required />
<input type="password" id="signupPassword" placeholder="Passwort" required />
<input type="password" id="signupPasswordConfirm" placeholder="Passwort wiederholen" required />
<button type="submit">Registrieren</button>
<div class="switch">Schon ein Konto? <span onclick="toggleForms()">Login</span></div>
</form>
</div>
</div>
</div>
<script id="sc">
fetch('/random/background').then(response => response.text()).then(d => {
if (d == "true") {
let x = document.createElement("img");
x.src = '/static/nico.png?' + new Date().getTime();
x.style.position = "fixed";
x.style.top = "0";
x.style.left = "0";
x.style.width = "100%";
x.style.height = "100%";
document.body.appendChild(x);
x.onload = () => {
setTimeout(() => {
x.style.transition = "opacity 5s";
x.style.opacity = "0";
setTimeout(() => {
document.body.removeChild(x);
}, 5100);
}, 3000);
}
}
})
// remove this script tag after execution
document.getElementById("sc").outerHTML = ""
</script>
</body> </body>
</html> </html>

339
public/index.js Normal file
View File

@@ -0,0 +1,339 @@
const PB = new PocketBase();
// Modul-funktion
function toggleForms() {
document.getElementById('loginForm').classList.toggle('hidden');
document.getElementById('signupForm').classList.toggle('hidden');
}
function openAuth() {
document.getElementById('auth-module').style.display = 'flex';
}
function closeAuth() {
document.getElementById('auth-module').style.display = 'none';
}
function shakeBox() {
const box = document.getElementById('auth-box');
box.classList.add('shake');
setTimeout(() => box.classList.remove('shake'), 300);
}
// Klick außerhalb des auth-box schließt das Modul
document.getElementById('auth-module').addEventListener('click', function (e) {
if (e.target === this) {
closeAuth();
}
});
document.getElementById('loginForm').addEventListener('submit', async function (e) {
e.preventDefault();
let email = e.target[0].value
let passwort = e.target[1].value
let authData = null;
try {
authData = await PB.collection('users').authWithPassword(email, passwort);
} catch (error) {
shakeBox();
console.error(error);
return;
}
// after the above you can also access the auth data from the authStore
console.log(PB.authStore.isValid);
console.log(PB.authStore.token);
console.log(PB.authStore.record.id);
closeAuth();
});
document.getElementById('signupForm').addEventListener('submit', async function (e) {
e.preventDefault();
let r = null;
let authData = null;
const data = {
"name": e.target[0].value,
"email": e.target[1].value,
"password": e.target[2].value,
"passwordConfirm": e.target[3].value
};
try {
r = await PB.collection('users').create(data);
} catch (error) {
shakeBox();
console.log(error);
return
}
try {
authData = await PB.collection('users').authWithPassword(data.email, data.passwort);
} catch (error) {
shakeBox();
console.error(error);
return;
}
closeAuth();
});
// Clock funktion
// TODO: getHtml from Server ??
function formatDate(date) {
return `${padZero(date.getDate(), 2)}.${padZero(date.getMonth() + 1, 2)}.${date.getFullYear()} um ${padZero(date.getHours(), 2)}:${padZero(date.getMinutes(), 2)}:${padZero(date.getSeconds(), 2)}`;
}
function padZero(num, places) {
return num.toString().padStart(places, '0');
}
function pad(d) {
return (d < 10) ? '0' + d.toString() : d.toString();
}
async function get_moodle() {
let r = await fetch("/moodle/getClasses")
let d = await r.json()
return d
}
function getNextOrCurrentLesson(data, now = new Date()) {
let nextLesson = null;
for (const dateKey in data) {
for (const entry of data[dateKey]) {
const year = Number(dateKey.slice(0, 4));
const month = Number(dateKey.slice(4, 6)) - 1;
const day = Number(dateKey.slice(6, 8));
const startHour = Math.floor(entry.startTime / 100);
const startMin = entry.startTime % 100;
const endHour = Math.floor(entry.endTime / 100);
const endMin = entry.endTime % 100;
const start = new Date(year, month, day, startHour, startMin);
const end = new Date(year, month, day, endHour, endMin);
// 🟢 Stunde läuft gerade
if (now >= start && now < end) {
return { start, end, entry, status: "running" };
}
// 🔵 Stunde kommt noch
if (start > now) {
if (!nextLesson || start < nextLesson.start) {
nextLesson = { start, end, entry, status: "next" };
}
}
}
}
return nextLesson;
}
function render_countdow_v2(two = false) {
const now = new Date();
const day = now.getDay();
const time = pad(now.getHours().toString()) + ':' + pad(now.getMinutes().toString());
let target;
if (!nextClass) {
requestAnimationFrame(render_countdow_v2);
return;
}
if (nextClass.status === "running") {
target = nextClass.end;
} else if (nextClass.status === "next") {
target = nextClass.start;
}
const distance = Math.abs(target - now);
document.getElementById("target-info").innerHTML = `Bis zum ${formatDate(target)} Uhr sind es noch:`;
// Display Label
if (nextClass.status === "running") {
document.getElementById("main-heading").textContent = `"${nextClass.entry.su[0].longname}" in Raum ${nextClass.entry.ro[0].name} findet grade statt`;
} else if (nextClass.status === "next") {
document.getElementById("main-heading").textContent = `Nächstes Stunde: ${nextClass.entry.su[0].longname} in Raum ${nextClass.entry.ro[0].name}`;
}
const days = distance / (1000 * 60 * 60 * 24);
const hours = (distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60);
const minutes = (distance % (1000 * 60 * 60)) / (1000 * 60);
const seconds = (distance % (1000 * 60)) / 1000;
const milliseconds = distance % 1000;
// Calculate total values
const totalDays = days;
const totalWeeks = Math.floor(totalDays / 7);
const totalHours = totalDays * 24;
const totalMinust = totalHours * 60;
const totalSeconds = totalMinust * 60;
const totalYears = (totalDays / 365).toFixed(2); // Calculate total years to 2 decimal places
// Display countdown/countup
document.getElementById("countdown").innerHTML = `${Math.floor(days)}d ${Math.floor(hours)}h ${Math.floor(minutes)}m ${Math.floor(seconds)}s ${padZero(milliseconds, 3)}ms`;
// Display totals with thousand separators
document.getElementById("totals").innerHTML = `Tage: ${totalDays.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 })} | Stunden: ${totalHours.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 })} | Minuten: ${totalMinust.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 })} | Sekunden: ${totalSeconds.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`;
requestAnimationFrame(render_countdow_v2);
}
async function init_countdown() {
document.getElementById("main-heading").textContent = "Lade Stundenplan...";
nextClass = getNextOrCurrentLesson(await get_moodle());
// Update next class every second
setInterval(async () => { nextClass = getNextOrCurrentLesson(await get_moodle()); }, 1000);
// Start the render loop
render_countdow_v2();
}
let nextClass = null;
init_countdown();
// fotos hochladen
async function addPic() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = false;
fileInput.accept = 'image/*';
fileInput.click();
// listen to file input changes and add the selected files to the form data
fileInput.addEventListener('change', async function () {
const formData = new FormData();
// set regular text field
formData.append('alt', "demo" || prompt("Bitte eine Bildbeschreibung eingeben:"));
formData.append('gewicht', 1);
formData.append('allowed', false);
for (let file of fileInput.files) {
formData.append('img', file);
}
const createdRecord = await PB.collection('images').create(formData);
alert("Bild erfolgreich hochgeladen!");
});
}
// Render event Data
function render_event(data) {
const eventContainer = document.getElementById('events');
const eventDiv = document.createElement('div');
eventDiv.classList.add('event');
const dateDiv = document.createElement('div');
dateDiv.classList.add('date');
const eventDate = new Date(data.start);
const monthNames = ["JAN", "FEB", "MÄR", "APR", "MAI", "JUN", "JUL", "AUG", "SEP", "OKT", "NOV", "DEZ"];
dateDiv.innerHTML = `${monthNames[eventDate.getMonth()]}<br><span>${eventDate.getDate()}</span>`;
if (eventDate.getDate() == new Date().getDate() && eventDate.getMonth() == new Date().getMonth() && eventDate.getFullYear() == new Date().getFullYear()) {
dateDiv.classList.add("today");
const duration = 15 * 1000,
animationEnd = Date.now() + duration,
defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
const interval = setInterval(function () {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
// since particles fall down, start a bit higher than random
confetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
})
);
confetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
})
);
}, 250);
}
const detailsDiv = document.createElement('div');
detailsDiv.classList.add("info")
const typeDiv = document.createElement('div');
typeDiv.classList.add('type');
typeDiv.textContent = data.type;
const titleStrong = document.createElement('strong');
titleStrong.textContent = data.title;
const info = document.createElement('p');
info.textContent = data.info;
const timeSmall = document.createElement('small');
time = eventDate.getHours().toString().padStart(2, '0') + ':' + eventDate.getMinutes().toString().padStart(2, '0');
timeSmall.textContent = `🕒 ${time} Uhr`;
if (data.type != "Klausur") {
titleStrong.classList.add('rainbow-text');
//typeDiv.classList.add('rainbow-text');
}
detailsDiv.appendChild(typeDiv);
detailsDiv.appendChild(titleStrong);
detailsDiv.appendChild(document.createElement('br'));
detailsDiv.appendChild(info);
if (data.type == "Klausur") {
detailsDiv.appendChild(timeSmall);
}
eventDiv.appendChild(dateDiv);
eventDiv.appendChild(detailsDiv);
eventContainer.appendChild(eventDiv);
}
async function add_event() {
const userDate = prompt("Bitte gib ein Datum im Format TT.MM.JJJJ ein (z. B. 05.11.2025):");
const userTime = prompt("Bitte gib eine Uhrzeit im Format HH:MM ein (z. B. 14:30):");
const [day, month, year] = userDate.split('.').map(Number);
const [hours, minutes] = userTime.split(':').map(Number);
// Date-Objekt erstellen (Monat ist 0-basiert!)
const userDateTime = new Date(year, month - 1, day, hours, minutes);
// example create data
const data = {
"date": userDateTime.toISOString(),
"title": prompt("Titel?"),
"info": prompt("Info?"),
"type": prompt("Typ?", "Klausur"),
};
const record = await PB.collection('termine').create(data);
}
(async () => {
const records = await PB.collection('termine').getList(1, 5, {
sort: '+start',
filter: `start >= "${new Date().getFullYear()}-${(new Date().getMonth() + 1).toString().padStart(2, '0')}-${(new Date().getDate()).toString().padStart(2, '0')} 00:00:00Z"`
});
records.items.forEach(record => render_event(record));
})();

197
public/index.less Normal file
View File

@@ -0,0 +1,197 @@
.mainbox {
padding: 8px 16px;
color: #000000;
background-color: #ffffff;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
}
.events {
h3 {
color: #009688;
}
.event {
display: flex;
align-items: center;
margin-bottom: 16px;
border-bottom: 1px solid #ddd;
padding-bottom: 8px;
.date {
width: 60px;
text-align: center;
background: #707070;
color: #fff;
padding: 10px;
border-radius: 8px;
font-weight: 600;
line-height: 1.2;
margin-right: 12px;
&.today {
background-color: #222222;
}
}
.info {
.type {
color: #009688;
font-weight: bold;
font-size: 13px;
text-transform: uppercase
}
p {
margin: 0;
padding: 0;
}
}
}
}
.modul {
@color_1: white;
@color_2: #4f46e5;
#auth-module {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
z-index: 1000;
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: auto;
background: red;
color: @color_1;
border: none;
border-radius: 5px;
padding: 5px 10px;
cursor: pointer;
}
.auth-box {
background: #fff;
padding: 2rem;
width: 320px;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
position: relative;
&.shake {
animation: shake 0.3s;
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(-10px) rotate(-2deg);
}
50% {
transform: translateX(10px) rotate(2deg);
}
75% {
transform: translateX(-10px) rotate(-2deg);
}
100% {
transform: translateX(0);
}
}
}
h2 {
text-align: center;
margin-bottom: 1rem;
}
input {
width: 100%;
padding: 10px;
margin: 8px 0;
border: 1px solid #ccc;
border-radius: 5px;
}
button {
width: 100%;
padding: 10px;
background: #4f46e5;
color: @color_1;
border: none;
border-radius: 5px;
cursor: pointer;
margin-top: 10px;
&:hover {
background: #4338ca;
}
}
.switch {
text-align: center;
margin-top: 1rem;
font-size: 0.9rem;
span {
color: @color_2;
cursor: pointer;
font-weight: bold;
}
}
.hidden {
display: none;
}
}
}
@keyframes rainbow_animation {
0% {
background-position: 0 0;
}
50% {
background-position: 50% 0;
}
100% {
background-position: 100% 0;
}
}
.rainbow-text {
background: linear-gradient(to right, #6666ff, #0099ff, #00ff00, #ff3399, #6666ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: rainbow_animation 6s infinite;
background-size: 400% 100%;
}

264
public/led/index.html Normal file
View File

@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bild skalieren & anzeigen</title>
<style>
/* Canvas sichtbar skalieren */
#outputCanvas {
width: 33%;
/* Bildschirmbreite */
height: auto;
/* Höhe automatisch */
border: 1px solid black;
image-rendering: pixelated;
/* WICHTIG: Pixel bleiben scharf */
}
</style>
</head>
<body>
<h2>Bild auswählen, skalieren und im Canvas anzeigen</h2>
<label>Breite: </label>
<input type="number" id="newWidth" value="8"><br>
<label>Höhe: </label>
<input type="number" id="newHeight" value="8"><br><br>
<button onclick="myFunction()">Copy text</button>
<script>
function myFunction() {
// Copy the text inside the text field
navigator.clipboard.writeText(AnimationData.getPy());
}
</script>
<input type="file" id="imageInput" accept="image/*,video/*"><br><br>
<!-- Canvas zur Anzeige -->
<canvas id="outputCanvas"></canvas>
<script>
// Pixel-Klasse
class Pixel {
constructor(x, y, r, g, b, a) {
this.x = x;
this.y = y;
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
}
class Frame {
constructor(file) {
this.file = URL.createObjectURL(file);
this.pixels = [];
this.updateSize(8, 8);
}
updateSize(w, h) {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Interne Pixelgröße des Canvas (NICHT sichtbare Größe)
canvas.width = w;
canvas.height = h;
// Bild intern auf dieser Pixelgröße zeichnen
ctx.drawImage(img, 0, 0, w, h);
// Pixel auslesen
const imageData = ctx.getImageData(0, 0, w, h);
const data = imageData.data;
this.pixels = [];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
this.pixels.push(new Pixel(x, y, data[i], data[i + 1], data[i + 2], data[i + 3]));
}
}
};
img.src = this.file;
}
}
class Animation {
constructor(frames, fps = 1) {
this.frames = frames;
this.fps = fps;
this.currentFrameIndex = 0;
}
updateSize(w, h) {
this.frames.forEach(f => f.updateSize(w, h));
}
getPy() {
let string = "";
this.frames.forEach((frame, index) => {
string += `frame${index} = [`;
frame.pixels.forEach((pixel, i) => {
const prevPixel = index > 0 ? this.frames[index - 1].pixels[i] : null;
if (!prevPixel || prevPixel.r !== pixel.r || prevPixel.g !== pixel.g || prevPixel.b !== pixel.b || prevPixel.a !== pixel.a) {
string += `(${i}, ${pixel.r}, ${pixel.g}, ${pixel.b}, ${pixel.a}),`;
}
});
string += "]\n";
});
string += "\nFRAMES = [\n";
this.frames.forEach((frame, index) => {
string += ` frame${index},\n`;
});
return string;
}
getFrame() {
return this.frames[this.currentFrameIndex];
}
nextFrame() {
this.currentFrameIndex = (this.currentFrameIndex + 1) % this.frames.length;
}
}
let AnimationData = new Animation([]);
document.getElementById("imageInput").addEventListener("change", function (event) {
const file = event.target.files[0];
if (!file) return;
if (file.type.startsWith("image/")) {
AnimationData = new Animation([new Frame(file)], 10);
AnimationData.frames[0].updateSize(
parseInt(document.getElementById("newWidth").value),
parseInt(document.getElementById("newHeight").value)
);
requestAnimationFrame(loop);
}
if (file.type.startsWith("video/")) {
let targetFPS = 24;
let frames = videoDecoder(file, targetFPS);
AnimationData = new Animation(frames, targetFPS);
setTimeout(() => {
requestAnimationFrame(loop);
}, 10000);
}
});
let accumulator = 0;
let lastTime = 0;
function videoDecoder(file, fps) {
let video = document.createElement("video");
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let frames = [];
function seek(videoEl, time) {
return new Promise((resolve, reject) => {
function cleanup() {
videoEl.removeEventListener('seeked', onSeeked);
videoEl.removeEventListener('error', onError);
}
function onSeeked() { cleanup(); resolve(); }
function onError(e) { cleanup(); reject(e); }
videoEl.addEventListener('seeked', onSeeked);
videoEl.addEventListener('error', onError);
videoEl.currentTime = Math.min(Math.max(time, 0), videoEl.duration || time);
});
}
video.addEventListener('loadeddata', async () => {
canvas.width = video.videoWidth || 640;
canvas.height = video.videoHeight || 360;
let delta = 1 / fps;
let currentTime = 0;
for (let currentTime = 0; currentTime < video.duration; currentTime += delta) {
await seek(video, currentTime);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
canvas.toBlob(blob => {
if (!blob) return;
const url = URL.createObjectURL(blob);
let frame = new Frame(blob);
document.body.appendChild(document.createTextNode(`Frame at ${currentTime.toFixed(2)}s`));
let img = document.createElement("img");
img.src = url;
//document.body.appendChild(img);
document.body.appendChild(document.createElement("br"));
frames.push(frame);
}, 'image/png');
}
});
video.src = URL.createObjectURL(file);
video.load();
return frames;
}
function loop(time) {
const deltaTime = (time - lastTime) / 1000; // in seconds
lastTime = time;
accumulator += deltaTime;
const FRAME_TIME = 1 / AnimationData.fps; // ~0.0167 seconds for 60 FPS
// Generate frames only when enough time has passed
if (accumulator >= FRAME_TIME) {
const canvas = document.getElementById("outputCanvas");
const ctx = canvas.getContext("2d");
const frame = AnimationData.getFrame();
AnimationData.nextFrame();
canvas.width = frame.pixels.reduce((max, p) => Math.max(max, p.x), 0) + 1;
canvas.height = frame.pixels.reduce((max, p) => Math.max(max, p.y), 0) + 1;
for (const pixel of frame.pixels) {
ctx.fillStyle = `rgba(${pixel.r}, ${pixel.g}, ${pixel.b}, ${pixel.a / 255})`;
ctx.fillRect(pixel.x, pixel.y, 1, 1);
}
accumulator -= FRAME_TIME;
}
requestAnimationFrame(loop);
}
</script>
</body>
</html>

199
public/led/v.html Normal file
View File

@@ -0,0 +1,199 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Video Frame-Stepping mit FPS-Erkennung</title>
<style>
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial;
padding: 18px;
max-width: 900px;
margin: auto;
}
.controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 12px;
}
video {
max-width: 100%;
display: block;
margin-bottom: 12px;
background: #000;
}
canvas {
border: 1px solid #ddd;
max-width: 100%;
display: block;
}
label {
font-size: 0.9rem;
}
input[type="number"] {
width: 80px;
}
button {
padding: 6px 10px;
}
.info {
margin-top: 10px;
color: #444;
font-size: 0.9rem;
}
</style>
</head>
<body>
<h1>Video: Frame für Frame mit FPS-Erkennung</h1>
<div class="controls">
<label>Wähle Datei:
<input id="file" type="file" accept="video/*">
</label>
<label>fps:
<input id="fps" type="number" step="0.01" value="25" min="1">
</label>
<button id="prev">◀️ Prev Frame</button>
<button id="next">Next Frame ▶️</button>
<button id="play">Play ▶</button>
<button id="pause">Pause ⏸</button>
<button id="export">Export Current Frame (PNG)</button>
</div>
<video id="video" controls crossorigin="anonymous" style="display:none"></video>
<canvas id="canvas"></canvas>
<div class="info" id="info">
Lade ein Video, warte auf Metadaten...
</div>
<script>
const fileInput = document.getElementById('file');
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const fpsInput = document.getElementById('fps');
const prevBtn = document.getElementById('prev');
const nextBtn = document.getElementById('next');
const playBtn = document.getElementById('play');
const pauseBtn = document.getElementById('pause');
const exportBtn = document.getElementById('export');
const info = document.getElementById('info');
let fileURL = null;
let rafId = null;
// --- Datei laden ---
fileInput.addEventListener('change', () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (fileURL) URL.revokeObjectURL(fileURL);
fileURL = URL.createObjectURL(file);
video.src = fileURL;
video.style.display = 'block';
video.load();
info.textContent = 'Video geladen. Warte auf Metadaten...';
});
// --- Metadaten geladen ---
video.addEventListener('loadedmetadata', () => {
canvas.width = video.videoWidth || 640;
canvas.height = video.videoHeight || 360;
seekAndDraw(0).catch(() => { });
info.textContent = `Dauer: ${formatSeconds(video.duration)} — Bildgröße: ${canvas.width}×${canvas.height}`;
detectFPS();
});
// --- Hilfsfunktionen ---
function formatSeconds(s) {
if (!isFinite(s)) return '';
const mm = Math.floor(s / 60);
const ss = (s % 60).toFixed(2).padStart(5, '0');
return `${mm}:${ss}`;
}
function drawCurrentFrame() {
try {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
} catch (e) {
console.warn('drawImage fehlgeschlagen:', e);
}
}
function seek(videoEl, time) {
return new Promise((resolve, reject) => {
function cleanup() {
videoEl.removeEventListener('seeked', onSeeked);
videoEl.removeEventListener('error', onError);
}
function onSeeked() { cleanup(); resolve(); }
function onError(e) { cleanup(); reject(e); }
videoEl.addEventListener('seeked', onSeeked);
videoEl.addEventListener('error', onError);
videoEl.currentTime = Math.min(Math.max(time, 0), videoEl.duration || time);
});
}
async function seekAndDraw(time) {
await seek(video, time);
drawCurrentFrame();
}
// --- Buttons ---
nextBtn.addEventListener('click', async () => {
const fps = parseFloat(fpsInput.value) || 25;
const frameDur = 1 / fps;
const target = (video.currentTime || 0) + frameDur;
await seekAndDraw(Math.min(target + 0.00001, video.duration || target));
});
prevBtn.addEventListener('click', async () => {
const fps = parseFloat(fpsInput.value) || 25;
const frameDur = 1 / fps;
const target = (video.currentTime || 0) - frameDur;
await seekAndDraw(Math.max(target, 0));
});
exportBtn.addEventListener('click', () => {
drawCurrentFrame();
canvas.toBlob(blob => {
if (!blob) return;
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url;
a.download = 'frame.png';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}, 'image/png');
});
// --- Seeked Event ---
video.addEventListener('seeked', () => { drawCurrentFrame(); });
// --- Safety Initial Draw ---
window.addEventListener('load', () => {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width || 640, canvas.height || 360);
});
</script>
</body>
</html>

8
public/lib/confetti.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

11
public/lib/less.js Normal file

File diff suppressed because one or more lines are too long

1988
public/lib/less.min.js.map Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

60
public/lib/sw.js Normal file
View File

@@ -0,0 +1,60 @@
// sw.js
self.addEventListener("push", function (event) {
const data = event.data ? event.data.json() : {};
const title = data.title || "Shit da ist was falsch"; // title braucht man, sonst Error
// Dynamisch Options-Objekt nur mit vorhandenen Werten
const options = {};
if (data.body) options.body = data.body;
if (data.icon) options.icon = data.icon;
if (data.badge) options.badge = data.badge;
if (data.actions) options.actions = data.actions;
if (data.requireInteraction !== undefined) options.requireInteraction = data.requireInteraction;
if (data.renotify !== undefined) options.renotify = data.renotify;
if (data.tag) options.tag = data.tag;
if (data.vibrate) options.vibrate = data.vibrate;
if (data.url) options.data = { url: data.url };
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener("notificationclick", function (event) {
event.notification.close(); // Notification schließen
// Prüfen, ob eine Action gedrückt wurde
if (event.action) {
// Dynamische Aktionen vom Server können hier behandelt werden
// Beispiel: Wir öffnen die URL, die im Notification-Data-Feld steht
if (event.notification.data && event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
// Optional: Weitere Action-IDs können hier behandelt werden
console.log("Action clicked:", event.action);
} else {
// Notification selbst angeklickt (ohne Button)
if (event.notification.data && event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
}
});
self.addEventListener('install', event => {
console.log("Service Worker installiert");
// Sofort aktivieren, ohne auf alte Version zu warten
self.skipWaiting();
});
self.addEventListener('activate', event => {
console.log("Service Worker aktiviert");
// Alte Clients übernehmen
event.waitUntil(self.clients.claim());
});

48
public/mass.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="lib/pocketbase.umd.js"></script>
</head>
<body>
<button onclick="addPic()">Uplaod</button>
<script>
const PB = new PocketBase();
PB.autoCancellation(false)
async function addPic() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = 'image/*';
fileInput.click();
// listen to file input changes and add the selected files to the form data
fileInput.addEventListener('change', async function () {
for (let file of fileInput.files) {
const formData = new FormData();
// set regular text field
formData.append('alt', "demo_mass" || prompt("Bitte eine Bildbeschreibung eingeben:"));
formData.append('gewicht', 1);
formData.append('allowed', true);
formData.append('wallpaper', true);
formData.append('img', file);
console.log(file);
const createdRecord = await PB.collection('images').create(formData);
}
alert("Bild erfolgreich hochgeladen!");
});
}
</script>
</body>
</html>

971
public/networktester.html Normal file
View File

@@ -0,0 +1,971 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NETZWERKTESTER™ — Das beste Gadget der Welt</title>
<meta name="description" content="Der übertriebenste Netzwerktester-Hype aller Zeiten. Animiert. Neon. Absolut unnötig — und genau deshalb perfekt." />
<style>
:root{
--bg:#060611;
--bg2:#0b0b1f;
--neon:#7CFF6B;
--neon2:#7afcff;
--hot:#ff4fd8;
--warn:#ffd34d;
--text:#f2f6ff;
--muted:rgba(242,246,255,.72);
--card:rgba(255,255,255,.06);
--stroke:rgba(255,255,255,.12);
--shadow: 0 22px 80px rgba(0,0,0,.65);
--radius: 22px;
--max: 1180px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
background: radial-gradient(1200px 800px at 20% 10%, rgba(122,252,255,.14), transparent 55%),
radial-gradient(1100px 900px at 80% 20%, rgba(255,79,216,.12), transparent 55%),
radial-gradient(900px 700px at 50% 80%, rgba(124,255,107,.10), transparent 55%),
linear-gradient(180deg, var(--bg), var(--bg2));
color:var(--text);
overflow-x:hidden;
}
/* ======= Animated background grid + lasers ======= */
.bg-grid{
position:fixed; inset:0;
pointer-events:none;
opacity:.55;
background:
linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px);
background-size: 42px 42px;
transform: perspective(900px) rotateX(60deg) translateY(-18vh);
filter: drop-shadow(0 0 18px rgba(122,252,255,.08));
animation: gridFloat 8s ease-in-out infinite;
}
@keyframes gridFloat{
0%,100%{transform: perspective(900px) rotateX(60deg) translateY(-18vh) translateX(0)}
50%{transform: perspective(900px) rotateX(60deg) translateY(-16vh) translateX(12px)}
}
.lasers{
position:fixed; inset:-30vh -30vw;
pointer-events:none;
mix-blend-mode: screen;
opacity:.65;
filter: blur(.2px);
background:
conic-gradient(from 180deg at 50% 50%,
rgba(122,252,255,.0),
rgba(122,252,255,.25),
rgba(255,79,216,.18),
rgba(124,255,107,.18),
rgba(255,211,77,.12),
rgba(122,252,255,.0));
animation: spin 14s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
/* ======= Layout ======= */
.wrap{max-width:var(--max); margin:0 auto; padding: 22px 18px 90px;}
header{
position:sticky; top:0; z-index:50;
backdrop-filter: blur(12px);
background: linear-gradient(180deg, rgba(6,6,17,.78), rgba(6,6,17,.35));
border-bottom: 1px solid rgba(255,255,255,.08);
}
.nav{
max-width:var(--max); margin:0 auto;
padding:12px 18px;
display:flex; align-items:center; justify-content:space-between; gap:10px;
}
.brand{
display:flex; align-items:center; gap:10px;
font-weight:900;
letter-spacing:.5px;
text-transform:uppercase;
user-select:none;
}
.brand .dot{
width:12px;height:12px;border-radius:99px;
background: radial-gradient(circle at 30% 30%, #fff, var(--neon2));
box-shadow: 0 0 18px rgba(122,252,255,.55), 0 0 34px rgba(255,79,216,.18);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse{
0%,100%{transform:scale(1); opacity:1}
50%{transform:scale(1.35); opacity:.85}
}
.nav a{
color:var(--muted);
text-decoration:none;
font-weight:700;
padding:10px 12px;
border-radius:12px;
transition:.2s;
display:none;
}
.nav a:hover{background:rgba(255,255,255,.06); color:var(--text)}
@media (min-width:860px){
.nav a{display:inline-block}
}
.cta{
display:flex; align-items:center; gap:10px;
}
.btn{
border:1px solid rgba(255,255,255,.14);
background: linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.06));
color:var(--text);
border-radius:14px;
padding:10px 14px;
font-weight:850;
text-decoration:none;
box-shadow: 0 10px 40px rgba(0,0,0,.35);
transition: transform .15s ease, filter .15s ease;
position:relative;
overflow:hidden;
user-select:none;
}
.btn:hover{transform: translateY(-2px); filter:brightness(1.08)}
.btn.primary{
border-color: rgba(122,252,255,.25);
background: linear-gradient(135deg, rgba(122,252,255,.25), rgba(255,79,216,.16));
box-shadow: 0 18px 60px rgba(122,252,255,.14), 0 18px 70px rgba(255,79,216,.10);
}
.btn .shine{
content:"";
position:absolute; inset:-30%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.45), transparent);
transform: rotate(20deg) translateX(-120%);
animation: shine 2.8s ease-in-out infinite;
opacity:.8;
}
@keyframes shine{
0%,55%{transform: rotate(20deg) translateX(-140%)}
85%,100%{transform: rotate(20deg) translateX(140%)}
}
/* ======= Hero ======= */
.hero{
padding: 46px 0 16px;
display:grid;
grid-template-columns: 1fr;
gap:18px;
position:relative;
}
@media (min-width:980px){
.hero{
grid-template-columns: 1.05fr .95fr;
align-items:center;
padding: 64px 0 22px;
}
}
.kicker{
display:inline-flex; align-items:center; gap:10px;
padding:8px 12px;
border-radius:999px;
border:1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.06);
color:var(--muted);
font-weight:800;
width:fit-content;
box-shadow: 0 10px 40px rgba(0,0,0,.35);
}
.badge-live{
display:inline-flex; align-items:center; gap:7px;
padding:4px 10px;
border-radius:999px;
font-size:12px;
font-weight:900;
letter-spacing:.6px;
color:#071208;
background: linear-gradient(135deg, var(--neon), var(--neon2));
box-shadow: 0 0 18px rgba(124,255,107,.22), 0 0 28px rgba(122,252,255,.18);
text-transform:uppercase;
}
.badge-live i{
width:8px;height:8px;border-radius:99px;background:#071208;
box-shadow: 0 0 0 6px rgba(7,18,8,.15);
animation: ping 1.1s ease-in-out infinite;
}
@keyframes ping{
0%,100%{transform:scale(1); opacity:1}
50%{transform:scale(1.35); opacity:.8}
}
h1{
margin: 14px 0 12px;
font-size: clamp(36px, 5vw, 62px);
line-height: 1.02;
letter-spacing: -1.2px;
}
.glitch{
position:relative;
display:inline-block;
text-shadow:
0 0 14px rgba(122,252,255,.18),
0 0 22px rgba(255,79,216,.14);
}
.glitch::before,.glitch::after{
content: attr(data-text);
position:absolute; left:0; top:0;
opacity:.8;
mix-blend-mode: screen;
clip-path: inset(0 0 0 0);
animation: glitch 2.4s infinite linear alternate-reverse;
}
.glitch::before{
transform: translate(2px,-2px);
color: var(--neon2);
filter: blur(.2px);
}
.glitch::after{
transform: translate(-2px,2px);
color: var(--hot);
animation-duration: 2.9s;
filter: blur(.25px);
}
@keyframes glitch{
0%{clip-path: inset(0 0 85% 0)}
10%{clip-path: inset(12% 0 60% 0)}
20%{clip-path: inset(65% 0 10% 0)}
30%{clip-path: inset(40% 0 40% 0)}
40%{clip-path: inset(80% 0 5% 0)}
50%{clip-path: inset(10% 0 75% 0)}
60%{clip-path: inset(35% 0 40% 0)}
70%{clip-path: inset(5% 0 85% 0)}
80%{clip-path: inset(55% 0 20% 0)}
90%{clip-path: inset(25% 0 55% 0)}
100%{clip-path: inset(70% 0 15% 0)}
}
.sub{
color:var(--muted);
font-size: clamp(16px, 2.1vw, 20px);
line-height:1.5;
margin: 0 0 18px;
max-width: 58ch;
}
.hero-actions{
display:flex; flex-wrap:wrap; gap:10px;
align-items:center;
margin: 18px 0 12px;
}
.stats{
display:grid;
grid-template-columns: repeat(2, minmax(0,1fr));
gap:10px;
margin-top: 14px;
max-width: 520px;
}
@media(min-width:520px){
.stats{grid-template-columns: repeat(3, minmax(0,1fr));}
}
.stat{
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
border-radius: 18px;
padding: 12px 12px;
box-shadow: 0 12px 55px rgba(0,0,0,.35);
position:relative;
overflow:hidden;
}
.stat b{
display:block;
font-size: 22px;
letter-spacing:-.3px;
}
.stat span{color:var(--muted); font-weight:700; font-size:13px}
.stat::after{
content:"";
position:absolute; inset:-40%;
background: radial-gradient(circle at 30% 30%, rgba(122,252,255,.18), transparent 55%),
radial-gradient(circle at 80% 70%, rgba(255,79,216,.16), transparent 55%);
animation: drift 6s ease-in-out infinite;
opacity:.8;
}
@keyframes drift{
0%,100%{transform: translate(0,0) rotate(0deg)}
50%{transform: translate(18px,-12px) rotate(8deg)}
}
.stat > *{position:relative; z-index:1}
/* ======= Right side device card ======= */
.device{
border:1px solid rgba(255,255,255,.14);
background: linear-gradient(180deg, rgba(255,255,255,.09), rgba(255,255,255,.04));
border-radius: var(--radius);
padding: 14px;
box-shadow: var(--shadow);
position:relative;
overflow:hidden;
min-height: 420px;
}
.device::before{
content:"";
position:absolute; inset:-40%;
background: conic-gradient(from 180deg at 50% 50%,
rgba(122,252,255,.0),
rgba(122,252,255,.22),
rgba(255,79,216,.18),
rgba(124,255,107,.16),
rgba(255,211,77,.10),
rgba(122,252,255,.0));
animation: spin 10s linear infinite;
opacity:.55;
}
.device-inner{
position:relative; z-index:1;
height:100%;
border-radius: calc(var(--radius) - 10px);
border:1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.22);
overflow:hidden;
display:grid;
grid-template-rows: auto 1fr auto;
}
.device-top{
display:flex; align-items:center; justify-content:space-between;
padding:12px 12px 10px;
gap:10px;
border-bottom:1px solid rgba(255,255,255,.10);
}
.dots{display:flex; gap:7px}
.dots i{
width:10px;height:10px;border-radius:99px;
background: rgba(255,255,255,.16);
box-shadow: 0 0 0 6px rgba(255,255,255,.03);
}
.device-top .mode{
font-size:12px; font-weight:900; letter-spacing:.7px;
text-transform:uppercase;
color:var(--muted);
}
.screen{
position:relative;
padding: 14px;
display:grid;
place-items:center;
}
.screen img{
width:100%;
height: 280px;
object-fit:cover;
border-radius: 18px;
border:1px solid rgba(255,255,255,.14);
filter: saturate(1.15) contrast(1.05);
transform: translateZ(0);
box-shadow: 0 18px 70px rgba(0,0,0,.55);
}
.floaters{
position:absolute; inset:0;
pointer-events:none;
overflow:hidden;
}
.floater{
position:absolute;
font-size: clamp(16px, 2.6vw, 26px);
filter: drop-shadow(0 10px 18px rgba(0,0,0,.55));
opacity:.95;
animation: floatUp linear infinite;
}
@keyframes floatUp{
from{transform: translateY(40px) rotate(0deg); opacity:0}
15%{opacity:1}
to{transform: translateY(-380px) rotate(22deg); opacity:0}
}
.device-bottom{
padding: 12px;
border-top:1px solid rgba(255,255,255,.10);
display:flex; gap:10px; align-items:center; justify-content:space-between;
flex-wrap:wrap;
}
.meter{
flex:1;
min-width: 210px;
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.05);
border-radius: 999px;
height: 14px;
overflow:hidden;
position:relative;
}
.meter span{
display:block; height:100%;
width: 20%;
background: linear-gradient(90deg, var(--neon2), var(--hot), var(--neon));
border-radius: 999px;
animation: fill 3.2s ease-in-out infinite;
filter: saturate(1.2);
}
@keyframes fill{
0%{width:18%}
50%{width:98%}
100%{width:30%}
}
.meter-label{
font-weight:900;
color:var(--muted);
font-size:12px;
letter-spacing:.6px;
text-transform:uppercase;
display:flex; align-items:center; gap:8px;
user-select:none;
}
/* ======= Sections ======= */
section{padding: 26px 0}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
}
@media(min-width:980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
border-radius: var(--radius);
padding: 16px;
box-shadow: 0 18px 70px rgba(0,0,0,.35);
position:relative;
overflow:hidden;
}
.card::before{
content:"";
position:absolute; inset:-30%;
background: radial-gradient(circle at 20% 20%, rgba(122,252,255,.14), transparent 55%),
radial-gradient(circle at 70% 70%, rgba(255,79,216,.14), transparent 55%);
animation: drift 8s ease-in-out infinite;
opacity:.7;
}
.card > *{position:relative; z-index:1}
.card h2{
margin: 2px 0 10px;
font-size: 22px;
letter-spacing:-.4px;
}
.card p{margin: 0; color: var(--muted); line-height:1.55}
.bullets{
margin: 12px 0 0;
padding-left: 0;
list-style:none;
display:grid;
gap:10px;
}
.bullets li{
display:flex; gap:10px;
align-items:flex-start;
padding: 10px 10px;
border-radius: 16px;
border:1px solid rgba(255,255,255,.10);
background: rgba(0,0,0,.18);
}
.emoji{
width: 34px; height: 34px;
display:grid; place-items:center;
border-radius: 14px;
background: rgba(255,255,255,.07);
border:1px solid rgba(255,255,255,.12);
flex:0 0 auto;
}
.bullets b{display:block; margin-bottom:2px}
.bullets span{color: var(--muted); font-weight:650}
/* ======= Old tester comparison ======= */
.compare{
display:grid;
grid-template-columns: 1fr;
gap: 14px;
align-items:stretch;
}
@media(min-width:980px){
.compare{grid-template-columns: 1fr 1fr}
}
.img-card{
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.05);
border-radius: var(--radius);
overflow:hidden;
box-shadow: var(--shadow);
position:relative;
}
.img-card img{
width:100%;
height: 320px;
object-fit: cover;
display:block;
filter: contrast(1.05) saturate(1.12);
}
.img-card .cap{
padding: 14px 14px 16px;
border-top:1px solid rgba(255,255,255,.10);
}
.tag{
display:inline-flex; align-items:center; gap:8px;
font-weight:900;
letter-spacing:.5px;
text-transform:uppercase;
font-size:12px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(255,255,255,.14);
background: rgba(0,0,0,.22);
color:var(--muted);
}
/* ======= Testimonials ======= */
.quotes{
display:grid;
grid-template-columns: 1fr;
gap: 12px;
margin-top: 10px;
}
@media(min-width:860px){
.quotes{grid-template-columns: repeat(3, 1fr)}
}
blockquote{
margin:0;
padding: 14px;
border-radius: var(--radius);
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
box-shadow: 0 18px 60px rgba(0,0,0,.28);
position:relative;
overflow:hidden;
}
blockquote::before{
content:"“";
position:absolute;
top:-24px; left:10px;
font-size: 88px;
opacity:.16;
}
blockquote p{margin:0 0 10px; color:var(--muted); line-height:1.5}
blockquote footer{font-weight:900; color:var(--text)}
blockquote small{display:block; color:var(--muted); font-weight:750; margin-top:3px}
/* ======= Footer ======= */
footer{
margin-top: 26px;
padding-top: 18px;
border-top: 1px solid rgba(255,255,255,.10);
color: var(--muted);
font-weight:700;
display:flex; flex-direction:column; gap:8px;
}
/* ======= Scroll reveal ======= */
.reveal{
opacity:0;
transform: translateY(14px) scale(.98);
transition: opacity .7s ease, transform .7s ease;
}
.reveal.on{
opacity:1;
transform: translateY(0) scale(1);
}
/* ======= Confetti canvas ======= */
canvas#confetti{
position:fixed; inset:0;
pointer-events:none;
z-index:999;
}
/* ======= Reduced motion ======= */
@media (prefers-reduced-motion: reduce){
*{animation:none!important; transition:none!important; scroll-behavior:auto!important}
.bg-grid,.lasers{display:none}
}
</style>
</head>
<body>
<div class="bg-grid" aria-hidden="true"></div>
<div class="lasers" aria-hidden="true"></div>
<canvas id="confetti"></canvas>
<header>
<div class="nav">
<div class="brand">
<span class="dot" aria-hidden="true"></span>
<span>NETZWERKTESTER™</span>
</div>
<nav>
<a href="#features">Features</a>
<a href="#compare">Vergleich</a>
<a href="#testimonials">Stimmen</a>
</nav>
<div class="cta">
<a class="btn" href="#compare">👀 Showdown</a>
<a class="btn primary" id="boostBtn" href="#features">🚀 BOOST <span class="shine" aria-hidden="true"></span></a>
</div>
</div>
</header>
<main class="wrap">
<section class="hero">
<div>
<div class="kicker">
<span class="badge-live"><i></i> Live Hype</span>
<span>Übertrieben. Animiert. Absolut notwendig. 😎⚡</span>
</div>
<h1>
<span class="glitch" data-text="Der Netzwerktester, den wirklich ALLE brauchen.">Der Netzwerktester, den wirklich ALLE brauchen.</span>
<span aria-hidden="true"> 🧪🔌✨</span>
</h1>
<p class="sub">
Schluss mit „Warum gehts nicht?!“ — unser Netzwerktester verwandelt jedes Kabel in eine <b>offiziell zertifizierte
Glücksleitung</b>. Er misst nicht nur Durchgang… er misst <b>Aura</b>, <b>Vibes</b> und <b>Respekt</b>. 💚📈
</p>
<div class="hero-actions">
<a class="btn primary" href="#features">😤 Ich brauche das jetzt <span class="shine" aria-hidden="true"></span></a>
<a class="btn" href="#testimonials">⭐ 100% echte Stimmen*</a>
<span class="meter-label">POWER-LEVEL: <b id="powerLevel">9000</b>+</span>
</div>
<div class="stats">
<div class="stat">
<b><span id="uptime">99.98</span>%</b>
<span>Hype-Uptime</span>
</div>
<div class="stat">
<b><span id="cables">0</span></b>
<span>Kabel gerettet</span>
</div>
<div class="stat">
<b><span id="wow">0</span>×</b>
<span>„WOW“ pro Minute</span>
</div>
</div>
</div>
<aside class="device" aria-label="Animiertes Showcase">
<div class="device-inner">
<div class="device-top">
<div class="dots" aria-hidden="true"><i></i><i></i><i></i></div>
<div class="mode">ULTRA DIAG MODE 🔥</div>
</div>
<div class="screen">
<!-- Bild 1: Person mit Netzwerkkabel -->
<img
src="https://cdn.pixabay.com/photo/2016/08/15/20/02/compression-pressure-recorder-1596369_1280.jpg"
alt="Person hält ein Netzwerkkabel"
loading="lazy"
/>
<div class="floaters" aria-hidden="true" id="floaters"></div>
</div>
<div class="device-bottom">
<div class="meter" aria-hidden="true"><span></span></div>
<div class="meter-label">SCAN: <span id="scanText">LINK UP ✅</span></div>
</div>
</div>
</aside>
</section>
<section id="features" class="reveal">
<div class="grid">
<div class="card">
<h2>Warum ist das Ding so legendär? 🏆</h2>
<p>
Weil er die Realität debuggt. Du steckst ein Kabel rein — und plötzlich macht die Welt Sinn.
(Zumindest das Patchfeld.) 🌍🔧
</p>
<ul class="bullets">
<li>
<div class="emoji">🧠</div>
<div>
<b>IQ-Boost für den Schaltschrank</b>
<span>Erkennt Fehler, bevor sie überhaupt passieren. (Gefühlt.)</span>
</div>
</li>
<li>
<div class="emoji"></div>
<div>
<b>Turbo-Scan in Lichtgeschwindigkeit</b>
<span>So schnell, dass dein Blick hinterher ruckelt.</span>
</div>
</li>
<li>
<div class="emoji">🛡️</div>
<div>
<b>Anti-“Warum gehts nicht?”-Shield</b>
<span>Blockt Ausreden, Drama und Kabel-Karma.</span>
</div>
</li>
</ul>
</div>
<div class="card">
<h2>Für wen? Für ALLE. Wirklich alle. 😤</h2>
<p>
Schüler, Azubis, Techniker, Netzwerkgötter, IT-Orakel, Menschen mit Kabeln, Menschen ohne Kabeln,
sogar Leute, die „LAN“ für eine Stadt halten. 🏙️🔌
</p>
<ul class="bullets">
<li>
<div class="emoji">📱</div>
<div>
<b>Handy & PC: Perfekt sichtbar</b>
<span>Responsive Layout, große Buttons, kein Zoomen wie 2012.</span>
</div>
</li>
<li>
<div class="emoji">🎛️</div>
<div>
<b>Animationen: Maximum Overdrive</b>
<span>Neon, Glitch, Laser, Floating Emojis… ja.</span>
</div>
</li>
<li>
<div class="emoji">🧪</div>
<div>
<b>Wissenschaftlich übertrieben</b>
<span>Messwerte wirken 17% seriöser, wenn sie leuchten.</span>
</div>
</li>
</ul>
</div>
</div>
</section>
<section id="compare" class="reveal">
<h2 style="margin:0 0 12px; font-size:28px; letter-spacing:-.5px;">
Der epische Vergleich: ALT vs. HYPER-ULTRA 😱
</h2>
<div class="compare">
<article class="img-card">
<!-- Bild 2: sehr alter Netzwerktester -->
<img
src="https://cdn.pixabay.com/photo/2013/03/09/02/36/tester-91696_1280.jpg"
alt="Sehr alter Netzwerktester"
loading="lazy"
/>
<div class="cap">
<span class="tag">🕰️ Altgerät</span>
<h3 style="margin:10px 0 6px;">Der Klassiker: „Geht… irgendwie“</h3>
<p style="margin:0; color:var(--muted); line-height:1.5;">
Misst Dinge. Manchmal. Mit Charme. Aber ohne Laser. Ohne Glitch. Ohne Drama. (Also langweilig.) 😴
</p>
</div>
</article>
<article class="img-card">
<div style="position:relative">
<img
src="https://cdn.pixabay.com/photo/2017/12/24/21/08/secret-3037639_1280.jpg"
alt="Person mit Netzwerkkabel"
loading="lazy"
style="filter:saturate(1.3) contrast(1.08);"
/>
<div style="
position:absolute; inset:0;
background: radial-gradient(circle at 20% 20%, rgba(122,252,255,.22), transparent 55%),
radial-gradient(circle at 70% 70%, rgba(255,79,216,.18), transparent 55%);
mix-blend-mode: screen;
pointer-events:none;
"></div>
</div>
<div class="cap">
<span class="tag">🚀 HYPE-DEVICE</span>
<h3 style="margin:10px 0 6px;">NETZWERKTESTER™: „Ich sehe alles.“</h3>
<p style="margin:0; color:var(--muted); line-height:1.5;">
Findet Fehler. Findet Sinn. Findet deinen verlorenen Schraubendreher (theoretisch). 🧲✨
Und sieht dabei gefährlich gut aus. 😎
</p>
</div>
</article>
</div>
</section>
<section id="testimonials" class="reveal">
<h2 style="margin:0 0 6px; font-size:28px; letter-spacing:-.5px;">
100% echte Stimmen* ⭐
</h2>
<p style="margin:0 0 10px; color:var(--muted); font-weight:700;">
*Echt im Sinne von: Niemand hat widersprochen. 🤝😇
</p>
<div class="quotes">
<blockquote>
<p>„Ich hab ihn einmal eingeschaltet und plötzlich war mein Leben geordnet. Sogar die Patchkabel.“</p>
<footer>Max, <small>zertifizierter Kabel-Flüsterer 🧙‍♂️</small></footer>
</blockquote>
<blockquote>
<p>„Er sagt LINK UP mit so viel Selbstbewusstsein, ich fühl mich direkt kompetenter.“</p>
<footer>Sara, <small>Fehlerfinderin auf Koffein ☕</small></footer>
</blockquote>
<blockquote>
<p>„Ich wollte keinen. Jetzt hab ich drei. Einen fürs Bett, einen fürs Auto, einen fürs Herz.“</p>
<footer>Ali, <small>LAN-Romantiker 💘</small></footer>
</blockquote>
</div>
</section>
<footer>
<div>© <span id="year"></span> Netzwerktester™ — „Wenns leuchtet, ists besser.“ ✨</div>
<div style="opacity:.85"><a href="https://www.fsae41.de">HOME</a></div>
</footer>
</main>
<script>
// ===== Scroll reveal =====
const revealEls = document.querySelectorAll('.reveal');
const io = new IntersectionObserver((entries)=>{
for (const e of entries){
if (e.isIntersecting) e.target.classList.add('on');
}
}, {threshold: 0.12});
revealEls.forEach(el => io.observe(el));
// ===== Floating emojis in the "device screen" =====
const floaters = document.getElementById('floaters');
const EMOJIS = ["🔌","⚡","✨","📶","🧪","😎","🟩","💥","🧠","🛠️","🌈","🚀"];
function spawnFloater(){
const el = document.createElement('div');
el.className = 'floater';
el.textContent = EMOJIS[Math.floor(Math.random()*EMOJIS.length)];
el.style.left = Math.random()*100 + "%";
el.style.bottom = (-10 - Math.random()*10) + "px";
el.style.animationDuration = (2.2 + Math.random()*2.8) + "s";
el.style.animationDelay = (Math.random()*0.2) + "s";
el.style.transform = `translateY(40px) rotate(${(Math.random()*18-9).toFixed(1)}deg)`;
floaters.appendChild(el);
setTimeout(()=> el.remove(), 5200);
}
setInterval(spawnFloater, 180);
// ===== Fake live scan text =====
const scanText = document.getElementById('scanText');
const scanPhrases = [
"LINK UP ✅", "PAIR OK ✅", "GIGABIT DREAM ✅", "VIBES: STABIL ✅",
"PATCHKABEL: GLÜCKLICH ✅", "KARMA: GEROUTET ✅", "FEHLER: GEFUNDEN 🎯"
];
setInterval(()=>{
scanText.textContent = scanPhrases[Math.floor(Math.random()*scanPhrases.length)];
}, 1300);
// ===== Counters =====
const yearEl = document.getElementById('year');
yearEl.textContent = new Date().getFullYear();
const uptimeEl = document.getElementById('uptime');
const cablesEl = document.getElementById('cables');
const wowEl = document.getElementById('wow');
const powerLevelEl = document.getElementById('powerLevel');
let cables = 0;
let wow = 0;
let power = 9001;
function tick(){
cables += Math.floor(1 + Math.random()*6);
wow += Math.floor(1 + Math.random()*4);
power += Math.floor(6 + Math.random()*18);
cablesEl.textContent = cables.toLocaleString('de-DE');
wowEl.textContent = wow.toLocaleString('de-DE');
powerLevelEl.textContent = power.toLocaleString('de-DE');
// uptime wiggle
const base = 99.90;
const v = (base + Math.random()*0.09).toFixed(2);
uptimeEl.textContent = v;
}
setInterval(tick, 900);
tick();
// ===== Confetti "BOOST" (simple canvas particles) =====
const canvas = document.getElementById('confetti');
const ctx = canvas.getContext('2d');
let W, H;
function resize(){
W = canvas.width = window.innerWidth * devicePixelRatio;
H = canvas.height = window.innerHeight * devicePixelRatio;
}
window.addEventListener('resize', resize);
resize();
let particles = [];
function burst(){
const count = 170;
for(let i=0;i<count;i++){
particles.push({
x: (Math.random()*window.innerWidth) * devicePixelRatio,
y: (-10 - Math.random()*60) * devicePixelRatio,
vx: (-1.2 + Math.random()*2.4) * devicePixelRatio,
vy: (2.0 + Math.random()*4.4) * devicePixelRatio,
r: (2 + Math.random()*4) * devicePixelRatio,
rot: Math.random()*Math.PI*2,
vr: (-0.18 + Math.random()*0.36),
life: 0,
max: 160 + Math.random()*120
});
}
}
function draw(){
ctx.clearRect(0,0,W,H);
for(const p of particles){
p.x += p.vx;
p.y += p.vy;
p.vy += 0.02 * devicePixelRatio; // gravity
p.rot += p.vr;
p.life++;
const alpha = Math.max(0, 1 - p.life / p.max);
ctx.save();
ctx.globalAlpha = alpha * 0.9;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
// No fixed colors: use random-ish neon via hue
const hue = (p.life*2 + p.x/W*360) % 360;
ctx.fillStyle = `hsla(${hue}, 95%, 60%, 1)`;
ctx.fillRect(-p.r, -p.r, p.r*2.2, p.r*1.2);
ctx.restore();
}
particles = particles.filter(p => p.life < p.max && p.y < H + 120);
requestAnimationFrame(draw);
}
draw();
document.getElementById('boostBtn').addEventListener('click', (e)=>{
// allow anchor scroll, but also boost visuals
burst();
// micro screen shake
document.body.animate([
{ transform: 'translate(0,0)' },
{ transform: 'translate(2px,-2px)' },
{ transform: 'translate(-2px,2px)' },
{ transform: 'translate(1px,1px)' },
{ transform: 'translate(0,0)' }
], { duration: 320, iterations: 1 });
});
</script>
</body>
</html>

185
public/old/countdown.html Normal file
View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Countdown/Countup Timer</title>
<!-- Tab-Icon hinzufügen -->
<link rel="icon" href="https://cdn-icons-png.flaticon.com/512/8730/8730547.png" type="image/x-icon">
<style>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
text-align: center;
}
#main-heading {
color: red;
font-size: 3.5rem;
margin-bottom: 30px;
font-weight: 900;
background-color: #90EE90;
padding: 20px;
border-radius: 10px;
}
#target-info {
font-size: 1.2rem;
margin-bottom: 20px;
color: #333;
}
#countdown {
font-size: 2rem;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
#totals {
margin-top: 20px;
font-size: 1rem;
color: #666;
}
</style>
</head>
<body>
<div id="main-heading">geht bald los...</div>
<div id="target-info">Bis zum 28.08.2025 um 17:30:00 Uhr sind es noch:</div>
<div id="countdown">
1d 0h 38m 26s 801ms
</div>
<div id="totals">
Jahre: 0.00 |
Wochen: 0 |
Tage: 1 |
Stunden: 24 |
Sekunden: 88.706
</div>
<script>
// Function to get query parameter from URL
function getQueryParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
}
// Function to pad numbers with leading zeros
function padZero(num, places) {
return num.toString().padStart(places, '0');
}
// Function to format number with thousand separators
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}
// Function to format date for display
function formatDate(date) {
return `${padZero(date.getDate(), 2)}.${padZero(date.getMonth() + 1, 2)}.${date.getFullYear()} um ${padZero(date.getHours(), 2)}:${padZero(date.getMinutes(), 2)}:${padZero(date.getSeconds(), 2)}`;
}
// Get query parameters
const targetDateStr = getQueryParam('target');
const headingText = getQueryParam('heading');
// Select elements
const mainHeadingElement = document.getElementById('main-heading');
const targetInfoElement = document.getElementById('target-info');
const countdownElement = document.getElementById('countdown');
const totalsElement = document.getElementById('totals');
// Set heading if provided
if (headingText) {
mainHeadingElement.textContent = decodeURIComponent(headingText);
} else {
mainHeadingElement.style.display = 'none';
}
// Handle different scenarios
if (!targetDateStr) {
// No target specified
targetInfoElement.innerHTML = "Ja, worauf warten wir?";
countdownElement.innerHTML = "";
totalsElement.innerHTML = "";
} else {
try {
// Parse the target date
const targetDate = new Date(targetDateStr);
// Validate the date
if (isNaN(targetDate.getTime())) {
targetInfoElement.innerHTML = "hmm, da stimmt was nicht!";
countdownElement.innerHTML = "";
totalsElement.innerHTML = "";
} else {
// Determine if date is in past or future
const now = new Date().getTime();
const target = targetDate.getTime();
let isInPast = target < now;
// Countdown/Countup function
function updateTimer() {
const now = new Date().getTime();
const distance = Math.abs(target - now);
isInPast = target < now;
// Set target info text based on past/future
const targetInfoText = isInPast
? `Seit dem ${formatDate(targetDate)} Uhr sind es schon:`
: `Bis zum ${formatDate(targetDate)} Uhr sind es noch:`;
targetInfoElement.innerHTML = targetInfoText;
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
const milliseconds = Math.floor(distance % 1000);
// Calculate total values
const totalDays = days;
const totalWeeks = Math.floor(totalDays / 7);
const totalHours = totalDays * 24 + hours;
const totalSeconds = totalDays * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds;
const totalYears = (totalDays / 365).toFixed(2); // Calculate total years to 2 decimal places
// Display countdown/countup
countdownElement.innerHTML = `
${days}d ${hours}h ${minutes}m ${seconds}s ${padZero(milliseconds, 3)}ms
`;
// Display totals with thousand separators
totalsElement.innerHTML = `
Jahre: ${totalYears} |
Wochen: ${formatNumber(totalWeeks)} |
Tage: ${formatNumber(totalDays)} |
Stunden: ${formatNumber(totalHours)} |
Sekunden: ${formatNumber(totalSeconds)}
`;
}
// Update timer every 10 milliseconds
setInterval(updateTimer, 10);
updateTimer(); // Initial call
}
} catch (error) {
targetInfoElement.innerHTML = "hmm, da stimmt was nicht!";
countdownElement.innerHTML = "";
totalsElement.innerHTML = "";
}
}
</script>
</body>
</html>

234
public/old/index.html Normal file
View File

@@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<meta name="robots" content="nofollow">
<link rel="icon" href="https://lifab.de/favicon_16x_32x_48x_v1.ico" type="image/x-icon">
<script defer src="https://analytics.fsae41.de/script.js" data-website-id="257da02e-d678-47b6-b036-e3bdabaf1405"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Unterrichtszeit Countdown</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #1e1e1e;
color: white;
text-align: center;
padding: 20px;
}
#clock {
font-size: 2.5em;
margin-bottom: 30px;
}
iframe {
border: none;
width: 90vw;
height: 60vh;
max-width: 1000px;
}
.wheeler {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: auto;
z-index: 1000;
}
#image {
max-width: 100%;
height: auto;
display: none;
}
</style>
<script src="/lib/pocketbase.umd.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.js"></script>
</head>
<body>
<div id="clock">14:51:32</div>
<div id="countdown-container">
<iframe id="countdown" src="countdown_old.html"></iframe>
</div>
<canvas id="myChart" style="display:none;width:100%;max-width:700px"></canvas>
<div class="wheeler">
<img id="image" src="Wheeler.webp" alt="Wheeler">
</div>
<div style="padding: 5px; background-color: white;">
<a href="https://fsae41.de/ausbildung_quiz.html">AEVO-Held</a>
</div>
<script>
let PB = new PocketBase();
let lastURL = "";
function updateClock() {
const now = new Date();
document.getElementById('clock').innerText = now.toLocaleTimeString('de-DE');
}
function pad(n) {
return n < 10 ? '0' + n : n;
}
function getCountdownURL() {
const now = new Date();
const day = now.getDay();
const current = now.getTime();
const todayStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const schedule = {
1: [ // Montag
{ start: "17:30", end: "19:00", label: "1. Stunde" },
{ start: "19:00", end: "19:15", label: "Pause" },
{ start: "19:15", end: "20:45", label: "2. Stunde" },
],
2: [ // Dienstag
{ start: "17:00", end: "18:20", label: "1. Stunde" },
{ start: "18:20", end: "18:30", label: "Pause" },
{ start: "18:30", end: "19:50", label: "2. Stunde" },
{ start: "19:50", end: "20:00", label: "Pause" },
{ start: "20:00", end: "21:20", label: "3. Stunde" },
],
4: [ // Donnerstag
{ start: "17:30", end: "19:00", label: "1. Stunde" },
{ start: "19:00", end: "19:15", label: "Pause" },
{ start: "19:15", end: "20:45", label: "2. Stunde" },
]
};
const todaySchedule = schedule[day] || [];
for (const block of todaySchedule) {
const [startH, startM] = block.start.split(":").map(Number);
const [endH, endM] = block.end.split(":").map(Number);
const startTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), startH, startM);
const endTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endH, endM);
if (current >= startTime.getTime() && current < endTime.getTime()) {
return `https://fsae41.de/old/countdown.html?target=${todayStr}T${pad(endH)}:${pad(endM)}:00&heading=${encodeURIComponent(block.label)}`;
}
}
// Kein aktueller Block aktiv: zeige nächstes Ereignis
const futureTimes = [];
for (let offset = 0; offset < 7; offset++) {
const checkDay = (day + offset) % 7;
const checkDate = new Date(now);
checkDate.setDate(now.getDate() + offset);
const dateStr = `${checkDate.getFullYear()}-${pad(checkDate.getMonth() + 1)}-${pad(checkDate.getDate())}`;
if (!schedule[checkDay]) continue;
for (const block of schedule[checkDay]) {
const [startH, startM] = block.start.split(":").map(Number);
const blockStart = new Date(checkDate.getFullYear(), checkDate.getMonth(), checkDate.getDate(), startH, startM);
if (blockStart.getTime() > current) {
futureTimes.push({ time: blockStart, label: "geht bald los..." });
break;
}
}
if (futureTimes.length > 0) break;
}
if (futureTimes.length > 0) {
const next = futureTimes[0];
let x = next.time.getHours(); // Korrigieren der Zeitumstellung
next.time.setHours(x + 2);
return `https://fsae41.de/old/countdown.html?target=${next.time.toISOString().split('.')[0]}&heading=geht%20bald%20los...`;
}
return `https://fsae41.de/old/countdown.html?target=2099-01-01T00:00:00&heading=Fehler`;
}
function updateCountdown() {
const url = getCountdownURL();
if (url !== lastURL) {
location.reload(); // Ganze Seite neu laden, wenn URL sich ändert
}
}
function setInitialCountdown() {
const url = getCountdownURL();
lastURL = url;
document.getElementById('countdown').src = url;
}
async function addView() {
const record = await PB.collection('views').create({ device: navigator.userAgent });
fetch('https://fsae41.de/views').then(response => response.json()).then(data => {
let time = [0];
let counts = [0];
for (let i = data.list.length - 1; i > 0; i--) {
let x = data.list[i];
time.push(i);
counts.push(x.count);
}
new Chart("myChart", {
type: "line",
data: {
labels: time,
datasets: [{
backgroundColor: "rgba(0,0,255,1.0)",
borderColor: "rgba(0,0,255,0.1)",
data: counts
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: true
}
},
scales: {
x: { title: { display: true, text: "Zeitpunkte" } },
y: { title: { display: true, text: "Views" }, beginAtZero: true }
}
}
});
});
}
addView();
updateClock();
setInitialCountdown();
setInterval(updateClock, 1000);
setInterval(updateCountdown, 10000); // alle 10 Sekunden prüfen
setInterval(addView, 30000); // alle 30 Sekunden prüfen
// Subscribe to changes in any popups record
PB.collection('popups').subscribe('*', function (e) {
if (e.action === 'create') {
if (e.record.image_url) {
document.getElementById("image").src = e.record.image_url;
document.getElementById("image").style.display = "block";
setTimeout(() => {
document.getElementById("image").style.display = "none";
}, 5000); // Nach 15 Sekunden zurücksetzen
return;
}
}
});
</script>
</body>
</html>

BIN
public/schule.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/static/nico.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

60
public/sw.js Normal file
View File

@@ -0,0 +1,60 @@
// sw.js
self.addEventListener("push", function (event) {
const data = event.data ? event.data.json() : {};
const title = data.title || "Shit da ist was falsch"; // title braucht man, sonst Error
// Dynamisch Options-Objekt nur mit vorhandenen Werten
const options = {};
if (data.body) options.body = data.body;
if (data.icon) options.icon = data.icon;
if (data.badge) options.badge = data.badge;
if (data.actions) options.actions = data.actions;
if (data.requireInteraction !== undefined) options.requireInteraction = data.requireInteraction;
if (data.renotify !== undefined) options.renotify = data.renotify;
if (data.tag) options.tag = data.tag;
if (data.vibrate) options.vibrate = data.vibrate;
if (data.url) options.data = { url: data.url };
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener("notificationclick", function (event) {
event.notification.close(); // Notification schließen
// Prüfen, ob eine Action gedrückt wurde
if (event.action) {
// Dynamische Aktionen vom Server können hier behandelt werden
// Beispiel: Wir öffnen die URL, die im Notification-Data-Feld steht
if (event.notification.data && event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
// Optional: Weitere Action-IDs können hier behandelt werden
console.log("Action clicked:", event.action);
} else {
// Notification selbst angeklickt (ohne Button)
if (event.notification.data && event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
}
});
self.addEventListener('install', event => {
console.log("Service Worker installiert");
// Sofort aktivieren, ohne auf alte Version zu warten
self.skipWaiting();
});
self.addEventListener('activate', event => {
console.log("Service Worker aktiviert");
// Alte Clients übernehmen
event.waitUntil(self.clients.claim());
});

313
public/test.html Normal file
View File

@@ -0,0 +1,313 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
:root {
--numDays: 5;
--numHours: 7;
--timeHeight: 60px;
--calBgColor: #fff1f8;
--eventBorderColor: #f2d3d8;
--eventColor1: #ffd6d1;
--eventColor2: #fafaa3;
--eventColor3: #e2f8ff;
--eventColor4: #d1ffe6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.calendar {
display: grid;
gap: 10px;
grid-template-columns: auto 1fr;
margin: 2rem;
}
.timeline {
display: grid;
grid-template-rows: repeat(var(--numHours), var(--timeHeight));
}
.days {
display: grid;
grid-column: 2;
gap: 5px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.events {
display: grid;
grid-template-rows: repeat(var(--numHours), var(--timeHeight));
border-radius: 5px;
background: var(--calBgColor);
}
.start-10 {
grid-row-start: 2;
}
.start-12 {
grid-row-start: 4;
}
.start-1 {
grid-row-start: 5;
}
.start-2 {
grid-row-start: 6;
}
.end-12 {
grid-row-end: 4;
}
.end-1 {
grid-row-end: 5;
}
.end-3 {
grid-row-end: 7;
}
.end-4 {
grid-row-end: 8;
}
.end-5 {
grid-row-end: 9;
}
.title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.event {
border: 1px solid var(--eventBorderColor);
border-radius: 5px;
padding: 0.5rem;
margin: 0 0.5rem;
background: white;
}
.space,
.date {
height: 60px
}
body {
font-family: system-ui, sans-serif;
}
.corp-fi {
background: var(--eventColor1);
}
.ent-law {
background: var(--eventColor2);
}
.writing {
background: var(--eventColor3);
}
.securities {
background: var(--eventColor4);
}
.date {
display: flex;
gap: 1em;
}
.date-num {
font-size: 3rem;
font-weight: 600;
display: inline;
}
.date-day {
display: inline;
font-size: 3rem;
font-weight: 100;
}
</style>
</head>
<body>
<div class="calendar">
<div class="days">
<div class="day mon">
<div class="date">
<p class="date-num">9</p>
<p class="date-day">Mon</p>
</div>
<div class="events">
<div class="event start-2 end-5 securities">
<p class="title">Securities Regulation</p>
<p class="time">2:00 - 5:00</p>
</div>
</div>
</div>
<div class="day tues">
<div class="date">
<p class="date-num">12</p>
<p class="date-day">Tues</p>
</div>
<div class="events">
<div class="event start-10 end-12 corp-fi">
<p class="title">Corporate Finance</p>
<p class="time">10:00 - 12:00</p>
</div>
<div class="event start-1 end-4 ent-law">
<p class="title">Entertainment Law</p>
<p class="time">1PM - 4PM</p>
</div>
</div>
</div>
<div class="day wed">
<div class="date">
<p class="date-num">11</p>
<p class="date-day">Wed</p>
</div>
<div class="events">
<div class="event start-12 end-1 writing">
<p class="title">Writing Seminar</p>
<p class="time">11:00 - 12:00</p>
</div>
<div class="event start-2 end-5 securities">
<p class="title">Securities Regulation</p>
<p class="time">2:00 - 5:00</p>
</div>
</div>
</div>
<div class="day thurs">
<div class="date">
<p class="date-num">12</p>
<p class="date-day">Thurs</p>
</div>
<div class="events">
<div class="event start-10 end-12 corp-fi">
<p class="title">Corporate Finance</p>
<p class="time">10:00 - 12:00</p>
</div>
<div class="event start-1 end-4 ent-law">
<p class="title">Entertainment Law</p>
<p class="time">1PM - 4PM</p>
</div>
</div>
</div>
<div class="day fri">
<div class="date">
<p class="date-num">13</p>
<p class="date-day">Fri</p>
</div>
<div class="events">
</div>
</div>
</div>
</div>
<script>
class Calander {
#timeHeight = 60;
#timeSlice = 1;
constructor(parent) {
this.parent = document.querySelector(parent);
this.elements = {
timeline: undefined,
days: undefined,
events: {},
}
this.createTimeline();
this.createDays();
this.parent.appendChild(this.elements.timeline);
this.parent.appendChild(this.elements.days);
}
createElement(type, content) {
let el = document.createElement(type);
if (content) el.textContent = content;
return el;
}
createTimeline(von = 16, bis = 22) {
let timeline = this.createElement('div');
timeline.style.display = 'grid';
timeline.style.gridTemplateRows = `repeat(${bis - von + 2}, ${this.#timeHeight}px)`;
console.log(timeline);
let spacer = this.createElement('div');
timeline.appendChild(spacer);
for (let i = von; i <= bis; i++) {
let timeMarker = this.createElement('div', `${i}:00`);
timeline.appendChild(timeMarker);
}
this.elements.timeline = timeline;
}
createDays() {
let days = this.createElement('div');
let dayNames = ['Mon', 'Tues', 'Wed', 'Thurs', 'Fri'];
days.style.display = 'grid';
days.style.gridColumn = '2';
days.style.gap = '5px';
days.style.gridTemplateColumns = 'repeat(auto-fit, minmax(150px, 1fr))';
dayNames.forEach((dayName, i) => {
let date = this.createElement('div');
date.style.display = 'flex';
date.style.gap = '1em';
let daytime = new Date();
daytime.setDate(daytime.getDate() + (daytime.getDay() === 0 ? -6 : 1 - daytime.getDay()));
let num = this.createElement('p', daytime.getDate() + i);
num.style.fontSize = '3rem';
num.style.fontWeight = '600';
num.style.display = 'inline';
date.appendChild(num);
let day = this.createElement('p', dayName);
day.style.fontSize = '3rem';
day.style.fontWeight = '100';
day.style.display = 'inline';
date.appendChild(day);
days.appendChild(date);
let events = this.createElement('div');
this.elements.events.dayName = events;
days.appendChild(events);
});
this.elements.days = days;
}
}
let cal = new Calander('.calendar');
</script>
</body>
</html>

624
public/wlan.html Normal file
View File

@@ -0,0 +1,624 @@
<!doctype html>
<html lang="de">
<head>
<meta name="robots" content="noindex">
<meta name="robots" content="nofollow">
<link rel="icon" href="https://fsae41.de/schule.ico" type="image/x-icon">
<script defer src="https://analytics.fsae41.de/script.js" data-website-id="257da02e-d678-47b6-b036-e3bdabaf1405"></script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FSAE41 WLAN QR-Code</title>
<meta name="description" content="Bunt, unnötig animiert, zeigt den WLAN-QR-Code der Klasse FSAE41." />
<style>
:root {
--glowA: 0 0 20px rgba(0, 255, 255, .8);
--glowB: 0 0 28px rgba(255, 0, 255, .8);
--glowC: 0 0 36px rgba(255, 255, 0, .7);
--card: rgba(10, 10, 20, .55);
--card2: rgba(255, 255, 255, .08);
--white: rgba(255, 255, 255, .92);
}
/* ====== Hintergrund: animierter Regenbogen + Noise + Scanlines ====== */
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Helvetica Neue", sans-serif;
color: var(--white);
background:
radial-gradient(circle at 20% 20%, rgba(255, 0, 200, .35), transparent 45%),
radial-gradient(circle at 80% 30%, rgba(0, 255, 200, .35), transparent 45%),
radial-gradient(circle at 40% 85%, rgba(255, 255, 0, .25), transparent 50%),
linear-gradient(120deg, #ff0080, #00e5ff, #ffee00, #8a2be2, #00ff8a, #ff3d00);
background-size: 200% 200%;
animation: bgShift 9s ease-in-out infinite alternate;
}
@keyframes bgShift {
0% {
background-position: 0% 30%;
filter: hue-rotate(0deg) saturate(1.2);
}
50% {
background-position: 70% 70%;
filter: hue-rotate(120deg) saturate(1.7);
}
100% {
background-position: 100% 0%;
filter: hue-rotate(260deg) saturate(1.35);
}
}
/* Scanlines */
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(to bottom,
rgba(255, 255, 255, .06) 0px,
rgba(255, 255, 255, .06) 1px,
rgba(0, 0, 0, 0) 3px,
rgba(0, 0, 0, 0) 6px);
mix-blend-mode: overlay;
opacity: .22;
animation: scanFlicker 2.8s infinite;
}
@keyframes scanFlicker {
0%,
100% {
opacity: .15;
transform: translateY(0);
}
50% {
opacity: .28;
transform: translateY(1px);
}
}
/* Noise */
.noise {
position: fixed;
inset: -50%;
pointer-events: none;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
opacity: .18;
transform: rotate(8deg);
animation: noiseMove 7s linear infinite;
}
@keyframes noiseMove {
from {
transform: translate3d(-2%, -2%, 0) rotate(8deg);
}
to {
transform: translate3d(2%, 2%, 0) rotate(8deg);
}
}
/* ====== Layout ====== */
.wrap {
position: relative;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
z-index: 2;
}
.card {
width: min(860px, 92vw);
display: grid;
grid-template-columns: 1.1fr .9fr;
gap: 22px;
padding: 22px;
border-radius: 26px;
background: linear-gradient(180deg, var(--card), rgba(10, 10, 20, .28));
border: 1px solid rgba(255, 255, 255, .18);
box-shadow:
0 25px 80px rgba(0, 0, 0, .45),
var(--glowA),
var(--glowB);
backdrop-filter: blur(12px);
position: relative;
overflow: hidden;
animation: cardFloat 4.2s ease-in-out infinite;
}
@keyframes cardFloat {
0%,
100% {
transform: translateY(0) rotate(-.2deg);
}
50% {
transform: translateY(-10px) rotate(.2deg);
}
}
/* Glitzerband im Card-Hintergrund */
.card::before {
content: "";
position: absolute;
inset: -60%;
background: conic-gradient(from 0deg,
rgba(255, 0, 150, .0),
rgba(255, 255, 0, .25),
rgba(0, 255, 255, .25),
rgba(140, 0, 255, .25),
rgba(0, 255, 140, .25),
rgba(255, 80, 0, .25),
rgba(255, 0, 150, .0));
filter: blur(14px);
opacity: .7;
animation: spin 5.5s linear infinite;
pointer-events: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.card::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 20%, rgba(255, 255, 255, .10), transparent 45%),
radial-gradient(circle at 70% 80%, rgba(255, 255, 255, .08), transparent 55%);
pointer-events: none;
}
@media (max-width: 780px) {
.card {
grid-template-columns: 1fr;
}
}
.left {
position: relative;
padding: 10px 10px 10px 6px;
z-index: 1;
}
.badge {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 255, 255, .16), rgba(255, 255, 255, .07));
border: 1px solid rgba(255, 255, 255, .18);
box-shadow: var(--glowC);
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
font-size: 13px;
width: fit-content;
animation: badgeWiggle 2.2s ease-in-out infinite;
}
@keyframes badgeWiggle {
0%,
100% {
transform: rotate(-1deg) scale(1);
}
50% {
transform: rotate(1deg) scale(1.04);
}
}
h1 {
margin: 14px 0 10px;
font-size: clamp(34px, 4.6vw, 56px);
line-height: 1.02;
letter-spacing: -0.02em;
text-shadow: 0 10px 40px rgba(0, 0, 0, .45);
animation: titleHue 3.6s linear infinite;
}
@keyframes titleHue {
0% {
filter: hue-rotate(0deg);
}
100% {
filter: hue-rotate(360deg);
}
}
.sub {
margin: 0 0 14px;
font-size: clamp(14px, 2.0vw, 18px);
opacity: .92;
}
.tips {
display: grid;
gap: 10px;
margin-top: 16px;
}
.tip {
padding: 12px 14px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, .11), rgba(255, 255, 255, .06));
border: 1px solid rgba(255, 255, 255, .15);
box-shadow: 0 12px 40px rgba(0, 0, 0, .25);
transform-origin: left center;
animation: tipPop 2.8s ease-in-out infinite;
}
.tip:nth-child(2) {
animation-delay: .35s;
}
.tip:nth-child(3) {
animation-delay: .7s;
}
@keyframes tipPop {
0%,
100% {
transform: translateY(0) rotate(-.15deg) scale(1);
}
50% {
transform: translateY(-6px) rotate(.15deg) scale(1.02);
}
}
.right {
position: relative;
display: grid;
place-items: center;
z-index: 1;
}
.qr-frame {
width: min(340px, 72vw);
aspect-ratio: 1 / 1;
border-radius: 28px;
padding: 18px;
background: linear-gradient(135deg, rgba(255, 255, 255, .18), rgba(255, 255, 255, .06));
border: 1px solid rgba(255, 255, 255, .22);
box-shadow:
0 18px 55px rgba(0, 0, 0, .35),
var(--glowA),
var(--glowB),
var(--glowC);
position: relative;
overflow: hidden;
animation: framePulse 1.8s ease-in-out infinite;
}
@keyframes framePulse {
0%,
100% {
transform: rotate(-.4deg) scale(1);
}
50% {
transform: rotate(.4deg) scale(1.03);
}
}
/* Regenbogenrand-Licht */
.qr-frame::before {
content: "";
position: absolute;
inset: -40%;
background: conic-gradient(from 180deg,
#ff004c, #ffea00, #00ffb7, #00b3ff, #a100ff, #ff004c);
opacity: .55;
filter: blur(18px);
animation: spin 3.2s linear infinite reverse;
pointer-events: none;
}
.qr {
width: 100%;
height: 100%;
border-radius: 18px;
background: #fff;
display: grid;
place-items: center;
position: relative;
overflow: hidden;
}
/* QR selbst */
.qr img {
width: 92%;
height: 92%;
object-fit: contain;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
transform-origin: center;
animation: qrWobble 1.4s ease-in-out infinite;
filter: drop-shadow(0 12px 22px rgba(0, 0, 0, .18));
}
@keyframes qrWobble {
0%,
100% {
transform: rotate(-.7deg) scale(1);
}
50% {
transform: rotate(.7deg) scale(1.02);
}
}
/* Shine-Sweep */
.shine {
position: absolute;
inset: 0;
background: linear-gradient(120deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, .55) 45%,
rgba(255, 255, 255, 0) 70%);
transform: translateX(-140%) rotate(18deg);
mix-blend-mode: screen;
animation: sweep 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes sweep {
0% {
transform: translateX(-140%) rotate(18deg);
opacity: 0;
}
18% {
opacity: .75;
}
55% {
opacity: .55;
}
100% {
transform: translateX(140%) rotate(18deg);
opacity: 0;
}
}
/* ====== Partikel-Canvas ====== */
canvas#party {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
mix-blend-mode: screen;
opacity: .85;
}
/* ====== Footer Bling ====== */
.footer {
margin-top: 16px;
font-size: 12px;
opacity: .85;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.pill {
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, .18);
background: rgba(255, 255, 255, .08);
animation: pillBounce 1.7s ease-in-out infinite;
}
.pill:nth-child(2) {
animation-delay: .2s;
}
.pill:nth-child(3) {
animation-delay: .4s;
}
@keyframes pillBounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
/* Accessibility-ish: weniger Bewegung */
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
body {
background-size: 100% 100%;
}
}
</style>
</head>
<body>
<canvas id="party"></canvas>
<div class="noise"></div>
<div class="scanlines"></div>
<main class="wrap">
<section class="card" aria-label="FSAE41 WLAN QR Code">
<div class="left">
<div class="badge">📶 FSAE41 • Schnelles-WLAN! • QR-Code</div>
<h1>Scan mich<br />für WLAN ✨</h1>
<p class="sub">
JETZT QR<br>Code scannen und mit bis zu 1Gbit/s <em>los surfen!</em>.
</p>
<div class="tips">
<div class="tip">✅ Kamera-App öffnen → QR scannen → verbinden</div>
<div class="tip">💡 Wenns nicht klappt: Abstand ändern / Licht an</div>
<div class="tip">🚀 Bonus: <b>SSID: FSAE41.de | Pass: FSAE41@bbs (WPA2)</b></div>
</div>
<div class="footer">
<span class="pill"><b><a href="https://www.fsae41.de">HOME</a></b></span>
<span class="pill"><b><a href="https://www.lifab.de/OT">die OT</a></b></span>
<span class="pill" id="ip">⏱️</span>
</div>
</div>
<div class="right">
<div class="qr-frame" title="QR-Code: qr-code_fsae41.png">
<div class="qr">
<img src="static/qr-code_fsae41.png" alt="QR Code für das Klassen-WLAN FSAE41" />
<div class="shine"></div>
</div>
</div>
</div>
</section>
</main>
<script>
// IP anzeigen
fetch("https://api.ipify.org?format=json")
.then(r => r.json())
.then(data => {
document.getElementById("ip").textContent = data.ip;
})
.catch(() => {
document.getElementById("ip").textContent = "Fehler beim Laden";
});
// ====== Partikel-Party im Canvas ======
const canvas = document.getElementById("party");
const ctx = canvas.getContext("2d", { alpha: true });
function resize() {
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
canvas.style.width = window.innerWidth + "px";
canvas.style.height = window.innerHeight + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener("resize", resize);
resize();
const rand = (a, b) => a + Math.random() * (b - a);
// Bunte „Konfetti“-Partikel + Orbit-Bubbles
const particles = [];
const N = Math.min(180, Math.floor((window.innerWidth * window.innerHeight) / 12000));
function makeParticle() {
const type = Math.random() < 0.72 ? "confetti" : "bubble";
return {
type,
x: rand(0, window.innerWidth),
y: rand(0, window.innerHeight),
vx: rand(-0.6, 0.6),
vy: rand(-1.2, -0.2),
size: type === "confetti" ? rand(2, 6) : rand(6, 16),
rot: rand(0, Math.PI * 2),
vr: rand(-0.08, 0.08),
hue: rand(0, 360),
life: rand(220, 520),
t: 0,
wobble: rand(0.6, 2.2),
phase: rand(0, Math.PI * 2),
};
}
for (let i = 0; i < N; i++) particles.push(makeParticle());
let mouseX = window.innerWidth / 2, mouseY = window.innerHeight / 2;
window.addEventListener("pointermove", (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
}, { passive: true });
function draw() {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
// Leichte „Aura“ um den Mauszeiger (komplett unnötig)
const grad = ctx.createRadialGradient(mouseX, mouseY, 0, mouseX, mouseY, 180);
grad.addColorStop(0, "rgba(255,255,255,0.22)");
grad.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = grad;
ctx.fillRect(mouseX - 180, mouseY - 180, 360, 360);
for (const p of particles) {
p.t += 1;
p.life -= 1;
p.rot += p.vr;
// Bewegung
const wob = Math.sin((p.t * 0.03) + p.phase) * p.wobble;
p.x += p.vx + wob * 0.05;
p.y += p.vy + Math.cos((p.t * 0.02) + p.phase) * 0.08;
// Wieder oben rein
if (p.y < -40 || p.x < -60 || p.x > window.innerWidth + 60 || p.life <= 0) {
Object.assign(p, makeParticle(), { y: window.innerHeight + rand(0, 120) });
}
// Zeichnen
const a = 0.55 + 0.35 * Math.sin(p.t * 0.02 + p.phase);
if (p.type === "confetti") {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = `hsla(${p.hue}, 95%, 60%, ${a})`;
ctx.fillRect(-p.size, -p.size / 2, p.size * 2.2, p.size);
ctx.restore();
} else {
ctx.beginPath();
ctx.fillStyle = `hsla(${p.hue}, 95%, 65%, ${a * 0.55})`;
ctx.arc(p.x, p.y, p.size * (0.55 + 0.25 * Math.sin(p.t * 0.03)), 0, Math.PI * 2);
ctx.fill();
}
}
requestAnimationFrame(draw);
}
draw();
// Kleines Easter Egg: Space = "Turbo Disco"
let turbo = false;
window.addEventListener("keydown", (e) => {
if (e.code === "Space") {
turbo = !turbo;
document.body.style.animationDuration = turbo ? "2.8s" : "9s";
}
});
</script>
</body>
</html>