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 {
+9 -2
View File
@@ -16,7 +16,11 @@ export default function DashboardHeader({
}: DashboardHeaderProps) {
const { user, logout } = useAuth();
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 [userOpen, setUserOpen] = 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"
>
<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}
</span>
<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>
<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>
+334 -14
View File
@@ -2,8 +2,10 @@
import {
ChangeEvent,
PointerEvent as ReactPointerEvent,
useEffect,
useRef,
useState,
} from "react";
type RichTextAreaProps = {
@@ -12,6 +14,12 @@ type RichTextAreaProps = {
placeholder?: string;
};
type ResizeState = {
img: HTMLImageElement;
startX: number;
startWidth: number;
};
export default function RichTextArea({
value,
onChange,
@@ -19,6 +27,12 @@ export default function RichTextArea({
}: RichTextAreaProps) {
const editorRef = useRef<HTMLDivElement>(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(() => {
const el = editorRef.current;
@@ -28,16 +42,138 @@ export default function RichTextArea({
}
}, [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() {
onChange(editorRef.current?.innerHTML ?? "");
}
function runCommand(command: string) {
function runCommand(command: string, valueArg?: string) {
editorRef.current?.focus();
document.execCommand(command, false);
document.execCommand(command, false, valueArg);
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>) {
const file = event.target.files?.[0];
if (!file || !editorRef.current) return;
@@ -47,10 +183,28 @@ export default function RichTextArea({
document.execCommand(
"insertHTML",
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();
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 (
@@ -67,10 +221,89 @@ export default function RichTextArea({
>
I
</button>
<button type="button" onClick={() => runCommand("underline")} aria-label="زیرخط">
<button
type="button"
onClick={() => runCommand("underline")}
aria-label="زیرخط"
>
U
</button>
<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
type="button"
onClick={() => fileRef.current?.click()}
@@ -86,16 +319,103 @@ export default function RichTextArea({
onChange={insertImage}
/>
</div>
<div
ref={editorRef}
className="rich-text-editor"
contentEditable
role="textbox"
aria-multiline="true"
data-placeholder={placeholder}
onInput={emitChange}
suppressContentEditableWarning
/>
<div className="rich-text-editor-wrap">
<div
ref={editorRef}
className="rich-text-editor"
contentEditable
role="textbox"
aria-multiline="true"
data-placeholder={placeholder}
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
/>
{selectedImg && handlePos ? (
<button
type="button"
className="rich-image-handle"
aria-label="تغییر اندازه تصویر"
style={{ top: handlePos.top, left: handlePos.left }}
onPointerDown={startResize}
/>
) : null}
</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;
body: string;
image: string;
categoryId?: string;
categoryName?: string;
tags: string[];
metaTitle: string;
metaDescription: string;
+56
View File
@@ -1,5 +1,15 @@
import { api } from "@/lib/api";
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 = {
id: string;
@@ -7,6 +17,8 @@ export type ApiBlog = {
summary: string;
body: string;
author: string;
categoryId?: string | null;
category?: { id: string; nameFa: string; nameEn: string } | null;
imageUrl: string | null;
imageKey?: string | null;
published: boolean;
@@ -27,6 +39,8 @@ export function mapBlog(row: ApiBlog): BlogPost {
abstract: row.summary || "",
body: row.body || "",
image: row.imageUrl || "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
categoryId: row.categoryId ?? row.category?.id ?? "",
categoryName: row.category?.nameFa || "",
tags: [],
metaTitle: row.metaTitle || "",
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() {
const rows = await api<ApiBlog[]>("/blogs");
return rows.map(mapBlog);
@@ -57,3 +80,36 @@ export async function updateBlog(id: string, body: Record<string, unknown>) {
export async function deleteBlog(id: string) {
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();
}