Initial commit: Mana Collection dashboards (Next.js).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
"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 ImageCropper from "@/components/ui/ImageCropper";
|
||||
import { FormModal } from "@/components/ui/Modal";
|
||||
import RichTextArea from "@/components/ui/RichTextArea";
|
||||
import { ApiError, uploadMedia } from "@/lib/api";
|
||||
import { createBlog, fetchBlog, 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 [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [author, setAuthor] = useState("");
|
||||
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(() => {
|
||||
if (!editId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const post = await fetchBlog(editId);
|
||||
if (cancelled) return;
|
||||
setTitle(post.title);
|
||||
setDate(post.date);
|
||||
setAuthor(post.author);
|
||||
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,
|
||||
author: author.trim() || undefined,
|
||||
metaTitle: metaTitle.trim() || undefined,
|
||||
metaDescription: metaDescription.trim() || undefined,
|
||||
published: true,
|
||||
...(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-date">تاریخ</label>
|
||||
<input
|
||||
id="blog-date"
|
||||
type="date"
|
||||
dir="ltr"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="clothes-field sm:col-span-12">
|
||||
<label htmlFor="blog-author">نویسنده</label>
|
||||
<input
|
||||
id="blog-author"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user