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
@@ -0,0 +1,38 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
export function GlossarSuche({ anfangswert }: { anfangswert: string }) {
const [wert, setzeWert] = useState(anfangswert);
const router = useRouter();
const erstesMal = useRef(true);
useEffect(() => {
if (erstesMal.current) {
erstesMal.current = false;
return;
}
const zeitgeber = setTimeout(() => {
router.replace(wert.trim() ? `/glossar?suche=${encodeURIComponent(wert.trim())}` : '/glossar', { scroll: false });
}, 200);
return () => clearTimeout(zeitgeber);
}, [wert, router]);
return (
<form action="/glossar" method="get" role="search" className="nicht-drucken">
<label htmlFor="glossar-suche" className="nur-lesehilfe">
Glossar durchsuchen
</label>
<input
id="glossar-suche"
name="suche"
type="search"
placeholder="Begriff suchen – z. B. Subnetzmaske"
value={wert}
onChange={(e) => setzeWert(e.target.value)}
autoComplete="off"
/>
</form>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
import { normalisiere } from '@/lib/inhalte/laden';
import { GlossarSuche } from './GlossarSuche';
export const metadata: Metadata = { title: 'Glossar' };
export const dynamic = 'force-dynamic';
export default async function GlossarSeite({ searchParams }: { searchParams: Promise<{ suche?: string }> }) {
await erfordereBenutzer();
const parameter = await searchParams;
const alle = inhalte();
const suchbegriff = (parameter.suche ?? '').trim();
const gesucht = normalisiere(suchbegriff);
const eintraege = [...alle.glossar].sort((a, b) => a.begriff.localeCompare(b.begriff, 'de-DE'));
const gefiltert = gesucht
? eintraege.filter(
(e) =>
normalisiere(e.begriff).includes(gesucht) ||
normalisiere(e.steht_fuer ?? '').includes(gesucht) ||
normalisiere(e.erklaerung).includes(gesucht) ||
e.formen.some((f) => normalisiere(f).includes(gesucht)),
)
: eintraege;
return (
<div className="stapel">
<div>
<h1>Glossar</h1>
<p className="leise">
{alle.glossar.length} Begriffe. In den Handouts sind sie verlinkt – ein Klick führt hierher.
</p>
</div>
<GlossarSuche anfangswert={suchbegriff} />
{gefiltert.length === 0 ? (
<p className="karte">Kein Begriff passt zu &bdquo;{suchbegriff}&ldquo;.</p>
) : (
<dl className="glossar">
{gefiltert.map((eintrag) => (
<div key={eintrag.begriff} id={glossarAnker(eintrag.begriff)} className="glossar-eintrag karte">
<dt>
{eintrag.begriff}
{eintrag.steht_fuer ? <span className="glossar-langform">{eintrag.steht_fuer}</span> : null}
</dt>
<dd>
<p style={{ marginBottom: eintrag.siehe_auch.length > 0 ? '0.5rem' : 0 }}>{eintrag.erklaerung}</p>
{eintrag.siehe_auch.length > 0 ? (
<p className="leise" style={{ margin: 0 }}>
Siehe auch:{' '}
{eintrag.siehe_auch.map((verweis, index) => (
<span key={verweis}>
{index > 0 ? ', ' : ''}
<Link href={`#${glossarAnker(verweis)}`}>{verweis}</Link>
</span>
))}
</p>
) : null}
</dd>
</div>
))}
</dl>
)}
</div>
);
}
@@ -0,0 +1,51 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
/**
* Suchfeld für die Handouts. Sucht beim Tippen mit kurzer Verzögerung, damit
* nicht bei jedem Buchstaben eine Anfrage losgeht, und funktioniert auch ohne
* JavaScript – dann eben erst beim Absenden.
*/
export function Suchfeld({ anfangswert }: { anfangswert: string }) {
const [wert, setzeWert] = useState(anfangswert);
const router = useRouter();
const erstesMal = useRef(true);
useEffect(() => {
if (erstesMal.current) {
erstesMal.current = false;
return;
}
const zeitgeber = setTimeout(() => {
const ziel = wert.trim() ? `/handouts?suche=${encodeURIComponent(wert.trim())}` : '/handouts';
router.replace(ziel, { scroll: false });
}, 250);
return () => clearTimeout(zeitgeber);
}, [wert, router]);
return (
<form action="/handouts" method="get" role="search" className="nicht-drucken">
<label htmlFor="suche" className="nur-lesehilfe">
Handouts durchsuchen
</label>
<div className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<input
id="suche"
name="suche"
type="search"
placeholder="Suchen – z. B. Subnetting, SMART, Vererbung"
value={wert}
onChange={(e) => setzeWert(e.target.value)}
autoComplete="off"
/>
<button type="submit" className="knopf knopf-zweit" style={{ flexShrink: 0 }}>
Suchen
</button>
</div>
</form>
);
}
@@ -0,0 +1,29 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { alsGelesenMerken, alsUngelesenMerken } from '@/lib/repos/fortschritt';
/**
* Gelesen-Markierung umschalten. Nur der Azubi führt seinen eigenen Fortschritt –
* der Ausbilder liest mit, hakt aber nichts für ihn ab.
*/
export async function gelesenUmschalten(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const handoutId = String(formular.get('handoutId') ?? '');
if (!inhalte().handoutNach.has(handoutId)) return;
const gelesen = String(formular.get('gelesen') ?? '') === '1';
if (gelesen) {
alsGelesenMerken(benutzer.id, handoutId);
} else {
alsUngelesenMerken(benutzer.id, handoutId);
}
revalidatePath(`/handouts/${handoutId}`);
revalidatePath('/handouts');
revalidatePath('/');
}
+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>
);
}
+144
View File
@@ -0,0 +1,144 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { sucheHandouts } from '@/lib/inhalte/suche';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { Suchfeld } from './Suchfeld';
export const metadata: Metadata = { title: 'Handouts' };
export const dynamic = 'force-dynamic';
export default async function HandoutListe({
searchParams,
}: {
searchParams: Promise<{ suche?: string; block?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const parameter = await searchParams;
const alle = inhalte();
const gelesen = benutzer.rolle === 'azubi' ? gelesenSet(benutzer.id) : new Set<string>();
const suchbegriff = (parameter.suche ?? '').trim();
const treffer = suchbegriff ? sucheHandouts(alle, suchbegriff) : null;
const gefilterterBlock = parameter.block && alle.blockNach.has(parameter.block) ? parameter.block : null;
return (
<div className="stapel">
<div>
<h1>Handouts</h1>
<p className="leise">
{alle.handouts.length} Lerneinheiten in {alle.bloecke.length} Themenblöcken
{benutzer.rolle === 'azubi' ? ` · ${gelesen.size} gelesen` : ''}
</p>
</div>
<Suchfeld anfangswert={suchbegriff} />
{treffer ? (
<section className="stapel" aria-labelledby="treffer-ueberschrift">
<h2 id="treffer-ueberschrift">
{treffer.length === 0
? `Nichts gefunden für „${suchbegriff}"`
: `${treffer.length} ${treffer.length === 1 ? 'Treffer' : 'Treffer'} für „${suchbegriff}"`}
</h2>
{treffer.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Versuch es mit einem einzelnen Wort. Gesucht wird in Titel, Merksatz, Merkkarten und im gesamten Text.
</p>
) : (
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{treffer.map(({ handout, ausschnitt }) => (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`} className="handout-treffer">
<span className="handout-kuerzel">{handout.id}</span>
<span>
<strong>{handout.titel}</strong>
<br />
<span className="leise">{ausschnitt}</span>
</span>
{gelesen.has(handout.id) ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : null}
</Link>
</li>
))}
</ul>
)}
</section>
) : (
<>
<nav className="reihe nicht-drucken" aria-label="Themenblöcke">
<Link href="/handouts" className={`knopf ${gefilterterBlock ? 'knopf-zweit' : ''}`} scroll={false}>
Alle
</Link>
{alle.bloecke.map((block) => (
<Link
key={block.id}
href={`/handouts?block=${block.id}`}
className={`knopf ${gefilterterBlock === block.id ? '' : 'knopf-zweit'}`}
scroll={false}
>
{block.titel}
</Link>
))}
</nav>
{alle.bloecke
.filter((block) => !gefilterterBlock || block.id === gefilterterBlock)
.map((block) => {
const desBlocks = alle.handoutsNachBlock.get(block.id) ?? [];
if (desBlocks.length === 0) return null;
const gelesenImBlock = desBlocks.filter((h) => gelesen.has(h.id)).length;
return (
<section key={block.id} className="stapel" aria-labelledby={`block-${block.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`block-${block.id}`} style={{ margin: 0 }}>
{block.titel}
</h2>
{benutzer.rolle === 'azubi' ? (
<span className="leise">
{gelesenImBlock} von {desBlocks.length} gelesen
</span>
) : (
<span className="leise">{desBlocks.length} Handouts</span>
)}
</div>
<ul className="raster" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{desBlocks.map((handout) => (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`} className="handout-kachel">
<span className="reihe" style={{ justifyContent: 'space-between', gap: '0.5rem' }}>
<span className="handout-kuerzel">{handout.id}</span>
{gelesen.has(handout.id) ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : (
<span className="leise" style={{ fontSize: '0.8rem' }}>
Woche {handout.woche}
</span>
)}
</span>
<strong style={{ display: 'block', marginTop: '0.4rem' }}>{handout.titel}</strong>
<span className="leise" style={{ display: 'block', marginTop: '0.2rem' }}>
{handout.worum}
</span>
</Link>
</li>
))}
</ul>
</section>
);
})}
</>
)}
</div>
);
}
+7 -1
View File
@@ -6,10 +6,16 @@ export const dynamic = 'force-dynamic';
// Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele,
// die es auch wirklich gibt.
const fuerAzubi: Navigationseintrag[] = [{ pfad: '/', titel: 'Woche' }];
const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' },
];
const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+2 -1
View File
@@ -5,6 +5,7 @@ import { erfordereAusbilder } from '@/lib/auth/rechte';
import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { alsDatumText } from '@/lib/zeit';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -39,7 +40,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
});
revalidatePath('/verwaltung');
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${beginn} eingerichtet.` };
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` };
}
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {