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
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:
@@ -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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user