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 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
@@ -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="متا تگها"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user