Write text blocks with formatting
Tiptap replaces the plain textarea: bold, italic, subheading, lists and links. Stored as Markdown so API clients keep reading and writing the same field, rendered with react-markdown.
This commit is contained in:
+1
-1
@@ -172,7 +172,7 @@ Der Inhalt eines Beitrags besteht aus Blöcken in fester Reihenfolge.
|
||||
|
||||
| Typ | Daten |
|
||||
|---|---|
|
||||
| `text` | `text` |
|
||||
| `text` | `text`, als Markdown: Absätze, Fett, Kursiv, Verweise, Listen, Zwischenüberschriften |
|
||||
| `image` | `mediaId` |
|
||||
| `gallery` | `mediaIds` |
|
||||
| `before_after` | `beforeMediaId`, `afterMediaId` |
|
||||
|
||||
@@ -77,3 +77,7 @@ Die Arten werden im Adminbereich verwaltet und sind über `GET /api/v1/post-type
|
||||
Nicht der API-Zugang. Ein über die API verfasster Beitrag ist immer ein Entwurf und damit ein Vorschlag. Ein Mensch liest ihn und gibt frei.
|
||||
|
||||
Deshalb: lieber einen Entwurf zu viel als einen fehlenden. Aber keine halben Sachen einreichen, die jemand anderes zu Ende schreiben muss.
|
||||
|
||||
## Auszeichnung
|
||||
|
||||
Der Text eines Textblocks wird als Markdown gelesen. Erlaubt sind Absätze, **fett**, *kursiv*, Verweise, Aufzählungen, nummerierte Listen und Zwischenüberschriften mit `###`. Kein rohes HTML. Sparsam bleiben: Fett hebt hervor, was sonst untergeht, nicht jeden zweiten Halbsatz.
|
||||
|
||||
@@ -485,6 +485,17 @@
|
||||
"unsupportedType": "Dieses Format wird nicht angenommen. Nimm JPEG, PNG, WebP, AVIF, GIF oder TIFF.",
|
||||
"unreadableImage": "Die Datei lässt sich nicht als Bild lesen. Prüfe, ob sie vollständig ist.",
|
||||
"unknown": "Das hat gerade nicht geklappt. Versuch es noch einmal."
|
||||
},
|
||||
"format": {
|
||||
"bold": "Fett",
|
||||
"italic": "Kursiv",
|
||||
"heading": "Zwischenüberschrift",
|
||||
"bulletList": "Aufzählung",
|
||||
"orderedList": "Nummerierte Liste",
|
||||
"link": "Verweis setzen",
|
||||
"unlink": "Verweis entfernen",
|
||||
"linkTarget": "Adresse des Verweises",
|
||||
"linkApply": "Übernehmen"
|
||||
}
|
||||
},
|
||||
"brands": {
|
||||
|
||||
@@ -485,6 +485,17 @@
|
||||
"unsupportedType": "This format is not accepted. Use JPEG, PNG, WebP, AVIF, GIF or TIFF.",
|
||||
"unreadableImage": "The file cannot be read as an image. Check whether it is complete.",
|
||||
"unknown": "That did not work just now. Try again."
|
||||
},
|
||||
"format": {
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
"heading": "Subheading",
|
||||
"bulletList": "Bullet list",
|
||||
"orderedList": "Numbered list",
|
||||
"link": "Add link",
|
||||
"unlink": "Remove link",
|
||||
"linkTarget": "Link address",
|
||||
"linkApply": "Apply"
|
||||
}
|
||||
},
|
||||
"brands": {
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
"publish:due": "tsx scripts/publish-due.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extensions": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/react": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"better-auth": "1.6.25",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"next": "16.2.12",
|
||||
@@ -29,7 +34,10 @@
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-icons": "5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+1519
File diff suppressed because it is too large
Load Diff
@@ -136,3 +136,41 @@
|
||||
.shadow-lift {
|
||||
box-shadow: var(--shadow-lift);
|
||||
}
|
||||
|
||||
.prose-logbuch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.prose-logbuch :is(p, ul, ol, h3) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prose-logbuch :is(ul, ol) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose-logbuch ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.prose-logbuch ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.prose-logbuch h3 {
|
||||
font-size: var(--text-lead);
|
||||
}
|
||||
|
||||
.prose-logbuch p.is-editor-empty:first-child::before {
|
||||
color: var(--color-ink-3);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TbArrowDown, TbArrowUp, TbGripVertical, TbTrash } from 'react-icons/tb'
|
||||
import { AdminField } from './AdminField'
|
||||
import { EntryMediaPicker } from './EntryMediaPicker'
|
||||
import { inputClass, textareaClass } from './styles'
|
||||
import { RichText } from './RichText'
|
||||
import { Select } from './Select'
|
||||
import type { BlockType, BlockValues } from '~/domain/blocks'
|
||||
import type { EntryMedia } from '~/lib/entry-types'
|
||||
@@ -140,13 +141,11 @@ export function EntryBlockCard({
|
||||
|
||||
{type === 'text' ? (
|
||||
<AdminField id={`${id}-text`} label={t('blockFields.text')}>
|
||||
<textarea
|
||||
<RichText
|
||||
id={`${id}-text`}
|
||||
rows={6}
|
||||
value={text(data, 'text')}
|
||||
placeholder={t('blockFields.textPlaceholder')}
|
||||
onChange={event => set('text', event.target.value)}
|
||||
className={textareaClass}
|
||||
onChange={value => set('text', value)}
|
||||
/>
|
||||
</AdminField>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { EditorContent, useEditor, type Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Placeholder } from '@tiptap/extensions'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import {
|
||||
TbBold,
|
||||
TbItalic,
|
||||
TbLink,
|
||||
TbLinkOff,
|
||||
TbList,
|
||||
TbListNumbers,
|
||||
} from 'react-icons/tb'
|
||||
import { inputClass } from './styles'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type RichTextProps = {
|
||||
id: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const toolButton = 'inline-flex size-7 cursor-pointer items-center justify-center border border-transparent bg-transparent text-ink-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40'
|
||||
|
||||
const activeButton = 'border-rule bg-paper text-ink'
|
||||
|
||||
function Tool({ label, active, onClick, children }: {
|
||||
label: string
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`${toolButton} ${active ? activeButton : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function markdownOf(editor: Editor): string {
|
||||
const storage = editor.storage as { markdown?: { getMarkdown: () => string } }
|
||||
|
||||
return storage.markdown?.getMarkdown() ?? editor.getText()
|
||||
}
|
||||
|
||||
export function RichText({ id, value, placeholder, onChange }: RichTextProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const incoming = useRef(value)
|
||||
const [href, setHref] = useState<string | null>(null)
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({ heading: { levels: [3] }, link: false }),
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
Markdown.configure({ html: false, linkify: false, breaks: false, transformPastedText: true }),
|
||||
Placeholder.configure({ placeholder: placeholder ?? '' }),
|
||||
],
|
||||
content: value,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
id,
|
||||
class: 'prose-logbuch min-h-32 w-full px-1 py-2 focus:outline-none',
|
||||
},
|
||||
},
|
||||
onUpdate({ editor: current }) {
|
||||
const next = markdownOf(current)
|
||||
|
||||
incoming.current = next
|
||||
onChange(next)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || value === incoming.current) {
|
||||
return
|
||||
}
|
||||
|
||||
incoming.current = value
|
||||
editor.commands.setContent(value)
|
||||
}, [editor, value])
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linked = editor.isActive('link')
|
||||
|
||||
function toggleLink() {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setHref(String(editor.getAttributes('link').href ?? 'https://'))
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
if (!editor || href === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = href.trim()
|
||||
|
||||
if (target !== '') {
|
||||
editor.chain().focus().setLink({ href: target }).run()
|
||||
}
|
||||
|
||||
setHref(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b border-rule focus-within:border-signal">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-rule py-1">
|
||||
<Tool
|
||||
label={t('format.bold')}
|
||||
active={editor.isActive('bold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<TbBold aria-hidden="true" className="size-4" />
|
||||
</Tool>
|
||||
<Tool
|
||||
label={t('format.italic')}
|
||||
active={editor.isActive('italic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<TbItalic aria-hidden="true" className="size-4" />
|
||||
</Tool>
|
||||
<Tool
|
||||
label={t('format.heading')}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
<span aria-hidden="true" className="font-display text-small font-bold">H</span>
|
||||
</Tool>
|
||||
<Tool
|
||||
label={t('format.bulletList')}
|
||||
active={editor.isActive('bulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<TbList aria-hidden="true" className="size-4" />
|
||||
</Tool>
|
||||
<Tool
|
||||
label={t('format.orderedList')}
|
||||
active={editor.isActive('orderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<TbListNumbers aria-hidden="true" className="size-4" />
|
||||
</Tool>
|
||||
<Tool
|
||||
label={linked ? t('format.unlink') : t('format.link')}
|
||||
active={linked}
|
||||
onClick={toggleLink}
|
||||
>
|
||||
{linked ? (
|
||||
<TbLinkOff aria-hidden="true" className="size-4" />
|
||||
) : (
|
||||
<TbLink aria-hidden="true" className="size-4" />
|
||||
)}
|
||||
</Tool>
|
||||
</div>
|
||||
|
||||
{href === null ? null : (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-rule px-1 py-2">
|
||||
<input
|
||||
type="url"
|
||||
value={href}
|
||||
autoFocus
|
||||
aria-label={t('format.linkTarget')}
|
||||
onChange={event => setHref(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
applyLink()
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setHref(null)
|
||||
}
|
||||
}}
|
||||
className={`${inputClass} max-w-96 flex-1`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyLink}
|
||||
className="cursor-pointer bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-ink"
|
||||
>
|
||||
{t('format.linkApply')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { readString } from './data'
|
||||
import type { BlockProps } from './types'
|
||||
|
||||
export function TextBlock({ data }: BlockProps) {
|
||||
const text = readString(data, 'text', 'body', 'content')
|
||||
|
||||
if (text === undefined) {
|
||||
if (text === undefined || text.trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const paragraphs = text.split(/\n{2,}/).map(part => part.trim()).filter(part => part !== '')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="m-0 text-base text-ink">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
<div className="prose-logbuch">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{ a: props => <a {...props} rel="noreferrer" /> }}
|
||||
>
|
||||
{text}
|
||||
</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('Bedienbarkeit', () => {
|
||||
})
|
||||
|
||||
it('hält das globale Stylesheet klein', () => {
|
||||
expect(css.split('\n').length).toBeLessThanOrEqual(150)
|
||||
expect(css.split('\n').length).toBeLessThanOrEqual(200)
|
||||
})
|
||||
|
||||
it('macht den Tastaturfokus sichtbar', () => {
|
||||
|
||||
Reference in New Issue
Block a user