feat(admin): rich text editor for blog content (TipTap)
CI/CD / CI · API (dotnet build + test) (push) Successful in 3m33s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m18s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Failing after 3m43s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been skipped

Blog post bodies were plain <textarea>s labelled "Markdown". Replace with a
TipTap rich editor (bold/italic/strike, H1–H3, lists, blockquote, code, links,
undo/redo), RTL-aware, producing HTML.

- New RichTextEditor component (TipTap v2: react + starter-kit + pm + link +
  placeholder), immediatelyRender:false for Next SSR, self-contained content
  styling, external-value sync.
- Wired into the FA/EN content fields of the blog editor; labels no longer say
  "Markdown" (fa/en/ar).
- Website blog page now renders HTML when the body is HTML and falls back to
  MDXRemote for older Markdown posts (backward-compatible). Content is authored
  only by trusted SystemAdmins, so HTML is stored/rendered directly.

Admin build + website typecheck clean.
This commit is contained in:
soroush.asadi
2026-06-02 22:25:47 +03:30
parent 97a9481627
commit f1756b491e
8 changed files with 998 additions and 23 deletions
@@ -14,6 +14,7 @@ import type {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
@@ -327,8 +328,14 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
</div>
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentFa")}</label>
<RichTextEditor value={form.contentFa} onChange={setField("contentFa")} dir="rtl" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentEn")}</label>
<RichTextEditor value={form.contentEn} onChange={setField("contentEn")} dir="ltr" />
</div>
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
<span className="text-sm font-medium">{t("fieldPublished")}</span>
@@ -0,0 +1,158 @@
"use client";
import { useEffect } from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link2,
Link2Off,
Undo2,
Redo2,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Props = {
value: string;
onChange: (html: string) => void;
dir?: "rtl" | "ltr";
placeholder?: string;
};
/** Headless TipTap rich-text editor producing HTML. Used for long-form content
* (blog posts) edited by trusted admins. */
export function RichTextEditor({ value, onChange, dir = "rtl", placeholder }: Props) {
const editor = useEditor({
immediatelyRender: false, // required under Next.js SSR to avoid hydration mismatch
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: "noopener", target: "_blank" } }),
Placeholder.configure({ placeholder: placeholder ?? "" }),
],
content: value || "",
editorProps: {
attributes: {
dir,
class: "meezi-rte-content min-h-44 px-3 py-2 focus:outline-none",
},
},
onUpdate: ({ editor }) => onChange(editor.getHTML()),
});
// Keep the editor in sync when the external value changes (load existing post / reset).
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
if (value !== current) {
editor.commands.setContent(value || "", false);
}
}, [value, editor]);
return (
<div className="overflow-hidden rounded-lg border border-border bg-background" dir={dir}>
<Toolbar editor={editor} />
<EditorContent editor={editor} />
<style>{`
.meezi-rte-content { font-size: 0.875rem; line-height: 1.7; }
.meezi-rte-content:focus { outline: none; }
.meezi-rte-content h1 { font-size: 1.5rem; font-weight: 700; margin: 0.6em 0 0.3em; }
.meezi-rte-content h2 { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.3em; }
.meezi-rte-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.5em 0 0.25em; }
.meezi-rte-content p { margin: 0.4em 0; }
.meezi-rte-content ul { list-style: disc; padding-inline-start: 1.5rem; margin: 0.4em 0; }
.meezi-rte-content ol { list-style: decimal; padding-inline-start: 1.5rem; margin: 0.4em 0; }
.meezi-rte-content blockquote { border-inline-start: 3px solid hsl(var(--primary)); padding-inline-start: 0.75rem; color: hsl(var(--muted-foreground)); margin: 0.5em 0; }
.meezi-rte-content a { color: hsl(var(--primary)); text-decoration: underline; }
.meezi-rte-content code { background: hsl(var(--muted)); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.85em; }
.meezi-rte-content pre { background: hsl(var(--muted)); padding: 0.6rem 0.8rem; border-radius: 8px; overflow-x: auto; }
.meezi-rte-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: hsl(var(--muted-foreground)); float: inline-start; height: 0; pointer-events: none; }
`}</style>
</div>
);
}
function Toolbar({ editor }: { editor: Editor | null }) {
if (!editor) return <div className="h-9 border-b border-border bg-muted/40" />;
const setLink = () => {
const prev = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("URL", prev ?? "https://");
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/40 px-1.5 py-1">
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} title="Bold"><Bold className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} title="Italic"><Italic className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} title="Strikethrough"><Strikethrough className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })} title="H1"><Heading1 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })} title="H2"><Heading2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })} title="H3"><Heading3 className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} title="Bullet list"><List className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} title="Numbered list"><ListOrdered className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} title="Quote"><Quote className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive("codeBlock")} title="Code block"><Code className="size-4" /></Btn>
<Sep />
<Btn onClick={setLink} active={editor.isActive("link")} title="Link"><Link2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().unsetLink().run()} active={false} title="Remove link" disabled={!editor.isActive("link")}><Link2Off className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().undo().run()} active={false} title="Undo" disabled={!editor.can().undo()}><Undo2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().redo().run()} active={false} title="Redo" disabled={!editor.can().redo()}><Redo2 className="size-4" /></Btn>
</div>
);
}
function Btn({
onClick,
active,
title,
disabled,
children,
}: {
onClick: () => void;
active: boolean;
title: string;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground disabled:opacity-40",
active && "bg-primary/15 text-primary"
)}
>
{children}
</button>
);
}
function Sep() {
return <span className="mx-0.5 h-5 w-px bg-border" />;
}