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 Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; 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 ClothesShell from "@/components/clothes/ClothesShell";
import CategoryTreeSelect from "@/components/ui/CategoryTreeSelect";
import ImageCropper from "@/components/ui/ImageCropper"; import ImageCropper from "@/components/ui/ImageCropper";
import { FormModal } from "@/components/ui/Modal"; import { FormModal } from "@/components/ui/Modal";
import RichTextArea from "@/components/ui/RichTextArea"; import RichTextArea from "@/components/ui/RichTextArea";
import { CategoryNode } from "@/data/clothes";
import { ApiError, uploadMedia } from "@/lib/api"; 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"; const DEFAULT_IMAGE = "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg";
@@ -19,8 +33,8 @@ function NewBlogPostForm() {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); const [categoryId, setCategoryId] = useState("");
const [author, setAuthor] = useState(""); const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [abstract, setAbstract] = useState(""); const [abstract, setAbstract] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [imagePreview, setImagePreview] = useState(DEFAULT_IMAGE); const [imagePreview, setImagePreview] = useState(DEFAULT_IMAGE);
@@ -39,6 +53,21 @@ function NewBlogPostForm() {
metaTitle.trim() || metaDescription.trim() || metaKeywords.trim(), 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(() => { useEffect(() => {
if (!editId) return; if (!editId) return;
let cancelled = false; let cancelled = false;
@@ -49,8 +78,7 @@ function NewBlogPostForm() {
const post = await fetchBlog(editId); const post = await fetchBlog(editId);
if (cancelled) return; if (cancelled) return;
setTitle(post.title); setTitle(post.title);
setDate(post.date); setCategoryId(post.categoryId || "");
setAuthor(post.author);
setAbstract(post.abstract); setAbstract(post.abstract);
setBody(post.body); setBody(post.body);
setImagePreview(post.image); setImagePreview(post.image);
@@ -111,10 +139,14 @@ function NewBlogPostForm() {
title: title.trim(), title: title.trim(),
summary: abstract.trim() || undefined, summary: abstract.trim() || undefined,
body: body || undefined, body: body || undefined,
author: author.trim() || undefined,
metaTitle: metaTitle.trim() || undefined, metaTitle: metaTitle.trim() || undefined,
metaDescription: metaDescription.trim() || undefined, metaDescription: metaDescription.trim() || undefined,
published: true, published: true,
...(categoryId
? { categoryId }
: editId
? { categoryId: null }
: {}),
...(imageUrl ? { imageUrl, imageKey } : {}), ...(imageUrl ? { imageUrl, imageKey } : {}),
}; };
@@ -145,7 +177,7 @@ function NewBlogPostForm() {
<ClothesShell <ClothesShell
eyebrow={editId ? "Edit Post" : "New Post"} eyebrow={editId ? "Edit Post" : "New Post"}
title={editId ? "ویرایش مطلب" : "مطلب جدید"} title={editId ? "ویرایش مطلب" : "مطلب جدید"}
description="کاور ۳:۲ را برش دهید و محتوای مطلب را کامل کنید." description="کاور ۳:۲ را برش دهید و محتوای مطلب را کامل کنید. تاریخ و نویسنده به‌صورت خودکار ثبت می‌شوند."
breadcrumbs={[ breadcrumbs={[
{ label: "داشبورد", href: "/" }, { label: "داشبورد", href: "/" },
{ label: "بلاگ", href: "/blog" }, { label: "بلاگ", href: "/blog" },
@@ -206,22 +238,13 @@ function NewBlogPostForm() {
/> />
</div> </div>
<div className="clothes-field sm:col-span-4"> <div className="clothes-field sm:col-span-4">
<label htmlFor="blog-date">تاریخ</label> <label htmlFor="blog-category">دسته‌بندی</label>
<input <CategoryTreeSelect
id="blog-date" id="blog-category"
type="date" nodes={categoryTree}
dir="ltr" value={categoryId}
value={date} onChange={setCategoryId}
onChange={(e) => setDate(e.target.value)} placeholder="انتخاب دسته بلاگ..."
/>
</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>
<div className="clothes-field sm:col-span-12"> <div className="clothes-field sm:col-span-12">
@@ -367,7 +390,9 @@ function NewBlogPostForm() {
export default function NewBlogPostPage() { export default function NewBlogPostPage() {
return ( return (
<Suspense fallback={<p className="p-6 text-sm text-muted">در حال بارگذاری...</p>}> <Suspense
fallback={<p className="p-6 text-sm text-muted">در حال بارگذاری...</p>}
>
<NewBlogPostForm /> <NewBlogPostForm />
</Suspense> </Suspense>
); );
+116 -4
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FormEvent, useCallback, useEffect, useState } from "react"; import { FormEvent, useCallback, useEffect, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell"; import ClothesShell from "@/components/clothes/ClothesShell";
@@ -11,7 +12,7 @@ import IconButton, {
MetaTagsIcon, MetaTagsIcon,
TrashIcon, TrashIcon,
} from "@/components/ui/IconButton"; } 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 Pagination, { usePagination } from "@/components/ui/Pagination";
import { BlogPost, formatBlogDate } from "@/data/blog"; import { BlogPost, formatBlogDate } from "@/data/blog";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
@@ -40,6 +41,7 @@ export default function BlogListPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Filters>(emptyFilters); const [draft, setDraft] = useState<Filters>(emptyFilters);
const [applied, setApplied] = useState<Filters>(emptyFilters); const [applied, setApplied] = useState<Filters>(emptyFilters);
const [detailPost, setDetailPost] = useState<BlogPost | null>(null);
const [metaPost, setMetaPost] = useState<BlogPost | null>(null); const [metaPost, setMetaPost] = useState<BlogPost | null>(null);
const [metaTitle, setMetaTitle] = useState(""); const [metaTitle, setMetaTitle] = useState("");
const [metaDescription, setMetaDescription] = useState(""); const [metaDescription, setMetaDescription] = useState("");
@@ -145,6 +147,14 @@ export default function BlogListPage() {
plusHref="/blog/new" plusHref="/blog/new"
plusLabel="مطلب جدید" 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 ? ( {error ? (
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink"> <p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
{error} {error}
@@ -217,8 +227,9 @@ export default function BlogListPage() {
{pageItems.map((post, i) => ( {pageItems.map((post, i) => (
<article <article
key={post.id} key={post.id}
className="blog-card dash-enter" className="blog-card dash-enter cursor-pointer"
style={{ animationDelay: `${70 + i * 45}ms` }} style={{ animationDelay: `${70 + i * 45}ms` }}
onClick={() => setDetailPost(post)}
> >
<div className="blog-card-image"> <div className="blog-card-image">
<Image <Image
@@ -244,7 +255,11 @@ export default function BlogListPage() {
{formatBlogDate(post.date)} {formatBlogDate(post.date)}
</time> </time>
</div> </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"> <p className="mt-2 line-clamp-2 text-sm leading-6 text-ink/80">
{post.abstract} {post.abstract}
</p> </p>
@@ -262,7 +277,10 @@ export default function BlogListPage() {
) : null} ) : null}
</div> </div>
<div className="tree-row-actions shrink-0"> <div
className="tree-row-actions shrink-0"
onClick={(e) => e.stopPropagation()}
>
<IconButton <IconButton
label="ویرایش" label="ویرایش"
variant="line" variant="line"
@@ -308,6 +326,100 @@ export default function BlogListPage() {
)} )}
</ClothesShell> </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 <FormModal
open={Boolean(metaPost)} open={Boolean(metaPost)}
title="متا تگ‌ها" title="متا تگ‌ها"
+86
View File
@@ -793,6 +793,7 @@ body {
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: 0.75rem; padding: 0.75rem;
transition: border-color 0.25s ease; transition: border-color 0.25s ease;
cursor: pointer;
} }
.blog-card:hover { .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 { .rich-text {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-card); border-radius: var(--radius-card);
@@ -833,6 +869,9 @@ body {
} }
.rich-text-toolbar button { .rich-text-toolbar button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem; min-width: 2rem;
height: 1.85rem; height: 1.85rem;
padding: 0 0.55rem; padding: 0 0.55rem;
@@ -859,6 +898,10 @@ body {
margin-inline: 0.2rem; margin-inline: 0.2rem;
} }
.rich-text-editor-wrap {
position: relative;
}
.rich-text-editor { .rich-text-editor {
min-height: 12rem; min-height: 12rem;
padding: 0.75rem 0.85rem; padding: 0.75rem 0.85rem;
@@ -874,12 +917,55 @@ body {
pointer-events: none; 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 .rich-inline-image,
.rich-text-editor img { .rich-text-editor img {
display: block; display: block;
max-width: 100%; max-width: 100%;
height: auto;
margin: 0.75rem 0; margin: 0.75rem 0;
border-radius: var(--radius-card); 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 { .pagination {
+9 -2
View File
@@ -16,7 +16,11 @@ export default function DashboardHeader({
}: DashboardHeaderProps) { }: DashboardHeaderProps) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const router = useRouter(); const router = useRouter();
const displayName = userName ?? user?.name ?? "کاربر"; const displayName =
[user?.firstName, user?.lastName].filter(Boolean).join(" ").trim() ||
user?.name?.trim() ||
userName ||
"کاربر";
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false); const [userOpen, setUserOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false); const [passwordOpen, setPasswordOpen] = useState(false);
@@ -70,7 +74,7 @@ export default function DashboardHeader({
}} }}
className="flex cursor-pointer items-center gap-2.5 text-ink transition-opacity hover:opacity-70" className="flex cursor-pointer items-center gap-2.5 text-ink transition-opacity hover:opacity-70"
> >
<span className="max-w-[9rem] truncate text-sm sm:max-w-none"> <span className="max-w-[12rem] truncate text-sm sm:max-w-none">
{displayName} {displayName}
</span> </span>
<span className="flex h-9 w-9 items-center justify-center border border-line bg-white"> <span className="flex h-9 w-9 items-center justify-center border border-line bg-white">
@@ -132,6 +136,9 @@ export default function DashboardHeader({
<Link href="/blog" className="text-ink transition-opacity hover:opacity-60"> <Link href="/blog" className="text-ink transition-opacity hover:opacity-60">
بلاگ من بلاگ من
</Link> </Link>
<Link href="/blog/categories" className="text-ink transition-opacity hover:opacity-60">
دسته‌بندی بلاگ
</Link>
<Link href="/orders" className="text-ink transition-opacity hover:opacity-60"> <Link href="/orders" className="text-ink transition-opacity hover:opacity-60">
سفارش‌های من سفارش‌های من
</Link> </Link>
+324 -4
View File
@@ -2,8 +2,10 @@
import { import {
ChangeEvent, ChangeEvent,
PointerEvent as ReactPointerEvent,
useEffect, useEffect,
useRef, useRef,
useState,
} from "react"; } from "react";
type RichTextAreaProps = { type RichTextAreaProps = {
@@ -12,6 +14,12 @@ type RichTextAreaProps = {
placeholder?: string; placeholder?: string;
}; };
type ResizeState = {
img: HTMLImageElement;
startX: number;
startWidth: number;
};
export default function RichTextArea({ export default function RichTextArea({
value, value,
onChange, onChange,
@@ -19,6 +27,12 @@ export default function RichTextArea({
}: RichTextAreaProps) { }: RichTextAreaProps) {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const selectedImgRef = useRef<HTMLImageElement | null>(null);
const resizeRef = useRef<ResizeState | null>(null);
const [selectedImg, setSelectedImg] = useState<HTMLImageElement | null>(null);
const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(
null,
);
useEffect(() => { useEffect(() => {
const el = editorRef.current; const el = editorRef.current;
@@ -28,16 +42,138 @@ export default function RichTextArea({
} }
}, [value]); }, [value]);
useEffect(() => {
function onPointerMove(event: PointerEvent) {
const state = resizeRef.current;
if (!state) return;
const delta = event.clientX - state.startX;
const maxWidth = editorRef.current?.clientWidth ?? 800;
const next = Math.max(80, Math.min(state.startWidth + delta, maxWidth));
state.img.style.width = `${Math.round(next)}px`;
state.img.style.height = "auto";
state.img.removeAttribute("height");
updateHandlePosition(state.img);
}
function onPointerUp() {
if (!resizeRef.current) return;
resizeRef.current = null;
emitChange();
}
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
}, []);
useEffect(() => {
function onScrollOrResize() {
if (selectedImgRef.current) updateHandlePosition(selectedImgRef.current);
}
const editor = editorRef.current;
window.addEventListener("resize", onScrollOrResize);
editor?.addEventListener("scroll", onScrollOrResize);
return () => {
window.removeEventListener("resize", onScrollOrResize);
editor?.removeEventListener("scroll", onScrollOrResize);
};
}, []);
function emitChange() { function emitChange() {
onChange(editorRef.current?.innerHTML ?? ""); onChange(editorRef.current?.innerHTML ?? "");
} }
function runCommand(command: string) { function runCommand(command: string, valueArg?: string) {
editorRef.current?.focus(); editorRef.current?.focus();
document.execCommand(command, false); document.execCommand(command, false, valueArg);
emitChange(); emitChange();
} }
function applyDirection(dir: "rtl" | "ltr") {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
editor.setAttribute("dir", dir);
emitChange();
return;
}
let node: Node | null = selection.anchorNode;
while (
node &&
node !== editor &&
!(
node instanceof HTMLElement &&
["P", "DIV", "LI", "H1", "H2", "H3", "BLOCKQUOTE", "UL", "OL"].includes(
node.tagName,
)
)
) {
node = node.parentNode;
}
if (node instanceof HTMLElement && node !== editor) {
node.setAttribute("dir", dir);
node.style.direction = dir;
if (!node.style.textAlign) {
node.style.textAlign = dir === "rtl" ? "right" : "left";
}
} else {
document.execCommand("formatBlock", false, "p");
const again = window.getSelection();
let block: Node | null = again?.anchorNode ?? null;
while (
block &&
block !== editor &&
!(block instanceof HTMLElement && block.tagName === "P")
) {
block = block.parentNode;
}
if (block instanceof HTMLElement) {
block.setAttribute("dir", dir);
block.style.direction = dir;
block.style.textAlign = dir === "rtl" ? "right" : "left";
} else {
editor.setAttribute("dir", dir);
}
}
emitChange();
}
function clearImageSelection() {
selectedImgRef.current?.classList.remove("rich-image-selected");
selectedImgRef.current = null;
setSelectedImg(null);
setHandlePos(null);
}
function updateHandlePosition(img: HTMLImageElement) {
const editor = editorRef.current;
if (!editor) return;
const editorRect = editor.getBoundingClientRect();
const imgRect = img.getBoundingClientRect();
setHandlePos({
top: imgRect.bottom - editorRect.top + editor.scrollTop - 8,
left: imgRect.right - editorRect.left + editor.scrollLeft - 8,
});
}
function selectImage(img: HTMLImageElement) {
if (selectedImgRef.current && selectedImgRef.current !== img) {
selectedImgRef.current.classList.remove("rich-image-selected");
}
img.classList.add("rich-image-selected");
selectedImgRef.current = img;
setSelectedImg(img);
updateHandlePosition(img);
}
function insertImage(event: ChangeEvent<HTMLInputElement>) { function insertImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file || !editorRef.current) return; if (!file || !editorRef.current) return;
@@ -47,10 +183,28 @@ export default function RichTextArea({
document.execCommand( document.execCommand(
"insertHTML", "insertHTML",
false, false,
`<img src="${url}" alt="" class="rich-inline-image" />`, `<img src="${url}" alt="" class="rich-inline-image" style="width: min(100%, 420px); height: auto;" />`,
); );
emitChange(); emitChange();
event.target.value = ""; event.target.value = "";
requestAnimationFrame(() => {
const images = editorRef.current?.querySelectorAll("img");
const last = images?.[images.length - 1];
if (last instanceof HTMLImageElement) selectImage(last);
});
}
function startResize(event: ReactPointerEvent<HTMLButtonElement>) {
const img = selectedImgRef.current;
if (!img) return;
event.preventDefault();
event.stopPropagation();
resizeRef.current = {
img,
startX: event.clientX,
startWidth: img.getBoundingClientRect().width,
};
} }
return ( return (
@@ -67,10 +221,89 @@ export default function RichTextArea({
> >
I I
</button> </button>
<button type="button" onClick={() => runCommand("underline")} aria-label="زیرخط"> <button
type="button"
onClick={() => runCommand("underline")}
aria-label="زیرخط"
>
U U
</button> </button>
<span className="rich-text-divider" /> <span className="rich-text-divider" />
<button
type="button"
onClick={() => runCommand("justifyRight")}
aria-label="تراز راست"
title="تراز راست"
>
<AlignRightIcon />
</button>
<button
type="button"
onClick={() => runCommand("justifyCenter")}
aria-label="تراز وسط"
title="تراز وسط"
>
<AlignCenterIcon />
</button>
<button
type="button"
onClick={() => runCommand("justifyLeft")}
aria-label="تراز چپ"
title="تراز چپ"
>
<AlignLeftIcon />
</button>
<button
type="button"
onClick={() => runCommand("justifyFull")}
aria-label="تراز دوطرفه"
title="تراز دوطرفه"
>
<AlignJustifyIcon />
</button>
<span className="rich-text-divider" />
<button
type="button"
onClick={() => applyDirection("rtl")}
aria-label="راست به چپ"
title="راست به چپ"
>
RTL
</button>
<button
type="button"
onClick={() => applyDirection("ltr")}
aria-label="چپ به راست"
title="چپ به راست"
>
LTR
</button>
<span className="rich-text-divider" />
<button
type="button"
onClick={() => runCommand("insertUnorderedList")}
aria-label="لیست نقطه‌ای"
title="لیست نقطه‌ای"
>
<BulletListIcon />
</button>
<button
type="button"
onClick={() => runCommand("insertOrderedList")}
aria-label="لیست شماره‌دار"
title="لیست شماره‌دار"
>
<NumberListIcon />
</button>
<span className="rich-text-divider" />
<button <button
type="button" type="button"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
@@ -86,6 +319,8 @@ export default function RichTextArea({
onChange={insertImage} onChange={insertImage}
/> />
</div> </div>
<div className="rich-text-editor-wrap">
<div <div
ref={editorRef} ref={editorRef}
className="rich-text-editor" className="rich-text-editor"
@@ -94,8 +329,93 @@ export default function RichTextArea({
aria-multiline="true" aria-multiline="true"
data-placeholder={placeholder} data-placeholder={placeholder}
onInput={emitChange} onInput={emitChange}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.tagName === "IMG") {
selectImage(target as HTMLImageElement);
} else {
clearImageSelection();
}
}}
onKeyDown={() => {
if (selectedImgRef.current) clearImageSelection();
}}
suppressContentEditableWarning suppressContentEditableWarning
/> />
{selectedImg && handlePos ? (
<button
type="button"
className="rich-image-handle"
aria-label="تغییر اندازه تصویر"
style={{ top: handlePos.top, left: handlePos.left }}
onPointerDown={startResize}
/>
) : null}
</div>
</div> </div>
); );
} }
function iconStroke() {
return {
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
fill: "none",
};
}
function AlignRightIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path {...iconStroke()} d="M4 6h16M8 12h12M4 18h16" />
</svg>
);
}
function AlignCenterIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path {...iconStroke()} d="M4 6h16M7 12h10M4 18h16" />
</svg>
);
}
function AlignLeftIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path {...iconStroke()} d="M4 6h16M4 12h12M4 18h16" />
</svg>
);
}
function AlignJustifyIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path {...iconStroke()} d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
}
function BulletListIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<circle cx="5" cy="7" r="1.2" fill="currentColor" />
<circle cx="5" cy="12" r="1.2" fill="currentColor" />
<circle cx="5" cy="17" r="1.2" fill="currentColor" />
<path {...iconStroke()} d="M9 7h11M9 12h11M9 17h11" />
</svg>
);
}
function NumberListIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path
{...iconStroke()}
d="M5 6.5V5h2v5M5 16.5h3M5 14.5h2v4M9 7h11M9 12h11M9 17h11"
/>
</svg>
);
}
+2
View File
@@ -6,6 +6,8 @@ export type BlogPost = {
abstract: string; abstract: string;
body: string; body: string;
image: string; image: string;
categoryId?: string;
categoryName?: string;
tags: string[]; tags: string[];
metaTitle: string; metaTitle: string;
metaDescription: string; metaDescription: string;
+56
View File
@@ -1,5 +1,15 @@
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { BlogPost } from "@/data/blog"; import type { BlogPost } from "@/data/blog";
import type { CategoryNode } from "@/data/clothes";
export type ApiBlogCategory = {
id: string;
parentId: string | null;
nameFa: string;
nameEn: string;
sortOrder: number;
children?: ApiBlogCategory[];
};
export type ApiBlog = { export type ApiBlog = {
id: string; id: string;
@@ -7,6 +17,8 @@ export type ApiBlog = {
summary: string; summary: string;
body: string; body: string;
author: string; author: string;
categoryId?: string | null;
category?: { id: string; nameFa: string; nameEn: string } | null;
imageUrl: string | null; imageUrl: string | null;
imageKey?: string | null; imageKey?: string | null;
published: boolean; published: boolean;
@@ -27,6 +39,8 @@ export function mapBlog(row: ApiBlog): BlogPost {
abstract: row.summary || "", abstract: row.summary || "",
body: row.body || "", body: row.body || "",
image: row.imageUrl || "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg", image: row.imageUrl || "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
categoryId: row.categoryId ?? row.category?.id ?? "",
categoryName: row.category?.nameFa || "",
tags: [], tags: [],
metaTitle: row.metaTitle || "", metaTitle: row.metaTitle || "",
metaDescription: row.metaDescription || "", metaDescription: row.metaDescription || "",
@@ -34,6 +48,15 @@ export function mapBlog(row: ApiBlog): BlogPost {
}; };
} }
function mapBlogCategoryNode(node: ApiBlogCategory): CategoryNode {
return {
id: node.id,
nameFa: node.nameFa,
nameEn: node.nameEn ?? "",
children: (node.children ?? []).map(mapBlogCategoryNode),
};
}
export async function fetchBlogs() { export async function fetchBlogs() {
const rows = await api<ApiBlog[]>("/blogs"); const rows = await api<ApiBlog[]>("/blogs");
return rows.map(mapBlog); return rows.map(mapBlog);
@@ -57,3 +80,36 @@ export async function updateBlog(id: string, body: Record<string, unknown>) {
export async function deleteBlog(id: string) { export async function deleteBlog(id: string) {
await api(`/blogs/${id}`, { method: "DELETE" }); await api(`/blogs/${id}`, { method: "DELETE" });
} }
export async function fetchBlogCategoriesTree(): Promise<CategoryNode[]> {
const rows = await api<ApiBlogCategory[]>("/blog-categories");
return rows.map(mapBlogCategoryNode);
}
export async function createBlogCategory(body: {
nameFa: string;
nameEn?: string;
parentId?: string;
sortOrder?: number;
}) {
await api("/blog-categories", { method: "POST", body });
return fetchBlogCategoriesTree();
}
export async function updateBlogCategory(
id: string,
body: {
nameFa?: string;
nameEn?: string;
parentId?: string | null;
sortOrder?: number;
},
) {
await api(`/blog-categories/${id}`, { method: "PATCH", body });
return fetchBlogCategoriesTree();
}
export async function deleteBlogCategory(id: string) {
await api(`/blog-categories/${id}`, { method: "DELETE" });
return fetchBlogCategoriesTree();
}