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:
co-authored by
Cursor
parent
8008ea4db1
commit
27d5cd0186
@@ -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
@@ -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
@@ -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="متا تگها"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export type BlogPost = {
|
||||
abstract: string;
|
||||
body: string;
|
||||
image: string;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
tags: string[];
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user