P42 Dashboard: erster Stand

FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung,
Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte,
Kanban-Projekten mit Priorität, Server-Monitoring und den Designs
Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-14 14:08:11 +02:00
commit dd58b3d1f8
75 files changed
+11418

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
import { useImperativeHandle, useRef, useState } from 'react';
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import { Placeholder } from '@tiptap/extensions';
import { imgUrl, uploadImage } from '../api.js';
const imageFiles = (list) => [...(list || [])].filter((f) => f.type.startsWith('image/'));
/**
* Artikel-Editor. Über `ref` lässt sich der aktuelle Inhalt mit `ref.current.getHTML()` abrufen.
* onChange: bei jeder Änderung, onUploading(bool): während Bilder hochgeladen werden.
*/
export default function Editor({ ref, content, onChange, onUploading, onError }) {
const [uploading, setUploading] = useState(0);
const fileInput = useRef(null);
const editorRef = useRef(null);
const callbacks = useRef({});
callbacks.current = { onChange, onUploading, onError };
// Bilder hochladen und nacheinander an `pos` (oder an der Cursorposition) einfügen
async function insertImages(files, pos) {
const images = imageFiles(files);
if (!images.length) return;
setUploading(images.length);
callbacks.current.onUploading?.(true);
try {
for (const file of images) {
const src = imgUrl(await uploadImage(file));
const chain = editorRef.current.chain().focus();
(pos == null ? chain.setImage({ src }) : chain.insertContentAt(pos, { type: 'image', attrs: { src } })).run();
pos = null;
setUploading((n) => n - 1);
}
} catch (err) {
callbacks.current.onError?.(err.message);
} finally {
setUploading(0);
callbacks.current.onUploading?.(false);
}
}
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
code: false,
codeBlock: false,
link: { openOnClick: false, autolink: true, defaultProtocol: 'https' },
}),
Image,
Placeholder.configure({ placeholder: 'Hier den Artikel schreiben … Bilder per Button, Einfügen (Strg+V) oder Drag & Drop.' }),
],
content,
editorProps: {
attributes: { class: 'article-body editor-content', 'aria-label': 'Artikeltext' },
handlePaste(view, event) {
const files = imageFiles(event.clipboardData?.files);
if (!files.length) return false;
insertImages(files);
return true;
},
handleDrop(view, event, slice, moved) {
const files = imageFiles(event.dataTransfer?.files);
if (moved || !files.length) return false;
event.preventDefault();
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
insertImages(files, pos);
return true;
},
},
onUpdate: () => callbacks.current.onChange?.(),
});
editorRef.current = editor;
useImperativeHandle(ref, () => ({ getHTML: () => editor.getHTML() }), [editor]);
return (
<div className="editor">
<Toolbar editor={editor} onImage={() => fileInput.current.click()} uploading={uploading} />
<input
ref={fileInput}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => { insertImages(e.target.files); e.target.value = ''; }}
/>
<EditorContent editor={editor} />
</div>
);
}
function Toolbar({ editor, onImage, uploading }) {
const state = useEditorState({
editor,
selector: ({ editor: e }) => ({
paragraph: e.isActive('paragraph'),
h2: e.isActive('heading', { level: 2 }),
h3: e.isActive('heading', { level: 3 }),
bold: e.isActive('bold'),
italic: e.isActive('italic'),
underline: e.isActive('underline'),
bulletList: e.isActive('bulletList'),
orderedList: e.isActive('orderedList'),
blockquote: e.isActive('blockquote'),
link: e.isActive('link'),
canUndo: e.can().undo(),
canRedo: e.can().redo(),
}),
});
const run = (fn) => () => fn(editor.chain().focus()).run();
function toggleLink() {
if (state.link) return editor.chain().focus().extendMarkRange('link').unsetLink().run();
if (editor.state.selection.empty) return window.alert('Zuerst den Text markieren, der verlinkt werden soll.');
const url = window.prompt('Link-Adresse:', 'https://');
if (url && url !== 'https://') editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}
const tools = [
{ label: 'Text', title: 'Normaler Absatz', active: state.paragraph, onClick: run((c) => c.setParagraph()) },
{ label: 'Überschrift', title: 'Überschrift', active: state.h2, onClick: run((c) => c.toggleHeading({ level: 2 })) },
{ label: 'Zwischentitel', title: 'Zwischenüberschrift', active: state.h3, onClick: run((c) => c.toggleHeading({ level: 3 })) },
null,
{ label: <b>B</b>, title: 'Fett (Strg+B)', active: state.bold, onClick: run((c) => c.toggleBold()) },
{ label: <i>I</i>, title: 'Kursiv (Strg+I)', active: state.italic, onClick: run((c) => c.toggleItalic()) },
{ label: <u>U</u>, title: 'Unterstrichen (Strg+U)', active: state.underline, onClick: run((c) => c.toggleUnderline()) },
null,
{ label: 'Liste', title: 'Aufzählung', active: state.bulletList, onClick: run((c) => c.toggleBulletList()) },
{ label: 'Nummeriert', title: 'Nummerierte Liste', active: state.orderedList, onClick: run((c) => c.toggleOrderedList()) },
{ label: 'Zitat', title: 'Zitat', active: state.blockquote, onClick: run((c) => c.toggleBlockquote()) },
null,
{ label: 'Link', title: state.link ? 'Link entfernen' : 'Link einfügen', active: state.link, onClick: toggleLink },
{ label: uploading ? `Lade ${uploading} …` : 'Bild', title: 'Bild einfügen', onClick: onImage, disabled: uploading > 0 },
{ label: 'Linie', title: 'Trennlinie', onClick: run((c) => c.setHorizontalRule()) },
null,
{ label: '↶', title: 'Rückgängig (Strg+Z)', onClick: run((c) => c.undo()), disabled: !state.canUndo },
{ label: '↷', title: 'Wiederholen (Strg+Y)', onClick: run((c) => c.redo()), disabled: !state.canRedo },
];
return (
<div className="toolbar" role="toolbar" aria-label="Formatierung">
{tools.map((t, i) =>
t ? (
<button
key={i}
type="button"
title={t.title}
aria-label={t.title}
className={t.active ? 'active' : undefined}
disabled={t.disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={t.onClick}
>
{t.label}
</button>
) : (
<span key={i} className="toolbar-sep" />
),
)}
</div>
);
}