From e89fcfb20e57d2ef2569331bc58fabf6f3c2922a Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 11 Jun 2026 21:21:43 +0330 Subject: [PATCH] feat(blog): add rich listing filters --- src/app/blog/page.tsx | 28 ++- src/lib/public-api.ts | 28 ++- src/lib/types.ts | 71 +++++++ src/views/Blog.tsx | 471 +++++++++++++++++++++++++++++++++++------- 4 files changed, 524 insertions(+), 74 deletions(-) diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 30cd99f..594400b 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import Blog from "@/views/Blog"; -import { getPublicPosts } from "@/lib/public-api"; +import { getBlogBanners, getBlogFilters, getPublicPosts } from "@/lib/public-api"; import { siteUrl } from "@/lib/site"; type SearchParams = Promise>; @@ -9,6 +9,11 @@ function firstString(value?: string | string[]) { return Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); } +function stringList(value?: string | string[]) { + const values = Array.isArray(value) ? value : value ? [value] : []; + return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean); +} + export async function generateMetadata({ searchParams, }: { @@ -45,7 +50,24 @@ export default async function BlogPage({ }) { const resolved = await searchParams; const search = firstString(resolved.search).trim(); - const posts = await getPublicPosts({ search: search || undefined }); + const category = firstString(resolved.category).trim(); + const tags = stringList(resolved.tag); + const authors = stringList(resolved.author); + const [posts, banners, filters] = await Promise.all([ + getPublicPosts({ search: search || undefined, category: category || undefined, tag: tags, author: authors }), + getBlogBanners().catch(() => []), + getBlogFilters().catch(() => ({ categories: [], tags: [], authors: [] })), + ]); - return ; + return ( + + ); } diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts index 60570ee..4792474 100644 --- a/src/lib/public-api.ts +++ b/src/lib/public-api.ts @@ -71,15 +71,39 @@ async function requestJson( return (await response.json()) as T; } -export async function getPublicPosts(options?: { search?: string; limit?: number }) { +export async function getPublicPosts(options?: { + search?: string; + category?: string; + tag?: string[]; + author?: string[]; + limit?: number; +}) { const search = options?.search?.trim(); + const category = options?.category?.trim(); + const tag = options?.tag?.filter(Boolean) ?? []; + const author = options?.author?.filter(Boolean) ?? []; return requestJson("/api/blog/posts", { params: { limit: options?.limit ?? 50, ...(search ? { search } : {}), + ...(category ? { category } : {}), + ...(tag.length ? { tag } : {}), + ...(author.length ? { author } : {}), }, - revalidate: search ? 60 : DEFAULT_REVALIDATE_SECONDS, + revalidate: search || category || tag.length || author.length ? 60 : DEFAULT_REVALIDATE_SECONDS, + }); +} + +export async function getBlogFilters() { + return requestJson("/api/blog/filters", { + revalidate: DEFAULT_REVALIDATE_SECONDS, + }); +} + +export async function getBlogBanners() { + return requestJson("/api/blog/banners", { + revalidate: DEFAULT_REVALIDATE_SECONDS, }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 4d91b81..cfbb0ad 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -229,6 +229,7 @@ export interface PostListSchema { username: string; first_name: string; last_name: string; + bio?: string | null; profile_picture?: string; profile_picture_thumbnail_url?: string | null; profile_picture_preview_url?: string | null; @@ -238,7 +239,23 @@ export interface PostListSchema { name: string; slug: string; description?: string; + parent_id?: number | null; }; + category_path?: Array<{ + id: number; + name: string; + slug: string; + }>; + writers?: Array<{ + id: number; + username: string; + first_name: string; + last_name: string; + bio?: string | null; + profile_picture?: string; + profile_picture_thumbnail_url?: string | null; + profile_picture_preview_url?: string | null; + }>; tags: Array<{ id: number; name: string; @@ -276,6 +293,7 @@ export interface PostCreateSchema { excerpt?: string; category_id?: number | null; tag_ids?: number[]; + writer_ids?: number[]; is_featured?: boolean; status?: 'draft' | 'submitted' | 'changes_requested' | 'published' | 'archived'; seo_title?: string; @@ -323,14 +341,23 @@ export interface CommentSchema { username: string; first_name: string; last_name: string; + bio?: string | null; + profile_picture?: string | null; + profile_picture_thumbnail_url?: string | null; + profile_picture_preview_url?: string | null; }; post_id: number; post_title: string; post_slug: string; parent_id?: number; created_at: string; + updated_at?: string; is_approved: boolean; + is_hidden?: boolean; + is_deleted?: boolean; hidden_at?: string | null; + deleted_at?: string | null; + hidden_replies_count?: number; replies?: CommentSchema[]; } @@ -339,6 +366,19 @@ export interface CommentCreateSchema { parent_id?: number; } +export interface CommentUpdateSchema { + content: string; +} + +export interface BlogBannerSchema { + id: number; + title?: string; + alt_text?: string; + image_url: string; + url: string; + sort_order: number; +} + export interface BlogInteractionSchema { liked: boolean; saved: boolean; @@ -359,6 +399,7 @@ export interface CategorySchema { name: string; slug: string; description?: string; + parent_id?: number | null; created_at: string; } @@ -369,6 +410,36 @@ export interface TagSchema { created_at: string; } +export interface BlogFilterCategory { + id: number; + name: string; + slug: string; + parent_id?: number | null; + post_count: number; + children: BlogFilterCategory[]; +} + +export interface BlogFilterTag { + id: number; + name: string; + slug: string; + post_count: number; +} + +export interface BlogFilterAuthor { + id: number; + username: string; + first_name: string; + last_name: string; + post_count: number; +} + +export interface BlogFiltersSchema { + categories: BlogFilterCategory[]; + tags: BlogFilterTag[]; + authors: BlogFilterAuthor[]; +} + // Events Types export interface EventListItemSchema { id: number; diff --git a/src/views/Blog.tsx b/src/views/Blog.tsx index c853446..5d668ca 100644 --- a/src/views/Blog.tsx +++ b/src/views/Blog.tsx @@ -1,69 +1,350 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; +import { ChevronDown, ChevronLeft, ChevronRight, Filter, UserRound, X } from "lucide-react"; import BlogThumbnail from "@/components/BlogThumbnail"; +import { BlogCardsSkeleton } from "@/components/page-loading"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; import { Input } from "@/components/ui/input"; import { Link, useLocation, useNavigate } from "@/lib/router"; -import { api } from "@/lib/api"; import { blogPostPath } from "@/lib/blog-routes"; import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site"; import type * as Types from "@/lib/types"; -import { formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils"; +import { cn, formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils"; type BlogProps = { initialPosts?: Types.PostListSchema[]; initialSearch?: string; + initialCategory?: string; + initialTags?: string[]; + initialAuthors?: string[]; + banners?: Types.BlogBannerSchema[]; + filters?: Types.BlogFiltersSchema; }; +function buildBlogPath( + pathname: string, + search: string, + category: string, + tags: string[], + authors: string[], +) { + const params = new URLSearchParams(); + if (category.trim()) params.set("category", category.trim()); + tags.forEach((tag) => params.append("tag", tag)); + authors.forEach((author) => params.append("author", author)); + if (search.trim()) params.set("search", search.trim()); + return params.size ? `${pathname}?${params.toString()}` : pathname; +} + +function BlogBannerSlider({ banners }: { banners: Types.BlogBannerSchema[] }) { + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + if (banners.length <= 1) return; + const timer = window.setInterval(() => { + setActiveIndex((index) => (index + 1) % banners.length); + }, 6000); + return () => window.clearInterval(timer); + }, [banners.length]); + + if (!banners.length) return null; + + const activeBanner = banners[activeIndex] ?? banners[0]; + const goToPrevious = () => setActiveIndex((index) => (index - 1 + banners.length) % banners.length); + const goToNext = () => setActiveIndex((index) => (index + 1) % banners.length); + + return ( +
+ + {activeBanner.alt_text + + {banners.length > 1 ? ( +
+
+ {banners.map((banner, index) => ( +
+
+ + +
+
+ ) : null} +
+ ); +} + export default function Blog({ initialPosts = [], initialSearch = "", + initialCategory = "", + initialTags = [], + initialAuthors = [], + banners = [], + filters = { categories: [], tags: [], authors: [] }, }: BlogProps) { const navigate = useNavigate(); const location = useLocation(); - const [posts, setPosts] = useState(initialPosts); - const [search, setSearch] = useState(initialSearch); - const [loading, setLoading] = useState(!initialPosts.length && !initialSearch); + const pathname = location.pathname || "/blog"; + const posts = initialPosts; + const [searchDraft, setSearchDraft] = useState(initialSearch); + const [selectedCategory, setSelectedCategory] = useState(initialCategory); + const [selectedTags, setSelectedTags] = useState(initialTags); + const [selectedAuthors, setSelectedAuthors] = useState(initialAuthors); + const [expandedCategories, setExpandedCategories] = useState>( + () => new Set(initialCategory ? [initialCategory] : []), + ); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + const [listPending, setListPending] = useState(false); useEffect(() => { - setPosts(initialPosts); - }, [initialPosts]); - - useEffect(() => { - setSearch(initialSearch); + setSearchDraft(initialSearch); + setListPending(false); }, [initialSearch]); - const loadPosts = useCallback(async () => { - try { - setLoading(true); - const data = await api.getPosts({ search: search || undefined }); - setPosts(data); - } catch (error) { - console.error("Error loading posts:", error); - } finally { - setLoading(false); + useEffect(() => { + setSelectedCategory(initialCategory); + if (initialCategory) { + setExpandedCategories((current) => new Set([...current, initialCategory])); } - }, [search]); + setListPending(false); + }, [initialCategory]); useEffect(() => { - loadPosts(); - }, [loadPosts]); + setSelectedTags(initialTags); + setListPending(false); + }, [initialTags]); useEffect(() => { - const params = new URLSearchParams(); - if (search.trim()) { - params.set("search", search.trim()); - } - const basePath = location.pathname || "/blog"; - const nextPath = params.size - ? `${basePath}?${params.toString()}` - : basePath; - navigate(nextPath, { replace: true }); - }, [location.pathname, navigate, search]); + setSelectedAuthors(initialAuthors); + setListPending(false); + }, [initialAuthors]); + + useEffect(() => { + const timer = window.setTimeout(() => { + if (searchDraft.trim() !== initialSearch.trim()) { + setListPending(true); + navigate( + buildBlogPath(pathname, searchDraft, selectedCategory, selectedTags, selectedAuthors), + { replace: true }, + ); + } + }, 400); + + return () => window.clearTimeout(timer); + }, [initialSearch, navigate, pathname, searchDraft, selectedAuthors, selectedCategory, selectedTags]); + + const navigateFilters = (next: { + search?: string; + category?: string; + tags?: string[]; + authors?: string[]; + }) => { + setListPending(true); + navigate( + buildBlogPath( + pathname, + next.search ?? searchDraft, + next.category ?? selectedCategory, + next.tags ?? selectedTags, + next.authors ?? selectedAuthors, + ), + { replace: true }, + ); + }; + + const selectCategory = (slug: string) => { + const nextCategory = selectedCategory === slug ? "" : slug; + setSelectedCategory(nextCategory); + navigateFilters({ category: nextCategory }); + }; + + const toggleCategoryExpanded = (slug: string) => { + setExpandedCategories((current) => { + const next = new Set(current); + if (next.has(slug)) { + next.delete(slug); + } else { + next.add(slug); + } + return next; + }); + }; + + const toggleTag = (slug: string) => { + const nextTags = selectedTags.includes(slug) + ? selectedTags.filter((item) => item !== slug) + : [...selectedTags, slug]; + setSelectedTags(nextTags); + navigateFilters({ tags: nextTags }); + }; + + const toggleAuthor = (username: string) => { + const nextAuthors = selectedAuthors.includes(username) + ? selectedAuthors.filter((item) => item !== username) + : [...selectedAuthors, username]; + setSelectedAuthors(nextAuthors); + navigateFilters({ authors: nextAuthors }); + }; + + const clearFilters = () => { + setSearchDraft(""); + setSelectedCategory(""); + setSelectedTags([]); + setSelectedAuthors([]); + setListPending(true); + navigate(pathname, { replace: true }); + }; + + const renderCategoryTree = (categories: Types.BlogFilterCategory[], level = 0) => ( +
+ {categories.map((category) => { + const active = selectedCategory === category.slug; + const hasChildren = Boolean(category.children?.length); + const expanded = expandedCategories.has(category.slug); + return ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + +
+ {hasChildren && expanded ? renderCategoryTree(category.children, level + 1) : null} +
+ ); + })} +
+ ); + + const hasActiveFilters = Boolean(searchDraft || selectedCategory || selectedTags.length || selectedAuthors.length); + + const filtersPanel = ( +
+
+
+ +
+

فیلترهای بلاگ

+
+
+ {hasActiveFilters ? ( + + ) : null} +
+
+
+

دسته‌بندی‌ها

+ {filters.categories.length ? renderCategoryTree(filters.categories) : ( +

دسته‌ای برای فیلتر وجود ندارد.

+ )} +
+
+

موضوعات

+
+ {filters.tags.map((tag) => { + const active = selectedTags.includes(tag.slug); + return ( + + ); + })} + {!filters.tags.length ?

موضوعی برای فیلتر وجود ندارد.

: null} +
+
+
+

نویسندگان

+
+ {filters.authors.map((author) => { + const active = selectedAuthors.includes(author.username); + const name = [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username; + return ( + + ); + })} + {!filters.authors.length ?

نویسنده‌ای برای فیلتر وجود ندارد.

: null} +
+
+
+
+ ); return (
+ +

خواندنی‌های انجمن

@@ -72,49 +353,101 @@ export default function Blog({ نوشته‌های آموزشی، تجربه‌های دانشجویی و یادداشت‌های تخصصی اعضای انجمن علمی.

+
setSearch(event.target.value)} - className="max-w-md rounded-full bg-background/80 text-right shadow-sm backdrop-blur" + value={searchDraft} + onChange={(event) => setSearchDraft(event.target.value)} + className="h-12 flex-1 rounded-2xl bg-background/80 text-right shadow-sm backdrop-blur" /> + +
- {loading ? ( -

در حال بارگذاری...

- ) : posts.length === 0 ? ( -

- نوشته‌ای پیدا نشد. -

- ) : ( -
- {posts.map((post) => ( - + + +
- )} + + + فیلترهای بلاگ + + + + + + فیلترهای بلاگ + + انتخاب دسته‌بندی، موضوع و نویسنده برای فیلتر کردن نوشته‌های بلاگ + +
+ {filtersPanel} + + + +
+
+ +
+ +
+ + + {listPending ? ( + + ) : posts.length === 0 ? ( +

+ نوشته‌ای پیدا نشد. +

+ ) : ( +
+ {posts.map((post) => ( + + +
+

+ {post.title} +

+

+ {post.excerpt || post.seo_description || "خلاصه‌ای برای این نوشته ثبت نشده است."} +

+ +
+ + ))} +
+ )} +
);