diff --git a/package.json b/package.json index 3fa1a48..cdbe017 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "konto:anlegen": "tsx scripts/konto-anlegen.ts", "backup": "bash scripts/im-container/sicherung.sh", "restore": "bash scripts/im-container/wiederherstellen.sh", - "screenshots": "tsx scripts/screenshots.ts" + "screenshots": "tsx scripts/screenshots.ts", + "symbole": "tsx scripts/symbole.ts" }, "dependencies": { "@node-rs/argon2": "^2.0.2", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..7cdea14 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,161 @@ +/* eslint-disable */ +/** + * Der Dienstarbeiter der Lern-Bar. + * + * Er hat genau eine Aufgabe: Der Azubi soll im Serverraum, im Keller oder im + * Zug weiterlernen können, wo kein Netz ist. Alles andere wäre Ballast. + * + * ─── Was offline liegen darf ────────────────────────────────────────────── + * + * Nur das Lernmaterial: Handouts, Merkkarten, Glossar und die eigene Woche. + * + * **Nicht** zwischengespeichert werden Lernzielkontrollen, Berichtsheft, + * Bewertungen, Feedbackgespräche, Anhänge und jedes PDF. Der Grund ist keine + * Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft: Musterlösungen und + * Bewertungsdaten stehen hinter einer eigenen Prüfung. Was im Browsercache + * liegt, ist an dieser Prüfung vorbei – also liegt es dort nicht. + * + * ─── Wie zwischengespeichert wird ───────────────────────────────────────── + * + * Seiten: erst das Netz, dann der Speicher. So sieht man nach einer + * Aktualisierung sofort die neue Fassung und nur bei fehlendem Netz die alte. + * + * Dateien unter /_next/static/: erst der Speicher. Die Namen tragen eine + * Prüfsumme, ihr Inhalt ändert sich nie. + * + * Alles andere – Serveraktionen, Anmeldung, POST – geht immer ans Netz. + */ + +const FASSUNG = 'v1'; +const SCHALE = `lernbar-schale-${FASSUNG}`; +const SEITEN = `lernbar-seiten-${FASSUNG}`; +const DATEIEN = `lernbar-dateien-${FASSUNG}`; + +/** Was schon beim Einrichten vorliegen muss. */ +const GRUNDVORRAT = ['/offline', '/symbol-192.png', '/lernbar.svg', '/manifest.webmanifest']; + +/** Höchstens so viele Seiten liegen im Speicher – ein Handy ist kein Archiv. */ +const HOECHSTZAHL_SEITEN = 60; + +/** Nur diese Pfade dürfen offline vorliegen. Alles andere niemals. */ +function darfOfflineLiegen(pfad) { + if (pfad === '/' || pfad === '/offline' || pfad === '/glossar') return true; + if (pfad === '/handouts' || pfad.startsWith('/handouts/')) return true; + if (pfad === '/karten' || pfad.startsWith('/karten/')) return true; + if (/^\/woche\/\d+$/.test(pfad)) return true; + return false; +} + +self.addEventListener('install', (ereignis) => { + ereignis.waitUntil( + caches + .open(SCHALE) + // Einzeln, damit ein fehlendes Stück nicht die ganze Einrichtung kippt. + .then((speicher) => Promise.all(GRUNDVORRAT.map((pfad) => speicher.add(pfad).catch(() => undefined)))) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (ereignis) => { + ereignis.waitUntil( + caches + .keys() + .then((namen) => + Promise.all( + namen + .filter((name) => name.startsWith('lernbar-') && ![SCHALE, SEITEN, DATEIEN].includes(name)) + .map((name) => caches.delete(name)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +/** + * Beim Abmelden wird alles Persönliche weggeräumt. + * + * Ein geteiltes Werkstattgerät soll nach dem Abmelden nicht noch die Woche des + * vorigen Azubis im Speicher haben. + */ +self.addEventListener('message', (ereignis) => { + if (ereignis.data && ereignis.data.typ === 'aufraeumen') { + ereignis.waitUntil(Promise.all([caches.delete(SEITEN), caches.delete(DATEIEN)])); + } +}); + +async function speicherKuerzen(name, hoechstzahl) { + const speicher = await caches.open(name); + const schluessel = await speicher.keys(); + // Die ältesten zuerst – `keys()` liefert sie in der Reihenfolge des Ablegens. + for (const alt of schluessel.slice(0, Math.max(0, schluessel.length - hoechstzahl))) { + await speicher.delete(alt); + } +} + +/** Seiten: erst das Netz, bei Ausfall der Speicher, sonst die Offlineseite. */ +async function seiteHolen(anfrage) { + const pfad = new URL(anfrage.url).pathname; + + try { + const antwort = await fetch(anfrage); + + if (antwort.ok && darfOfflineLiegen(pfad)) { + const speicher = await caches.open(SEITEN); + await speicher.put(anfrage, antwort.clone()); + await speicherKuerzen(SEITEN, HOECHSTZAHL_SEITEN); + } + + return antwort; + } catch (fehler) { + const gespeichert = await caches.match(anfrage, { ignoreSearch: false }); + if (gespeichert) return gespeichert; + + const offline = await caches.match('/offline'); + if (offline) return offline; + + return new Response('Kein Netz und nichts im Speicher.', { + status: 503, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + } +} + +/** Unveränderliche Dateien: erst der Speicher. */ +async function dateiHolen(anfrage) { + const gespeichert = await caches.match(anfrage); + if (gespeichert) return gespeichert; + + const antwort = await fetch(anfrage); + if (antwort.ok) { + const speicher = await caches.open(DATEIEN); + await speicher.put(anfrage, antwort.clone()); + } + return antwort; +} + +self.addEventListener('fetch', (ereignis) => { + const anfrage = ereignis.request; + + // Nur eigene Adressen, nur Lesezugriffe. + if (anfrage.method !== 'GET') return; + + const adresse = new URL(anfrage.url); + if (adresse.origin !== self.location.origin) return; + + // Serveraktionen und Datenschnittstellen gehen immer ans Netz – eine + // zwischengespeicherte Antwort wäre hier schlicht falsch. + if (adresse.pathname.startsWith('/api/') || anfrage.headers.has('Next-Action')) return; + + // Auch die Teilnachladungen von Next (RSC) nicht: Sie gehören zu einer + // Sitzung und sollen nicht als Seite verwechselt werden. + if (adresse.searchParams.has('_rsc')) return; + + if (anfrage.mode === 'navigate') { + ereignis.respondWith(seiteHolen(anfrage)); + return; + } + + if (adresse.pathname.startsWith('/_next/static/') || adresse.pathname.endsWith('.svg') || adresse.pathname.endsWith('.png')) { + ereignis.respondWith(dateiHolen(anfrage)); + } +}); diff --git a/public/symbol-192.png b/public/symbol-192.png new file mode 100644 index 0000000..8175d58 Binary files /dev/null and b/public/symbol-192.png differ diff --git a/public/symbol-512.png b/public/symbol-512.png new file mode 100644 index 0000000..90c8364 Binary files /dev/null and b/public/symbol-512.png differ diff --git a/public/symbol-maskabel-512.png b/public/symbol-maskabel-512.png new file mode 100644 index 0000000..93f575f Binary files /dev/null and b/public/symbol-maskabel-512.png differ diff --git a/scripts/pruefungen/baustein-10.mjs b/scripts/pruefungen/baustein-10.mjs new file mode 100644 index 0000000..6316a1a --- /dev/null +++ b/scripts/pruefungen/baustein-10.mjs @@ -0,0 +1,292 @@ +import { chromium } from '@playwright/test'; + +/** + * Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck. + * + * Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz + * weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen. + * Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen + * Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den + * Browserspeicher. + * + * Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes. + */ + +const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000'; +const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' }; + +let fehler = 0; +function pruefe(bedingung, text, zusatz = '') { + console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`); + if (!bedingung) fehler += 1; +} + +const browser = await chromium.launch(); +const kontext = await browser.newContext({ locale: 'de-DE' }); +const seite = await kontext.newPage(); + +async function anmelden() { + await seite.goto(`${BASIS}/anmelden`); + await seite.fill('#email', AZUBI.email); + await seite.fill('#passwort', AZUBI.pw); + await seite.click('button[type=submit]'); + await seite.waitForURL(`${BASIS}/`); +} + +/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */ +async function dienstarbeiterBereit() { + return seite.evaluate(async () => { + if (!('serviceWorker' in navigator)) return false; + const registrierung = await navigator.serviceWorker.ready; + // `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab. + if (navigator.serviceWorker.controller) return true; + await new Promise((fertig) => { + navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true }); + window.setTimeout(fertig, 5000); + }); + return Boolean(navigator.serviceWorker.controller || registrierung.active); + }); +} + +// --- 1. Die Anwendung lässt sich installieren ------------------------------ + +console.log('\n1. Installierbar'); + +const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`); +pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`); + +const manifestInhalt = await manifest.json().catch(() => null); +pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung'); +pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung'); +pruefe( + Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'), + 'Es enthält ein Symbol für den Beschnitt unter Android', +); + +for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) { + const antwort = await kontext.request.get(`${BASIS}${datei}`); + pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`); +} + +// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz +// ohne Internet. +await anmelden(); +const fremdeQuellen = await seite.evaluate(() => + [...document.querySelectorAll('script[src], link[href], img[src]')] + .map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '') + .filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)), +); +pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine'); + +// --- 2. Der Dienstarbeiter übernimmt --------------------------------------- + +console.log('\n2. Dienstarbeiter'); + +pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite'); + +// Lernmaterial einmal ansehen, damit es im Speicher landet. +await seite.goto(`${BASIS}/handouts/N3`); +await seite.waitForSelector('h1'); +await seite.goto(`${BASIS}/karten/lernen`); +await seite.waitForSelector('h1, .lernkarte'); + +// Und etwas, das **nicht** in den Speicher darf. +await seite.goto(`${BASIS}/kontrollen`); +await seite.waitForSelector('h1'); + +const imSpeicher = await seite.evaluate(async () => { + const namen = await caches.keys(); + const pfade = []; + for (const name of namen) { + const speicher = await caches.open(name); + for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname); + } + return pfade; +}); + +pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher'); +pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher'); + +// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune. +const verboten = imSpeicher.filter((pfad) => + /^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad), +); +pruefe( + verboten.length === 0, + 'Nichts Bewertungsrelevantes liegt im Browserspeicher', + verboten.join(', ') || 'nichts', +); +pruefe( + !imSpeicher.some((pfad) => pfad.endsWith('/pdf')), + 'Kein PDF liegt im Browserspeicher', +); + +// --- 3. Ohne Netz -------------------------------------------------------- + +console.log('\n3. Ohne Netz'); + +await kontext.setOffline(true); + +const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null); +pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz'); +pruefe( + (await seite.locator('h1').innerText().catch(() => '')).length > 0, + 'Es ist wirklich der Inhalt und keine Fehlerseite', + await seite.locator('h1').innerText().catch(() => '—'), +); + +const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null); +pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz'); + +// Die Netzleiste sagt Bescheid. +await seite.waitForTimeout(500); +const leiste = await seite.locator('.netzleiste').count(); +pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint'); + +// Eine Seite, die nie im Speicher war, landet auf der Offlineseite. +const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null); +const unbekannterText = await seite.locator('body').innerText().catch(() => ''); +pruefe( + unbekannterText.includes('Kein Netz'), + 'Eine nicht gespeicherte Seite zeigt die Offlineseite', + `HTTP ${unbekannt?.status() ?? '—'}`, +); + +// --- 4. Bewertungen gehen nicht verloren ----------------------------------- + +console.log('\n4. Bewertungen ohne Netz'); + +await seite.goto(`${BASIS}/karten/lernen`); +const hatKarte = (await seite.locator('.lernkarte').count()) > 0; + +if (!hatKarte) { + console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen'); +} else { + await seite.click('.lernkarte'); + await seite.waitForSelector('.bewerten-knopf'); + await seite.click('.bewerten-sitzt'); + await seite.waitForTimeout(800); + + const schlange = await seite.evaluate(() => + Object.keys(localStorage) + .filter((s) => s.startsWith('lernbar-warteschlange-')) + .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')), + ); + pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`); + pruefe( + schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)), + 'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung', + ); + + const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText(); + pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim()); + + // --- Und wieder im Netz --- + console.log('\n5. Wieder im Netz'); + + // Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück, + // während der Azubi noch im Lernmodus steht – genau dort muss die + // Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie, + // weil die Schlange dann schon leer ist. + await kontext.setOffline(false); + + const bestaetigung = await seite + .locator('.netzleiste') + .filter({ hasText: 'nachgereicht' }) + .first() + .innerText({ timeout: 15000 }) + .catch(() => ''); + pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—'); + + const nachher = await seite.evaluate(() => + Object.keys(localStorage) + .filter((s) => s.startsWith('lernbar-warteschlange-')) + .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')), + ); + pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`); + + // Und die Bewertung ist wirklich angekommen, nicht nur verschwunden. + await seite.goto(`${BASIS}/karten`); + await seite.waitForSelector('h1'); + const uebersicht = await seite.locator('body').innerText(); + pruefe( + !/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht), + 'Die Übersicht kennt den neuen Lernstand', + ); +} + +await kontext.setOffline(false); + +// --- 6. Die Schnittstelle prüft die Rechte --------------------------------- + +console.log('\n6. Rechte an der Schnittstelle'); + +const ohneAnmeldung = await browser.newContext(); +const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, { + data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] }, +}); +pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`); +await ohneAnmeldung.close(); + +const alsAusbilder = await browser.newContext({ locale: 'de-DE' }); +const ausbilderSeite = await alsAusbilder.newPage(); +await ausbilderSeite.goto(`${BASIS}/anmelden`); +await ausbilderSeite.fill('#email', 'fw@wildeflorian.de'); +await ausbilderSeite.fill('#passwort', 'ausbilder-start'); +await ausbilderSeite.click('button[type=submit]'); +await ausbilderSeite.waitForURL(`${BASIS}/`); + +const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, { + data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] }, +}); +pruefe( + alsAusbilderAbgewiesen.status() === 403, + 'Der Ausbilder darf den Lernstand nicht führen', + `HTTP ${alsAusbilderAbgewiesen.status()}`, +); +await alsAusbilder.close(); + +// --- 7. Druck -------------------------------------------------------------- + +console.log('\n7. Druck'); + +await seite.goto(`${BASIS}/handouts/N3`); +await seite.waitForSelector('h1'); + +// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem +// früher schwarze Flächen aufs Papier gekommen wären. +await seite.evaluate(() => { + document.documentElement.setAttribute('data-thema', 'dunkel'); + localStorage.setItem('lernbar-thema', 'dunkel'); +}); +await seite.emulateMedia({ media: 'print' }); +await seite.waitForTimeout(200); + +const druckfarben = await seite.evaluate(() => { + const stil = getComputedStyle(document.body); + const wurzel = getComputedStyle(document.documentElement); + return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() }; +}); +pruefe( + druckfarben.grund === 'rgb(255, 255, 255)', + 'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung', + druckfarben.grund, +); +pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text); +pruefe( + druckfarben.flaeche.toLowerCase() === '#f2f2f2', + 'Auch die Flächen sind hell', + druckfarben.flaeche, +); + +const versteckt = await seite.evaluate(() => { + const kopf = document.querySelector('.kopf'); + return kopf ? getComputedStyle(kopf).display : 'keine'; +}); +pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt); + +await seite.emulateMedia({ media: null }); +await browser.close(); + +console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/scripts/symbole.ts b/scripts/symbole.ts new file mode 100644 index 0000000..5bf9894 --- /dev/null +++ b/scripts/symbole.ts @@ -0,0 +1,77 @@ +/** + * Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst – + * ohne Bilddienst, ohne Netz. + * + * npm run symbole + * + * Zwei Fassungen, weil Android sie unterschiedlich behandelt: + * + * symbol-192.png / symbol-512.png das Zeichen, wie es ist + * symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf + * Petrol und nach innen gerückt. Android + * schneidet das Symbol in eine eigene + * Form – ein Kreis, ein Tropfen, was das + * Gerät gerade mag. Was zu weit außen + * liegt, ist dann weg. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { chromium } from '@playwright/test'; + +const ZIEL = path.join(process.cwd(), 'public'); + +/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */ +function zeichen(randAnteil: number, eckenRadius: number): string { + // Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten. + // `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern. + const innen = 64 * (1 - 2 * randAnteil); + const versatz = 64 * randAnteil; + + return ` + + + + + + +`; +} + +type Auftrag = { datei: string; groesse: number; svg: string }; + +const auftraege: Auftrag[] = [ + { datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) }, + { datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) }, + // Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden. + { datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) }, +]; + +async function main() { + const browser = await chromium.launch(); + + for (const auftrag of auftraege) { + const seite = await browser.newPage({ + viewport: { width: auftrag.groesse, height: auftrag.groesse }, + deviceScaleFactor: 1, + }); + + await seite.setContent( + `${auftrag.svg}`, + ); + + const datei = path.join(ZIEL, auftrag.datei); + await seite.screenshot({ path: datei, omitBackground: false }); + await seite.close(); + + console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`); + } + + await browser.close(); + console.log('Symbole erzeugt.'); +} + +main().catch((fehler) => { + console.error('Die Symbole konnten nicht erzeugt werden.'); + console.error(fehler instanceof Error ? fehler.message : fehler); + process.exit(1); +}); diff --git a/src/app/(angemeldet)/auswertung/page.tsx b/src/app/(angemeldet)/auswertung/page.tsx index 9ce1ea4..0393427 100644 --- a/src/app/(angemeldet)/auswertung/page.tsx +++ b/src/app/(angemeldet)/auswertung/page.tsx @@ -6,6 +6,7 @@ import { benutzerNachId } from '@/lib/repos/benutzer'; import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln'; import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung'; import { verlauf } from '@/lib/kontrollen/bewertung'; +import { Druckknopf } from '@/komponenten/Druckknopf'; export const metadata: Metadata = { title: 'Auswertung' }; export const dynamic = 'force-dynamic'; @@ -72,6 +73,12 @@ export default async function AuswertungsSeite({ searchParams }: { searchParams: + + {/* Die Abdeckung ist die Ansicht, die man zum Gespräch mitnimmt – und zwar + auf Papier, weil dort niemand scrollt. */} +
+ +
); } diff --git a/src/app/(angemeldet)/glossar/page.tsx b/src/app/(angemeldet)/glossar/page.tsx index 84fbcec..776cc06 100644 --- a/src/app/(angemeldet)/glossar/page.tsx +++ b/src/app/(angemeldet)/glossar/page.tsx @@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte'; import { glossarAnker } from '@/lib/inhalte/glossarverweise'; import { normalisiere } from '@/lib/inhalte/laden'; import { GlossarSuche } from './GlossarSuche'; +import { Druckknopf } from '@/komponenten/Druckknopf'; export const metadata: Metadata = { title: 'Glossar' }; export const dynamic = 'force-dynamic'; @@ -67,6 +68,10 @@ export default async function GlossarSeite({ searchParams }: { searchParams: Pro ))} )} + +
+ +
); } diff --git a/src/app/(angemeldet)/handouts/[id]/page.tsx b/src/app/(angemeldet)/handouts/[id]/page.tsx index a2f088f..ed5b372 100644 --- a/src/app/(angemeldet)/handouts/[id]/page.tsx +++ b/src/app/(angemeldet)/handouts/[id]/page.tsx @@ -7,6 +7,7 @@ import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise'; import { gelesenAm } from '@/lib/repos/fortschritt'; import { alsZeitpunktText } from '@/lib/zeit'; import { gelesenUmschalten } from './aktionen'; +import { Druckknopf } from '@/komponenten/Druckknopf'; export const dynamic = 'force-dynamic'; @@ -159,6 +160,10 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s ) : null} +
+ +
+