feat(admin): full-screen forms + WYSIWYG rich-text editor
- AdminResource + TemplatesAdmin modals are now large full-height panels (max-w-5xl, sticky header/footer, scrolling body, 2-column field grid; textarea/richtext span full width) - RichTextField: dependency-free contentEditable WYSIWYG (bold/italic/underline, H2/H3/¶, lists, link, clear) emitting HTML, dir="auto" for fa/en - blog content + category description now use the rich editor Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,12 @@ import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||
import { RichTextField } from "@/components/admin/RichTextField";
|
||||
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "text" | "textarea" | "number" | "checkbox" | "select" | "image" | "file";
|
||||
type?: "text" | "textarea" | "richtext" | "number" | "checkbox" | "select" | "image" | "file";
|
||||
options?: { value: string; label: string }[];
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
@@ -193,21 +194,26 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
</div>
|
||||
|
||||
{(creating || editing) && config.fields && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={closeForm}>
|
||||
<div className={`${card} w-full max-w-lg p-5`} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
{editing ? "ویرایش" : "افزودن"} — {config.title}
|
||||
</h2>
|
||||
<div className="mt-4 grid max-h-[60vh] gap-3 overflow-y-auto pr-1">
|
||||
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" onClick={closeForm}>
|
||||
<div className={`${card} flex max-h-full w-full max-w-5xl flex-col`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
{editing ? "ویرایش" : "افزودن"} — {config.title}
|
||||
</h2>
|
||||
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={closeForm}>✕</button>
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-1 gap-4 overflow-y-auto p-5 sm:grid-cols-2">
|
||||
{config.fields.map((f) => (
|
||||
<div key={f.key}>
|
||||
<div key={f.key} className={f.type === "textarea" || f.type === "richtext" ? "sm:col-span-2" : ""}>
|
||||
{f.type !== "checkbox" && (
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
{f.label}{f.required && <span className="text-red-400"> *</span>}
|
||||
</label>
|
||||
)}
|
||||
{f.type === "textarea" ? (
|
||||
<textarea className={`${inputCls} min-h-[80px]`} placeholder={f.placeholder}
|
||||
{f.type === "richtext" ? (
|
||||
<RichTextField value={String(form[f.key] ?? "")} onChange={(html) => setForm({ ...form, [f.key]: html })} />
|
||||
) : f.type === "textarea" ? (
|
||||
<textarea className={`${inputCls} min-h-[160px]`} placeholder={f.placeholder}
|
||||
value={String(form[f.key] ?? "")} onChange={(e) => setForm({ ...form, [f.key]: e.target.value })} />
|
||||
) : f.type === "select" ? (
|
||||
<select className={inputCls} value={String(form[f.key] ?? "")}
|
||||
@@ -234,7 +240,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] px-5 py-3">
|
||||
<button className={btnGhost} onClick={closeForm}>انصراف</button>
|
||||
<button className={btn} onClick={submit} disabled={saving}>{saving ? "در حال ذخیره…" : "ذخیره"}</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Dependency-free WYSIWYG HTML editor for large/rich admin text (blog content,
|
||||
* descriptions). contentEditable + a small toolbar; emits HTML. dir="auto" so
|
||||
* Persian and English paragraphs each align correctly.
|
||||
*/
|
||||
export function RichTextField({ value, onChange }: { value: string; onChange: (html: string) => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Seed content once on mount (and when switching records) without fighting the caret.
|
||||
useEffect(() => {
|
||||
if (ref.current && ref.current.innerHTML !== (value || "")) {
|
||||
ref.current.innerHTML = value || "";
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const emit = () => onChange(ref.current?.innerHTML ?? "");
|
||||
const exec = (cmd: string, arg?: string) => {
|
||||
ref.current?.focus();
|
||||
document.execCommand(cmd, false, arg);
|
||||
emit();
|
||||
};
|
||||
const link = () => {
|
||||
const url = prompt("نشانی لینک:");
|
||||
if (url) exec("createLink", url);
|
||||
};
|
||||
|
||||
const Btn = ({ label, title, cmd, arg }: { label: string; title: string; cmd: string; arg?: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => exec(cmd, arg)}
|
||||
className="rounded px-2 py-1 text-xs text-gray-300 hover:bg-[#1e2235]"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#262b40] bg-[#0c0e1a]">
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-[#262b40] p-1.5">
|
||||
<Btn label="B" title="ضخیم" cmd="bold" />
|
||||
<Btn label="I" title="مورب" cmd="italic" />
|
||||
<Btn label="U" title="زیرخط" cmd="underline" />
|
||||
<span className="mx-1 h-4 w-px bg-[#262b40]" />
|
||||
<Btn label="H2" title="عنوان ۲" cmd="formatBlock" arg="<h2>" />
|
||||
<Btn label="H3" title="عنوان ۳" cmd="formatBlock" arg="<h3>" />
|
||||
<Btn label="¶" title="پاراگراف" cmd="formatBlock" arg="<p>" />
|
||||
<span className="mx-1 h-4 w-px bg-[#262b40]" />
|
||||
<Btn label="• فهرست" title="فهرست نقطهای" cmd="insertUnorderedList" />
|
||||
<Btn label="۱. فهرست" title="فهرست شمارهدار" cmd="insertOrderedList" />
|
||||
<button type="button" title="لینک" onMouseDown={(e) => e.preventDefault()} onClick={link} className="rounded px-2 py-1 text-xs text-gray-300 hover:bg-[#1e2235]">لینک</button>
|
||||
<span className="mx-1 h-4 w-px bg-[#262b40]" />
|
||||
<Btn label="پاککردن قالب" title="حذف قالببندی" cmd="removeFormat" />
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
dir="auto"
|
||||
onInput={emit}
|
||||
onBlur={emit}
|
||||
className="min-h-[260px] max-h-[55vh] overflow-y-auto p-3 text-sm leading-7 text-gray-100 outline-none
|
||||
[&_a]:text-indigo-400 [&_a]:underline
|
||||
[&_h2]:my-2 [&_h2]:text-lg [&_h2]:font-bold [&_h2]:text-white
|
||||
[&_h3]:my-2 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-white
|
||||
[&_ul]:my-2 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:ps-6
|
||||
[&_p]:my-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -259,10 +259,13 @@ export function TemplatesAdmin() {
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
|
||||
<div className={`${card} w-full max-w-2xl p-5`} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-sm font-semibold text-white">{editId ? "ویرایش قالب" : "قالب جدید"}</h2>
|
||||
<div className="mt-4 grid max-h-[65vh] gap-3 overflow-y-auto pr-1">
|
||||
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" onClick={() => setOpen(false)}>
|
||||
<div className={`${card} flex max-h-full w-full max-w-5xl flex-col`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
|
||||
<h2 className="text-sm font-semibold text-white">{editId ? "ویرایش قالب" : "قالب جدید"}</h2>
|
||||
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setOpen(false)}>✕</button>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-3 overflow-y-auto p-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={lbl}>نام *</label><input className={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||
<div><label className={lbl}>اسلاگ (نشانی) *</label><input className={inp} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} /></div>
|
||||
@@ -375,7 +378,7 @@ export function TemplatesAdmin() {
|
||||
<p className="rounded-lg border border-dashed border-[#262b40] p-3 text-[11px] text-gray-500">پس از ذخیرهٔ قالب، میتوانید نسخهها و فایلهای افترافکت را اضافه کنید.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] px-5 py-3">
|
||||
<button className={ghost} onClick={() => setOpen(false)}>انصراف</button>
|
||||
<button className={btn} onClick={save} disabled={saving || !form.name || !form.slug}>{saving ? "در حال ذخیره…" : "ذخیره"}</button>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const categoriesConfig: ResourceConfig = {
|
||||
fields: [
|
||||
{ key: "name", label: "نام", required: true },
|
||||
{ key: "slug", label: "اسلاگ (نشانی)", required: true },
|
||||
{ key: "description", label: "توضیحات / محتوا", type: "textarea" },
|
||||
{ key: "description", label: "توضیحات / محتوا", type: "richtext" },
|
||||
{ key: "image_url", label: "تصویر", type: "image" },
|
||||
{ key: "icon", label: "آیکون (کد SVG یا نام آیکون)", type: "textarea" },
|
||||
// SEO
|
||||
@@ -157,7 +157,7 @@ export const blogsConfig: ResourceConfig = {
|
||||
{ key: "title", label: "عنوان", required: true },
|
||||
{ key: "slug", label: "اسلاگ (نشانی)", required: true },
|
||||
{ key: "short_description", label: "توضیح کوتاه", type: "textarea" },
|
||||
{ key: "content", label: "محتوا (HTML)", type: "textarea", required: true },
|
||||
{ key: "content", label: "محتوا", type: "richtext", required: true },
|
||||
{ key: "meta_title", label: "عنوان متا" },
|
||||
{ key: "meta_description", label: "توضیحات متا", type: "textarea" },
|
||||
{ key: "meta_keywords", label: "کلمات کلیدی متا" },
|
||||
|
||||
Reference in New Issue
Block a user