Files
ifb-schulapp/public/index.html
T
Simon ae789318ba feat: add teacher system with approval flow
- Teacher registration requires subject selection; account starts pending
- Admin approves/rejects via existing admin panel
- Teacher panel (Materialien, Ankündigungen, Prüfungen, Noten) visible only to approved teachers
- Students see class materials and announcements via sidebar overlays
- Teachers can assign grades to students (scoped to own subject)
- New tables: teacher_materials, teacher_announcements, teacher_exams, teacher_assigned_grades
- subject column added to users; included in JWT and /api/me
- requireTeacher middleware fetches fresh status+subject from DB on every request
- Login hint: username is the part of the school email before the @
2026-04-17 10:00:09 +02:00

2442 lines
105 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>IFB · 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>
<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">IFB-Berufsfachschule Rosenheim</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">
<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);
}
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');
}
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
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' });
}
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;
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(m.content)}</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 = '';
msgs.forEach(m => renderChatMsg(m, true));
if (msgs.length) chatLastId = msgs[msgs.length - 1].id;
}
async function pollChat() {
try {
const msgs = await api('GET', 'chat?after=' + chatLastId);
msgs.forEach(m => {
renderChatMsg(m, true);
chatLastId = Math.max(chatLastId, m.id);
});
} catch {}
chatPollTimer = setTimeout(pollChat, 3000);
}
async function sendChatMsg() {
const inp = document.getElementById('chat-input');
const content = inp.value.trim();
if (!content) return;
inp.value = '';
const r = await api('POST', 'chat', { content });
if (r.error) { toast(r.error, 'error'); return; }
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();
}
function initChat(username) {
chatMyUsername = username;
loadChat().then(() => 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>