Files
ifb-schulapp/public/index.html
T
2026-04-17 00:12:47 +02:00

1968 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IFB · INFO1 Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
:root {
--blue: #2563eb; --blue-d: #1d4ed8;
--blue-50: #eff6ff; --blue-100: #dbeafe;
--n-0: #ffffff;
--n-50: #f9fafb; --n-100: #f3f4f6; --n-150: #eaecf0;
--n-200: #e5e7eb; --n-300: #d1d5db; --n-400: #9ca3af;
--n-500: #6b7280; --n-600: #4b5563; --n-700: #374151;
--n-800: #1f2937; --n-900: #111827;
--green: #16a34a; --green-50: #f0fdf4;
--red: #dc2626; --red-50: #fef2f2;
--amber: #d97706; --amber-50: #fffbeb;
--bg: #f4f6f9;
--surface: #ffffff;
--surface-2: #f9fafb;
--border: #e5e7eb;
--border-subtle: #f0f2f5;
--text: #111827;
--text-2: #374151;
--text-muted: #6b7280;
--text-subtle: #9ca3af;
--shadow-xs: 0 1px 2px rgba(0,0,0,.04);
--shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow: 0 2px 8px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow-lg: 0 8px 24px rgba(0,0,0,.08), 0 2px 6px rgba(0,0,0,.04);
--shadow-xl: 0 20px 40px rgba(0,0,0,.1), 0 4px 8px rgba(0,0,0,.04);
--r-xs: 4px; --r-sm: 6px; --r: 10px; --r-lg: 12px; --r-xl: 16px;
}
body.dark {
--blue-50: #172554; --blue-100: #1e3a8a;
--n-50: #1a1f2e; --n-100: #202637; --n-150: #262d3f;
--n-200: #2d3548; --n-300: #3a4459; --n-400: #5d6d84;
--n-500: #7e90a8; --n-600: #9dafC5; --n-700: #b8cad9;
--n-800: #d4e0ec; --n-900: #eaf0f8;
--green-50: #052e16; --red-50: #300; --amber-50: #2d1a00;
--bg: #141820;
--surface: #1a1f2e;
--surface-2: #202637;
--border: #2d3548;
--border-subtle: #262d3f;
--text: #eaf0f8; --text-2: #b8cad9;
--text-muted: #7e90a8; --text-subtle: #5d6d84;
--shadow-xs: 0 1px 2px rgba(0,0,0,.25);
--shadow-sm: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2);
--shadow: 0 2px 8px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.2);
--shadow-lg: 0 8px 24px rgba(0,0,0,.45), 0 2px 6px rgba(0,0,0,.25);
--shadow-xl: 0 20px 40px rgba(0,0,0,.55), 0 4px 8px rgba(0,0,0,.3);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
line-height: 1.5;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── HEADER ──────────────────────────────────────────────── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 20px;
height: 54px;
display: flex;
align-items: center;
gap: 0;
position: sticky;
top: 0;
z-index: 100;
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
user-select: none;
flex-shrink: 0;
}
.brand-mark {
width: 30px;
height: 30px;
background: var(--blue);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
color: #fff;
letter-spacing: -.5px;
flex-shrink: 0;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-sub { font-size: 10px; color: var(--text-subtle); font-weight: 500; letter-spacing: .2px; }
.brand-name { font-size: 14px; font-weight: 700; color: var(--text); letter-spacing: -.3px; }
.h-sep { width: 1px; height: 18px; background: var(--border); margin: 0 14px; flex-shrink: 0; }
.h-spacer { flex: 1; }
.h-right { display: flex; align-items: center; gap: 8px; }
#weather {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 3px 9px;
height: 28px;
}
#clock {
font-size: 12px;
color: var(--text-subtle);
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.h-icon-btn {
width: 30px; height: 30px;
border-radius: var(--r-sm);
border: none;
background: none;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 15px;
color: var(--text-muted);
transition: background .12s;
font-family: inherit;
}
.h-icon-btn:hover { background: var(--n-100); }
body.dark #btn-dark { color: #f1c40f; }
.h-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 4px;
height: 30px; padding: 0 12px;
border-radius: var(--r-sm);
font-size: 12px; font-weight: 600; font-family: inherit;
cursor: pointer;
transition: background .1s, color .1s, border-color .1s;
text-decoration: none; white-space: nowrap;
border: 1px solid var(--border);
background: none; color: var(--text-muted);
}
.h-btn:hover { background: var(--n-100); color: var(--text); }
.h-btn-primary { background: var(--blue); color: #fff; border-color: var(--blue); }
.h-btn-primary:hover { background: var(--blue-d); border-color: var(--blue-d); color: #fff; }
/* Legacy class aliases */
.btn-sm { display: inline-flex; align-items: center; gap: 4px; height: 30px; padding: 0 12px; border-radius: var(--r-sm); font-size: 12px; font-weight: 600; font-family: inherit; cursor: pointer; transition: background .1s; text-decoration: none; white-space: nowrap; border: 1px solid var(--border); background: none; color: var(--text-muted); }
.btn-sm:hover { background: var(--n-100); color: var(--text); }
.btn-primary-sm { background: var(--blue); color: #fff; border-color: var(--blue); }
.btn-primary-sm:hover { background: var(--blue-d); border-color: var(--blue-d); }
.avatar {
width: 30px; height: 30px;
border-radius: 50%;
background: var(--blue);
color: #fff;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 12px;
cursor: pointer;
position: relative;
flex-shrink: 0;
user-select: none;
transition: opacity .12s;
}
.avatar:hover { opacity: .85; }
.dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
min-width: 190px;
overflow: hidden;
display: none;
z-index: 200;
padding: 5px;
}
.dropdown.open { display: block; }
.dd-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px;
font-size: 13px; font-weight: 500;
cursor: pointer;
color: var(--text-2);
border-radius: var(--r-sm);
transition: background .1s;
text-decoration: none;
user-select: none;
}
.dd-item:hover { background: var(--n-100); }
.dd-item.danger { color: var(--red); }
.dd-item.danger:hover { background: var(--red-50); }
.dd-item.meta { color: var(--text-subtle); font-size: 11px; cursor: default; }
.dd-item.meta:hover { background: none; }
.dd-sep { height: 1px; background: var(--border-subtle); margin: 4px 0; }
/* ── BANNER ──────────────────────────────────────────────── */
#banner {
background: var(--blue);
padding: 0 20px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
min-height: 44px;
flex-shrink: 0;
}
.banner-label { font-size: 13px; font-weight: 500; color: rgba(255,255,255,.92); white-space: nowrap; }
.banner-chips { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
.chip {
background: rgba(255,255,255,.15);
border: 1px solid rgba(255,255,255,.2);
border-radius: var(--r-sm);
padding: 2px 8px;
font-size: 11px; font-weight: 500;
color: rgba(255,255,255,.9);
}
.banner-cta {
background: rgba(255,255,255,.95);
color: var(--blue);
font-size: 12px; font-weight: 600;
border: none;
padding: 6px 14px;
border-radius: var(--r-sm);
cursor: pointer;
white-space: nowrap;
font-family: inherit;
flex-shrink: 0;
transition: background .12s;
}
.banner-cta:hover { background: #fff; }
/* ── MAIN LAYOUT ─────────────────────────────────────────── */
main {
flex: 1;
width: 100%;
max-width: 1380px;
margin: 0 auto;
padding: 20px 20px 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 296px;
gap: 16px;
align-items: start;
}
.col-primary {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.col-secondary {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.card-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
/* ── CARD ────────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow-xs);
}
.card-head {
padding: 12px 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid var(--border-subtle);
}
.card-title {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
display: flex; align-items: center; gap: 6px;
line-height: 1;
text-transform: uppercase;
letter-spacing: .4px;
}
.card-title .ci { font-size: 13px; }
.card-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.card-body { padding: 14px 16px; }
.card-body-flush { padding: 0; }
/* Card action buttons */
.add-btn {
display: inline-flex; align-items: center; gap: 4px;
height: 26px; padding: 0 10px;
border-radius: var(--r-sm);
font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer;
background: var(--blue); color: #fff;
border: 1px solid var(--blue);
transition: background .1s;
white-space: nowrap;
}
.add-btn:hover { background: var(--blue-d); border-color: var(--blue-d); }
.print-btn {
display: inline-flex; align-items: center; gap: 4px;
height: 26px; padding: 0 10px;
border-radius: var(--r-sm);
font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer;
background: none; color: var(--text-muted);
border: 1px solid var(--border);
transition: background .1s;
}
.print-btn:hover { background: var(--n-100); color: var(--text); }
/* ── SKELETON ────────────────────────────────────────────── */
.sk {
background: linear-gradient(90deg, var(--n-100) 25%, var(--n-150) 50%, var(--n-100) 75%);
background-size: 200%;
animation: shimmer 1.6s infinite;
border-radius: var(--r-xs);
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
.sk-line { height: 12px; margin-bottom: 8px; }
.sk-line.short { width: 55%; }
/* ── TIMETABLE ───────────────────────────────────────────── */
.tt-wrap { overflow-x: auto; }
.tt-grid {
display: grid;
grid-template-columns: 52px repeat(5, 1fr);
border: 1px solid var(--border);
border-radius: var(--r-sm);
overflow: hidden;
min-width: 380px;
}
.tt-cell {
border-right: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border-subtle);
min-height: 52px;
position: relative;
}
.tt-cell:last-child, .tt-cell.no-right { border-right: none; }
.tt-cell.no-bot { border-bottom: none; }
.tt-head {
background: var(--surface-2);
padding: 7px 5px;
font-size: 10px; font-weight: 700;
text-align: center;
color: var(--text-muted);
letter-spacing: .5px; text-transform: uppercase;
min-height: auto;
}
.tt-head.today-col { color: var(--blue); background: var(--blue-50); }
.tt-time {
background: var(--surface-2);
padding: 5px 7px;
display: flex; flex-direction: column; justify-content: center; gap: 1px;
}
.tt-time-num { font-size: 11px; font-weight: 600; color: var(--text-muted); }
.tt-time-val { font-size: 9px; color: var(--text-subtle); }
.tt-lesson {
position: absolute; inset: 3px;
border-radius: 5px; padding: 5px 7px;
overflow: hidden;
border-left: 2.5px solid transparent;
}
.tt-lesson-subj { font-size: 11px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tt-lesson-meta { font-size: 9px; opacity: .7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.tt-del {
position: absolute; top: 3px; right: 4px;
font-size: 11px; cursor: pointer;
opacity: 0;
border: none; background: none; font-family: inherit; color: inherit; padding: 0; line-height: 1;
}
.tt-lesson:hover .tt-del { opacity: .5; }
.tt-lesson:hover .tt-del:hover { opacity: 1; }
.tt-pending { text-align: center; font-size: 13px; color: var(--text-subtle); padding: 28px 0; font-weight: 500; }
/* Placeholder TT */
.ph-tt { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.ph-col { display: flex; flex-direction: column; gap: 4px; }
.ph-head { text-align: center; font-size: 10px; font-weight: 700; color: var(--text-muted); padding: 4px 0; text-transform: uppercase; letter-spacing: .5px; }
.ph-head.today { color: var(--blue); }
.ph-slot { height: 52px; border-radius: 7px; }
/* ── EVENTS ──────────────────────────────────────────────── */
.ev-item {
display: flex; align-items: center; gap: 11px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.ev-item:last-child { border-bottom: none; }
.ev-days {
font-size: 18px; font-weight: 800; min-width: 36px; text-align: center;
letter-spacing: -1px; font-variant-numeric: tabular-nums; line-height: 1;
}
.ev-info { flex: 1; min-width: 0; }
.ev-title { font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; color: var(--text); }
.ev-date { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.ev-del {
color: var(--text-subtle); cursor: pointer;
font-size: 12px; border: none; background: none;
padding: 4px; border-radius: var(--r-sm); flex-shrink: 0;
opacity: 0; transition: opacity .1s, color .1s;
}
.ev-item:hover .ev-del { opacity: 1; }
.ev-del:hover { color: var(--red); }
/* ── BADGES ──────────────────────────────────────────────── */
.badge {
font-size: 10px; font-weight: 600;
padding: 1px 5px; border-radius: var(--r-xs);
letter-spacing: .1px; white-space: nowrap;
}
.badge-red { background: #fee2e2; color: #dc2626; }
.badge-orange { background: #fef3c7; color: #b45309; }
.badge-blue { background: var(--blue-100); color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #15803d; }
/* ── HOMEWORK ────────────────────────────────────────────── */
.hw-item {
display: flex; align-items: flex-start; gap: 9px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.hw-item:last-child { border-bottom: none; }
.check {
width: 16px; height: 16px;
border-radius: 4px;
border: 1.5px solid var(--n-300);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: 2px;
transition: all .14s; font-size: 9px; color: transparent;
}
.check.done { background: var(--green); border-color: var(--green); color: #fff; }
.hw-body { flex: 1; min-width: 0; }
.hw-title { font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.35; }
.hw-title.crossed { text-decoration: line-through; color: var(--text-muted); }
.hw-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.del-btn {
color: var(--text-subtle); cursor: pointer;
font-size: 12px; border: none; background: none;
flex-shrink: 0; padding: 4px; border-radius: var(--r-sm);
opacity: 0; transition: opacity .1s, color .1s;
font-family: inherit;
}
.hw-item:hover .del-btn,
.grade-row:hover .del-btn,
.ab-item:hover .del-btn,
.todo-item:hover .del-btn,
.cd-item:hover .del-btn,
.file-item:hover .del-btn { opacity: 1; }
.del-btn:hover { color: var(--red); }
/* ── GRADES ──────────────────────────────────────────────── */
.grade-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.grade-row:last-child { border-bottom: none; }
.grade-info { flex: 1; min-width: 0; }
.grade-subj { font-size: 13px; font-weight: 500; color: var(--text); }
.grade-type { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.grade-note { font-size: 11px; color: var(--text-muted); }
.grade-val {
font-size: 13px; font-weight: 700;
min-width: 30px; text-align: center;
padding: 3px 6px; border-radius: 5px;
font-variant-numeric: tabular-nums;
}
.g1,.g2 { background: #dcfce7; color: #166534; }
.g3 { background: #fef9c3; color: #854d0e; }
.g4 { background: #ffedd5; color: #c2410c; }
.g5,.g6 { background: #fee2e2; color: #991b1b; }
.grade-avg {
display: flex; justify-content: space-between; align-items: center;
padding-top: 12px; margin-top: 2px;
border-top: 1px solid var(--border-subtle);
}
.grade-avg-label { font-size: 11px; color: var(--text-muted); font-weight: 500; }
.grade-avg-val { font-size: 24px; font-weight: 800; color: var(--blue); letter-spacing: -1px; }
.grade-avg-sub { font-size: 10px; color: var(--text-subtle); margin-top: 2px; }
/* ── ABSENCES ────────────────────────────────────────────── */
.ab-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border-subtle);
}
.ab-item:last-child { border-bottom: none; }
.ab-date { font-size: 11px; font-weight: 600; min-width: 70px; color: var(--text-muted); padding-top: 1px; }
.ab-info { flex: 1; }
.ab-subj { font-size: 13px; font-weight: 500; color: var(--text); }
.ab-reason { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.ab-total { font-size: 11px; color: var(--text-muted); font-weight: 500; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-subtle); }
/* ── TODOS ───────────────────────────────────────────────── */
.todo-item {
display: flex; align-items: center; gap: 9px;
padding: 7px 0;
border-bottom: 1px solid var(--border-subtle);
}
.todo-item:last-child { border-bottom: none; }
.todo-check {
width: 16px; height: 16px;
border-radius: 50%;
border: 1.5px solid var(--n-300);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; font-size: 9px; color: transparent;
transition: all .14s;
}
.todo-check.done { background: var(--blue); border-color: var(--blue); color: #fff; }
.todo-title { flex: 1; font-size: 13px; font-weight: 500; color: var(--text); }
.todo-title.crossed { text-decoration: line-through; color: var(--text-muted); font-weight: 400; }
/* ── COUNTDOWNS ──────────────────────────────────────────── */
.cd-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-subtle);
}
.cd-item:last-child { border-bottom: none; }
.cd-days { font-size: 18px; font-weight: 800; color: var(--blue); min-width: 36px; text-align: center; letter-spacing: -1px; font-variant-numeric: tabular-nums; line-height: 1; }
.cd-days.past { color: var(--text-subtle); font-size: 14px; }
.cd-info { flex: 1; }
.cd-title { font-size: 13px; font-weight: 500; color: var(--text); }
.cd-date { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
/* ── QUICKLINKS ──────────────────────────────────────────── */
.ql-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 6px; }
.ql-item {
display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 10px 6px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r);
text-decoration: none;
color: var(--text-muted);
font-size: 11px; font-weight: 500;
text-align: center;
transition: border-color .12s, background .12s, color .12s;
position: relative; cursor: pointer;
line-height: 1.3;
}
.ql-item:hover { background: var(--blue-50); border-color: var(--blue); color: var(--blue); }
.ql-icon { font-size: 18px; line-height: 1; }
.ql-del { position: absolute; top: 3px; right: 4px; font-size: 10px; color: var(--text-subtle); display: none; border: none; background: none; cursor: pointer; line-height: 1; }
.ql-item:hover .ql-del { display: block; }
.ql-del:hover { color: var(--red); }
/* ── EMPTY STATE ─────────────────────────────────────────── */
.empty { text-align: center; color: var(--text-subtle); font-size: 13px; padding: 24px 0; font-weight: 500; }
/* ── CHAT ────────────────────────────────────────────────── */
.chat-msgs {
height: 240px; overflow-y: auto;
display: flex; flex-direction: column; gap: 8px;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: var(--n-200) transparent;
}
.chat-msg { display: flex; flex-direction: column; gap: 1px; }
.chat-msg-meta { display: flex; align-items: baseline; gap: 6px; }
.chat-msg-user { font-size: 12px; font-weight: 600; color: var(--blue); }
.chat-msg-user.own { color: var(--green); }
.chat-msg-time { font-size: 10px; color: var(--text-subtle); }
.chat-msg-body { font-size: 13px; color: var(--text); line-height: 1.45; word-break: break-word; padding-left: 2px; }
.chat-msg-del { font-size: 11px; color: var(--text-subtle); border: none; background: none; cursor: pointer; margin-left: auto; flex-shrink: 0; visibility: hidden; }
.chat-msg:hover .chat-msg-del { visibility: visible; }
.chat-msg-del:hover { color: var(--red); }
.chat-input-row { display: flex; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-subtle); }
.chat-input {
flex: 1; padding: 7px 11px;
border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13px; font-family: inherit;
background: var(--surface); color: var(--text);
outline: none; transition: border-color .12s, box-shadow .12s;
}
.chat-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
/* ── FILE STORAGE ────────────────────────────────────────── */
.files-quota { margin-bottom: 12px; }
.files-quota-bar { height: 5px; background: var(--n-100); border-radius: 99px; overflow: hidden; margin-top: 5px; }
.files-quota-fill { height: 100%; background: var(--blue); border-radius: 99px; transition: width .3s; }
.files-quota-fill.warn { background: var(--amber); }
.files-quota-fill.danger { background: var(--red); }
.files-quota-text { font-size: 11px; color: var(--text-subtle); display: flex; justify-content: space-between; margin-top: 3px; }
.file-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
.file-item:last-child { border-bottom: none; }
.file-icon { font-size: 15px; flex-shrink: 0; line-height: 1; }
.file-info { flex: 1; min-width: 0; }
.file-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.file-meta { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.file-dl { font-size: 11px; font-weight: 600; color: var(--blue); background: var(--blue-50); border: none; padding: 4px 9px; border-radius: var(--r-sm); cursor: pointer; font-family: inherit; white-space: nowrap; transition: background .1s; }
.file-dl:hover { background: var(--blue-100); }
.upload-drop {
border: 1.5px dashed var(--n-300); border-radius: var(--r-sm);
padding: 16px; text-align: center;
color: var(--text-muted); font-size: 12px;
margin-bottom: 12px;
transition: border-color .12s, background .12s;
}
.upload-drop.drag { border-color: var(--blue); background: var(--blue-50); color: var(--blue); }
/* ── MODAL ───────────────────────────────────────────────── */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.45);
z-index: 300;
display: flex; align-items: center; justify-content: center;
padding: 16px;
backdrop-filter: blur(1px);
}
.modal {
background: var(--surface);
border-radius: var(--r-lg);
padding: 22px;
width: 100%; max-width: 400px;
box-shadow: var(--shadow-xl);
border: 1px solid var(--border);
}
.modal h3 { font-size: 15px; font-weight: 700; margin-bottom: 16px; color: var(--text); }
.modal-fields { display: flex; flex-direction: column; gap: 8px; }
.modal-fields input,
.modal-fields select,
.modal-fields textarea {
padding: 8px 11px;
border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13px; font-family: inherit;
color: var(--text); background: var(--surface);
outline: none;
transition: border-color .12s, box-shadow .12s;
}
.modal-fields input:focus,
.modal-fields select:focus,
.modal-fields textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
.modal-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
.btn-cancel {
padding: 7px 15px;
border: 1px solid var(--border); border-radius: var(--r-sm);
background: none; cursor: pointer;
font-size: 13px; font-weight: 500; font-family: inherit;
color: var(--text-muted); transition: background .1s;
}
.btn-cancel:hover { background: var(--n-100); color: var(--text); }
.btn-save {
padding: 7px 15px;
background: var(--blue); color: #fff;
border: 1px solid var(--blue); border-radius: var(--r-sm);
font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer; transition: background .1s;
}
.btn-save:hover { background: var(--blue-d); }
.btn-danger {
padding: 7px 15px;
background: var(--red); color: #fff;
border: 1px solid var(--red); border-radius: var(--r-sm);
font-size: 13px; font-weight: 600; font-family: inherit;
cursor: pointer;
}
/* ── SETTINGS ────────────────────────────────────────────── */
.settings-section { margin-bottom: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); }
.settings-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.settings-label { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text); }
.settings-fields { display: flex; flex-direction: column; gap: 8px; }
.settings-fields input {
padding: 8px 11px;
border: 1px solid var(--border); border-radius: var(--r-sm);
font-size: 13px; font-family: inherit;
background: var(--surface); color: var(--text); outline: none;
}
.settings-fields input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
.danger-zone { background: var(--red-50); border: 1px solid #fecaca; border-radius: var(--r-sm); padding: 14px; }
.danger-zone p { font-size: 13px; color: #7f1d1d; margin-bottom: 10px; line-height: 1.5; }
body.dark .danger-zone { background: #300; border-color: #5a1a1a; }
body.dark .danger-zone p { color: #fca5a5; }
/* ── TOAST ───────────────────────────────────────────────── */
#toasts { position: fixed; bottom: 20px; right: 20px; z-index: 400; display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
.toast {
background: var(--n-900);
color: #fff;
font-size: 13px; font-weight: 500;
padding: 9px 14px; border-radius: var(--r);
box-shadow: var(--shadow-lg);
opacity: 0; transform: translateY(6px) scale(.98);
transition: opacity .18s, transform .18s;
pointer-events: none; max-width: 300px;
border: 1px solid rgba(255,255,255,.06);
}
.toast.show { opacity: 1; transform: none; }
.toast.success { background: #166534; }
.toast.error { background: #991b1b; }
/* ── FOOTER ──────────────────────────────────────────────── */
footer {
background: var(--surface);
border-top: 1px solid var(--border);
padding: 13px 20px;
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 8px; flex-shrink: 0;
}
.footer-left { font-size: 11px; color: var(--text-subtle); }
.footer-left strong { color: var(--text-muted); font-weight: 500; }
.footer-links { display: flex; gap: 14px; }
.footer-links a { font-size: 11px; color: var(--text-subtle); text-decoration: none; transition: color .12s; }
.footer-links a:hover { color: var(--blue); }
/* ── PRINT ───────────────────────────────────────────────── */
@media print {
body > *:not(main) { display: none !important; }
.main-grid { display: block !important; }
.col-secondary { display: none !important; }
#card-tt { box-shadow: none !important; border: 1px solid #ddd !important; }
.col-primary > *:not(#card-tt) { display: none !important; }
}
/* ── SIDEBAR ─────────────────────────────────────────────── */
.sidebar-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.32);
z-index: 150;
opacity: 0; pointer-events: none;
transition: opacity .2s;
}
.sidebar-backdrop.open { opacity: 1; pointer-events: all; }
.sidebar {
position: fixed; top: 0; right: 0;
width: 360px; max-width: 100vw; height: 100%;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-lg);
z-index: 160;
transform: translateX(100%);
transition: transform .22s cubic-bezier(.4,0,.2,1);
display: flex; flex-direction: column;
overflow: hidden;
}
.sidebar.open { transform: translateX(0); }
.sidebar-head {
height: 54px;
padding: 0 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; gap: 10px;
background: var(--surface);
flex-shrink: 0;
}
.sidebar-title { font-size: 13px; font-weight: 600; color: var(--text); }
.sidebar-close {
width: 28px; height: 28px;
border: none; background: none; cursor: pointer;
border-radius: var(--r-sm);
display: flex; align-items: center; justify-content: center;
font-size: 18px; line-height: 1; color: var(--text-muted);
transition: background .1s; font-family: inherit;
}
.sidebar-close:hover { background: var(--n-100); color: var(--text); }
.sidebar-body {
flex: 1; overflow-y: auto;
padding: 14px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--n-200) transparent;
}
/* burger button badge dot (optional active state) */
#sidebar-btn { position: relative; }
/* ── 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); }
@media (max-width: 860px) {
.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; }
}
/* ── 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>
<div class="brand" onclick="location.href='/'">
<div class="brand-mark">i1</div>
<div class="brand-text">
<span class="brand-sub">IFB-Berufsfachschule Rosenheim</span>
<span class="brand-name">INFO1</span>
</div>
</div>
<div class="h-sep"></div>
<div class="h-spacer"></div>
<div class="h-right">
<div id="weather"></div>
<div id="clock"></div>
<button id="btn-dark" class="h-icon-btn" onclick="toggleDark()" title="Dark Mode"><i data-lucide="moon" aria-label="Dark Mode"></i></button>
<button id="sidebar-btn" class="h-icon-btn" style="display:none" onclick="openSidebar()" title="Noten, Dateien & Fehlzeiten"><i data-lucide="menu" aria-label="Seitenleiste öffnen"></i></button>
<div id="h-user" style="display:flex;align-items:center;gap:7px">
<a href="/login" class="h-btn">Anmelden</a>
<a href="/login?tab=register" class="h-btn h-btn-primary">Registrieren</a>
</div>
</div>
</header>
<div id="banner">
<div class="banner-label">Mit Account freischalten:</div>
<div class="banner-chips">
<span class="chip"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</span>
<span class="chip"><i data-lucide="pencil" aria-hidden="true"></i> Hausaufgaben</span>
<span class="chip"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten &amp; Ø</span>
<span class="chip"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</span>
<span class="chip"><i data-lucide="check-square" aria-hidden="true"></i> To-Do</span>
<span class="chip"><i data-lucide="timer" aria-hidden="true"></i> Countdowns</span>
<span class="chip"><i data-lucide="link" aria-hidden="true"></i> Links</span>
</div>
<button class="banner-cta" onclick="location.href='/login?tab=register'">Jetzt registrieren →</button>
</div>
<main>
<div class="main-grid">
<!-- ── PRIMARY COLUMN ──────────────────────────── -->
<div class="col-primary">
<!-- Timetable (logged-out placeholder) -->
<div class="card" id="card-tt-public">
<div class="card-head">
<div class="card-title"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</div>
</div>
<div class="card-body">
<div class="ph-tt">
<div class="ph-col"><div class="ph-head" id="ph0">Mo</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph1">Di</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph2">Mi</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph3">Do</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
<div class="ph-col"><div class="ph-head" id="ph4">Fr</div><div class="ph-slot sk"></div><div class="ph-slot sk" style="height:40px;opacity:.6"></div><div class="ph-slot sk" style="height:40px;opacity:.4"></div></div>
</div>
<div class="tt-pending"><i data-lucide="clock" aria-hidden="true"></i> Wird noch eingetragen</div>
</div>
</div>
<!-- Timetable (logged-in) -->
<div class="card" id="card-tt" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="calendar" aria-hidden="true"></i> Stundenplan</div>
<div class="card-actions">
<button class="print-btn" onclick="window.print()"><i data-lucide="printer" aria-hidden="true"></i> Drucken</button>
<button class="add-btn" onclick="openModal('timetable')">+ Stunde</button>
</div>
</div>
<div class="card-body">
<div class="tt-wrap"><div class="tt-grid" id="tt-grid"></div></div>
</div>
</div>
<!-- Homework + Todos (pair) -->
<div class="card-pair" id="pair-hw-todo" style="display:none">
<div class="card" id="card-hw">
<div class="card-head">
<div class="card-title"><i data-lucide="pencil" aria-hidden="true"></i> Hausaufgaben</div>
<button class="add-btn" onclick="openModal('homework')">+ Aufgabe</button>
</div>
<div class="card-body" id="list-hw"></div>
</div>
<div class="card" id="card-todo">
<div class="card-head">
<div class="card-title"><i data-lucide="check-square" aria-hidden="true"></i> To-Do</div>
<button class="add-btn" onclick="openModal('todos')">+ Aufgabe</button>
</div>
<div class="card-body" id="list-todo"></div>
</div>
</div>
</div><!-- /col-primary -->
<!-- ── SECONDARY COLUMN ─────────────────────────── -->
<div class="col-secondary">
<!-- Exams (shared) -->
<div class="card" id="card-pruefungen">
<div class="card-head">
<div class="card-title"><i data-lucide="clipboard-list" aria-hidden="true"></i> Prüfungen &amp; Klausuren</div>
<button class="add-btn" id="btn-add-pruefung" style="display:none" onclick="openModal('class-events','pruefung')">+ Prüfung</button>
</div>
<div class="card-body" id="list-pruefungen"><div class="sk-line sk"></div><div class="sk-line sk short"></div></div>
</div>
<!-- Holidays (shared) -->
<div class="card" id="card-ferien">
<div class="card-head">
<div class="card-title"><i data-lucide="calendar-range" aria-hidden="true"></i> Ferien &amp; Feiertage</div>
<button class="add-btn" id="btn-add-ferien" style="display:none" onclick="openModal('class-events','ferien')">+ Termin</button>
</div>
<div class="card-body" id="list-ferien"><div class="sk-line sk"></div><div class="sk-line sk short"></div></div>
</div>
<!-- Countdowns -->
<div class="card" id="card-cd" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="timer" aria-hidden="true"></i> Countdowns</div>
<button class="add-btn" onclick="openModal('countdowns')">+ Countdown</button>
</div>
<div class="card-body" id="list-cd"></div>
</div>
<!-- Quick Links -->
<div class="card" id="card-ql">
<div class="card-head">
<div class="card-title"><i data-lucide="link" aria-hidden="true"></i> Schnelllinks</div>
<button class="add-btn" id="btn-add-ql" style="display:none" onclick="openModal('quicklinks')">+ Link</button>
</div>
<div class="card-body"><div class="ql-grid" id="ql-grid">
<a class="ql-item" href="https://www.ifb-schulen.de" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="building-2" aria-hidden="true"></i></span>IFB Website</a>
<a class="ql-item" href="https://www.bib-info.de" target="_blank" rel="noopener"><span class="ql-icon"><i data-lucide="book-open" aria-hidden="true"></i></span>Bibliothek</a>
</div></div>
</div>
</div><!-- /col-secondary -->
</div><!-- /main-grid -->
<!-- Chat (full width) -->
<div class="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 · INFO1</div>
</div>
<div class="card-body">
<div class="chat-msgs" id="chat-msgs"></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>
<!-- Support Tickets (full width, logged-in) -->
<div class="card" id="card-tickets" style="display:none">
<div class="card-head">
<div class="card-title"><span class="ci">🎫</span> Support-Tickets</div>
<button class="add-btn" onclick="openNewTicket()">+ Ticket</button>
</div>
<div class="card-body" id="list-tickets"><div class="empty">Keine Tickets</div></div>
</div>
</main>
<!-- ── SIDEBAR ──────────────────────────────────────────── -->
<div class="sidebar-backdrop" id="sidebar-backdrop" onclick="closeSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-head">
<span class="sidebar-title">Noten, Dateien &amp; Fehlzeiten</span>
<button class="sidebar-close" onclick="closeSidebar()" aria-label="Schließen"><i data-lucide="x" aria-hidden="true"></i></button>
</div>
<div class="sidebar-body">
<div class="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>
<button class="add-btn" onclick="openModal('grades')">+ Note</button>
</div>
<div class="card-body" id="list-grades"></div>
</div>
<div class="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">
<input type="file" id="file-input" style="display:none" multiple onchange="uploadFiles(event)">
<button class="add-btn" onclick="document.getElementById('file-input').click()">↑ Hochladen</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" 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>
<button class="add-btn" onclick="openModal('absences')">+ Eintragen</button>
</div>
<div class="card-body" id="list-ab"></div>
</div>
</div>
</aside>
<footer>
<div class="footer-left">Daten auf <strong>Hetzner-Server, Nürnberg, Deutschland</strong> · EU-DSGVO konform</div>
<div class="footer-links">
<a href="/datenschutz">Datenschutzerklärung</a>
</div>
</footer>
<!-- NEW TICKET MODAL -->
<div class="overlay" id="new-ticket-overlay" style="display:none" onclick="if(event.target===this)closeNewTicket()">
<div class="modal" style="max-width:460px">
<h3>Neues Support-Ticket</h3>
<div style="display:flex;flex-direction:column;gap:10px;margin:14px 0">
<input type="text" id="new-ticket-subject" placeholder="Betreff (max. 200 Zeichen)" maxlength="200"
style="padding:8px 10px;border:1.5px solid var(--border);border-radius:var(--r-sm);font-size:13px;font-family:inherit;outline:none;background:var(--surface);color:var(--text)">
<textarea id="new-ticket-message" placeholder="Beschreibe dein Problem…" rows="5" maxlength="5000"
style="padding:8px 10px;border:1.5px solid var(--border);border-radius:var(--r-sm);font-size:13px;font-family:inherit;resize:vertical;outline:none;background:var(--surface);color:var(--text)"></textarea>
</div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeNewTicket()">Abbrechen</button>
<button class="btn-save" onclick="submitNewTicket()">Absenden</button>
</div>
</div>
</div>
<!-- TICKET THREAD MODAL -->
<div class="overlay" id="thread-overlay" style="display:none" onclick="if(event.target===this)closeThread()">
<div class="modal" style="max-width:520px">
<h3 id="thread-title" style="word-break:break-word"></h3>
<p id="thread-meta" style="font-size:12px;color:var(--text-muted);margin-top:3px"></p>
<div class="thread-wrap" id="thread-msgs"></div>
<div class="thread-reply-row" id="thread-reply-area">
<textarea id="thread-input" placeholder="Antwort schreiben…" rows="2" maxlength="5000"></textarea>
<button class="btn-save" onclick="sendThreadReply()">Senden</button>
</div>
<div class="modal-actions" style="margin-top:8px">
<button class="btn-cancel" onclick="closeThread()">Schließen</button>
</div>
</div>
</div>
<!-- DATA MODAL -->
<div class="overlay" id="modal-overlay" style="display:none" onclick="if(event.target===this)closeModal()">
<div class="modal">
<h3 id="modal-title"></h3>
<div class="modal-fields" id="modal-fields"></div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModal()">Abbrechen</button>
<button class="btn-save" onclick="saveModal()">Speichern</button>
</div>
</div>
</div>
<!-- SETTINGS MODAL -->
<div class="overlay" id="settings-overlay" style="display:none" onclick="if(event.target===this)closeSettings()">
<div class="modal" style="max-width:420px">
<h3>Einstellungen</h3>
<div class="settings-section">
<div class="settings-label">Passwort ändern</div>
<div class="settings-fields">
<input type="password" id="pw-current" placeholder="Aktuelles Passwort">
<input type="password" id="pw-new" placeholder="Neues Passwort (min. 6 Zeichen)">
<button class="btn-save" style="align-self:flex-start" onclick="changePassword()">Ändern</button>
</div>
</div>
<div class="settings-section">
<div class="danger-zone">
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
<div class="settings-fields">
<input type="password" id="pw-delete" placeholder="Passwort zur Bestätigung">
<button class="btn-danger" style="align-self:flex-start" onclick="deleteAccount()">Account löschen</button>
</div>
</div>
</div>
<div class="modal-actions" style="margin-top:10px">
<button class="btn-cancel" onclick="closeSettings()">Schließen</button>
</div>
</div>
</div>
<div id="toasts"></div>
<script>
// ── CONSTANTS ────────────────────────────────────────────────
const DAYS = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag'];
const DAY_S = ['Mo','Di','Mi','Do','Fr'];
const TIME_SLOTS = [
{ key:'07:45', label:'1.', time:'07:45' },
{ key:'08:30', label:'2.', time:'08:30' },
{ key:'09:30', label:'3.', time:'09:30' },
{ key:'10:15', label:'4.', time:'10:15' },
{ key:'11:15', label:'5.', time:'11:15' },
{ key:'12:00', label:'6.', time:'12:00' },
{ key:'13:30', label:'7.', time:'13:30' },
{ key:'14:15', label:'8.', time:'14:15' },
];
const GRADE_WEIGHTS = { schulaufgabe:2, kurzarbeit:1.5, stegreifaufgabe:1, muendlich:1, sonstiges:1 };
const GRADE_TYPES = { schulaufgabe:'Schulaufgabe', kurzarbeit:'Kurzarbeit', stegreifaufgabe:'Stegreifaufgabe', muendlich:'Mündlich', sonstiges:'Sonstiges' };
const SUBJECT_COLORS = [
{ bg:'#eff6ff', border:'#2563eb', text:'#1e40af' },
{ bg:'#f0fdf4', border:'#16a34a', text:'#166534' },
{ bg:'#fef3c7', border:'#d97706', text:'#92400e' },
{ bg:'#fdf4ff', border:'#9333ea', text:'#7e22ce' },
{ bg:'#fff1f2', border:'#e11d48', text:'#9f1239' },
{ bg:'#ecfdf5', border:'#059669', text:'#065f46' },
{ bg:'#f0f9ff', border:'#0ea5e9', text:'#0c4a6e' },
{ bg:'#fefce8', border:'#ca8a04', text:'#713f12' },
];
let currentModal = null;
let currentModalSub = null;
// ── UTILITIES ─────────────────────────────────────────────────
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function subjectColor(s){
let h=0; for(const c of String(s)) h=(h*31+c.charCodeAt(0))&0xffffffff;
return SUBJECT_COLORS[Math.abs(h)%SUBJECT_COLORS.length];
}
function daysUntil(dateStr){
const t=new Date(); t.setHours(0,0,0,0);
const d=new Date(dateStr); d.setHours(0,0,0,0);
return Math.round((d-t)/86400000);
}
function fmtDate(s){ return s ? new Date(s).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}) : ''; }
// ── TOAST ─────────────────────────────────────────────────────
function toast(msg, type='success'){
const el=document.createElement('div');
el.className=`toast ${type}`; el.textContent=msg;
document.getElementById('toasts').appendChild(el);
requestAnimationFrame(()=>{ requestAnimationFrame(()=>el.classList.add('show')); });
setTimeout(()=>{ el.classList.remove('show'); setTimeout(()=>el.remove(),300); },3000);
}
// ── CLOCK ─────────────────────────────────────────────────────
function tick(){
const n=new Date();
document.getElementById('clock').textContent=
n.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit',year:'numeric'})+
' · '+n.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
}
tick(); setInterval(tick,10000);
// ── WEATHER ───────────────────────────────────────────────────
async function loadWeather(){
try{
const d=await(await fetch('https://api.open-meteo.com/v1/forecast?latitude=47.857&longitude=12.128&current_weather=true')).json();
const w=d.current_weather;
const ic={0:'☀️',1:'🌤️',2:'⛅',3:'☁️',45:'🌫️',51:'🌦️',55:'🌧️',61:'🌧️',65:'🌧️',71:'🌨️',75:'❄️',80:'🌧️',95:'⛈️'};
document.getElementById('weather').innerHTML=`${ic[w.weathercode]||'🌡️'} <strong>${Math.round(w.temperature)}°C</strong>&thinsp;<span style="color:var(--text-subtle);font-size:10px">RO</span>`;
}catch{}
}
loadWeather();
// ── API ────────────────────────────────────────────────────────
async function api(method,path,body){
const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
return r.json();
}
// ── SESSION ────────────────────────────────────────────────────
async function init(){
// Highlight today on public timetable
const ti=(new Date().getDay()+6)%7;
if(ti<5) document.getElementById('ph'+ti)?.classList.add('today');
// Load shared class events immediately
loadClassEvents();
const r=await fetch('/api/me');
if(r.ok){ const d=await r.json(); loginUI(d.username,d.id,d.role); }
}
function loginUI(username,id,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('sidebar-btn').style.display='flex';
document.getElementById('card-tickets').style.display='';
['card-hw','card-grades','card-ab','card-todo','card-cd','card-files'].forEach(id=>document.getElementById(id).style.display='');
['btn-add-pruefung','btn-add-ferien','btn-add-ql'].forEach(id=>document.getElementById(id).style.display='');
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>` : '';
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>
<div class="dd-sep"></div>
<span class="dd-item" onclick="openSettings()">⚙️ Einstellungen</span>
${adminLink}
<div class="dd-sep"></div>
<span class="dd-item danger" onclick="doLogout()">Abmelden</span>
</div>
</div>`;
loadAll();
initChat(username);
}
function toggleDropdown(el){
event.stopPropagation();
document.getElementById('user-dropdown').classList.toggle('open');
}
document.addEventListener('click',()=>document.getElementById('user-dropdown')?.classList.remove('open'));
async function doLogout(){
await fetch('/api/logout',{method:'POST'});
location.reload();
}
// ── LOAD ALL ──────────────────────────────────────────────────
async function loadAll(){
const [tt,hw,gr,ab,td,cd,ql]=await Promise.all([
api('GET','timetable'),api('GET','homework'),api('GET','grades'),
api('GET','absences'),api('GET','todos'),api('GET','countdowns'),api('GET','quicklinks')
]);
renderTT(tt); renderHW(hw); renderGrades(gr);
renderAbsences(ab); renderTodos(td); renderCountdowns(cd); renderQL(ql);
loadFiles();
loadTickets();
}
async function loadClassEvents(){
const events=await api('GET','class-events');
renderPruefungen(events.filter(e=>e.type==='pruefung'));
renderFerien(events.filter(e=>e.type==='ferien'));
}
// ── TIMETABLE ─────────────────────────────────────────────────
function renderTT(data){
const todayIdx=(new Date().getDay()+6)%7;
const grid=document.getElementById('tt-grid');
grid.innerHTML='';
const cols=6; // time col + 5 days
const rows=TIME_SLOTS.length+1; // header + slots
// Header row
const corner=document.createElement('div');
corner.className='tt-cell tt-head no-right'; corner.textContent='';
grid.appendChild(corner);
DAYS.forEach((d,i)=>{
const h=document.createElement('div');
h.className='tt-cell tt-head'+(i===todayIdx?' today-col':'')+(i===4?' no-right':'');
h.textContent=DAY_S[i]; grid.appendChild(h);
});
// Slot rows
TIME_SLOTS.forEach((slot,si)=>{
const isLast=si===TIME_SLOTS.length-1;
// Time label
const tc=document.createElement('div');
tc.className='tt-cell tt-time'+(isLast?' no-bot':'');
tc.innerHTML=`<div class="tt-time-num">${slot.label}</div><div class="tt-time-val">${slot.time}</div>`;
grid.appendChild(tc);
DAYS.forEach((day,di)=>{
const cell=document.createElement('div');
cell.className='tt-cell'+(isLast?' no-bot':'')+(di===4?' no-right':'');
const lesson=data.find(l=>l.day===day && l.time_start===slot.key);
if(lesson){
const col=subjectColor(lesson.subject||'');
const lel=document.createElement('div');
lel.className='tt-lesson';
lel.style.cssText=`background:${col.bg};border-left:2.5px solid ${col.border};color:${col.text}`;
lel.innerHTML=`<div class="tt-lesson-subj">${esc(lesson.subject||'')}</div><div class="tt-lesson-meta">${esc(lesson.room||'')}${lesson.teacher?' · '+esc(lesson.teacher):''}</div><button class="tt-del" onclick="delItem('timetable',${lesson.id})">✕</button>`;
cell.appendChild(lel);
}
grid.appendChild(cell);
});
});
}
// ── CLASS EVENTS ──────────────────────────────────────────────
function renderPruefungen(data){
const el=document.getElementById('list-pruefungen');
if(!data.length){el.innerHTML='<div class="empty">Keine Prüfungen eingetragen</div>';return;}
el.innerHTML=data.map(e=>{
const diff=e.date?daysUntil(e.date):null;
let badge='', col='var(--blue)';
if(diff!==null){
if(diff<0){badge=`<span class="badge badge-green">Vorbei</span>`;col='var(--text-subtle)';}
else if(diff===0){badge=`<span class="badge badge-red">Heute</span>`;col='var(--red)';}
else if(diff<=7){badge=`<span class="badge badge-orange">in ${diff}d</span>`;col='var(--amber)';}
else{badge=`<span class="badge badge-blue">in ${diff}d</span>`;}
}
return `<div class="ev-item">
<div class="ev-days" style="color:${col}">${diff===null?'':diff<0?'✓':diff}</div>
<div class="ev-info"><div class="ev-title">${esc(e.title)} ${badge}</div><div class="ev-date">${e.date?fmtDate(e.date):''}${e.description?' · '+esc(e.description):''}</div></div>
<button class="ev-del" onclick="delClassEvent(${e.id})">🗑</button>
</div>`;
}).join('');
}
function renderFerien(data){
const el=document.getElementById('list-ferien');
if(!data.length){el.innerHTML='<div class="empty">Keine Termine eingetragen</div>';return;}
el.innerHTML=data.map(e=>{
const diff=e.date?daysUntil(e.date):null;
const col=diff!==null&&diff<0?'var(--text-subtle)':'var(--green)';
return `<div class="ev-item">
<div class="ev-days" style="color:${col}">${diff===null?'':diff<0?'✓':diff}</div>
<div class="ev-info"><div class="ev-title">${esc(e.title)}</div><div class="ev-date">${e.date?fmtDate(e.date):''}${e.date_end?' '+fmtDate(e.date_end):''}</div></div>
<button class="ev-del" onclick="delClassEvent(${e.id})">🗑</button>
</div>`;
}).join('');
}
async function delClassEvent(id){
await api('DELETE','class-events/'+id);
loadClassEvents();
toast('Gelöscht');
}
// ── HOMEWORK ──────────────────────────────────────────────────
function renderHW(data){
const el=document.getElementById('list-hw');
if(!data.length){el.innerHTML='<div class="empty">Keine Hausaufgaben</div>';return;}
const today=new Date(); today.setHours(0,0,0,0);
el.innerHTML=[...data].sort((a,b)=>(a.due_date||'9999').localeCompare(b.due_date||'9999')).map(h=>{
const due=h.due_date?new Date(h.due_date):null;
let badge='';
if(due&&!h.done){due.setHours(0,0,0,0);const d=Math.round((due-today)/86400000);
badge=d<0?'<span class="badge badge-red">Überfällig</span>':d===0?'<span class="badge badge-red">Heute</span>':d<=7?`<span class="badge badge-orange">in ${d}d</span>`:`<span class="badge badge-blue">in ${d}d</span>`;}
return `<div class="hw-item">
<div class="check ${h.done?'done':''}" onclick="toggle('homework',${h.id},${h.done})">${h.done?'✓':''}</div>
<div class="hw-body">
<div class="hw-title ${h.done?'crossed':''}">${esc(h.title)}</div>
<div class="hw-meta">${esc(h.subject||'')}${h.due_date?' · '+fmtDate(h.due_date):''} ${badge}</div>
</div>
<button class="del-btn" onclick="delItem('homework',${h.id})">🗑</button>
</div>`;
}).join('');
}
// ── GRADES ────────────────────────────────────────────────────
function renderGrades(data){
const el=document.getElementById('list-grades');
if(!data.length){el.innerHTML='<div class="empty">Keine Noten eingetragen</div>';return;}
const rows=data.map(g=>{
const gn=Math.round(g.grade||0);
const typeName=GRADE_TYPES[g.type]||'Sonstiges';
return `<div class="grade-row">
<div class="grade-info">
<div class="grade-subj">${esc(g.subject)}</div>
<div class="grade-type">${typeName}${g.note?' · '+esc(g.note):''}</div>
</div>
<div class="grade-val g${Math.min(gn,6)}">${g.grade}</div>
<button class="del-btn" onclick="delItem('grades',${g.id})">🗑</button>
</div>`;
}).join('');
// Weighted average
const valid=data.filter(g=>g.grade!=null);
const wSum=valid.reduce((s,g)=>s+(GRADE_WEIGHTS[g.type]||1),0);
const wGrades=valid.reduce((s,g)=>s+g.grade*(GRADE_WEIGHTS[g.type]||1),0);
const wavg=wSum?wGrades/wSum:null;
const avg=valid.length?(valid.reduce((s,g)=>s+g.grade,0)/valid.length).toFixed(2):null;
const avgBlock=avg?`<div class="grade-avg">
<div><div class="grade-avg-label">Ø gewichtet</div><div class="grade-avg-sub">ungewichtet: ${avg}</div></div>
<div class="grade-avg-val">${wavg.toFixed(2)}</div>
</div>`:'';
el.innerHTML=rows+avgBlock;
}
// ── ABSENCES ──────────────────────────────────────────────────
function renderAbsences(data){
const el=document.getElementById('list-ab');
if(!data.length){el.innerHTML='<div class="empty">Keine Fehlzeiten</div>';return;}
el.innerHTML=[...data].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(a=>
`<div class="ab-item">
<div class="ab-date">${a.date?fmtDate(a.date):''}</div>
<div class="ab-info"><div class="ab-subj">${esc(a.subject||'')}</div><div class="ab-reason">${esc(a.reason||'')}</div></div>
<button class="del-btn" onclick="delItem('absences',${a.id})">🗑</button>
</div>`).join('')+`<div class="ab-total">${data.length} Fehlzeit${data.length!==1?'en':''} gesamt</div>`;
}
// ── TODOS ─────────────────────────────────────────────────────
function renderTodos(data){
const el=document.getElementById('list-todo');
if(!data.length){el.innerHTML='<div class="empty">Keine Aufgaben</div>';return;}
el.innerHTML=data.map(t=>`<div class="todo-item">
<div class="todo-check ${t.done?'done':''}" onclick="toggle('todos',${t.id},${t.done})">${t.done?'✓':''}</div>
<div class="todo-title ${t.done?'crossed':''}">${esc(t.title)}</div>
<button class="del-btn" onclick="delItem('todos',${t.id})">🗑</button>
</div>`).join('');
}
// ── COUNTDOWNS ────────────────────────────────────────────────
function renderCountdowns(data){
const el=document.getElementById('list-cd');
if(!data.length){el.innerHTML='<div class="empty">Keine Countdowns</div>';return;}
el.innerHTML=[...data].sort((a,b)=>(a.target_date||'').localeCompare(b.target_date||'')).map(c=>{
const d=daysUntil(c.target_date);
return `<div class="cd-item">
<div class="cd-days ${d<0?'past':''}">${d<0?'✓':d}</div>
<div class="cd-info"><div class="cd-title">${esc(c.title)}</div><div class="cd-date">${fmtDate(c.target_date)}</div></div>
<button class="del-btn" onclick="delItem('countdowns',${c.id})">🗑</button>
</div>`;
}).join('');
}
// ── QUICKLINKS ────────────────────────────────────────────────
function renderQL(data){
if(!data.length)return;
const grid=document.getElementById('ql-grid');
data.forEach(l=>{
const a=document.createElement('a');
a.className='ql-item'; a.href=l.url; a.target='_blank'; a.rel='noopener';
a.innerHTML=`<span class="ql-icon">🔗</span>${esc(l.label)}<button class="ql-del" onclick="event.preventDefault();delItem('quicklinks',${l.id})">✕</button>`;
grid.appendChild(a);
});
}
// ── CRUD HELPERS ──────────────────────────────────────────────
async function delItem(type,id){
await api('DELETE',`${type}/${id}`);
loadAll(); toast('Gelöscht');
}
async function toggle(type,id,current){
await api('PUT',`${type}/${id}`,{done:current?0:1});
loadAll();
}
// ── MODAL CONFIG ──────────────────────────────────────────────
const MODALS={
timetable:{title:'Stunde hinzufügen',fields:[
{n:'day',l:'Tag',t:'select',opts:DAYS},
{n:'time_start',l:'Uhrzeit',t:'select',opts:TIME_SLOTS.map(s=>s.key),labels:TIME_SLOTS.map(s=>`${s.label} Std. ${s.time}`)},
{n:'subject',l:'Fach',t:'text'},{n:'room',l:'Raum',t:'text'},{n:'teacher',l:'Lehrkraft',t:'text'}
]},
homework:{title:'Hausaufgabe hinzufügen',fields:[
{n:'title',l:'Titel',t:'text'},{n:'subject',l:'Fach',t:'text'},{n:'due_date',l:'Fällig am',t:'date'}
]},
grades:{title:'Note hinzufügen',fields:[
{n:'subject',l:'Fach',t:'text'},
{n:'grade',l:'Note (16)',t:'number',min:1,max:6,step:0.1},
{n:'type',l:'Typ',t:'select',opts:Object.keys(GRADE_TYPES),labels:Object.values(GRADE_TYPES)},
{n:'note',l:'Anmerkung (optional)',t:'text'}
]},
absences:{title:'Fehlzeit eintragen',fields:[
{n:'date',l:'Datum',t:'date'},{n:'subject',l:'Fach / Stunde',t:'text'},{n:'reason',l:'Grund',t:'text'}
]},
todos:{title:'Aufgabe hinzufügen',fields:[{n:'title',l:'Aufgabe',t:'text'}]},
countdowns:{title:'Countdown hinzufügen',fields:[
{n:'title',l:'Bezeichnung',t:'text'},{n:'target_date',l:'Datum',t:'date'}
]},
quicklinks:{title:'Link hinzufügen',fields:[
{n:'label',l:'Bezeichnung',t:'text'},{n:'url',l:'URL',t:'url'}
]},
'class-events-pruefung':{title:'Prüfung eintragen',fields:[
{n:'title',l:'Bezeichnung',t:'text'},{n:'date',l:'Datum',t:'date'},{n:'description',l:'Anmerkung (optional)',t:'text'}
]},
'class-events-ferien':{title:'Ferienblock eintragen',fields:[
{n:'title',l:'Bezeichnung',t:'text'},{n:'date',l:'Von',t:'date'},{n:'date_end',l:'Bis (optional)',t:'date'}
]},
};
function openModal(type, sub){
const key = sub ? `${type}-${sub}` : type;
currentModal=type; currentModalSub=sub||null;
const cfg=MODALS[key];
document.getElementById('modal-title').textContent=cfg.title;
document.getElementById('modal-fields').innerHTML=cfg.fields.map(f=>{
if(f.t==='select'){
const opts=(f.opts||[]).map((o,i)=>`<option value="${esc(o)}">${esc(f.labels?f.labels[i]:o)}</option>`).join('');
return `<select name="${f.n}"><option value=""> ${f.l} </option>${opts}</select>`;
}
const ex=f.min!=null?` min="${f.min}" max="${f.max}" step="${f.step}"`:'';
return `<input type="${f.t}" name="${f.n}" placeholder="${f.l}"${ex}>`;
}).join('');
document.getElementById('modal-overlay').style.display='flex';
document.querySelector('#modal-fields input, #modal-fields select')?.focus();
}
function closeModal(){document.getElementById('modal-overlay').style.display='none';currentModal=null;currentModalSub=null;}
async function saveModal(){
if(!currentModal)return;
const key=currentModalSub?`${currentModal}-${currentModalSub}`:currentModal;
const cfg=MODALS[key];
const body={};
if(currentModalSub) body.type=currentModalSub;
cfg.fields.forEach(f=>{body[f.n]=document.querySelector(`#modal-fields [name="${f.n}"]`)?.value||null;});
await api('POST', currentModal==='class-events'?'class-events':currentModal, body);
closeModal();
if(currentModal==='class-events'||['pruefung','ferien'].includes(currentModalSub)) loadClassEvents();
else loadAll();
toast('Gespeichert ✓','success');
}
// ── SETTINGS ──────────────────────────────────────────────────
function openSettings(){
document.getElementById('settings-overlay').style.display='flex';
document.getElementById('user-dropdown')?.classList.remove('open');
}
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
async function changePassword(){
const cp=document.getElementById('pw-current').value;
const np=document.getElementById('pw-new').value;
const r=await api('PUT','me/password',{currentPassword:cp,newPassword:np});
if(r.ok){toast('Passwort geändert','success');document.getElementById('pw-current').value='';document.getElementById('pw-new').value='';}
else toast(r.error,'error');
}
async function deleteAccount(){
const pw=document.getElementById('pw-delete').value;
if(!confirm('Account und alle Daten wirklich löschen? Dies kann nicht rückgängig gemacht werden.'))return;
const r=await api('DELETE','me',{password:pw});
if(r.ok){location.reload();}
else toast(r.error,'error');
}
// ── CHAT ──────────────────────────────────────────────────────
let chatLastId = 0;
let chatPollTimer = null;
let chatMyUsername = '';
function chatFmtTime(ts) {
if (!ts) return '';
const d = new Date(ts + 'Z');
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function renderChatMsg(m, append) {
const el = document.getElementById('chat-msgs');
const isOwn = m.username === chatMyUsername;
const div = document.createElement('div');
div.className = 'chat-msg';
div.dataset.id = m.id;
div.innerHTML = `<div class="chat-msg-meta">
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
<button class="chat-msg-del" onclick="delChatMsg(${m.id})" title="Löschen">✕</button>
</div>
<div class="chat-msg-body">${esc(m.content)}</div>`;
if (append) {
el.appendChild(div);
el.scrollTop = el.scrollHeight;
} else {
el.prepend(div);
}
}
async function loadChat() {
const msgs = await api('GET', 'chat');
const el = document.getElementById('chat-msgs');
el.innerHTML = '';
msgs.forEach(m => renderChatMsg(m, true));
if (msgs.length) chatLastId = msgs[msgs.length - 1].id;
}
async function pollChat() {
try {
const msgs = await api('GET', 'chat?after=' + chatLastId);
msgs.forEach(m => {
renderChatMsg(m, true);
chatLastId = Math.max(chatLastId, m.id);
});
} catch {}
chatPollTimer = setTimeout(pollChat, 3000);
}
async function sendChatMsg() {
const inp = document.getElementById('chat-input');
const content = inp.value.trim();
if (!content) return;
inp.value = '';
const r = await api('POST', 'chat', { content });
if (r.error) { toast(r.error, 'error'); return; }
renderChatMsg(r, true);
chatLastId = Math.max(chatLastId, r.id);
}
async function delChatMsg(id) {
if (!confirm('Nachricht wirklich löschen?')) return;
const r = await api('DELETE', 'chat/' + id);
if (r.error) { toast(r.error, 'error'); return; }
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
}
function initChat(username) {
chatMyUsername = username;
document.getElementById('card-chat').style.display = '';
loadChat().then(() => pollChat());
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); }
});
}
// ── FILE STORAGE ──────────────────────────────────────────────
let filesData = { files: [], used: 0, quota: 2 * 1024 * 1024 * 1024 };
function fmtBytes(b) {
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB';
if (b >= 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB';
if (b >= 1024) return (b / 1024).toFixed(1) + ' KB';
return b + ' B';
}
function fileIcon(mime) {
if (mime.startsWith('image/')) return '🖼️';
if (mime === 'application/pdf') return '📕';
if (mime.includes('spreadsheet') || mime.includes('excel') || mime === 'text/csv') return '📊';
if (mime.includes('presentation') || mime.includes('powerpoint')) return '📋';
if (mime.includes('word') || mime.includes('document')) return '📝';
if (['application/zip','application/vnd.rar','application/x-7z-compressed','application/x-tar','application/gzip'].includes(mime)) return '🗜️';
if (mime === 'application/json' || mime === 'application/xml' || mime === 'text/xml') return '🔧';
return '📄';
}
async function loadFiles() {
try {
const r = await fetch('/api/files');
if (!r.ok) return;
filesData = await r.json();
renderFiles();
} catch {}
}
function renderFiles() {
const el = document.getElementById('files-body');
const pct = filesData.quota > 0 ? Math.min(100, filesData.used / filesData.quota * 100) : 0;
const fillClass = pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : '';
const quotaHtml = `<div class="files-quota">
<div class="files-quota-bar"><div class="files-quota-fill ${fillClass}" style="width:${pct.toFixed(1)}%"></div></div>
<div class="files-quota-text"><span>${fmtBytes(filesData.used)} genutzt</span><span>${fmtBytes(filesData.quota)} Speicher</span></div>
</div>`;
const dropZone = `<div class="upload-drop" id="files-drop">Dateien hier ablegen oder oben hochladen</div>`;
if (!filesData.files.length) {
el.innerHTML = quotaHtml + dropZone + '<div class="empty">Noch keine Dateien hochgeladen</div>';
} else {
el.innerHTML = quotaHtml + dropZone + filesData.files.map(f => `<div class="file-item">
<div class="file-icon">${fileIcon(f.mime_type)}</div>
<div class="file-info">
<div class="file-name" title="${esc(f.original_name)}">${esc(f.original_name)}</div>
<div class="file-meta">${fmtBytes(f.size)} · ${fmtDate(f.created_at ? f.created_at.slice(0,10) : '')}</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="file-dl" onclick="downloadFile(${f.id})">↓ Laden</button>
<button class="del-btn" onclick="deleteFile(${f.id})">🗑</button>
</div>
</div>`).join('');
}
initDropZone();
}
function initDropZone() {
const zone = document.getElementById('files-drop');
if (!zone) return;
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag');
const files = Array.from(e.dataTransfer.files);
if (files.length) uploadFileList(files);
});
}
function downloadFile(id) {
window.location.href = '/api/files/' + id + '/download';
}
async function deleteFile(id) {
if (!confirm('Datei wirklich löschen?')) return;
const r = await fetch('/api/files/' + id, { method: 'DELETE' });
const d = await r.json();
if (d.error) { toast(d.error, 'error'); return; }
toast('Datei gelöscht');
loadFiles();
}
async function uploadFiles(event) {
const files = Array.from(event.target.files);
document.getElementById('file-input').value = '';
if (!files.length) return;
await uploadFileList(files);
}
async function uploadFileList(files) {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch('/api/files', { method: 'POST', body: fd });
const d = await r.json();
if (d.error) toast(d.error, 'error');
else toast(`${esc(file.name)} hochgeladen ✓`);
} catch {
toast('Upload fehlgeschlagen', 'error');
}
}
loadFiles();
}
// ── SUPPORT TICKETS ───────────────────────────────────────────
let ticketsData = [];
let activeTicket = null;
let myUserId = null;
function fmtDateTime(s){
if(!s)return'';
const d=new Date(s);
return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})+' '+d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
}
async function loadTickets(){
const r = await api('GET','tickets');
if(r.error) return;
ticketsData = r;
renderTickets();
}
function ticketStatusBadge(s){
const m={open:'badge-red',in_progress:'badge-orange',closed:'badge-green'};
const l={open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'};
return `<span class="badge ${m[s]||'badge-gray'}">${l[s]||esc(s)}</span>`;
}
function renderTickets(){
const el=document.getElementById('list-tickets');
if(!ticketsData.length){ el.innerHTML='<div class="empty">Noch keine Tickets erstellt</div>'; return; }
el.innerHTML=ticketsData.map(t=>`
<div class="ticket-list-item" onclick="openThread(${t.id})">
<div style="flex:1;min-width:0">
<div class="ticket-list-subj">${esc(t.subject)}</div>
<div class="ticket-list-meta">${fmtDateTime(t.created_at)}</div>
</div>
${ticketStatusBadge(t.status)}
</div>`).join('');
}
function openNewTicket(){
document.getElementById('new-ticket-subject').value='';
document.getElementById('new-ticket-message').value='';
document.getElementById('new-ticket-overlay').style.display='flex';
setTimeout(()=>document.getElementById('new-ticket-subject').focus(),50);
}
function closeNewTicket(){ document.getElementById('new-ticket-overlay').style.display='none'; }
async function submitNewTicket(){
const subject=document.getElementById('new-ticket-subject').value.trim();
const message=document.getElementById('new-ticket-message').value.trim();
if(!subject||!message){ toast('Betreff und Nachricht erforderlich','error'); return; }
const r=await api('POST','tickets',{subject,message});
if(r.error){ toast(r.error,'error'); return; }
closeNewTicket();
toast('Ticket erstellt');
await loadTickets();
openThread(r.id);
}
async function openThread(ticketId){
const t=ticketsData.find(x=>x.id===ticketId); if(!t) return;
activeTicket=t;
document.getElementById('thread-title').textContent=t.subject;
document.getElementById('thread-meta').textContent=`Status: ${({open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'}[t.status]||t.status)} · Erstellt: ${fmtDateTime(t.created_at)}`;
document.getElementById('thread-msgs').innerHTML='<div class="empty" style="padding:8px 0">Laden…</div>';
const ra=document.getElementById('thread-reply-area');
ra.style.display=t.status==='closed'?'none':'flex';
document.getElementById('thread-overlay').style.display='flex';
const msgs=await api('GET',`tickets/${ticketId}/messages`);
renderThread(t, msgs.error?[]:msgs);
}
function renderThread(ticket, messages){
const wrap=document.getElementById('thread-msgs');
const rows=[];
// Initial message from ticket itself
rows.push(`<div style="display:flex;flex-direction:column;align-items:flex-end">
<div class="thread-bubble mine">${esc(ticket.message)}</div>
<div class="thread-bubble-meta" style="text-align:right">${fmtDateTime(ticket.created_at)}</div>
</div>`);
messages.forEach(m=>{
const isAdmin=m.role==='admin';
const isMine=!isAdmin;
rows.push(`<div style="display:flex;flex-direction:column;align-items:${isMine?'flex-end':'flex-start'}">
<div class="thread-bubble ${isMine?'mine':'other'}${isAdmin?' admin-bubble':''}">${esc(m.message)}</div>
<div class="thread-bubble-meta" style="text-align:${isMine?'right':'left'}">${isAdmin?'🛡️ Admin · ':''}${fmtDateTime(m.created_at)}</div>
</div>`);
});
if(!rows.length){ wrap.innerHTML='<div class="empty" style="padding:8px 0">Noch keine Nachrichten</div>'; return; }
wrap.innerHTML=rows.join('');
wrap.scrollTop=wrap.scrollHeight;
}
async function sendThreadReply(){
if(!activeTicket) return;
const inp=document.getElementById('thread-input');
const message=inp.value.trim();
if(!message){ toast('Nachricht darf nicht leer sein','error'); return; }
const r=await api('POST',`tickets/${activeTicket.id}/messages`,{message});
if(r.error){ toast(r.error,'error'); return; }
inp.value='';
await loadTickets();
const t=ticketsData.find(x=>x.id===activeTicket.id);
if(t){ activeTicket=t; document.getElementById('thread-meta').textContent=`Status: ${({open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'}[t.status]||t.status)} · Erstellt: ${fmtDateTime(t.created_at)}`; }
const msgs=await api('GET',`tickets/${activeTicket.id}/messages`);
renderThread(activeTicket, msgs.error?[]:msgs);
}
function closeThread(){
document.getElementById('thread-overlay').style.display='none';
activeTicket=null;
}
// ── DARK MODE ─────────────────────────────────────────────────
function toggleDark(){
const dark=document.body.classList.toggle('dark');
localStorage.setItem('dark',dark?'1':'');
document.getElementById('btn-dark').textContent=dark?'☀️':'🌙';
}
if(localStorage.getItem('dark')){
document.body.classList.add('dark');
document.getElementById('btn-dark').textContent='☀️';
}
// ── SIDEBAR ───────────────────────────────────────────────────
function openSidebar(){
document.getElementById('sidebar').classList.add('open');
document.getElementById('sidebar-backdrop').classList.add('open');
document.body.style.overflow='hidden';
}
function closeSidebar(){
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-backdrop').classList.remove('open');
document.body.style.overflow='';
}
// ── KEYBOARD ──────────────────────────────────────────────────
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeModal();closeSettings();closeSidebar();closeThread();closeNewTicket();}
});
// ── START ─────────────────────────────────────────────────────
init();
</script>
</body>
</html>