hellth-hub/apps/admin/app/_components/news-blocknote-editor.tsx
Sebastian Mayer 2436dd9cac Initial commit: Hellth Hub monorepo
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>
2026-05-27 22:15:16 +02:00

141 lines
3.7 KiB
TypeScript

"use client";
import { BlockNoteView } from "@blocknote/mantine";
import { de } from "@blocknote/core/locales";
import { useCreateBlockNote } from "@blocknote/react";
import { useEffect } from "react";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
type NewsBlockNoteEditorProps = {
label?: string;
value: string;
placeholder?: string;
minHeightClassName?: string;
ensureLeadingHeading?: boolean;
onChange: (next: string) => void;
};
function normalizeHtml(html: string) {
const trimmed = html.trim();
if (!trimmed) return "";
if (trimmed === "<p></p>" || trimmed === "<p><br></p>") return "";
return trimmed;
}
function plainTextFromHtml(html: string) {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
async function uploadEditorImage(file: File) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${API_URL}/api/uploads/image`, {
method: "POST",
credentials: "include",
body: formData,
});
const payload = (await response.json().catch(() => ({}))) as { url?: string; error?: string };
if (!response.ok || !payload.url) {
throw new Error(payload.error ?? "Bild-Upload fehlgeschlagen");
}
return payload.url;
}
export function NewsBlockNoteEditor({
label,
value,
placeholder = "Hier den Beitrag schreiben...",
minHeightClassName = "min-h-[480px]",
ensureLeadingHeading = false,
onChange,
}: NewsBlockNoteEditorProps) {
const editor = useCreateBlockNote({
dictionary: {
...de,
placeholders: {
...(de as { placeholders?: Record<string, string> }).placeholders,
default: placeholder,
emptyDocument: placeholder,
},
},
uploadFile: uploadEditorImage,
});
useEffect(() => {
const currentHtml = normalizeHtml(editor.blocksToHTMLLossy(editor.document));
const targetHtml = normalizeHtml(value || "");
if (currentHtml === targetHtml) return;
const starterBlocks = [
{
type: "heading",
props: { level: 1 },
},
{
type: "paragraph",
},
] as never;
try {
const blocks = targetHtml ? editor.tryParseHTMLToBlocks(targetHtml) : [];
if (blocks.length > 0) {
editor.replaceBlocks(editor.document, blocks);
return;
}
const fallbackText = plainTextFromHtml(targetHtml);
if (!fallbackText) {
editor.replaceBlocks(editor.document, ensureLeadingHeading ? starterBlocks : []);
return;
}
editor.replaceBlocks(editor.document, [
{
type: "paragraph",
content: fallbackText,
} as never,
]);
} catch {
const fallbackText = plainTextFromHtml(targetHtml);
if (!fallbackText) {
if (ensureLeadingHeading) {
editor.replaceBlocks(editor.document, starterBlocks);
}
return;
}
editor.replaceBlocks(editor.document, [
{
type: "paragraph",
content: fallbackText,
} as never,
]);
}
}, [editor, ensureLeadingHeading, value]);
return (
<div>
{label ? <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label> : null}
<div
className={`rounded-md border border-gray-200 overflow-hidden bg-white ${minHeightClassName}`}
>
<BlockNoteView
editor={editor}
theme="light"
onChange={() => {
const html = normalizeHtml(editor.blocksToHTMLLossy(editor.document));
onChange(html);
}}
slashMenu
formattingToolbar
sideMenu
tableHandles
className="bg-white"
/>
</div>
</div>
);
}