469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
"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";
|
|
import IconButton, {
|
|
ClearFilterIcon,
|
|
EditIcon,
|
|
FilterIcon,
|
|
MetaTagsIcon,
|
|
TrashIcon,
|
|
} from "@/components/ui/IconButton";
|
|
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";
|
|
import { deleteBlog, fetchBlogs, updateBlog } from "@/lib/blog-api";
|
|
import { useConfirm } from "@/components/ui/ConfirmDialog";
|
|
|
|
const PAGE_SIZE = 3;
|
|
|
|
type Filters = {
|
|
name: string;
|
|
dateFrom: string;
|
|
dateTo: string;
|
|
};
|
|
|
|
const emptyFilters: Filters = {
|
|
name: "",
|
|
dateFrom: "",
|
|
dateTo: "",
|
|
};
|
|
|
|
export default function BlogListPage() {
|
|
const router = useRouter();
|
|
const confirmDialog = useConfirm();
|
|
const [items, setItems] = useState<BlogPost[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
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("");
|
|
const [metaKeywords, setMetaKeywords] = useState("");
|
|
const [savingMeta, setSavingMeta] = useState(false);
|
|
|
|
const reload = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setItems(await fetchBlogs());
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof ApiError ? err.message : "بارگذاری بلاگ ناموفق بود.",
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void reload();
|
|
}, [reload]);
|
|
|
|
const list = items.filter((post) => {
|
|
const q = applied.name.trim();
|
|
const matchesName =
|
|
!q ||
|
|
post.title.includes(q) ||
|
|
post.author.includes(q) ||
|
|
post.abstract.includes(q);
|
|
|
|
const fromOk = !applied.dateFrom || post.date >= applied.dateFrom;
|
|
const toOk = !applied.dateTo || post.date <= applied.dateTo;
|
|
|
|
return matchesName && fromOk && toOk;
|
|
});
|
|
|
|
const { page, setPage, pageItems, totalPages, totalItems, pageSize } =
|
|
usePagination(list, PAGE_SIZE, JSON.stringify(applied));
|
|
|
|
function openMeta(post: BlogPost) {
|
|
setMetaPost(post);
|
|
setMetaTitle(post.metaTitle);
|
|
setMetaDescription(post.metaDescription);
|
|
setMetaKeywords(post.metaKeywords);
|
|
}
|
|
|
|
function closeMeta() {
|
|
setMetaPost(null);
|
|
}
|
|
|
|
async function saveMeta(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!metaPost) return;
|
|
setSavingMeta(true);
|
|
setError(null);
|
|
try {
|
|
const updated = await updateBlog(metaPost.id, {
|
|
metaTitle: metaTitle.trim() || null,
|
|
metaDescription: metaDescription.trim() || null,
|
|
});
|
|
setItems((prev) =>
|
|
prev.map((post) =>
|
|
post.id === metaPost.id
|
|
? { ...updated, metaKeywords: metaKeywords.trim() }
|
|
: post,
|
|
),
|
|
);
|
|
closeMeta();
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof ApiError ? err.message : "ذخیره متا ناموفق بود.",
|
|
);
|
|
} finally {
|
|
setSavingMeta(false);
|
|
}
|
|
}
|
|
|
|
async function removePost(id: string) {
|
|
if (!(await confirmDialog("این مطلب حذف شود؟"))) return;
|
|
setError(null);
|
|
try {
|
|
await deleteBlog(id);
|
|
setItems((prev) => prev.filter((post) => post.id !== id));
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof ApiError ? err.message : "حذف مطلب ناموفق بود.",
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ClothesShell
|
|
eyebrow="Blog"
|
|
title="بلاگ"
|
|
description="مطالب بلاگ را فیلتر کنید و متا تگها را مدیریت کنید."
|
|
breadcrumbs={[
|
|
{ label: "داشبورد", href: "/" },
|
|
{ label: "بلاگ" },
|
|
]}
|
|
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}
|
|
</p>
|
|
) : null}
|
|
|
|
<section className="clothes-filter-bar mb-2 dash-enter">
|
|
<div className="clothes-field">
|
|
<label htmlFor="filter-blog-name">نام</label>
|
|
<input
|
|
id="filter-blog-name"
|
|
value={draft.name}
|
|
onChange={(e) =>
|
|
setDraft((prev) => ({ ...prev, name: e.target.value }))
|
|
}
|
|
placeholder="عنوان، نویسنده..."
|
|
/>
|
|
</div>
|
|
<div className="clothes-field">
|
|
<label htmlFor="filter-blog-from">از تاریخ</label>
|
|
<input
|
|
id="filter-blog-from"
|
|
type="date"
|
|
dir="ltr"
|
|
value={draft.dateFrom}
|
|
onChange={(e) =>
|
|
setDraft((prev) => ({ ...prev, dateFrom: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="clothes-field">
|
|
<label htmlFor="filter-blog-to">تا تاریخ</label>
|
|
<input
|
|
id="filter-blog-to"
|
|
type="date"
|
|
dir="ltr"
|
|
value={draft.dateTo}
|
|
onChange={(e) =>
|
|
setDraft((prev) => ({ ...prev, dateTo: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="clothes-filter-actions">
|
|
<IconButton
|
|
label="اعمال فیلتر"
|
|
variant="solid"
|
|
onClick={() => setApplied(draft)}
|
|
>
|
|
<FilterIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
label="حذف فیلتر"
|
|
variant="line"
|
|
onClick={() => {
|
|
setDraft(emptyFilters);
|
|
setApplied(emptyFilters);
|
|
}}
|
|
>
|
|
<ClearFilterIcon />
|
|
</IconButton>
|
|
</div>
|
|
</section>
|
|
|
|
{loading ? (
|
|
<p className="text-sm text-muted">در حال بارگذاری...</p>
|
|
) : (
|
|
<>
|
|
<div className="space-y-1">
|
|
{pageItems.map((post, i) => (
|
|
<article
|
|
key={post.id}
|
|
className="blog-card dash-enter cursor-pointer"
|
|
style={{ animationDelay: `${70 + i * 45}ms` }}
|
|
onClick={() => setDetailPost(post)}
|
|
>
|
|
<div className="blog-card-image">
|
|
<Image
|
|
src={post.image}
|
|
alt={post.title}
|
|
fill
|
|
unoptimized
|
|
sizes="180px"
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
<h2 className="text-base text-ink sm:text-lg">
|
|
{post.title}
|
|
</h2>
|
|
<time
|
|
dateTime={post.date}
|
|
dir="ltr"
|
|
className="text-xs tabular-nums text-muted"
|
|
>
|
|
{formatBlogDate(post.date)}
|
|
</time>
|
|
</div>
|
|
<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>
|
|
{post.tags.length ? (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{post.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="tag-chip !rounded-md !py-0.5"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div
|
|
className="tree-row-actions shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<IconButton
|
|
label="ویرایش"
|
|
variant="line"
|
|
onClick={() => router.push(`/blog/new?edit=${post.id}`)}
|
|
>
|
|
<EditIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
label="متا تگ"
|
|
variant="line"
|
|
onClick={() => openMeta(post)}
|
|
>
|
|
<MetaTagsIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
label="حذف"
|
|
variant="line"
|
|
onClick={() => void removePost(post.id)}
|
|
>
|
|
<TrashIcon />
|
|
</IconButton>
|
|
</div>
|
|
</article>
|
|
))}
|
|
|
|
{list.length === 0 ? (
|
|
<p className="rounded-lg border border-line bg-surface px-4 py-8 text-center text-sm text-muted">
|
|
مطلبی با این فیلترها پیدا نشد.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{list.length > 0 ? (
|
|
<Pagination
|
|
page={page}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
pageSize={pageSize}
|
|
onChange={setPage}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</ClothesShell>
|
|
|
|
<Modal
|
|
open={Boolean(detailPost)}
|
|
title="جزئیات مطلب"
|
|
subtitle={detailPost?.title || "Post details"}
|
|
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="متا تگها"
|
|
subtitle="SEO title and description for this post"
|
|
onClose={closeMeta}
|
|
onSubmit={saveMeta}
|
|
submitLabel={savingMeta ? "..." : "ذخیره متا"}
|
|
>
|
|
<div className="clothes-field">
|
|
<label htmlFor="meta-title">Meta Title</label>
|
|
<input
|
|
id="meta-title"
|
|
dir="ltr"
|
|
className="!text-end"
|
|
value={metaTitle}
|
|
onChange={(e) => setMetaTitle(e.target.value)}
|
|
placeholder="SEO title"
|
|
/>
|
|
</div>
|
|
<div className="clothes-field">
|
|
<label htmlFor="meta-desc">Meta Description</label>
|
|
<textarea
|
|
id="meta-desc"
|
|
dir="ltr"
|
|
className="!min-h-[5rem] !text-end"
|
|
value={metaDescription}
|
|
onChange={(e) => setMetaDescription(e.target.value)}
|
|
placeholder="SEO description"
|
|
/>
|
|
</div>
|
|
<div className="clothes-field">
|
|
<label htmlFor="meta-keys">Meta Keywords</label>
|
|
<input
|
|
id="meta-keys"
|
|
dir="ltr"
|
|
className="!text-end"
|
|
value={metaKeywords}
|
|
onChange={(e) => setMetaKeywords(e.target.value)}
|
|
placeholder="keyword, keyword"
|
|
/>
|
|
</div>
|
|
</FormModal>
|
|
</>
|
|
);
|
|
}
|