feat(frontend): merge paper/notebook design overhaul

Complete redesign of the SvelteKit frontend with a paper/notebook
aesthetic: design tokens, Google Fonts, 10 new components, responsive
student check-in, live seat map, and redesigned admin pages.
This commit is contained in:
2026-04-29 01:27:55 +02:00
27 changed files with 2510 additions and 1299 deletions

151
frontend/src/app.css Normal file
View File

@@ -0,0 +1,151 @@
/* Academic / paper-inspired design system for Tutormanager */
:root {
--paper: #f4efe6;
--paper-2: #ebe4d6;
--paper-3: #ded4c0;
--rule: #c9bfa9;
--rule-soft: #d9d0bb;
--ink: #1f1b16;
--ink-2: #3a342b;
--ink-3: #6b6356;
--ink-4: #968b7a;
--accent: #8a2c1f; /* oxblood */
--accent-soft: #c66a5b;
--highlight: #f1d36a; /* highlighter yellow */
--highlight-soft: #f5e3a4;
--green: #4a6b3a; /* present */
--red: #8a2c1f; /* absent / lock */
--amber: #b07d2a; /* open */
--serif: "Source Serif 4", "Source Serif Pro", "EB Garamond", Georgia, serif;
--sans: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
/* Paper grain — subtle SVG noise overlay */
.paper-bg {
background-color: var(--paper);
background-image:
radial-gradient(circle at 25% 15%, rgba(160,140,110,0.05) 0, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(160,140,110,0.04) 0, transparent 50%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.12 0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0.06 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
/* Ruled lines — like a notebook */
.ruled {
background-image: repeating-linear-gradient(
to bottom,
transparent 0,
transparent 27px,
var(--rule-soft) 27px,
var(--rule-soft) 28px
);
}
* { box-sizing: border-box; }
body, .ui {
font-family: var(--sans);
color: var(--ink);
font-feature-settings: "ss01", "cv11";
}
.serif { font-family: var(--serif); font-weight: 400; letter-spacing: -0.01em; }
.mono { font-family: var(--mono); }
.h1 { font-family: var(--serif); font-weight: 500; font-size: 44px; line-height: 1.05; letter-spacing: -0.02em; color: var(--ink); }
.h2 { font-family: var(--serif); font-weight: 500; font-size: 28px; line-height: 1.15; letter-spacing: -0.01em; color: var(--ink); }
.h3 { font-family: var(--serif); font-weight: 500; font-size: 20px; line-height: 1.2; color: var(--ink); }
.eyebrow { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-3); }
.body { font-family: var(--sans); font-size: 14px; line-height: 1.5; color: var(--ink-2); }
.small { font-family: var(--sans); font-size: 12px; color: var(--ink-3); }
.tiny { font-family: var(--sans); font-size: 11px; color: var(--ink-3); }
/* Underline marker — looks like a hand drawn highlight stroke */
.marker {
background: linear-gradient(180deg, transparent 60%, var(--highlight-soft) 60%, var(--highlight-soft) 92%, transparent 92%);
padding: 0 2px;
}
/* Status pills */
.pill {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase;
padding: 3px 9px; border-radius: 999px;
border: 1px solid currentColor;
background: transparent;
}
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill.open { color: var(--amber); background: rgba(176,125,42,0.08); }
.pill.closed { color: var(--ink-3); }
.pill.locked { color: var(--accent); background: rgba(138,44,31,0.06); }
.pill.present { color: var(--green); background: rgba(74,107,58,0.08); }
.pill.absent { color: var(--accent); }
/* Buttons */
.btn {
font-family: var(--sans); font-size: 13px; font-weight: 500;
padding: 8px 14px; border-radius: 6px;
border: 1px solid var(--ink); background: var(--ink); color: var(--paper);
cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
}
.btn.ghost { background: transparent; color: var(--ink); border-color: var(--rule); }
.btn.ghost:hover { background: rgba(0,0,0,0.04); }
.btn.accent { background: var(--accent); border-color: var(--accent); color: #f7eedc; }
.btn.sm { padding: 5px 10px; font-size: 12px; }
/* Card */
.card {
background: #fbf7ee;
border: 1px solid var(--rule);
border-radius: 4px;
box-shadow: 0 1px 0 rgba(0,0,0,0.03);
}
/* Marginalia — handwritten look. Use Caveat as marginal handwriting. */
.handwritten {
font-family: "Caveat", "Kalam", cursive;
color: var(--accent);
font-size: 18px;
line-height: 1;
}
/* Custom scrollbars */
.scroll::-webkit-scrollbar { width: 8px; height: 8px; }
.scroll::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 8px; }
.scroll::-webkit-scrollbar-track { background: transparent; }
/* hand-drawn underline svg accent under section titles */
.underline-stroke { display: inline-block; margin-top: 2px; }
/* Stamp — for "PRÄSENT" mark */
.stamp {
display: inline-block; font-family: var(--mono); font-weight: 700; font-size: 11px;
letter-spacing: 0.15em; text-transform: uppercase;
color: var(--accent); border: 2px solid var(--accent);
padding: 3px 8px; border-radius: 3px;
transform: rotate(-4deg);
background: rgba(138,44,31,0.04);
}
/* Subtle hover row */
.row-hover:hover { background: rgba(0,0,0,0.025); }
/* Tab bar */
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--rule); }
.tab { font-family: var(--sans); font-size: 13px; padding: 10px 14px; color: var(--ink-3); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tab.active { color: var(--ink); border-bottom-color: var(--ink); }
/* Input */
.input {
font-family: var(--sans); font-size: 13px;
padding: 8px 11px; border: 1px solid var(--rule);
background: #fbf7ee; border-radius: 4px;
color: var(--ink);
}
.input:focus { outline: none; border-color: var(--ink); }
/* Inline divider */
.div-h { height: 1px; background: var(--rule); width: 100%; }

View File

@@ -4,6 +4,9 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,500;0,8..60,600;0,8..60,700;1,8..60,400&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&family=Caveat:wght@400;500;600&display=swap">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -125,10 +125,10 @@ export const api = {
}
},
checkin: {
getInfo: (code: string) => request<any>(`/api/checkin/${code}`),
getStudents: (code: string) => request<any[]>(`/api/checkin/${code}/students`),
post: (code: string, student_id: number, seat_id?: string) =>
request<any>('/api/checkin', {
getInfo: (code: string) => request<any>(`/checkin/${code}`),
getStudents: (code: string) => request<any[]>(`/checkin/${code}/students`),
post: (code: string, student_id: number, seat_id?: string) =>
request<any>('/checkin', {
method: 'POST',
body: JSON.stringify({ code, student_id, seat_id })
}),

View File

@@ -0,0 +1,24 @@
<script lang="ts">
const { label, value = '', mono = false, suffix, readonly = false } = $props<{
label: string;
value?: string;
mono?: boolean;
suffix?: string;
readonly?: boolean;
}>();
</script>
<div style="display:flex;flex-direction:column;gap:3px">
<span class="tiny" style="color:var(--ink-3)">{label}</span>
<div style="display:flex;align-items:center;gap:6px">
<input
class="input"
style={mono ? 'font-family:var(--mono);font-size:12px' : ''}
value={value}
readonly={readonly}
/>
{#if suffix}
<span class="tiny" style="color:var(--ink-4)">{suffix}</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
const { name, size = 14 } = $props<{
name: 'check'|'x'|'lock'|'open'|'copy'|'edit'|'download'|'arrow'|'search'|'plus';
size?: number;
}>();
</script>
{#if name === 'check'}
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.5 L5.5 10.5 L11.5 3.5"/>
</svg>
{:else if name === 'x'}
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M3 3 L9 9 M9 3 L3 9"/>
</svg>
{:else if name === 'lock'}
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3">
<rect x="2.5" y="6" width="8" height="5.5" rx="1"/>
<path d="M4.5 6 V4.5 a2 2 0 0 1 4 0 V6"/>
</svg>
{:else if name === 'open'}
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3">
<rect x="2.5" y="6" width="8" height="5.5" rx="1"/>
<path d="M4.5 6 V4.5 a2 2 0 0 1 4 0"/>
</svg>
{:else if name === 'copy'}
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2">
<rect x="2" y="2" width="7" height="7" rx="1"/>
<rect x="4.5" y="4.5" width="7" height="7" rx="1"/>
</svg>
{:else if name === 'edit'}
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round">
<path d="M2 11 L2 9 L9 2 L11 4 L4 11 Z"/>
</svg>
{:else if name === 'download'}
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 2 V9 M3.5 6 L7 9 L10.5 6 M2.5 11.5 H11.5"/>
</svg>
{:else if name === 'arrow'}
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6 H9 M6.5 3 L9 6 L6.5 9"/>
</svg>
{:else if name === 'search'}
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3">
<circle cx="5.5" cy="5.5" r="3.5"/>
<path d="M8 8 L11 11" stroke-linecap="round"/>
</svg>
{:else if name === 'plus'}
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
<path d="M6 2 V10 M2 6 H10"/>
</svg>
{/if}

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import type { Student, Attendance, Note } from '$lib/types';
import { api } from '$lib/api';
const {
slotId,
students = [],
attendances = [],
notes = [],
selectedStudentId = null,
onStudentSelect,
weekNr = 0,
} = $props<{
slotId: number;
students?: Student[];
attendances?: Attendance[];
notes?: Note[];
selectedStudentId?: number | null;
onStudentSelect?: (id: number) => void;
weekNr?: number;
}>();
const TAGS = [
'aktiv beteiligt',
'stille:r Kämpfer:in',
'verstanden ✓',
'nochmal aufgreifen',
'Rückfrage offen',
'elegante Lösung',
];
const presentIds = $derived(new Set(attendances.map((a: Attendance) => a.student_id)));
const present = $derived(students.filter((s: Student) => presentIds.has(s.id)));
const absent = $derived(students.filter((s: Student) => !presentIds.has(s.id)));
const selected = $derived(students.find((s: Student) => s.id === selectedStudentId) ?? null);
const selectedAttendance = $derived(attendances.find((a: Attendance) => a.student_id === selectedStudentId) ?? null);
function initials(name: string): string {
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
}
function checkinTime(studentId: number): string {
const att = attendances.find((a: Attendance) => a.student_id === studentId);
if (!att) return '—';
return att.checked_in_at.slice(11, 16);
}
function hasNote(studentId: number): boolean {
return notes.some((n: Note) => n.student_id === studentId && n.content.trim());
}
// Note editing state
let noteContent = $state('');
let savedAt = $state('');
let saveTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (selectedStudentId == null) {
noteContent = '';
return;
}
const note = notes.find((n: Note) => n.student_id === selectedStudentId);
noteContent = note?.content ?? '';
});
function onNoteInput(e: Event) {
noteContent = (e.target as HTMLTextAreaElement).value;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveNote, 800);
}
async function saveNote() {
if (selectedStudentId == null) return;
try {
await api.admin.slots.upsertNote(slotId, selectedStudentId, noteContent);
const now = new Date();
savedAt = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
} catch (_) {
// silent — user sees no feedback on transient failure
}
}
function appendTag(tag: string) {
noteContent = noteContent ? `${noteContent}\n+ ${tag}` : `+ ${tag}`;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveNote, 800);
}
</script>
<div class="card" style="display:flex;flex-direction:column;overflow:hidden;height:100%">
<!-- Roster header -->
<div style="padding:14px 16px 10px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:16px;font-weight:500">Studierende</div>
<div class="tiny" style="margin-top:2px">{present.length} anwesend · {absent.length} fehlen</div>
</div>
<!-- Roster list -->
<div class="scroll" style="overflow-y:auto;max-height:220px;padding:6px 0">
{#each present as s}
{@const isSel = s.id === selectedStudentId}
<button
style="width:100%;text-align:left;border:none;background:{isSel ? 'rgba(31,27,22,0.06)' : 'transparent'};padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;border-left:{isSel ? '3px solid var(--ink)' : '3px solid transparent'}"
class="row-hover"
onclick={() => onStudentSelect?.(s.id)}
>
<span style="width:22px;height:22px;border-radius:50%;background:var(--ink);color:var(--paper);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;flex-shrink:0">
{initials(s.name)}
</span>
<span style="flex:1;font-size:13px">{s.name}</span>
{#if hasNote(s.id)}
<span style="width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
{/if}
<span class="mono tiny" style="color:var(--ink-4)">{checkinTime(s.id)}</span>
</button>
{/each}
{#each absent as s}
<button
style="width:100%;text-align:left;border:none;background:transparent;padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;opacity:0.55;border-left:3px solid transparent"
class="row-hover"
onclick={() => onStudentSelect?.(s.id)}
>
<span style="width:22px;height:22px;border-radius:50%;background:transparent;color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;border:1px dashed var(--ink-4);flex-shrink:0">
{initials(s.name)}
</span>
<span style="flex:1;font-size:13px;text-decoration:line-through;text-decoration-color:var(--ink-4)">{s.name}</span>
<span class="mono tiny" style="color:var(--ink-4)"></span>
</button>
{/each}
</div>
<!-- Note editor -->
{#if selected}
<div style="border-top:1px solid var(--rule);padding:14px 16px;flex:1;display:flex;flex-direction:column;background:#fbf7ee">
<!-- Selected student header -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
<span style="width:32px;height:32px;border-radius:50%;background:var(--ink);color:var(--paper);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:11px;font-weight:600;flex-shrink:0">
{initials(selected.name)}
</span>
<div style="flex:1">
<div class="serif" style="font-size:16px;font-weight:500">{selected.name}</div>
<div class="tiny">
Sitzplatz {selectedAttendance?.seat_id ?? '—'} · seit {checkinTime(selected.id)}
</div>
</div>
<span class="stamp">Präsent</span>
</div>
<div class="eyebrow" style="margin-top:6px;margin-bottom:6px">
Notiz · Woche {String(weekNr).padStart(2, '0')}
</div>
<textarea
value={noteContent}
oninput={onNoteInput}
placeholder="Beobachtungen für diese Woche…"
class="ruled"
style="flex:1;min-height:110px;resize:none;font-family:var(--serif);font-size:15px;line-height:28px;padding:0 0 4px 0;background:transparent;border:none;outline:none;color:var(--ink);width:100%"
></textarea>
<!-- Quick tags -->
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
{#each TAGS as tag}
<button
class="pill closed"
style="border-color:var(--rule);cursor:pointer;font-family:var(--sans);text-transform:none;font-size:11px;letter-spacing:0"
onclick={() => appendTag(tag)}
>+ {tag}</button>
{/each}
</div>
<!-- Footer -->
<div class="tiny" style="margin-top:10px;display:flex;justify-content:space-between">
<span>{savedAt ? `Auto-gespeichert · ${savedAt}` : 'Noch nicht gespeichert'}</span>
<a href="/admin/students" style="color:var(--ink-3)">Notizen vergangener Wochen ↗</a>
</div>
</div>
{:else}
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:24px">
<span class="small" style="color:var(--ink-4)">Sitz anklicken um Notiz zu schreiben</span>
</div>
{/if}
</div>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
interface SeatDef {
id: string;
x: number;
y: number;
table: string;
}
interface StudentRef {
id: number;
name: string;
initials: string;
}
const {
assignments = {},
students = [],
selectedStudent = null,
onSeatClick,
variant = 'tutor',
ownSeat = null,
scale = 1,
} = $props<{
assignments?: Record<string, number>;
students?: StudentRef[];
selectedStudent?: number | null;
onSeatClick?: (seat: SeatDef) => void;
variant?: 'tutor' | 'student' | 'student-self';
ownSeat?: string | null;
scale?: number;
}>();
const W = 760;
const H = 460;
const WALLS = { x: 12, y: 12, w: 736, h: 436 };
const WINDOW = { x: 12, y: 120, w: 6, h: 220 };
const DOOR = { x: 60, y: 448, w: 70, h: 6 };
const BEAMER = { x: 372, y: 24, w: 110, h: 8 };
const PODIUM = { x: 332, y: 60, w: 190, h: 38 };
const TABLES = [
{ id: 'T1', x: 90, y: 150, w: 200, h: 70, label: 'T1' },
{ id: 'T2', x: 470, y: 150, w: 200, h: 70, label: 'T2' },
{ id: 'T3', x: 90, y: 320, w: 200, h: 70, label: 'T3' },
{ id: 'T4', x: 470, y: 320, w: 200, h: 70, label: 'T4' },
];
function makeSeats(): SeatDef[] {
return TABLES.flatMap((t) => [
{ id: `${t.id}-1`, x: t.x + t.w * 0.28, y: t.y - 22, table: t.id },
{ id: `${t.id}-2`, x: t.x + t.w * 0.72, y: t.y - 22, table: t.id },
{ id: `${t.id}-3`, x: t.x + t.w * 0.28, y: t.y + t.h + 22, table: t.id },
{ id: `${t.id}-4`, x: t.x + t.w * 0.72, y: t.y + t.h + 22, table: t.id },
{ id: `${t.id}-5`, x: t.x + t.w + 26, y: t.y + t.h / 2, table: t.id },
]);
}
const SEATS = makeSeats();
function studentById(id: number): StudentRef | undefined {
return students.find((s: StudentRef) => s.id === id);
}
function seatStyle(seat: SeatDef): { bg: string; border: string; label: string; labelColor: string; shadow: string } {
const sid = assignments[seat.id];
const student = sid ? studentById(sid) : undefined;
const isSelected = selectedStudent != null && sid === selectedStudent;
const isOwn = ownSeat === seat.id;
if (variant === 'tutor') {
if (student) {
return {
bg: isSelected ? 'var(--ink)' : '#fbf7ee',
border: isSelected ? 'var(--ink)' : 'var(--ink-2)',
label: student.initials,
labelColor: isSelected ? '#f7eedc' : 'var(--ink)',
shadow: isSelected ? '0 0 0 3px rgba(241,211,106,0.6)' : 'none',
};
}
return { bg: '#f7f1e3', border: 'var(--ink-4)', label: '', labelColor: 'var(--ink-4)', shadow: 'none' };
}
if (variant === 'student') {
if (isOwn) {
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
}
if (sid) {
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
}
return { bg: '#fbf7ee', border: 'var(--ink-2)', label: '', labelColor: '', shadow: 'none' };
}
// student-self (read-only)
if (isOwn) {
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
}
if (sid) {
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
}
return { bg: '#fbf7ee', border: 'var(--ink-3)', label: '', labelColor: '', shadow: 'none' };
}
</script>
<div style="position:relative;width:{W * scale}px;height:{H * scale}px;background:#f7f1e3;border:1px solid var(--rule);border-radius:4px;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.02),0 1px 0 rgba(0,0,0,0.03)">
<!-- Inner graph-paper grid -->
<div style="position:absolute;inset:0;background-image:linear-gradient(to right,rgba(110,90,60,0.05) 1px,transparent 1px),linear-gradient(to bottom,rgba(110,90,60,0.05) 1px,transparent 1px);background-size:{24*scale}px {24*scale}px"></div>
<!-- Design-space inner container at 1× coords, CSS-scaled -->
<div style="position:absolute;inset:0;transform:scale({scale});transform-origin:top left;width:{W}px;height:{H}px">
<!-- Walls -->
<div style="position:absolute;left:{WALLS.x}px;top:{WALLS.y}px;width:{WALLS.w}px;height:{WALLS.h}px;border:2px solid var(--ink-2);border-radius:2px"></div>
<!-- Window -->
<div style="position:absolute;left:{WINDOW.x - 2}px;top:{WINDOW.y}px;width:{WINDOW.w + 4}px;height:{WINDOW.h}px;background:#dfeaf0;border-top:2px solid var(--ink-2);border-bottom:2px solid var(--ink-2)">
<div style="position:absolute;left:1px;top:50%;width:{WINDOW.w + 2}px;height:2px;background:var(--ink-2)"></div>
</div>
<div style="position:absolute;left:-8px;top:{WINDOW.y + WINDOW.h / 2 - 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);transform:rotate(-90deg);transform-origin:left top;letter-spacing:0.1em;text-transform:uppercase">Fenster</div>
<!-- Door gap + arc -->
<div style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 2}px;width:{DOOR.w}px;height:4px;background:#f7f1e3"></div>
<svg style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 36}px;pointer-events:none" width="{DOOR.w + 10}" height="40">
<path d="M 2 38 Q 2 2 {DOOR.w} 2" stroke="var(--ink-3)" stroke-width="1" stroke-dasharray="2 2" fill="none"/>
<line x1="2" y1="38" x2="2" y2="2" stroke="var(--ink-2)" stroke-width="1.5"/>
</svg>
<div style="position:absolute;left:{DOOR.x + 6}px;top:{DOOR.y + 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Tür</div>
<!-- Beamer -->
<div style="position:absolute;left:{BEAMER.x}px;top:{BEAMER.y}px;width:{BEAMER.w}px;height:{BEAMER.h}px;background:var(--ink-2);border-radius:1px"></div>
<div style="position:absolute;left:{BEAMER.x + BEAMER.w + 6}px;top:{BEAMER.y}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Beamer</div>
<!-- Podium -->
<div style="position:absolute;left:{PODIUM.x}px;top:{PODIUM.y}px;width:{PODIUM.w}px;height:{PODIUM.h}px;border:1.5px solid var(--ink-2);background:#efe6d2;border-radius:2px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:0.15em;text-transform:uppercase">
Pult · Tutor:in
</div>
<!-- Tables -->
{#each TABLES as t}
<div style="position:absolute;left:{t.x}px;top:{t.y}px;width:{t.w}px;height:{t.h}px;background:#e8dec5;border:1.5px solid var(--ink-2);border-radius:3px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:22px;color:rgba(31,27,22,0.35);font-style:italic">
{t.label}
</div>
{/each}
<!-- Seats -->
{#each SEATS as seat}
{@const s = seatStyle(seat)}
<button
style="position:absolute;left:{seat.x - 18}px;top:{seat.y - 18}px;width:36px;height:36px;border-radius:50%;background:{s.bg};border:1.5px solid {s.border};cursor:{onSeatClick && variant !== 'student-self' ? 'pointer' : 'default'};display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-weight:600;font-size:11px;color:{s.labelColor};padding:0;box-shadow:{s.shadow};transition:background 120ms,border-color 120ms"
title={assignments[seat.id] ? (studentById(assignments[seat.id])?.name ?? 'besetzt') : 'frei'}
disabled={variant === 'student-self'}
onclick={() => onSeatClick?.(seat)}
>
{s.label}
</button>
{/each}
<!-- Compass -->
<div style="position:absolute;right:18px;bottom:14px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.15em">
<div style="display:flex;flex-direction:column;align-items:center">
<span>N</span>
<svg width="14" height="22">
<path d="M7 2 L7 20 M3 6 L7 2 L11 6" stroke="var(--ink-3)" stroke-width="1" fill="none"/>
</svg>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
const { label, value, suffix, hint, accent } = $props<{
label: string;
value: string | number;
suffix?: string;
hint?: string;
accent?: string;
}>();
</script>
<div class="card" style="padding: 16px 20px">
<span class="eyebrow">{label}</span>
<div style="display:flex;align-items:baseline;gap:6px;margin-top:8px">
<span style="font-family:var(--serif);font-size:32px;font-weight:500;line-height:1;color:{accent || 'var(--ink)'}">
{value}
</span>
{#if suffix}
<span class="tiny" style="color:var(--ink-4)">{suffix}</span>
{/if}
</div>
{#if hint}
<div class="tiny" style="color:var(--ink-4);margin-top:4px">{hint}</div>
{/if}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
const { status } = $props<{
status: 'open' | 'closed' | 'locked' | 'present' | 'absent';
}>();
const labels: Record<string, string> = {
open: 'OFFEN',
closed: 'GESCHL.',
locked: 'GESPERRT',
present: 'ANWESEND',
absent: 'FEHLT',
};
</script>
<span class="pill {status}"><span class="dot"></span>{labels[status]}</span>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
const { label, value, total, suffix, accent } = $props<{
label: string;
value: string | number;
total?: number;
suffix?: string;
accent?: string;
}>();
</script>
<div style="display:flex;flex-direction:column;gap:2px">
<span class="eyebrow">{label}</span>
<span style="font-family:var(--serif);font-size:22px;font-weight:500;color:{accent || 'var(--ink)'}">
{value}
{#if total !== undefined}
<span class="tiny" style="color:var(--ink-4)"> / {total}</span>
{:else if suffix}
<span class="tiny" style="color:var(--ink-4)"> {suffix}</span>
{/if}
</span>
</div>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import type { Snippet } from 'svelte';
const {
activePath,
courseName = '',
semester = '',
weekday = '',
tutorName = '',
tutorEmail = '',
children,
} = $props<{
activePath: string;
courseName?: string;
semester?: string;
weekday?: string;
tutorName?: string;
tutorEmail?: string;
children: Snippet;
}>();
const navItems = [
{ id: 'dashboard', label: 'Dashboard', href: '/admin' },
{ id: 'live', label: 'Live · Sitzplan', href: '/admin/sessions' },
{ id: 'attendance', label: 'Anwesenheit', href: '/admin/attendance' },
{ id: 'rooms', label: 'Räume', href: '/admin/rooms' },
{ id: 'students', label: 'Studierende', href: '/admin/students' },
];
function isActive(item: { id: string; href: string }): boolean {
if (item.id === 'dashboard') return activePath === '/admin';
if (item.id === 'live') return activePath.startsWith('/admin/live') || activePath.startsWith('/admin/sessions');
return activePath.startsWith(item.href);
}
function initials(name: string): string {
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
}
</script>
<div class="paper-bg" style="width:100%;min-height:100vh;display:grid;grid-template-columns:220px 1fr;overflow:hidden">
<aside style="border-right:1px solid var(--rule);padding:20px 18px;background:rgba(0,0,0,0.015);display:flex;flex-direction:column;gap:18px;min-height:100vh">
<!-- Brand -->
<div>
<div class="serif" style="font-size:22px;font-weight:500;letter-spacing:-0.01em">
Tutor<span style="color:var(--accent)">·</span>manager
</div>
<div class="tiny" style="margin-top:2px">v0.1 · Puchstein</div>
</div>
<!-- Course block -->
{#if courseName}
<div>
<div class="eyebrow" style="margin-bottom:8px">Kurs</div>
<div style="padding:10px 12px;background:#fbf7ee;border:1px solid var(--rule);border-radius:4px">
<div class="serif" style="font-size:14px;font-weight:500">{courseName}</div>
<div class="tiny" style="margin-top:2px">{semester}{weekday ? ` · ${weekday}s` : ''}</div>
</div>
</div>
{/if}
<!-- Navigation -->
<nav style="display:flex;flex-direction:column;gap:1px">
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
{#each navItems as item}
{@const active = isActive(item)}
<a
href={item.href}
style="text-align:left;text-decoration:none;background:{active ? 'rgba(31,27,22,0.08)' : 'transparent'};padding:8px 10px;border-radius:4px;font-family:var(--sans);font-size:13px;color:{active ? 'var(--ink)' : 'var(--ink-2)'};font-weight:{active ? 500 : 400};display:flex;align-items:center;gap:8px"
>
<span style="width:4px;height:4px;border-radius:50%;flex-shrink:0;background:{active ? 'var(--accent)' : 'var(--ink-4)'}"></span>
{item.label}
</a>
{/each}
</nav>
<div style="flex:1"></div>
<!-- User profile -->
<div style="border-top:1px solid var(--rule);padding-top:14px;display:flex;align-items:center;gap:8px">
<span style="width:28px;height:28px;border-radius:50%;background:var(--ink);color:var(--paper);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:10px;font-weight:600;flex-shrink:0">
{tutorName ? initials(tutorName) : 'T'}
</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:500">{tutorName || 'Tutor:in'}</div>
<div class="tiny" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{tutorEmail}</div>
</div>
</div>
</aside>
<main style="overflow:auto">
{@render children()}
</main>
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
const { width = 110, color = 'var(--accent)' } = $props<{
width?: number;
color?: string;
}>();
</script>
<svg
width={width}
height="8"
viewBox="0 0 {width} 8"
fill="none"
style="display:block;margin-top:2px"
>
<path
d="M 2 5 Q {width * 0.25} 1 {width * 0.5} 4 T {width - 2} 3"
stroke={color}
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>

View File

@@ -1 +1,4 @@
<script>
import '../app.css';
</script>
<slot />

View File

@@ -1,47 +1,9 @@
<script lang="ts">
import { token } from '$lib/auth';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { token } from '$lib/auth';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
if ($token) {
goto('/admin');
}
});
onMount(() => {
goto($token ? '/admin' : '/admin/login');
});
</script>
<div class="welcome">
<h1>FPTutor Attendance</h1>
<p>Efficiently tracking attendance and student observations.</p>
<div class="actions">
<a href="/login" class="btn">Tutor Login</a>
</div>
<div class="footer">
<p>Students: Please use the link provided by your tutor during the session.</p>
</div>
</div>
<style>
.welcome {
max-width: 600px;
margin: 100px auto;
text-align: center;
padding: 40px;
background: #f8f9fa;
border-radius: 12px;
}
h1 { font-size: 2.5em; color: #333; margin-bottom: 10px; }
p { color: #666; font-size: 1.2em; }
.actions { margin-top: 40px; }
.btn {
background: #007bff;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
}
.footer { margin-top: 60px; font-size: 0.9em; color: #888; }
</style>

View File

@@ -1,65 +1,50 @@
<script lang="ts">
import { token, logout } from '$lib/auth';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { token, logout } from '$lib/auth';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import TutorShell from '$lib/components/TutorShell.svelte';
import type { Course } from '$lib/types';
onMount(() => {
if (!$token) {
goto('/login');
}
});
const { children }: { children: Snippet } = $props();
function handleLogout() {
logout();
goto('/login');
let course = $state<Course | null>(null);
onMount(async () => {
if (!$token) {
goto('/admin/login');
return;
}
try {
const courses = await api.admin.courses.list();
if (courses.length > 0) course = courses[0];
} catch (_) {}
});
$effect(() => {
if (!$token) goto('/admin/login');
});
function handleLogout() {
logout();
goto('/admin/login');
}
const activePath = $derived($page.url.pathname);
</script>
{#if $token}
<nav>
<div class="nav-content">
<div class="links">
<a href="/admin">Dashboard</a>
<a href="/admin/courses">Courses</a>
<a href="/admin/rooms">Rooms</a>
<a href="/admin/sessions">Sessions</a>
<a href="/admin/attendance">Attendance</a>
<a href="/admin/notes">Notes</a>
<a href="/admin/export">Export</a>
</div>
<button on:click={handleLogout}>Logout</button>
</div>
</nav>
<main>
<slot />
</main>
<TutorShell
{activePath}
courseName={course?.name ?? ''}
semester={course?.semester ?? ''}
>
{#snippet children()}
{@render children()}
{/snippet}
</TutorShell>
{:else}
{@render children()}
{/if}
<style>
nav {
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 10px 20px;
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px;
margin: 0 auto;
}
.links a {
margin-right: 20px;
text-decoration: none;
color: #333;
}
.links a:hover {
color: #007bff;
}
main {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
</style>

View File

@@ -1,147 +1,214 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Session, Slot } from '$lib/types';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Session, Slot } from '$lib/types';
import StatusPill from '$lib/components/StatusPill.svelte';
import StatCard from '$lib/components/StatCard.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
import Icon from '$lib/components/Icon.svelte';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let sessions = $state<Session[]>([]);
let loading = $state(false);
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let sessions = $state<Session[]>([]);
let loading = $state(false);
onMount(async () => {
loading = true;
try {
courses = await api.admin.courses.list();
if (courses.length > 0) {
selectedCourseId = courses[0].id;
}
} catch (e) {
console.error(e);
} finally {
loading = false;
}
});
$effect(() => {
if (selectedCourseId !== null) {
loadSessions(selectedCourseId);
}
});
async function loadSessions(courseId: number) {
loading = true;
try {
sessions = await api.admin.sessions.list(courseId);
} catch (e) {
console.error(e);
} finally {
loading = false;
}
onMount(async () => {
loading = true;
try {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
} catch (e) {
console.error(e);
} finally {
loading = false;
}
});
async function updateStatus(slotId: number, status: string) {
try {
await api.admin.slots.updateStatus(slotId, status);
if (selectedCourseId) loadSessions(selectedCourseId);
} catch (e) {
alert(e);
}
}
$effect(() => {
if (selectedCourseId !== null) loadSessions(selectedCourseId);
});
function copyLink(code: string) {
const url = `${window.location.origin}/s/${code}`;
navigator.clipboard.writeText(url);
alert('Link copied to clipboard');
async function loadSessions(courseId: number) {
loading = true;
try {
sessions = await api.admin.sessions.list(courseId);
} catch (e) {
console.error(e);
} finally {
loading = false;
}
}
async function updateStatus(slotId: number, status: string) {
try {
await api.admin.slots.updateStatus(slotId, status);
if (selectedCourseId) loadSessions(selectedCourseId);
} catch (e) {
alert(e);
}
}
function copyLink(code: string) {
const url = `${window.location.origin}/s/${code}`;
navigator.clipboard.writeText(url);
}
// Derived stats
const allSlots = $derived(sessions.flatMap((s: Session) => s.slots ?? []));
const openSlots = $derived(allSlots.filter((s: Slot) => s.status === 'open'));
const lockedSlots = $derived(allSlots.filter((s: Slot) => s.status === 'locked'));
const currentWeekNr = $derived(sessions[0]?.week_nr ?? 1);
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
// Flat list for the table: sessions × slots, sorted newest first
interface SlotRow {
slot: Slot;
session: Session;
}
const slotRows = $derived<SlotRow[]>(
sessions.flatMap((session: Session) =>
(session.slots ?? []).map((slot: Slot) => ({ slot, session }))
)
);
function weekLabel(n: number): string {
return `W${String(n).padStart(2, '0')}`;
}
</script>
<h1>Admin Dashboard</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<div class="course-selector">
<label for="course">Select Course:</label>
<select id="course" bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
</div>
{#if loading && sessions.length === 0}
<p>Loading sessions...</p>
{:else}
<div class="sessions">
{#each sessions as session}
<div class="session-card">
<h2>Week {session.week_nr} - {session.date}</h2>
<div class="slots">
{#if session.slots && session.slots.length > 0}
{#each session.slots as slot}
<div class="slot-row">
<div class="slot-info">
<span class="status-badge {slot.status}">{slot.status}</span>
<strong>{slot.start_time} - {slot.end_time}</strong>
{#if slot.code}
<code class="checkin-code">{slot.code}</code>
{/if}
</div>
<div class="actions">
{#if slot.status === 'closed'}
<button onclick={() => updateStatus(slot.id, 'open')}>Open</button>
{:else if slot.status === 'open'}
<button onclick={() => copyLink(slot.code!)}>Copy Link</button>
<button onclick={() => updateStatus(slot.id, 'locked')}>Lock</button>
<button onclick={() => updateStatus(slot.id, 'closed')}>Close</button>
{:else if slot.status === 'locked'}
<button onclick={() => updateStatus(slot.id, 'open')}>Unlock</button>
<button onclick={() => updateStatus(slot.id, 'closed')}>Close</button>
{/if}
</div>
</div>
{/each}
{:else}
<p>No slots scheduled.</p>
{/if}
</div>
</div>
{/each}
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px">
<div>
<div class="eyebrow">Dashboard</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
Diese Woche, <span class="marker">Woche {String(currentWeekNr).padStart(2, '0')}</span>
</div>
{#if selectedCourse}
<div class="small" style="margin-top:6px;color:var(--ink-3)">
{selectedCourse.name} · {selectedCourse.semester}
</div>
{/if}
</div>
{/if}
<div style="display:flex;align-items:center;gap:10px">
{#if courses.length > 1}
<select
class="input"
style="font-size:12px"
bind:value={selectedCourseId}
>
{#each courses as course}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
{/if}
<a href={api.admin.export.courseCsv(selectedCourseId ?? 0)} class="btn ghost">
<Icon name="download" /> Export
</a>
</div>
</header>
<style>
.course-selector {
margin-bottom: 20px;
}
.session-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.slot-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-top: 1px solid #eee;
}
.status-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
text-transform: uppercase;
margin-right: 10px;
}
.status-badge.closed { background: #6c757d; color: white; }
.status-badge.open { background: #28a745; color: white; }
.status-badge.locked { background: #ffc107; color: black; }
.checkin-code {
background: #f0f0f0;
padding: 2px 5px;
border-radius: 4px;
margin-left: 10px;
}
.actions button {
margin-left: 5px;
}
</style>
<!-- Stat row -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
<StatCard
label="Offene Slots"
value={openSlots.length}
hint={openSlots.length > 0 ? `Code: ${openSlots[0].code ?? '—'}` : 'Kein Slot offen'}
accent={openSlots.length > 0 ? 'var(--green)' : undefined}
/>
<StatCard
label="Abgeschlossene Slots"
value={lockedSlots.length}
hint="Gesperrt"
/>
<StatCard
label="Tutorien bisher"
value={sessions.length}
hint="Wochen mit Slot"
/>
<StatCard
label="Alle Slots"
value={allSlots.length}
hint={`${sessions.length} Wochen`}
/>
</div>
<!-- Slots table -->
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule)">
<div>
<div class="serif" style="font-size:18px;font-weight:500">Slots</div>
<UnderlineStroke width={50} />
</div>
<div class="small" style="color:var(--ink-3)">Neueste zuerst</div>
</div>
{#if loading && slotRows.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Wird geladen…</span>
</div>
{:else if slotRows.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Noch keine Slots geplant.</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Woche</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Datum</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Zeit</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Status</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Code</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
<tbody>
{#each slotRows as { slot, session }, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px">{weekLabel(session.week_nr)}</span>
</td>
<td style="padding:12px 14px">{session.date}</td>
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px">{slot.start_time}{slot.end_time}</span>
</td>
<td style="padding:12px 14px"><StatusPill status={slot.status} /></td>
<td style="padding:12px 14px">
{#if slot.code}
<span class="mono" style="font-size:12px;letter-spacing:0.08em">{slot.code}</span>
{:else}
<span class="tiny" style="color:var(--ink-4)"></span>
{/if}
</td>
<td style="padding:12px 14px;text-align:right">
<div style="display:flex;gap:6px;justify-content:flex-end">
{#if slot.status === 'closed'}
<button class="btn sm" onclick={() => updateStatus(slot.id, 'open')}>
<Icon name="open" size={12} /> Öffnen
</button>
{:else if slot.status === 'open'}
{#if slot.code}
<button class="btn ghost sm" onclick={() => copyLink(slot.code!)}>
<Icon name="copy" size={12} /> Kopieren
</button>
{/if}
<a href="/admin/live/{slot.id}" class="btn ghost sm">Anzeigen</a>
<button class="btn sm" onclick={() => updateStatus(slot.id, 'locked')}>
<Icon name="lock" size={12} /> Sperren
</button>
{:else if slot.status === 'locked'}
<a href="/admin/live/{slot.id}" class="btn ghost sm">Anzeigen</a>
<button class="btn ghost sm" onclick={() => updateStatus(slot.id, 'open')}>Öffnen</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</div>

View File

@@ -1,111 +1,220 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Session, Student, Attendance } from '$lib/types';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Session, Student, Attendance } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let sessions = $state<Session[]>([]);
let selectedSessionId = $state<number | null>(null);
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let sessions = $state<Session[]>([]);
let students = $state<Student[]>([]);
// Map: slotId → Attendance[]
let attendanceBySlot = $state<Record<number, Attendance[]>>({});
let allSlotIds = $state<number[]>([]);
let loading = $state(false);
let data = $state<{
students: Student[],
slots: any[],
attendances: Attendance[]
} | null>(null);
onMount(async () => {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
});
onMount(async () => {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
});
$effect(() => {
if (selectedCourseId) loadMatrix(selectedCourseId);
});
$effect(() => {
if (selectedCourseId) {
api.admin.sessions.list(selectedCourseId).then(res => {
sessions = res;
if (sessions.length > 0) selectedSessionId = sessions[0].id;
});
}
});
async function loadMatrix(courseId: number) {
loading = true;
try {
sessions = await api.admin.sessions.list(courseId);
students = await api.admin.courses.listStudents(courseId);
$effect(() => {
if (selectedSessionId) {
api.admin.sessions.getAttendance(selectedSessionId).then(res => data = res);
}
});
// Load attendance for each session in parallel
const perSession = await Promise.all(
sessions.map((s: Session) => api.admin.sessions.getAttendance(s.id))
);
async function toggleAttendance(slotId: number, studentId: number) {
if (!data) return;
const existing = data.attendances.find(a => a.slot_id === slotId && a.student_id === studentId);
try {
if (existing) {
await api.admin.slots.deleteAttendance(slotId, studentId);
} else {
await api.admin.slots.addAttendance(slotId, studentId);
}
data = await api.admin.sessions.getAttendance(selectedSessionId!);
} catch (e) {
alert(e);
}
const slotIds: number[] = [];
const newMap: Record<number, Attendance[]> = {};
perSession.forEach((d: { students: Student[]; slots: any[]; attendances: Attendance[] }) => {
(d.slots ?? []).forEach((slot: any) => {
slotIds.push(slot.id);
newMap[slot.id] = (d.attendances ?? []).filter((a: Attendance) => a.slot_id === slot.id);
});
});
allSlotIds = slotIds;
attendanceBySlot = newMap;
} catch (e) {
console.error(e);
} finally {
loading = false;
}
}
async function toggleAttendance(slotId: number, studentId: number) {
const existing = (attendanceBySlot[slotId] ?? []).find((a: Attendance) => a.student_id === studentId);
try {
if (existing) {
await api.admin.slots.deleteAttendance(slotId, studentId);
} else {
await api.admin.slots.addAttendance(slotId, studentId);
}
if (selectedCourseId) await loadMatrix(selectedCourseId);
} catch (e) {
alert(e);
}
}
function isPresent(slotId: number, studentId: number): boolean {
return (attendanceBySlot[slotId] ?? []).some((a: Attendance) => a.student_id === studentId);
}
function presentCount(studentId: number): number {
return allSlotIds.filter((sid) => isPresent(sid, studentId)).length;
}
function absentCount(studentId: number): number {
return allSlotIds.length - presentCount(studentId);
}
// Bonus: student qualifies if they missed at most 1 session
function bonusEligible(studentId: number): boolean {
return allSlotIds.length > 0 && absentCount(studentId) <= 1;
}
function initials(name: string): string {
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
}
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
</script>
<h1>Attendance Matrix</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<div class="selectors">
<select bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name}</option>
{/each}
</select>
{#if sessions.length > 0}
<select bind:value={selectedSessionId}>
{#each sessions as session}
<option value={session.id}>Week {session.week_nr} ({session.date})</option>
{/each}
</select>
{/if}
</div>
{#if data}
<div class="matrix-container">
<table>
<thead>
<tr>
<th>Student</th>
{#each data.slots as slot}
<th>{slot.start_time}</th>
{/each}
</tr>
</thead>
<tbody>
{#each data.students as student}
<tr>
<td>{student.name}</td>
{#each data.slots as slot}
{@const present = data.attendances.some(a => a.slot_id === slot.id && a.student_id === student.id)}
<td class="cell" class:present onclick={() => toggleAttendance(slot.id, student.id)}>
{present ? '✓' : ''}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px">
<div>
<div class="eyebrow">Anwesenheit</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
Kursmatrix · <span class="marker">{selectedCourse?.semester ?? '—'}</span>
</div>
</div>
{:else}
<p>Loading matrix...</p>
{/if}
<div style="display:flex;gap:8px;align-items:center">
{#if courses.length > 1}
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name}</option>
{/each}
</select>
{/if}
{#if selectedCourseId}
<a href={api.admin.export.courseCsv(selectedCourseId)} class="btn ghost sm">
<Icon name="download" size={12} /> CSV
</a>
<a href={api.admin.export.courseMd(selectedCourseId)} class="btn ghost sm">
<Icon name="download" size={12} /> Markdown
</a>
<a href={api.admin.export.backup()} class="btn ghost sm">
<Icon name="download" size={12} /> SQLite Backup
</a>
{/if}
</div>
</header>
<style>
.selectors { margin-bottom: 20px; }
select { margin-right: 10px; padding: 5px; }
.matrix-container { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; background: white; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: center; }
th { background: #f8f9fa; }
.cell { cursor: pointer; width: 60px; height: 40px; }
.cell:hover { background: #f0f0f0; }
.cell.present { background: #d4edda; color: #155724; font-weight: bold; }
</style>
<!-- Matrix table -->
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
<div>
<div class="serif" style="font-size:18px;font-weight:500">Pro Studierende:r</div>
<UnderlineStroke width={140} />
</div>
{#if loading}
<span class="tiny" style="color:var(--ink-4)">Wird geladen…</span>
{/if}
</div>
{#if students.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Keine Studierenden gefunden.</span>
</div>
{:else}
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Studierende:r</th>
{#each sessions as session, i}
<th style="padding:10px 10px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">
W{String(session.week_nr).padStart(2, '0')}
</th>
{/each}
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">Anwesend</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">Bonus</th>
</tr>
</thead>
<tbody>
{#each students as student, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
</td>
<td style="padding:12px 14px">
<div style="display:flex;align-items:center;gap:8px">
<span style="width:22px;height:22px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;flex-shrink:0">
{initials(student.name)}
</span>
<span>{student.name}</span>
</div>
</td>
{#each sessions as session}
{@const slotIds = (session.slots ?? []).map((sl: any) => sl.id)}
{@const sessionPresent = slotIds.some((sid: number) => isPresent(sid, student.id))}
<td style="padding:10px;text-align:center">
{#if slotIds.length > 0}
<button
style="width:24px;height:24px;border-radius:3px;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;background:{sessionPresent ? 'rgba(74,107,58,0.14)' : 'transparent'}"
onclick={() => toggleAttendance(slotIds[0], student.id)}
title={sessionPresent ? 'Anwesend klicken zum Entfernen' : 'Abwesend klicken zum Eintragen'}
>
{#if sessionPresent}
<Icon name="check" size={13} />
{:else}
<span style="color:var(--ink-4)"></span>
{/if}
</button>
{:else}
<span style="color:var(--ink-4)"></span>
{/if}
</td>
{/each}
<td style="padding:12px 14px;text-align:center;font-variant-numeric:tabular-nums">
{presentCount(student.id)} / {allSlotIds.length}
</td>
<td style="padding:12px 14px;text-align:center">
{#if allSlotIds.length > 0}
<span
style="width:24px;height:24px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:{bonusEligible(student.id) ? 'rgba(138,44,31,0.08)' : 'transparent'}"
title={bonusEligible(student.id) ? 'Bonus-Anspruch: max. 1 Fehlen' : `${absentCount(student.id)} Mal gefehlt kein Bonus`}
>
{#if bonusEligible(student.id)}
<Icon name="check" size={13} />
{:else}
<span style="color:var(--ink-4)"></span>
{/if}
</span>
{:else}
<span style="color:var(--ink-4)"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</div>

View File

@@ -1,188 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Student } from '$lib/types';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let students = $state<Student[]>([]);
let newCourseName = $state('');
let newCourseSemester = $state('');
let newStudentName = $state('');
let loading = $state(false);
let courses = $state<Course[]>([]);
let newCourseName = $state('');
let newCourseSemester = $state('');
onMount(async () => {
await loadCourses();
});
onMount(async () => {
courses = await api.admin.courses.list();
});
async function loadCourses() {
courses = await api.admin.courses.list();
if (courses.length > 0 && selectedCourseId === null) {
selectedCourseId = courses[0].id;
}
}
$effect(() => {
if (selectedCourseId !== null) {
loadStudents(selectedCourseId);
}
});
async function loadStudents(courseId: number) {
students = await api.admin.courses.listStudents(courseId);
}
async function createCourse() {
try {
const course = await api.admin.courses.create(newCourseName, newCourseSemester);
newCourseName = '';
newCourseSemester = '';
await loadCourses();
selectedCourseId = course.id;
} catch (e) {
alert(e);
}
}
async function addStudent() {
if (!selectedCourseId) return;
try {
await api.admin.courses.addStudent(selectedCourseId, newStudentName);
newStudentName = '';
await loadStudents(selectedCourseId);
} catch (e) {
alert(e);
}
}
async function deleteStudent(id: number) {
if (!confirm('Are you sure?')) return;
try {
await api.admin.students.delete(id);
if (selectedCourseId) await loadStudents(selectedCourseId);
} catch (e) {
alert(e);
}
}
let fileInput: HTMLInputElement;
async function handleImport() {
if (!selectedCourseId || !fileInput.files?.[0]) return;
try {
await api.admin.courses.importStudents(selectedCourseId, fileInput.files[0]);
await loadStudents(selectedCourseId);
fileInput.value = '';
} catch (e) {
alert(e);
}
}
async function createCourse(e: Event) {
e.preventDefault();
if (!newCourseName.trim() || !newCourseSemester.trim()) return;
try {
await api.admin.courses.create(newCourseName.trim(), newCourseSemester.trim());
newCourseName = '';
newCourseSemester = '';
courses = await api.admin.courses.list();
} catch (e) { alert(e); }
}
</script>
<h1>Courses & Students</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<div class="management-grid">
<div class="courses-panel">
<h2>Manage Courses</h2>
<form onsubmit={createCourse}>
<input bind:value={newCourseName} placeholder="Course Name (e.g. FP)" required />
<input bind:value={newCourseSemester} placeholder="Semester (e.g. SS2026)" required />
<button type="submit">Create Course</button>
</form>
<div class="course-list">
{#each courses as course}
<div
class="course-item"
class:selected={selectedCourseId === course.id}
onclick={() => selectedCourseId = course.id}
>
<strong>{course.name}</strong> ({course.semester})
</div>
{/each}
</div>
<!-- Header -->
<header>
<div class="eyebrow">Verwaltung</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
Kurse
<span style="color:var(--ink-4);font-size:20px;font-weight:400"> · {courses.length}</span>
</div>
</header>
<div class="students-panel">
{#if selectedCourseId}
{@const selectedCourse = courses.find(c => c.id === selectedCourseId)}
<h2>Students in {selectedCourse?.name}</h2>
<div class="student-actions">
<form onsubmit={addStudent} style="display: inline-block;">
<input bind:value={newStudentName} placeholder="Student Name" required />
<button type="submit">Add Student</button>
</form>
<div class="import-box">
<span>Import CSV (name header):</span>
<input type="file" accept=".csv" bind:this={fileInput} onchange={handleImport} />
</div>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each students as student}
<tr>
<td>{student.id}</td>
<td>{student.name}</td>
<td>
<button onclick={() => deleteStudent(student.id)}>Delete</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p>Select a course to manage students.</p>
{/if}
<!-- Create course form -->
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:18px;font-weight:500">Neuen Kurs anlegen</div>
<UnderlineStroke width={160} />
</div>
<form onsubmit={createCourse} style="padding:16px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:4px">
<label for="course-name" class="tiny" style="color:var(--ink-3)">Kursname</label>
<input id="course-name" class="input" bind:value={newCourseName} placeholder="z.B. Funktionale Programmierung" style="width:260px" required />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label for="course-semester" class="tiny" style="color:var(--ink-3)">Semester</label>
<input id="course-semester" class="input" bind:value={newCourseSemester} placeholder="z.B. SS2026" style="width:120px" required />
</div>
<button class="btn" type="submit"><Icon name="plus" size={12} /> Kurs anlegen</button>
</form>
</section>
<!-- Courses table -->
<section class="card" style="overflow:hidden">
{#if courses.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Noch keine Kurse vorhanden.</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Semester</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
<tbody>
{#each courses as course, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
</td>
<td style="padding:12px 14px;font-weight:500">{course.name}</td>
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px">{course.semester}</span>
</td>
<td style="padding:12px 14px;text-align:right">
<div style="display:flex;gap:6px;justify-content:flex-end">
<a href="/admin/students" class="btn ghost sm">Studierende</a>
<a href="/admin/sessions" class="btn ghost sm">Sitzungen</a>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</div>
<style>
.management-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
}
.course-item {
padding: 10px;
border: 1px solid #eee;
margin-bottom: 5px;
cursor: pointer;
border-radius: 4px;
}
.course-item.selected {
background: #e7f1ff;
border-color: #007bff;
}
.student-actions {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.import-box {
margin-top: 10px;
font-size: 0.9em;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 10px;
border-bottom: 1px solid #eee;
}
input {
padding: 6px;
margin-right: 5px;
}
</style>

View File

@@ -0,0 +1,234 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import type { Course, Session, Slot, Student, Attendance, Note } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import SeatMap from '$lib/components/SeatMap.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
import Tally from '$lib/components/Tally.svelte';
const slotId = $derived(parseInt(($page.params as Record<string, string>).slotId));
let slot = $state<Slot | null>(null);
let session = $state<Session | null>(null);
let students = $state<Student[]>([]);
let attendances = $state<Attendance[]>([]);
let notes = $state<Note[]>([]);
let selectedStudentId = $state<number | null>(null);
let loading = $state(true);
let pollInterval: ReturnType<typeof setInterval> | null = null;
onMount(async () => {
await loadData();
loading = false;
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
});
$effect(() => {
if (slot?.status === 'open') {
if (!pollInterval) {
pollInterval = setInterval(loadData, 6000);
}
} else {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
});
async function loadData() {
try {
// Find which session/course contains this slot by searching all courses
const courses: Course[] = await api.admin.courses.list();
let found = false;
for (const course of courses) {
const sessions: Session[] = await api.admin.sessions.list(course.id);
for (const sess of sessions) {
const matchingSlot = (sess.slots ?? []).find((sl: Slot) => sl.id === slotId);
if (matchingSlot) {
slot = matchingSlot;
session = sess;
students = await api.admin.courses.listStudents(course.id);
const attendance = await api.admin.sessions.getAttendance(sess.id);
attendances = (attendance.attendances ?? []).filter((a: Attendance) => a.slot_id === slotId);
notes = await api.admin.slots.getNotes(slotId);
found = true;
break;
}
}
if (found) break;
}
} catch (e) {
console.error(e);
}
}
async function updateStatus(status: string) {
if (!slot) return;
try {
await api.admin.slots.updateStatus(slot.id, status);
await loadData();
} catch (e) { alert(e); }
}
function copyLink() {
if (!slot?.code) return;
const url = `${window.location.origin}/s/${slot.code}`;
navigator.clipboard.writeText(url);
}
async function toggleAttendance(studentId: number) {
if (!slot) return;
const existing = attendances.find((a: Attendance) => a.student_id === studentId);
try {
if (existing) {
await api.admin.slots.deleteAttendance(slot.id, studentId);
} else {
await api.admin.slots.addAttendance(slot.id, studentId);
}
await loadData();
} catch (e) { alert(e); }
}
const presentCount = $derived(attendances.length);
const absentCount = $derived(students.length - presentCount);
const bonusCount = $derived(students.filter((s: Student) => {
// Bonus eligibility would require cross-session data; show attendees as placeholder
return attendances.some((a: Attendance) => a.student_id === s.id);
}).length);
function weekLabel(n: number): string {
return `W${String(n).padStart(2, '0')}`;
}
</script>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
{#if loading}
<div style="padding:48px;text-align:center">
<span class="small" style="color:var(--ink-4)">Wird geladen…</span>
</div>
{:else if !slot || !session}
<div style="padding:48px;text-align:center">
<span class="small" style="color:var(--ink-4)">Slot nicht gefunden.</span>
</div>
{:else}
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div class="eyebrow">Tutor:innen-Ansicht · Live</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
{weekLabel(session.week_nr)} · <span class="marker">{session.date}</span>
</div>
<div class="small" style="margin-top:6px;color:var(--ink-3)">
{slot.start_time}{slot.end_time}
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
{#if slot.code}
<span class="mono" style="font-size:22px;letter-spacing:0.12em;font-weight:600">{slot.code}</span>
{/if}
<StatusPill status={slot.status} />
{#if slot.code}
<button class="btn ghost" onclick={copyLink}><Icon name="copy" size={12} /> Kopieren</button>
{/if}
{#if slot.status === 'closed'}
<button class="btn" onclick={() => updateStatus('open')}><Icon name="open" size={12} /> Öffnen</button>
{:else if slot.status === 'open'}
<button class="btn" onclick={() => updateStatus('locked')}><Icon name="lock" size={12} /> Sperren</button>
{:else if slot.status === 'locked'}
<button class="btn ghost" onclick={() => updateStatus('open')}>Öffnen</button>
{/if}
</div>
</header>
<!-- 2-column grid -->
<div style="display:grid;grid-template-columns:1fr 380px;gap:28px;align-items:start">
<!-- Left: SeatMap + Tally -->
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<div class="serif" style="font-size:18px;font-weight:500">Sitzplan</div>
<UnderlineStroke width={70} />
</div>
<div class="card" style="overflow:hidden;padding:16px">
<SeatMap variant="tutor" scale={0.78} />
</div>
<!-- Tally row -->
<div class="card" style="padding:16px 20px;display:flex;gap:24px;align-items:center">
<Tally label="Anwesend" value={presentCount} total={students.length} />
<div style="width:1px;height:32px;background:var(--rule)"></div>
<Tally label="Fehlt" value={absentCount} total={students.length} />
<div style="margin-left:auto">
<button class="btn ghost sm" onclick={() => selectedStudentId = null}>
Manuell eintragen
</button>
</div>
</div>
<!-- Manual attendance toggle -->
{#if students.length > 0}
<section class="card" style="overflow:hidden">
<div style="padding:10px 14px;border-bottom:1px solid var(--rule)">
<span class="tiny" style="color:var(--ink-3);font-family:var(--mono);text-transform:uppercase;letter-spacing:0.08em">Anwesenheit manuell</span>
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
{#each students as student, i}
{@const present = attendances.some((a: Attendance) => a.student_id === student.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:8px 14px">
<div style="display:flex;align-items:center;gap:8px">
<span style="width:20px;height:20px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;flex-shrink:0">
{student.name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase()}
</span>
{student.name}
</div>
</td>
<td style="padding:8px 14px;text-align:right">
<button
class="btn {present ? '' : 'ghost'} sm"
style={present ? 'background:rgba(74,107,58,0.14);color:var(--ink);border-color:rgba(74,107,58,0.3)' : ''}
onclick={() => toggleAttendance(student.id)}
>
{#if present}
<Icon name="check" size={12} /> Anwesend
{:else}
— Abwesend
{/if}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
{/if}
</div>
<!-- Right: NoteEditor -->
<div>
<NoteEditor
{slotId}
{students}
{attendances}
{notes}
{selectedStudentId}
onStudentSelect={(id) => { selectedStudentId = id; }}
weekNr={session.week_nr}
/>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { api } from '$lib/api';
import { token } from '$lib/auth';
import { goto } from '$app/navigation';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function login() {
loading = true;
error = '';
try {
const res = await api.auth.login(email, password);
token.set(res.token);
goto('/admin');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';
} finally {
loading = false;
}
}
</script>
<div class="paper-bg" style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px">
<!-- Brand -->
<div style="margin-bottom:32px;text-align:center">
<div class="serif" style="font-size:38px;font-weight:500;letter-spacing:-0.02em">
Tutor<span style="color:var(--accent)">·</span>manager
</div>
<div class="body" style="color:var(--ink-3);margin-top:6px">Anwesenheit &amp; Notizen für Tutorien.</div>
</div>
<!-- Card -->
<div class="card" style="width:100%;max-width:420px;padding:26px">
<div class="eyebrow" style="margin-bottom:6px">Anmeldung</div>
<div class="serif" style="font-size:22px;font-weight:500;margin-bottom:2px">Willkommen zurück</div>
<UnderlineStroke width={140} />
<form onsubmit={(e) => { e.preventDefault(); login(); }} style="margin-top:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:4px">
<label for="email" class="tiny" style="color:var(--ink-3)">E-Mail</label>
<input id="email" type="email" class="input" bind:value={email} placeholder="tutor@uni.de" required />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label for="password" class="tiny" style="color:var(--ink-3)">Passwort</label>
<input id="password" type="password" class="input" bind:value={password} required />
</div>
{#if error}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px">
{error}
</div>
{/if}
<button type="submit" class="btn" style="width:100%;padding:11px 14px;margin-top:4px" disabled={loading}>
{loading ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
<div class="tiny" style="color:var(--ink-3);margin-top:16px;text-align:center">
Nur für Tutor:innen. Studierende nutzen den Link der Tutor:in.
</div>
</div>
<div class="handwritten" style="margin-top:28px;font-size:18px">~ Donnerstags ab 14 Uhr ~</div>
</div>

View File

@@ -1,172 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Course, Session, Slot, Room, Student, LayoutElement, Note } from '$lib/types';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let sessions = $state<Session[]>([]);
let selectedSessionId = $state<number | null>(null);
let slots = $state<Slot[]>([]);
let selectedSlotId = $state<number | null>(null);
let layout = $state<LayoutElement[]>([]);
let notes = $state<Note[]>([]);
let students = $state<Student[]>([]);
let attendances = $state<any[]>([]);
onMount(async () => {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
});
$effect(() => {
if (selectedCourseId) {
api.admin.sessions.list(selectedCourseId).then(res => {
sessions = res;
if (sessions.length > 0) selectedSessionId = sessions[0].id;
});
api.admin.courses.listStudents(selectedCourseId).then(res => students = res);
}
});
$effect(() => {
if (selectedSessionId) {
const s = sessions.find(s => s.id === selectedSessionId);
slots = s?.slots || [];
if (slots.length > 0) selectedSlotId = slots[0].id;
}
});
$effect(() => {
if (selectedSlotId) {
const slot = slots.find(s => s.id === selectedSlotId);
if (slot?.room_id) {
api.admin.rooms.get(slot.room_id).then(r => layout = r.layout);
} else {
layout = [];
}
api.admin.slots.getNotes(selectedSlotId).then(res => notes = res);
api.admin.sessions.getAttendance(selectedSessionId!).then(res => {
attendances = res.attendances.filter((a: any) => a.slot_id === selectedSlotId);
});
}
});
let activeSeatId = $state<string | null>(null);
let noteContent = $state('');
function handleSeatClick(el: LayoutElement) {
if (el.type !== 'seat') return;
activeSeatId = el.id;
const studentId = attendances.find(a => a.seat_id === el.id)?.student_id;
if (studentId) {
const existing = notes.find(n => n.student_id === studentId);
noteContent = existing?.content || '';
} else {
noteContent = '';
}
}
async function saveNote() {
if (!selectedSlotId || !activeSeatId) return;
const studentId = attendances.find(a => a.seat_id === activeSeatId)?.student_id;
if (!studentId) {
alert('No student at this seat');
return;
}
try {
await api.admin.slots.upsertNote(selectedSlotId, studentId, noteContent);
notes = await api.admin.slots.getNotes(selectedSlotId);
activeSeatId = null;
} catch (e) {
alert(e);
}
}
let studentNames = $derived.by(() => {
const map: Record<string, string> = {};
attendances.forEach(a => {
if (a.seat_id) {
const s = students.find(s => s.id === a.student_id);
map[a.seat_id] = s?.name || 'Unknown';
}
});
return map;
});
let occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(id => id !== null));
</script>
<h1>Seat Notes</h1>
<div class="selectors">
<select bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name}</option>
{/each}
</select>
<select bind:value={selectedSessionId}>
{#each sessions as session}
<option value={session.id}>Week {session.week_nr}</option>
{/each}
</select>
<select bind:value={selectedSlotId}>
{#each slots as slot}
<option value={slot.id}>{slot.start_time} - {slot.end_time}</option>
{/each}
</select>
</div>
<div class="notes-container">
<div class="map-view">
<RoomCanvas
elements={layout}
{occupiedSeatIds}
selectedId={activeSeatId}
{studentNames}
onElementClick={handleSeatClick}
/>
</div>
<div class="note-editor">
{#if activeSeatId}
{@const studentName = studentNames[activeSeatId]}
<h3>Note for {studentName || 'Empty Seat'}</h3>
{#if studentName}
<textarea bind:value={noteContent} placeholder="Enter observations..."></textarea>
<button class="primary" onclick={saveNote}>Save Note</button>
{:else}
<p>Select a seat occupied by a student to leave a note.</p>
{/if}
<button onclick={() => activeSeatId = null}>Cancel</button>
{:else}
<p>Click a seat on the map to add or view notes.</p>
{/if}
<div class="existing-notes">
<h3>Recent Notes</h3>
{#each notes as note}
<div class="note-item">
<strong>{students.find(s => s.id === note.student_id)?.name}:</strong>
<p>{note.content}</p>
</div>
{/each}
</div>
</div>
</div>
<style>
.selectors { margin-bottom: 20px; }
select { margin-right: 10px; padding: 5px; }
.notes-container { display: grid; grid-template-columns: 1fr 300px; gap: 20px; }
.note-editor { background: #f8f9fa; padding: 20px; border-radius: 8px; }
textarea { width: 100%; height: 100px; margin: 10px 0; }
.primary { background: #007bff; color: white; border: none; padding: 8px 16px; border-radius: 4px; }
.note-item { font-size: 0.9em; border-bottom: 1px solid #ddd; padding: 5px 0; }
.note-item p { margin: 5px 0; }
</style>

View File

@@ -1,205 +1,63 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Room, LayoutElement } from '$lib/types';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Room } from '$lib/types';
let rooms = $state<Room[]>([]);
let selectedRoomId = $state<number | null>(null);
let selectedRoom = $state<Room | null>(null);
let newRoomName = $state('');
let rooms = $state<Room[]>([]);
let newRoomName = $state('');
onMount(async () => {
await loadRooms();
});
onMount(async () => {
rooms = await api.admin.rooms.list();
});
async function loadRooms() {
rooms = await api.admin.rooms.list();
}
$effect(() => {
if (selectedRoomId) {
api.admin.rooms.get(selectedRoomId).then(r => selectedRoom = r);
}
});
async function createRoom() {
const defaultLayout: LayoutElement[] = [
{ id: 's1', label: '1', x: 2, y: 2, width: 1, height: 1, type: 'seat' }
];
try {
const room = await api.admin.rooms.create(newRoomName, defaultLayout);
newRoomName = '';
await loadRooms();
selectedRoomId = room.id;
} catch (e) {
alert(e);
}
}
async function saveLayout() {
if (!selectedRoom) return;
try {
await api.admin.rooms.updateLayout(selectedRoom.id, selectedRoom.layout);
alert('Layout saved');
} catch (e) {
alert(e);
}
}
function addElement(type: LayoutElement['type']) {
if (!selectedRoom) return;
const id = Math.random().toString(36).substr(2, 9);
const newEl: LayoutElement = {
id,
label: type === 'seat' ? (selectedRoom.layout.filter(e => e.type === 'seat').length + 1).toString() : '',
x: 0,
y: 0,
width: type === 'table' ? 2 : 1,
height: 1,
type
};
selectedRoom.layout = [...selectedRoom.layout, newEl];
}
let selectedElementId = $state<string | null>(null);
let selectedElement = $derived(selectedRoom?.layout.find(e => e.id === selectedElementId));
function deleteElement() {
if (!selectedRoom || !selectedElementId) return;
selectedRoom.layout = selectedRoom.layout.filter(e => e.id !== selectedElementId);
selectedElementId = null;
}
async function createRoom() {
if (!newRoomName.trim()) return;
try {
await api.admin.rooms.create(newRoomName, []);
newRoomName = '';
rooms = await api.admin.rooms.list();
} catch (_) {}
}
</script>
<h1>Room Layouts</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<div>
<span class="eyebrow">Räume</span>
<h1 class="h1" style="font-family:var(--serif)">Raumlayout-Editor</h1>
</div>
<div class="management-grid">
<div class="rooms-panel">
<h2>Rooms</h2>
<form onsubmit={createRoom}>
<input bind:value={newRoomName} placeholder="Room Name" required />
<button type="submit">Create</button>
</form>
<div class="room-list">
{#each rooms as room}
<div
class="room-item"
class:selected={selectedRoomId === room.id}
onclick={() => selectedRoomId = room.id}
>
{room.name}
</div>
{/each}
</div>
<div class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
<div class="serif" style="font-size:18px;font-weight:500">Räume</div>
<form onsubmit={(e) => { e.preventDefault(); createRoom(); }} style="display:flex;gap:8px">
<input class="input" bind:value={newRoomName} placeholder="Raumname" style="width:200px" />
<button class="btn" type="submit">+ Neuer Raum</button>
</form>
</div>
<div class="editor-panel">
{#if selectedRoom}
<div class="editor-header">
<h2>Editing: {selectedRoom.name}</h2>
<div class="toolbar">
<button onclick={() => addElement('seat')}>Add Seat</button>
<button onclick={() => addElement('table')}>Add Table</button>
<button onclick={() => addElement('door')}>Add Door</button>
<button class="save-btn" onclick={saveLayout}>Save Layout</button>
</div>
</div>
<div class="canvas-container">
<RoomCanvas
bind:elements={selectedRoom.layout}
editable={true}
selectedId={selectedElementId}
onElementClick={(el) => selectedElementId = el.id}
/>
<div class="properties-panel">
<h3>Properties</h3>
{#if selectedElement}
<div class="field">
<label>Label</label>
<input bind:value={selectedElement.label} />
</div>
<div class="field">
<label>Width</label>
<input type="number" step="0.5" bind:value={selectedElement.width} />
</div>
<div class="field">
<label>Height</label>
<input type="number" step="0.5" bind:value={selectedElement.height} />
</div>
<button class="delete-btn" onclick={deleteElement}>Delete Element</button>
{:else}
<p>Select an element to edit properties.</p>
{/if}
</div>
</div>
{:else}
<p>Select a room to edit its layout.</p>
{/if}
</div>
{#if rooms.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Noch keine Räume angelegt.</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
<tbody>
{#each rooms as room, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">{room.name}</td>
<td style="padding:12px 14px;text-align:right">
<a href="/admin/rooms/{room.id}" class="btn ghost sm">Bearbeiten</a>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<style>
.management-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 20px;
}
.room-item {
padding: 10px;
border: 1px solid #eee;
margin-bottom: 5px;
cursor: pointer;
border-radius: 4px;
}
.room-item.selected {
background: #e7f1ff;
border-color: #007bff;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.toolbar button {
margin-right: 5px;
}
.save-btn {
background: #28a745;
color: white;
border: none;
padding: 5px 15px;
border-radius: 4px;
}
.canvas-container {
display: flex;
gap: 20px;
}
.properties-panel {
width: 200px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.field {
margin-bottom: 10px;
}
.field label {
display: block;
font-size: 0.8em;
color: #666;
}
.field input {
width: 100%;
padding: 4px;
}
.delete-btn {
margin-top: 10px;
color: red;
width: 100%;
}
</style>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Room, LayoutElement } from '$lib/types';
const roomId = $derived(parseInt(($page.params as Record<string, string>).roomId));
let room = $state<Room | null>(null);
onMount(async () => {
room = await api.admin.rooms.get(roomId);
});
$effect(() => {
if (roomId) {
api.admin.rooms.get(roomId).then((r: Room) => { room = r; });
}
});
async function saveLayout() {
if (!room) return;
try {
await api.admin.rooms.updateLayout(room.id, room.layout);
} catch (_) {}
}
function addElement(type: LayoutElement['type']) {
if (!room) return;
const id = Math.random().toString(36).substr(2, 9);
const newEl: LayoutElement = {
id,
label: type === 'seat' ? (room.layout.filter((e: LayoutElement) => e.type === 'seat').length + 1).toString() : '',
x: 0, y: 0,
width: type === 'table' ? 2 : 1,
height: 1,
type,
};
room.layout = [...room.layout, newEl];
}
let selectedElementId = $state<string | null>(null);
const selectedElement = $derived(room?.layout.find((e: LayoutElement) => e.id === selectedElementId));
function deleteElement() {
if (!room || !selectedElementId) return;
room.layout = room.layout.filter((e: LayoutElement) => e.id !== selectedElementId);
selectedElementId = null;
}
</script>
{#if room}
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<span class="eyebrow">Räume</span>
<h2 class="h2" style="font-family:var(--serif)">{room.name}</h2>
</div>
<div style="display:flex;gap:8px">
<button class="btn ghost" onclick={() => addElement('seat')}>+ Sitz</button>
<button class="btn ghost" onclick={() => addElement('table')}>+ Tisch</button>
<button class="btn ghost" onclick={() => addElement('door')}>+ Tür</button>
<button class="btn" onclick={saveLayout}>Speichern</button>
</div>
</div>
<div style="display:flex;gap:20px">
<div style="flex:1">
<RoomCanvas
bind:elements={room.layout}
editable={true}
selectedId={selectedElementId}
onElementClick={(el) => { selectedElementId = el.id; }}
/>
</div>
<div class="card" style="width:240px;padding:16px;display:flex;flex-direction:column;gap:12px">
<div class="eyebrow">Auswahl</div>
{#if selectedElement}
<div style="display:flex;flex-direction:column;gap:8px">
<div>
<div class="tiny" style="color:var(--ink-3)">Bezeichnung</div>
<input class="input" bind:value={selectedElement.label} />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<div class="tiny" style="color:var(--ink-3)">Breite</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.width} />
</div>
<div>
<div class="tiny" style="color:var(--ink-3)">Höhe</div>
<input class="input" type="number" step="0.5" bind:value={selectedElement.height} />
</div>
</div>
<button
style="color:var(--accent);background:none;border:none;cursor:pointer;text-align:left;font-family:var(--sans);font-size:13px;padding:4px 0"
onclick={deleteElement}
>Löschen ⌫</button>
</div>
{:else}
<span class="small" style="color:var(--ink-4)">Element auswählen</span>
{/if}
</div>
</div>
</div>
{:else}
<div style="padding:28px 36px">
<span class="small" style="color:var(--ink-4)">Raum wird geladen…</span>
</div>
{/if}

View File

@@ -1,241 +1,214 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Room, Tutor, Session } from '$lib/types';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Room, Session, Slot } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let rooms = $state<Room[]>([]);
let sessions = $state<Session[]>([]);
// New Session Form
let weekNr = $state(1);
let date = $state(new Date().toISOString().split('T')[0]);
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let rooms = $state<Room[]>([]);
let sessions = $state<Session[]>([]);
// New Slot Form
let selectedSessionId = $state<number | null>(null);
let slotTutorId = $state<number | null>(null);
let slotRoomId = $state<number | null>(null);
let startTime = $state('09:00');
let endTime = $state('11:00');
let weekNr = $state(1);
let date = $state(new Date().toISOString().split('T')[0]);
onMount(async () => {
courses = await api.admin.courses.list();
rooms = await api.admin.rooms.list();
if (courses.length > 0) {
selectedCourseId = courses[0].id;
}
});
let selectedSessionId = $state<number | null>(null);
let slotTutorId = $state<number | null>(null);
let slotRoomId = $state<number | null>(null);
let startTime = $state('09:00');
let endTime = $state('11:00');
$effect(() => {
if (selectedCourseId) {
loadSessions(selectedCourseId);
}
});
onMount(async () => {
courses = await api.admin.courses.list();
rooms = await api.admin.rooms.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
});
async function loadSessions(courseId: number) {
sessions = await api.admin.sessions.list(courseId);
}
$effect(() => {
if (selectedCourseId) loadSessions(selectedCourseId);
});
async function createSession() {
if (!selectedCourseId) return;
try {
await api.admin.sessions.create(selectedCourseId, weekNr, date);
loadSessions(selectedCourseId);
} catch (e) {
alert(e);
}
}
async function loadSessions(courseId: number) {
sessions = await api.admin.sessions.list(courseId);
}
async function createSlot() {
if (!selectedSessionId || !slotTutorId) return;
try {
await api.admin.slots.create(
selectedSessionId,
slotTutorId,
startTime,
endTime,
slotRoomId || undefined
);
if (selectedCourseId) loadSessions(selectedCourseId);
selectedSessionId = null;
} catch (e) {
alert(e);
}
}
async function createSession(e: Event) {
e.preventDefault();
if (!selectedCourseId) return;
try {
await api.admin.sessions.create(selectedCourseId, weekNr, date);
await loadSessions(selectedCourseId);
weekNr++;
} catch (e) { alert(e); }
}
async function deleteSlot(id: number) {
if (!confirm('Are you sure?')) return;
try {
await api.admin.slots.delete(id);
if (selectedCourseId) loadSessions(selectedCourseId);
} catch (e) {
alert(e);
}
}
async function createSlot(e: Event) {
e.preventDefault();
if (!selectedSessionId || !slotTutorId) return;
try {
await api.admin.slots.create(selectedSessionId, slotTutorId, startTime, endTime, slotRoomId || undefined);
if (selectedCourseId) await loadSessions(selectedCourseId);
selectedSessionId = null;
slotTutorId = null;
} catch (e) { alert(e); }
}
async function deleteSlot(id: number) {
if (!confirm('Slot wirklich löschen?')) return;
try {
await api.admin.slots.delete(id);
if (selectedCourseId) await loadSessions(selectedCourseId);
} catch (e) { alert(e); }
}
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
</script>
<h1>Schedule Sessions & Slots</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<div class="management-grid">
<div class="sessions-list">
<div class="course-selector">
<label>Course:</label>
<select bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name}</option>
{/each}
</select>
</div>
<div class="add-session">
<h3>Add Session</h3>
<div class="form-row">
<input type="number" bind:value={weekNr} placeholder="Week" style="width: 60px" />
<input type="date" bind:value={date} />
<button onclick={createSession}>Add</button>
</div>
</div>
<div class="sessions-grid">
{#each sessions as session}
<div class="session-block">
<div class="session-header">
<strong>Week {session.week_nr}</strong> ({session.date})
<button onclick={() => selectedSessionId = session.id}>+ Add Slot</button>
</div>
<div class="slots-list">
{#each session.slots || [] as slot}
<div class="slot-item">
<span>{slot.start_time}-{slot.end_time}</span>
<button class="delete-btn" onclick={() => deleteSlot(slot.id)}>×</button>
</div>
{/each}
</div>
</div>
{/each}
</div>
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div class="eyebrow">Sitzungen</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
Planung
{#if selectedCourse}
<span style="color:var(--ink-4);font-size:20px;font-weight:400"> · {selectedCourse.name}</span>
{/if}
</div>
</div>
{#if selectedSessionId}
<div class="modal-overlay">
<div class="modal">
<h2>Add Slot to Session</h2>
<div class="field">
<label>Tutor ID (Mock: 1)</label>
<input type="number" bind:value={slotTutorId} placeholder="Tutor ID" />
</div>
<div class="field">
<label>Room (Optional)</label>
<select bind:value={slotRoomId}>
<option value={null}>None</option>
{#each rooms as room}
<option value={room.id}>{room.name}</option>
{/each}
</select>
</div>
<div class="field">
<label>Start Time</label>
<input type="time" bind:value={startTime} />
</div>
<div class="field">
<label>End Time</label>
<input type="time" bind:value={endTime} />
</div>
<div class="modal-actions">
<button onclick={() => selectedSessionId = null}>Cancel</button>
<button class="primary" onclick={createSlot}>Create Slot</button>
</div>
</div>
</div>
{#if courses.length > 1}
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
{/if}
</header>
<!-- Add session form -->
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:18px;font-weight:500">Neue Sitzung anlegen</div>
<UnderlineStroke width={160} />
</div>
<form onsubmit={createSession} style="padding:16px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:4px">
<label for="session-week" class="tiny" style="color:var(--ink-3)">Woche #</label>
<input id="session-week" class="input" type="number" bind:value={weekNr} min="1" style="width:80px" />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label for="session-date" class="tiny" style="color:var(--ink-3)">Datum</label>
<input id="session-date" class="input" type="date" bind:value={date} />
</div>
<button class="btn" type="submit"><Icon name="plus" size={12} /> Sitzung anlegen</button>
</form>
</section>
<!-- Sessions list -->
{#if sessions.length === 0}
<div class="card" style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Noch keine Sitzungen angelegt.</span>
</div>
{:else}
<div style="display:flex;flex-direction:column;gap:12px">
{#each sessions as session}
<section class="card" style="overflow:hidden">
<div style="padding:12px 16px;background:rgba(0,0,0,0.02);border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:10px">
<span class="mono" style="font-size:11px;color:var(--ink-3)">W{String(session.week_nr).padStart(2, '0')}</span>
<span class="body" style="font-weight:500">{session.date}</span>
</div>
<button class="btn ghost sm" onclick={() => selectedSessionId = session.id}>
<Icon name="plus" size={12} /> Slot hinzufügen
</button>
</div>
{#if (session.slots ?? []).length === 0}
<div style="padding:12px 16px">
<span class="tiny" style="color:var(--ink-4)">Noch kein Slot für diese Sitzung.</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
{#each session.slots ?? [] as slot, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:10px 16px">
<span class="mono" style="font-size:12px">{slot.start_time}{slot.end_time}</span>
</td>
<td style="padding:10px 16px"><StatusPill status={slot.status} /></td>
<td style="padding:10px 16px">
{#if slot.code}
<span class="mono" style="font-size:12px;color:var(--ink-3)">{slot.code}</span>
{:else}
<span class="tiny" style="color:var(--ink-4)"></span>
{/if}
</td>
<td style="padding:10px 16px;text-align:right">
<div style="display:flex;gap:6px;justify-content:flex-end">
{#if slot.status === 'open' || slot.status === 'locked'}
<a href="/admin/live/{slot.id}" class="btn ghost sm">Anzeigen</a>
{/if}
<button
class="btn ghost sm"
style="color:var(--accent);border-color:rgba(138,44,31,0.2)"
onclick={() => deleteSlot(slot.id)}
><Icon name="x" size={12} /></button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{/each}
</div>
{/if}
</div>
<style>
.course-selector {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.add-session {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.form-row {
display: flex;
gap: 10px;
}
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.session-block {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.session-header {
background: #eee;
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.slots-list {
padding: 10px;
}
.slot-item {
display: flex;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #f0f0f0;
}
.delete-btn {
background: none;
border: none;
color: #dc3545;
cursor: pointer;
font-weight: bold;
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: white;
padding: 30px;
border-radius: 8px;
width: 400px;
}
.field {
margin-bottom: 15px;
}
.field label {
display: block;
margin-bottom: 5px;
}
.field input, .field select {
width: 100%;
padding: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.primary {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
}
</style>
<!-- Add slot modal -->
{#if selectedSessionId !== null}
{@const sess = sessions.find((s: Session) => s.id === selectedSessionId)}
<div style="position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:100">
<div class="card" style="width:100%;max-width:420px;padding:24px">
<div class="eyebrow" style="margin-bottom:4px">Neuer Slot</div>
<div class="serif" style="font-size:22px;font-weight:500;margin-bottom:2px">
Woche {sess?.week_nr} · {sess?.date}
</div>
<UnderlineStroke width={180} />
<form onsubmit={createSlot} style="margin-top:18px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:4px">
<label for="slot-tutor" class="tiny" style="color:var(--ink-3)">Tutor-ID</label>
<input id="slot-tutor" class="input" type="number" bind:value={slotTutorId} placeholder="1" required />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label for="slot-room" class="tiny" style="color:var(--ink-3)">Raum (optional)</label>
<select id="slot-room" class="input" bind:value={slotRoomId}>
<option value={null}>Kein Raum</option>
{#each rooms as room}
<option value={room.id}>{room.name}</option>
{/each}
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="display:flex;flex-direction:column;gap:4px">
<label for="slot-start" class="tiny" style="color:var(--ink-3)">Beginn</label>
<input id="slot-start" class="input" type="time" bind:value={startTime} />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label for="slot-end" class="tiny" style="color:var(--ink-3)">Ende</label>
<input id="slot-end" class="input" type="time" bind:value={endTime} />
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px">
<button type="button" class="btn ghost" onclick={() => selectedSessionId = null}>Abbrechen</button>
<button type="submit" class="btn"><Icon name="plus" size={12} /> Slot anlegen</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Student } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let students = $state<Student[]>([]);
let newStudentName = $state('');
let search = $state('');
let fileInput = $state<HTMLInputElement | null>(null);
onMount(async () => {
courses = await api.admin.courses.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
});
$effect(() => {
if (selectedCourseId) loadStudents(selectedCourseId);
});
async function loadStudents(courseId: number) {
students = await api.admin.courses.listStudents(courseId);
}
async function addStudent(e: Event) {
e.preventDefault();
if (!selectedCourseId || !newStudentName.trim()) return;
try {
await api.admin.courses.addStudent(selectedCourseId, newStudentName);
newStudentName = '';
await loadStudents(selectedCourseId);
} catch (err) { alert(err); }
}
async function deleteStudent(id: number) {
if (!confirm('Studierende:n wirklich entfernen?')) return;
try {
await api.admin.students.delete(id);
if (selectedCourseId) await loadStudents(selectedCourseId);
} catch (err) { alert(err); }
}
async function handleImport() {
if (!selectedCourseId || !fileInput?.files?.[0]) return;
try {
await api.admin.courses.importStudents(selectedCourseId, fileInput.files[0]);
await loadStudents(selectedCourseId);
if (fileInput) fileInput.value = '';
} catch (err) { alert(err); }
}
const filtered = $derived(
students.filter((s: Student) => s.name.toLowerCase().includes(search.toLowerCase()))
);
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
function initials(name: string): string {
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
}
</script>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div class="eyebrow">Studierende</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
{selectedCourse?.name ?? 'Studierende'}
<span style="color:var(--ink-4);font-size:20px;font-weight:400"> · {filtered.length}</span>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
{#if courses.length > 1}
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
{/if}
<!-- Search -->
<div style="position:relative">
<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--ink-4);pointer-events:none">
<Icon name="search" size={13} />
</span>
<input class="input" bind:value={search} placeholder="Name suchen…" style="padding-left:28px;width:180px" />
</div>
<!-- CSV import (hidden file input) -->
<label class="btn ghost" style="cursor:pointer">
<Icon name="download" size={12} /> CSV importieren
<input
type="file"
accept=".csv"
bind:this={fileInput}
onchange={handleImport}
style="display:none"
/>
</label>
<!-- Add form inline -->
<form onsubmit={addStudent} style="display:flex;gap:6px">
<input class="input" bind:value={newStudentName} placeholder="Name…" required style="width:150px" />
<button class="btn" type="submit"><Icon name="plus" size={12} /> Hinzufügen</button>
</form>
</div>
</header>
<!-- Table -->
<section class="card" style="overflow:hidden">
{#if filtered.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">
{search ? 'Keine Treffer.' : 'Noch keine Studierenden.'}
</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
<tbody>
{#each filtered as student, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
</td>
<td style="padding:12px 14px">
<div style="display:flex;align-items:center;gap:8px">
<span style="width:22px;height:22px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;flex-shrink:0">
{initials(student.name)}
</span>
{student.name}
</div>
</td>
<td style="padding:12px 14px;text-align:right">
<button
class="btn ghost sm"
style="color:var(--accent);border-color:rgba(138,44,31,0.2)"
onclick={() => deleteStudent(student.id)}
>Entfernen</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</div>

View File

@@ -1,82 +0,0 @@
<script lang="ts">
import { api } from '$lib/api';
import { token } from '$lib/auth';
import { goto } from '$app/navigation';
let email = '';
let password = '';
let error = '';
let loading = false;
async function login() {
loading = true;
error = '';
try {
const res = await api.auth.login(email, password);
token.set(res.token);
goto('/admin');
} catch (e: any) {
error = e.message || 'Invalid credentials';
} finally {
loading = false;
}
}
</script>
<div class="login-container">
<h1>Tutor Login</h1>
<form on:submit|preventDefault={login}>
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} required />
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" bind:value={password} required />
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
<style>
.login-container {
max-width: 400px;
margin: 100px auto;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.field {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
}
input {
width: 100%;
padding: 8px;
box-sizing: border-box;
}
.error {
color: red;
margin-bottom: 15px;
}
button {
width: 100%;
padding: 10px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
}
</style>

View File

@@ -1,124 +1,385 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Slot, LayoutElement, Student, Attendance } from '$lib/types';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import type { Slot, Student, Attendance } from '$lib/types';
import SeatMap from '$lib/components/SeatMap.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
const code = $page.params.code;
let slot = $state<Slot | null>(null);
let layout = $state<LayoutElement[]>([]);
let attendances = $state<any[]>([]);
let students = $state<Student[]>([]);
let selectedStudentId = $state<number | null>(null);
let myAttendance = $derived(attendances.find(a => a.is_mine));
let loading = $state(true);
let error = $state('');
const code = $page.params.code as string;
onMount(async () => {
try {
await loadInfo();
if (!myAttendance && slot?.status === 'open') {
students = await api.checkin.getStudents(code);
}
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
});
type Step = 'loading' | 'error' | 'name' | 'seat' | 'confirmed' | 'locked';
let step = $state<Step>('loading');
let errorMsg = $state('');
async function loadInfo() {
const res = await api.checkin.getInfo(code);
slot = res.slot;
layout = res.layout || [];
attendances = res.attendances || [];
let slot = $state<Slot | null>(null);
let students = $state<Student[]>([]);
let attendances = $state<Attendance[]>([]);
let myAttendance = $state<Attendance | null>(null);
let search = $state('');
let selectedStudent = $state<Student | null>(null);
let isDesktop = $state(false);
onMount(async () => {
const mq = window.matchMedia('(min-width: 900px)');
isDesktop = mq.matches;
mq.addEventListener('change', (e) => { isDesktop = e.matches; });
try {
await loadInfo();
} catch (e: any) {
errorMsg = e.message ?? 'Fehler beim Laden.';
step = 'error';
}
});
async function loadInfo() {
const res = await api.checkin.getInfo(code);
slot = res.slot;
attendances = res.attendances ?? [];
const mine = attendances.find((a: Attendance) => (a as any).is_mine);
if (mine) {
myAttendance = mine;
}
async function handleCheckin(el: LayoutElement) {
if (!slot || slot.status !== 'open') return;
if (el.type !== 'seat') return;
// If already checked in and clicked the same seat, do nothing
if (myAttendance?.seat_id === el.id) return;
// If not checked in, student must be selected
const studentId = myAttendance?.student_id || selectedStudentId;
if (!studentId) {
alert('Please select your name first');
return;
}
try {
await api.checkin.post(code, studentId, el.id);
await loadInfo();
} catch (e: any) {
alert(e.message);
}
if (slot?.status === 'locked') {
step = 'locked';
} else if (mine) {
step = 'confirmed';
} else if (slot?.status === 'open') {
students = await api.checkin.getStudents(code);
step = 'name';
} else {
step = 'locked';
}
}
let occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(id => id !== null) as string[]);
async function selectName(student: Student) {
selectedStudent = student;
search = '';
step = 'seat';
}
async function checkin(seatId?: string) {
if (!selectedStudent) return;
try {
const res = await api.checkin.post(code, selectedStudent.id, seatId);
myAttendance = res;
await loadInfo();
} catch (e: any) {
if (e.message?.includes('already checked in') || e.message?.includes('409')) {
errorMsg = 'Dieser Platz ist bereits belegt.';
} else {
errorMsg = e.message ?? 'Einchecken fehlgeschlagen.';
}
}
}
async function changeSeat() {
step = 'seat';
}
const filteredStudents = $derived(
students.filter((s: Student) => s.name.toLowerCase().includes(search.toLowerCase()))
);
function initials(name: string): string {
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
}
</script>
<div class="checkin-page">
{#if loading}
<p>Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else if slot}
<h1>Check-in: {slot.start_time} - {slot.end_time}</h1>
{#if slot.status === 'locked'}
<p class="warning">Check-in is currently locked by the tutor.</p>
{/if}
<div class="paper-bg" style="min-height:100vh">
{#if !myAttendance && slot.status === 'open'}
<div class="identity-selector">
<label for="student">I am:</label>
<select id="student" bind:value={selectedStudentId}>
<option value={null}>Select your name...</option>
{#each students as student}
<option value={student.id}>{student.name}</option>
{/each}
</select>
</div>
{:else if myAttendance}
<p class="success">You are checked in as <strong>{students.find(s => s.id === myAttendance.student_id)?.name || 'Student'}</strong></p>
{/if}
{#if step === 'loading'}
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh">
<span class="body" style="color:var(--ink-4)">Wird geladen…</span>
</div>
<div class="map-container">
<p>Select a seat to check in:</p>
<RoomCanvas
elements={layout}
{occupiedSeatIds}
mySeatId={myAttendance?.seat_id}
onElementClick={handleCheckin}
/>
{:else if step === 'error'}
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:40px 20px">
<div class="card" style="max-width:420px;width:100%;padding:28px;text-align:center">
<div class="serif" style="font-size:24px;font-weight:500;margin-bottom:8px">Fehler</div>
<div class="body" style="color:var(--ink-3)">{errorMsg}</div>
</div>
</div>
{:else if step === 'locked'}
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:40px 20px;flex-direction:column;gap:20px">
<div style="text-align:center">
<div class="eyebrow">Anwesenheit</div>
<div class="serif" style="font-size:32px;font-weight:500;margin-top:4px">
Erfassung abgeschlossen
</div>
{/if}
</div>
</div>
<div class="card" style="max-width:420px;width:100%;padding:24px;text-align:center">
{#if myAttendance}
<div class="stamp" style="margin:0 auto 16px">✓ ANWESEND</div>
<div class="body">Du wurdest als anwesend eingetragen.</div>
{:else}
<div style="color:var(--ink-3)" class="body">Der Check-in-Link ist nicht mehr aktiv.</div>
<div class="tiny" style="color:var(--ink-4);margin-top:8px">Wende dich an deine:n Tutor:in.</div>
{/if}
</div>
{#if slot}
<div class="tiny" style="color:var(--ink-4)">{slot.start_time}{slot.end_time}</div>
{/if}
</div>
<style>
.checkin-page {
max-width: 800px;
margin: 40px auto;
text-align: center;
}
.identity-selector {
margin-bottom: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
select {
padding: 8px;
font-size: 1.1em;
}
.error { color: red; }
.success { color: #28a745; font-size: 1.2em; }
.warning { color: #856404; background: #fff3cd; padding: 10px; border-radius: 4px; }
.map-container {
margin-top: 30px;
}
</style>
{:else if !isDesktop}
<!-- PHONE LAYOUT -->
{#if step === 'name'}
<div style="padding:32px 20px;display:flex;flex-direction:column;gap:20px;max-width:480px;margin:0 auto">
<div style="text-align:center">
<div class="eyebrow">Check-in</div>
<div class="serif" style="font-size:30px;font-weight:500;margin-top:4px">Wer bist du?</div>
<div class="small" style="color:var(--ink-4);margin-top:6px">Wähle deinen Namen aus der Liste.</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px;text-align:center">
{errorMsg}
</div>
{/if}
<input
class="input"
bind:value={search}
placeholder="Name suchen…"
style="font-size:16px"
/>
<div style="display:flex;flex-direction:column;gap:4px;max-height:55vh;overflow-y:auto" class="scroll">
{#each filteredStudents as student}
<button
class="card"
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
onclick={() => selectName(student)}
>
<span style="width:32px;height:32px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">
{initials(student.name)}
</span>
<span class="body">{student.name}</span>
</button>
{:else}
<div style="padding:20px;text-align:center">
<span class="small" style="color:var(--ink-4)">Keine Treffer.</span>
</div>
{/each}
</div>
{#if slot}
<div class="tiny" style="color:var(--ink-4);text-align:center">{slot.start_time}{slot.end_time}</div>
{/if}
</div>
{:else if step === 'seat' && selectedStudent}
<div style="padding:24px 20px;display:flex;flex-direction:column;gap:16px;max-width:480px;margin:0 auto">
<div style="text-align:center">
<div class="eyebrow">Hallo, {selectedStudent.name.split(' ')[0]} 👋</div>
<div class="serif" style="font-size:28px;font-weight:500;margin-top:4px">Wähle deinen Sitz</div>
<div class="small" style="color:var(--ink-4);margin-top:4px">Tippe auf einen freien Platz.</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px;text-align:center">
{errorMsg}
</div>
{/if}
<div style="overflow-x:auto">
<SeatMap
variant="student"
scale={0.46}
onSeatClick={(seat) => checkin(seat.id)}
/>
</div>
<div style="display:flex;gap:16px;justify-content:center" class="tiny">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:12px;border-radius:50%;background:var(--accent);display:inline-block"></span> Dein Platz
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:12px;border-radius:50%;background:#d6cdb5;display:inline-block"></span> Belegt
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:12px;border-radius:50%;background:#fbf7ee;border:1.5px solid var(--ink-2);display:inline-block"></span> Frei
</span>
</div>
<button class="btn ghost" style="font-size:12px" onclick={() => { step = 'name'; selectedStudent = null; }}>
← Zurück
</button>
</div>
{:else if step === 'confirmed' && (myAttendance || selectedStudent)}
<div style="padding:24px 20px;display:flex;flex-direction:column;gap:16px;max-width:480px;margin:0 auto">
<div style="text-align:center">
<div class="serif" style="font-size:28px;font-weight:500">
Du sitzt auf <span class="marker">Platz {myAttendance?.seat_id ?? '—'}</span>
</div>
</div>
<div class="card" style="padding:16px;text-align:center">
<div class="stamp" style="margin:0 auto 12px">✓ ANWESEND</div>
<div class="body">Eingecheckt um {myAttendance?.checked_in_at?.slice(11, 16) ?? '—'} Uhr</div>
</div>
<div style="overflow-x:auto">
<SeatMap variant="student-self" scale={0.46} ownSeat={myAttendance?.seat_id ?? null} />
</div>
<button class="btn ghost sm" style="align-self:center" onclick={changeSeat}>
Sitz wechseln
</button>
</div>
{/if}
{:else}
<!-- DESKTOP LAYOUT -->
{#if step === 'name'}
<div style="max-width:560px;margin:0 auto;padding:60px 20px">
<div style="text-align:center;margin-bottom:32px">
<div class="eyebrow">Check-in</div>
<div class="serif" style="font-size:40px;font-weight:500;margin-top:6px">Wer bist du?</div>
<div class="body" style="color:var(--ink-4);margin-top:8px">Wähle deinen Namen aus der Liste.</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px;margin-bottom:16px;text-align:center">
{errorMsg}
</div>
{/if}
<input
class="input"
bind:value={search}
placeholder="Name suchen…"
style="width:100%;font-size:16px;margin-bottom:16px"
/>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-height:55vh;overflow-y:auto" class="scroll">
{#each filteredStudents as student}
<button
class="card"
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
onclick={() => selectName(student)}
>
<span style="width:28px;height:28px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;flex-shrink:0">
{initials(student.name)}
</span>
<span class="body">{student.name}</span>
</button>
{:else}
<div style="grid-column:1/-1;padding:20px;text-align:center">
<span class="small" style="color:var(--ink-4)">Keine Treffer.</span>
</div>
{/each}
</div>
</div>
{:else if step === 'seat' && selectedStudent}
<div style="display:grid;grid-template-columns:1fr 360px;gap:0;min-height:100vh">
<!-- Left: SeatMap -->
<div style="padding:40px;display:flex;flex-direction:column;gap:20px">
<div>
<div class="eyebrow">Hallo, {selectedStudent.name.split(' ')[0]} 👋</div>
<div class="serif" style="font-size:32px;font-weight:500;margin-top:4px">Wähle deinen Sitz</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px">
{errorMsg}
</div>
{/if}
<SeatMap
variant="student"
scale={0.78}
onSeatClick={(seat) => checkin(seat.id)}
/>
<div style="display:flex;gap:20px" class="tiny">
<span style="display:flex;align-items:center;gap:6px">
<span style="width:14px;height:14px;border-radius:50%;background:var(--accent);display:inline-block"></span> Dein Platz
</span>
<span style="display:flex;align-items:center;gap:6px">
<span style="width:14px;height:14px;border-radius:50%;background:#d6cdb5;display:inline-block"></span> Belegt
</span>
<span style="display:flex;align-items:center;gap:6px">
<span style="width:14px;height:14px;border-radius:50%;background:#fbf7ee;border:1.5px solid var(--ink-2);display:inline-block"></span> Frei
</span>
</div>
</div>
<!-- Right: session info panel -->
<div style="border-left:1px solid var(--rule);padding:40px;display:flex;flex-direction:column;gap:20px">
<div class="card" style="padding:20px">
<div class="eyebrow" style="margin-bottom:6px">Sitzung</div>
{#if slot}
<div class="serif" style="font-size:20px;font-weight:500">{slot.start_time}{slot.end_time}</div>
{/if}
<div style="margin-top:12px"><StatusPill status={slot?.status ?? 'open'} /></div>
</div>
<div class="card" style="padding:16px">
<div class="tiny" style="color:var(--ink-3);margin-bottom:8px">Anwesend</div>
<div class="serif" style="font-size:28px;font-weight:500">{attendances.length}</div>
<div class="tiny" style="color:var(--ink-4);margin-top:4px">von {students.length} Studierenden</div>
</div>
<button class="btn ghost" onclick={() => { step = 'name'; selectedStudent = null; }}>
← Zurück
</button>
</div>
</div>
{:else if step === 'confirmed'}
<div style="display:grid;grid-template-columns:1fr 360px;gap:0;min-height:100vh">
<!-- Left -->
<div style="padding:40px;display:flex;flex-direction:column;gap:20px">
<div>
<div class="eyebrow">Eingecheckt</div>
<div class="serif" style="font-size:32px;font-weight:500;margin-top:4px">
Du sitzt auf <span class="marker">Platz {myAttendance?.seat_id ?? '—'}</span>
</div>
</div>
<SeatMap variant="student-self" scale={0.78} ownSeat={myAttendance?.seat_id ?? null} />
<div style="display:flex;gap:10px">
<button class="btn ghost" onclick={changeSeat}>Sitz wechseln</button>
<button class="btn ghost" onclick={() => window.print()}>Drucken</button>
</div>
</div>
<!-- Right -->
<div style="border-left:1px solid var(--rule);padding:40px;display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:20px;text-align:center">
<div class="stamp" style="margin:0 auto 12px">✓ ANWESEND</div>
<div class="body" style="font-weight:500">
{myAttendance?.checked_in_at?.slice(11, 16) ?? '—'} Uhr
</div>
</div>
{#if slot}
<div class="card" style="padding:16px">
<div class="eyebrow" style="margin-bottom:6px">Sitzung</div>
<div class="serif" style="font-size:18px;font-weight:500">{slot.start_time}{slot.end_time}</div>
</div>
{/if}
<div class="card" style="padding:16px">
<div class="tiny" style="color:var(--ink-3);margin-bottom:6px">Anwesende</div>
<div class="serif" style="font-size:24px;font-weight:500">{attendances.length} / {students.length}</div>
</div>
</div>
</div>
{/if}
{/if}
</div>