From 27d5cd0186f22505ac433b52220392889e4f10a4 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 9 Sep 2026 12:28:36 +0330 Subject: [PATCH] Improve blog editor, details modal, and header display of first and last name. Co-authored-by: Cursor --- src/app/blog/categories/page.tsx | 326 +++++++++++++++++ src/app/blog/new/page.tsx | 75 ++-- src/app/blog/page.tsx | 120 ++++++- src/app/globals.css | 86 +++++ src/components/dashboard/DashboardHeader.tsx | 11 +- src/components/ui/RichTextArea.tsx | 348 ++++++++++++++++++- src/data/blog.ts | 2 + src/lib/blog-api.ts | 56 +++ 8 files changed, 979 insertions(+), 45 deletions(-) create mode 100644 src/app/blog/categories/page.tsx diff --git a/src/app/blog/categories/page.tsx b/src/app/blog/categories/page.tsx new file mode 100644 index 0000000..514e618 --- /dev/null +++ b/src/app/blog/categories/page.tsx @@ -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([]); + const [open, setOpen] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const [modalOpen, setModalOpen] = useState(false); + const [editingId, setEditingId] = useState(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) { + 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 ( + <> + openCreate()} + plusLabel="دسته جدید" + > + {error ? ( +

+ {error} +

+ ) : null} + {loading ? ( +

در حال بارگذاری...

+ ) : ( +
+ {tree.length === 0 ? ( +

+ هنوز دسته‌ای ثبت نشده است. +

+ ) : null} + {tree.map((node, i) => ( + openCreate(id)} + onDelete={handleDelete} + delay={80 + i * 60} + /> + ))} +
+ )} +
+ + + {!editingId ? ( +
+ + +
+ ) : null} +
+ + setNameFa(e.target.value)} + placeholder="مثلاً سبک زندگی" + required + /> +
+
+ + setNameEn(e.target.value)} + placeholder="Lifestyle" + /> +
+
+ + ); +} + +function CategoryBranch({ + node, + depth, + number, + open, + onToggle, + onEdit, + onAddChild, + onDelete, + delay, +}: { + node: CategoryNode; + depth: number; + number: string; + open: Record; + 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 ( +
+
+
+ {hasChildren ? ( + + ) : ( + + )} +
+

+ + {number} + + {node.nameFa} +

+ {node.nameEn ? ( +

+ {node.nameEn} +

+ ) : null} +
+
+
+ onAddChild(node.id)}> + + + onEdit(node)}> + + + void onDelete(node.id)} + > + + +
+
+ {hasChildren && isOpen + ? node.children!.map((child, i) => ( + + )) + : null} +
+ ); +} diff --git a/src/app/blog/new/page.tsx b/src/app/blog/new/page.tsx index 7b4397d..a579770 100644 --- a/src/app/blog/new/page.tsx +++ b/src/app/blog/new/page.tsx @@ -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(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([]); 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() {
- - setDate(e.target.value)} - /> -
-
- - setAuthor(e.target.value)} - placeholder="نام نویسنده" + +
@@ -367,7 +390,9 @@ function NewBlogPostForm() { export default function NewBlogPostPage() { return ( - در حال بارگذاری...

}> + در حال بارگذاری...

} + >
); diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 47d3d99..50845c4 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -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(null); const [draft, setDraft] = useState(emptyFilters); const [applied, setApplied] = useState(emptyFilters); + const [detailPost, setDetailPost] = useState(null); const [metaPost, setMetaPost] = useState(null); const [metaTitle, setMetaTitle] = useState(""); const [metaDescription, setMetaDescription] = useState(""); @@ -145,6 +147,14 @@ export default function BlogListPage() { plusHref="/blog/new" plusLabel="مطلب جدید" > +
+ + مدیریت دسته‌بندی‌ها + +
{error ? (

{error} @@ -217,8 +227,9 @@ export default function BlogListPage() { {pageItems.map((post, i) => (

setDetailPost(post)} >
-

{post.author}

+

+ {post.categoryName + ? `${post.categoryName} · ${post.author}` + : post.author} +

{post.abstract}

@@ -262,7 +277,10 @@ export default function BlogListPage() { ) : null}
-
+
e.stopPropagation()} + > + setDetailPost(null)} + wide="full" + footer={ + + } + > + {detailPost ? ( +
+
+
+ {detailPost.title} +
+
+ +
+
+

+ {detailPost.title} +

+

+ + {detailPost.author ? ` · ${detailPost.author}` : null} +

+
+ +
+
+
دسته‌بندی
+
+ {detailPost.categoryName || "—"} +
+
+
+
نویسنده
+
+ {detailPost.author || "—"} +
+
+
+ + {detailPost.abstract ? ( +
+

خلاصه

+

+ {detailPost.abstract} +

+
+ ) : null} + + {detailPost.body ? ( +
+

متن

+
+
+ ) : null} + + {detailPost.tags.length ? ( +
+

برچسب‌ها

+
+ {detailPost.tags.map((tag) => ( + + {tag} + + ))} +
+
+ ) : null} +
+
+ ) : null} + + - + {displayName} @@ -132,6 +136,9 @@ export default function DashboardHeader({ بلاگ من + + دسته‌بندی بلاگ + سفارش‌های من diff --git a/src/components/ui/RichTextArea.tsx b/src/components/ui/RichTextArea.tsx index 81825e1..a3b5afb 100644 --- a/src/components/ui/RichTextArea.tsx +++ b/src/components/ui/RichTextArea.tsx @@ -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(null); const fileRef = useRef(null); + const selectedImgRef = useRef(null); + const resizeRef = useRef(null); + const [selectedImg, setSelectedImg] = useState(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) { const file = event.target.files?.[0]; if (!file || !editorRef.current) return; @@ -47,10 +183,28 @@ export default function RichTextArea({ document.execCommand( "insertHTML", false, - ``, + ``, ); 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) { + 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 - + + + + + + + + + + + + + + + + + + +
-
+ +
+
{ + const target = event.target as HTMLElement; + if (target.tagName === "IMG") { + selectImage(target as HTMLImageElement); + } else { + clearImageSelection(); + } + }} + onKeyDown={() => { + if (selectedImgRef.current) clearImageSelection(); + }} + suppressContentEditableWarning + /> + + {selectedImg && handlePos ? ( +
); } + +function iconStroke() { + return { + stroke: "currentColor", + strokeWidth: 1.5, + strokeLinecap: "round" as const, + fill: "none", + }; +} + +function AlignRightIcon() { + return ( + + + + ); +} + +function AlignCenterIcon() { + return ( + + + + ); +} + +function AlignLeftIcon() { + return ( + + + + ); +} + +function AlignJustifyIcon() { + return ( + + + + ); +} + +function BulletListIcon() { + return ( + + + + + + + ); +} + +function NumberListIcon() { + return ( + + + + ); +} diff --git a/src/data/blog.ts b/src/data/blog.ts index 8184185..e1d84e0 100644 --- a/src/data/blog.ts +++ b/src/data/blog.ts @@ -6,6 +6,8 @@ export type BlogPost = { abstract: string; body: string; image: string; + categoryId?: string; + categoryName?: string; tags: string[]; metaTitle: string; metaDescription: string; diff --git a/src/lib/blog-api.ts b/src/lib/blog-api.ts index 025873b..4f3a57b 100644 --- a/src/lib/blog-api.ts +++ b/src/lib/blog-api.ts @@ -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("/blogs"); return rows.map(mapBlog); @@ -57,3 +80,36 @@ export async function updateBlog(id: string, body: Record) { export async function deleteBlog(id: string) { await api(`/blogs/${id}`, { method: "DELETE" }); } + +export async function fetchBlogCategoriesTree(): Promise { + const rows = await api("/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(); +}