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