1- import { createFileRoute , Link } from "@tanstack/react-router" ;
1+ import { createFileRoute , Link , useNavigate } from "@tanstack/react-router" ;
22import { allArticles , type Article } from "content-collections" ;
3- import { useState } from "react" ;
3+ import { useMemo , useState } from "react" ;
44
55import { cn } from "@hypr/utils" ;
66
7+ import { SlashSeparator } from "@/components/slash-separator" ;
8+
79const 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+
1629export 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
3655function 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
62146function 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+
75291function 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