Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import Link from "@tiptap/extension-link";
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
import { Table } from "@tiptap/extension-table";
|
|
import TableCell from "@tiptap/extension-table-cell";
|
|
import TableHeader from "@tiptap/extension-table-header";
|
|
import TableRow from "@tiptap/extension-table-row";
|
|
import TextAlign from "@tiptap/extension-text-align";
|
|
import Underline from "@tiptap/extension-underline";
|
|
import { EditorContent, useEditor } from "@tiptap/react";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
type EditorMode = "basic" | "news";
|
|
|
|
type RichTextEditorProps = {
|
|
label: string;
|
|
value: string;
|
|
placeholder?: string;
|
|
minHeightClassName?: string;
|
|
mode?: EditorMode;
|
|
onChange: (next: string) => void;
|
|
};
|
|
|
|
type BlockAction = {
|
|
id: string;
|
|
label: string;
|
|
run: () => void;
|
|
};
|
|
|
|
type SlashMenuState = {
|
|
open: boolean;
|
|
top: number;
|
|
left: number;
|
|
slashPos: number | null;
|
|
};
|
|
|
|
function normalizeHtml(html: string) {
|
|
const trimmed = html.trim();
|
|
if (!trimmed) return "";
|
|
if (trimmed === "<br>" || trimmed === "<p><br></p>") return "";
|
|
return trimmed;
|
|
}
|
|
|
|
function buttonClass(active = false) {
|
|
return `px-2 py-1 rounded text-xs transition-colors ${
|
|
active ? "bg-[#ECFDF5] text-[#0F766E]" : "text-gray-700 hover:bg-gray-200"
|
|
}`;
|
|
}
|
|
|
|
export function RichTextEditor({
|
|
label,
|
|
value,
|
|
placeholder,
|
|
minHeightClassName = "min-h-[180px]",
|
|
mode = "basic",
|
|
onChange,
|
|
}: RichTextEditorProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
|
|
const [slashMenu, setSlashMenu] = useState<SlashMenuState>({
|
|
open: false,
|
|
top: 0,
|
|
left: 0,
|
|
slashPos: null,
|
|
});
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: { levels: [2, 3] },
|
|
}),
|
|
Underline,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: {
|
|
rel: "noopener noreferrer",
|
|
target: "_blank",
|
|
},
|
|
}),
|
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
Placeholder.configure({
|
|
placeholder: placeholder ?? "Inhalt schreiben...",
|
|
}),
|
|
Table.configure({
|
|
resizable: true,
|
|
HTMLAttributes: { class: "nf-editor-table" },
|
|
}),
|
|
TableRow,
|
|
TableHeader,
|
|
TableCell,
|
|
],
|
|
content: value || "",
|
|
immediatelyRender: false,
|
|
onUpdate: ({ editor: ed }) => {
|
|
onChange(normalizeHtml(ed.getHTML()));
|
|
},
|
|
});
|
|
|
|
const blockActions = useMemo<BlockAction[]>(() => {
|
|
if (!editor) return [];
|
|
return [
|
|
{ id: "paragraph", label: "Absatz", run: () => editor.chain().focus().setParagraph().run() },
|
|
{ id: "h2", label: "Überschrift H2", run: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
|
|
{ id: "h3", label: "Überschrift H3", run: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
|
|
{ id: "bullet", label: "Aufzählung", run: () => editor.chain().focus().toggleBulletList().run() },
|
|
{ id: "ordered", label: "Nummerierte Liste", run: () => editor.chain().focus().toggleOrderedList().run() },
|
|
{ id: "quote", label: "Zitat", run: () => editor.chain().focus().toggleBlockquote().run() },
|
|
{
|
|
id: "table",
|
|
label: "Tabelle (3x3)",
|
|
run: () =>
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
.run(),
|
|
},
|
|
];
|
|
}, [editor]);
|
|
|
|
useEffect(() => {
|
|
if (!editor) return;
|
|
const normalized = value || "";
|
|
if (editor.getHTML() !== normalized) {
|
|
editor.commands.setContent(normalized, { emitUpdate: false });
|
|
}
|
|
}, [editor, value]);
|
|
|
|
useEffect(() => {
|
|
if (!editor || mode !== "news") return;
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (!editor.isFocused) return;
|
|
|
|
if (event.key === "Escape") {
|
|
setSlashMenu((prev) => ({ ...prev, open: false }));
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "/") return;
|
|
|
|
setTimeout(() => {
|
|
if (!containerRef.current) return;
|
|
const pos = editor.state.selection.from;
|
|
const coords = editor.view.coordsAtPos(pos);
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
setSlashMenu({
|
|
open: true,
|
|
top: coords.bottom - containerRect.top + 6,
|
|
left: Math.max(8, coords.left - containerRect.left),
|
|
slashPos: pos,
|
|
});
|
|
}, 0);
|
|
};
|
|
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [editor, mode]);
|
|
|
|
useEffect(() => {
|
|
function onClickOutside(event: MouseEvent) {
|
|
if (!containerRef.current) return;
|
|
if (!containerRef.current.contains(event.target as Node)) {
|
|
setBlockMenuOpen(false);
|
|
setSlashMenu((prev) => ({ ...prev, open: false }));
|
|
}
|
|
}
|
|
window.addEventListener("mousedown", onClickOutside);
|
|
return () => window.removeEventListener("mousedown", onClickOutside);
|
|
}, []);
|
|
|
|
function applyBlockAction(action: BlockAction) {
|
|
if (!editor) return;
|
|
|
|
if (slashMenu.open && slashMenu.slashPos) {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.deleteRange({ from: slashMenu.slashPos - 1, to: slashMenu.slashPos })
|
|
.run();
|
|
}
|
|
|
|
action.run();
|
|
setBlockMenuOpen(false);
|
|
setSlashMenu((prev) => ({ ...prev, open: false, slashPos: null }));
|
|
}
|
|
|
|
function handleLink() {
|
|
if (!editor) return;
|
|
const url = window.prompt("Link-URL eingeben (https://...)");
|
|
if (url === null) return;
|
|
const trimmedUrl = url.trim();
|
|
if (!trimmedUrl) {
|
|
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
return;
|
|
}
|
|
editor.chain().focus().extendMarkRange("link").setLink({ href: trimmedUrl }).run();
|
|
}
|
|
|
|
if (!editor) {
|
|
return null;
|
|
}
|
|
|
|
const showNewsBlockFeatures = mode === "news";
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
|
<div className="border border-gray-200 rounded-md overflow-hidden bg-white">
|
|
<div className="flex flex-wrap items-center gap-1 border-b border-gray-200 bg-gray-50 px-2 py-1.5">
|
|
{showNewsBlockFeatures && (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setBlockMenuOpen((value) => !value)}
|
|
className="px-2 py-1 rounded text-xs font-medium text-gray-700 hover:bg-gray-200"
|
|
title="Block-Menü"
|
|
>
|
|
Block +
|
|
</button>
|
|
{blockMenuOpen && (
|
|
<div className="absolute top-8 left-0 z-20 w-56 rounded-md border border-gray-200 bg-white shadow-lg p-1">
|
|
{blockActions.map((action) => (
|
|
<button
|
|
key={action.id}
|
|
type="button"
|
|
onClick={() => applyBlockAction(action)}
|
|
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
|
|
>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
className={`${buttonClass(editor.isActive("bold"))} font-semibold`}
|
|
title="Fett"
|
|
>
|
|
B
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
className={`${buttonClass(editor.isActive("italic"))} italic`}
|
|
title="Kursiv"
|
|
>
|
|
I
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
className={`${buttonClass(editor.isActive("underline"))} underline`}
|
|
title="Unterstrichen"
|
|
>
|
|
U
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
className={`${buttonClass(editor.isActive("strike"))} line-through`}
|
|
title="Durchgestrichen"
|
|
>
|
|
S
|
|
</button>
|
|
|
|
<span className="w-px h-4 bg-gray-300 mx-1" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setParagraph().run()}
|
|
className={buttonClass(editor.isActive("paragraph"))}
|
|
title="Absatz"
|
|
>
|
|
P
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
className={buttonClass(editor.isActive("heading", { level: 2 }))}
|
|
title="Überschrift 2"
|
|
>
|
|
H2
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
className={buttonClass(editor.isActive("heading", { level: 3 }))}
|
|
title="Überschrift 3"
|
|
>
|
|
H3
|
|
</button>
|
|
|
|
<span className="w-px h-4 bg-gray-300 mx-1" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
className={buttonClass(editor.isActive("bulletList"))}
|
|
title="Aufzählung"
|
|
>
|
|
Liste
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
className={buttonClass(editor.isActive("orderedList"))}
|
|
title="Nummerierte Liste"
|
|
>
|
|
1.
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
className={buttonClass(editor.isActive("blockquote"))}
|
|
title="Zitat"
|
|
>
|
|
Quote
|
|
</button>
|
|
|
|
{showNewsBlockFeatures && (
|
|
<>
|
|
<span className="w-px h-4 bg-gray-300 mx-1" />
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
|
className={buttonClass(editor.isActive("table"))}
|
|
title="Tabelle einfügen"
|
|
>
|
|
Tabelle
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().addRowAfter().run()}
|
|
className={buttonClass(false)}
|
|
title="Zeile hinzufügen"
|
|
>
|
|
+Zeile
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
|
className={buttonClass(false)}
|
|
title="Spalte hinzufügen"
|
|
>
|
|
+Spalte
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().deleteTable().run()}
|
|
className={buttonClass(false)}
|
|
title="Tabelle entfernen"
|
|
>
|
|
-Tabelle
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<span className="w-px h-4 bg-gray-300 mx-1" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleLink}
|
|
className={buttonClass(editor.isActive("link"))}
|
|
title="Link einfügen"
|
|
>
|
|
Link
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
|
|
className={buttonClass(false)}
|
|
title="Formatierung entfernen"
|
|
>
|
|
Reset
|
|
</button>
|
|
|
|
<span className="w-px h-4 bg-gray-300 mx-1" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
|
className={buttonClass(editor.isActive({ textAlign: "left" }))}
|
|
title="Linksbündig"
|
|
>
|
|
L
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
|
className={buttonClass(editor.isActive({ textAlign: "center" }))}
|
|
title="Zentriert"
|
|
>
|
|
C
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
|
className={buttonClass(editor.isActive({ textAlign: "right" }))}
|
|
title="Rechtsbündig"
|
|
>
|
|
R
|
|
</button>
|
|
|
|
<span className="w-px h-4 bg-gray-300 mx-1" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
disabled={!editor.can().undo()}
|
|
className={`${buttonClass(false)} disabled:opacity-40`}
|
|
title="Rückgängig"
|
|
>
|
|
Undo
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
disabled={!editor.can().redo()}
|
|
className={`${buttonClass(false)} disabled:opacity-40`}
|
|
title="Wiederholen"
|
|
>
|
|
Redo
|
|
</button>
|
|
</div>
|
|
|
|
<EditorContent
|
|
editor={editor}
|
|
className={`w-full px-3 py-2 text-sm rounded-b-md focus-within:ring-1 focus-within:ring-[#0F766E] ${minHeightClassName} [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[inherit] [&_.ProseMirror_h2]:text-xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h3]:text-lg [&_.ProseMirror_h3]:font-semibold [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:ml-5 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:ml-5 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-gray-300 [&_.ProseMirror_blockquote]:pl-3 [&_.ProseMirror_a]:text-[#0F766E] [&_.ProseMirror_a]:underline [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:border [&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-gray-300 [&_.ProseMirror_td]:p-2 [&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-gray-300 [&_.ProseMirror_th]:p-2 [&_.ProseMirror_th]:bg-gray-100`}
|
|
/>
|
|
</div>
|
|
|
|
{showNewsBlockFeatures && slashMenu.open && (
|
|
<div
|
|
className="absolute z-30 w-60 rounded-md border border-gray-200 bg-white shadow-lg p-1"
|
|
style={{ top: slashMenu.top, left: slashMenu.left }}
|
|
>
|
|
<p className="px-3 py-1 text-[11px] text-gray-500">Slash-Menü</p>
|
|
{blockActions.map((action) => (
|
|
<button
|
|
key={`slash-${action.id}`}
|
|
type="button"
|
|
onClick={() => applyBlockAction(action)}
|
|
className="w-full text-left px-3 py-2 text-sm rounded hover:bg-gray-100"
|
|
>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|