Skip to content

Commit 662da6b

Browse files
feat(blog): add category filtering and navigation (#1990)
1 parent 753bb61 commit 662da6b

File tree

2 files changed

+263
-11
lines changed

2 files changed

+263
-11
lines changed

apps/web/content-collections.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ const articles = defineCollection({
4747
coverImage: z.string().optional(),
4848
featured: z.boolean().optional(),
4949
published: z.boolean().default(true),
50+
category: z
51+
.enum([
52+
"Case Study",
53+
"Hyprnote Weekly",
54+
"Productivity Hack",
55+
"Engineering",
56+
])
57+
.optional(),
5058
}),
5159
transform: async (document, context) => {
5260
const toc = extractToc(document.content);

apps/web/src/routes/_view/blog/index.tsx

Lines changed: 255 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { createFileRoute, Link } from "@tanstack/react-router";
1+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
22
import { allArticles, type Article } from "content-collections";
3-
import { useState } from "react";
3+
import { useMemo, useState } from "react";
44

55
import { cn } from "@hypr/utils";
66

7+
import { SlashSeparator } from "@/components/slash-separator";
8+
79
const AUTHOR_AVATARS: Record<string, string> = {
810
"John Jeong":
911
"https://ijoptyyjrfqwaqhyxkxj.supabase.co/storage/v1/object/public/public_images/team/john.png",
@@ -13,8 +15,25 @@ const AUTHOR_AVATARS: Record<string, string> = {
1315
"https://ijoptyyjrfqwaqhyxkxj.supabase.co/storage/v1/object/public/public_images/team/yujong.png",
1416
};
1517

18+
const CATEGORIES = [
19+
"Case Study",
20+
"Hyprnote Weekly",
21+
"Productivity Hack",
22+
"Engineering",
23+
] as const;
24+
25+
type BlogSearch = {
26+
category?: string;
27+
};
28+
1629
export const Route = createFileRoute("/_view/blog/")({
1730
component: Component,
31+
validateSearch: (search: Record<string, unknown>): BlogSearch => {
32+
return {
33+
category:
34+
typeof search.category === "string" ? search.category : undefined,
35+
};
36+
},
1837
head: () => ({
1938
meta: [
2039
{ title: "Blog - Hyprnote" },
@@ -34,6 +53,9 @@ export const Route = createFileRoute("/_view/blog/")({
3453
});
3554

3655
function Component() {
56+
const navigate = useNavigate({ from: Route.fullPath });
57+
const search = Route.useSearch();
58+
3759
const publishedArticles = allArticles.filter(
3860
(a) => import.meta.env.DEV || a.published !== false,
3961
);
@@ -43,35 +65,229 @@ function Component() {
4365
return new Date(bDate).getTime() - new Date(aDate).getTime();
4466
});
4567

68+
const selectedCategory = search.category || null;
69+
70+
const setSelectedCategory = (category: string | null) => {
71+
navigate({ search: category ? { category } : {}, resetScroll: false });
72+
};
73+
4674
const featuredArticles = sortedArticles.filter((a) => a.featured);
4775

76+
const articlesByCategory = useMemo(() => {
77+
return sortedArticles.reduce(
78+
(acc, article) => {
79+
const category = article.category;
80+
if (category) {
81+
if (!acc[category]) {
82+
acc[category] = [];
83+
}
84+
acc[category].push(article);
85+
}
86+
return acc;
87+
},
88+
{} as Record<string, Article[]>,
89+
);
90+
}, [sortedArticles]);
91+
92+
const filteredArticles = useMemo(() => {
93+
if (selectedCategory === "featured") {
94+
return featuredArticles;
95+
}
96+
if (selectedCategory) {
97+
return sortedArticles.filter((a) => a.category === selectedCategory);
98+
}
99+
return sortedArticles;
100+
}, [sortedArticles, selectedCategory, featuredArticles]);
101+
102+
const categoriesWithCount = CATEGORIES.filter(
103+
(cat) => articlesByCategory[cat]?.length,
104+
);
105+
48106
return (
49107
<div
50108
className="bg-linear-to-b from-white via-stone-50/20 to-white"
51109
style={{ backgroundImage: "url(/patterns/dots.svg)" }}
52110
>
53-
<div className="px-4 sm:px-6 lg:px-8 py-16 max-w-6xl mx-auto border-x border-neutral-100 bg-white min-h-screen">
111+
<div className="max-w-6xl mx-auto border-x border-neutral-100 bg-white min-h-screen">
54112
<Header />
55-
<FeaturedSection articles={featuredArticles} />
56-
<AllArticlesSection articles={sortedArticles} />
113+
{featuredArticles.length > 0 && (
114+
<FeaturedSection articles={featuredArticles} />
115+
)}
116+
<SlashSeparator />
117+
<MobileCategoriesSection
118+
categories={categoriesWithCount}
119+
selectedCategory={selectedCategory}
120+
setSelectedCategory={setSelectedCategory}
121+
hasFeatured={featuredArticles.length > 0}
122+
/>
123+
<div className="px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
124+
<div className="flex gap-8">
125+
<DesktopSidebar
126+
categories={categoriesWithCount}
127+
selectedCategory={selectedCategory}
128+
setSelectedCategory={setSelectedCategory}
129+
articlesByCategory={articlesByCategory}
130+
featuredCount={featuredArticles.length}
131+
totalArticles={sortedArticles.length}
132+
/>
133+
<div className="flex-1 min-w-0">
134+
<AllArticlesSection
135+
articles={filteredArticles}
136+
selectedCategory={selectedCategory}
137+
/>
138+
</div>
139+
</div>
140+
</div>
57141
</div>
58142
</div>
59143
);
60144
}
61145

62146
function Header() {
63147
return (
64-
<header className="mb-16 text-center">
148+
<header className="py-16 text-center border-b border-neutral-100 bg-linear-to-b from-stone-50/30 to-stone-100/30">
65149
<h1 className="text-4xl sm:text-5xl font-serif text-stone-600 mb-4">
66150
Blog
67151
</h1>
68-
<p className="text-lg text-neutral-600 max-w-2xl mx-auto">
152+
<p className="text-lg text-neutral-600 max-w-2xl mx-auto px-4">
69153
Insights, updates, and stories from the Hyprnote team
70154
</p>
71155
</header>
72156
);
73157
}
74158

159+
function MobileCategoriesSection({
160+
categories,
161+
selectedCategory,
162+
setSelectedCategory,
163+
hasFeatured,
164+
}: {
165+
categories: string[];
166+
selectedCategory: string | null;
167+
setSelectedCategory: (category: string | null) => void;
168+
hasFeatured: boolean;
169+
}) {
170+
return (
171+
<div className="lg:hidden border-b border-neutral-100 bg-stone-50">
172+
<div className="flex overflow-x-auto scrollbar-hide">
173+
<button
174+
onClick={() => setSelectedCategory(null)}
175+
className={cn([
176+
"px-5 py-3 text-sm font-medium transition-colors whitespace-nowrap shrink-0 border-r border-neutral-100 cursor-pointer",
177+
selectedCategory === null
178+
? "bg-stone-600 text-white"
179+
: "text-stone-600 hover:bg-stone-100",
180+
])}
181+
>
182+
All
183+
</button>
184+
{hasFeatured && (
185+
<button
186+
onClick={() => setSelectedCategory("featured")}
187+
className={cn([
188+
"px-5 py-3 text-sm font-medium transition-colors whitespace-nowrap shrink-0 border-r border-neutral-100 cursor-pointer",
189+
selectedCategory === "featured"
190+
? "bg-stone-600 text-white"
191+
: "text-stone-600 hover:bg-stone-100",
192+
])}
193+
>
194+
Featured
195+
</button>
196+
)}
197+
{categories.map((category) => (
198+
<button
199+
key={category}
200+
onClick={() => setSelectedCategory(category)}
201+
className={cn([
202+
"px-5 py-3 text-sm font-medium transition-colors whitespace-nowrap shrink-0 border-r border-neutral-100 last:border-r-0 cursor-pointer",
203+
selectedCategory === category
204+
? "bg-stone-600 text-white"
205+
: "text-stone-600 hover:bg-stone-100",
206+
])}
207+
>
208+
{category}
209+
</button>
210+
))}
211+
</div>
212+
</div>
213+
);
214+
}
215+
216+
function DesktopSidebar({
217+
categories,
218+
selectedCategory,
219+
setSelectedCategory,
220+
articlesByCategory,
221+
featuredCount,
222+
totalArticles,
223+
}: {
224+
categories: string[];
225+
selectedCategory: string | null;
226+
setSelectedCategory: (category: string | null) => void;
227+
articlesByCategory: Record<string, Article[]>;
228+
featuredCount: number;
229+
totalArticles: number;
230+
}) {
231+
return (
232+
<aside className="hidden lg:block w-56 shrink-0">
233+
<div className="sticky top-[85px]">
234+
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
235+
Categories
236+
</h3>
237+
<nav className="space-y-1">
238+
<button
239+
onClick={() => setSelectedCategory(null)}
240+
className={cn([
241+
"w-full text-left px-3 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer",
242+
selectedCategory === null
243+
? "bg-stone-100 text-stone-800"
244+
: "text-stone-600 hover:bg-stone-50",
245+
])}
246+
>
247+
All Articles
248+
<span className="ml-2 text-xs text-neutral-400">
249+
({totalArticles})
250+
</span>
251+
</button>
252+
{featuredCount > 0 && (
253+
<button
254+
onClick={() => setSelectedCategory("featured")}
255+
className={cn([
256+
"w-full text-left px-3 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer",
257+
selectedCategory === "featured"
258+
? "bg-stone-100 text-stone-800"
259+
: "text-stone-600 hover:bg-stone-50",
260+
])}
261+
>
262+
Featured
263+
<span className="ml-2 text-xs text-neutral-400">
264+
({featuredCount})
265+
</span>
266+
</button>
267+
)}
268+
{categories.map((category) => (
269+
<button
270+
key={category}
271+
onClick={() => setSelectedCategory(category)}
272+
className={cn([
273+
"w-full text-left px-3 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer",
274+
selectedCategory === category
275+
? "bg-stone-100 text-stone-800"
276+
: "text-stone-600 hover:bg-stone-50",
277+
])}
278+
>
279+
{category}
280+
<span className="ml-2 text-xs text-neutral-400">
281+
({articlesByCategory[category]?.length || 0})
282+
</span>
283+
</button>
284+
))}
285+
</nav>
286+
</div>
287+
</aside>
288+
);
289+
}
290+
75291
function FeaturedSection({ articles }: { articles: Article[] }) {
76292
if (articles.length === 0) {
77293
return null;
@@ -81,8 +297,7 @@ function FeaturedSection({ articles }: { articles: Article[] }) {
81297
const displayedOthers = others.slice(0, 4);
82298

83299
return (
84-
<section className="mb-20">
85-
<SectionHeader title="Featured" />
300+
<section className="px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
86301
<div
87302
className={cn([
88303
"flex flex-col gap-3",
@@ -113,7 +328,13 @@ function FeaturedSection({ articles }: { articles: Article[] }) {
113328
);
114329
}
115330

116-
function AllArticlesSection({ articles }: { articles: Article[] }) {
331+
function AllArticlesSection({
332+
articles,
333+
selectedCategory,
334+
}: {
335+
articles: Article[];
336+
selectedCategory: string | null;
337+
}) {
117338
if (articles.length === 0) {
118339
return (
119340
<div className="text-center py-16">
@@ -122,9 +343,12 @@ function AllArticlesSection({ articles }: { articles: Article[] }) {
122343
);
123344
}
124345

346+
const title =
347+
selectedCategory === "featured" ? "Featured" : selectedCategory || "All";
348+
125349
return (
126350
<section>
127-
<SectionHeader title="All" />
351+
<SectionHeader title={title} />
128352
<div className="divide-y divide-neutral-100 sm:divide-y-0">
129353
{articles.map((article) => (
130354
<ArticleListItem key={article._meta.filePath} article={article} />
@@ -174,6 +398,11 @@ function MostRecentFeaturedCard({ article }: { article: Article }) {
174398
)}
175399

176400
<div className="p-6 md:p-8">
401+
{article.category && (
402+
<span className="text-xs font-medium text-stone-500 uppercase tracking-wider mb-2 block">
403+
{article.category}
404+
</span>
405+
)}
177406
<h3
178407
className={cn([
179408
"text-xl font-serif text-stone-600 mb-2",
@@ -271,6 +500,11 @@ function OtherFeaturedCard({
271500
"lg:p-4",
272501
])}
273502
>
503+
{article.category && (
504+
<span className="text-xs font-medium text-stone-500 uppercase tracking-wider mb-1">
505+
{article.category}
506+
</span>
507+
)}
274508
<h3
275509
className={cn([
276510
"text-base font-serif text-stone-600 mb-2",
@@ -316,6 +550,11 @@ function ArticleListItem({ article }: { article: Article }) {
316550
<article className="py-4 hover:bg-stone-50/50 transition-colors duration-200">
317551
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
318552
<div className="flex items-center gap-3 min-w-0 sm:max-w-2xl">
553+
{article.category && (
554+
<span className="text-xs font-medium text-stone-500 uppercase tracking-wider shrink-0 hidden sm:inline">
555+
{article.category}
556+
</span>
557+
)}
319558
<span className="text-base font-serif text-stone-600 group-hover:text-stone-800 transition-colors truncate">
320559
{article.title}
321560
</span>
@@ -334,6 +573,11 @@ function ArticleListItem({ article }: { article: Article }) {
334573
</div>
335574
<div className="flex items-center justify-between gap-3 sm:hidden">
336575
<div className="flex items-center gap-3">
576+
{article.category && (
577+
<span className="text-xs font-medium text-stone-500 uppercase tracking-wider">
578+
{article.category}
579+
</span>
580+
)}
337581
{avatarUrl && (
338582
<img
339583
src={avatarUrl}

0 commit comments

Comments
 (0)