Files
dashboards/src/app/blog/new/page.tsx
T

400 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}