Baustein 2: Inhalte einlesen und pruefen, Handouts, Suche, Glossar

Die fachlichen Inhalte liegen als Dateien unter content/ und werden beim
Start eingelesen, geprueft und in den Speicher geladen.

- Inhaltslader mit Zod-Schemata und Querverweispruefung
- Fehlermeldungen nennen Datei und Zeile, gesammelt statt beim ersten
  Fehler geworfen; die Anwendung startet bei Fehlern nicht
- npm run inhalte:pruefen prueft ohne Start
- Handout-Ansicht mit festem Aufbau, Uebersicht nach Themenbloecken
- Volltextsuche mit Gewichtung und Trefferausschnitt
- Gelesen-Markierung je Handout
- Glossar mit Suche und automatischer Verlinkung in den Handouts
- Beispielinhalte: 4 Wochen, 12 Handouts, 24 Merkkarten, 4 Aufgaben,
  4 Checklisten, 2 Lernzielkontrollen, 24 Glossareintraege
- rahmenplan.yaml aus FIAusbV 2020 und KMK-Rahmenlehrplan, mit Fundstellen
- gray-matter entfernt, YAML-Kopf wird selbst herausgeloest
- npm run screenshots nimmt die Bilder fuer die Anleitung auf
- 39 neue Tests, insgesamt 68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 07:56:38 +02:00
co-authored by Claude Opus 5
parent e856349cd4
commit 1af5613e9b
70 changed files with 4779 additions and 124 deletions
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const handout = inhalte().handoutNach.get(id.toUpperCase());
return { title: handout ? `${handout.id} – ${handout.titel}` : 'Handout' };
}
export default async function HandoutSeite({ params }: { params: Promise<{ id: string }> }) {
const benutzer = await erfordereBenutzer();
const { id } = await params;
const alle = inhalte();
const handout = alle.handoutNach.get(id.toUpperCase());
if (!handout) notFound();
const block = alle.blockNach.get(handout.block);
const lernfeld = alle.lernfeldNach.get(handout.lernfeld);
const positionen = handout.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
const woche = alle.wocheNach.get(handout.woche);
const istAzubi = benutzer.rolle === 'azubi';
const gelesen = istAzubi ? gelesenAm(benutzer.id, handout.id) : null;
const desBlocks = alle.handoutsNachBlock.get(handout.block) ?? [];
const stelle = desBlocks.findIndex((h) => h.id === handout.id);
const vorher = stelle > 0 ? desBlocks[stelle - 1] : undefined;
const nachher = stelle < desBlocks.length - 1 ? desBlocks[stelle + 1] : undefined;
const wesentlichHtml = handout.wesentlich ? verlinkeGlossar(handout.wesentlich.html, alle.glossar) : '';
return (
<article className="stapel handout">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/handouts">Handouts</Link>
{block ? (
<>
{' › '}
<Link href={`/handouts?block=${block.id}`}>{block.titel}</Link>
</>
) : null}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="handout-kuerzel handout-kuerzel-gross">{handout.id}</span>
{gelesen ? (
<span className="marke-status" data-ton="gut">
✓ gelesen am {alsZeitpunktText(gelesen)}
</span>
) : null}
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{handout.titel}</h1>
<p className="handout-worum">{handout.worum}</p>
</header>
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.length === 0
? '–'
: positionen.map((p) => (
<span key={p.id} title={`${p.titel} (${p.verordnung})`} className="zuordnung-marke">
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
<div>
<dt>Lernfeld</dt>
<dd>
{lernfeld ? (
<span className="zuordnung-marke" title={`${lernfeld.stunden} Stunden im ${lernfeld.jahr}. Jahr`}>
<strong>{lernfeld.id}</strong> {lernfeld.titel}
</span>
) : (
'–'
)}
</dd>
</div>
<div>
<dt>Woche</dt>
<dd>
{woche ? (
<span className="zuordnung-marke">
<strong>{woche.nummer}</strong> {woche.titel}
</span>
) : (
'–'
)}
</dd>
</div>
</dl>
</div>
{handout.wesentlich ? (
<section aria-labelledby="wesentlich">
<h2 id="wesentlich">Das Wesentliche</h2>
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: wesentlichHtml }} />
</section>
) : null}
{handout.uebersicht ? (
<section aria-labelledby="uebersicht">
<h2 id="uebersicht">Übersicht</h2>
<div className="tabelle-huelle fliesstext" dangerouslySetInnerHTML={{ __html: handout.uebersicht.html }} />
</section>
) : null}
<aside className="merksatz" aria-label="Merksatz">
<span className="merksatz-kennzeichnung">Merksatz</span>
<p>{handout.merksatz}</p>
</aside>
{handout.pruefungsfrage ? (
<details className="pruefungsfrage">
<summary>Prüfungsfrage anzeigen</summary>
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: handout.pruefungsfrage.html }} />
</details>
) : null}
<section aria-labelledby="karten" className="karte stapel-eng">
<h2 id="karten" style={{ marginBottom: '0.3rem' }}>
Merkkarten zu diesem Handout
</h2>
<p className="leise" style={{ margin: 0 }}>
{handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie
nach Fälligkeit auf.
</p>
<ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
{handout.karten.map((karte, index) => (
<li key={index}>
<strong>{karte.frage}</strong>
<br />
<span className="leise">{karte.antwort}</span>
</li>
))}
</ol>
</section>
{istAzubi ? (
<form action={gelesenUmschalten} className="nicht-drucken">
<input type="hidden" name="handoutId" value={handout.id} />
<input type="hidden" name="gelesen" value={gelesen ? '0' : '1'} />
<button type="submit" className={`knopf ${gelesen ? 'knopf-zweit' : 'knopf-akzent'}`}>
{gelesen ? 'Doch noch nicht gelesen' : 'Als gelesen markieren'}
</button>
</form>
) : null}
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
{vorher ? (
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
← {vorher.id} {vorher.titel}
</Link>
) : (
<span />
)}
{nachher ? (
<Link href={`/handouts/${nachher.id}`} className="knopf knopf-still">
{nachher.id} {nachher.titel} →
</Link>
) : (
<span />
)}
</nav>
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>{handout.datei}</code>
</p>
</article>
);
}