docs: Bedienungsanleitung und Roadmap
Bis jetzt gab es Code und Betriebsdokumentation, aber nichts, was einem beim Arbeiten hilft. Die Anleitung ist kein Handbuch zum Durchlesen, sondern die zwoelf Handgriffe, die im Alltag vorkommen - vom Behoerdenschreiben aufnehmen bis zum Vertrag kuendigen. Dazu der Jahresrhythmus, die Berichte, was am Handy anders ist, und ein Abschnitt "wenn etwas schiefgeht" mit den Faellen, die wirklich passieren: Handy verloren, Code wird nicht angenommen, Frist stimmt nicht, Bericht zeigt eine Differenz. Ein eigener Abschnitt sagt, was die Anwendung NICHT tut. Wer sich auf etwas verlaesst, das sie nicht leistet, faellt sonst genau dann darauf herein, wenn es darauf ankommt. Sie ist unter /anleitung auch in der Anwendung lesbar - dieselbe Datei, kein zweiter Text, der auseinanderlaeuft. Wer im Heim steht und nicht weiterweiss, hat sie auf dem Telefon dabei, und der Druckknopf legt sie zu den Betreuungsunterlagen. Dafuer ein schlichter Markdown-Baustein statt einer Bibliothek: die Anwendung zeigt genau zwei solche Dokumente, beide schreibe ich selbst. Rohes HTML wird nicht durchgereicht. Die Datenschutzseite benutzt ihn jetzt ebenfalls, statt eine zweite Umsetzung zu pflegen. Beide Dateien werden ueber outputFileTracingIncludes in den Standalone-Build gezogen - der Pfad entsteht erst zur Laufzeit, die Dateiverfolgung von Next.js kann ihn nicht erkennen. Ohne den Eintrag waeren beide Seiten im Behaelter leer. Die Roadmap sortiert die 44 Schema-Wuensche der Etappen nach einer einzigen Frage zuerst: Kann daraus ein falscher Wert in einem Bericht ans Gericht werden? Was das kann, steht in 1.1. Dazu ein Abschnitt "bewusst nicht geplant" mit Begruendung - damit klar ist, worauf niemand wartet. Etappe: 8 Phase: Haertung
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||
import { MarkdownSeite } from '@/components/markdown-seite'
|
||||
import { DruckKnopf } from '@/components/druck-knopf'
|
||||
import { erfordereBenutzer } from '@/lib/auth/sitzung'
|
||||
|
||||
export const metadata: Metadata = { title: 'Anleitung' }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Die Bedienungsanleitung, gelesen aus docs/ANLEITUNG.md.
|
||||
*
|
||||
* Dieselbe Datei, die auch im Repository liegt und sich ausdrucken laesst -
|
||||
* zwei Fassungen desselben Textes waeren zwei Fassungen, die auseinander-
|
||||
* laufen. Wer im Heim steht und nicht weiterweiss, hat sie damit auf dem
|
||||
* Telefon dabei.
|
||||
*/
|
||||
export default async function AnleitungSeite() {
|
||||
await erfordereBenutzer()
|
||||
|
||||
const pfad = path.join(process.cwd(), 'docs', 'ANLEITUNG.md')
|
||||
let inhalt: string
|
||||
|
||||
try {
|
||||
inhalt = await readFile(pfad, 'utf8')
|
||||
} catch {
|
||||
inhalt = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Bedienungsanleitung"
|
||||
beschreibung="Die Handgriffe, die du tatsächlich brauchst — in der Reihenfolge, in der sie im Alltag vorkommen."
|
||||
aktionen={<DruckKnopf beschriftung="Anleitung drucken" />}
|
||||
/>
|
||||
|
||||
{inhalt === '' ? (
|
||||
<Alert variant="warnung">
|
||||
<BookOpen aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Die Anleitung konnte nicht geladen werden. Sie liegt im Projekt unter{' '}
|
||||
<code>docs/ANLEITUNG.md</code>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<MarkdownSeite inhalt={inhalt} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { MarkdownSeite } from '@/components/markdown-seite'
|
||||
|
||||
export const metadata: Metadata = { title: 'Datenschutz' }
|
||||
|
||||
@@ -11,83 +12,7 @@ export const metadata: Metadata = { title: 'Datenschutz' }
|
||||
* Der Text kommt aus docs/datenschutzhinweise.md - dieselbe Datei, die man
|
||||
* auch ausdrucken und der betreuten Person aushaendigen kann. Zwei Quellen
|
||||
* fuer denselben Text waeren zwei Quellen fuer Fehler.
|
||||
*
|
||||
* Die Umsetzung ist bewusst schlicht: keine Markdown-Bibliothek, nur die
|
||||
* wenigen Auszeichnungen, die im Dokument wirklich vorkommen.
|
||||
*/
|
||||
function alsAbsaetze(markdown: string) {
|
||||
const zeilen = markdown.split(/\r?\n/)
|
||||
const bloecke: React.ReactNode[] = []
|
||||
let absatz: string[] = []
|
||||
|
||||
const absatzSchliessen = (schluessel: number) => {
|
||||
if (absatz.length === 0) return
|
||||
bloecke.push(
|
||||
<p key={`p-${schluessel}`} className="leading-relaxed">
|
||||
{absatz.join(' ')}
|
||||
</p>,
|
||||
)
|
||||
absatz = []
|
||||
}
|
||||
|
||||
zeilen.forEach((zeile, i) => {
|
||||
const roh = zeile.trim()
|
||||
|
||||
// Der Hinweisblock fuer den Ausfuellenden gehoert nicht auf die Seite.
|
||||
if (roh.startsWith('>')) return
|
||||
|
||||
if (roh === '' || roh === '---') {
|
||||
absatzSchliessen(i)
|
||||
return
|
||||
}
|
||||
if (roh.startsWith('## ')) {
|
||||
absatzSchliessen(i)
|
||||
bloecke.push(
|
||||
<h2 key={i} className="mt-8 text-xl font-semibold">
|
||||
{roh.slice(3)}
|
||||
</h2>,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (roh.startsWith('# ')) {
|
||||
absatzSchliessen(i)
|
||||
bloecke.push(
|
||||
<h1 key={i} className="text-2xl font-semibold">
|
||||
{roh.slice(2)}
|
||||
</h1>,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (roh.startsWith('- ')) {
|
||||
absatzSchliessen(i)
|
||||
bloecke.push(
|
||||
<li key={i} className="ml-5 list-disc leading-relaxed">
|
||||
{roh.slice(2)}
|
||||
</li>,
|
||||
)
|
||||
return
|
||||
}
|
||||
// Tabellenzeilen werden als einfache Aufzaehlung dargestellt.
|
||||
if (roh.startsWith('|')) {
|
||||
absatzSchliessen(i)
|
||||
const spalten = roh.split('|').map((s) => s.trim()).filter(Boolean)
|
||||
if (spalten.every((s) => /^-+$/.test(s))) return
|
||||
bloecke.push(
|
||||
<div key={i} className="flex flex-col gap-0.5 border-l-2 py-1 pl-3 sm:flex-row sm:gap-3">
|
||||
<span className="min-w-56 font-medium">{spalten[0]}</span>
|
||||
<span className="text-muted-foreground">{spalten.slice(1).join(' · ')}</span>
|
||||
</div>,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
absatz.push(roh)
|
||||
})
|
||||
|
||||
absatzSchliessen(zeilen.length)
|
||||
return bloecke
|
||||
}
|
||||
|
||||
export default async function DatenschutzSeite() {
|
||||
const pfad = path.join(process.cwd(), 'docs', 'datenschutzhinweise.md')
|
||||
let inhalt: string
|
||||
@@ -95,14 +20,24 @@ export default async function DatenschutzSeite() {
|
||||
try {
|
||||
inhalt = await readFile(pfad, 'utf8')
|
||||
} catch {
|
||||
inhalt =
|
||||
'# Datenschutzhinweise\n\nDer Text konnte nicht geladen werden. ' +
|
||||
'Er liegt in docs/datenschutzhinweise.md.'
|
||||
inhalt = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
|
||||
<article className="space-y-3 [&_strong]:font-semibold">{alsAbsaetze(inhalt)}</article>
|
||||
<h1 className="text-2xl font-semibold">Datenschutzhinweise</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">nach Art. 13 und 14 DSGVO</p>
|
||||
|
||||
<div className="mt-8">
|
||||
{inhalt === '' ? (
|
||||
<p className="text-muted-foreground">
|
||||
Der Text konnte nicht geladen werden. Er liegt im Projekt unter{' '}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">docs/datenschutzhinweise.md</code>.
|
||||
</p>
|
||||
) : (
|
||||
<MarkdownSeite inhalt={inhalt} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-10 border-t pt-6 text-sm text-muted-foreground">
|
||||
<Link href="/anmelden" className="underline hover:no-underline">
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { LogOut, Monitor, Moon, Settings, ShieldCheck, Sun, User as UserIcon } from 'lucide-react'
|
||||
import {
|
||||
BookOpen,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Moon,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -96,6 +105,12 @@ export function BenutzerMenue({
|
||||
Einstellungen
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/anleitung">
|
||||
<BookOpen className="size-4" />
|
||||
Anleitung
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Printer } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* Druckt die aktuelle Seite.
|
||||
*
|
||||
* Muss eine Client-Komponente sein - window.print() gibt es auf dem
|
||||
* Server nicht. Der Knopf selbst traegt "no-print", damit er auf dem
|
||||
* Ausdruck nicht auftaucht.
|
||||
*/
|
||||
export function DruckKnopf({ beschriftung = 'Drucken' }: { beschriftung?: string }) {
|
||||
return (
|
||||
<Button variant="outline" className="no-print" onClick={() => window.print()}>
|
||||
<Printer aria-hidden="true" />
|
||||
{beschriftung}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* Anzeige einer Markdown-Datei aus dem docs-Verzeichnis.
|
||||
*
|
||||
* Bewusst ohne Markdown-Bibliothek: Die Anwendung zeigt genau zwei solche
|
||||
* Dokumente an - die Datenschutzhinweise und die Anleitung. Beide schreibe
|
||||
* ich selbst, ich weiss also, welche Auszeichnungen darin vorkommen. Eine
|
||||
* Bibliothek waere hier vor allem zusaetzliche Abhaengigkeit und
|
||||
* zusaetzliche Angriffsflaeche.
|
||||
*
|
||||
* Unterstuetzt: Ueberschriften, Absaetze, Aufzaehlungen (auch nummeriert),
|
||||
* Tabellen, Zitatbloecke, Codebloecke, Trennlinien sowie **fett**,
|
||||
* `Code` und [Links](ziel) im Fliesstext.
|
||||
*
|
||||
* Nicht unterstuetzt und auch nicht noetig: eingebettetes HTML, Bilder,
|
||||
* Fussnoten. Rohes HTML wird NICHT durchgereicht - der Text wird immer als
|
||||
* Text ausgegeben, nie als Markup.
|
||||
*/
|
||||
|
||||
type Block =
|
||||
| { art: 'ueberschrift'; ebene: 2 | 3 | 4; text: string }
|
||||
| { art: 'absatz'; text: string }
|
||||
| { art: 'liste'; nummeriert: boolean; punkte: string[] }
|
||||
| { art: 'tabelle'; kopf: string[]; zeilen: string[][] }
|
||||
| { art: 'zitat'; zeilen: string[] }
|
||||
| { art: 'code'; zeilen: string[] }
|
||||
| { art: 'trenner' }
|
||||
|
||||
/** Zerlegt den Fliesstext in fett, Code und Links. */
|
||||
function inline(text: string, schluessel: string): React.ReactNode[] {
|
||||
const teile: React.ReactNode[] = []
|
||||
// Reihenfolge zaehlt: Code zuerst, damit Sternchen darin nicht greifen.
|
||||
const muster = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g
|
||||
|
||||
let letzte = 0
|
||||
let treffer: RegExpExecArray | null
|
||||
let nummer = 0
|
||||
|
||||
while ((treffer = muster.exec(text)) !== null) {
|
||||
if (treffer.index > letzte) {
|
||||
teile.push(text.slice(letzte, treffer.index))
|
||||
}
|
||||
|
||||
const stueck = treffer[0]
|
||||
const id = `${schluessel}-${nummer++}`
|
||||
|
||||
if (stueck.startsWith('`')) {
|
||||
teile.push(
|
||||
<code key={id} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.9em]">
|
||||
{stueck.slice(1, -1)}
|
||||
</code>,
|
||||
)
|
||||
} else if (stueck.startsWith('**')) {
|
||||
teile.push(
|
||||
<strong key={id} className="font-semibold">
|
||||
{stueck.slice(2, -2)}
|
||||
</strong>,
|
||||
)
|
||||
} else {
|
||||
const beschriftung = stueck.slice(1, stueck.indexOf(']'))
|
||||
const ziel = stueck.slice(stueck.indexOf('](') + 2, -1)
|
||||
const extern = /^https?:\/\//.test(ziel)
|
||||
teile.push(
|
||||
<a
|
||||
key={id}
|
||||
href={ziel}
|
||||
className="text-primary underline underline-offset-2 hover:no-underline"
|
||||
{...(extern ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
|
||||
>
|
||||
{beschriftung}
|
||||
</a>,
|
||||
)
|
||||
}
|
||||
|
||||
letzte = treffer.index + stueck.length
|
||||
}
|
||||
|
||||
if (letzte < text.length) teile.push(text.slice(letzte))
|
||||
return teile
|
||||
}
|
||||
|
||||
function zerlegen(markdown: string, ohneTitel: boolean): Block[] {
|
||||
const zeilen = markdown.split(/\r?\n/)
|
||||
const bloecke: Block[] = []
|
||||
let absatz: string[] = []
|
||||
|
||||
const absatzSchliessen = () => {
|
||||
if (absatz.length === 0) return
|
||||
bloecke.push({ art: 'absatz', text: absatz.join(' ') })
|
||||
absatz = []
|
||||
}
|
||||
|
||||
for (let i = 0; i < zeilen.length; i++) {
|
||||
const roh = zeilen[i] ?? ''
|
||||
const zeile = roh.trimEnd()
|
||||
const gekuerzt = zeile.trim()
|
||||
|
||||
// --- Codeblock ---------------------------------------------------------
|
||||
if (gekuerzt.startsWith('```')) {
|
||||
absatzSchliessen()
|
||||
const inhalt: string[] = []
|
||||
i += 1
|
||||
while (i < zeilen.length && !(zeilen[i] ?? '').trim().startsWith('```')) {
|
||||
inhalt.push(zeilen[i] ?? '')
|
||||
i += 1
|
||||
}
|
||||
bloecke.push({ art: 'code', zeilen: inhalt })
|
||||
continue
|
||||
}
|
||||
|
||||
if (gekuerzt === '') {
|
||||
absatzSchliessen()
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^-{3,}$/.test(gekuerzt) || /^\*{3,}$/.test(gekuerzt)) {
|
||||
absatzSchliessen()
|
||||
bloecke.push({ art: 'trenner' })
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Ueberschriften ----------------------------------------------------
|
||||
const ueberschrift = gekuerzt.match(/^(#{1,4})\s+(.*)$/)
|
||||
if (ueberschrift) {
|
||||
absatzSchliessen()
|
||||
const ebene = ueberschrift[1]!.length
|
||||
// Die H1 ist der Dokumenttitel - den setzt die Seite selbst.
|
||||
if (ebene === 1 && ohneTitel) continue
|
||||
bloecke.push({
|
||||
art: 'ueberschrift',
|
||||
ebene: (Math.min(Math.max(ebene, 2), 4) as 2 | 3 | 4),
|
||||
text: ueberschrift[2]!,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Zitatblock --------------------------------------------------------
|
||||
if (gekuerzt.startsWith('>')) {
|
||||
absatzSchliessen()
|
||||
const inhalt: string[] = []
|
||||
while (i < zeilen.length && (zeilen[i] ?? '').trim().startsWith('>')) {
|
||||
inhalt.push((zeilen[i] ?? '').trim().replace(/^>\s?/, ''))
|
||||
i += 1
|
||||
}
|
||||
i -= 1
|
||||
bloecke.push({ art: 'zitat', zeilen: [inhalt.join(' ')] })
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Tabelle -----------------------------------------------------------
|
||||
if (gekuerzt.startsWith('|')) {
|
||||
absatzSchliessen()
|
||||
const spalten = (z: string) =>
|
||||
z
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((s) => s.trim())
|
||||
|
||||
const kopf = spalten(gekuerzt)
|
||||
i += 1 // Trennzeile ueberspringen
|
||||
const zeilenInhalt: string[][] = []
|
||||
while (i + 1 < zeilen.length && (zeilen[i + 1] ?? '').trim().startsWith('|')) {
|
||||
i += 1
|
||||
zeilenInhalt.push(spalten(zeilen[i] ?? ''))
|
||||
}
|
||||
bloecke.push({ art: 'tabelle', kopf, zeilen: zeilenInhalt })
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Aufzaehlung -------------------------------------------------------
|
||||
const punkt = gekuerzt.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
|
||||
if (punkt) {
|
||||
absatzSchliessen()
|
||||
const nummeriert = /^\d+\./.test(gekuerzt)
|
||||
const punkte: string[] = [punkt[1]!]
|
||||
|
||||
while (i + 1 < zeilen.length) {
|
||||
const naechste = (zeilen[i + 1] ?? '').trim()
|
||||
const weiterer = naechste.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
|
||||
if (weiterer && /^\d+\./.test(naechste) === nummeriert) {
|
||||
punkte.push(weiterer[1]!)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
// Eingerueckte Fortsetzung gehoert zum letzten Punkt.
|
||||
if (naechste !== '' && /^\s{2,}\S/.test(zeilen[i + 1] ?? '')) {
|
||||
punkte[punkte.length - 1] += ` ${naechste}`
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
bloecke.push({ art: 'liste', nummeriert, punkte })
|
||||
continue
|
||||
}
|
||||
|
||||
absatz.push(gekuerzt)
|
||||
}
|
||||
|
||||
absatzSchliessen()
|
||||
return bloecke
|
||||
}
|
||||
|
||||
export function MarkdownSeite({
|
||||
inhalt,
|
||||
ohneTitel = true,
|
||||
className,
|
||||
}: {
|
||||
inhalt: string
|
||||
/** Die erste H1 weglassen - die Seite setzt ihre Ueberschrift selbst. */
|
||||
ohneTitel?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const bloecke = zerlegen(inhalt, ohneTitel)
|
||||
|
||||
return (
|
||||
<article className={className}>
|
||||
{bloecke.map((block, i) => {
|
||||
switch (block.art) {
|
||||
case 'ueberschrift': {
|
||||
const Tag = `h${block.ebene}` as 'h2' | 'h3' | 'h4'
|
||||
const groesse =
|
||||
block.ebene === 2
|
||||
? 'mt-10 text-xl font-semibold'
|
||||
: block.ebene === 3
|
||||
? 'mt-7 text-lg font-semibold'
|
||||
: 'mt-5 text-base font-semibold'
|
||||
return (
|
||||
<Tag key={i} className={`${groesse} scroll-mt-20 first:mt-0`}>
|
||||
{block.text}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
case 'absatz':
|
||||
return (
|
||||
<p key={i} className="mt-3 leading-relaxed">
|
||||
{inline(block.text, String(i))}
|
||||
</p>
|
||||
)
|
||||
|
||||
case 'liste': {
|
||||
const Tag = block.nummeriert ? 'ol' : 'ul'
|
||||
return (
|
||||
<Tag
|
||||
key={i}
|
||||
className={`mt-3 space-y-1.5 pl-6 ${
|
||||
block.nummeriert ? 'list-decimal' : 'list-disc'
|
||||
}`}
|
||||
>
|
||||
{block.punkte.map((punkt, j) => (
|
||||
<li key={j} className="leading-relaxed">
|
||||
{inline(punkt, `${i}-${j}`)}
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
case 'tabelle':
|
||||
return (
|
||||
// Tabellen scrollen im eigenen Rahmen - am Handy darf nie die
|
||||
// ganze Seite seitlich wandern.
|
||||
<div key={i} className="mt-4 w-full overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{block.kopf.map((spalte, j) => (
|
||||
<th key={j} className="px-3 py-2 text-left font-semibold">
|
||||
{inline(spalte, `${i}-k-${j}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.zeilen.map((zeile, j) => (
|
||||
<tr key={j} className="border-b last:border-0">
|
||||
{zeile.map((zelle, k) => (
|
||||
<td key={k} className="px-3 py-2 align-top">
|
||||
{inline(zelle, `${i}-${j}-${k}`)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'zitat':
|
||||
return (
|
||||
<blockquote
|
||||
key={i}
|
||||
className="mt-4 rounded-r-md border-l-4 border-primary/40 bg-muted/40 py-3 pl-4 pr-3"
|
||||
>
|
||||
{block.zeilen.map((zeile, j) => (
|
||||
<p key={j} className="leading-relaxed">
|
||||
{inline(zeile, `${i}-${j}`)}
|
||||
</p>
|
||||
))}
|
||||
</blockquote>
|
||||
)
|
||||
|
||||
case 'code':
|
||||
return (
|
||||
<pre
|
||||
key={i}
|
||||
className="mt-4 overflow-x-auto rounded-lg border bg-muted/60 p-3 text-sm"
|
||||
>
|
||||
<code className="font-mono">{block.zeilen.join('\n')}</code>
|
||||
</pre>
|
||||
)
|
||||
|
||||
case 'trenner':
|
||||
return <hr key={i} className="my-8" />
|
||||
}
|
||||
})}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user