Files
dashboards/src/app/blog/page.tsx
T

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>
</>
);
}