Improve blog editor, details modal, and header display of first and last name.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-09 12:28:36 +03:30
co-authored by Cursor
parent 8008ea4db1
commit 27d5cd0186
8 changed files with 979 additions and 45 deletions
+326
View File
@@ -0,0 +1,326 @@
"use client";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
AddChildIcon,
EditIcon,
TrashIcon,
} from "@/components/ui/IconButton";
import { FormModal } from "@/components/ui/Modal";
import { CategoryNode, flattenCategories } from "@/data/clothes";
import { ApiError } from "@/lib/api";
import {
createBlogCategory,
deleteBlogCategory,
fetchBlogCategoriesTree,
updateBlogCategory,
} from "@/lib/blog-api";
import { useConfirm } from "@/components/ui/ConfirmDialog";
export default function BlogCategoriesPage() {
const confirmDialog = useConfirm();
const [tree, setTree] = useState<CategoryNode[]>([]);
const [open, setOpen] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [nameFa, setNameFa] = useState("");
const [nameEn, setNameEn] = useState("");
const [parentId, setParentId] = useState("");
const parentOptions = useMemo(() => flattenCategories(tree), [tree]);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const cats = await fetchBlogCategoriesTree();
setTree(cats);
setOpen((prev) => {
const next = { ...prev };
for (const opt of flattenCategories(cats)) {
if (next[opt.id] === undefined) next[opt.id] = true;
}
return next;
});
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری دسته‌های بلاگ ناموفق بود.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
function toggle(id: string) {
setOpen((prev) => ({ ...prev, [id]: !prev[id] }));
}
function resetForm() {
setEditingId(null);
setNameFa("");
setNameEn("");
setParentId("");
}
function closeModal() {
setModalOpen(false);
resetForm();
}
function openCreate(parent?: string) {
resetForm();
setParentId(parent ?? "");
setModalOpen(true);
}
function openEdit(node: CategoryNode) {
setEditingId(node.id);
setNameFa(node.nameFa);
setNameEn(node.nameEn);
setParentId("");
setModalOpen(true);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!nameFa.trim()) return;
setSaving(true);
setError(null);
try {
if (editingId) {
setTree(
await updateBlogCategory(editingId, {
nameFa: nameFa.trim(),
nameEn: nameEn.trim() || undefined,
}),
);
} else {
const next = await createBlogCategory({
nameFa: nameFa.trim(),
nameEn: nameEn.trim() || undefined,
parentId: parentId || undefined,
});
setTree(next);
if (parentId) setOpen((prev) => ({ ...prev, [parentId]: true }));
}
closeModal();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ذخیره دسته ناموفق بود.",
);
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!(await confirmDialog("این دسته حذف شود؟"))) return;
setError(null);
try {
setTree(await deleteBlogCategory(id));
} catch (err) {
setError(err instanceof ApiError ? err.message : "حذف دسته ناموفق بود.");
}
}
return (
<>
<ClothesShell
eyebrow="Blog Categories"
title="دسته‌بندی بلاگ"
description="ساختار درختی دسته‌های بلاگ را مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "بلاگ", href: "/blog" },
{ label: "دسته‌بندی‌ها" },
]}
onPlusClick={() => openCreate()}
plusLabel="دسته جدید"
>
{error ? (
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
{error}
</p>
) : null}
{loading ? (
<p className="text-sm text-muted">در حال بارگذاری...</p>
) : (
<div className="tree-box dash-enter space-y-2">
{tree.length === 0 ? (
<p className="rounded-lg border border-line bg-surface px-4 py-8 text-center text-sm text-muted">
هنوز دسته‌ای ثبت نشده است.
</p>
) : null}
{tree.map((node, i) => (
<CategoryBranch
key={node.id}
node={node}
depth={0}
number={`${i + 1}`}
open={open}
onToggle={toggle}
onEdit={openEdit}
onAddChild={(id) => openCreate(id)}
onDelete={handleDelete}
delay={80 + i * 60}
/>
))}
</div>
)}
</ClothesShell>
<FormModal
open={modalOpen}
title={editingId ? "ویرایش دسته" : "دسته جدید"}
onClose={closeModal}
onSubmit={handleSubmit}
submitLabel={saving ? "..." : editingId ? "ذخیره" : "افزودن دسته"}
>
{!editingId ? (
<div className="clothes-field">
<label htmlFor="blog-category-parent">دسته والد</label>
<select
id="blog-category-parent"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">بدون والد (ریشه)</option>
{parentOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
<div className="clothes-field">
<label htmlFor="blog-category-name-fa">نام فارسی</label>
<input
id="blog-category-name-fa"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
placeholder="مثلاً سبک زندگی"
required
/>
</div>
<div className="clothes-field">
<label htmlFor="blog-category-name-en">نام انگلیسی</label>
<input
id="blog-category-name-en"
dir="ltr"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder="Lifestyle"
/>
</div>
</FormModal>
</>
);
}
function CategoryBranch({
node,
depth,
number,
open,
onToggle,
onEdit,
onAddChild,
onDelete,
delay,
}: {
node: CategoryNode;
depth: number;
number: string;
open: Record<string, boolean>;
onToggle: (id: string) => void;
onEdit: (node: CategoryNode) => void;
onAddChild: (id: string) => void;
onDelete: (id: string) => void;
delay: number;
}) {
const hasChildren = Boolean(node.children?.length);
const isOpen = open[node.id] !== false;
return (
<div className="dash-enter" style={{ animationDelay: `${delay}ms` }}>
<div
className="tree-row"
style={{ paddingInlineStart: `${0.95 + depth * 1.1}rem` }}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{hasChildren ? (
<button
type="button"
className="category-tree-toggle"
aria-label={isOpen ? "بستن" : "باز کردن"}
onClick={() => onToggle(node.id)}
>
{isOpen ? "−" : "+"}
</button>
) : (
<span className="category-tree-toggle-spacer" />
)}
<div className="min-w-0">
<p className="truncate text-sm text-ink">
<span className="me-2 font-montserrat text-xs text-muted">
{number}
</span>
{node.nameFa}
</p>
{node.nameEn ? (
<p
dir="ltr"
className="truncate text-end font-display text-xs tracking-[0.04em] text-muted"
>
{node.nameEn}
</p>
) : null}
</div>
</div>
<div className="tree-row-actions">
<IconButton label="زیر‌دسته" variant="ghost" onClick={() => onAddChild(node.id)}>
<AddChildIcon />
</IconButton>
<IconButton label="ویرایش" variant="ghost" onClick={() => onEdit(node)}>
<EditIcon />
</IconButton>
<IconButton
label="حذف"
variant="ghost"
onClick={() => void onDelete(node.id)}
>
<TrashIcon />
</IconButton>
</div>
</div>
{hasChildren && isOpen
? node.children!.map((child, i) => (
<CategoryBranch
key={child.id}
node={child}
depth={depth + 1}
number={`${number}.${i + 1}`}
open={open}
onToggle={onToggle}
onEdit={onEdit}
onAddChild={onAddChild}
onDelete={onDelete}
delay={delay + 30 + i * 20}
/>
))
: null}
</div>
);
}
+50 -25
View File
@@ -2,13 +2,27 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ChangeEvent, FormEvent, Suspense, useEffect, useRef, useState } from "react";
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, updateBlog } from "@/lib/blog-api";
import {
createBlog,
fetchBlog,
fetchBlogCategoriesTree,
updateBlog,
} from "@/lib/blog-api";
const DEFAULT_IMAGE = "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg";
@@ -19,8 +33,8 @@ function NewBlogPostForm() {
const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [author, setAuthor] = useState("");
const [categoryId, setCategoryId] = useState("");
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [abstract, setAbstract] = useState("");
const [body, setBody] = useState("");
const [imagePreview, setImagePreview] = useState(DEFAULT_IMAGE);
@@ -39,6 +53,21 @@ function NewBlogPostForm() {
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;
@@ -49,8 +78,7 @@ function NewBlogPostForm() {
const post = await fetchBlog(editId);
if (cancelled) return;
setTitle(post.title);
setDate(post.date);
setAuthor(post.author);
setCategoryId(post.categoryId || "");
setAbstract(post.abstract);
setBody(post.body);
setImagePreview(post.image);
@@ -111,10 +139,14 @@ function NewBlogPostForm() {
title: title.trim(),
summary: abstract.trim() || undefined,
body: body || undefined,
author: author.trim() || undefined,
metaTitle: metaTitle.trim() || undefined,
metaDescription: metaDescription.trim() || undefined,
published: true,
...(categoryId
? { categoryId }
: editId
? { categoryId: null }
: {}),
...(imageUrl ? { imageUrl, imageKey } : {}),
};
@@ -145,7 +177,7 @@ function NewBlogPostForm() {
<ClothesShell
eyebrow={editId ? "Edit Post" : "New Post"}
title={editId ? "ویرایش مطلب" : "مطلب جدید"}
description="کاور ۳:۲ را برش دهید و محتوای مطلب را کامل کنید."
description="کاور ۳:۲ را برش دهید و محتوای مطلب را کامل کنید. تاریخ و نویسنده به‌صورت خودکار ثبت می‌شوند."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "بلاگ", href: "/blog" },
@@ -206,22 +238,13 @@ function NewBlogPostForm() {
/>
</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="نام نویسنده"
<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">
@@ -367,7 +390,9 @@ function NewBlogPostForm() {
export default function NewBlogPostPage() {
return (
<Suspense fallback={<p className="p-6 text-sm text-muted">در حال بارگذاری...</p>}>
<Suspense
fallback={<p className="p-6 text-sm text-muted">در حال بارگذاری...</p>}
>
<NewBlogPostForm />
</Suspense>
);
+116 -4
View File
@@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useCallback, useEffect, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
@@ -11,7 +12,7 @@ import IconButton, {
MetaTagsIcon,
TrashIcon,
} from "@/components/ui/IconButton";
import { FormModal } from "@/components/ui/Modal";
import Modal, { FormModal } from "@/components/ui/Modal";
import Pagination, { usePagination } from "@/components/ui/Pagination";
import { BlogPost, formatBlogDate } from "@/data/blog";
import { ApiError } from "@/lib/api";
@@ -40,6 +41,7 @@ export default function BlogListPage() {
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Filters>(emptyFilters);
const [applied, setApplied] = useState<Filters>(emptyFilters);
const [detailPost, setDetailPost] = useState<BlogPost | null>(null);
const [metaPost, setMetaPost] = useState<BlogPost | null>(null);
const [metaTitle, setMetaTitle] = useState("");
const [metaDescription, setMetaDescription] = useState("");
@@ -145,6 +147,14 @@ export default function BlogListPage() {
plusHref="/blog/new"
plusLabel="مطلب جدید"
>
<div className="mb-3 flex flex-wrap gap-2">
<Link
href="/blog/categories"
className="border border-line bg-white px-4 py-2 text-sm text-ink transition-colors hover:border-ink"
>
مدیریت دسته‌بندی‌ها
</Link>
</div>
{error ? (
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
{error}
@@ -217,8 +227,9 @@ export default function BlogListPage() {
{pageItems.map((post, i) => (
<article
key={post.id}
className="blog-card dash-enter"
className="blog-card dash-enter cursor-pointer"
style={{ animationDelay: `${70 + i * 45}ms` }}
onClick={() => setDetailPost(post)}
>
<div className="blog-card-image">
<Image
@@ -244,7 +255,11 @@ export default function BlogListPage() {
{formatBlogDate(post.date)}
</time>
</div>
<p className="mt-1 text-sm text-muted">{post.author}</p>
<p className="mt-1 text-sm text-muted">
{post.categoryName
? `${post.categoryName} · ${post.author}`
: post.author}
</p>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-ink/80">
{post.abstract}
</p>
@@ -262,7 +277,10 @@ export default function BlogListPage() {
) : null}
</div>
<div className="tree-row-actions shrink-0">
<div
className="tree-row-actions shrink-0"
onClick={(e) => e.stopPropagation()}
>
<IconButton
label="ویرایش"
variant="line"
@@ -308,6 +326,100 @@ export default function BlogListPage() {
)}
</ClothesShell>
<Modal
open={Boolean(detailPost)}
title="جزئیات مطلب"
onClose={() => setDetailPost(null)}
wide="full"
footer={
<button
type="button"
className="modal-btn-ghost"
onClick={() => setDetailPost(null)}
>
بستن
</button>
}
>
{detailPost ? (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[280px_1fr]">
<div className="space-y-3">
<div className="relative mx-auto aspect-[3/2] w-full max-w-[280px] overflow-hidden rounded-md bg-[#f3f3f1] lg:max-w-none">
<Image
src={detailPost.image}
alt={detailPost.title}
fill
unoptimized
sizes="280px"
className="object-cover"
/>
</div>
</div>
<div className="space-y-4 text-start">
<div>
<h3 className="text-xl text-ink sm:text-2xl">
{detailPost.title}
</h3>
<p className="mt-1 text-sm text-muted">
<time dateTime={detailPost.date} dir="ltr">
{formatBlogDate(detailPost.date)}
</time>
{detailPost.author ? ` · ${detailPost.author}` : null}
</p>
</div>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted">دسته‌بندی</dt>
<dd className="mt-0.5 text-ink">
{detailPost.categoryName || "—"}
</dd>
</div>
<div>
<dt className="text-xs text-muted">نویسنده</dt>
<dd className="mt-0.5 text-ink">
{detailPost.author || "—"}
</dd>
</div>
</dl>
{detailPost.abstract ? (
<div>
<p className="mb-1 text-xs text-muted">خلاصه</p>
<p className="text-sm leading-7 text-ink/90 whitespace-pre-wrap">
{detailPost.abstract}
</p>
</div>
) : null}
{detailPost.body ? (
<div>
<p className="mb-1 text-xs text-muted">متن</p>
<div
className="blog-detail-body text-sm leading-7 text-ink/90"
dangerouslySetInnerHTML={{ __html: detailPost.body }}
/>
</div>
) : null}
{detailPost.tags.length ? (
<div>
<p className="mb-1.5 text-xs text-muted">برچسب‌ها</p>
<div className="flex flex-wrap gap-1.5">
{detailPost.tags.map((tag) => (
<span key={tag} className="tag-chip !rounded-md !py-0.5">
{tag}
</span>
))}
</div>
</div>
) : null}
</div>
</div>
) : null}
</Modal>
<FormModal
open={Boolean(metaPost)}
title="متا تگ‌ها"
+86
View File
@@ -793,6 +793,7 @@ body {
border-radius: var(--radius-card);
padding: 0.75rem;
transition: border-color 0.25s ease;
cursor: pointer;
}
.blog-card:hover {
@@ -815,6 +816,41 @@ body {
}
}
.blog-detail-body {
word-break: break-word;
}
.blog-detail-body p {
margin: 0.55rem 0;
}
.blog-detail-body ul,
.blog-detail-body ol {
margin-block: 0.5rem;
margin-inline: 1.25rem 0;
padding: 0;
}
.blog-detail-body ul {
list-style: disc;
}
.blog-detail-body ol {
list-style: decimal;
}
.blog-detail-body li {
margin: 0.2rem 0;
}
.blog-detail-body img {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0;
border-radius: var(--radius-card);
}
.rich-text {
border: 1px solid var(--line);
border-radius: var(--radius-card);
@@ -833,6 +869,9 @@ body {
}
.rich-text-toolbar button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.85rem;
padding: 0 0.55rem;
@@ -859,6 +898,10 @@ body {
margin-inline: 0.2rem;
}
.rich-text-editor-wrap {
position: relative;
}
.rich-text-editor {
min-height: 12rem;
padding: 0.75rem 0.85rem;
@@ -874,12 +917,55 @@ body {
pointer-events: none;
}
.rich-text-editor ul,
.rich-text-editor ol {
margin-block: 0.5rem;
margin-inline: 1.25rem 0;
padding: 0;
}
.rich-text-editor ul {
list-style: disc;
}
.rich-text-editor ol {
list-style: decimal;
}
.rich-text-editor li {
margin: 0.2rem 0;
}
.rich-text-editor .rich-inline-image,
.rich-text-editor img {
display: block;
max-width: 100%;
height: auto;
margin: 0.75rem 0;
border-radius: var(--radius-card);
cursor: pointer;
}
.rich-text-editor img.rich-image-selected {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.rich-image-handle {
position: absolute;
z-index: 2;
width: 0.85rem;
height: 0.85rem;
border: 1px solid var(--ink);
border-radius: 2px;
background: #fff;
cursor: nwse-resize;
transform: translate(-50%, -50%);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.rich-image-handle:hover {
background: var(--ink);
}
.pagination {