Compare commits

...

10 Commits

Author SHA1 Message Date
Simon adc3ac828f feat: stundenplan page with timetable, exams, and quicklinks
- Static timetable for all 5 classes (Info1/2, Freko1/2, MR)
- Click-to-highlight subject across week grid
- Cell tooltip with full subject, teacher, room, time
- Today list with remaining lesson count and Freistunde gaps
- Klausurenplan with Bayern Ferien dividers
- iCal export button for weekly schedule
- Quicklinks panel (Notenportal)
2026-04-30 08:14:18 +02:00
Simon 7d464c21eb feat: Web Push notifications
- VAPID-based push via web-push package
- push_subscriptions table: endpoint + keys per user (upsert on conflict)
- GET /api/push/vapid-key — public key for subscribe flow
- POST/DELETE /api/push/subscribe — store/remove subscription
- POST /api/push/test — manual test notification
- Hourly scheduler: notifies users day before homework due + countdown expires
- SW: push event handler shows notification; notificationclick opens /app
- Settings: Push section with enable/disable/test buttons, auto-detects
  browser support and VAPID availability
2026-04-23 11:44:28 +02:00
Simon a765d2d088 feat: iCal export, grade calculator, countdown pulse, session logout
- iCal feed at /api/ical/:token.ics — timetable (recurring), homework,
  class events, countdowns; subscribe in Google/Apple/Outlook Calendar
- POST /api/ical-token + /api/ical-token/regenerate for token management
- Grade calculator panel in Noten overlay: per-subject weighted average
  + "what score do I need?" solver (picks correct GRADE_WEIGHTS per type)
- Countdown urgency: items ≤2 days pulse red via CSS animation
- POST /api/me/logout-other — invalidates all other sessions by bumping
  token_version, re-issues JWT for current session
- Settings: iCal URL section + copy button; session management section
- Migration: users.ical_token, grades.created_at
2026-04-23 11:42:41 +02:00
simon ca5f3f39e2 Update README.md 2026-04-23 10:02:44 +02:00
Simon 78f3cb6fb4 feat: ifb.lol landing page with PDF downloads 2026-04-23 10:00:19 +02:00
Simon 345d995b96 feat: password generator, weak-password block, confirm step for generated passwords 2026-04-22 23:04:46 +02:00
Simon 480c3d09e5 fix: ql icons on load, text overflow in quicklink boxes 2026-04-22 22:53:04 +02:00
Simon 62ebc53875 feat: default quicklinks, static PDF downloads, improved app icon 2026-04-22 22:49:35 +02:00
Simon 6b54e3c813 feat: add PWA support (manifest, service worker, icons) 2026-04-22 22:28:17 +02:00
Simon d9189d558d fix: update contact email to kontakt@info1.ifb.lol 2026-04-22 21:49:28 +02:00
27 changed files with 2161 additions and 64 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
Privates Dashboard für Schulklassen. Stundenplan, Hausaufgaben, Noten, Fehlzeiten, Klassenkalender und Chat an einem Ort.
Läuft unter [info1.ifb.lol](https://info1.ifb.lol).
Läuft unter [ifb.lol](https://ifb.lol).
## Stack
+11 -1
View File
@@ -6,6 +6,7 @@ const path = require('path');
const routes = require('./src/routes');
const { router: filesRouter } = require('./src/files');
const teacherRouter = require('./src/teacher');
const { startNotificationScheduler } = require('./src/push');
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set.');
@@ -25,6 +26,7 @@ app.use(helmet({
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'https://api.open-meteo.com'],
workerSrc: ["'self'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
},
@@ -32,6 +34,10 @@ app.use(helmet({
}));
app.use(express.json());
app.use(cookieParser());
app.get('/sw.js', (req, res, next) => {
res.setHeader('Cache-Control', 'no-cache');
next();
});
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api', routes);
app.use('/api/files', filesRouter);
@@ -44,6 +50,10 @@ app.get('/admin', html('admin.html'));
app.get('/datenschutz', html('datenschutz.html'));
app.get('/app', html('app.html'));
app.get('/reset-password', html('reset-password.html'));
app.get('/stundenplan', html('stundenplan.html'));
app.get('/{*path}', html('index.html'));
app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`));
app.listen(PORT, '127.0.0.1', () => {
console.log(`info1 läuft auf :${PORT}`);
startNotificationScheduler();
});
Binary file not shown.
Binary file not shown.
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#2563eb">
<title>IFB · Klassenportal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f4f6f9;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
color: #111827;
}
.page {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 14px;
box-shadow: 0 4px 16px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
padding: 40px 36px 36px;
width: 100%;
max-width: 400px;
text-align: center;
}
.brand-mark {
width: 48px; height: 48px;
background: #2563eb;
border-radius: 10px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 800; color: #fff;
letter-spacing: -.5px;
margin-bottom: 20px;
}
h1 {
font-size: 22px;
font-weight: 800;
color: #111827;
letter-spacing: -.4px;
margin-bottom: 8px;
}
.sub {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
margin-bottom: 28px;
}
.portal-link {
display: flex;
align-items: center;
justify-content: space-between;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 10px;
padding: 14px 18px;
text-decoration: none;
transition: background .12s, border-color .12s, box-shadow .12s;
gap: 12px;
}
.portal-link:hover {
background: #dbeafe;
border-color: #93c5fd;
box-shadow: 0 2px 8px rgba(37,99,235,.1);
}
.portal-link-left {
display: flex;
align-items: center;
gap: 12px;
text-align: left;
}
.portal-mark {
width: 36px; height: 36px;
background: #2563eb;
border-radius: 7px;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 800; color: #fff;
letter-spacing: -.4px;
flex-shrink: 0;
}
.portal-info-label {
font-size: 10px;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
letter-spacing: .5px;
}
.portal-info-name {
font-size: 15px;
font-weight: 700;
color: #1d4ed8;
letter-spacing: -.2px;
}
.portal-arrow {
color: #3b82f6;
font-size: 18px;
flex-shrink: 0;
}
.info-box {
margin-top: 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 14px 18px;
text-align: left;
}
.info-box-title {
font-size: 11px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: .5px;
margin-bottom: 10px;
}
.dl-link {
display: flex;
align-items: center;
justify-content: space-between;
text-decoration: none;
padding: 7px 0;
gap: 10px;
border-bottom: 1px solid #f3f4f6;
transition: opacity .12s;
}
.dl-link:last-child { border-bottom: none; }
.dl-link:hover { opacity: .7; }
.dl-link-left {
display: flex;
align-items: center;
gap: 9px;
}
.dl-icon {
width: 28px; height: 28px;
background: #fee2e2;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 800; color: #dc2626;
flex-shrink: 0;
}
.dl-name {
font-size: 13px;
font-weight: 600;
color: #111827;
}
.dl-meta {
font-size: 11px;
color: #9ca3af;
}
.dl-arrow {
font-size: 13px;
color: #9ca3af;
flex-shrink: 0;
}
footer {
text-align: center;
padding: 18px;
font-size: 12px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="page">
<div class="card">
<div class="brand-mark">KP</div>
<h1>Klassenportal</h1>
<p class="sub">Das Klassenportal für die Klasse <strong>Info1</strong> ist hier erreichbar:</p>
<a class="portal-link" href="https://info1.ifb.lol">
<div class="portal-link-left">
<div class="portal-mark">KP</div>
<div>
<div class="portal-info-label">Klassenportal · Info1</div>
<div class="portal-info-name">info1.ifb.lol</div>
</div>
</div>
<span class="portal-arrow"></span>
</a>
<div class="info-box">
<div class="info-box-title">Downloads</div>
<a class="dl-link" href="/Stundenplan_ab_02_03_26.pdf" download>
<div class="dl-link-left">
<div class="dl-icon">PDF</div>
<div>
<div class="dl-name">Stundenplan</div>
<div class="dl-meta">ab 02.03.26</div>
</div>
</div>
<span class="dl-arrow"></span>
</a>
<a class="dl-link" href="/Klausurenplan_ab_06_03_26.pdf" download>
<div class="dl-link-left">
<div class="dl-icon">PDF</div>
<div>
<div class="dl-name">Klausurenplan</div>
<div class="dl-meta">ab 06.03.26</div>
</div>
</div>
<span class="dl-arrow"></span>
</a>
</div>
</div>
</div>
<footer>
ifb.lol
</footer>
</body>
</html>
+79 -4
View File
@@ -1,13 +1,13 @@
{
"name": "info1",
"version": "1.0.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "info1",
"version": "1.0.0",
"license": "ISC",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0",
@@ -20,7 +20,8 @@
"multer": "^2.1.1",
"otplib": "^13.4.0",
"qrcode": "^1.5.4",
"resend": "^6.12.0"
"resend": "^6.12.0",
"web-push": "^3.6.7"
}
},
"node_modules/@noble/hashes": {
@@ -119,6 +120,15 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -149,6 +159,18 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -212,6 +234,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -869,6 +897,15 @@
"node": ">=18.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -889,6 +926,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -1134,6 +1184,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1956,6 +2012,25 @@
"node": ">= 0.8"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+10 -2
View File
@@ -5,7 +5,14 @@
"scripts": {
"start": "node index.js"
},
"keywords": ["dashboard", "education", "class", "self-hosted", "express", "sqlite"],
"keywords": [
"dashboard",
"education",
"class",
"self-hosted",
"express",
"sqlite"
],
"author": "lulinretrograde",
"license": "MIT",
"repository": {
@@ -25,6 +32,7 @@
"multer": "^2.1.1",
"otplib": "^13.4.0",
"qrcode": "^1.5.4",
"resend": "^6.12.0"
"resend": "^6.12.0",
"web-push": "^3.6.7"
}
}
+8
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Klassenportal">
<title>Klassenportal · Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -122,6 +129,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
.tab .lucide { width: 14px; height: 14px; }
</style>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body>
+269 -6
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Klassenportal">
<title>Klassenportal · Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -544,6 +551,20 @@ main {
.grade-avg-val { font-size: 24px; font-weight: 800; color: var(--blue); letter-spacing: -1px; }
.grade-avg-sub { font-size: 10px; color: var(--text-subtle); margin-top: 2px; }
.grade-calc-panel {
margin-top: 12px; padding: 12px;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--r); display: none;
}
.grade-calc-panel.open { display: block; }
.grade-calc-row { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
.grade-calc-row select, .grade-calc-row input {
height:28px; padding:0 8px; border:1px solid var(--border);
border-radius:var(--r-sm); font-size:12px; background:var(--surface);
color:var(--text); font-family:inherit; min-width:0;
}
.grade-calc-result { font-size:13px; color:var(--text-2); min-height:18px; }
/* ── ABSENCES ────────────────────────────────────────────── */
.ab-item {
@@ -589,6 +610,9 @@ main {
.cd-item:last-child { border-bottom: none; }
.cd-days { font-size: 18px; font-weight: 800; color: var(--blue); min-width: 36px; text-align: center; letter-spacing: -1px; font-variant-numeric: tabular-nums; line-height: 1; }
.cd-days.past { color: var(--text-subtle); font-size: 14px; }
@keyframes cd-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(220,38,38,0)} 50%{box-shadow:0 0 0 5px rgba(220,38,38,.18)} }
.cd-item.urgent { border-radius:var(--r-sm); border-bottom:1px solid rgba(220,38,38,.25); animation:cd-pulse 2s ease-in-out infinite; }
.cd-item.urgent .cd-days { color:var(--red)!important; }
.cd-info { flex: 1; }
.cd-title { font-size: 13px; font-weight: 500; color: var(--text); }
.cd-date { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
@@ -609,12 +633,14 @@ main {
transition: border-color .12s, background .12s, color .12s;
position: relative; cursor: pointer;
line-height: 1.3;
overflow-wrap: break-word; word-break: break-word; overflow: hidden;
}
.ql-item:hover { background: var(--blue-50); border-color: var(--blue); color: var(--blue); }
.ql-icon { font-size: 18px; line-height: 1; }
.ql-del { position: absolute; top: 3px; right: 4px; font-size: 10px; color: var(--text-subtle); display: none; border: none; background: none; cursor: pointer; line-height: 1; }
.ql-item:hover .ql-del { display: block; }
.ql-del:hover { color: var(--red); }
.ql-item--soon { opacity: .4; cursor: not-allowed; pointer-events: none; }
/* ── EMPTY STATE ─────────────────────────────────────────── */
@@ -1010,6 +1036,7 @@ footer {
.btn-sm .lucide { width: 13px; height: 13px; }
.sidebar-close .lucide { width: 16px; height: 16px; }
</style>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body>
@@ -1098,11 +1125,31 @@ footer {
<div class="card-head">
<div class="card-title"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten</div>
<div class="card-actions">
<button class="add-btn" onclick="toggleGradeCalc()" id="btn-grade-calc">Rechner</button>
<button class="add-btn" onclick="openModal('grades')">+ Note</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-grades"></div>
<div class="card-body" style="padding:0;display:flex;flex-direction:column">
<div class="grade-calc-panel" id="grade-calc-panel" style="margin:12px 12px 0">
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Notenrechner</div>
<div class="grade-calc-row">
<select id="calc-subject" onchange="updateGradeCalc()" style="flex:1">
<option value="">Alle Fächer</option>
</select>
<select id="calc-type" onchange="updateGradeCalc()">
<option value="schulaufgabe">Schulaufgabe</option>
<option value="kurzarbeit">Kurzarbeit</option>
<option value="stegreifaufgabe">Stegreifaufgabe</option>
<option value="muendlich">Mündlich</option>
<option value="sonstiges">Sonstiges</option>
</select>
<input type="number" id="calc-target" min="1" max="6" step="0.1" placeholder="Ziel (16)" style="width:90px" oninput="updateGradeCalc()">
</div>
<div class="grade-calc-result" id="calc-result"></div>
</div>
<div id="list-grades" style="padding:12px;flex:1"></div>
</div>
</div>
<div class="card ov-card" id="card-files" style="display:none">
@@ -1410,8 +1457,12 @@ footer {
<button class="add-btn" id="btn-add-ql" style="display:none" onclick="openModal('quicklinks')">+ Link</button>
</div>
<div class="card-body"><div class="ql-grid" id="ql-grid">
<a class="ql-item" href="https://www.ifb-schulen.de" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="building-2" aria-hidden="true"></i></span>IFB Website</a>
<a class="ql-item" href="https://www.bib-info.de" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="book-open" aria-hidden="true"></i></span>Bibliothek</a>
<span class="ql-item ql-item--soon" title="Demnächst verfügbar"><span class="ql-icon"><i data-lucide="award" aria-hidden="true"></i></span>Notenportal</span>
<a class="ql-item" href="https://github.com/lulinretrograde/ifb-schulapp" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="github" aria-hidden="true"></i></span>Quellcode</a>
<a class="ql-item" href="https://ifb-rosenheim.de/" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="building-2" aria-hidden="true"></i></span>IFB-Website</a>
<a class="ql-item" href="https://webentwicklerkurs.de/" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="globe" aria-hidden="true"></i></span>Webentwicklerkurs</a>
<a class="ql-item" href="https://pythonentwicklerkurs.de/" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="terminal" aria-hidden="true"></i></span>Pythonentwicklerkurs</a>
<a class="ql-item" href="https://kientwicklerkurs.de/" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="bot" aria-hidden="true"></i></span>KI-Entwicklerkurs</a>
</div></div>
</div>
@@ -1580,6 +1631,35 @@ footer {
</div>
</div>
<div class="settings-section" id="push-section">
<div class="settings-label">Push-Benachrichtigungen</div>
<div id="push-status" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geprüft…</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-save" id="btn-push-enable" style="font-size:12px;padding:5px 12px;display:none" onclick="enablePush()">Aktivieren</button>
<button class="btn-cancel" id="btn-push-disable" style="font-size:12px;padding:5px 12px;display:none" onclick="disablePush()">Deaktivieren</button>
<button class="btn-cancel" id="btn-push-test" style="font-size:12px;padding:5px 12px;display:none" onclick="testPush()">Test senden</button>
</div>
</div>
<div class="settings-section">
<div class="settings-label">Kalender-Export (iCal)</div>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-save" style="font-size:12px;padding:5px 12px" onclick="getIcalUrl()">URL generieren</button>
<button class="btn-cancel" style="font-size:12px;padding:5px 12px" onclick="regenIcalUrl()">Neu generieren</button>
</div>
<div id="ical-url-row" style="display:none;margin-top:8px">
<input id="ical-url" type="text" readonly style="width:100%;font-size:11px;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r-sm);background:var(--surface-2);color:var(--text);font-family:monospace" onclick="this.select()">
<button class="btn-cancel" style="font-size:11px;padding:3px 10px;margin-top:4px" onclick="copyIcalUrl()">Kopieren</button>
</div>
</div>
<div class="settings-section">
<div class="settings-label">Sitzungsverwaltung</div>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Alle anderen Geräte und Browser werden sofort abgemeldet. Du bleibst eingeloggt.</p>
<button class="btn-cancel" style="font-size:12px;padding:5px 12px" onclick="logoutOther()">Andere Geräte abmelden</button>
</div>
<div class="settings-section">
<div class="danger-zone">
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
@@ -1888,6 +1968,8 @@ function renderHW(data){
// ── GRADES ────────────────────────────────────────────────────
function renderGrades(data){
gradeCalcData=data;
if(document.getElementById('grade-calc-panel')?.classList.contains('open')) updateGradeCalc();
const el=document.getElementById('list-grades');
if(!data.length){el.innerHTML='<div class="empty">Keine Noten eingetragen</div>';return;}
const rows=data.map(g=>{
@@ -1998,7 +2080,8 @@ function renderCountdowns(data){
if(!data.length){el.innerHTML='<div class="empty">Keine Countdowns</div>';return;}
el.innerHTML=[...data].sort((a,b)=>(a.target_date||'').localeCompare(b.target_date||'')).map(c=>{
const d=daysUntil(c.target_date);
return `<div class="cd-item">
const urgent=d>=0&&d<=2?' urgent':'';
return `<div class="cd-item${urgent}">
<div class="cd-days ${d<0?'past':''}">${d<0?'✓':d}</div>
<div class="cd-info"><div class="cd-title">${esc(c.title)}</div><div class="cd-date">${fmtDate(c.target_date)}</div></div>
<button class="del-btn" onclick="delItem('countdowns',${c.id})">🗑</button>
@@ -2092,6 +2175,7 @@ function openSettings(){
document.getElementById('settings-overlay').style.display='flex';
document.getElementById('user-dropdown')?.classList.remove('open');
load2FAStatus();
initPushUI();
if(currentRole==='teacher'){
document.getElementById('teacher-subject-section').style.display='';
loadMySubjects();
@@ -2296,6 +2380,169 @@ async function deleteAccount(){
else toast(r.error,'error');
}
// ── WEB PUSH ──────────────────────────────────────────────────
let _pushReg = null;
function urlBase64ToUint8Array(b64) {
const pad = '='.repeat((4 - b64.length % 4) % 4);
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
return new Uint8Array([...raw].map(c => c.charCodeAt(0)));
}
async function initPushUI() {
const section = document.getElementById('push-section');
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
section.style.display = 'none'; return;
}
const { key } = await api('GET', 'push/vapid-key').catch(() => ({}));
if (!key) { section.style.display = 'none'; return; }
const reg = await navigator.serviceWorker.ready;
_pushReg = reg;
const existing = await reg.pushManager.getSubscription();
updatePushUI(existing);
}
function updatePushUI(sub) {
const status = document.getElementById('push-status');
const btnEn = document.getElementById('btn-push-enable');
const btnDis = document.getElementById('btn-push-disable');
const btnTest = document.getElementById('btn-push-test');
if (sub) {
status.textContent = 'Aktiv du erhältst Benachrichtigungen bei fälligen Hausaufgaben und Countdowns.';
status.style.color = 'var(--green)';
btnEn.style.display = 'none';
btnDis.style.display = '';
btnTest.style.display = '';
} else {
status.textContent = 'Deaktiviert.';
status.style.color = 'var(--text-muted)';
btnEn.style.display = '';
btnDis.style.display = 'none';
btnTest.style.display = 'none';
}
}
async function enablePush() {
try {
const { key } = await api('GET', 'push/vapid-key');
const perm = await Notification.requestPermission();
if (perm !== 'granted') { toast('Benachrichtigungen verweigert', 'error'); return; }
const reg = _pushReg || await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
});
const { endpoint, keys } = sub.toJSON();
const r = await api('POST', 'push/subscribe', { endpoint, keys });
if (r.error) { toast(r.error, 'error'); return; }
updatePushUI(sub);
toast('Push-Benachrichtigungen aktiviert ✓');
} catch (e) {
toast('Fehler: ' + e.message, 'error');
}
}
async function disablePush() {
try {
const reg = _pushReg || await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
await api('DELETE', 'push/subscribe', { endpoint: sub.endpoint });
await sub.unsubscribe();
}
updatePushUI(null);
toast('Push-Benachrichtigungen deaktiviert');
} catch (e) {
toast('Fehler: ' + e.message, 'error');
}
}
async function testPush() {
await api('POST', 'push/test');
toast('Test-Benachrichtigung gesendet');
}
// ── ICAL EXPORT ───────────────────────────────────────────────
async function getIcalUrl(regen=false){
const r=await api('POST', regen?'ical-token/regenerate':'ical-token');
if(r.error){toast(r.error,'error');return;}
const url=location.origin+'/api/ical/'+r.token+'.ics';
document.getElementById('ical-url').value=url;
document.getElementById('ical-url-row').style.display='';
}
async function regenIcalUrl(){
if(!confirm('Neue URL generieren? Der alte Link wird ungültig.'))return;
await getIcalUrl(true);
toast('Neue iCal-URL generiert');
}
function copyIcalUrl(){
const input=document.getElementById('ical-url');
navigator.clipboard.writeText(input.value).then(()=>toast('URL kopiert ✓')).catch(()=>{input.select();document.execCommand('copy');toast('URL kopiert ✓');});
}
// ── LOGOUT OTHER DEVICES ──────────────────────────────────────
async function logoutOther(){
if(!confirm('Alle anderen Geräte abmelden?'))return;
const r=await api('POST','me/logout-other');
if(r.ok) toast('Andere Sitzungen beendet ✓');
else toast(r.error,'error');
}
// ── GRADE CALCULATOR ──────────────────────────────────────────
let gradeCalcData=[];
function toggleGradeCalc(){
const panel=document.getElementById('grade-calc-panel');
panel.classList.toggle('open');
if(panel.classList.contains('open')) updateGradeCalcSubjects();
}
function updateGradeCalcSubjects(){
const sel=document.getElementById('calc-subject');
const subjects=[...new Set(gradeCalcData.map(g=>g.subject).filter(Boolean))].sort();
sel.innerHTML='<option value="">Alle Fächer</option>'+subjects.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
updateGradeCalc();
}
function updateGradeCalc(){
const subj=document.getElementById('calc-subject').value;
const type=document.getElementById('calc-type').value;
const target=parseFloat(document.getElementById('calc-target').value);
const el=document.getElementById('calc-result');
if(!el)return;
const grades=subj?gradeCalcData.filter(g=>g.subject===subj):gradeCalcData;
const valid=grades.filter(g=>g.grade!=null);
const wSum=valid.reduce((s,g)=>s+(GRADE_WEIGHTS[g.type]||1),0);
const wGrades=valid.reduce((s,g)=>s+g.grade*(GRADE_WEIGHTS[g.type]||1),0);
const wavg=wSum?wGrades/wSum:null;
let html='';
if(wavg!==null){
html+=`<div style="margin-bottom:6px">Aktuell${subj?' ('+esc(subj)+')':''}: <strong style="color:var(--blue)">${wavg.toFixed(2)}</strong> <span style="color:var(--text-muted);font-size:11px">(${valid.length} Note${valid.length!==1?'n':''})</span></div>`;
} else {
html+=`<div style="margin-bottom:6px;color:var(--text-muted)">Noch keine Noten${subj?' in '+esc(subj):''}.</div>`;
}
if(!isNaN(target)&&target>=1&&target<=6){
const w=GRADE_WEIGHTS[type]||1;
const needed=((target*(wSum+w))-wGrades)/w;
let msg;
if(needed<1){
msg=`<span style="color:var(--green)">Bereits erreicht mit ${GRADE_TYPES[type]} egal welche Note.</span>`;
} else if(needed>6){
msg=`<span style="color:var(--red)">Nicht mehr erreichbar mit einem ${GRADE_TYPES[type]}.</span>`;
} else {
const col=needed<=2?'var(--green)':needed<=4?'var(--amber)':'var(--red)';
msg=`Für Schnitt <strong>${target}</strong> beim nächsten ${GRADE_TYPES[type]}: <strong style="color:${col}">${needed.toFixed(2)}</strong>`;
}
html+=`<div>${msg}</div>`;
}
el.innerHTML=html;
}
// ── CHAT ──────────────────────────────────────────────────────
let chatLastId = 0;
let chatPollTimer = null;
@@ -2970,8 +3217,22 @@ async function delTeacherGrade(id){
async function loadStudentMaterials(){
const mats=await api('GET','teacher/materials/class/info1');
const el=document.getElementById('list-student-materials');
if(!mats||mats.error||!mats.length){el.innerHTML='<div class="empty">Keine Materialien vorhanden</div>';return;}
el.innerHTML=mats.map(m=>`<div class="file-item">
const staticItems=`<div class="file-item">
<div class="file-icon">📕</div>
<div class="file-info">
<div class="file-name">Stundenplan ab 02.03.26</div>
<div class="file-meta">PDF · Allgemein</div>
</div>
<a class="file-dl" href="/downloads/Stundenplan_ab_02_03_26.pdf" download="Stundenplan_ab_02_03_26.pdf">↓ Laden</a>
</div><div class="file-item">
<div class="file-icon">📕</div>
<div class="file-info">
<div class="file-name">Klausurenplan ab 06.03.26</div>
<div class="file-meta">PDF · Allgemein</div>
</div>
<a class="file-dl" href="/downloads/Klausurenplan_ab_06_03_26.pdf" download="Klausurenplan_ab_06_03_26.pdf">↓ Laden</a>
</div>`;
const dynItems=(!mats||mats.error||!mats.length)?'':mats.map(m=>`<div class="file-item">
<div class="file-icon">${fileIcon(m.mime_type)}</div>
<div class="file-info">
<div class="file-name">${esc(m.title)}</div>
@@ -2979,6 +3240,7 @@ async function loadStudentMaterials(){
</div>
<button class="file-dl" onclick="window.location.href='/api/teacher/materials/${m.id}/download'">↓ Laden</button>
</div>`).join('');
el.innerHTML=staticItems+dynItems;
}
async function loadStudentAnnouncements(){
@@ -2995,6 +3257,7 @@ async function loadStudentAnnouncements(){
}
// ── START ─────────────────────────────────────────────────────
lucide.createIcons();
init();
</script>
</body>
+9 -1
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Klassenportal">
<title>Datenschutzerklärung · Klassenportal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -30,6 +37,7 @@
th { text-align: left; padding: 8px 12px; background: #f8fafc; font-weight: 600; color: #374151; border-bottom: 1px solid #e2e8f0; }
td { padding: 8px 12px; color: #334155; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
</style>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body>
<header>
@@ -44,7 +52,7 @@
<h2>1. Verantwortlicher</h2>
<div class="box">
<p>Dieses Klassenportal wird privat von einer Privatperson betrieben, ausschließlich für interne Klassenzwecke. Es handelt sich weder um ein kommerzielles Angebot noch um ein offizielles Angebot einer Schule, eines Trägers oder einer Bildungseinrichtung.</p>
<p style="margin-top:10px"><strong>Kontakt:</strong> <a href="mailto:kontakt@simon0x.xyz">kontakt@simon0x.xyz</a></p>
<p style="margin-top:10px"><strong>Kontakt:</strong> <a href="mailto:kontakt@info1.ifb.lol">kontakt@info1.ifb.lol</a></p>
</div>
</section>
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2563eb"/>
<polygon points="16,7 29,13 16,19 3,13" fill="white"/>
<path d="M9.5,15.5 L9.5,22 Q16,26 22.5,22 L22.5,15.5" fill="white"/>
<line x1="29" y1="13" x2="29" y2="20.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
<circle cx="29" cy="22" r="1.8" fill="white"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2563eb"/>
<polygon points="256,76 480,188 256,300 32,188" fill="white"/>
<path d="M128,216 L128,326 C128,384 186,428 256,428 C326,428 384,384 384,326 L384,216 L256,300 Z" fill="white"/>
<rect x="462" y="188" width="18" height="118" rx="9" fill="white"/>
<circle cx="471" cy="330" r="28" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 399 B

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+8
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Klassenportal">
<title>Klassenportal · Das Klassen-Cockpit</title>
<meta name="description" content="Privates Klassenportal für Schüler und Lehrer. Stundenplan, Noten, Hausaufgaben, Klassen-Chat und mehr.">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
@@ -604,6 +611,7 @@ footer {
.brand-sub { display: none; }
}
</style>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body>
+129 -11
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Klassenportal">
<title>Klassenportal · Anmelden</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -186,6 +193,7 @@ footer a:hover { color: #2563eb; }
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
.notice .lucide { width: 14px; height: 14px; margin-right: 3px; }
</style>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body>
@@ -250,7 +258,8 @@ footer a:hover { color: #2563eb; }
<button type="button" onclick="hideForgot()" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:4px 0;margin-top:4px;text-align:center">← Zurück zum Login</button>
</form>
<form class="form" id="form-reg" onsubmit="doRegister(event)">
<form class="form" id="form-reg" onsubmit="return false">
<div id="reg-step-1">
<div class="field">
<label>Ich bin</label>
<div class="role-toggle">
@@ -280,15 +289,34 @@ footer a:hover { color: #2563eb; }
</div>
<div class="field">
<label for="r-pass">Passwort</label>
<input type="password" id="r-pass" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" required oninput="checkStrength(this.value)">
<input type="password" id="r-pass" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" required oninput="onPassInput(this.value)">
<button type="button" onclick="useGeneratedPassword()" style="margin-top:7px;font-size:12px;font-weight:600;color:#2563eb;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:inherit;width:100%;text-align:center">🔀 Sicheres Passwort generieren</button>
<div id="gen-display" style="display:none;margin-top:6px;background:#f0fdf4;border:1px solid #86efac;border-radius:6px;padding:8px 10px;font-size:12px;color:#166534">
<div style="margin-bottom:4px;font-weight:600">Generiertes Passwort unbedingt speichern:</div>
<div style="display:flex;align-items:center;gap:8px">
<span id="gen-pw-text" style="font-family:monospace;font-size:13px;word-break:break-all;flex:1;letter-spacing:.5px"></span>
<button type="button" id="copy-btn" onclick="copyGenPw()" style="flex-shrink:0;font-size:11px;font-weight:600;color:#166534;background:#dcfce7;border:1px solid #86efac;border-radius:5px;padding:3px 8px;cursor:pointer;font-family:inherit">Kopieren</button>
</div>
</div>
<div class="strength-wrap">
<div class="strength-bar"><div class="strength-fill" id="strength-fill"></div></div>
<div class="strength-label" id="strength-label">Passwort eingeben</div>
</div>
</div>
<div class="notice notice-red" id="reg-err"></div>
<button class="btn-submit" type="button" id="reg-weiter-btn" onclick="regWeiter()">Weiter</button>
</div>
<div id="reg-step-2" style="display:none">
<p style="font-size:14px;color:#374151;margin-bottom:14px;line-height:1.5">Gib das generierte Passwort nochmal ein, um sicherzustellen, dass du es dir gespeichert hast.</p>
<div class="field">
<label for="r-pass-confirm">Passwort bestätigen</label>
<input type="password" id="r-pass-confirm" autocomplete="new-password" placeholder="••••••">
</div>
<div class="notice notice-red" id="reg-confirm-err"></div>
<button class="btn-submit" type="button" id="reg-confirm-btn" onclick="doRegister()">Account erstellen</button>
<button type="button" onclick="regBack()" style="display:block;width:100%;text-align:center;font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:6px 0;margin-top:4px">← Zurück</button>
</div>
<div class="notice notice-green" id="reg-ok">Registrierung erfolgreich! Wir haben dir eine Bestätigungs-Mail geschickt. Bitte prüfe dein Postfach und klicke auf den Link.</div>
<button class="btn-submit" type="submit" id="reg-btn">Account erstellen</button>
</form>
</div>
@@ -357,21 +385,111 @@ async function doLogin(e) {
window.location.href = '/app';
}
async function doRegister(e) {
e.preventDefault(); clearErr('reg-err');
let generatedPasswordUsed = false;
let lastGeneratedPw = '';
const WEAK_PW = new Set([
'passwort','password','passwort1','password1','passwort123','password123',
'123456','1234567','12345678','123456789','1234567890','qwerty','qwertz',
'abc123','iloveyou','admin','admin123','letmein','welcome','111111',
'000000','123123','hallo','hallo123','test','test1234','schueler','schule',
'klassenportal','ifbschule','ifb',
]);
function onPassInput(val) {
generatedPasswordUsed = false;
document.getElementById('gen-display').style.display = 'none';
checkStrength(val);
}
function useGeneratedPassword() {
const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*';
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
const pw = Array.from(arr, b => chars[b % chars.length]).join('');
lastGeneratedPw = pw;
generatedPasswordUsed = true;
const input = document.getElementById('r-pass');
input.value = pw;
input.type = 'text';
checkStrength(pw);
document.getElementById('gen-pw-text').textContent = pw;
document.getElementById('gen-display').style.display = '';
document.getElementById('copy-btn').textContent = 'Kopieren';
}
function copyGenPw() {
navigator.clipboard.writeText(lastGeneratedPw).catch(() => {
const t = document.createElement('textarea');
t.value = lastGeneratedPw; document.body.appendChild(t); t.select();
document.execCommand('copy'); document.body.removeChild(t);
});
const btn = document.getElementById('copy-btn');
btn.textContent = '✓ Kopiert';
setTimeout(() => { btn.textContent = 'Kopieren'; }, 2000);
}
function validateRegForm() {
const email = document.getElementById('r-email').value.trim().toLowerCase();
const pw = document.getElementById('r-pass').value;
clearErr('reg-err');
if (!email) { showErr('reg-err', 'E-Mail-Adresse erforderlich'); return false; }
if (!pw) { showErr('reg-err', 'Passwort erforderlich'); return false; }
if (pw.length < 8) { showErr('reg-err', 'Passwort muss mindestens 8 Zeichen lang sein'); return false; }
const uname = email.split('@')[0].toLowerCase();
if (pw.toLowerCase() === uname) { showErr('reg-err', 'Passwort darf nicht dem Benutzernamen entsprechen'); return false; }
if (WEAK_PW.has(pw.toLowerCase())) { showErr('reg-err', 'Dieses Passwort ist zu leicht zu erraten'); return false; }
if (selectedRole === 'teacher' && !document.getElementById('r-subject').value) {
showErr('reg-err', 'Bitte ein Lehrfach auswählen'); return false;
}
return true;
}
function regWeiter() {
if (!validateRegForm()) return;
if (generatedPasswordUsed) {
document.getElementById('reg-step-1').style.display = 'none';
document.getElementById('reg-step-2').style.display = '';
document.getElementById('r-pass-confirm').value = '';
document.getElementById('r-pass-confirm').focus();
clearErr('reg-confirm-err');
} else {
doRegister();
}
}
function regBack() {
document.getElementById('reg-step-2').style.display = 'none';
document.getElementById('reg-step-1').style.display = '';
clearErr('reg-confirm-err');
}
async function doRegister() {
if (generatedPasswordUsed) {
const confirm = document.getElementById('r-pass-confirm').value;
if (confirm !== document.getElementById('r-pass').value) {
showErr('reg-confirm-err', 'Passwörter stimmen nicht überein'); return;
}
clearErr('reg-confirm-err');
}
const btn = document.getElementById(generatedPasswordUsed ? 'reg-confirm-btn' : 'reg-weiter-btn');
btn.disabled = true; btn.textContent = 'Wird gesendet…';
const body = {
email: document.getElementById('r-email').value,
email: document.getElementById('r-email').value.trim().toLowerCase(),
password: document.getElementById('r-pass').value,
role: selectedRole,
};
if (selectedRole === 'teacher') body.subject = document.getElementById('r-subject').value;
const r = await fetch('/api/register', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(body) });
const r = await fetch('/api/register', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) { showErr('reg-err', d.error); return; }
if (!r.ok) {
showErr(generatedPasswordUsed ? 'reg-confirm-err' : 'reg-err', d.error);
btn.disabled = false;
btn.textContent = generatedPasswordUsed ? 'Account erstellen' : 'Weiter';
return;
}
document.getElementById('reg-ok').classList.add('show');
document.getElementById('reg-btn').disabled = true;
document.getElementById('reg-btn').textContent = 'Registriert';
btn.disabled = true; btn.textContent = 'Registriert ✓';
}
function showForgot() {
+38
View File
@@ -0,0 +1,38 @@
{
"name": "Klassenportal",
"short_name": "Klassenportal",
"description": "Privates Klassenportal für Schüler und Lehrer",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"theme_color": "#2563eb",
"background_color": "#f4f6f9",
"lang": "de",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+8
View File
@@ -3,6 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Klassenportal">
<title>Klassenportal · Passwort zurücksetzen</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -64,6 +71,7 @@ h1 { font-size: 18px; font-weight: 700; margin-bottom: 6px; letter-spacing: -.2p
footer { text-align: center; padding: 18px; font-size: 12px; color: #9ca3af; }
footer a { color: #6b7280; text-decoration: none; }
</style>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body>
<div class="page">
+942
View File
@@ -0,0 +1,942 @@
<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stundenplan · ifb.lol</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
:root {
--bg:#f0f4f9;--surface:#fff;--surface-2:#f8fafc;--border:#e2e8f0;--border-s:#f0f4f8;
--text:#0f172a;--text-2:#334155;--muted:#64748b;--subtle:#94a3b8;--blue:#3b82f6;
--shadow-sm:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
--shadow:0 4px 12px rgba(0,0,0,.07),0 1px 3px rgba(0,0,0,.04);
--r:10px;--r-lg:14px;
}
[data-theme="dark"] {
--bg:#0d1117;--surface:#161b27;--surface-2:#1c2333;--border:#2d3748;--border-s:#1e2a3a;
--text:#e2e8f0;--text-2:#a0aec0;--muted:#718096;--subtle:#4a5568;--blue:#63b3ed;
--shadow-sm:0 1px 3px rgba(0,0,0,.4),0 1px 2px rgba(0,0,0,.3);
--shadow:0 4px 12px rgba(0,0,0,.5),0 1px 3px rgba(0,0,0,.3);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',-apple-system,sans-serif;font-size:14px;line-height:1.5;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;min-height:100vh}
a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
.page{max-width:1200px;margin:0 auto;padding:16px}
@media(min-width:768px){.page{padding:24px 32px}}
/* header */
.header{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap}
.header-title{flex:1;min-width:160px}
.header-title h1{font-size:20px;font-weight:800;letter-spacing:-.5px}
.header-title .sub{font-size:12px;color:var(--muted);margin-top:1px}
.header-actions{display:flex;align-items:center;gap:8px}
.clock-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:8px 14px;text-align:right;box-shadow:var(--shadow-sm)}
.clock-time{font-size:18px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.5px}
.clock-date{font-size:11px;color:var(--muted)}
.icon-btn{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);width:36px;height:36px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--muted);transition:background .15s,color .15s;flex-shrink:0}
.icon-btn:hover{background:var(--surface-2);color:var(--text)}
.back-link{display:flex;align-items:center;gap:5px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:0 12px;height:36px;font-size:12px;font-weight:600;color:var(--muted);text-decoration:none;transition:background .15s,color .15s;white-space:nowrap}
.back-link:hover{background:var(--surface-2);color:var(--text);text-decoration:none}
/* class tabs */
.class-tabs{display:flex;gap:4px;margin-bottom:16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);padding:4px;box-shadow:var(--shadow-sm);flex-wrap:wrap}
.ct-btn{flex:1;min-width:60px;padding:7px 10px;border:none;border-radius:var(--r);background:transparent;color:var(--muted);font-size:13px;font-weight:600;cursor:pointer;transition:background .12s,color .12s;font-family:inherit;white-space:nowrap}
.ct-btn:hover{background:var(--surface-2);color:var(--text)}
.ct-btn.active{background:var(--blue);color:#fff;box-shadow:0 2px 6px rgba(59,130,246,.3)}
[data-theme="dark"] .ct-btn.active{box-shadow:0 2px 6px rgba(99,179,237,.2)}
/* lesson banner */
.lesson-banner{border-radius:var(--r-lg);padding:14px 18px;color:#fff;display:flex;align-items:center;gap:14px;margin-bottom:16px;overflow:hidden}
.lesson-banner.active{background:linear-gradient(135deg,#3b82f6,#6366f1);box-shadow:0 4px 20px rgba(99,102,241,.3)}
.lesson-banner.free{background:linear-gradient(135deg,#64748b,#475569);box-shadow:0 4px 16px rgba(71,85,105,.2)}
.lesson-banner.break-time{background:linear-gradient(135deg,#0891b2,#0284c7);box-shadow:0 4px 16px rgba(8,145,178,.2)}
.lesson-banner.done{background:linear-gradient(135deg,#16a34a,#15803d);box-shadow:0 4px 16px rgba(22,163,74,.2)}
.lesson-banner.hidden{display:none}
.lb-dot{width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.5);flex-shrink:0;animation:lpulse 1.8s ease-in-out infinite}
@keyframes lpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.75)}}
.lb-info{flex:1;min-width:0}
.lb-eyebrow{font-size:10px;letter-spacing:.08em;text-transform:uppercase;opacity:.75;margin-bottom:2px}
.lb-subject{font-size:17px;font-weight:800}
.lb-detail{font-size:11px;opacity:.7;margin-top:1px}
.lb-prog-track{height:3px;background:rgba(255,255,255,.2);border-radius:2px;margin-top:8px;overflow:hidden}
.lb-prog-bar{height:100%;background:rgba(255,255,255,.75);border-radius:2px;transition:width .5s ease}
.lb-right{text-align:right;flex-shrink:0}
.lb-count{font-size:22px;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:-.5px}
.lb-count-lbl{font-size:10px;opacity:.65;text-transform:uppercase;letter-spacing:.06em;margin-top:1px}
/* layout */
.layout{display:flex;flex-direction:column;gap:16px}
@media(min-width:960px){.layout{flex-direction:row;align-items:flex-start}.main-col{flex:1;min-width:0}.side-col{width:300px;flex-shrink:0;display:flex;flex-direction:column;gap:16px}}
.side-mob{display:flex;flex-direction:column;gap:16px;margin-top:16px}
@media(min-width:960px){.side-mob{display:none}}
.side-col{display:none}
@media(min-width:960px){.side-col{display:flex}}
/* cards */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);box-shadow:var(--shadow-sm);overflow:hidden}
.card-head{padding:13px 18px 11px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
.card-head h2{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
.card-head .chi{color:var(--subtle)}
/* timetable */
.tt-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;padding:10px 16px 14px}
.tt-grid{display:grid;grid-template-columns:50px repeat(5,1fr);gap:3px;min-width:460px}
.tt-hdr{padding:6px 4px;text-align:center;font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.05em}
.tt-hdr.td{color:var(--blue)}
.tt-hdr-dot{width:5px;height:5px;border-radius:50%;background:var(--blue);margin:3px auto 0}
.tt-time{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:3px 2px;font-size:10px;font-variant-numeric:tabular-nums;color:var(--subtle);text-align:center;line-height:1.3}
.tt-pn{font-size:9px;font-weight:700;color:var(--subtle);letter-spacing:.04em;margin-bottom:1px}
.tt-brk{grid-column:1/-1;display:flex;align-items:center;height:9px}
.tt-brk-line{flex:1;height:1px;background:var(--border-s)}
.tt-cell{border-radius:var(--r);padding:6px 8px;min-height:48px;display:flex;flex-direction:column;justify-content:center;position:relative;transition:transform .12s,box-shadow .12s;cursor:default}
.tt-cell.emp{background:transparent}
.tt-cell:not(.emp){background:var(--sc-bg);border:1px solid var(--sc-brd);cursor:pointer}
.tt-cell:not(.emp):hover{transform:translateY(-1px);box-shadow:var(--shadow);z-index:1}
.tt-cell.td:not(.emp){filter:brightness(1.05)}
[data-theme="dark"] .tt-cell.td:not(.emp){filter:brightness(1.12)}
.tt-cell.now{box-shadow:0 0 0 2px var(--sc)!important;animation:aglow 2s ease-in-out infinite alternate}
@keyframes aglow{from{box-shadow:0 0 0 2px var(--sc)}to{box-shadow:0 0 0 2px var(--sc),0 0 10px rgba(99,102,241,.25)}}
.tt-cell .cs{font-size:12px;font-weight:700;color:var(--sc-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tt-cell .ct{font-size:10px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tt-cell .cr{position:absolute;top:4px;right:5px;font-size:9px;font-weight:600;color:var(--subtle);background:var(--surface);border-radius:4px;padding:1px 4px;border:1px solid var(--border)}
.gf{border-radius:var(--r) var(--r) 3px 3px}
.gm{border-radius:3px;border-top:1px solid transparent!important;margin-top:-1px}
.gl{border-radius:3px 3px var(--r) var(--r);border-top:1px solid transparent!important;margin-top:-1px}
/* subject colors */
.sc-java {--sc:#f59e0b;--sc-bg:rgba(245,158,11,.10);--sc-brd:rgba(245,158,11,.22);--sc-text:#92400e}
.sc-linux {--sc:#10b981;--sc-bg:rgba(16,185,129,.10);--sc-brd:rgba(16,185,129,.22);--sc-text:#065f46}
.sc-acc {--sc:#8b5cf6;--sc-bg:rgba(139,92,246,.10);--sc-brd:rgba(139,92,246,.22);--sc-text:#4c1d95}
.sc-bwl {--sc:#3b82f6;--sc-bg:rgba(59,130,246,.10);--sc-brd:rgba(59,130,246,.22);--sc-text:#1e3a8a}
.sc-rewe {--sc:#ef4444;--sc-bg:rgba(239,68,68,.10);--sc-brd:rgba(239,68,68,.22);--sc-text:#7f1d1d}
.sc-orga {--sc:#14b8a6;--sc-bg:rgba(20,184,166,.10);--sc-brd:rgba(20,184,166,.22);--sc-text:#134e4a}
.sc-e {--sc:#f97316;--sc-bg:rgba(249,115,22,.10);--sc-brd:rgba(249,115,22,.22);--sc-text:#7c2d12}
.sc-ma {--sc:#ec4899;--sc-bg:rgba(236,72,153,.10);--sc-brd:rgba(236,72,153,.22);--sc-text:#831843}
.sc-dv {--sc:#6366f1;--sc-bg:rgba(99,102,241,.10);--sc-brd:rgba(99,102,241,.22);--sc-text:#312e81}
.sc-web {--sc:#06b6d4;--sc-bg:rgba(6,182,212,.10);--sc-brd:rgba(6,182,212,.22);--sc-text:#164e63}
.sc-pug {--sc:#84cc16;--sc-bg:rgba(132,204,22,.10);--sc-brd:rgba(132,204,22,.22);--sc-text:#365314}
.sc-py {--sc:#22c55e;--sc-bg:rgba(34,197,94,.10);--sc-brd:rgba(34,197,94,.22);--sc-text:#14532d}
.sc-exc {--sc:#65a30d;--sc-bg:rgba(101,163,13,.10);--sc-brd:rgba(101,163,13,.22);--sc-text:#365314}
.sc-ki {--sc:#a855f7;--sc-bg:rgba(168,85,247,.10);--sc-brd:rgba(168,85,247,.22);--sc-text:#581c87}
.sc-wima {--sc:#f43f5e;--sc-bg:rgba(244,63,94,.10);--sc-brd:rgba(244,63,94,.22);--sc-text:#881337}
.sc-inv {--sc:#0ea5e9;--sc-bg:rgba(14,165,233,.10);--sc-brd:rgba(14,165,233,.22);--sc-text:#0c4a6e}
.sc-voc {--sc:#fb923c;--sc-bg:rgba(251,146,60,.10);--sc-brd:rgba(251,146,60,.22);--sc-text:#7c2d12}
.sc-sql {--sc:#0891b2;--sc-bg:rgba(8,145,178,.10);--sc-brd:rgba(8,145,178,.22);--sc-text:#164e63}
.sc-geo {--sc:#78716c;--sc-bg:rgba(120,113,108,.10);--sc-brd:rgba(120,113,108,.22);--sc-text:#44403c}
.sc-phy {--sc:#7c3aed;--sc-bg:rgba(124,58,237,.10);--sc-brd:rgba(124,58,237,.22);--sc-text:#4c1d95}
.sc-sowe {--sc:#84cc16;--sc-bg:rgba(132,204,22,.10);--sc-brd:rgba(132,204,22,.22);--sc-text:#365314}
.sc-zzz {--sc:#94a3b8;--sc-bg:rgba(148,163,184,.08);--sc-brd:rgba(148,163,184,.18);--sc-text:#475569}
[data-theme="dark"] .sc-java {--sc-bg:rgba(245,158,11,.14);--sc-brd:rgba(245,158,11,.25);--sc-text:#fbbf24}
[data-theme="dark"] .sc-linux {--sc-bg:rgba(16,185,129,.14);--sc-brd:rgba(16,185,129,.25);--sc-text:#34d399}
[data-theme="dark"] .sc-acc {--sc-bg:rgba(139,92,246,.14);--sc-brd:rgba(139,92,246,.25);--sc-text:#c4b5fd}
[data-theme="dark"] .sc-bwl {--sc-bg:rgba(59,130,246,.14);--sc-brd:rgba(59,130,246,.25);--sc-text:#93c5fd}
[data-theme="dark"] .sc-rewe {--sc-bg:rgba(239,68,68,.14);--sc-brd:rgba(239,68,68,.25);--sc-text:#fca5a5}
[data-theme="dark"] .sc-orga {--sc-bg:rgba(20,184,166,.14);--sc-brd:rgba(20,184,166,.25);--sc-text:#5eead4}
[data-theme="dark"] .sc-e {--sc-bg:rgba(249,115,22,.14);--sc-brd:rgba(249,115,22,.25);--sc-text:#fdba74}
[data-theme="dark"] .sc-ma {--sc-bg:rgba(236,72,153,.14);--sc-brd:rgba(236,72,153,.25);--sc-text:#f9a8d4}
[data-theme="dark"] .sc-dv {--sc-bg:rgba(99,102,241,.14);--sc-brd:rgba(99,102,241,.25);--sc-text:#a5b4fc}
[data-theme="dark"] .sc-web {--sc-bg:rgba(6,182,212,.14);--sc-brd:rgba(6,182,212,.25);--sc-text:#67e8f9}
[data-theme="dark"] .sc-pug {--sc-bg:rgba(132,204,22,.14);--sc-brd:rgba(132,204,22,.25);--sc-text:#bef264}
[data-theme="dark"] .sc-py {--sc-bg:rgba(34,197,94,.14);--sc-brd:rgba(34,197,94,.25);--sc-text:#86efac}
[data-theme="dark"] .sc-exc {--sc-bg:rgba(101,163,13,.14);--sc-brd:rgba(101,163,13,.25);--sc-text:#bef264}
[data-theme="dark"] .sc-ki {--sc-bg:rgba(168,85,247,.14);--sc-brd:rgba(168,85,247,.25);--sc-text:#d8b4fe}
[data-theme="dark"] .sc-wima {--sc-bg:rgba(244,63,94,.14);--sc-brd:rgba(244,63,94,.25);--sc-text:#fda4af}
[data-theme="dark"] .sc-inv {--sc-bg:rgba(14,165,233,.14);--sc-brd:rgba(14,165,233,.25);--sc-text:#7dd3fc}
[data-theme="dark"] .sc-voc {--sc-bg:rgba(251,146,60,.14);--sc-brd:rgba(251,146,60,.25);--sc-text:#fdba74}
[data-theme="dark"] .sc-sql {--sc-bg:rgba(8,145,178,.14);--sc-brd:rgba(8,145,178,.25);--sc-text:#67e8f9}
[data-theme="dark"] .sc-geo {--sc-bg:rgba(120,113,108,.14);--sc-brd:rgba(120,113,108,.25);--sc-text:#d6d3d1}
[data-theme="dark"] .sc-phy {--sc-bg:rgba(124,58,237,.14);--sc-brd:rgba(124,58,237,.25);--sc-text:#c4b5fd}
[data-theme="dark"] .sc-sowe {--sc-bg:rgba(132,204,22,.14);--sc-brd:rgba(132,204,22,.25);--sc-text:#bef264}
[data-theme="dark"] .sc-zzz {--sc-bg:rgba(148,163,184,.11);--sc-brd:rgba(148,163,184,.2);--sc-text:#94a3b8}
/* legend */
.legend{display:flex;flex-wrap:wrap;gap:5px 9px;padding:0 16px 12px}
.lg-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted)}
.lg-dot{width:7px;height:7px;border-radius:50%;background:var(--sc);flex-shrink:0}
/* next exam */
.nex-body{padding:14px 18px}
.nex-ey{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--subtle);margin-bottom:5px}
.nex-subj{font-size:18px;font-weight:800;letter-spacing:-.3px}
.nex-date{font-size:12px;color:var(--muted);margin-top:2px}
.nex-badge{display:inline-flex;align-items:center;border-radius:999px;padding:4px 11px;font-size:12px;font-weight:700;margin-top:9px}
.nb-today{background:#fee2e2;color:#dc2626}.nb-soon{background:#fef3c7;color:#d97706}
.nb-norm{background:#eff6ff;color:#2563eb}.nb-ap{background:#f0fdf4;color:#16a34a}
[data-theme="dark"] .nb-today{background:#450a0a;color:#f87171}
[data-theme="dark"] .nb-soon{background:#451a03;color:#fbbf24}
[data-theme="dark"] .nb-norm{background:#172554;color:#93c5fd}
[data-theme="dark"] .nb-ap{background:#052e16;color:#4ade80}
/* today list */
.tod-empty{padding:14px 18px;font-size:13px;color:var(--muted);text-align:center}
.tod-item{display:flex;align-items:center;gap:10px;padding:8px 18px;border-top:1px solid var(--border-s)}
.tod-item:first-child{border-top:none}
.tod-item.now{background:var(--border-s)}
.ti-dot{width:8px;height:8px;border-radius:50%;background:var(--sc);flex-shrink:0}
.ti-time{font-size:11px;font-variant-numeric:tabular-nums;color:var(--muted);width:76px;flex-shrink:0}
.ti-subj{font-size:13px;font-weight:600;color:var(--sc-text);flex:1}
.ti-teach{font-size:11px;color:var(--muted)}
.ti-now-tag{font-size:10px;font-weight:700;background:var(--blue);color:#fff;border-radius:999px;padding:1px 7px;flex-shrink:0}
/* exam list */
.ex-mhdr{padding:10px 18px 3px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--subtle);background:var(--surface-2);border-top:1px solid var(--border)}
.ex-mhdr:first-child{border-top:none}
.ex-row{display:flex;align-items:center;gap:10px;padding:8px 18px;border-top:1px solid var(--border-s);transition:background .1s}
.ex-row:hover{background:var(--surface-2)}
.ex-row.past{opacity:.35}
.ex-dot{width:8px;height:8px;border-radius:50%;background:var(--sc);flex-shrink:0}
.ex-date{font-size:11px;font-variant-numeric:tabular-nums;color:var(--muted);width:64px;flex-shrink:0}
.ex-subj{flex:1;font-weight:600;font-size:13px;color:var(--text);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ex-badge{font-size:10px;font-weight:700;border-radius:999px;padding:2px 8px;flex-shrink:0}
.eb-today{background:#fee2e2;color:#dc2626}
.eb-soon{background:#fef3c7;color:#d97706}
.eb-fut{background:#f1f5f9;color:#64748b}
.eb-ap{background:#eff6ff;color:#2563eb}
.eb-zg{background:#f0fdf4;color:#16a34a}
[data-theme="dark"] .eb-today{background:#450a0a;color:#f87171}
[data-theme="dark"] .eb-soon{background:#451a03;color:#fbbf24}
[data-theme="dark"] .eb-fut{background:#1e293b;color:#64748b}
[data-theme="dark"] .eb-ap{background:#172554;color:#93c5fd}
[data-theme="dark"] .eb-zg{background:#052e16;color:#4ade80}
.ex-empty{padding:20px 18px;text-align:center;font-size:13px;color:var(--muted)}
/* feat: subject highlight */
.tt-cell.subj-hi{outline:2px solid var(--sc);outline-offset:-2px;z-index:2;filter:brightness(1.12)}
.tt-cell.subj-dim{opacity:.28}
/* feat: cell tooltip */
#cellTip{position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:10px 14px;box-shadow:var(--shadow);font-size:12px;z-index:1000;min-width:150px;max-width:230px;display:none}
#cellTip.vis{display:block}
.ctp-name{font-size:14px;font-weight:700;margin-bottom:6px}
.ctp-row{color:var(--muted);margin-top:3px;font-size:11px}
/* feat: gap indicator in today list */
.tod-gap{display:flex;align-items:center;gap:10px;padding:6px 18px;border-top:1px solid var(--border-s);color:var(--muted);font-size:12px}
.tod-gap-dot{width:8px;height:8px;border-radius:50%;background:var(--subtle);flex-shrink:0}
/* feat: ferien divider in exam list */
.ex-ferien{padding:6px 18px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#16a34a;background:#f0fdf4;border-top:1px solid #bbf7d0}
[data-theme="dark"] .ex-ferien{color:#4ade80;background:#052e16;border-color:#166534}
/* quicklinks */
.ql-item{display:flex;align-items:center;gap:11px;padding:10px 16px;border-top:1px solid var(--border-s);text-decoration:none;transition:background .12s;color:inherit}
.ql-item:first-child{border-top:none}
.ql-item:hover{background:var(--surface-2);text-decoration:none}
.ql-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:var(--sc-bg);border:1px solid var(--sc-brd);color:var(--sc-text)}
.ql-text{flex:1;min-width:0}
.ql-name{font-size:13px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ql-url{font-size:10px;color:var(--muted);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ql-arrow{color:var(--subtle);flex-shrink:0}
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="header-title">
<h1>Stundenplan</h1>
<div class="sub" id="headerSub">ab 09.&thinsp;März 2026</div>
</div>
<div class="header-actions">
<a href="/app" class="back-link"><i data-lucide="layout-dashboard" style="width:13px;height:13px"></i> App</a>
<button class="icon-btn" id="themeBtn" title="Theme"><i data-lucide="moon" style="width:15px;height:15px" id="themeIcon"></i></button>
<div class="clock-box">
<div class="clock-time" id="clockTime">--:--:--</div>
<div class="clock-date" id="clockDate">---</div>
</div>
</div>
</div>
<!-- class selector -->
<div class="class-tabs" id="classTabs">
<button class="ct-btn active" data-cls="info1">Info 1</button>
<button class="ct-btn" data-cls="info2">Info 2</button>
<button class="ct-btn" data-cls="freko1">Freko 1</button>
<button class="ct-btn" data-cls="freko2">Freko 2</button>
<button class="ct-btn" data-cls="mr">MR</button>
</div>
<!-- lesson banner -->
<div class="lesson-banner hidden" id="banner">
<div class="lb-dot"></div>
<div class="lb-info">
<div class="lb-eyebrow" id="lbEy">Aktuelle Stunde</div>
<div class="lb-subject" id="lbSubj"></div>
<div class="lb-detail" id="lbDet"></div>
<div class="lb-prog-track"><div class="lb-prog-bar" id="lbBar" style="width:0%"></div></div>
</div>
<div class="lb-right">
<div class="lb-count" id="lbCount"></div>
<div class="lb-count-lbl" id="lbCountLbl">verbleibend</div>
</div>
</div>
<div class="layout">
<div class="main-col">
<div class="card">
<div class="card-head">
<i data-lucide="calendar-days" style="width:14px;height:14px" class="chi"></i>
<h2 id="ttTitle">Wochenplan</h2>
<button class="icon-btn" id="icalBtn" title="iCal exportieren" style="margin-left:auto;width:30px;height:30px">
<i data-lucide="download" style="width:13px;height:13px"></i>
</button>
</div>
<div class="tt-scroll"><div class="tt-grid" id="ttGrid"></div></div>
<div class="legend" id="legend"></div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-head">
<i data-lucide="book-open" style="width:14px;height:14px" class="chi"></i>
<h2>Klausurenplan</h2>
</div>
<div id="examList"></div>
</div>
</div>
<div class="side-col">
<div class="card"><div class="card-head"><i data-lucide="alarm-clock" style="width:14px;height:14px" class="chi"></i><h2>Nächste Klausur</h2></div><div id="nexBody"></div></div>
<div class="card"><div class="card-head"><i data-lucide="sun" style="width:14px;height:14px" class="chi"></i><h2 id="todayTitle">Heute</h2></div><div id="todayList"></div></div>
<div class="card"><div class="card-head"><i data-lucide="link" style="width:14px;height:14px" class="chi"></i><h2>Links</h2></div><div id="quicklinks"></div></div>
</div>
</div>
<div class="side-mob">
<div class="card"><div class="card-head"><i data-lucide="alarm-clock" style="width:14px;height:14px" class="chi"></i><h2>Nächste Klausur</h2></div><div id="nexBodyMob"></div></div>
<div class="card"><div class="card-head"><i data-lucide="sun" style="width:14px;height:14px" class="chi"></i><h2 id="todayTitleMob">Heute</h2></div><div id="todayListMob"></div></div>
<div class="card"><div class="card-head"><i data-lucide="link" style="width:14px;height:14px" class="chi"></i><h2>Links</h2></div><div id="quicklinksMob"></div></div>
</div>
</div>
<div id="cellTip" class="cell-tip"></div>
<script>
// ─── DATA ────────────────────────────────────────────────────────────────────
const CLASSES = [
{id:'info1', name:'Info 1', room:'Raum 5'},
{id:'info2', name:'Info 2', room:'Raum 1'},
{id:'freko1', name:'Freko 1', room:'Raum 4'},
{id:'freko2', name:'Freko 2', room:'Raum 2'},
{id:'mr', name:'MR', room:'Raum 3'},
];
const PERIODS = [
{n:1, s:'08:00', e:'08:45', sm:480, em:525 },
{n:2, s:'08:45', e:'09:30', sm:525, em:570 },
{n:3, s:'09:45', e:'10:30', sm:585, em:630 },
{n:4, s:'10:30', e:'11:15', sm:630, em:675 },
{n:5, s:'11:30', e:'12:15', sm:690, em:735 },
{n:6, s:'12:15', e:'13:00', sm:735, em:780 },
{n:7, s:'13:30', e:'14:15', sm:810, em:855 },
{n:8, s:'14:15', e:'15:00', sm:855, em:900 },
{n:9, s:'15:15', e:'16:00', sm:915, em:960 },
];
const BREAKS = [[2,570,585],[4,675,690],[6,780,810],[8,900,915]];
const BRK = new Set(BREAKS.map(b=>b[0]));
// sched[classId][dayIdx 0-4][periodIdx 0-8] = {s,t,r?}|null
const SCHED = {
info1:[
[null,{s:'Linux',t:'Ober'},{s:'Linux',t:'Ober'},{s:'Linux',t:'Ober'},{s:'Acc',t:'Breth'},{s:'Acc',t:'Breth'},{s:'PuG',t:'Blad'},{s:'PuG',t:'Blad'},null],
[{s:'JAVA',t:'Zeit'},{s:'JAVA',t:'Zeit'},{s:'JAVA',t:'Zeit'},null,{s:'E',t:'Bon',r:'R4'},{s:'E',t:'Bon',r:'R4'},null,null,null],
[{s:'Exc',t:'Berndt'},{s:'Exc',t:'Berndt'},{s:'BWL',t:'Breth'},{s:'BWL',t:'Breth'},{s:'Rewe',t:'Breth'},{s:'Orga',t:'Breth'},{s:'Orga',t:'Breth'},{s:'Orga',t:'Breth'},null],
[{s:'KI',t:'Wink'},{s:'WEB',t:'Wink'},{s:'WEB',t:'Wink'},{s:'DVHard',t:'Ber'},{s:'DVHard',t:'Ber'},{s:'DVHard',t:'Ber'},null,{s:'Python',t:'Wink'},{s:'Python',t:'Wink'}],
[{s:'Wima',t:'Bon'},{s:'Wima',t:'Bon'},{s:'Orga',t:'Breth'},{s:'Rewe',t:'Breth',r:'R4'},{s:'Rewe',t:'Breth',r:'R4'},null,null,null,null],
],
info2:[
[{s:'DVNet',t:'Heft'},{s:'DVNet',t:'Heft'},{s:'DVNet',t:'Heft'},{s:'DVNet',t:'Heft'},null,{s:'DVServ',t:'Hef'},{s:'DVServ',t:'Hef'},{s:'DVServ',t:'Hef'},null],
[{s:'Orga',t:'Breth'},{s:'Orga',t:'Breth'},{s:'Orga',t:'Breth'},{s:'OrgaP',t:'Breth'},{s:'OrgaP',t:'Breth'},null,null,null,null],
[null,null,{s:'Linux',t:'Oberm'},{s:'Linux',t:'Oberm'},{s:'Linux',t:'Oberm'},{s:'Orga',t:'Breth'},{s:'Orga',t:'Breth'},{s:'Orga',t:'Breth'},null],
[{s:'DVServ',t:'Hef'},{s:'DVServ',t:'Hef'},{s:'DVServ',t:'Hef'},{s:'WEB',t:'Wink'},{s:'WEB',t:'Wink'},{s:'Python',t:'Wi'},{s:'Python',t:'Wi'},null,null],
[{s:'KI',t:'Wink'},{s:'KI',t:'Wink'},{s:'KIProj',t:'Wink'},{s:'SQL',t:'Ober'},{s:'SQL',t:'Ober'},null,null,null,null],
],
freko1:[
[{s:'SGram',t:'Cas'},{s:'SSpue',t:'Cas'},{s:'Hako',t:'Schi'},{s:'Ft',t:'Schi'},{s:'Spue',t:'Schi'},{s:'Spue',t:'Schi'},{s:'PuG',t:'Blad'},{s:'PuG',t:'Blad'},null],
[{s:'Gram',t:'Bo'},{s:'Dic',t:'Bon'},{s:'Dol',t:'Hay'},null,null,null,null,null,null],
[{s:'Hea',t:'Schi'},{s:'Hea',t:'Schi'},{s:'SUeber',t:'Cas'},{s:'SUeber',t:'Cas'},{s:'SSpue',t:'Cas'},null,null,null,null],
[{s:'Hia',t:'Bon'},{s:'Hia',t:'Bon'},{s:'Gram',t:'Schi'},{s:'Ft',t:'Schi'},null,null,null,null,null],
[{s:'BWL',t:'Breth'},{s:'D',t:'Breth'},{s:'SGram',t:'Cas'},{s:'Ppt',t:'Berndt',r:'R5'},{s:'Ppt',t:'Berndt',r:'R5'},null,null,null,null],
],
freko2:[
[{s:'Hia',t:'Schi'},null,{s:'Laku',t:'Hay'},{s:'Laku',t:'Hay'},{s:'Hifa',t:'Hay'},null,{s:'Spue',t:'Schi'},null,null],
[{s:'Hako',t:'Hay'},{s:'Hako',t:'Hay'},{s:'Dol',t:'Hay'},{s:'Dol',t:'Hay'},{s:'SGram',t:'Scho'},{s:'SUe',t:'Scho'},null,null,null],
[{s:'BWL',t:'Breth'},{s:'D',t:'Breth'},{s:'Ft',t:'Schi'},{s:'Ft',t:'Schi'},{s:'Hefa',t:'Schi'},null,null,null,null],
[{s:'Her',t:'Schi'},{s:'Hako',t:'Schi'},{s:'Gram',t:'Bon'},{s:'SUe',t:'Scho'},{s:'SHako',t:'Scho'},{s:'SHako',t:'Scho'},{s:'SGram',t:'Scho'},null,null],
[{s:'Hea',t:'Bon'},{s:'HeaU',t:'Bon'},null,{s:'SpueS',t:'Cas'},{s:'SpueS',t:'Cas'},null,null,null,null],
],
mr:[
[{s:'Ge',t:''},{s:'Ge',t:''},{s:'D',t:'Breth'},{s:'D',t:'Breth'},{s:'Sowe',t:'Reut'},{s:'Sowe',t:'Reut'},{s:'Sowe',t:'Reut'},{s:'Sowe',t:'Reut'},null],
[{s:'E',t:'Car'},{s:'E',t:'Car'},{s:'Ma',t:'Bon'},{s:'Ma',t:'Bon'},{s:'Phy',t:'Ober'},{s:'Phy',t:'Ober'},{s:'DFoe',t:'Breth'},{s:'DFoe',t:'Breth'},null],
[{s:'E',t:'Car'},{s:'E',t:'Car'},{s:'E',t:'Car'},{s:'E',t:'Car'},null,null,null,null,null],
[{s:'Ma',t:'Bon'},{s:'Ma',t:'Bon'},{s:'Ma',t:'Bon'},{s:'Ma',t:'Bon'},{s:'DGrund',t:'Bon'},{s:'DGrund',t:'Bon'},null,null,null],
[{s:'PuG',t:'Bla'},{s:'PuG',t:'Bla'},{s:'PuG',t:'Bla'},{s:'PuG',t:'Bla'},{s:'MaFoe',t:''},{s:'MaFoe',t:''},null,null,null],
],
};
// {label, cls, display}
const SM = {
JAVA: {l:'Java', c:'sc-java', d:'Java'},
Linux: {l:'Linux', c:'sc-linux',d:'Linux'},
Acc: {l:'Access', c:'sc-acc', d:'Access'},
BWL: {l:'BWL', c:'sc-bwl', d:'BWL'},
Rewe: {l:'Rechnungswesen',c:'sc-rewe', d:'Rewe'},
Orga: {l:'Organisation', c:'sc-orga', d:'Orga'},
E: {l:'Englisch', c:'sc-e', d:'Englisch'},
Ma: {l:'Mathematik', c:'sc-ma', d:'Mathe'},
DVHard: {l:'DV Hardware', c:'sc-dv', d:'DV Hard'},
DVNet: {l:'DV Netzwerke', c:'sc-dv', d:'DV Netz'},
DVServ: {l:'DV Server', c:'sc-dv', d:'DV Serv'},
WEB: {l:'Web-Entwicklung',c:'sc-web', d:'WEB'},
PuG: {l:'Politik & Ges.',c:'sc-pug', d:'PuG'},
Python: {l:'Python', c:'sc-py', d:'Python'},
Exc: {l:'Excel', c:'sc-exc', d:'Excel'},
KI: {l:'Künstl. Intell.',c:'sc-ki', d:'KI'},
KIProj: {l:'KI-Projekt', c:'sc-ki', d:'KI-Proj'},
Wima: {l:'Wirtschaftsmath.',c:'sc-wima',d:'WiMa'},
InV: {l:'Investition', c:'sc-inv', d:'InV'},
Voc: {l:'Vokabeln (E)', c:'sc-voc', d:'Voc'},
SQL: {l:'SQL', c:'sc-sql', d:'SQL'},
OrgaP: {l:'Orga-Projekt', c:'sc-orga', d:'Orga Proj'},
SGram: {l:'S. Grammatik', c:'sc-e', d:'S. Gram'},
SSpue: {l:'Schreibübung', c:'sc-voc', d:'S. Spü'},
Hako: {l:'Handelskorr.', c:'sc-orga', d:'Hako'},
Ft: {l:'Fachtext', c:'sc-zzz', d:'Fachtext'},
Spue: {l:'Schreibübung', c:'sc-voc', d:'Spü'},
Gram: {l:'Grammatik', c:'sc-e', d:'Gramm.'},
Dic: {l:'Diktat', c:'sc-voc', d:'Diktat'},
Dol: {l:'Dolmetschen', c:'sc-e', d:'Dolm.'},
SUeber: {l:'S. Übersetzung',c:'sc-e', d:'S. Über'},
Hea: {l:'Hör-/Leseverst.',c:'sc-e', d:'Hea'},
HeaU: {l:'Hör-/Üb.', c:'sc-e', d:'Hea/Üb'},
Hia: {l:'Hör-/Einstieg', c:'sc-e', d:'Hia'},
Her: {l:'Hör-/Einstieg', c:'sc-e', d:'Her'},
Ppt: {l:'Präsentation', c:'sc-exc', d:'Präs.'},
D: {l:'Deutsch', c:'sc-e', d:'Deutsch'},
SUe: {l:'Schreibübung', c:'sc-voc', d:'S. Üb'},
SGram2: {l:'S. Grammatik', c:'sc-e', d:'S. Gram'},
SHako: {l:'S. Handelskorr.',c:'sc-orga',d:'S. Hako'},
SpueS: {l:'Schreib-Übung', c:'sc-voc', d:'Spü S'},
Laku: {l:'Lagerbuchhalt.',c:'sc-bwl', d:'LaBu'},
Hifa: {l:'Hör-Fachtext', c:'sc-zzz', d:'Hifa'},
Hefa: {l:'Hör-Fachtext', c:'sc-zzz', d:'Hefa'},
Ge: {l:'Geschichte', c:'sc-geo', d:'Geschichte'},
Sowe: {l:'Sozialwirtsch.',c:'sc-sowe', d:'Sowe'},
Phy: {l:'Physik', c:'sc-phy', d:'Physik'},
DFoe: {l:'Dt. Förderung', c:'sc-e', d:'D. Fö'},
DGrund: {l:'Dt. Grundlagen',c:'sc-e', d:'D. Grund'},
MaFoe: {l:'Ma-Förderung', c:'sc-ma', d:'Ma Fö'},
};
function sm(s){return SM[s]||{l:s,c:'sc-zzz',d:s}}
// Exams per class (klausurenplan)
const EXAMS = {
info1:[
{date:'2026-01-04',s:'Rewe', l:'Rechnungswesen',t:'Klausur'},
{date:'2026-01-05',s:'DVHard',l:'DV Netzwerke', t:'Klausur'},
{date:'2026-01-06',s:'Orga', l:'Organisation', t:'Klausur'},
{date:'2026-01-13',s:'E', l:'Englisch', t:'Klausur'},
{date:'2026-01-21',s:'BWL', l:'BWL', t:'Klausur'},
{date:'2026-01-23',s:'Acc', l:'Access', t:'Klausur'},
{date:'2026-01-26',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-01-30',s:'Ma', l:'Mathematik', t:'Klausur'},
{date:'2026-03-10',s:'E', l:'Englisch', t:'Klausur'},
{date:'2026-03-13',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-03-23',s:'DVHard',l:'DV Hardware', t:'Klausur'},
{date:'2026-03-27',s:'Ma', l:'Mathematik', t:'Klausur'},
{date:'2026-04-13',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-04-15',s:'BWL', l:'BWL', t:'Klausur'},
{date:'2026-04-22',s:'Exc', l:'Excel', t:'Klausur'},
{date:'2026-04-23',s:'DVHard',l:'DV Hardware', t:'Klausur'},
{date:'2026-04-24',s:'Rewe', l:'Rechnungswesen', t:'Klausur'},
{date:'2026-04-28',s:'JAVA', l:'Java', t:'Klausur'},
{date:'2026-04-29',s:'Orga', l:'Organisation', t:'Klausur'},
{date:'2026-05-05',s:'E', l:'Englisch', t:'Klausur'},
{date:'2026-05-07',s:'WEB', l:'Webentwicklung', t:'Klausur'},
{date:'2026-05-13',s:'Acc', l:'Access', t:'Klausur'},
{date:'2026-05-18',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-05-21',s:'Python',l:'Python', t:'Klausur'},
{date:'2026-06-10',s:'Exc', l:'Excel', t:'Klausur'},
{date:'2026-06-11',s:'KI', l:'Künstl. Intell.',t:'Klausur'},
{date:'2026-06-15',s:'BWL', l:'BWL', t:'Klausur'},
{date:'2026-06-17',s:'Acc', l:'Access', t:'Klausur'},
{date:'2026-06-18',s:'DVHard',l:'DV Hardware', t:'Klausur'},
{date:'2026-06-24',s:'Rewe', l:'Rechnungswesen', t:'Klausur'},
{date:'2026-06-26',s:'Ma', l:'Mathematik', t:'Klausur'},
{date:'2026-06-29',s:'Orga', l:'Organisation', t:'Klausur'},
{date:'2026-06-30',s:'JAVA', l:'Java', t:'Klausur'},
{date:'2026-07-01',s:'Orga', l:'AP Organisation',t:'AP'},
{date:'2026-07-03',s:'WEB', l:'AB Webentwicklung',t:'AP'},
{date:'2026-07-06',s:'Python',l:'AP Python', t:'AP'},
{date:'2026-07-08',s:'SQL', l:'AP SQL', t:'AP'},
{date:'2026-07-10',s:'E', l:'Zeugnis 🎓', t:'Zeugnis'},
],
info2:[
{date:'2026-01-02',s:'DVNet', l:'DV Netzwerke', t:'Klausur'},
{date:'2026-01-03',s:'BWL', l:'BWL', t:'Klausur'},
{date:'2026-01-20',s:'Orga', l:'Organisation', t:'Klausur'},
{date:'2026-01-27',s:'DVNet', l:'3D / Rewe', t:'Klausur'},
{date:'2026-01-28',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-01-29',s:'JAVA', l:'Java', t:'Klausur'},
{date:'2026-02-02',s:'JAVA', l:'AP Java', t:'AP'},
{date:'2026-02-04',s:'BWL', l:'AB BWL', t:'AP'},
{date:'2026-02-06',s:'Rewe', l:'AP Rewe', t:'AP'},
{date:'2026-03-16',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-04-16',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-04-20',s:'DVNet', l:'DV Netzwerke', t:'Klausur'},
{date:'2026-05-06',s:'KI', l:'MS Project', t:'Klausur'},
{date:'2026-05-07',s:'WEB', l:'Webentwicklung', t:'Klausur'},
{date:'2026-05-12',s:'Linux', l:'Linux', t:'Klausur'},
{date:'2026-05-21',s:'Python',l:'Python', t:'Klausur'},
{date:'2026-05-22',s:'SQL', l:'SQL', t:'Klausur'},
{date:'2026-06-08',s:'DVNet', l:'DV Netzwerke', t:'Klausur'},
{date:'2026-06-10',s:'Orga', l:'Organisation', t:'Klausur'},
{date:'2026-06-12',s:'KI', l:'KI', t:'Klausur'},
{date:'2026-06-23',s:'KI', l:'MS Project', t:'Klausur'},
{date:'2026-07-10',s:'E', l:'Zeugnis 🎓', t:'Zeugnis'},
],
freko1:null,
freko2:null,
mr:null,
};
const FERIEN = [
{name:'Faschingsferien', s:'2026-02-16', e:'2026-02-20'},
{name:'Osterferien', s:'2026-03-30', e:'2026-04-11'},
{name:'Pfingstferien', s:'2026-05-30', e:'2026-06-13'},
{name:'Sommerferien', s:'2026-07-30', e:'2026-09-14'},
];
// ─── HELPERS ──────────────────────────────────────────────────────────────────
const p2=n=>String(n).padStart(2,'0');
function nowM(){const d=new Date();return d.getHours()*60+d.getMinutes()}
function todayDI(){const d=new Date().getDay();return d===0||d===6?-1:d-1}
const DWS=['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const DWK=['So','Mo','Di','Mi','Do','Fr','Sa'];
const MN=['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const MNS=['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
function fmtTime(d){return p2(d.getHours())+':'+p2(d.getMinutes())+':'+p2(d.getSeconds())}
function fmtDate(d){return DWS[d.getDay()]+', '+d.getDate()+'. '+MNS[d.getMonth()]+' '+d.getFullYear()}
function fmtDateShort(d){return DWK[d.getDay()]+' '+p2(d.getDate())+'.'+p2(d.getMonth()+1)+'.'}
function parseD(s){const[y,m,dy]=s.split('-').map(Number);return new Date(y,m-1,dy)}
function todayMid(){const d=new Date();d.setHours(0,0,0,0);return d}
function daysUntil(s){return Math.round((parseD(s)-todayMid())/86400000)}
function mHM(m){const h=Math.floor(m/60),mm=m%60;return h>0?h+'h '+p2(mm)+'m':mm+' min'}
// ─── STATE ────────────────────────────────────────────────────────────────────
let isDark = localStorage.getItem('sp-theme')==='dark';
let selCls = localStorage.getItem('sp-class')||'info1';
// ─── THEME ────────────────────────────────────────────────────────────────────
function applyTheme(){
document.documentElement.setAttribute('data-theme',isDark?'dark':'light');
const ic=document.getElementById('themeIcon');
if(ic)ic.setAttribute('data-lucide',isDark?'sun':'moon');
if(window.lucide)lucide.createIcons();
}
document.getElementById('themeBtn').addEventListener('click',()=>{
isDark=!isDark;localStorage.setItem('sp-theme',isDark?'dark':'light');applyTheme();
});
// ─── CLASS TABS ───────────────────────────────────────────────────────────────
document.getElementById('classTabs').addEventListener('click',e=>{
const btn=e.target.closest('[data-cls]');
if(!btn)return;
selCls=btn.dataset.cls;
localStorage.setItem('sp-class',selCls);
document.querySelectorAll('.ct-btn').forEach(b=>b.classList.toggle('active',b.dataset.cls===selCls));
activeSubj=null;hideTip();
renderAll();
});
// set initial active
document.querySelectorAll('.ct-btn').forEach(b=>b.classList.toggle('active',b.dataset.cls===selCls));
// ─── TIMETABLE ────────────────────────────────────────────────────────────────
function renderTT(){
const grid=document.getElementById('ttGrid');
const dayIdx=todayDI();
const nm=nowM();
let currP=-1;
for(let i=0;i<PERIODS.length;i++)if(nm>=PERIODS[i].sm&&nm<PERIODS[i].em&&dayIdx>=0)currP=i;
const sched=SCHED[selCls];
const cls=CLASSES.find(c=>c.id===selCls);
document.getElementById('ttTitle').textContent='Wochenplan — '+cls.name;
document.getElementById('headerSub').textContent=cls.name+' · '+cls.room+' · ab 09.März 2026';
const E=(tag,cls2,html)=>{const e=document.createElement(tag);if(cls2)e.className=cls2;if(html!=null)e.innerHTML=html;return e};
grid.innerHTML='';
grid.appendChild(E('div','tt-hdr',''));
for(let di=0;di<5;di++){
const h=E('div','tt-hdr'+(di===dayIdx?' td':''),['Mo','Di','Mi','Do','Fr'][di]);
if(di===dayIdx){const d=document.createElement('div');d.className='tt-hdr-dot';h.appendChild(d);}
grid.appendChild(h);
}
for(let pi=0;pi<PERIODS.length;pi++){
if(BRK.has(pi)){const br=E('div','tt-brk','');br.appendChild(E('div','tt-brk-line',''));grid.appendChild(br);}
const p=PERIODS[pi];
grid.appendChild(E('div','tt-time',`<span class="tt-pn">${p.n}.</span>${p.s}<br>${p.e}`));
for(let di=0;di<5;di++){
const l=sched[di][pi];
if(!l){grid.appendChild(E('div','tt-cell emp',''));continue;}
const m=sm(l.s);
let cls2=`tt-cell ${m.c}`;
if(di===dayIdx)cls2+=' td';
if(di===dayIdx&&pi===currP)cls2+=' now';
const pl=pi>0?sched[di][pi-1]:null;
const nl=pi<8?sched[di][pi+1]:null;
const cp=pl&&pl.s===l.s&&!BRK.has(pi);
const cn=nl&&nl.s===l.s&&!BRK.has(pi+1);
if(cp&&cn)cls2+=' gm';else if(cp)cls2+=' gl';else if(cn)cls2+=' gf';
const cell=E('div',cls2,'');
cell.dataset.subj=l.s;
cell._lesson=l;
cell._pi=pi;
if(!cp){cell.innerHTML=`<div class="cs">${m.d}</div><div class="ct">${l.t}</div>`;}
else{cell.innerHTML=`<div class="cs" style="opacity:.35">···</div>`;}
if(l.r)cell.appendChild(E('div','cr',l.r));
grid.appendChild(cell);
}
}
if(activeSubj)highlightSubj(activeSubj);
}
// ─── LEGEND ───────────────────────────────────────────────────────────────────
function renderLegend(){
const used=new Set();
for(const day of SCHED[selCls])for(const l of day)if(l)used.add(l.s);
const leg=document.getElementById('legend');
leg.innerHTML='';
for(const s of used){
const m=sm(s);
const it=document.createElement('div');
it.className=`lg-item ${m.c}`;
it.innerHTML=`<div class="lg-dot"></div>${m.l}`;
leg.appendChild(it);
}
}
// ─── BANNER ───────────────────────────────────────────────────────────────────
function updateBanner(){
const bn=document.getElementById('banner');
const nm=nowM(),di=todayDI();
if(di<0){bn.className='lesson-banner hidden';return;}
const sched=SCHED[selCls];
for(let i=0;i<PERIODS.length;i++){
const p=PERIODS[i];
if(nm>=p.sm&&nm<p.em){
const l=sched[di][i];
const prog=((nm-p.sm)/(p.em-p.sm)*100).toFixed(1);
const ml=p.em-nm;
if(l){
const m=sm(l.s);
bn.className='lesson-banner active';
document.getElementById('lbEy').textContent=p.n+'. Stunde · '+p.s+' '+p.e;
document.getElementById('lbSubj').textContent=m.l;
document.getElementById('lbDet').textContent=l.t+(l.r?' · '+l.r:'');
document.getElementById('lbBar').style.width=prog+'%';
document.getElementById('lbCount').textContent=mHM(ml);
document.getElementById('lbCountLbl').textContent='verbleibend';
} else {
bn.className='lesson-banner free';
document.getElementById('lbEy').textContent=p.n+'. Stunde · '+p.s+' '+p.e;
document.getElementById('lbSubj').textContent='Freistunde';
document.getElementById('lbDet').textContent='Keine Unterrichtseinheit';
document.getElementById('lbBar').style.width=prog+'%';
document.getElementById('lbCount').textContent=mHM(ml);
document.getElementById('lbCountLbl').textContent='bis Ende';
}
return;
}
}
for(const[idx,bsm,bem]of BREAKS){
if(nm>=bsm&&nm<bem){
bn.className='lesson-banner break-time';
document.getElementById('lbEy').textContent='Pause';
document.getElementById('lbSubj').textContent='Pause · '+bsm/60|0+':'+p2(bsm%60)+' '+bem/60|0+':'+p2(bem%60);
document.getElementById('lbDet').textContent='Weiter um '+PERIODS[idx].s+' Uhr';
document.getElementById('lbBar').style.width=((nm-bsm)/(bem-bsm)*100).toFixed(1)+'%';
document.getElementById('lbCount').textContent=mHM(bem-nm);
document.getElementById('lbCountLbl').textContent='bis Unterricht';
return;
}
}
if(nm<PERIODS[0].sm){
bn.className='lesson-banner break-time';
document.getElementById('lbEy').textContent='Vor Schulbeginn';
document.getElementById('lbSubj').textContent='Schulbeginn um 08:00 Uhr';
document.getElementById('lbDet').textContent=DWS[new Date().getDay()];
document.getElementById('lbBar').style.width='0%';
document.getElementById('lbCount').textContent=mHM(PERIODS[0].sm-nm);
document.getElementById('lbCountLbl').textContent='bis Schulbeginn';
return;
}
if(nm>=PERIODS[PERIODS.length-1].em){
bn.className='lesson-banner done';
document.getElementById('lbEy').textContent='Schultag beendet';
document.getElementById('lbSubj').textContent='Schulschluss — guter Abend!';
document.getElementById('lbDet').textContent='Morgen wieder ab 08:00 Uhr';
document.getElementById('lbBar').style.width='100%';
document.getElementById('lbCount').textContent='✓';
document.getElementById('lbCountLbl').textContent='Feierabend';
return;
}
bn.className='lesson-banner hidden';
}
// ─── NEXT EXAM ────────────────────────────────────────────────────────────────
function renderNex(){
const exams=EXAMS[selCls];
function build(id){
const el=document.getElementById(id);
if(!exams){el.innerHTML='<div class="tod-empty">Kein Klausurenplan für diese Klasse verfügbar</div>';return;}
const today=todayMid();
let next=null;
for(const ex of exams){const d=parseD(ex.date);if(d>=today){next={...ex,days:daysUntil(ex.date)};break;}}
if(!next){el.innerHTML='<div class="tod-empty">Keine weiteren Klausuren</div>';return;}
const m=sm(next.s);
const d=parseD(next.date);
let bc,bt;
if(next.t==='Zeugnis'){bc='nb-ap';bt='Zeugnis';}
else if(next.t==='AP'){bc='nb-ap';bt='AP';}
else if(next.days===0){bc='nb-today';bt='HEUTE';}
else if(next.days===1){bc='nb-soon';bt='Morgen';}
else if(next.days<=4){bc='nb-soon';bt='in '+next.days+' Tagen';}
else{bc='nb-norm';bt='in '+next.days+' Tagen';}
el.innerHTML=`<div class="nex-body"><div class="nex-ey">${next.t}</div><div class="nex-subj ${m.c}" style="color:var(--sc-text)">${next.l}</div><div class="nex-date">${DWS[d.getDay()]}, ${d.getDate()}. ${MN[d.getMonth()]} ${d.getFullYear()}</div><div class="nex-badge ${bc}">${bt}</div></div>`;
}
build('nexBody');build('nexBodyMob');
}
// ─── TODAY LIST ───────────────────────────────────────────────────────────────
function renderToday(){
const di=todayDI(),nm=nowM();
const sched=SCHED[selCls];
function build(lid,tid){
const le=document.getElementById(lid),te=document.getElementById(tid);
if(!le)return;
if(di<0){if(te)te.textContent='Wochenende';le.innerHTML='<div class="tod-empty">Kein Unterricht</div>';return;}
// build lesson blocks
const blocks=[];
for(let pi=0;pi<PERIODS.length;pi++){
const l=sched[di][pi];
if(!l)continue;
const pl=pi>0?sched[di][pi-1]:null;
if(pl&&pl.s===l.s&&!BRK.has(pi))continue;
let lpi=pi;
for(let j=pi+1;j<PERIODS.length;j++){if(!BRK.has(j)&&sched[di][j]&&sched[di][j].s===l.s)lpi=j;else break;}
blocks.push({l,pi,lpi});
}
// remaining lessons (not yet ended)
const rem=blocks.filter(b=>PERIODS[b.lpi].em>nm).length;
if(te)te.textContent=DWS[di+1]+(rem>0?' · noch '+rem+' Std.':'');
if(!blocks.length){le.innerHTML='<div class="tod-empty">Kein Unterricht heute</div>';return;}
let html='';
for(let i=0;i<blocks.length;i++){
const{l,pi,lpi}=blocks[i];
const ps=PERIODS[pi],pe=PERIODS[lpi];
const isNow=(nm>=ps.sm&&nm<pe.em);
const m=sm(l.s);
// gap indicator between consecutive blocks
if(i>0&&pi>blocks[i-1].lpi+1){
const gs=PERIODS[blocks[i-1].lpi+1].s,ge=PERIODS[pi-1].e;
html+=`<div class="tod-gap"><div class="tod-gap-dot"></div><div class="ti-time">${gs}${ge}</div><div style="flex:1;font-style:italic">Freistunde</div></div>`;
}
html+=`<div class="tod-item${isNow?' now':''} ${m.c}"><div class="ti-dot"></div><div class="ti-time">${ps.s}${pe.e}</div><div class="ti-subj">${m.l}</div><div class="ti-teach">${l.t}${l.r?' · '+l.r:''}</div>${isNow?'<div class="ti-now-tag">jetzt</div>':''}</div>`;
}
le.innerHTML=html;
}
build('todayList','todayTitle');build('todayListMob','todayTitleMob');
}
// ─── EXAM LIST ────────────────────────────────────────────────────────────────
function renderExams(){
const exams=EXAMS[selCls];
const el=document.getElementById('examList');
if(!exams){el.innerHTML='<div class="ex-empty">Klausurenplan für diese Klasse nicht verfügbar</div>';return;}
const today=todayMid();
let html='',curM='',feriIdx=0,prevD=null;
for(const ex of exams){
const d=parseD(ex.date);
const mo=MN[d.getMonth()]+' '+d.getFullYear();
const days=daysUntil(ex.date);
const past=d<today;
const m=sm(ex.s);
if(mo!==curM){curM=mo;html+=`<div class="ex-mhdr">${mo}</div>`;}
// ferien dividers
while(feriIdx<FERIEN.length){
const fs=parseD(FERIEN[feriIdx].s);
if(fs>d)break;
if(!prevD||fs>prevD){
const fe=parseD(FERIEN[feriIdx].e);
html+=`<div class="ex-ferien">☀ ${FERIEN[feriIdx].name} · bis ${fe.getDate()}. ${MNS[fe.getMonth()]}</div>`;
}
feriIdx++;
}
prevD=d;
let bc,bt;
if(ex.t==='Zeugnis'){bc='eb-zg';bt='Zeugnis';}
else if(ex.t==='AP'){bc='eb-ap';bt='AP';}
else if(past){bc='eb-fut';bt='vorbei';}
else if(days===0){bc='eb-today';bt='HEUTE';}
else if(days<=3){bc='eb-soon';bt=days===1?'morgen':'in '+days+' T.';}
else{bc='eb-fut';bt='in '+days+' T.';}
html+=`<div class="ex-row${past?' past':''} ${m.c}"><div class="ex-dot"></div><div class="ex-date">${fmtDateShort(d)}</div><div class="ex-subj">${ex.l}</div><div class="ex-badge ${bc}">${bt}</div></div>`;
}
el.innerHTML=html;
}
// ─── QUICKLINKS ───────────────────────────────────────────────────────────────
const QLINKS = [
{name:'Notenportal', url:'https://notenportal.ifb-rosenheim.de/notenportal/schueler/login.php', icon:'bar-chart-2', host:'notenportal.ifb-rosenheim.de', c:'sc-rewe'},
];
function renderQuicklinks(){
const html=QLINKS.map(l=>`
<a href="${l.url}" class="ql-item ${l.c}" target="_blank" rel="noopener noreferrer">
<div class="ql-icon"><i data-lucide="${l.icon}" style="width:15px;height:15px"></i></div>
<div class="ql-text"><div class="ql-name">${l.name}</div><div class="ql-url">${l.host}</div></div>
<i data-lucide="external-link" class="ql-arrow" style="width:13px;height:13px"></i>
</a>`).join('');
['quicklinks','quicklinksMob'].forEach(id=>{
const el=document.getElementById(id);
if(el)el.innerHTML=html;
});
lucide.createIcons();
}
// ─── SUBJECT HIGHLIGHT ───────────────────────────────────────────────────────
let activeSubj=null;
function highlightSubj(s){
document.querySelectorAll('.tt-cell').forEach(c=>{
if(c.classList.contains('emp'))return;
if(c.dataset.subj===s)c.classList.add('subj-hi');
else c.classList.add('subj-dim');
});
}
function clearHighlight(){
activeSubj=null;
document.querySelectorAll('.tt-cell').forEach(c=>c.classList.remove('subj-hi','subj-dim'));
}
document.getElementById('ttGrid').addEventListener('click',e=>{
const cell=e.target.closest('#ttGrid .tt-cell:not(.emp)');
if(!cell){clearHighlight();hideTip();return;}
const subj=cell.dataset.subj;
if(subj===activeSubj){clearHighlight();hideTip();return;}
clearHighlight();
activeSubj=subj;
highlightSubj(subj);
showTip(cell,cell._lesson,cell._pi);
});
document.addEventListener('click',e=>{
if(!e.target.closest('#ttGrid')&&!e.target.closest('#cellTip')){
clearHighlight();hideTip();
}
});
// ─── TOOLTIP ──────────────────────────────────────────────────────────────────
function showTip(cell,l,pi){
const tip=document.getElementById('cellTip');
const m=sm(l.s);
const p=PERIODS[pi];
const cls=CLASSES.find(c=>c.id===selCls);
const room=l.r||cls.room;
tip.innerHTML=`<div class="ctp-name ${m.c}" style="color:var(--sc-text)">${m.l}</div>
<div class="ctp-row">Lehrer · ${l.t||''}</div>
<div class="ctp-row">Raum · ${room}</div>
<div class="ctp-row">Zeit · ${p.s} ${p.e}</div>`;
const rect=cell.getBoundingClientRect();
let left=rect.right+8,top=rect.top;
if(left+230>window.innerWidth-10)left=rect.left-238;
if(left<8)left=8;
if(top+110>window.innerHeight-10)top=window.innerHeight-120;
tip.style.left=left+'px';
tip.style.top=top+'px';
tip.classList.add('vis');
}
function hideTip(){document.getElementById('cellTip').classList.remove('vis');}
// ─── ICAL EXPORT ──────────────────────────────────────────────────────────────
function exportIcal(){
const cls=CLASSES.find(c=>c.id===selCls);
const sched=SCHED[selCls];
// Monday of current week (or next if weekend)
const today=new Date();today.setHours(0,0,0,0);
const dow=today.getDay();
let monday=new Date(today);
if(dow===0)monday.setDate(today.getDate()+1);
else if(dow===6)monday.setDate(today.getDate()+2);
else monday.setDate(today.getDate()-(dow-1));
function idt(d,ts){
const[h,m]=ts.split(':').map(Number);
return`${d.getFullYear()}${p2(d.getMonth()+1)}${p2(d.getDate())}T${p2(h)}${p2(m)}00`;
}
const events=[];
for(let di=0;di<5;di++){
const dd=new Date(monday);dd.setDate(monday.getDate()+di);
for(let pi=0;pi<PERIODS.length;pi++){
const l=sched[di][pi];
if(!l)continue;
const pl=pi>0?sched[di][pi-1]:null;
if(pl&&pl.s===l.s&&!BRK.has(pi))continue;
let lpi=pi;
for(let j=pi+1;j<PERIODS.length;j++){if(!BRK.has(j)&&sched[di][j]&&sched[di][j].s===l.s)lpi=j;else break;}
const m=sm(l.s);
events.push([
'BEGIN:VEVENT',
`UID:${selCls}-d${di}-p${pi}@ifb.lol`,
`DTSTART:${idt(dd,PERIODS[pi].s)}`,
`DTEND:${idt(dd,PERIODS[lpi].e)}`,
`SUMMARY:${m.l}`,
`DESCRIPTION:${l.t||''}`,
`LOCATION:${l.r||cls.room}`,
'RRULE:FREQ=WEEKLY',
'END:VEVENT'
].join('\r\n'));
}
}
const cal=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//ifb.lol//Stundenplan//DE',
`X-WR-CALNAME:${cls.name} · Stundenplan`,'CALSCALE:GREGORIAN',
...events,'END:VCALENDAR'].join('\r\n');
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([cal],{type:'text/calendar;charset=utf-8'}));
a.download=`stundenplan-${selCls}.ics`;
a.click();
URL.revokeObjectURL(a.href);
}
// ─── CLOCK ────────────────────────────────────────────────────────────────────
function updateClock(){
const n=new Date();
document.getElementById('clockTime').textContent=fmtTime(n);
document.getElementById('clockDate').textContent=fmtDate(n);
}
// ─── INIT ─────────────────────────────────────────────────────────────────────
function renderAll(){renderTT();renderLegend();renderNex();renderToday();renderExams();renderQuicklinks();}
applyTheme();
renderAll();
updateClock();
updateBanner();
document.getElementById('icalBtn').addEventListener('click',exportIcal);
setInterval(()=>{updateClock();updateBanner();},1000);
setInterval(()=>{renderTT();renderToday();},30000);
lucide.createIcons();
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
const CACHE = 'klassenportal-v2';
const PRECACHE = [
'/',
'/login.html',
'/app.html',
'/manifest.json',
'/favicon.svg',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/apple-touch-icon.png',
];
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE)
.then(c => c.addAll(PRECACHE))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== CACHE).map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('push', e => {
let data = { title: 'Klassenportal', body: '' };
try { data = e.data ? e.data.json() : data; } catch {}
e.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
tag: data.tag || 'kp',
renotify: true,
data: { url: data.url || '/app' },
})
);
});
self.addEventListener('notificationclick', e => {
e.notification.close();
const url = e.notification.data?.url || '/app';
e.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
const found = list.find(c => c.url.includes(self.location.origin));
return found ? found.focus() : clients.openWindow(url);
})
);
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// Only handle same-origin GET requests
if (url.origin !== self.location.origin) return;
if (e.request.method !== 'GET') return;
// API calls: always network, never cache
if (url.pathname.startsWith('/api/')) return;
// Page navigations: network-first, fallback to cache
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request)
.then(r => {
const clone = r.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
return r;
})
.catch(() =>
caches.match(e.request).then(r => r || caches.match('/login.html'))
)
);
return;
}
// Static assets: cache-first, populate on miss
e.respondWith(
caches.match(e.request).then(cached => {
if (cached) return cached;
return fetch(e.request).then(r => {
if (r.ok) {
const clone = r.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return r;
});
})
);
});
+14
View File
@@ -248,6 +248,20 @@ try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); } catch {}
try { db.exec(`ALTER TABLE absences ADD COLUMN teacher_id INTEGER`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN ical_token TEXT`); } catch {}
try { db.exec(`ALTER TABLE grades ADD COLUMN created_at TEXT DEFAULT (datetime('now'))`); } catch {}
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
+80
View File
@@ -0,0 +1,80 @@
const webpush = require('web-push');
const db = require('./db');
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(
process.env.VAPID_SUBJECT || 'mailto:admin@localhost',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
}
async function sendToUser(userId, payload) {
const subs = db.prepare('SELECT id, endpoint, keys FROM push_subscriptions WHERE user_id = ?').all(userId);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: JSON.parse(sub.keys) },
typeof payload === 'string' ? payload : JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
}
}
}
}
async function sendToAll(payload) {
const subs = db.prepare('SELECT id, user_id, endpoint, keys FROM push_subscriptions').all();
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: JSON.parse(sub.keys) },
typeof payload === 'string' ? payload : JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
}
}
}
}
function startNotificationScheduler() {
if (!process.env.VAPID_PUBLIC_KEY) return;
setInterval(async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().slice(0, 10);
// Homework due tomorrow
const hwUsers = db.prepare(`
SELECT DISTINCT user_id FROM homework
WHERE due_date = ? AND done = 0
`).all(tomorrowStr);
for (const { user_id } of hwUsers) {
const items = db.prepare("SELECT title, subject FROM homework WHERE user_id = ? AND due_date = ? AND done = 0").all(user_id, tomorrowStr);
const body = items.length === 1
? `${items[0].subject ? items[0].subject + ': ' : ''}${items[0].title}`
: `${items.length} Aufgaben fällig`;
await sendToUser(user_id, { title: '📚 Hausaufgaben morgen fällig', body, tag: 'hw-due' });
}
// Countdowns expiring tomorrow
const cdUsers = db.prepare(`
SELECT DISTINCT user_id FROM countdowns WHERE target_date = ?
`).all(tomorrowStr);
for (const { user_id } of cdUsers) {
const items = db.prepare('SELECT title FROM countdowns WHERE user_id = ? AND target_date = ?').all(user_id, tomorrowStr);
for (const c of items) {
await sendToUser(user_id, { title: '⏳ Morgen: ' + c.title, body: 'Dein Countdown endet morgen', tag: 'cd-' + c.title });
}
}
}, 60 * 60 * 1000); // check every hour
}
module.exports = { sendToUser, sendToAll, startNotificationScheduler, vapidPublicKey: process.env.VAPID_PUBLIC_KEY };
+176
View File
@@ -10,6 +10,7 @@ const db = require('./db');
const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files');
const { sendVerifyMail, sendPasswordResetMail } = require('./mailer');
const { sendToUser, vapidPublicKey } = require('./push');
const STORAGE_DIR = path.resolve(__dirname, '../storage');
@@ -18,6 +19,14 @@ const RESET_TTL_MS = 60 * 60 * 1000;
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('dummy-placeholder-value', 12);
const WEAK_PASSWORDS = new Set([
'passwort','password','passwort1','password1','passwort123','password123',
'123456','1234567','12345678','123456789','1234567890','qwerty','qwertz',
'abc123','iloveyou','admin','admin123','letmein','welcome','111111',
'000000','123123','hallo','hallo123','test','test1234','schueler','schule',
'klassenportal','ifbschule','ifb',
]);
function generateRecoveryCodes() {
const plain = Array.from({ length: 8 }, () => {
const h = crypto.randomBytes(5).toString('hex');
@@ -108,6 +117,9 @@ router.post('/register', registerLimiter, async (req, res) => {
if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' });
if (password.length < 8) return res.status(400).json({ error: 'Passwort zu kurz (min. 8 Zeichen)' });
const username = email.split('@')[0].toLowerCase();
const pwLower = password.toLowerCase();
if (pwLower === username) return res.status(400).json({ error: 'Passwort darf nicht dem Benutzernamen entsprechen' });
if (WEAK_PASSWORDS.has(pwLower)) return res.status(400).json({ error: 'Dieses Passwort ist zu leicht zu erraten. Bitte wähle ein sichereres.' });
const safeRole = (role === 'teacher') ? 'teacher' : 'student';
const initialStatus = safeRole === 'teacher' ? 'pending' : 'active';
@@ -763,4 +775,168 @@ router.get('/health', (req, res) => {
}
});
// --- ICAL EXPORT ---
const ICAL_BYDAY = { Montag:'MO', Dienstag:'TU', Mittwoch:'WE', Donnerstag:'TH', Freitag:'FR' };
const ICAL_DAY_OFFSET = { Montag:0, Dienstag:1, Mittwoch:2, Donnerstag:3, Freitag:4 };
function icalDate(s) { return s.replace(/-/g, ''); }
function icalDateNext(s) {
const d = new Date(s); d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10).replace(/-/g, '');
}
function icalEsc(s) {
return String(s || '').replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n');
}
function icalStamp() {
return new Date().toISOString().replace(/[-:]/g, '').slice(0, 15) + 'Z';
}
router.post('/ical-token', requireAuth, (req, res) => {
let { ical_token } = db.prepare('SELECT ical_token FROM users WHERE id = ?').get(req.user.id);
if (!ical_token) {
ical_token = crypto.randomBytes(24).toString('hex');
db.prepare('UPDATE users SET ical_token = ? WHERE id = ?').run(ical_token, req.user.id);
}
res.json({ token: ical_token });
});
router.post('/ical-token/regenerate', requireAuth, (req, res) => {
const ical_token = crypto.randomBytes(24).toString('hex');
db.prepare('UPDATE users SET ical_token = ? WHERE id = ?').run(ical_token, req.user.id);
res.json({ token: ical_token });
});
router.get('/ical/:token.ics', (req, res) => {
const row = db.prepare('SELECT id FROM users WHERE ical_token = ?').get(req.params.token);
if (!row) return res.status(404).type('text/plain').send('Not found');
const uid = row.id;
const stamp = icalStamp();
const timetable = db.prepare('SELECT * FROM timetable WHERE user_id = ?').all(uid);
const homework = db.prepare("SELECT * FROM homework WHERE user_id = ? AND due_date IS NOT NULL AND done = 0").all(uid);
const classEvents = db.prepare('SELECT * FROM class_events WHERE date IS NOT NULL ORDER BY date').all();
const countdowns = db.prepare('SELECT * FROM countdowns WHERE user_id = ?').all(uid);
const L = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Klassenportal//KP//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Klassenportal',
'X-WR-CALDESC:Stundenplan und Termine',
];
for (const t of timetable) {
const byday = ICAL_BYDAY[t.day];
if (!byday) continue;
const off = ICAL_DAY_OFFSET[t.day];
const base = new Date(2024, 0, 1 + off);
const ymd = base.getFullYear().toString() +
String(base.getMonth() + 1).padStart(2, '0') +
String(base.getDate()).padStart(2, '0');
const st = (t.time_start || '08:00').replace(':', '') + '00';
const et = (t.time_end || '08:45').replace(':', '') + '00';
const summary = [t.subject, t.room].filter(Boolean).join(' · ');
L.push('BEGIN:VEVENT',
`UID:tt-${t.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART:${ymd}T${st}`,
`DTEND:${ymd}T${et}`,
`RRULE:FREQ=WEEKLY;BYDAY=${byday}`,
`SUMMARY:${icalEsc(summary || 'Unterricht')}`,
...(t.room ? [`LOCATION:${icalEsc(t.room)}`] : []),
...(t.teacher ? [`DESCRIPTION:Lehrer/in: ${icalEsc(t.teacher)}`] : []),
'END:VEVENT');
}
for (const h of homework) {
const label = h.subject ? `${h.subject}: ${h.title}` : h.title;
L.push('BEGIN:VEVENT',
`UID:hw-${h.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icalDate(h.due_date)}`,
`DTEND;VALUE=DATE:${icalDateNext(h.due_date)}`,
`SUMMARY:📚 ${icalEsc(label)}`,
'CATEGORIES:Hausaufgaben',
'END:VEVENT');
}
const TYPE_ICON = { pruefung:'📝', ferien:'🏖️', termin:'📅', sonstiges:'📌' };
for (const e of classEvents) {
const icon = TYPE_ICON[e.type] || '📌';
const endDate = e.date_end ? icalDateNext(e.date_end) : icalDateNext(e.date);
L.push('BEGIN:VEVENT',
`UID:ce-${e.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icalDate(e.date)}`,
`DTEND;VALUE=DATE:${endDate}`,
`SUMMARY:${icon} ${icalEsc(e.title)}`,
...(e.description ? [`DESCRIPTION:${icalEsc(e.description)}`] : []),
'END:VEVENT');
}
for (const c of countdowns) {
L.push('BEGIN:VEVENT',
`UID:cd-${c.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icalDate(c.target_date)}`,
`DTEND;VALUE=DATE:${icalDateNext(c.target_date)}`,
`SUMMARY:⏳ ${icalEsc(c.title)}`,
'END:VEVENT');
}
L.push('END:VCALENDAR');
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="klassenportal.ics"');
res.send(L.join('\r\n'));
});
// --- WEB PUSH ---
router.get('/push/vapid-key', (req, res) => {
if (!vapidPublicKey) return res.status(503).json({ error: 'Push not configured' });
res.json({ key: vapidPublicKey });
});
router.post('/push/subscribe', requireAuth, (req, res) => {
const { endpoint, keys } = req.body;
if (!endpoint || !keys) return res.status(400).json({ error: 'endpoint and keys required' });
try {
db.prepare(`
INSERT INTO push_subscriptions (user_id, endpoint, keys)
VALUES (?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, keys=excluded.keys
`).run(req.user.id, endpoint, JSON.stringify(keys));
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: 'Speichern fehlgeschlagen' });
}
});
router.delete('/push/subscribe', requireAuth, (req, res) => {
const { endpoint } = req.body;
if (endpoint) {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
} else {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(req.user.id);
}
res.json({ ok: true });
});
// Test notification (dev only convenience)
router.post('/push/test', requireAuth, async (req, res) => {
await sendToUser(req.user.id, { title: '🔔 Klassenportal', body: 'Push-Benachrichtigungen funktionieren!', tag: 'test' });
res.json({ ok: true });
});
// --- SESSION MANAGEMENT ---
router.post('/me/logout-other', requireAuth, (req, res) => {
const newVer = (req.user.token_version | 0) + 1;
db.prepare('UPDATE users SET token_version = ? WHERE id = ?').run(newVer, req.user.id);
const refreshed = db.prepare('SELECT id, username, role, subject, token_version FROM users WHERE id = ?').get(req.user.id);
res.cookie('token', signToken(refreshed), COOKIE_OPTIONS);
res.json({ ok: true });
});
module.exports = router;