Initial commit: Mana Collection dashboards (Next.js).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-07 20:50:24 +03:30
co-authored by Cursor
commit 18b29f5b62
95 changed files with 16414 additions and 0 deletions
+374
View File
@@ -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>
);
}