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:
commit
dd58b3d1f8
75 files changed
+11418
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user