trptk/app/blog/(archive)/page.tsx
2026-02-24 17:14:07 +01:00

175 lines
4.9 KiB
TypeScript

import type { Metadata } from "next";
import { defineQuery } from "next-sanity";
import { sanity } from "@/lib/sanity";
import { BlogCard, type BlogCardData } from "@/components/blog/BlogCard";
import { AnimatedText } from "@/components/AnimatedText";
import { SearchBar } from "@/components/SearchBar";
import { PaginationNav } from "@/components/PaginationNav";
import { CARD_GRID_CLASSES_4 } from "@/lib/constants";
import { PAGE_SIZE, clampInt, normalizeQuery, groqLikeParam } from "@/lib/listHelpers";
import type { SortOption } from "@/components/SortDropdown";
import type { FilterGroup } from "@/components/FilterDropdown";
export const revalidate = 86400;
type SortMode = "dateDesc" | "dateAsc";
const CATEGORY_OPTIONS = [
{ value: "News", label: "News" },
{ value: "Behind the Scenes", label: "Behind the Scenes" },
{ value: "Music History", label: "Music History" },
{ value: "Tech Talk", label: "Tech Talk" },
];
const FILTER_GROUPS: FilterGroup[] = [
{ label: "Category", param: "category", options: CATEGORY_OPTIONS },
];
const VALID_CATEGORIES = new Set(CATEGORY_OPTIONS.map((o) => o.value));
function parseFilterParam(raw: string | undefined, valid: Set<string>): string[] {
if (!raw) return [];
return raw.split(",").filter((v) => valid.has(v));
}
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "dateDesc", label: "Date", iconDirection: "desc" },
{ value: "dateAsc", label: "Date", iconDirection: "asc" },
];
function normalizeSort(s: string | undefined): SortMode {
return s === "dateAsc" ? "dateAsc" : "dateDesc";
}
const BLOG_FILTER_CLAUSE = `
_type == "blog" &&
(
$q == "" ||
title match $qPattern ||
subtitle match $qPattern ||
author match $qPattern
) &&
(count($categories) == 0 || category in $categories)
`;
const BLOG_PROJECTION = `{
_id,
title,
subtitle,
author,
publishDate,
category,
"slug": slug.current,
featuredImage
}`;
const BLOGS_BY_DATE_DESC_QUERY = defineQuery(`
*[
${BLOG_FILTER_CLAUSE}
]
| order(coalesce(publishDate, "0000-01-01") desc, lower(title) asc)
[$start...$end]${BLOG_PROJECTION}
`);
const BLOGS_BY_DATE_ASC_QUERY = defineQuery(`
*[
${BLOG_FILTER_CLAUSE}
]
| order(coalesce(publishDate, "9999-12-31") asc, lower(title) asc)
[$start...$end]${BLOG_PROJECTION}
`);
const BLOGS_COUNT_QUERY = defineQuery(`
count(*[
${BLOG_FILTER_CLAUSE}
])
`);
export const metadata: Metadata = {
title: "Blog",
description:
"News, behind-the-scenes stories, and insights from TRPTK.",
};
export default async function BlogArchivePage({
searchParams,
}: {
searchParams: Promise<{
page?: string;
q?: string;
sort?: string;
category?: string;
}>;
}) {
const sp = await searchParams;
const q = normalizeQuery(sp.q);
const sort = normalizeSort(sp.sort);
const page = clampInt(sp.page, 1, 1, 9999);
const categories = parseFilterParam(sp.category, VALID_CATEGORIES);
const start = (page - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const qPattern = q ? `${groqLikeParam(q)}*` : "";
const listQuery = sort === "dateAsc" ? BLOGS_BY_DATE_ASC_QUERY : BLOGS_BY_DATE_DESC_QUERY;
const queryParams = { start, end, q, qPattern, categories };
const [blogs, total] = await Promise.all([
sanity.fetch<BlogCardData[]>(listQuery, queryParams),
sanity.fetch<number>(BLOGS_COUNT_QUERY, queryParams),
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const initialFilters: Record<string, string[]> = {};
if (categories.length) initialFilters.category = categories;
const buildHref = (nextPage: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort !== "dateDesc") params.set("sort", sort);
if (categories.length) params.set("category", categories.join(","));
if (nextPage > 1) params.set("page", String(nextPage));
const qs = params.toString();
return qs ? `/blog?${qs}` : "/blog";
};
return (
<main className="mx-auto my-auto max-w-300 px-6 py-12 font-silka md:px-8 md:py-16 2xl:max-w-400">
<div className="mb-10">
<AnimatedText text="Blog" as="h1" className="font-argesta text-3xl" />
<p className="mt-2 text-base text-lightsec dark:text-darksec">
{total ? `${total} post(s)` : "No posts found."}
</p>
</div>
<div className="mb-12">
<SearchBar
initialQuery={q}
initialSort={sort}
initialPage={safePage}
defaultSort="dateDesc"
placeholder="Search posts…"
sortOptions={SORT_OPTIONS}
sortAriaLabel="Sort posts"
filterGroups={FILTER_GROUPS}
initialFilters={initialFilters}
/>
</div>
<section className={CARD_GRID_CLASSES_4}>
{blogs.map((blog) => (
<BlogCard key={blog._id} blog={blog} />
))}
</section>
{totalPages > 1 ? (
<PaginationNav page={safePage} totalPages={totalPages} buildHref={buildHref} />
) : null}
</main>
);
}