Initial commit: Mana Collection dashboards (Next.js).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
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 { 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 [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="مطلب جدید"
|
||||
>
|
||||
{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"
|
||||
style={{ animationDelay: `${70 + i * 45}ms` }}
|
||||
>
|
||||
<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.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">
|
||||
<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>
|
||||
|
||||
<FormModal
|
||||
open={Boolean(metaPost)}
|
||||
title="متا تگها"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user