Openstatus www.openstatus.dev

chore: blog category selector (#1540)

* chore: blog category selector

* fix: all categories

* fix: height

authored by

Maximilian Kaske and committed by
GitHub
e35b42c4 e808de4f

+89 -15
+44
apps/web/src/app/(pages)/(content)/blog/_components/category-select.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + Select, 5 + SelectContent, 6 + SelectItem, 7 + SelectTrigger, 8 + SelectValue, 9 + } from "@openstatus/ui/src/components/select"; 10 + import { allPosts } from "content-collections"; 11 + import { useQueryStates } from "nuqs"; 12 + import { searchParamsParsers } from "../search-params"; 13 + 14 + const categories = new Set(allPosts.map((post) => post.tag)); 15 + 16 + export function CategorySelect() { 17 + const [{ category }, setSearchParams] = useQueryStates(searchParamsParsers, { 18 + shallow: false, 19 + }); 20 + 21 + return ( 22 + <Select 23 + value={category || "all"} 24 + onValueChange={async (e) => { 25 + await setSearchParams({ 26 + category: e === "all" ? null : (e as typeof category), 27 + pageIndex: 0, // Reset to first page when category changes 28 + }); 29 + }} 30 + > 31 + <SelectTrigger className="capitalize h-9"> 32 + <SelectValue placeholder="Select a category" /> 33 + </SelectTrigger> 34 + <SelectContent> 35 + <SelectItem value="all">All Categories</SelectItem> 36 + {Array.from(categories).map((category) => ( 37 + <SelectItem key={category} value={category} className="capitalize"> 38 + {category} 39 + </SelectItem> 40 + ))} 41 + </SelectContent> 42 + </Select> 43 + ); 44 + }
+30 -10
apps/web/src/app/(pages)/(content)/blog/page.tsx
··· 15 15 import { Rss } from "lucide-react"; 16 16 import type { Metadata } from "next"; 17 17 import Link from "next/link"; 18 + import { CategorySelect } from "./_components/category-select"; 18 19 import { 19 20 ITEMS_PER_PAGE, 20 - MAX_PAGE_INDEX, 21 + getMaxPageIndex, 21 22 searchParamsCache, 22 23 } from "./search-params"; 23 24 ··· 38 39 searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 39 40 }) { 40 41 const searchParams = await props.searchParams; 41 - const { pageIndex } = searchParamsCache.parse(searchParams); 42 + const { pageIndex, category } = searchParamsCache.parse(searchParams); 43 + 44 + const maxPageIndex = getMaxPageIndex(category); 45 + const currentPageIndex = Math.min(pageIndex, maxPageIndex); 42 46 43 47 const posts = allPosts 44 48 .sort( 45 49 (a, b) => 46 50 new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), 47 51 ) 48 - .slice(pageIndex * ITEMS_PER_PAGE, (pageIndex + 1) * ITEMS_PER_PAGE); 52 + .filter((post) => { 53 + if (!category) return true; 54 + return post.tag === category; 55 + }) 56 + .slice( 57 + currentPageIndex * ITEMS_PER_PAGE, 58 + (currentPageIndex + 1) * ITEMS_PER_PAGE, 59 + ); 49 60 50 61 return ( 51 62 <Shell> 52 63 <Timeline 53 64 title="Blog" 54 65 description="All the latest articles and news from OpenStatus." 55 - actions={ 56 - <Button variant="outline" size="icon" asChild> 66 + actions={[ 67 + <CategorySelect key="category-select" />, 68 + <Button 69 + key="rss-feed" 70 + variant="outline" 71 + size="icon" 72 + className="shrink-0" 73 + asChild 74 + > 57 75 <a href="/blog/feed.xml" target="_blank" rel="noreferrer"> 58 76 <Rss className="h-4 w-4" /> 59 77 <span className="sr-only">RSS feed</span> 60 78 </a> 61 - </Button> 62 - } 79 + </Button>, 80 + ]} 63 81 > 64 82 {posts.map((post) => ( 65 83 <Timeline.Article ··· 84 102 <div className="w-full md:order-2 md:col-span-4"> 85 103 <Pagination> 86 104 <PaginationContent> 87 - {Array.from({ length: MAX_PAGE_INDEX + 1 }).map((_, index) => { 105 + {Array.from({ length: maxPageIndex + 1 }).map((_, index) => { 88 106 return ( 89 107 <PaginationLink 90 108 key={index} 91 - href={`?pageIndex=${index}`} 92 - isActive={pageIndex === index} 109 + href={`?pageIndex=${index}${ 110 + category ? `&category=${category}` : "" 111 + }`} 112 + isActive={currentPageIndex === index} 93 113 > 94 114 {index + 1} 95 115 </PaginationLink>
+14 -4
apps/web/src/app/(pages)/(content)/blog/search-params.ts
··· 3 3 createParser, 4 4 createSearchParamsCache, 5 5 parseAsInteger, 6 + parseAsStringEnum, 6 7 } from "nuqs/server"; 7 8 9 + export const ITEMS_PER_PAGE = 10; 10 + 11 + // Helper function to calculate max page index based on filtered posts 12 + export function getMaxPageIndex(category?: string | null) { 13 + let filteredPosts = allPosts; 14 + if (category) { 15 + filteredPosts = allPosts.filter((post) => post.tag === category); 16 + } 17 + return Math.max(0, Math.ceil(filteredPosts.length / ITEMS_PER_PAGE) - 1); 18 + } 19 + 8 20 const parseAsPageIndex = createParser({ 9 21 parse(queryValue) { 10 22 const parsed = parseAsInteger.parse(queryValue); 11 23 if (!parsed || parsed < 0) return 0; 12 - return Math.min(parsed, MAX_PAGE_INDEX); 24 + return parsed; // We'll validate against max page index in the component 13 25 }, 14 26 serialize(value) { 15 27 return value.toString(); 16 28 }, 17 29 }); 18 30 19 - export const ITEMS_PER_PAGE = 10; 20 - export const MAX_PAGE_INDEX = Math.ceil(allPosts.length / ITEMS_PER_PAGE) - 1; 21 - 22 31 export const searchParamsParsers = { 23 32 pageIndex: parseAsPageIndex.withDefault(0), 33 + category: parseAsStringEnum(allPosts.map((post) => post.tag)), 24 34 }; 25 35 26 36 export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+1 -1
apps/web/src/components/content/timeline.tsx
··· 25 25 <h1 className="font-cal text-4xl text-foreground">{title}</h1> 26 26 <p className="text-muted-foreground">{description}</p> 27 27 </div> 28 - <div>{actions}</div> 28 + <div className="flex items-center gap-2">{actions}</div> 29 29 </div> 30 30 </div> 31 31 {children}