400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import {
|
||
ChangeEvent,
|
||
FormEvent,
|
||
Suspense,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||
import CategoryTreeSelect from "@/components/ui/CategoryTreeSelect";
|
||
import ImageCropper from "@/components/ui/ImageCropper";
|
||
import { FormModal } from "@/components/ui/Modal";
|
||
import RichTextArea from "@/components/ui/RichTextArea";
|
||
import { CategoryNode } from "@/data/clothes";
|
||
import { ApiError, uploadMedia } from "@/lib/api";
|
||
import {
|
||
createBlog,
|
||
fetchBlog,
|
||
fetchBlogCategoriesTree,
|
||
updateBlog,
|
||
} from "@/lib/blog-api";
|
||
|
||
const DEFAULT_IMAGE = "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg";
|
||
|
||
function NewBlogPostForm() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const editId = searchParams.get("edit");
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [title, setTitle] = useState("");
|
||
const [categoryId, setCategoryId] = useState("");
|
||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||
const [abstract, setAbstract] = useState("");
|
||
const [body, setBody] = useState("");
|
||
const [imagePreview, setImagePreview] = useState(DEFAULT_IMAGE);
|
||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [tagInput, setTagInput] = useState("");
|
||
const [metaOpen, setMetaOpen] = useState(false);
|
||
const [metaTitle, setMetaTitle] = useState("");
|
||
const [metaDescription, setMetaDescription] = useState("");
|
||
const [metaKeywords, setMetaKeywords] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [loading, setLoading] = useState(Boolean(editId));
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const hasMeta = Boolean(
|
||
metaTitle.trim() || metaDescription.trim() || metaKeywords.trim(),
|
||
);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const cats = await fetchBlogCategoriesTree();
|
||
if (!cancelled) setCategoryTree(cats);
|
||
} catch {
|
||
/* optional */
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!editId) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const post = await fetchBlog(editId);
|
||
if (cancelled) return;
|
||
setTitle(post.title);
|
||
setCategoryId(post.categoryId || "");
|
||
setAbstract(post.abstract);
|
||
setBody(post.body);
|
||
setImagePreview(post.image);
|
||
setTags(post.tags);
|
||
setMetaTitle(post.metaTitle);
|
||
setMetaDescription(post.metaDescription);
|
||
setMetaKeywords(post.metaKeywords);
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
setError(
|
||
err instanceof ApiError
|
||
? err.message
|
||
: "بارگذاری مطلب ناموفق بود.",
|
||
);
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [editId]);
|
||
|
||
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
setImageFile(file);
|
||
setImagePreview(URL.createObjectURL(file));
|
||
}
|
||
|
||
function addTag() {
|
||
const next = tagInput.trim();
|
||
if (!next || tags.includes(next)) return;
|
||
setTags((prev) => [...prev, next]);
|
||
setTagInput("");
|
||
}
|
||
|
||
async function handleSubmit(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!title.trim()) {
|
||
setError("عنوان الزامی است.");
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
let imageUrl: string | undefined;
|
||
let imageKey: string | undefined;
|
||
if (imageFile) {
|
||
const uploaded = await uploadMedia(imageFile, "blog");
|
||
imageUrl = uploaded.url;
|
||
imageKey = uploaded.storageKey;
|
||
}
|
||
|
||
const payload = {
|
||
title: title.trim(),
|
||
summary: abstract.trim() || undefined,
|
||
body: body || undefined,
|
||
metaTitle: metaTitle.trim() || undefined,
|
||
metaDescription: metaDescription.trim() || undefined,
|
||
published: true,
|
||
...(categoryId
|
||
? { categoryId }
|
||
: editId
|
||
? { categoryId: null }
|
||
: {}),
|
||
...(imageUrl ? { imageUrl, imageKey } : {}),
|
||
};
|
||
|
||
if (editId) {
|
||
await updateBlog(editId, payload);
|
||
} else {
|
||
await createBlog(payload);
|
||
}
|
||
|
||
router.push("/blog");
|
||
router.refresh();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof ApiError ? err.message : "ثبت مطلب ناموفق بود.",
|
||
);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
function saveMeta(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
setMetaOpen(false);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<ClothesShell
|
||
eyebrow={editId ? "Edit Post" : "New Post"}
|
||
title={editId ? "ویرایش مطلب" : "مطلب جدید"}
|
||
description="کاور ۳:۲ را برش دهید و محتوای مطلب را کامل کنید. تاریخ و نویسنده بهصورت خودکار ثبت میشوند."
|
||
breadcrumbs={[
|
||
{ label: "داشبورد", href: "/" },
|
||
{ label: "بلاگ", href: "/blog" },
|
||
{ label: editId ? "ویرایش" : "مطلب جدید" },
|
||
]}
|
||
>
|
||
{loading ? (
|
||
<p className="text-sm text-muted">در حال بارگذاری...</p>
|
||
) : (
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="grid grid-cols-1 gap-3 lg:grid-cols-[300px_1fr]"
|
||
>
|
||
<aside className="dash-enter rounded-lg border border-line bg-surface p-4">
|
||
<p className="mb-2 text-sm text-ink">تصویر کاور</p>
|
||
<p className="mb-3 text-xs leading-6 text-muted">نسبت ۳:۲</p>
|
||
<ImageCropper
|
||
src={imagePreview}
|
||
aspect="3:2"
|
||
alt="کاور مطلب"
|
||
className="blog-cover-cropper"
|
||
/>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
className="sr-only"
|
||
onChange={handleImageChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="mt-3 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
|
||
onClick={() => fileRef.current?.click()}
|
||
>
|
||
انتخاب تصویر
|
||
</button>
|
||
</aside>
|
||
|
||
<div
|
||
className="space-y-3 dash-enter"
|
||
style={{ animationDelay: "70ms" }}
|
||
>
|
||
{error ? (
|
||
<p className="rounded-lg border border-line bg-white px-4 py-3 text-sm text-ink">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
|
||
<section className="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-4 sm:grid-cols-12">
|
||
<div className="clothes-field sm:col-span-8">
|
||
<label htmlFor="blog-title">عنوان</label>
|
||
<input
|
||
id="blog-title"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="عنوان مطلب"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-4">
|
||
<label htmlFor="blog-category">دستهبندی</label>
|
||
<CategoryTreeSelect
|
||
id="blog-category"
|
||
nodes={categoryTree}
|
||
value={categoryId}
|
||
onChange={setCategoryId}
|
||
placeholder="انتخاب دسته بلاگ..."
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-12">
|
||
<label htmlFor="blog-abstract">خلاصه</label>
|
||
<textarea
|
||
id="blog-abstract"
|
||
value={abstract}
|
||
onChange={(e) => setAbstract(e.target.value)}
|
||
placeholder="چکیده کوتاه مطلب..."
|
||
className="!min-h-[4.5rem]"
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-12">
|
||
<span className="text-[0.7rem] text-muted">متن</span>
|
||
<RichTextArea value={body} onChange={setBody} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-line bg-surface p-4">
|
||
<p className="mb-3 text-sm text-ink">برچسبها</p>
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
{tags.map((tag) => (
|
||
<span key={tag} className="tag-chip">
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
className="text-muted hover:text-ink"
|
||
onClick={() =>
|
||
setTags((prev) => prev.filter((item) => item !== tag))
|
||
}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={tagInput}
|
||
onChange={(e) => setTagInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addTag();
|
||
}
|
||
}}
|
||
placeholder="برچسب جدید"
|
||
className="flex-1 border border-line px-3 py-2 text-sm outline-none focus:border-ink"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={addTag}
|
||
className="border border-ink bg-ink px-4 py-2 text-sm text-white hover:bg-white hover:text-ink"
|
||
>
|
||
افزودن
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMetaOpen(true)}
|
||
className="border border-line bg-white px-4 py-2.5 text-sm text-ink transition-colors hover:border-ink"
|
||
>
|
||
متا تگها
|
||
{hasMeta ? (
|
||
<span className="ms-2 text-xs text-muted">• تنظیم شده</span>
|
||
) : null}
|
||
</button>
|
||
|
||
<div className="flex gap-2">
|
||
<Link
|
||
href="/blog"
|
||
className="border border-line px-5 py-2.5 text-sm text-muted hover:text-ink"
|
||
>
|
||
انصراف
|
||
</Link>
|
||
<button
|
||
type="submit"
|
||
disabled={saving}
|
||
className="auth-button !mt-0 !max-w-none !w-auto px-6 disabled:opacity-60"
|
||
>
|
||
<span>
|
||
{saving
|
||
? "در حال ثبت..."
|
||
: editId
|
||
? "ذخیره تغییرات"
|
||
: "ثبت مطلب"}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</ClothesShell>
|
||
|
||
<FormModal
|
||
open={metaOpen}
|
||
title="متا تگها"
|
||
onClose={() => setMetaOpen(false)}
|
||
onSubmit={saveMeta}
|
||
submitLabel="ذخیره متا"
|
||
>
|
||
<div className="clothes-field">
|
||
<label htmlFor="blog-meta-title">Meta Title</label>
|
||
<input
|
||
id="blog-meta-title"
|
||
dir="ltr"
|
||
className="!text-end"
|
||
value={metaTitle}
|
||
onChange={(e) => setMetaTitle(e.target.value)}
|
||
placeholder="SEO title"
|
||
/>
|
||
</div>
|
||
<div className="clothes-field">
|
||
<label htmlFor="blog-meta-desc">Meta Description</label>
|
||
<textarea
|
||
id="blog-meta-desc"
|
||
dir="ltr"
|
||
className="!min-h-[5rem] !text-end"
|
||
value={metaDescription}
|
||
onChange={(e) => setMetaDescription(e.target.value)}
|
||
placeholder="SEO description"
|
||
/>
|
||
</div>
|
||
<div className="clothes-field">
|
||
<label htmlFor="blog-meta-keys">Meta Keywords</label>
|
||
<input
|
||
id="blog-meta-keys"
|
||
dir="ltr"
|
||
className="!text-end"
|
||
value={metaKeywords}
|
||
onChange={(e) => setMetaKeywords(e.target.value)}
|
||
placeholder="keyword, keyword"
|
||
/>
|
||
</div>
|
||
</FormModal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function NewBlogPostPage() {
|
||
return (
|
||
<Suspense
|
||
fallback={<p className="p-6 text-sm text-muted">در حال بارگذاری...</p>}
|
||
>
|
||
<NewBlogPostForm />
|
||
</Suspense>
|
||
);
|
||
}
|