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:
co-authored by
Claude Opus 5
parent
e856349cd4
commit
1af5613e9b
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 „{suchbegriff}“.</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('/');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+211
-3
@@ -323,9 +323,7 @@ select:disabled {
|
||||
color: #f2f8fa;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.marke:hover { color: #fff; }
|
||||
.marke .marke-akzent { color: #e5a552; }
|
||||
@@ -488,3 +486,213 @@ table.tabelle th {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* --- Handouts --- */
|
||||
|
||||
.handout-kuerzel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.6rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-klein);
|
||||
background: var(--petrol);
|
||||
color: var(--text-auf-petrol);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.handout-kuerzel-gross {
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
}
|
||||
|
||||
.handout-kachel {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rand-zart);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.handout-kachel:hover {
|
||||
background: var(--flaeche-kraeftig);
|
||||
border-color: var(--rand);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.handout-treffer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rand-zart);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.8rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.handout-treffer:hover {
|
||||
background: var(--flaeche-kraeftig);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.handout-worum {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-leise);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.handout-zuordnung dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.handout-zuordnung dt {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-leise);
|
||||
font-weight: 650;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.handout-zuordnung dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.zuordnung-marke {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
background: var(--grund);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius-klein);
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.87rem;
|
||||
}
|
||||
.zuordnung-marke strong {
|
||||
color: var(--petrol);
|
||||
}
|
||||
|
||||
.fliesstext > *:first-child { margin-top: 0; }
|
||||
.fliesstext > *:last-child { margin-bottom: 0; }
|
||||
.fliesstext ul,
|
||||
.fliesstext ol {
|
||||
padding-left: 1.3rem;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.fliesstext li + li { margin-top: 0.4rem; }
|
||||
.fliesstext table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
.fliesstext th,
|
||||
.fliesstext td {
|
||||
border: 1px solid var(--rand-zart);
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.fliesstext th {
|
||||
background: var(--flaeche);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.merksatz {
|
||||
border-left: 5px solid var(--bernstein);
|
||||
background: var(--warnung-flaeche);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
.merksatz-kennzeichnung {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
color: var(--warnung);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.merksatz p {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pruefungsfrage {
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius);
|
||||
background: var(--grund);
|
||||
}
|
||||
.pruefungsfrage > summary {
|
||||
cursor: pointer;
|
||||
padding: 0.7rem 1rem;
|
||||
font-weight: 650;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
.pruefungsfrage > summary::-webkit-details-marker { display: none; }
|
||||
.pruefungsfrage > summary::before {
|
||||
content: "▸";
|
||||
color: var(--petrol);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.pruefungsfrage[open] > summary::before { content: "▾"; }
|
||||
.pruefungsfrage[open] > summary { border-bottom: 1px solid var(--rand-zart); }
|
||||
.pruefungsfrage > div { padding: 0.9rem 1rem; }
|
||||
|
||||
a.glossarbegriff {
|
||||
color: inherit;
|
||||
text-decoration: underline dotted var(--petrol);
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
cursor: help;
|
||||
}
|
||||
a.glossarbegriff:hover {
|
||||
color: var(--petrol);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
/* --- Glossar --- */
|
||||
|
||||
.glossar {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
.glossar-eintrag {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
.glossar-eintrag dt {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.glossar-langform {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-leise);
|
||||
}
|
||||
.glossar-eintrag dd { margin: 0; }
|
||||
.glossar-eintrag:target {
|
||||
outline: 3px solid var(--bernstein);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.pruefungsfrage > div { display: block !important; }
|
||||
.handout-zuordnung { border: 1px solid #999; }
|
||||
a.glossarbegriff { text-decoration: none; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export const metadata: Metadata = {
|
||||
title: { default: 'Lern-Bar', template: '%s · Lern-Bar' },
|
||||
description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration',
|
||||
applicationName: 'Lern-Bar',
|
||||
// Sonst macht der Browser aus Zahlenkolonnen in Tabellen Telefonnummern.
|
||||
formatDetection: { telephone: false, date: false, address: false, email: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
Reference in New Issue
Block a user