Files
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

3265 lines
146 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<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">
<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 {
--blue: #2563eb; --blue-d: #1d4ed8;
--blue-50: #eff6ff; --blue-100: #dbeafe;
--n-0: #ffffff;
--n-50: #f9fafb; --n-100: #f3f4f6; --n-150: #eaecf0;
--n-200: #e5e7eb; --n-300: #d1d5db; --n-400: #9ca3af;
--n-500: #6b7280; --n-600: #4b5563; --n-700: #374151;
--n-800: #1f2937; --n-900: #111827;
--green: #16a34a; --green-50: #f0fdf4;
--red: #dc2626; --red-50: #fef2f2;
--amber: #d97706; --amber-50: #fffbeb;
--bg: #f4f6f9;
--surface: #ffffff;
--surface-2: #f9fafb;
--border: #e5e7eb;
--border-subtle: #f0f2f5;
--text: #111827;
--text-2: #374151;
--text-muted: #6b7280;
--text-subtle: #9ca3af;
--shadow-xs: 0 1px 2px rgba(0,0,0,.04);
--shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow: 0 2px 8px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow-lg: 0 8px 24px rgba(0,0,0,.08), 0 2px 6px rgba(0,0,0,.04);
--shadow-xl: 0 20px 40px rgba(0,0,0,.1), 0 4px 8px rgba(0,0,0,.04);
--r-xs: 4px; --r-sm: 6px; --r: 10px; --r-lg: 12px; --r-xl: 16px;
}
body.dark {
--blue-50: #172554; --blue-100: #1e3a8a;
--n-50: #1a1f2e; --n-100: #202637; --n-150: #262d3f;
--n-200: #2d3548; --n-300: #3a4459; --n-400: #5d6d84;
--n-500: #7e90a8; --n-600: #9dafC5; --n-700: #b8cad9;
--n-800: #d4e0ec; --n-900: #eaf0f8;
--green-50: #052e16; --red-50: #300; --amber-50: #2d1a00;
--bg: #141820;
--surface: #1a1f2e;
--surface-2: #202637;
--border: #2d3548;
--border-subtle: #262d3f;
--text: #eaf0f8; --text-2: #b8cad9;
--text-muted: #7e90a8; --text-subtle: #5d6d84;
--shadow-xs: 0 1px 2px rgba(0,0,0,.25);
--shadow-sm: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2);
--shadow: 0 2px 8px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.2);
--shadow-lg: 0 8px 24px rgba(0,0,0,.45), 0 2px 6px rgba(0,0,0,.25);
--shadow-xl: 0 20px 40px rgba(0,0,0,.55), 0 4px 8px rgba(0,0,0,.3);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
line-height: 1.5;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── HEADER ──────────────────────────────────────────────── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 20px;
height: 54px;
display: flex;
align-items: center;
gap: 0;
position: sticky;
top: 0;
z-index: 100;
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
user-select: none;
flex-shrink: 0;
}
.brand-mark {
width: 30px;
height: 30px;
background: var(--blue);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
color: #fff;
letter-spacing: -.5px;
flex-shrink: 0;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-sub { font-size: 10px; color: var(--text-subtle); font-weight: 500; letter-spacing: .2px; }
.brand-name { font-size: 14px; font-weight: 700; color: var(--text); letter-spacing: -.3px; }
.h-sep { width: 1px; height: 18px; background: var(--border); margin: 0 14px; flex-shrink: 0; }
.h-spacer { flex: 1; }
.h-right { display: flex; align-items: center; gap: 8px; }
#weather {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 3px 9px;
height: 28px;
}
#clock {
font-size: 12px;
color: var(--text-subtle);
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.h-icon-btn {
width: 30px; height: 30px;
border-radius: var(--r-sm);
border: none;
background: none;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 15px;
color: var(--text-muted);
transition: background .12s;
font-family: inherit;
}
.h-icon-btn:hover { background: var(--n-100); }
body.dark #btn-dark { color: #f1c40f; }
.h-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 4px;
height: 30px; padding: 0 12px;
border-radius: var(--r-sm);
font-size: 12px; font-weight: 600; font-family: inherit;
cursor: pointer;
transition: background .1s, color .1s, border-color .1s;
text-decoration: none; white-space: nowrap;
border: 1px solid var(--border);
background: none; color: var(--text-muted);
}
.h-btn:hover { background: var(--n-100); color: var(--text); }
.h-btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.h-btn-primary:hover { background: var(--blue-d); border-color: var(--blue-d); color: #fff; }
/* Legacy class aliases */
.btn-sm { display: inline-flex; align-items: center; gap: 4px; height: 30px; padding: 0 12px; border-radius: var(--r-sm); font-size: 12px; font-weight: 600; font-family: inherit; cursor: pointer; transition: background .1s; text-decoration: none; white-space: nowrap; border: 1px solid var(--border); background: none; color: var(--text-muted); }
.btn-sm:hover { background: var(--n-100); color: var(--text); }
.btn-primary-sm { background: var(--blue); color: #fff; border-color: var(--blue); }
.btn-primary-sm:hover { background: var(--blue-d); border-color: var(--blue-d); }
.avatar {
width: 30px; height: 30px;
border-radius: 50%;
background: var(--blue);
color: #fff;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 12px;
cursor: pointer;
position: relative;
flex-shrink: 0;
user-select: none;
transition: opacity .12s;
}
.avatar:hover { opacity: .85; }
.dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
min-width: 190px;
overflow: hidden;
display: none;
z-index: 200;
padding: 5px;
}
.dropdown.open { display: block; }
.dd-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px;
font-size: 13px; font-weight: 500;
cursor: pointer;
color: var(--text-2);
border-radius: var(--r-sm);
transition: background .1s;
text-decoration: none;
user-select: none;
}
.dd-item:hover { background: var(--n-100); }
.dd-item.danger { color: var(--red); }
.dd-item.danger:hover { background: var(--red-50); }
.dd-item.meta { color: var(--text-subtle); font-size: 11px; cursor: default; }
.dd-item.meta:hover { background: none; }
.dd-sep { height: 1px; background: var(--border-subtle); margin: 4px 0; }
/* ── BANNER ──────────────────────────────────────────────── */
#banner {
background: var(--blue);
padding: 0 20px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
min-height: 44px;
flex-shrink: 0;
}
.banner-label { font-size: 13px; font-weight: 500; color: rgba(255,255,255,.92); white-space: nowrap; }
.banner-chips { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
.chip {
background: rgba(255,255,255,.15);
border: 1px solid rgba(255,255,255,.2);
border-radius: var(--r-sm);
padding: 2px 8px;
font-size: 11px; font-weight: 500;
color: rgba(255,255,255,.9);
}
.banner-cta {
background: rgba(255,255,255,.95);
color: var(--blue);
font-size: 12px; font-weight: 600;
border: none;
padding: 6px 14px;
border-radius: var(--r-sm);
cursor: pointer;
white-space: nowrap;
font-family: inherit;
flex-shrink: 0;
transition: background .12s;
}
.banner-cta:hover { background: #fff; }
/* ── MAIN LAYOUT ─────────────────────────────────────────── */
main {
flex: 1;
width: 100%;
max-width: 1380px;
margin: 0 auto;
padding: 20px 20px 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 296px;
gap: 16px;
align-items: start;
}
.col-primary {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.col-secondary {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.card-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
/* ── CARD ────────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow-xs);
}
.card-head {
padding: 12px 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid var(--border-subtle);
}
.card-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
display: flex; align-items: center; gap: 6px;
line-height: 1;
text-transform: uppercase;
letter-spacing: .4px;
}
.card-title .ci { font-size: 13px; }
.card-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.card-body { padding: 14px 16px; }
.card-body-flush { padding: 0; }
/* Card action buttons */
.add-btn {
display: inline-flex; align-items: center; gap: 4px;
height: 26px; padding: 0 10px;
border-radius: var(--r-sm);
font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer;
background: var(--blue); color: #fff;
border: 1px solid var(--blue);
transition: background .1s;
white-space: nowrap;
}
.add-btn:hover { background: var(--blue-d); border-color: var(--blue-d); }
.print-btn {
display: inline-flex; align-items: center; gap: 4px;
height: 26px; padding: 0 10px;
border-radius: var(--r-sm);
font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer;
background: none; color: var(--text-muted);
border: 1px solid var(--border);
transition: background .1s;
}
.print-btn:hover { background: var(--n-100); color: var(--text); }
/* ── SKELETON ────────────────────────────────────────────── */
.sk {
background: linear-gradient(90deg, var(--n-100) 25%, var(--n-150) 50%, var(--n-100) 75%);
background-size: 200%;
animation: shimmer 1.6s infinite;
border-radius: var(--r-xs);
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
.sk-line { height: 12px; margin-bottom: 8px; }
.sk-line.short { width: 55%; }
/* ── TIMETABLE ───────────────────────────────────────────── */
.tt-wrap { overflow-x: auto; }
.tt-grid {
display: grid;
grid-template-columns: 52px repeat(5, 1fr);
border: 1px solid var(--border);
border-radius: var(--r-sm);
overflow: hidden;
min-width: 380px;
}
.tt-cell {
border-right: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border-subtle);
min-height: 52px;
position: relative;
}
.tt-cell:last-child, .tt-cell.no-right { border-right: none; }
.tt-cell.no-bot { border-bottom: none; }
.tt-head {
background: var(--surface-2);
padding: 7px 5px;
font-size: 10px; font-weight: 700;
text-align: center;
color: var(--text-muted);
letter-spacing: .5px; text-transform: uppercase;
min-height: auto;
}
.tt-head.today-col { color: var(--blue); background: var(--blue-50); }
.tt-time {
background: var(--surface-2);
padding: 5px 7px;
display: flex; flex-direction: column; justify-content: center; gap: 1px;
}
.tt-time-num { font-size: 11px; font-weight: 600; color: var(--text-muted); }
.tt-time-val { font-size: 9px; color: var(--text-subtle); }
.tt-lesson {
position: absolute; inset: 3px;
border-radius: 5px; padding: 5px 7px;
overflow: hidden;
border-left: 2.5px solid transparent;
}
.tt-lesson-subj { font-size: 11px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tt-lesson-meta { font-size: 9px; opacity: .7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.tt-del {
position: absolute; top: 3px; right: 4px;
font-size: 11px; cursor: pointer;
opacity: 0;
border: none; background: none; font-family: inherit; color: inherit; padding: 0; line-height: 1;
}
.tt-lesson:hover .tt-del { opacity: .5; }
.tt-lesson:hover .tt-del:hover { opacity: 1; }
.tt-pending { text-align: center; font-size: 13px; color: var(--text-subtle); padding: 28px 0; font-weight: 500; }
/* Placeholder TT */
.ph-tt { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.ph-col { display: flex; flex-direction: column; gap: 4px; }
.ph-head { text-align: center; font-size: 10px; font-weight: 700; color: var(--text-muted); padding: 4px 0; text-transform: uppercase; letter-spacing: .5px; }
.ph-head.today { color: var(--blue); }
.ph-slot { height: 52px; border-radius: 7px; }
/* ── EVENTS ──────────────────────────────────────────────── */
.ev-item {
display: flex; align-items: center; gap: 11px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.ev-item:last-child { border-bottom: none; }
.ev-days {
font-size: 18px; font-weight: 800; min-width: 36px; text-align: center;
letter-spacing: -1px; font-variant-numeric: tabular-nums; line-height: 1;
}
.ev-info { flex: 1; min-width: 0; }
.ev-title { font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; color: var(--text); }
.ev-date { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.ev-del {
color: var(--text-subtle); cursor: pointer;
font-size: 12px; border: none; background: none;
padding: 4px; border-radius: var(--r-sm); flex-shrink: 0;
opacity: 0; transition: opacity .1s, color .1s;
}
.ev-item:hover .ev-del { opacity: 1; }
.ev-del:hover { color: var(--red); }
/* ── BADGES ──────────────────────────────────────────────── */
.badge {
font-size: 10px; font-weight: 600;
padding: 1px 5px; border-radius: var(--r-xs);
letter-spacing: .1px; white-space: nowrap;
}
.badge-red { background: #fee2e2; color: #dc2626; }
.badge-orange { background: #fef3c7; color: #b45309; }
.badge-blue { background: var(--blue-100); color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #15803d; }
/* ── HOMEWORK ────────────────────────────────────────────── */
.hw-item {
display: flex; align-items: flex-start; gap: 9px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.hw-item:last-child { border-bottom: none; }
.check {
width: 16px; height: 16px;
border-radius: 4px;
border: 1.5px solid var(--n-300);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: 2px;
transition: all .14s; font-size: 9px; color: transparent;
}
.check.done { background: var(--green); border-color: var(--green); color: #fff; }
.hw-body { flex: 1; min-width: 0; }
.hw-title { font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.35; }
.hw-title.crossed { text-decoration: line-through; color: var(--text-muted); }
.hw-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.del-btn {
color: var(--text-subtle); cursor: pointer;
font-size: 12px; border: none; background: none;
flex-shrink: 0; padding: 4px; border-radius: var(--r-sm);
opacity: 0; transition: opacity .1s, color .1s;
font-family: inherit;
}
.hw-item:hover .del-btn,
.grade-row:hover .del-btn,
.ab-item:hover .del-btn,
.todo-item:hover .del-btn,
.cd-item:hover .del-btn,
.file-item:hover .del-btn { opacity: 1; }
.del-btn:hover { color: var(--red); }
/* ── GRADES ──────────────────────────────────────────────── */
.grade-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.grade-row:last-child { border-bottom: none; }
.grade-info { flex: 1; min-width: 0; }
.grade-subj { font-size: 13px; font-weight: 500; color: var(--text); }
.grade-type { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.grade-note { font-size: 11px; color: var(--text-muted); }
.grade-val {
font-size: 13px; font-weight: 700;
min-width: 30px; text-align: center;
padding: 3px 6px; border-radius: 5px;
font-variant-numeric: tabular-nums;
}
.g1,.g2 { background: #dcfce7; color: #166534; }
.g3 { background: #fef9c3; color: #854d0e; }
.g4 { background: #ffedd5; color: #c2410c; }
.g5,.g6 { background: #fee2e2; color: #991b1b; }
.grade-avg {
display: flex; justify-content: space-between; align-items: center;
padding-top: 12px; margin-top: 2px;
border-top: 1px solid var(--border-subtle);
}
.grade-avg-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.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 {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border-subtle);
}
.ab-item:last-child { border-bottom: none; }
.ab-date { font-size: 11px; font-weight: 600; min-width: 70px; color: var(--text-muted); padding-top: 1px; }
.ab-info { flex: 1; }
.ab-subj { font-size: 13px; font-weight: 500; color: var(--text); }
.ab-reason { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.ab-total { font-size: 11px; color: var(--text-muted); font-weight: 500; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-subtle); }
/* ── TODOS ───────────────────────────────────────────────── */
.todo-item {
display: flex; align-items: center; gap: 9px;
padding: 7px 0;
border-bottom: 1px solid var(--border-subtle);
}
.todo-item:last-child { border-bottom: none; }
.todo-check {
width: 16px; height: 16px;
border-radius: 50%;
border: 1.5px solid var(--n-300);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; font-size: 9px; color: transparent;
transition: all .14s;
}
.todo-check.done { background: var(--blue); border-color: var(--blue); color: #fff; }
.todo-title { flex: 1; font-size: 13px; font-weight: 500; color: var(--text); }
.todo-title.crossed { text-decoration: line-through; color: var(--text-muted); font-weight: 400; }
/* ── COUNTDOWNS ──────────────────────────────────────────── */
.cd-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.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; }
/* ── QUICKLINKS ──────────────────────────────────────────── */
.ql-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 6px; }
.ql-item {
display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 10px 6px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r);
text-decoration: none;
color: var(--text-muted);
font-size: 11px; font-weight: 500;
text-align: center;
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 ─────────────────────────────────────────── */
.empty { text-align: center; color: var(--text-subtle); font-size: 13px; padding: 24px 0; font-weight: 500; }
/* ── CHAT ────────────────────────────────────────────────── */
.chat-msgs {
height: 240px; overflow-y: auto;
display: flex; flex-direction: column; gap: 8px;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: var(--n-200) transparent;
}
.chat-msg { display: flex; flex-direction: column; gap: 1px; }
.chat-msg-meta { display: flex; align-items: baseline; gap: 6px; }
.chat-msg-user { font-size: 12px; font-weight: 600; color: var(--blue); }
.chat-msg-user.own { color: var(--green); }
.chat-msg-time { font-size: 10px; color: var(--text-subtle); }
.chat-msg-body { font-size: 13px; color: var(--text); line-height: 1.45; word-break: break-word; padding-left: 2px; }
.chat-msg-del { font-size: 11px; color: var(--text-subtle); border: none; background: none; cursor: pointer; margin-left: auto; flex-shrink: 0; visibility: hidden; }
.chat-msg:hover .chat-msg-del { visibility: visible; }
.chat-msg-del:hover { color: var(--red); }
.chat-input-row { display: flex; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-subtle); }
.chat-input {
flex: 1; padding: 7px 11px;
border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13px; font-family: inherit;
background: var(--surface); color: var(--text);
outline: none; transition: border-color .12s, box-shadow .12s;
}
.chat-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
/* ── FILE STORAGE ────────────────────────────────────────── */
.files-quota { margin-bottom: 12px; }
.files-quota-bar { height: 5px; background: var(--n-100); border-radius: 99px; overflow: hidden; margin-top: 5px; }
.files-quota-fill { height: 100%; background: var(--blue); border-radius: 99px; transition: width .3s; }
.files-quota-fill.warn { background: var(--amber); }
.files-quota-fill.danger { background: var(--red); }
.files-quota-text { font-size: 11px; color: var(--text-subtle); display: flex; justify-content: space-between; margin-top: 3px; }
.file-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
.file-item:last-child { border-bottom: none; }
.file-icon { font-size: 15px; flex-shrink: 0; line-height: 1; }
.file-info { flex: 1; min-width: 0; }
.file-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.file-meta { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.file-dl { font-size: 11px; font-weight: 600; color: var(--blue); background: var(--blue-50); border: none; padding: 4px 9px; border-radius: var(--r-sm); cursor: pointer; font-family: inherit; white-space: nowrap; transition: background .1s; }
.file-dl:hover { background: var(--blue-100); }
.upload-drop {
border: 1.5px dashed var(--n-300); border-radius: var(--r-sm);
padding: 16px; text-align: center;
color: var(--text-muted); font-size: 12px;
margin-bottom: 12px;
transition: border-color .12s, background .12s;
}
.upload-drop.drag { border-color: var(--blue); background: var(--blue-50); color: var(--blue); }
/* ── MODAL ───────────────────────────────────────────────── */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.45);
z-index: 300;
display: flex; align-items: center; justify-content: center;
padding: 16px;
backdrop-filter: blur(1px);
}
.modal {
background: var(--surface);
border-radius: var(--r-lg);
padding: 22px;
width: 100%; max-width: 400px;
box-shadow: var(--shadow-xl);
border: 1px solid var(--border);
}
.modal h3 { font-size: 15px; font-weight: 700; margin-bottom: 16px; color: var(--text); }
.modal-fields { display: flex; flex-direction: column; gap: 8px; }
.modal-fields input,
.modal-fields select,
.modal-fields textarea {
padding: 8px 11px;
border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13px; font-family: inherit;
color: var(--text); background: var(--surface);
outline: none;
transition: border-color .12s, box-shadow .12s;
}
.modal-fields input:focus,
.modal-fields select:focus,
.modal-fields textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
.modal-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
.btn-cancel {
padding: 7px 15px;
border: 1px solid var(--border); border-radius: var(--r-sm);
background: none; cursor: pointer;
font-size: 13px; font-weight: 500; font-family: inherit;
color: var(--text-muted); transition: background .1s;
}
.btn-cancel:hover { background: var(--n-100); color: var(--text); }
.btn-save {
padding: 7px 15px;
background: var(--blue); color: #fff;
border: 1px solid var(--blue); border-radius: var(--r-sm);
font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: background .1s;
}
.btn-save:hover { background: var(--blue-d); }
.btn-danger {
padding: 7px 15px;
background: var(--red); color: #fff;
border: 1px solid var(--red); border-radius: var(--r-sm);
font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer;
}
/* ── SETTINGS ────────────────────────────────────────────── */
.settings-section { margin-bottom: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); }
.settings-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.settings-label { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text); }
.settings-fields { display: flex; flex-direction: column; gap: 8px; }
.settings-fields input {
padding: 8px 11px;
border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13px; font-family: inherit;
background: var(--surface); color: var(--text); outline: none;
}
.settings-fields input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
.danger-zone { background: var(--red-50); border: 1px solid #fecaca; border-radius: var(--r-sm); padding: 14px; }
.danger-zone p { font-size: 13px; color: #7f1d1d; margin-bottom: 10px; line-height: 1.5; }
body.dark .danger-zone { background: #300; border-color: #5a1a1a; }
body.dark .danger-zone p { color: #fca5a5; }
/* ── TOAST ───────────────────────────────────────────────── */
#toasts { position: fixed; bottom: 20px; right: 20px; z-index: 400; display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
.toast {
background: var(--n-900);
color: #fff;
font-size: 13px; font-weight: 500;
padding: 9px 14px; border-radius: var(--r);
box-shadow: var(--shadow-lg);
opacity: 0; transform: translateY(6px) scale(.98);
transition: opacity .18s, transform .18s;
pointer-events: none; max-width: 300px;
border: 1px solid rgba(255,255,255,.06);
}
.toast.show { opacity: 1; transform: none; }
.toast.success { background: #166534; }
.toast.error { background: #991b1b; }
/* ── FOOTER ──────────────────────────────────────────────── */
footer {
background: var(--surface);
border-top: 1px solid var(--border);
padding: 13px 20px;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 8px; flex-shrink: 0;
}
.footer-left { font-size: 11px; color: var(--text-subtle); }
.footer-left strong { color: var(--text-muted); font-weight: 500; }
.footer-links { display: flex; gap: 14px; }
.footer-links a { font-size: 11px; color: var(--text-subtle); text-decoration: none; transition: color .12s; }
.footer-links a:hover { color: var(--blue); }
/* ── PRINT ───────────────────────────────────────────────── */
@media print {
body > *:not(main) { display: none !important; }
.main-grid { display: block !important; }
.col-secondary { display: none !important; }
#card-tt { box-shadow: none !important; border: 1px solid #ddd !important; }
.col-primary > *:not(#card-tt) { display: none !important; }
}
/* ── PAGE LAYOUT ─────────────────────────────────────────────── */
.page-body {
display: flex;
flex: 1;
align-items: stretch;
min-height: 0;
}
/* ── LEFT SIDEBAR ─────────────────────────────────────────────── */
.app-sidebar {
width: 296px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 54px;
height: calc(100vh - 54px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--n-200) transparent;
display: flex;
flex-direction: column;
z-index: 10;
}
.app-sidebar-inner {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.sb-nav-item {
display: flex; align-items: center; gap: 9px;
width: 100%; padding: 9px 10px;
border: none; background: none; cursor: pointer;
border-radius: var(--r-sm);
font-size: 13px; font-weight: 500; color: var(--text);
font-family: inherit; text-align: left;
transition: background .12s, color .12s;
}
.sb-nav-item:hover { background: var(--n-100); color: var(--blue); }
.sb-nav-item .lucide { width: 15px; height: 15px; color: var(--text-muted); flex-shrink: 0; }
.sb-nav-item:hover .lucide { color: var(--blue); }
.sb-nav-arrow { margin-left: auto; }
/* Overlay panel */
.overlay-panel {
position: fixed !important; inset: 16px !important;
z-index: 260; border-radius: var(--r-xl) !important;
overflow: hidden !important; box-shadow: var(--shadow-xl) !important;
flex-direction: column;
}
.overlay-panel .card-body {
overflow-y: auto;
flex: 1;
}
.sb-group-label {
font-size: 10px;
font-weight: 700;
letter-spacing: .6px;
text-transform: uppercase;
color: var(--text-subtle);
padding: 6px 6px 2px;
margin-top: 2px;
}
.sb-group-label:first-child { margin-top: 0; padding-top: 2px; }
/* Expand-to-fullscreen button */
.card-expand-btn {
width: 24px; height: 24px;
border: none; background: none; cursor: pointer;
border-radius: var(--r-sm);
display: flex; align-items: center; justify-content: center;
color: var(--text-subtle);
transition: background .1s, color .1s;
flex-shrink: 0;
padding: 0;
}
.card-expand-btn:hover { background: var(--n-100); color: var(--text); }
.card-expand-btn .lucide { width: 13px; height: 13px; }
/* Fullscreen card overlay */
.card.fullscreen {
position: fixed !important;
inset: 16px !important;
z-index: 260;
border-radius: var(--r-xl) !important;
overflow: auto !important;
box-shadow: var(--shadow-xl) !important;
display: flex !important;
flex-direction: column;
max-width: none !important;
}
.card.fullscreen .card-body {
flex: 1;
overflow-y: auto;
max-width: 860px;
width: 100%;
margin: 0 auto;
}
.card.fullscreen .chat-msgs { height: auto; min-height: 300px; flex: 1; }
/* Fullscreen backdrop */
#fs-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.45);
z-index: 259;
opacity: 0; pointer-events: none;
transition: opacity .2s;
}
#fs-backdrop.open { opacity: 1; pointer-events: all; }
/* Sidebar overlay (small screens) */
.sidebar-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.32);
z-index: 140;
opacity: 0; pointer-events: none;
transition: opacity .2s;
display: none;
}
.sidebar-backdrop.open { opacity: 1; pointer-events: all; }
#sidebar-btn { position: relative; font-size: 18px; }
/* ── RESPONSIVE ──────────────────────────────────────────── */
/* ── TICKET THREAD ────────────────────────────── */
.ticket-list-item { display:flex; align-items:center; gap:10px; padding:9px 0; border-bottom:1px solid var(--border-subtle); cursor:pointer; transition:background .1s; }
.ticket-list-item:last-child { border-bottom:none; }
.ticket-list-item:hover { background:var(--n-50); margin:0 -14px; padding-left:14px; padding-right:14px; border-radius:var(--r-sm); }
.ticket-list-subj { flex:1; font-size:13px; font-weight:600; color:var(--text); }
.ticket-list-meta { font-size:11px; color:var(--text-muted); margin-top:1px; }
.thread-wrap { display:flex; flex-direction:column; gap:8px; max-height:360px; overflow-y:auto; margin:12px 0; padding-right:2px; scrollbar-width:thin; }
.thread-bubble { padding:8px 12px; border-radius:var(--r-lg); font-size:13px; line-height:1.55; word-break:break-word; white-space:pre-wrap; max-width:85%; }
.thread-bubble.mine { background:var(--blue); color:#fff; align-self:flex-end; border-bottom-right-radius:4px; }
.thread-bubble.other { background:var(--n-100); color:var(--text); align-self:flex-start; border-bottom-left-radius:4px; }
.thread-bubble.admin-bubble { background:var(--amber-50); color:var(--text); border:1px solid var(--border); }
.thread-bubble-meta { font-size:10px; opacity:.65; margin-top:3px; }
.thread-reply-row { display:flex; gap:8px; margin-top:6px; }
.thread-reply-row textarea { flex:1; border:1.5px solid var(--border); border-radius:var(--r-sm); padding:8px 10px; font-size:13px; font-family:inherit; resize:none; outline:none; background:var(--surface); color:var(--text); transition:border-color .12s; }
.thread-reply-row textarea:focus { border-color:var(--blue); }
/* Large: sidebar always visible, no burger */
@media (min-width: 1101px) {
#sidebar-btn { display: none !important; }
.sidebar-backdrop { display: none !important; }
}
/* Small: sidebar becomes overlay, burger appears */
@media (max-width: 1100px) {
.app-sidebar {
position: fixed;
top: 54px; left: 0;
height: calc(100vh - 54px);
transform: translateX(-100%);
transition: transform .22s cubic-bezier(.4,0,.2,1);
box-shadow: var(--shadow-lg);
z-index: 150;
}
.app-sidebar.open { transform: translateX(0); }
.sidebar-backdrop { display: block; }
#sidebar-btn { display: flex !important; }
}
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
main { padding: 12px 12px 20px; gap: 14px; }
.main-grid { gap: 12px; }
.card-pair { grid-template-columns: 1fr; }
header { padding: 0 12px; height: 50px; }
#clock { display: none; }
#weather { display: none; }
.banner-chips { display: none; }
.h-sep { display: none; }
footer { padding: 11px 14px; }
}
/* ── TEACHER TABS ────────────────────────────────────────── */
.t-tabs { display:flex; border-bottom:1px solid var(--border); margin-bottom:12px; gap:0; overflow-x:auto; }
.t-tab { padding:8px 14px; font-size:12px; font-weight:600; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .12s, border-color .12s; white-space:nowrap; flex-shrink:0; }
.t-tab.active { color:var(--blue); border-bottom-color:var(--blue); }
.t-pane { display:none; }
.t-pane.active { display:block; }
.t-input { width:100%; padding:7px 10px; border:1px solid var(--border); border-radius:var(--r-sm); font-size:13px; font-family:inherit; background:var(--surface); color:var(--text); outline:none; transition:border-color .12s; }
.t-input:focus { border-color:var(--blue); }
/* ── ICONS ───────────────────────────────────────────────── */
.lucide {
display: inline-block;
vertical-align: -0.125em;
flex-shrink: 0;
width: 1em; height: 1em;
stroke-width: 2;
}
.card-title .lucide { width: 14px; height: 14px; }
.chip .lucide { width: 11px; height: 11px; stroke-width: 2.5; }
.h-icon-btn .lucide { width: 16px; height: 16px; }
.ql-icon .lucide { width: 20px; height: 20px; }
.file-icon .lucide { width: 16px; height: 16px; }
.dd-item .lucide { width: 15px; height: 15px; }
.del-btn .lucide, .ev-del .lucide { width: 13px; height: 13px; }
.tt-del .lucide, .chat-msg-del .lucide, .ql-del .lucide { width: 11px; height: 11px; stroke-width: 2.5; }
.check .lucide, .todo-check .lucide { width: 9px; height: 9px; stroke-width: 3; }
.cd-days.past .lucide, .ev-days .lucide { width: 18px; height: 18px; }
#weather .lucide { width: 14px; height: 14px; stroke-width: 1.5; }
.print-btn .lucide { width: 13px; height: 13px; }
.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>
<header>
<button id="sidebar-btn" class="h-icon-btn" onclick="openSidebar()" title="Menü" aria-label="Seitenleiste öffnen">&#9776;</button>
<div class="brand" onclick="location.href='/'">
<div class="brand-mark">KP</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">Klassenportal</span>
</div>
</div>
<div class="h-sep"></div>
<div class="h-spacer"></div>
<div class="h-right">
<div id="weather"></div>
<div id="clock"></div>
<button id="btn-dark" class="h-icon-btn" onclick="toggleDark()" title="Dark Mode"><i data-lucide="moon"></i></button>
<div id="h-user" style="display:flex;align-items:center;gap:7px">
<a href="/login" class="h-btn">Anmelden</a>
<a href="/login?tab=register" class="h-btn h-btn-primary">Registrieren</a>
</div>
</div>
</header>
<div id="banner">
<div class="banner-label">Mit Account freischalten:</div>
<div class="banner-chips">
<span class="chip"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</span>
<span class="chip"><i data-lucide="pencil" aria-hidden="true"></i> Hausaufgaben</span>
<span class="chip"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten &amp; Ø</span>
<span class="chip"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</span>
<span class="chip"><i data-lucide="check-square" aria-hidden="true"></i> To-Do</span>
<span class="chip"><i data-lucide="timer" aria-hidden="true"></i> Countdowns</span>
<span class="chip"><i data-lucide="link" aria-hidden="true"></i> Links</span>
</div>
<button class="banner-cta" onclick="location.href='/login?tab=register'">Jetzt registrieren →</button>
</div>
<div class="page-body">
<aside class="app-sidebar" id="app-sidebar">
<div class="app-sidebar-inner">
<div class="sb-group-label">Persönlich</div>
<button class="sb-nav-item" id="sb-nav-grades" style="display:none" onclick="openOverlay('card-grades');closeSidebar()">
<i data-lucide="graduation-cap"></i><span>Noten</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-files" style="display:none" onclick="openOverlay('card-files');closeSidebar()">
<i data-lucide="folder"></i><span>Dateispeicher</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-ab" style="display:none" onclick="openOverlay('card-ab');loadAbsencesCard();closeSidebar()">
<i data-lucide="user-x"></i><span>Fehlzeiten</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<div class="sb-group-label" id="sb-label-komm" style="display:none">Kommunikation</div>
<button class="sb-nav-item" id="sb-nav-chat" style="display:none" onclick="openOverlay('card-chat');closeSidebar()">
<i data-lucide="message-square"></i><span>Klassen-Chat</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-tickets" style="display:none" onclick="openOverlay('card-tickets');closeSidebar()">
<i data-lucide="ticket"></i><span>Support-Tickets</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<div class="sb-group-label" id="sb-label-klasse" style="display:none">Klasse</div>
<button class="sb-nav-item" id="sb-nav-materials" style="display:none" onclick="openOverlay('card-materials');loadStudentMaterials();closeSidebar()">
<i data-lucide="book-open"></i><span>Materialien</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-announcements" style="display:none" onclick="openOverlay('card-announcements');loadStudentAnnouncements();closeSidebar()">
<i data-lucide="megaphone"></i><span>Ankündigungen</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<div class="sb-group-label" id="sb-label-lehrer" style="display:none">Lehrer</div>
<button class="sb-nav-item" id="sb-nav-teacher" style="display:none" onclick="openOverlay('card-teacher');loadTeacherPanel();closeSidebar()">
<i data-lucide="user-cog"></i><span>Lehrer-Panel</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
</div>
</aside>
<!-- Overlay cards hidden until openOverlay() called -->
<input type="file" id="file-input" style="display:none" multiple onchange="uploadFiles(event)">
<div class="card ov-card" id="card-grades" style="display:none">
<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" 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">
<div class="card-head">
<div class="card-title"><i data-lucide="folder" aria-hidden="true"></i> Dateispeicher</div>
<div class="card-actions">
<button class="add-btn" onclick="document.getElementById('file-input').click()">↑ Hochladen</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="files-body">
<div class="upload-drop" id="files-drop">Dateien hier ablegen oder oben hochladen</div>
<div class="sk-line sk"></div>
</div>
</div>
<div class="card ov-card" id="card-ab" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body">
<!-- Teacher add form (shown for teachers only) -->
<div id="ab-teacher-form" style="display:none;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<select id="ab-student-card" class="t-input" style="flex:2;min-width:140px">
<option value="">Schüler auswählen…</option>
</select>
<input type="date" id="ab-date-card" class="t-input" style="flex:1;min-width:140px">
<input type="text" id="ab-subject-card" class="t-input" placeholder="Fach / Stunde" maxlength="200" style="flex:1;min-width:120px" list="subjects-datalist">
<input type="text" id="ab-reason-card" class="t-input" placeholder="Grund (optional)" maxlength="500" style="flex:2;min-width:140px">
<button class="add-btn" onclick="addAbsenceFromCard()">+ Fehlzeit</button>
</div>
</div>
<div id="list-ab"></div>
</div>
</div>
<div class="card ov-card" id="card-chat" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="message-square" aria-hidden="true"></i> Klassen-Chat</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" style="display:flex;flex-direction:column;flex:1">
<div class="chat-msgs" id="chat-msgs" style="flex:1;overflow-y:auto"></div>
<div class="chat-input-row">
<input class="chat-input" id="chat-input" type="text" placeholder="Nachricht schreiben…" maxlength="500" autocomplete="off">
<button class="add-btn" onclick="sendChatMsg()">Senden</button>
</div>
</div>
</div>
<div class="card ov-card" id="card-tickets" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="ticket" aria-hidden="true"></i> Support-Tickets</div>
<div class="card-actions">
<button class="add-btn" onclick="openNewTicket()">+ Ticket</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-tickets"><div class="empty">Keine Tickets</div></div>
</div>
<!-- STUDENT MATERIALS OVERLAY -->
<div class="card ov-card" id="card-materials" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="book-open" aria-hidden="true"></i> Materialien</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-student-materials"><div class="sk-line sk"></div></div>
</div>
<!-- STUDENT ANNOUNCEMENTS OVERLAY -->
<div class="card ov-card" id="card-announcements" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="megaphone" aria-hidden="true"></i> Ankündigungen</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-student-announcements"><div class="sk-line sk"></div></div>
</div>
<!-- TEACHER PANEL OVERLAY -->
<input type="file" id="teacher-file-input" style="display:none" onchange="uploadTeacherMaterial(event)">
<div class="card ov-card" id="card-teacher" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="user-cog" aria-hidden="true"></i> Lehrer-Panel</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body">
<div class="t-tabs" style="flex-wrap:wrap">
<div class="t-tab active" onclick="teacherTab('faecher')">Fächer</div>
<div class="t-tab" onclick="teacherTab('materials')">Materialien</div>
<div class="t-tab" onclick="teacherTab('announcements')">Ankündigungen</div>
<div class="t-tab" onclick="teacherTab('exams')">Prüfungen</div>
<div class="t-tab" onclick="teacherTab('grades')">Noten</div>
<div class="t-tab" onclick="teacherTab('timetable')">Stundenplan</div>
<div class="t-tab" onclick="teacherTab('absences')">Fehlzeiten</div>
</div>
<div id="t-pane-faecher" class="t-pane active">
<p style="font-size:13px;color:var(--text-muted);margin:0 0 12px">Wähle die Fächer aus, die du unterrichtest. Diese erscheinen dann als Auswahlmöglichkeit in allen anderen Bereichen.</p>
<div id="t-my-subjects-list" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;min-height:28px"></div>
<div style="display:flex;gap:8px;margin-bottom:8px">
<select id="t-subject-add" style="flex:1">
<option value="">Vorhandenes Fach hinzufügen…</option>
</select>
<button class="add-btn" onclick="tAddMySubject()">Hinzufügen</button>
</div>
<div style="display:flex;gap:8px">
<input type="text" id="t-subject-new" class="t-input" placeholder="Neues Fach erstellen und hinzufügen…" maxlength="60" style="flex:1">
<button class="add-btn" onclick="tCreateSubject()">Erstellen</button>
</div>
</div>
<div id="t-pane-materials" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<select id="mat-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach auswählen…</option></select>
<input type="text" id="mat-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:2;min-width:160px">
<button class="add-btn" onclick="document.getElementById('teacher-file-input').click()">↑ Datei hochladen</button>
</div>
<div id="list-teacher-materials"><div class="empty">Keine Materialien hochgeladen</div></div>
</div>
<div id="t-pane-announcements" class="t-pane">
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<select id="ann-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach auswählen…</option></select>
<input type="text" id="ann-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:2;min-width:160px">
</div>
<textarea id="ann-content" class="t-input" placeholder="Inhalt (max. 5000 Zeichen)" maxlength="5000" rows="3" style="resize:vertical"></textarea>
<button class="add-btn" style="align-self:flex-start" onclick="createAnnouncement()">+ Ankündigung</button>
</div>
<div id="list-teacher-announcements"><div class="empty">Keine Ankündigungen</div></div>
</div>
<div id="t-pane-exams" class="t-pane">
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<select id="exam-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach auswählen…</option></select>
<input type="text" id="exam-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:2;min-width:160px">
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="date" id="exam-date" class="t-input" style="flex:1;min-width:140px">
<input type="text" id="exam-desc" class="t-input" placeholder="Beschreibung (optional)" maxlength="1000" style="flex:2;min-width:160px">
</div>
<button class="add-btn" style="align-self:flex-start" onclick="createExam()">+ Prüfung anlegen</button>
</div>
<div id="list-teacher-exams"><div class="empty">Keine Prüfungen angelegt</div></div>
</div>
<div id="t-pane-grades" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
<select id="grade-subject" class="t-input" style="flex:1;min-width:130px"><option value="">Fach…</option></select>
<select id="grade-student" class="t-input" style="flex:2;min-width:140px">
<option value="">Schüler auswählen…</option>
</select>
<select id="grade-type" class="t-input" style="flex:1;min-width:120px">
<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="grade-val" class="t-input" min="1" max="6" step="0.5" placeholder="Note (16)" style="flex:1;min-width:100px">
<input type="text" id="grade-note" class="t-input" placeholder="Anmerkung (optional)" maxlength="500" style="flex:2;min-width:140px">
<button class="add-btn" onclick="assignGrade()">Note vergeben</button>
</div>
<div id="list-teacher-grades"><div class="empty">Keine Noten vergeben</div></div>
</div>
<div id="t-pane-timetable" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
<select id="tt-day" class="t-input" style="flex:1;min-width:120px">
<option value="">Tag…</option>
<option>Montag</option><option>Dienstag</option><option>Mittwoch</option><option>Donnerstag</option><option>Freitag</option>
</select>
<select id="tt-time" class="t-input" style="flex:1;min-width:140px">
<option value="">Stunde…</option>
</select>
<input type="text" id="tt-subject" class="t-input" placeholder="Fach (leer = eigenes)" maxlength="60" style="flex:1;min-width:120px" list="subjects-datalist">
<input type="text" id="tt-room" class="t-input" placeholder="Raum (optional)" maxlength="60" style="flex:1;min-width:100px">
<button class="add-btn" onclick="addTimetableEntry()">+ Eintragen</button>
</div>
<div id="list-teacher-timetable"><div class="empty">Keine Einträge</div></div>
</div>
<div id="t-pane-absences" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
<select id="ab-student" class="t-input" style="flex:2;min-width:140px">
<option value="">Schüler auswählen…</option>
</select>
<input type="date" id="ab-date" class="t-input" style="flex:1;min-width:140px">
<input type="text" id="ab-subject" class="t-input" placeholder="Fach / Stunde" maxlength="200" style="flex:1;min-width:120px" list="subjects-datalist">
<input type="text" id="ab-reason" class="t-input" placeholder="Grund (optional)" maxlength="500" style="flex:2;min-width:140px">
<button class="add-btn" onclick="addStudentAbsence()">+ Fehlzeit</button>
</div>
<div id="list-teacher-absences"><div class="empty">Keine Fehlzeiten</div></div>
</div>
</div>
</div>
<datalist id="subjects-datalist"></datalist>
<main>
<div class="main-grid">
<!-- ── PRIMARY COLUMN ──────────────────────────── -->
<div class="col-primary">
<!-- Timetable (logged-out placeholder) -->
<div class="card" id="card-tt-public">
<div class="card-head">
<div class="card-title"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</div>
</div>
<div class="card-body">
<div class="ph-tt">
<div class="ph-col"><div class="ph-head" id="ph0">Mo</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph1">Di</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph2">Mi</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph3">Do</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph4">Fr</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
</div>
<div class="tt-pending"><i data-lucide="clock" aria-hidden="true"></i> Wird noch eingetragen</div>
</div>
</div>
<!-- Timetable (logged-in) -->
<div class="card" id="card-tt" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</div>
<div class="card-actions">
<button class="print-btn" onclick="window.print()"><i data-lucide="printer" aria-hidden="true"></i> Drucken</button>
</div>
</div>
<div class="card-body">
<div class="tt-wrap"><div class="tt-grid" id="tt-grid"></div></div>
</div>
</div>
<!-- Homework + Todos (pair) -->
<div class="card-pair" id="pair-hw-todo" style="display:none">
<div class="card" id="card-hw">
<div class="card-head">
<div class="card-title"><i data-lucide="pencil" aria-hidden="true"></i> Hausaufgaben</div>
<button class="add-btn" onclick="openModal('homework')">+ Aufgabe</button>
</div>
<div class="card-body" id="list-hw"></div>
</div>
<div class="card" id="card-todo">
<div class="card-head">
<div class="card-title"><i data-lucide="check-square" aria-hidden="true"></i> To-Do</div>
<button class="add-btn" onclick="openModal('todos')">+ Aufgabe</button>
</div>
<div class="card-body" id="list-todo"></div>
</div>
</div>
</div><!-- /col-primary -->
<!-- ── SECONDARY COLUMN ─────────────────────────── -->
<div class="col-secondary">
<!-- Exams (shared) -->
<div class="card" id="card-pruefungen">
<div class="card-head">
<div class="card-title"><i data-lucide="clipboard-list" aria-hidden="true"></i> Prüfungen &amp; Klausuren</div>
<button class="add-btn" id="btn-add-pruefung" style="display:none" onclick="openModal('class-events','pruefung')">+ Prüfung</button>
</div>
<div class="card-body" id="list-pruefungen"><div class="sk-line sk"></div><div class="sk-line sk short"></div></div>
</div>
<!-- Holidays (shared) -->
<div class="card" id="card-ferien">
<div class="card-head">
<div class="card-title"><i data-lucide="calendar-range" aria-hidden="true"></i> Ferien &amp; Feiertage</div>
<button class="add-btn" id="btn-add-ferien" style="display:none" onclick="openModal('class-events','ferien')">+ Termin</button>
</div>
<div class="card-body" id="list-ferien"><div class="sk-line sk"></div><div class="sk-line sk short"></div></div>
</div>
<!-- Countdowns -->
<div class="card" id="card-cd" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="timer" aria-hidden="true"></i> Countdowns</div>
<button class="add-btn" onclick="openModal('countdowns')">+ Countdown</button>
</div>
<div class="card-body" id="list-cd"></div>
</div>
<!-- Quick Links -->
<div class="card" id="card-ql">
<div class="card-head">
<div class="card-title"><i data-lucide="link" aria-hidden="true"></i> Schnelllinks</div>
<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">
<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>
</div><!-- /col-secondary -->
</div><!-- /main-grid -->
</main>
</div><!-- /page-body -->
<div class="sidebar-backdrop" id="sidebar-backdrop" onclick="closeSidebar()"></div>
<div id="fs-backdrop" onclick="closeOverlay()"></div>
<footer>
<div class="footer-left">Daten auf <strong>Server in Deutschland</strong> · EU-DSGVO konform</div>
<div class="footer-links">
<a href="/datenschutz">Datenschutzerklärung</a>
</div>
</footer>
<!-- NEW TICKET MODAL -->
<div class="overlay" id="new-ticket-overlay" style="display:none" onclick="if(event.target===this)closeNewTicket()">
<div class="modal" style="max-width:460px">
<h3>Neues Support-Ticket</h3>
<div style="display:flex;flex-direction:column;gap:10px;margin:14px 0">
<input type="text" id="new-ticket-subject" placeholder="Betreff (max. 200 Zeichen)" maxlength="200"
style="padding:8px 10px;border:1.5px solid var(--border);border-radius:var(--r-sm);font-size:13px;font-family:inherit;outline:none;background:var(--surface);color:var(--text)">
<textarea id="new-ticket-message" placeholder="Beschreibe dein Problem…" rows="5" maxlength="5000"
style="padding:8px 10px;border:1.5px solid var(--border);border-radius:var(--r-sm);font-size:13px;font-family:inherit;resize:vertical;outline:none;background:var(--surface);color:var(--text)"></textarea>
</div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeNewTicket()">Abbrechen</button>
<button class="btn-save" onclick="submitNewTicket()">Absenden</button>
</div>
</div>
</div>
<!-- TICKET THREAD MODAL -->
<div class="overlay" id="thread-overlay" style="display:none" onclick="if(event.target===this)closeThread()">
<div class="modal" style="max-width:520px">
<h3 id="thread-title" style="word-break:break-word"></h3>
<p id="thread-meta" style="font-size:12px;color:var(--text-muted);margin-top:3px"></p>
<div class="thread-wrap" id="thread-msgs"></div>
<div class="thread-reply-row" id="thread-reply-area">
<textarea id="thread-input" placeholder="Antwort schreiben…" rows="2" maxlength="5000"></textarea>
<button class="btn-save" onclick="sendThreadReply()">Senden</button>
</div>
<div class="modal-actions" style="margin-top:8px">
<button class="btn-cancel" onclick="closeThread()">Schließen</button>
</div>
</div>
</div>
<!-- DATA MODAL -->
<div class="overlay" id="modal-overlay" style="display:none" onclick="if(event.target===this)closeModal()">
<div class="modal">
<h3 id="modal-title"></h3>
<div class="modal-fields" id="modal-fields"></div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModal()">Abbrechen</button>
<button class="btn-save" onclick="saveModal()">Speichern</button>
</div>
</div>
</div>
<!-- SETTINGS MODAL -->
<div class="overlay" id="settings-overlay" style="display:none" onclick="if(event.target===this)closeSettings()">
<div class="modal" style="max-width:420px">
<h3>Einstellungen</h3>
<div class="settings-section">
<div class="settings-label">Passwort ändern</div>
<div class="settings-fields">
<input type="password" id="pw-current" placeholder="Aktuelles Passwort">
<input type="password" id="pw-new" placeholder="Neues Passwort (min. 6 Zeichen)">
<button class="btn-save" style="align-self:flex-start" onclick="changePassword()">Ändern</button>
</div>
</div>
<div class="settings-section" id="teacher-subject-section" style="display:none">
<div class="settings-label">Meine Lehrfächer</div>
<div id="my-subjects-list" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px"></div>
<div class="settings-fields">
<div style="display:flex;gap:8px">
<select id="subject-add-select" style="flex:1">
<option value="">Vorhandenes Fach hinzufügen…</option>
</select>
<button class="btn-save" style="white-space:nowrap" onclick="addMySubject()">Hinzufügen</button>
</div>
<div style="display:flex;gap:8px">
<input type="text" id="subject-new" placeholder="Neues Fach erstellen &amp; hinzufügen…" maxlength="60" style="flex:1">
<button class="btn-save" style="white-space:nowrap" onclick="createNewSubject()">Erstellen</button>
</div>
</div>
</div>
<div class="settings-section" id="2fa-section">
<div class="settings-label">Zwei-Faktor-Authentifizierung (2FA)</div>
<div id="2fa-status-row" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geladen…</div>
<!-- Setup flow -->
<div id="2fa-setup-area" style="display:none">
<div style="margin-bottom:10px;font-size:13px;color:var(--text-2)">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy).</div>
<img id="2fa-qr" style="width:180px;height:180px;border-radius:8px;border:1px solid var(--border);display:block;margin-bottom:8px" alt="QR Code">
<details style="margin-bottom:10px;font-size:12px">
<summary style="cursor:pointer;color:var(--text-muted);user-select:none">Kein Kamera? Manuell eingeben</summary>
<div style="margin-top:6px;padding:8px;background:var(--n-100);border-radius:6px;border:1px solid var(--border)">
<div style="color:var(--text-muted);margin-bottom:4px">Geheimschlüssel (Base32):</div>
<code id="2fa-secret" style="font-size:13px;word-break:break-all;color:var(--text);letter-spacing:.05em"></code>
<div style="color:var(--text-subtle);margin-top:4px;font-size:11px">In App: Konto manuell hinzufügen → TOTP → diesen Schlüssel eingeben</div>
</div>
</details>
<div class="settings-fields">
<input type="text" id="2fa-confirm-code" placeholder="6-stelliger Code zur Bestätigung" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-save" style="align-self:flex-start" onclick="confirm2FA()">Bestätigen &amp; aktivieren</button>
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FASetup()">Abbrechen</button>
</div>
</div>
</div>
<!-- Recovery codes display (after setup or regen) -->
<div id="2fa-codes-area" style="display:none">
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#92400e">
Speichere diese Codes sicher. Jeder Code kann nur <strong>einmal</strong> verwendet werden. Ohne Codes und Authenticator-App verlierst du den Zugang.
</div>
<div id="2fa-codes-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px;font-family:monospace;font-size:13px;background:var(--n-50);border:1px solid var(--border);border-radius:8px;padding:12px"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-save" style="align-self:flex-start;font-size:12px;padding:5px 12px" onclick="copyRecoveryCodes()">Kopieren</button>
<button class="btn-save" style="align-self:flex-start;font-size:12px;padding:5px 12px" onclick="downloadRecoveryCodes()">Als Datei speichern</button>
<button class="btn-cancel" style="align-self:flex-start;font-size:12px;padding:5px 12px" onclick="dismissRecoveryCodes()">Fertig</button>
</div>
</div>
<!-- Regenerate recovery codes -->
<div id="2fa-regen-area" style="display:none">
<div style="font-size:13px;color:var(--text-2);margin-bottom:8px">Bestätige mit deinem aktuellen 2FA-Code um neue Wiederherstellungscodes zu erstellen. Alte Codes werden ungültig.</div>
<div class="settings-fields">
<input type="text" id="2fa-regen-code" placeholder="6-stelliger 2FA-Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
<div style="display:flex;gap:8px">
<button class="btn-save" style="align-self:flex-start" onclick="doRegenCodes()">Neu generieren</button>
<button class="btn-cancel" style="align-self:flex-start" onclick="cancelRegenCodes()">Abbrechen</button>
</div>
</div>
</div>
<!-- Disable flow -->
<div id="2fa-disable-area" style="display:none">
<div class="settings-fields">
<input type="password" id="2fa-disable-pw" placeholder="Aktuelles Passwort">
<input type="text" id="2fa-disable-code" placeholder="6-stelliger 2FA-Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
<div style="display:flex;gap:8px">
<button class="btn-danger" style="align-self:flex-start" onclick="disable2FA()">2FA deaktivieren</button>
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FADisable()">Abbrechen</button>
</div>
</div>
</div>
<!-- Idle buttons -->
<div id="2fa-idle-area" style="display:none">
<button class="btn-save" style="align-self:flex-start" onclick="setup2FA()">2FA einrichten</button>
</div>
<div id="2fa-enabled-area" style="display:none">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:13px;color:var(--green);font-weight:600">✓ 2FA ist aktiv</span>
</div>
<div id="2fa-rc-count" style="font-size:12px;color:var(--text-muted);margin-bottom:8px"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-cancel" style="font-size:12px;padding:5px 12px" onclick="showRegenCodes()">Wiederherstellungscodes neu generieren</button>
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button>
</div>
</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>
<div class="settings-fields">
<input type="password" id="pw-delete" placeholder="Passwort zur Bestätigung">
<button class="btn-danger" style="align-self:flex-start" onclick="deleteAccount()">Account löschen</button>
</div>
</div>
</div>
<div class="modal-actions" style="margin-top:10px">
<button class="btn-cancel" onclick="closeSettings()">Schließen</button>
</div>
</div>
</div>
<div id="toasts"></div>
<script>
// ── CONSTANTS ────────────────────────────────────────────────
const DAYS = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag'];
const DAY_S = ['Mo','Di','Mi','Do','Fr'];
const TIME_SLOTS = [
{ key:'08:00', label:'1.', time:'08:00' },
{ key:'08:45', label:'2.', time:'08:45' },
{ key:'09:45', label:'3.', time:'09:45' },
{ key:'10:30', label:'4.', time:'10:30' },
{ key:'11:30', label:'5.', time:'11:30' },
{ key:'12:15', label:'6.', time:'12:15' },
{ key:'13:45', label:'7.', time:'13:45' },
{ key:'14:30', label:'8.', time:'14:30' },
];
const GRADE_WEIGHTS = { schulaufgabe:2, kurzarbeit:1.5, stegreifaufgabe:1, muendlich:1, sonstiges:1 };
const GRADE_TYPES = { schulaufgabe:'Schulaufgabe', kurzarbeit:'Kurzarbeit', stegreifaufgabe:'Stegreifaufgabe', muendlich:'Mündlich', sonstiges:'Sonstiges' };
const SUBJECT_COLORS = [
{ bg:'#eff6ff', border:'#2563eb', text:'#1e40af' },
{ bg:'#f0fdf4', border:'#16a34a', text:'#166534' },
{ bg:'#fef3c7', border:'#d97706', text:'#92400e' },
{ bg:'#fdf4ff', border:'#9333ea', text:'#7e22ce' },
{ bg:'#fff1f2', border:'#e11d48', text:'#9f1239' },
{ bg:'#ecfdf5', border:'#059669', text:'#065f46' },
{ bg:'#f0f9ff', border:'#0ea5e9', text:'#0c4a6e' },
{ bg:'#fefce8', border:'#ca8a04', text:'#713f12' },
];
let currentModal = null;
let currentModalSub = null;
// ── UTILITIES ─────────────────────────────────────────────────
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function subjectColor(s){
let h=0; for(const c of String(s)) h=(h*31+c.charCodeAt(0))&0xffffffff;
return SUBJECT_COLORS[Math.abs(h)%SUBJECT_COLORS.length];
}
function daysUntil(dateStr){
const t=new Date(); t.setHours(0,0,0,0);
const d=new Date(dateStr); d.setHours(0,0,0,0);
return Math.round((d-t)/86400000);
}
function fmtDate(s){ return s ? new Date(s).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}) : ''; }
// ── TOAST ─────────────────────────────────────────────────────
function toast(msg, type='success'){
const el=document.createElement('div');
el.className=`toast ${type}`; el.textContent=msg;
document.getElementById('toasts').appendChild(el);
requestAnimationFrame(()=>{ requestAnimationFrame(()=>el.classList.add('show')); });
setTimeout(()=>{ el.classList.remove('show'); setTimeout(()=>el.remove(),300); },3000);
}
// ── CLOCK ─────────────────────────────────────────────────────
function tick(){
const n=new Date();
document.getElementById('clock').textContent=
n.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit',year:'numeric'})+
' · '+n.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
}
tick(); setInterval(tick,10000);
// ── WEATHER ───────────────────────────────────────────────────
async function loadWeather(){
try{
const d=await(await fetch('https://api.open-meteo.com/v1/forecast?latitude=47.857&longitude=12.128&current_weather=true')).json();
const w=d.current_weather;
const ic={0:'☀️',1:'🌤️',2:'⛅',3:'☁️',45:'🌫️',51:'🌦️',55:'🌧️',61:'🌧️',65:'🌧️',71:'🌨️',75:'❄️',80:'🌧️',95:'⛈️'};
document.getElementById('weather').innerHTML=`${ic[w.weathercode]||'🌡️'} <strong>${Math.round(w.temperature)}°C</strong>&thinsp;<span style="color:var(--text-subtle);font-size:10px">RO</span>`;
}catch{}
}
loadWeather();
// ── API ────────────────────────────────────────────────────────
async function api(method,path,body){
try{
const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const text=await r.text();
let data;
try{data=text?JSON.parse(text):{};}catch{data={error:'Ungültige Antwort vom Server'};console.warn('api non-JSON',path,text.slice(0,200));}
if(!r.ok){console.warn('api',method,path,r.status,data);if(!data.error)data.error='HTTP '+r.status;}
return data;
}catch(e){console.error('api fetch',method,path,e);return{error:'Netzwerkfehler: '+e.message};}
}
// ── SESSION ────────────────────────────────────────────────────
async function init(){
// Highlight today on public timetable
const ti=(new Date().getDay()+6)%7;
if(ti<5) document.getElementById('ph'+ti)?.classList.add('today');
// Load shared class events immediately
loadClassEvents();
const r=await fetch('/api/me');
if(r.ok){ const d=await r.json(); loginUI(d.username,d.id,d.role,d.subject); }
}
let currentRole = '';
function loginUI(username,id,role,subject){
currentRole = role;
document.getElementById('banner').style.display='none';
document.getElementById('card-tt-public').style.display='none';
document.getElementById('card-tt').style.display='';
document.getElementById('pair-hw-todo').style.display='grid';
document.getElementById('sb-label-komm').style.display='';
['sb-nav-files','sb-nav-ab','sb-nav-chat','sb-nav-tickets'].forEach(id=>document.getElementById(id).style.display='flex');
if(role!=='teacher') document.getElementById('sb-nav-grades').style.display='flex';
['card-hw','card-todo','card-cd'].forEach(id=>document.getElementById(id).style.display='');
['btn-add-pruefung','btn-add-ferien','btn-add-ql'].forEach(id=>document.getElementById(id).style.display='');
// Class materials and announcements visible to all logged-in users
document.getElementById('sb-label-klasse').style.display='';
['sb-nav-materials','sb-nav-announcements'].forEach(id=>document.getElementById(id).style.display='flex');
// Teacher features
if(role==='teacher'){
document.getElementById('sb-label-lehrer').style.display='';
document.getElementById('sb-nav-teacher').style.display='flex';
document.getElementById('ab-teacher-form').style.display='';
loadStudentListForAbsencesCard();
}
const adminLink = role === 'admin' ? `<div class="dd-sep"></div><a class="dd-item" href="/admin">🛡️ Admin</a>` : '';
const adminBtn = role === 'admin' ? `<a href="/admin" class="btn-sm" style="background:#fef3c7;color:#92400e;border-color:#fde68a;font-weight:700">🛡️ Admin</a>` : '';
const subjectBadge = (role==='teacher'&&subject) ? `<span class="dd-item meta" style="color:var(--text-muted);font-size:11px">${esc(subject)}</span>` : '';
document.getElementById('h-user').innerHTML=`
${adminBtn}
<div class="avatar" onclick="toggleDropdown(this)">
${esc(username[0].toUpperCase())}
<div class="dropdown" id="user-dropdown">
<div class="dd-item meta">${esc(username)}</div>
${subjectBadge}
<div class="dd-sep"></div>
<span class="dd-item" onclick="openSettings()">⚙️ Einstellungen</span>
${adminLink}
<div class="dd-sep"></div>
<span class="dd-item danger" onclick="doLogout()">Abmelden</span>
</div>
</div>`;
loadSubjectsDatalist();
loadAll();
initChat(username);
}
function toggleDropdown(el){
event.stopPropagation();
document.getElementById('user-dropdown').classList.toggle('open');
}
document.addEventListener('click',()=>document.getElementById('user-dropdown')?.classList.remove('open'));
async function doLogout(){
await fetch('/api/logout',{method:'POST'});
location.reload();
}
// ── LOAD ALL ──────────────────────────────────────────────────
async function loadAll(){
const [tt,hw,gr,td,cd,ql]=await Promise.all([
api('GET','teacher/class-timetable?class_id=info1'),
api('GET','homework'),
api('GET','grades'),
api('GET','todos'),
api('GET','countdowns'),
api('GET','quicklinks')
]);
renderTT(Array.isArray(tt)?tt:[]); renderHW(hw); renderGrades(gr);
renderTodos(td); renderCountdowns(cd); renderQL(ql);
loadFiles();
loadTickets();
}
async function loadClassEvents(){
const events=await api('GET','class-events');
renderPruefungen(events.filter(e=>e.type==='pruefung'));
renderFerien(events.filter(e=>e.type==='ferien'));
}
// ── TIMETABLE ─────────────────────────────────────────────────
function renderTT(data){
const todayIdx=(new Date().getDay()+6)%7;
const grid=document.getElementById('tt-grid');
grid.innerHTML='';
const cols=6; // time col + 5 days
const rows=TIME_SLOTS.length+1; // header + slots
// Header row
const corner=document.createElement('div');
corner.className='tt-cell tt-head no-right'; corner.textContent='';
grid.appendChild(corner);
DAYS.forEach((d,i)=>{
const h=document.createElement('div');
h.className='tt-cell tt-head'+(i===todayIdx?' today-col':'')+(i===4?' no-right':'');
h.textContent=DAY_S[i]; grid.appendChild(h);
});
// Slot rows
TIME_SLOTS.forEach((slot,si)=>{
const isLast=si===TIME_SLOTS.length-1;
// Time label
const tc=document.createElement('div');
tc.className='tt-cell tt-time'+(isLast?' no-bot':'');
tc.innerHTML=`<div class="tt-time-num">${slot.label}</div><div class="tt-time-val">${slot.time}</div>`;
grid.appendChild(tc);
DAYS.forEach((day,di)=>{
const cell=document.createElement('div');
cell.className='tt-cell'+(isLast?' no-bot':'')+(di===4?' no-right':'');
const lesson=data.find(l=>l.day===day && l.time_start===slot.key);
if(lesson){
const col=subjectColor(lesson.subject||'');
const lel=document.createElement('div');
lel.className='tt-lesson';
lel.style.cssText=`background:${col.bg};border-left:2.5px solid ${col.border};color:${col.text}`;
lel.innerHTML=`<div class="tt-lesson-subj">${esc(lesson.subject||'')}</div><div class="tt-lesson-meta">${esc(lesson.room||'')}${lesson.teacher_name?' · '+esc(lesson.teacher_name):''}</div>`;
cell.appendChild(lel);
}
grid.appendChild(cell);
});
});
}
// ── CLASS EVENTS ──────────────────────────────────────────────
function renderPruefungen(data){
const el=document.getElementById('list-pruefungen');
if(!data.length){el.innerHTML='<div class="empty">Keine Prüfungen eingetragen</div>';return;}
el.innerHTML=data.map(e=>{
const diff=e.date?daysUntil(e.date):null;
let badge='', col='var(--blue)';
if(diff!==null){
if(diff<0){badge=`<span class="badge badge-green">Vorbei</span>`;col='var(--text-subtle)';}
else if(diff===0){badge=`<span class="badge badge-red">Heute</span>`;col='var(--red)';}
else if(diff<=7){badge=`<span class="badge badge-orange">in ${diff}d</span>`;col='var(--amber)';}
else{badge=`<span class="badge badge-blue">in ${diff}d</span>`;}
}
return `<div class="ev-item">
<div class="ev-days" style="color:${col}">${diff===null?'':diff<0?'✓':diff}</div>
<div class="ev-info"><div class="ev-title">${esc(e.title)} ${badge}</div><div class="ev-date">${e.date?fmtDate(e.date):''}${e.description?' · '+esc(e.description):''}</div></div>
<button class="ev-del" onclick="delClassEvent(${e.id})">🗑</button>
</div>`;
}).join('');
}
function renderFerien(data){
const el=document.getElementById('list-ferien');
if(!data.length){el.innerHTML='<div class="empty">Keine Termine eingetragen</div>';return;}
el.innerHTML=data.map(e=>{
const diff=e.date?daysUntil(e.date):null;
const col=diff!==null&&diff<0?'var(--text-subtle)':'var(--green)';
return `<div class="ev-item">
<div class="ev-days" style="color:${col}">${diff===null?'':diff<0?'✓':diff}</div>
<div class="ev-info"><div class="ev-title">${esc(e.title)}</div><div class="ev-date">${e.date?fmtDate(e.date):''}${e.date_end?' '+fmtDate(e.date_end):''}</div></div>
<button class="ev-del" onclick="delClassEvent(${e.id})">🗑</button>
</div>`;
}).join('');
}
async function delClassEvent(id){
await api('DELETE','class-events/'+id);
loadClassEvents();
toast('Gelöscht');
}
// ── HOMEWORK ──────────────────────────────────────────────────
function renderHW(data){
const el=document.getElementById('list-hw');
if(!data.length){el.innerHTML='<div class="empty">Keine Hausaufgaben</div>';return;}
const today=new Date(); today.setHours(0,0,0,0);
el.innerHTML=[...data].sort((a,b)=>(a.due_date||'9999').localeCompare(b.due_date||'9999')).map(h=>{
const due=h.due_date?new Date(h.due_date):null;
let badge='';
if(due&&!h.done){due.setHours(0,0,0,0);const d=Math.round((due-today)/86400000);
badge=d<0?'<span class="badge badge-red">Überfällig</span>':d===0?'<span class="badge badge-red">Heute</span>':d<=7?`<span class="badge badge-orange">in ${d}d</span>`:`<span class="badge badge-blue">in ${d}d</span>`;}
return `<div class="hw-item">
<div class="check ${h.done?'done':''}" onclick="toggle('homework',${h.id},${h.done})">${h.done?'✓':''}</div>
<div class="hw-body">
<div class="hw-title ${h.done?'crossed':''}">${esc(h.title)}</div>
<div class="hw-meta">${esc(h.subject||'')}${h.due_date?' · '+fmtDate(h.due_date):''} ${badge}</div>
</div>
<button class="del-btn" onclick="delItem('homework',${h.id})">🗑</button>
</div>`;
}).join('');
}
// ── 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=>{
const gn=Math.round(g.grade||0);
const typeName=GRADE_TYPES[g.type]||'Sonstiges';
return `<div class="grade-row">
<div class="grade-info">
<div class="grade-subj">${esc(g.subject)}</div>
<div class="grade-type">${typeName}${g.note?' · '+esc(g.note):''}</div>
</div>
<div class="grade-val g${Math.min(gn,6)}">${g.grade}</div>
<button class="del-btn" onclick="delItem('grades',${g.id})">🗑</button>
</div>`;
}).join('');
// Weighted average
const valid=data.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;
const avg=valid.length?(valid.reduce((s,g)=>s+g.grade,0)/valid.length).toFixed(2):null;
const avgBlock=avg?`<div class="grade-avg">
<div><div class="grade-avg-label">Ø gewichtet</div><div class="grade-avg-sub">ungewichtet: ${avg}</div></div>
<div class="grade-avg-val">${wavg.toFixed(2)}</div>
</div>`:'';
el.innerHTML=rows+avgBlock;
}
// ── ABSENCES ──────────────────────────────────────────────────
async function loadAbsencesCard(){
const el=document.getElementById('list-ab');
el.innerHTML='<div class="sk-line sk"></div>';
if(currentRole==='teacher') await loadStudentListForAbsencesCard();
const data=currentRole==='teacher'
? await api('GET','teacher/absences')
: await api('GET','absences');
renderAbsences(Array.isArray(data)?data:[]);
}
function renderAbsences(data){
const el=document.getElementById('list-ab');
if(!data.length){el.innerHTML='<div class="empty">Keine Fehlzeiten eingetragen</div>';return;}
const sorted=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||''));
if(currentRole==='teacher'){
el.innerHTML=sorted.map(a=>`<div class="ab-item">
<div class="ab-date">${a.date?fmtDate(a.date):''}</div>
<div class="ab-info">
<div class="ab-subj"><strong>${esc(a.student_name||'')}</strong>${a.subject?' · '+esc(a.subject):''}</div>
<div class="ab-reason">${esc(a.reason||'')}</div>
</div>
<button class="del-btn" onclick="delTeacherAbsenceCard(${a.id})">🗑</button>
</div>`).join('')+`<div class="ab-total">${data.length} Fehlzeit${data.length!==1?'en':''} gesamt</div>`;
} else {
el.innerHTML=sorted.map(a=>`<div class="ab-item">
<div class="ab-date">${a.date?fmtDate(a.date):''}</div>
<div class="ab-info"><div class="ab-subj">${esc(a.subject||'')}</div><div class="ab-reason">${esc(a.reason||'')}</div></div>
</div>`).join('')+`<div class="ab-total">${data.length} Fehlzeit${data.length!==1?'en':''} gesamt</div>`;
}
}
async function loadStudentListForAbsencesCard(){
const students=await api('GET','teacher/students');
if(!Array.isArray(students))return;
['ab-student-card','ab-student'].forEach(id=>{
const sel=document.getElementById(id);
if(!sel)return;
const cur=sel.value;
sel.innerHTML='<option value="">Schüler auswählen…</option>'+students.map(s=>`<option value="${s.id}">${esc(s.username)}</option>`).join('');
if(cur)sel.value=cur;
});
}
async function addAbsenceFromCard(){
const student_id=parseInt(document.getElementById('ab-student-card').value,10);
const date=document.getElementById('ab-date-card').value;
const subject=document.getElementById('ab-subject-card').value.trim();
const reason=document.getElementById('ab-reason-card').value.trim();
if(!student_id){toast('Schüler auswählen','error');return;}
const r=await api('POST','teacher/absences',{student_id,date:date||null,subject:subject||null,reason:reason||null});
if(r.error){toast(r.error,'error');return;}
document.getElementById('ab-date-card').value='';
document.getElementById('ab-subject-card').value='';
document.getElementById('ab-reason-card').value='';
toast('Fehlzeit eingetragen ✓','success');
loadAbsencesCard();
}
async function delTeacherAbsenceCard(id){
await api('DELETE','teacher/absences/'+id);
toast('Gelöscht');
loadAbsencesCard();
}
// ── TODOS ─────────────────────────────────────────────────────
function renderTodos(data){
const el=document.getElementById('list-todo');
if(!data.length){el.innerHTML='<div class="empty">Keine Aufgaben</div>';return;}
el.innerHTML=data.map(t=>`<div class="todo-item">
<div class="todo-check ${t.done?'done':''}" onclick="toggle('todos',${t.id},${t.done})">${t.done?'✓':''}</div>
<div class="todo-title ${t.done?'crossed':''}">${esc(t.title)}</div>
<button class="del-btn" onclick="delItem('todos',${t.id})">🗑</button>
</div>`).join('');
}
// ── COUNTDOWNS ────────────────────────────────────────────────
function renderCountdowns(data){
const el=document.getElementById('list-cd');
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);
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>
</div>`;
}).join('');
}
// ── QUICKLINKS ────────────────────────────────────────────────
function renderQL(data){
if(!data.length)return;
const grid=document.getElementById('ql-grid');
data.forEach(l=>{
const a=document.createElement('a');
a.className='ql-item'; a.href=l.url; a.target='_blank'; a.rel='noopener';
a.innerHTML=`<span class="ql-icon">🔗</span>${esc(l.label)}<button class="ql-del" onclick="event.preventDefault();delItem('quicklinks',${l.id})">✕</button>`;
grid.appendChild(a);
});
}
// ── CRUD HELPERS ──────────────────────────────────────────────
async function delItem(type,id){
await api('DELETE',`${type}/${id}`);
loadAll(); toast('Gelöscht');
}
async function toggle(type,id,current){
await api('PUT',`${type}/${id}`,{done:current?0:1});
loadAll();
}
// ── MODAL CONFIG ──────────────────────────────────────────────
const MODALS={
homework:{title:'Hausaufgabe hinzufügen',fields:[
{n:'title',l:'Titel',t:'text'},{n:'subject',l:'Fach',t:'text'},{n:'due_date',l:'Fällig am',t:'date'}
]},
grades:{title:'Note hinzufügen',fields:[
{n:'subject',l:'Fach',t:'text'},
{n:'grade',l:'Note (16)',t:'number',min:1,max:6,step:0.1},
{n:'type',l:'Typ',t:'select',opts:Object.keys(GRADE_TYPES),labels:Object.values(GRADE_TYPES)},
{n:'note',l:'Anmerkung (optional)',t:'text'}
]},
todos:{title:'Aufgabe hinzufügen',fields:[{n:'title',l:'Aufgabe',t:'text'}]},
countdowns:{title:'Countdown hinzufügen',fields:[
{n:'title',l:'Bezeichnung',t:'text'},{n:'target_date',l:'Datum',t:'date'}
]},
quicklinks:{title:'Link hinzufügen',fields:[
{n:'label',l:'Bezeichnung',t:'text'},{n:'url',l:'URL',t:'url'}
]},
'class-events-pruefung':{title:'Prüfung eintragen',fields:[
{n:'title',l:'Bezeichnung',t:'text'},{n:'date',l:'Datum',t:'date'},{n:'description',l:'Anmerkung (optional)',t:'text'}
]},
'class-events-ferien':{title:'Ferienblock eintragen',fields:[
{n:'title',l:'Bezeichnung',t:'text'},{n:'date',l:'Von',t:'date'},{n:'date_end',l:'Bis (optional)',t:'date'}
]},
};
function openModal(type, sub){
const key = sub ? `${type}-${sub}` : type;
currentModal=type; currentModalSub=sub||null;
const cfg=MODALS[key];
document.getElementById('modal-title').textContent=cfg.title;
document.getElementById('modal-fields').innerHTML=cfg.fields.map(f=>{
if(f.t==='select'){
const opts=(f.opts||[]).map((o,i)=>`<option value="${esc(o)}">${esc(f.labels?f.labels[i]:o)}</option>`).join('');
return `<select name="${f.n}"><option value=""> ${f.l} </option>${opts}</select>`;
}
const ex=f.min!=null?` min="${f.min}" max="${f.max}" step="${f.step}"`:'';
return `<input type="${f.t}" name="${f.n}" placeholder="${f.l}"${ex}>`;
}).join('');
document.getElementById('modal-overlay').style.display='flex';
document.querySelector('#modal-fields input, #modal-fields select')?.focus();
}
function closeModal(){document.getElementById('modal-overlay').style.display='none';currentModal=null;currentModalSub=null;}
async function saveModal(){
if(!currentModal)return;
const key=currentModalSub?`${currentModal}-${currentModalSub}`:currentModal;
const cfg=MODALS[key];
const body={};
if(currentModalSub) body.type=currentModalSub;
cfg.fields.forEach(f=>{body[f.n]=document.querySelector(`#modal-fields [name="${f.n}"]`)?.value||null;});
await api('POST', currentModal==='class-events'?'class-events':currentModal, body);
closeModal();
if(currentModal==='class-events'||['pruefung','ferien'].includes(currentModalSub)) loadClassEvents();
else loadAll();
toast('Gespeichert ✓','success');
}
// ── SETTINGS ──────────────────────────────────────────────────
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();
loadSubjectsDatalist();
}
}
async function loadSubjectsDatalist(){
const subjects=await api('GET','subjects');
if(!Array.isArray(subjects))return;
const dl=document.getElementById('subjects-datalist');
dl.innerHTML=subjects.map(s=>`<option value="${esc(s.name)}">`).join('');
const sel=document.getElementById('subject-add-select');
if(sel) sel.innerHTML='<option value="">Vorhandenes Fach hinzufügen…</option>'+subjects.map(s=>`<option value="${esc(s.name)}">${esc(s.name)}</option>`).join('');
}
async function loadMySubjects(){
const mine=await api('GET','teacher/my-subjects');
if(!Array.isArray(mine))return;
const el=document.getElementById('my-subjects-list');
if(!el)return;
el.innerHTML=mine.length?mine.map(s=>`<span style="display:inline-flex;align-items:center;gap:4px;background:var(--blue-100);color:#1d4ed8;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:500">${esc(s)}<button onclick="removeMySubject('${esc(s)}')" style="background:none;border:none;cursor:pointer;color:inherit;font-size:11px;padding:0;line-height:1;margin-left:2px">✕</button></span>`).join(''):'<span style="font-size:13px;color:var(--text-muted)">Noch keine Fächer hinzugefügt</span>';
}
async function addMySubject(){
const name=document.getElementById('subject-add-select').value;
if(!name){toast('Fach auswählen','error');return;}
const r=await api('POST','teacher/my-subjects',{name});
if(r.error){toast(r.error,'error');return;}
toast('Fach hinzugefügt ✓','success');
loadMySubjects();
}
async function removeMySubject(name){
await api('DELETE','teacher/my-subjects/'+encodeURIComponent(name));
loadMySubjects();
}
async function createNewSubject(){
const name=document.getElementById('subject-new').value.trim();
if(!name){toast('Name erforderlich','error');return;}
const r=await api('POST','teacher/subjects',{name});
if(r.error){toast(r.error,'error');return;}
document.getElementById('subject-new').value='';
toast('Fach erstellt & hinzugefügt ✓','success');
loadMySubjects();
loadSubjectsDatalist();
}
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
async function load2FAStatus(){
const statusRow=document.getElementById('2fa-status-row');
['2fa-idle-area','2fa-enabled-area','2fa-setup-area','2fa-disable-area','2fa-codes-area','2fa-regen-area'].forEach(id=>{
document.getElementById(id).style.display='none';
});
try {
const r=await api('GET','2fa/status');
statusRow.textContent='';
if(r.enabled){
const n=r.recovery_codes_remaining;
document.getElementById('2fa-rc-count').textContent=n+' Wiederherstellungscode'+(n!==1?'s':'')+' verbleibend';
document.getElementById('2fa-enabled-area').style.display='';
} else {
document.getElementById('2fa-idle-area').style.display='';
}
} catch(e) {
statusRow.textContent='Fehler beim Laden.';
}
}
async function setup2FA(){
const pw=prompt('Zur Bestätigung bitte dein Passwort eingeben:');
if(!pw)return;
document.getElementById('2fa-idle-area').style.display='none';
document.getElementById('2fa-status-row').textContent='QR-Code wird generiert…';
try {
const r=await api('POST','2fa/setup',{password:pw});
document.getElementById('2fa-status-row').textContent='';
if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;}
document.getElementById('2fa-qr').src=r.qr;
document.getElementById('2fa-secret').textContent=r.secret;
document.getElementById('2fa-confirm-code').value='';
document.getElementById('2fa-setup-area').style.display='';
document.getElementById('2fa-confirm-code').focus();
} catch(e) {
document.getElementById('2fa-status-row').textContent='';
toast('Fehler beim Generieren des QR-Codes','error');
document.getElementById('2fa-idle-area').style.display='';
}
}
function cancel2FASetup(){
document.getElementById('2fa-setup-area').style.display='none';
document.getElementById('2fa-idle-area').style.display='';
}
async function confirm2FA(){
const code=document.getElementById('2fa-confirm-code').value.trim();
if(!code){toast('Code eingeben','error');return;}
const r=await api('POST','2fa/confirm',{token:code});
if(r.ok){
toast('2FA aktiviert ✓','success');
document.getElementById('2fa-setup-area').style.display='none';
showRecoveryCodes(r.codes);
} else {
toast(r.error,'error');
}
}
let _recoveryCodes=[];
function showRecoveryCodes(codes){
_recoveryCodes=codes;
const grid=document.getElementById('2fa-codes-grid');
grid.innerHTML=codes.map(c=>`<div style="padding:4px 6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;letter-spacing:.05em">${esc(c)}</div>`).join('');
document.getElementById('2fa-codes-area').style.display='';
}
function copyRecoveryCodes(){
navigator.clipboard.writeText(_recoveryCodes.join('\n')).then(()=>toast('Codes kopiert','success'));
}
function downloadRecoveryCodes(){
const text='Klassenportal Wiederherstellungscodes\n'+new Date().toLocaleString('de-DE')+'\n\n'+_recoveryCodes.join('\n')+'\n\nJeder Code kann nur einmal verwendet werden.';
const a=document.createElement('a');
a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(text);
a.download='klassenportal-wiederherstellungscodes.txt';
a.click();
}
async function dismissRecoveryCodes(){
_recoveryCodes=[];
document.getElementById('2fa-codes-area').style.display='none';
await load2FAStatus();
}
function showRegenCodes(){
document.getElementById('2fa-enabled-area').style.display='none';
document.getElementById('2fa-regen-code').value='';
document.getElementById('2fa-regen-area').style.display='';
document.getElementById('2fa-regen-code').focus();
}
function cancelRegenCodes(){
document.getElementById('2fa-regen-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display='';
}
async function doRegenCodes(){
const code=document.getElementById('2fa-regen-code').value.trim();
if(!code){toast('Code eingeben','error');return;}
const pw=prompt('Zur Bestätigung bitte dein Passwort eingeben:');
if(!pw)return;
const r=await api('POST','2fa/regenerate-codes',{token:code,password:pw});
if(r.ok){
document.getElementById('2fa-regen-area').style.display='none';
showRecoveryCodes(r.codes);
} else {
toast(r.error,'error');
}
}
function showDisable2FA(){
document.getElementById('2fa-enabled-area').style.display='none';
document.getElementById('2fa-disable-pw').value='';
document.getElementById('2fa-disable-code').value='';
document.getElementById('2fa-disable-area').style.display='';
}
function cancel2FADisable(){
document.getElementById('2fa-disable-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display='';
}
async function disable2FA(){
const pw=document.getElementById('2fa-disable-pw').value;
const code=document.getElementById('2fa-disable-code').value.trim();
if(!pw||!code){toast('Passwort und Code erforderlich','error');return;}
const r=await api('POST','2fa/disable',{password:pw,token:code});
if(r.ok){
toast('2FA deaktiviert','success');
document.getElementById('2fa-disable-area').style.display='none';
document.getElementById('2fa-idle-area').style.display='';
} else {
toast(r.error,'error');
}
}
async function changePassword(){
const cp=document.getElementById('pw-current').value;
const np=document.getElementById('pw-new').value;
const r=await api('PUT','me/password',{currentPassword:cp,newPassword:np});
if(r.ok){toast('Passwort geändert','success');document.getElementById('pw-current').value='';document.getElementById('pw-new').value='';}
else toast(r.error,'error');
}
async function deleteAccount(){
const pw=document.getElementById('pw-delete').value;
if(!confirm('Account und alle Daten wirklich löschen? Dies kann nicht rückgängig gemacht werden.'))return;
const r=await api('DELETE','me',{password:pw});
if(r.ok){location.reload();}
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;
let chatMyUsername = '';
function chatFmtTime(ts) {
if (!ts) return '';
const d = new Date(ts + 'Z');
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
async function renderChatMsg(m, append) {
const el = document.getElementById('chat-msgs');
const isOwn = m.username === chatMyUsername;
const div = document.createElement('div');
div.className = 'chat-msg';
div.dataset.id = m.id;
const displayContent = m.content;
div.innerHTML = `<div class="chat-msg-meta">
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
<button class="chat-msg-del" onclick="delChatMsg(${m.id})" title="Löschen">✕</button>
</div>
<div class="chat-msg-body">${esc(displayContent)}</div>`;
if (append) {
el.appendChild(div);
el.scrollTop = el.scrollHeight;
} else {
el.prepend(div);
}
}
async function loadChat() {
const msgs = await api('GET', 'chat');
const el = document.getElementById('chat-msgs');
el.innerHTML = '';
for (const m of msgs) await renderChatMsg(m, true);
if (msgs.length) chatLastId = msgs[msgs.length - 1].id;
}
async function pollChat() {
try {
const msgs = await api('GET', 'chat?after=' + chatLastId);
for (const m of msgs) {
await renderChatMsg(m, true);
chatLastId = Math.max(chatLastId, m.id);
}
} catch {}
chatPollTimer = setTimeout(pollChat, 3000);
}
async function sendChatMsg() {
const inp = document.getElementById('chat-input');
const content = inp.value.trim();
if (!content) return;
inp.value = '';
const r = await api('POST', 'chat', { content });
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
await renderChatMsg(r, true);
chatLastId = Math.max(chatLastId, r.id);
}
async function delChatMsg(id) {
if (!confirm('Nachricht wirklich löschen?')) return;
const r = await api('DELETE', 'chat/' + id);
if (r.error) { toast(r.error, 'error'); return; }
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
}
async function initChat(username) {
chatMyUsername = username;
await loadChat();
pollChat();
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); }
});
}
// ── FILE STORAGE ──────────────────────────────────────────────
let filesData = { files: [], used: 0, quota: 2 * 1024 * 1024 * 1024 };
function fmtBytes(b) {
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB';
if (b >= 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB';
if (b >= 1024) return (b / 1024).toFixed(1) + ' KB';
return b + ' B';
}
function fileIcon(mime) {
if (mime.startsWith('image/')) return '🖼️';
if (mime === 'application/pdf') return '📕';
if (mime.includes('spreadsheet') || mime.includes('excel') || mime === 'text/csv') return '📊';
if (mime.includes('presentation') || mime.includes('powerpoint')) return '📋';
if (mime.includes('word') || mime.includes('document')) return '📝';
if (['application/zip','application/vnd.rar','application/x-7z-compressed','application/x-tar','application/gzip'].includes(mime)) return '🗜️';
if (mime === 'application/json' || mime === 'application/xml' || mime === 'text/xml') return '🔧';
return '📄';
}
async function loadFiles() {
try {
const r = await fetch('/api/files');
if (!r.ok) return;
filesData = await r.json();
renderFiles();
} catch {}
}
function renderFiles() {
const el = document.getElementById('files-body');
const pct = filesData.quota > 0 ? Math.min(100, filesData.used / filesData.quota * 100) : 0;
const fillClass = pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : '';
const quotaHtml = `<div class="files-quota">
<div class="files-quota-bar"><div class="files-quota-fill ${fillClass}" style="width:${pct.toFixed(1)}%"></div></div>
<div class="files-quota-text"><span>${fmtBytes(filesData.used)} genutzt</span><span>${fmtBytes(filesData.quota)} Speicher</span></div>
</div>`;
const dropZone = `<div class="upload-drop" id="files-drop">Dateien hier ablegen oder oben hochladen</div>`;
if (!filesData.files.length) {
el.innerHTML = quotaHtml + dropZone + '<div class="empty">Noch keine Dateien hochgeladen</div>';
} else {
el.innerHTML = quotaHtml + dropZone + filesData.files.map(f => `<div class="file-item">
<div class="file-icon">${fileIcon(f.mime_type)}</div>
<div class="file-info">
<div class="file-name" title="${esc(f.original_name)}">${esc(f.original_name)}</div>
<div class="file-meta">${fmtBytes(f.size)} · ${fmtDate(f.created_at ? f.created_at.slice(0,10) : '')}</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="file-dl" onclick="downloadFile(${f.id})">↓ Laden</button>
<button class="del-btn" onclick="deleteFile(${f.id})">🗑</button>
</div>
</div>`).join('');
}
initDropZone();
}
function initDropZone() {
const zone = document.getElementById('files-drop');
if (!zone) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag');
const files = Array.from(e.dataTransfer.files);
if (files.length) uploadFileList(files);
});
}
function downloadFile(id) {
window.location.href = '/api/files/' + id + '/download';
}
async function deleteFile(id) {
if (!confirm('Datei wirklich löschen?')) return;
const r = await fetch('/api/files/' + id, { method: 'DELETE' });
const d = await r.json();
if (d.error) { toast(d.error, 'error'); return; }
toast('Datei gelöscht');
loadFiles();
}
async function uploadFiles(event) {
const files = Array.from(event.target.files);
document.getElementById('file-input').value = '';
if (!files.length) return;
await uploadFileList(files);
}
async function uploadFileList(files) {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch('/api/files', { method: 'POST', body: fd });
const d = await r.json();
if (d.error) toast(d.error, 'error');
else toast(`${esc(file.name)} hochgeladen ✓`);
} catch {
toast('Upload fehlgeschlagen', 'error');
}
}
loadFiles();
}
// ── SUPPORT TICKETS ───────────────────────────────────────────
let ticketsData = [];
let activeTicket = null;
let myUserId = null;
function fmtDateTime(s){
if(!s)return'';
const d=new Date(s);
return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})+' '+d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
}
async function loadTickets(){
const r = await api('GET','tickets');
if(r.error) return;
ticketsData = r;
renderTickets();
}
function ticketStatusBadge(s){
const m={open:'badge-red',in_progress:'badge-orange',closed:'badge-green'};
const l={open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'};
return `<span class="badge ${m[s]||'badge-gray'}">${l[s]||esc(s)}</span>`;
}
function renderTickets(){
const el=document.getElementById('list-tickets');
if(!ticketsData.length){ el.innerHTML='<div class="empty">Noch keine Tickets erstellt</div>'; return; }
el.innerHTML=ticketsData.map(t=>`
<div class="ticket-list-item" onclick="openThread(${t.id})">
<div style="flex:1;min-width:0">
<div class="ticket-list-subj">${esc(t.subject)}</div>
<div class="ticket-list-meta">${fmtDateTime(t.created_at)}</div>
</div>
${ticketStatusBadge(t.status)}
</div>`).join('');
}
function openNewTicket(){
document.getElementById('new-ticket-subject').value='';
document.getElementById('new-ticket-message').value='';
document.getElementById('new-ticket-overlay').style.display='flex';
setTimeout(()=>document.getElementById('new-ticket-subject').focus(),50);
}
function closeNewTicket(){ document.getElementById('new-ticket-overlay').style.display='none'; }
async function submitNewTicket(){
const subject=document.getElementById('new-ticket-subject').value.trim();
const message=document.getElementById('new-ticket-message').value.trim();
if(!subject||!message){ toast('Betreff und Nachricht erforderlich','error'); return; }
const r=await api('POST','tickets',{subject,message});
if(r.error){ toast(r.error,'error'); return; }
closeNewTicket();
toast('Ticket erstellt');
await loadTickets();
openThread(r.id);
}
async function openThread(ticketId){
const t=ticketsData.find(x=>x.id===ticketId); if(!t) return;
activeTicket=t;
document.getElementById('thread-title').textContent=t.subject;
document.getElementById('thread-meta').textContent=`Status: ${({open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'}[t.status]||t.status)} · Erstellt: ${fmtDateTime(t.created_at)}`;
document.getElementById('thread-msgs').innerHTML='<div class="empty" style="padding:8px 0">Laden…</div>';
const ra=document.getElementById('thread-reply-area');
ra.style.display=t.status==='closed'?'none':'flex';
document.getElementById('thread-overlay').style.display='flex';
const msgs=await api('GET',`tickets/${ticketId}/messages`);
renderThread(t, msgs.error?[]:msgs);
}
function renderThread(ticket, messages){
const wrap=document.getElementById('thread-msgs');
const rows=[];
// Initial message from ticket itself
rows.push(`<div style="display:flex;flex-direction:column;align-items:flex-end">
<div class="thread-bubble mine">${esc(ticket.message)}</div>
<div class="thread-bubble-meta" style="text-align:right">${fmtDateTime(ticket.created_at)}</div>
</div>`);
messages.forEach(m=>{
const isAdmin=m.role==='admin';
const isMine=!isAdmin;
rows.push(`<div style="display:flex;flex-direction:column;align-items:${isMine?'flex-end':'flex-start'}">
<div class="thread-bubble ${isMine?'mine':'other'}${isAdmin?' admin-bubble':''}">${esc(m.message)}</div>
<div class="thread-bubble-meta" style="text-align:${isMine?'right':'left'}">${isAdmin?'🛡️ Admin · ':''}${fmtDateTime(m.created_at)}</div>
</div>`);
});
if(!rows.length){ wrap.innerHTML='<div class="empty" style="padding:8px 0">Noch keine Nachrichten</div>'; return; }
wrap.innerHTML=rows.join('');
wrap.scrollTop=wrap.scrollHeight;
}
async function sendThreadReply(){
if(!activeTicket) return;
const inp=document.getElementById('thread-input');
const message=inp.value.trim();
if(!message){ toast('Nachricht darf nicht leer sein','error'); return; }
const r=await api('POST',`tickets/${activeTicket.id}/messages`,{message});
if(r.error){ toast(r.error,'error'); return; }
inp.value='';
await loadTickets();
const t=ticketsData.find(x=>x.id===activeTicket.id);
if(t){ activeTicket=t; document.getElementById('thread-meta').textContent=`Status: ${({open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'}[t.status]||t.status)} · Erstellt: ${fmtDateTime(t.created_at)}`; }
const msgs=await api('GET',`tickets/${activeTicket.id}/messages`);
renderThread(activeTicket, msgs.error?[]:msgs);
}
function closeThread(){
document.getElementById('thread-overlay').style.display='none';
activeTicket=null;
}
// ── DARK MODE ─────────────────────────────────────────────────
function toggleDark(){
const dark=document.body.classList.toggle('dark');
localStorage.setItem('dark',dark?'1':'');
document.getElementById('btn-dark').textContent=dark?'☀️':'🌙';
}
if(localStorage.getItem('dark')){
document.body.classList.add('dark');
document.getElementById('btn-dark').textContent='☀️';
}
// ── SIDEBAR (small screens overlay) ──────────────────────────
function openSidebar(){
document.getElementById('app-sidebar').classList.add('open');
document.getElementById('sidebar-backdrop').classList.add('open');
document.body.style.overflow='hidden';
}
function closeSidebar(){
document.getElementById('app-sidebar').classList.remove('open');
document.getElementById('sidebar-backdrop').classList.remove('open');
document.body.style.overflow='';
}
// ── OVERLAY CARDS ─────────────────────────────────────────────
let _activeOverlay = null;
function openOverlay(id){
closeOverlay();
const card = document.getElementById(id);
if(!card) return;
card.style.display = 'flex';
card.classList.add('overlay-panel');
document.getElementById('fs-backdrop').classList.add('open');
document.body.style.overflow='hidden';
_activeOverlay = id;
lucide.createIcons();
}
function closeOverlay(){
if(_activeOverlay){
const card = document.getElementById(_activeOverlay);
if(card){ card.style.display='none'; card.classList.remove('overlay-panel'); }
_activeOverlay = null;
}
document.getElementById('fs-backdrop').classList.remove('open');
document.body.style.overflow='';
}
// kept for any legacy calls
function toggleFullscreen(id){ openOverlay(id); }
function collapseAllCards(){ closeOverlay(); }
// ── KEYBOARD ──────────────────────────────────────────────────
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeModal();closeSettings();closeSidebar();closeOverlay();closeThread();closeNewTicket();}
});
// ── TEACHER PANEL ─────────────────────────────────────────────
function teacherTab(name){
const names=['faecher','materials','announcements','exams','grades','timetable','absences'];
document.querySelectorAll('.t-tab').forEach((t,i)=>t.classList.toggle('active',names[i]===name));
document.querySelectorAll('.t-pane').forEach(p=>p.classList.remove('active'));
document.getElementById('t-pane-'+name).classList.add('active');
if(name==='faecher') loadTFaecher();
if(name==='grades'){loadStudentListForGrades();populateTeacherSubjectSelects();}
if(name==='timetable'){loadTeacherTimetable();populateTimetableTimeSelect();}
if(name==='absences'){loadTeacherAbsences();loadStudentListForAbsences();}
}
async function loadTeacherPanel(){
loadTFaecher();
}
async function loadTFaecher(){
const [all, mine]=await Promise.all([api('GET','subjects'),api('GET','teacher/my-subjects')]);
const mySet=new Set(Array.isArray(mine)?mine:[]);
// populate "add existing" select
const sel=document.getElementById('t-subject-add');
if(sel){
const opts=Array.isArray(all)?all.filter(s=>!mySet.has(s.name)).map(s=>`<option value="${esc(s.name)}">${esc(s.name)}</option>`).join(''):'';
sel.innerHTML='<option value="">Vorhandenes Fach hinzufügen…</option>'+opts;
}
// render current subjects as removable badges
const el=document.getElementById('t-my-subjects-list');
if(el){
el.innerHTML=mySet.size?[...mySet].map(s=>`<span style="display:inline-flex;align-items:center;gap:4px;background:var(--blue-100);color:#1d4ed8;border-radius:12px;padding:3px 10px;font-size:12px;font-weight:500">${esc(s)}<button onclick="tRemoveSubject('${esc(s)}')" style="background:none;border:none;cursor:pointer;color:inherit;font-size:12px;padding:0;line-height:1;margin-left:2px">✕</button></span>`).join(''):'<span style="font-size:13px;color:var(--text-muted)">Noch keine Fächer hinzugefügt</span>';
}
// also refresh the other pane selects
populateTeacherSubjectSelects(mine);
}
async function populateTeacherSubjectSelects(mine){
if(!mine) mine=await api('GET','teacher/my-subjects');
if(!Array.isArray(mine))return;
const opts='<option value="">Fach auswählen…</option>'+mine.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
['mat-subject','ann-subject','exam-subject','grade-subject'].forEach(id=>{
const el=document.getElementById(id);
if(el){const v=el.value;el.innerHTML=opts;if(v)el.value=v;}
});
}
async function tAddMySubject(){
const name=document.getElementById('t-subject-add').value;
if(!name){toast('Fach auswählen','error');return;}
const r=await api('POST','teacher/my-subjects',{name});
if(r.error){toast(r.error,'error');return;}
toast('Hinzugefügt ✓','success');
loadTFaecher();
}
async function tCreateSubject(){
const name=document.getElementById('t-subject-new').value.trim();
if(!name){toast('Name eingeben','error');return;}
const r=await api('POST','teacher/subjects',{name});
if(r.error){toast(r.error,'error');return;}
document.getElementById('t-subject-new').value='';
toast(name+' erstellt & hinzugefügt ✓','success');
loadTFaecher();
loadSubjectsDatalist();
}
async function tRemoveSubject(name){
await api('DELETE','teacher/my-subjects/'+encodeURIComponent(name));
loadTFaecher();
}
function populateTimetableTimeSelect(){
const sel=document.getElementById('tt-time');
if(sel.options.length>1)return;
TIME_SLOTS.forEach(s=>{
const o=document.createElement('option');
o.value=s.key; o.textContent=`${s.label}. Stunde ${s.time}`;
sel.appendChild(o);
});
}
async function loadTeacherTimetable(){
const data=await api('GET','teacher/class-timetable?class_id=info1');
const el=document.getElementById('list-teacher-timetable');
if(!Array.isArray(data)||!data.length){el.innerHTML='<div class="empty">Keine Einträge</div>';return;}
el.innerHTML=data.map(e=>`<div class="ev-item">
<div class="ev-info">
<div class="ev-title">${esc(e.day)} · ${esc(e.time_start||'')}${e.time_end?''+esc(e.time_end):''}</div>
<div class="ev-date">${esc(e.subject||'')}${e.room?' · '+esc(e.room):''}</div>
</div>
<button class="ev-del" onclick="delTeacherTimetableEntry(${e.id})">🗑</button>
</div>`).join('');
}
async function addTimetableEntry(){
const day=document.getElementById('tt-day').value;
const time_start=document.getElementById('tt-time').value;
const subject=document.getElementById('tt-subject').value.trim();
const room=document.getElementById('tt-room').value.trim();
if(!day||!time_start){toast('Tag und Stunde erforderlich','error');return;}
const r=await api('POST','teacher/class-timetable',{day,time_start,subject:subject||null,room:room||null,class_id:'info1'});
if(r.error){toast(r.error,'error');return;}
document.getElementById('tt-subject').value='';
document.getElementById('tt-room').value='';
toast('Eingetragen ✓','success');
loadTeacherTimetable();
loadAll();
}
async function delTeacherTimetableEntry(id){
await api('DELETE','teacher/class-timetable/'+id);
toast('Gelöscht');
loadTeacherTimetable();
loadAll();
}
async function loadTeacherAbsences(){
const data=await api('GET','teacher/absences');
const el=document.getElementById('list-teacher-absences');
if(!Array.isArray(data)||!data.length){el.innerHTML='<div class="empty">Keine Fehlzeiten</div>';return;}
el.innerHTML=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(a=>`<div class="ab-item">
<div class="ab-date">${a.date?fmtDate(a.date):''}</div>
<div class="ab-info">
<div class="ab-subj"><strong>${esc(a.student_name)}</strong> · ${esc(a.subject||'')}</div>
<div class="ab-reason">${esc(a.reason||'')}</div>
</div>
<button class="del-btn" onclick="delTeacherAbsence(${a.id})">🗑</button>
</div>`).join('');
}
async function loadStudentListForAbsences(){
const students=await api('GET','teacher/students');
if(students.error)return;
const sel=document.getElementById('ab-student');
const cur=sel.value;
sel.innerHTML='<option value="">Schüler auswählen…</option>'+students.map(s=>`<option value="${s.id}">${esc(s.username)}</option>`).join('');
if(cur)sel.value=cur;
}
async function addStudentAbsence(){
const student_id=parseInt(document.getElementById('ab-student').value,10);
const date=document.getElementById('ab-date').value;
const subject=document.getElementById('ab-subject').value.trim();
const reason=document.getElementById('ab-reason').value.trim();
if(!student_id){toast('Schüler auswählen','error');return;}
const r=await api('POST','teacher/absences',{student_id,date:date||null,subject:subject||null,reason:reason||null});
if(r.error){toast(r.error,'error');return;}
document.getElementById('ab-date').value='';
document.getElementById('ab-subject').value='';
document.getElementById('ab-reason').value='';
toast('Fehlzeit eingetragen ✓','success');
loadTeacherAbsences();
}
async function delTeacherAbsence(id){
await api('DELETE','teacher/absences/'+id);
toast('Gelöscht');
loadTeacherAbsences();
}
async function loadTeacherMaterials(){
const mats=await api('GET','teacher/materials');
const el=document.getElementById('list-teacher-materials');
if(mats.error||!mats.length){el.innerHTML='<div class="empty">Keine Materialien hochgeladen</div>';return;}
el.innerHTML=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)} <span style="font-size:11px;color:var(--text-muted)">(${esc(m.original_name)})</span></div>
<div class="file-meta">${esc(m.subject)} · ${fmtBytes(m.size)} · ${fmtDate(m.created_at?m.created_at.slice(0,10):'')}</div>
</div>
<button class="file-dl" onclick="window.location.href='/api/teacher/materials/${m.id}/download'">↓ Laden</button>
<button class="del-btn" onclick="delTeacherMaterial(${m.id})">🗑</button>
</div>`).join('');
}
async function uploadTeacherMaterial(e){
const files=e.target.files;
if(!files.length)return;
const title=document.getElementById('mat-title').value.trim();
const subject=document.getElementById('mat-subject').value;
if(!title){toast('Bitte einen Titel eingeben','error');e.target.value='';return;}
if(!subject){toast('Bitte ein Fach auswählen','error');e.target.value='';return;}
const fd=new FormData();
fd.append('file',files[0]);
fd.append('title',title);
fd.append('subject',subject);
fd.append('class_id','info1');
e.target.value='';
const r=await fetch('/api/teacher/materials',{method:'POST',body:fd});
const d=await r.json();
if(!r.ok){toast(d.error||'Upload fehlgeschlagen','error');return;}
document.getElementById('mat-title').value='';
toast('Material hochgeladen');
loadTeacherMaterials();
}
async function delTeacherMaterial(id){
await api('DELETE','teacher/materials/'+id);
toast('Gelöscht');
loadTeacherMaterials();
}
async function loadTeacherAnnouncements(){
const anns=await api('GET','teacher/announcements');
const el=document.getElementById('list-teacher-announcements');
if(anns.error||!anns.length){el.innerHTML='<div class="empty">Keine Ankündigungen</div>';return;}
el.innerHTML=anns.map(a=>`<div class="hw-item">
<div class="hw-body">
<div class="hw-title">${esc(a.title)}</div>
<div class="hw-meta">${esc(a.subject)} · ${fmtDate(a.created_at?a.created_at.slice(0,10):'')}</div>
<div style="font-size:12px;color:var(--text-2);margin-top:4px;white-space:pre-wrap;word-break:break-word">${esc(a.content)}</div>
</div>
<button class="del-btn" onclick="delTeacherAnnouncement(${a.id})">🗑</button>
</div>`).join('');
}
async function createAnnouncement(){
const title=document.getElementById('ann-title').value.trim();
const content=document.getElementById('ann-content').value.trim();
const subject=document.getElementById('ann-subject').value;
if(!title||!content){toast('Titel und Inhalt erforderlich','error');return;}
if(!subject){toast('Bitte ein Fach auswählen','error');return;}
const r=await api('POST','teacher/announcements',{title,content,subject,class_id:'info1'});
if(r.error){toast(r.error,'error');return;}
document.getElementById('ann-title').value='';
document.getElementById('ann-content').value='';
toast('Ankündigung erstellt');
loadTeacherAnnouncements();
}
async function delTeacherAnnouncement(id){
await api('DELETE','teacher/announcements/'+id);
toast('Gelöscht');
loadTeacherAnnouncements();
}
async function loadTeacherExams(){
const exams=await api('GET','teacher/exams');
const el=document.getElementById('list-teacher-exams');
if(exams.error||!exams.length){el.innerHTML='<div class="empty">Keine Prüfungen angelegt</div>';return;}
el.innerHTML=exams.map(ex=>{
const diff=ex.date?daysUntil(ex.date):null;
return `<div class="ev-item">
<div class="ev-days">${diff===null?'':diff<0?'✓':diff}</div>
<div class="ev-info">
<div class="ev-title">${esc(ex.title)}</div>
<div class="ev-date">${esc(ex.subject)}${ex.date?' · '+fmtDate(ex.date):''}${ex.description?' · '+esc(ex.description):''}</div>
</div>
<button class="ev-del" onclick="delTeacherExam(${ex.id})">🗑</button>
</div>`;
}).join('');
}
async function createExam(){
const title=document.getElementById('exam-title').value.trim();
const date=document.getElementById('exam-date').value;
const description=document.getElementById('exam-desc').value.trim();
const subject=document.getElementById('exam-subject').value;
if(!title){toast('Titel erforderlich','error');return;}
if(!subject){toast('Bitte ein Fach auswählen','error');return;}
const r=await api('POST','teacher/exams',{title,date:date||null,description:description||null,subject,class_id:'info1'});
if(r.error){toast(r.error,'error');return;}
document.getElementById('exam-title').value='';
document.getElementById('exam-date').value='';
document.getElementById('exam-desc').value='';
toast('Prüfung angelegt');
loadTeacherExams();
}
async function delTeacherExam(id){
await api('DELETE','teacher/exams/'+id);
toast('Gelöscht');
loadTeacherExams();
}
async function loadTeacherGrades(){
const grades=await api('GET','teacher/grades');
const el=document.getElementById('list-teacher-grades');
if(grades.error||!grades.length){el.innerHTML='<div class="empty">Keine Noten vergeben</div>';return;}
el.innerHTML=grades.map(g=>`<div class="grade-row">
<div class="grade-info">
<div class="grade-subj">${esc(g.student_name)}</div>
<div class="grade-type">${GRADE_TYPES[g.type]||g.type}${g.note?' · '+esc(g.note):''}</div>
</div>
<div class="grade-val g${Math.min(Math.round(g.grade),6)}">${g.grade}</div>
<button class="del-btn" onclick="delTeacherGrade(${g.id})">🗑</button>
</div>`).join('');
}
async function loadStudentListForGrades(){
const students=await api('GET','teacher/students');
if(students.error)return;
const sel=document.getElementById('grade-student');
const cur=sel.value;
sel.innerHTML='<option value="">Schüler auswählen…</option>'+students.map(s=>`<option value="${s.id}">${esc(s.username)}</option>`).join('');
if(cur)sel.value=cur;
}
async function assignGrade(){
const student_id=parseInt(document.getElementById('grade-student').value,10);
const grade=parseFloat(document.getElementById('grade-val').value);
const type=document.getElementById('grade-type').value;
const note=document.getElementById('grade-note').value.trim();
const subject=document.getElementById('grade-subject').value;
if(!subject){toast('Fach auswählen','error');return;}
if(!student_id){toast('Schüler auswählen','error');return;}
if(!grade||grade<1||grade>6){toast('Note muss zwischen 1 und 6 liegen','error');return;}
const r=await api('POST','teacher/grades',{student_id,grade,type,note:note||null,subject});
if(r.error){toast(r.error,'error');return;}
document.getElementById('grade-val').value='';
document.getElementById('grade-note').value='';
toast('Note vergeben');
loadTeacherGrades();
}
async function delTeacherGrade(id){
await api('DELETE','teacher/grades/'+id);
toast('Gelöscht');
loadTeacherGrades();
}
// ── STUDENT CLASS CONTENT ──────────────────────────────────────
async function loadStudentMaterials(){
const mats=await api('GET','teacher/materials/class/info1');
const el=document.getElementById('list-student-materials');
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>
<div class="file-meta">${esc(m.subject)} · ${esc(m.teacher_name)} · ${fmtBytes(m.size)} · ${fmtDate(m.created_at?m.created_at.slice(0,10):'')}</div>
</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(){
const anns=await api('GET','teacher/announcements/class/info1');
const el=document.getElementById('list-student-announcements');
if(!anns||anns.error||!anns.length){el.innerHTML='<div class="empty">Keine Ankündigungen</div>';return;}
el.innerHTML=anns.map(a=>`<div class="hw-item">
<div class="hw-body">
<div class="hw-title">${esc(a.title)}</div>
<div class="hw-meta">${esc(a.subject)} · ${esc(a.teacher_name)} · ${fmtDate(a.created_at?a.created_at.slice(0,10):'')}</div>
<div style="font-size:12px;color:var(--text-2);margin-top:4px;white-space:pre-wrap;word-break:break-word">${esc(a.content)}</div>
</div>
</div>`).join('');
}
// ── START ─────────────────────────────────────────────────────
lucide.createIcons();
init();
</script>
</body>
</html>