Files
ifb-schulapp/public/app.html
T
Simon 5ff616e0d9 chore: remove school-brand impersonation from public pages
- Replace "IFB-Berufsfachschule Rosenheim" brand text with neutral "Klassenportal" labels in titles, brand headers, and footers.
- Rewrite privacy-policy responsible-party section to clarify this is a private, non-official project (no school/organization affiliation).
- Include prior uncommitted work on index.html and app.html.

Retain factual audience descriptors ("Nur für IFB-Schüler") and external links to the school website; these reference but do not impersonate.
2026-04-18 01:33:53 +02:00

2578 lines
112 KiB
HTML
Raw 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">
<title>INFO1 · 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>
<script src="/e2ee.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; }
/* ── 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; }
.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;
}
.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); }
/* ── 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>
</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">i1</div>
<div class="brand-text">
<span class="brand-sub">Klassenportal</span>
<span class="brand-name">INFO1</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');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="openModal('grades')">+ Note</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-grades"></div>
</div>
<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="add-btn" onclick="openModal('absences')">+ Eintragen</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-ab"></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">
<div class="t-tab active" 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>
<div id="t-pane-materials" class="t-pane active">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input type="text" id="mat-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:1;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">
<input type="text" id="ann-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200">
<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">
<input type="text" id="exam-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200">
<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-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>
</div>
<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>
<button class="add-btn" onclick="openModal('timetable')">+ Stunde</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">
<a class="ql-item" href="https://www.ifb-schulen.de" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="building-2" aria-hidden="true"></i></span>IFB Website</a>
<a class="ql-item" href="https://www.bib-info.de" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="book-open" aria-hidden="true"></i></span>Bibliothek</a>
</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>Hetzner-Server, Nürnberg, 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="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>
<!-- 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>
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button>
</div>
</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:'07:45', label:'1.', time:'07:45' },
{ key:'08:30', label:'2.', time:'08:30' },
{ key:'09:30', label:'3.', time:'09:30' },
{ key:'10:15', label:'4.', time:'10:15' },
{ key:'11:15', label:'5.', time:'11:15' },
{ key:'12:00', label:'6.', time:'12:00' },
{ key:'13:30', label:'7.', time:'13:30' },
{ key:'14:15', label:'8.', time:'14:15' },
];
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){
const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
return r.json();
}
// ── 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); }
}
function loginUI(username,id,role,subject){
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-grades','sb-nav-files','sb-nav-ab','sb-nav-chat','sb-nav-tickets'].forEach(id=>document.getElementById(id).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-only panel
if(role==='teacher'){
document.getElementById('sb-label-lehrer').style.display='';
document.getElementById('sb-nav-teacher').style.display='flex';
}
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>`;
loadAll();
initChat(username, id);
}
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,ab,td,cd,ql]=await Promise.all([
api('GET','timetable'),api('GET','homework'),api('GET','grades'),
api('GET','absences'),api('GET','todos'),api('GET','countdowns'),api('GET','quicklinks')
]);
renderTT(tt); renderHW(hw); renderGrades(gr);
renderAbsences(ab); 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?' · '+esc(lesson.teacher):''}</div><button class="tt-del" onclick="delItem('timetable',${lesson.id})">✕</button>`;
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){
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 ──────────────────────────────────────────────────
function renderAbsences(data){
const el=document.getElementById('list-ab');
if(!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">${esc(a.subject||'')}</div><div class="ab-reason">${esc(a.reason||'')}</div></div>
<button class="del-btn" onclick="delItem('absences',${a.id})">🗑</button>
</div>`).join('')+`<div class="ab-total">${data.length} Fehlzeit${data.length!==1?'en':''} gesamt</div>`;
}
// ── 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);
return `<div class="cd-item">
<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={
timetable:{title:'Stunde hinzufügen',fields:[
{n:'day',l:'Tag',t:'select',opts:DAYS},
{n:'time_start',l:'Uhrzeit',t:'select',opts:TIME_SLOTS.map(s=>s.key),labels:TIME_SLOTS.map(s=>`${s.label} Std. ${s.time}`)},
{n:'subject',l:'Fach',t:'text'},{n:'room',l:'Raum',t:'text'},{n:'teacher',l:'Lehrkraft',t:'text'}
]},
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'}
]},
absences:{title:'Fehlzeit eintragen',fields:[
{n:'date',l:'Datum',t:'date'},{n:'subject',l:'Fach / Stunde',t:'text'},{n:'reason',l:'Grund',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();
}
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
async function load2FAStatus(){
const statusRow=document.getElementById('2fa-status-row');
document.getElementById('2fa-idle-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display='none';
document.getElementById('2fa-setup-area').style.display='none';
document.getElementById('2fa-disable-area').style.display='none';
try {
const r=await api('GET','2fa/status');
statusRow.textContent='';
if(r.enabled){
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(){
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');
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';
document.getElementById('2fa-enabled-area').style.display='';
} 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');
}
// ── 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 = await E2EE.decrypt(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 = '';
let ciphertext;
try { ciphertext = await E2EE.encrypt(content); }
catch { toast('Verschlüsselung fehlgeschlagen', 'error'); inp.value = content; return; }
const r = await api('POST', 'chat', { content: ciphertext });
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, userId) {
chatMyUsername = username;
await E2EE.init(userId);
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=['materials','announcements','exams','grades'];
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==='grades') loadStudentListForGrades();
}
async function loadTeacherPanel(){
loadTeacherMaterials();
loadTeacherAnnouncements();
loadTeacherExams();
loadTeacherGrades();
}
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();
if(!title){toast('Bitte einen Titel eingeben','error');e.target.value='';return;}
const fd=new FormData();
fd.append('file',files[0]);
fd.append('title',title);
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();
if(!title||!content){toast('Titel und Inhalt erforderlich','error');return;}
const r=await api('POST','teacher/announcements',{title,content,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();
if(!title){toast('Titel erforderlich','error');return;}
const r=await api('POST','teacher/exams',{title,date:date||null,description:description||null,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();
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});
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');
if(!mats||mats.error||!mats.length){el.innerHTML='<div class="empty">Keine Materialien vorhanden</div>';return;}
el.innerHTML=mats.map(m=>`<div class="file-item">
<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('');
}
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 ─────────────────────────────────────────────────────
init();
</script>
</body>
</html>