feat(frontend): add TutorShell sidebar layout and NoteEditor with auto-save
This commit is contained in:
187
frontend/src/lib/components/NoteEditor.svelte
Normal file
187
frontend/src/lib/components/NoteEditor.svelte
Normal 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>
|
||||
96
frontend/src/lib/components/TutorShell.svelte
Normal file
96
frontend/src/lib/components/TutorShell.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user