1- import { useState } from 'react' ;
1+ import { useState , useMemo } from 'react' ;
22import { motion } from 'motion/react' ;
3- import { Heart , Globe , BookOpen , Laptop , Users , Zap , ArrowLeft , Calendar , Instagram } from 'lucide-react' ;
3+ import { Link } from 'react-router-dom' ;
4+ import { Heart , Globe , BookOpen , Laptop , Users , Zap , Calendar , Instagram } from 'lucide-react' ;
45import { Button } from '../ui/button' ;
5- import { InstagramFrame } from '../ui/InstagramFrame' ;
66import contentData from '../../data/content' ;
77import { getImageUrl } from '../../utils/imageUtils' ;
88
9+ // Category Filter Bar Component
10+ interface CategoryFilterBarProps {
11+ categories : string [ ] ;
12+ selectedCategory : string ;
13+ onCategoryChange : ( category : string ) => void ;
14+ }
15+
16+ function CategoryFilterBar ( { categories, selectedCategory, onCategoryChange } : CategoryFilterBarProps ) {
17+ const categoryLabels : Record < string , string > = {
18+ 'All' : 'All' ,
19+ 'blue' : 'Mountains' ,
20+ 'green' : 'National Parks' ,
21+ 'purple' : 'Lakes & Water' ,
22+ 'indigo' : 'Forests' ,
23+ 'teal' : 'Seasons' ,
24+ 'orange' : 'Cycling' ,
25+ 'red' : 'Running' ,
26+ } ;
27+
28+ return (
29+ < nav aria-label = "Adventure categories" className = "mb-8" >
30+ < ul className = "flex flex-wrap gap-2 overflow-x-auto pb-2 scrollbar-hide" role = "list" >
31+ { categories . map ( ( category ) => {
32+ const isSelected = category === selectedCategory ;
33+ return (
34+ < li key = { category } role = "listitem" >
35+ < button
36+ type = "button"
37+ onClick = { ( ) => onCategoryChange ( category ) }
38+ aria-pressed = { isSelected }
39+ aria-current = { isSelected ? 'page' : undefined }
40+ className = { `
41+ px-4 py-2 rounded-full text-sm font-medium transition-all duration-200
42+ whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
43+ ${ isSelected
44+ ? 'bg-blue-600 text-white shadow-md border border-blue-700'
45+ : 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-200'
46+ }
47+ ` }
48+ >
49+ { categoryLabels [ category ] || category }
50+ </ button >
51+ </ li >
52+ ) ;
53+ } ) }
54+ </ ul >
55+ </ nav >
56+ ) ;
57+ }
58+
959const iconMap : Record < string , typeof Globe > = {
1060 'Globe' : Globe ,
1161 'BookOpen' : BookOpen ,
@@ -40,11 +90,26 @@ interface Story {
4090}
4191
4292export function StoriesOfAdventure ( ) {
93+ const [ selectedCategory , setSelectedCategory ] = useState ( 'All' ) ;
4394 const { hero, stories : storiesData , labels } = contentData . storiesOfAdventure ;
4495 const { images } = contentData . assets ;
4596
97+ // Extract unique themes from stories
98+ const categories = useMemo ( ( ) => {
99+ const uniqueThemes = new Set ( storiesData . map ( story => story . theme ) ) ;
100+ return [ 'All' , ...Array . from ( uniqueThemes ) . sort ( ) ] ;
101+ } , [ storiesData ] ) ;
102+
103+ // Filter stories based on selected category
104+ const filteredStories = useMemo ( ( ) => {
105+ if ( selectedCategory === 'All' ) {
106+ return storiesData ;
107+ }
108+ return storiesData . filter ( story => story . theme === selectedCategory ) ;
109+ } , [ storiesData , selectedCategory ] ) ;
110+
46111 // Map stories with icons and thumbnails
47- const stories : Story [ ] = storiesData . map ( ( story ) : Story => {
112+ const stories : Story [ ] = filteredStories . map ( ( story ) : Story => {
48113 // Use first image as thumbnail, or fallback to impact images
49114 const storyImages = story . content . images || [ ] ;
50115 const firstImage = storyImages . length > 0
@@ -76,133 +141,6 @@ export function StoriesOfAdventure() {
76141 orange : 'from-orange-500/10 to-orange-600/5 border-orange-200' ,
77142 } ;
78143
79- const [ selectedStory , setSelectedStory ] = useState < Story | null > ( null ) ;
80-
81- if ( selectedStory ) {
82- return (
83- < div className = "min-h-screen pt-24 lg:pt-32" >
84- < div className = "max-w-4xl mx-auto px-6 lg:px-12" >
85- { /* Back Button */ }
86- < motion . button
87- initial = { { opacity : 0 , x : - 20 } }
88- animate = { { opacity : 1 , x : 0 } }
89- onClick = { ( ) => setSelectedStory ( null ) }
90- className = "flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-8"
91- >
92- < ArrowLeft size = { 20 } />
93- < span > { labels . backToStories } </ span >
94- </ motion . button >
95-
96- { /* Story Content */ }
97- < motion . article
98- initial = { { opacity : 0 , y : 20 } }
99- animate = { { opacity : 1 , y : 0 } }
100- className = "space-y-8"
101- >
102- { /* Header */ }
103- < header className = "space-y-4" >
104- < div className = "flex items-center gap-3 text-sm text-muted-foreground" >
105- < Calendar size = { 16 } />
106- < span > { selectedStory . date } </ span >
107- </ div >
108- < h1 className = "text-4xl lg:text-5xl tracking-tight" >
109- { selectedStory . title }
110- </ h1 >
111- < p className = "text-xl text-muted-foreground" >
112- { selectedStory . content . description }
113- </ p >
114- </ header >
115-
116- { /* Images */ }
117- { selectedStory . content . images && selectedStory . content . images . length > 0 && (
118- < div className = "grid md:grid-cols-2 gap-4 lg:gap-6" >
119- { selectedStory . content . images . map ( ( image , idx ) => {
120- const imageUrl = typeof image === 'string' ? image : image . url ;
121- const imageAlt = typeof image === 'string'
122- ? `${ selectedStory . title } - Image ${ idx + 1 } `
123- : image . alt ;
124- const imageCaption = typeof image === 'string'
125- ? ''
126- : image . caption ;
127-
128- return (
129- < InstagramFrame
130- key = { idx }
131- imageUrl = { imageUrl }
132- alt = { imageAlt }
133- caption = { imageCaption }
134- index = { idx }
135- />
136- ) ;
137- } ) }
138- </ div >
139- ) }
140-
141- { /* What I Did */ }
142- < section className = "space-y-4" >
143- < h2 className = "text-2xl font-semibold" > { labels . whatIDid } </ h2 >
144- < ul className = "grid md:grid-cols-2 gap-3" >
145- { selectedStory . content . work . map ( ( item ) => (
146- < li key = { item } className = "flex items-start gap-2" >
147- < span className = "text-blue-600 mt-1" > •</ span >
148- < span className = "text-muted-foreground" > { item } </ span >
149- </ li >
150- ) ) }
151- </ ul >
152- </ section >
153-
154- { /* Reflection */ }
155- < section className = { `bg-gradient-to-br ${ themeColors [ selectedStory . theme as keyof typeof themeColors ] } border rounded-2xl p-6 space-y-3` } >
156- < h2 className = "text-2xl font-semibold" > { labels . reflection } </ h2 >
157- < p className = "text-lg text-muted-foreground italic" >
158- { selectedStory . content . impact }
159- </ p >
160- </ section >
161- </ motion . article >
162-
163- { /* Connect CTA Section */ }
164- < section className = "py-16 lg:py-24 bg-gradient-to-b from-white to-blue-50/20 mt-16" >
165- < div className = "max-w-4xl mx-auto px-6 lg:px-12 text-center" >
166- < motion . div
167- initial = { { opacity : 0 , y : 30 } }
168- whileInView = { { opacity : 1 , y : 0 } }
169- viewport = { { once : true } }
170- transition = { { duration : 0.8 } }
171- >
172- < h2 className = "text-4xl lg:text-5xl tracking-tight mb-6" >
173- Let's connect
174- </ h2 >
175- < p className = "text-xl text-muted-foreground mb-8" >
176- Want to learn more about this story or share your own adventures? I'd love to hear from you.
177- </ p >
178- < div className = "flex flex-col sm:flex-row gap-4 justify-center" >
179- < a href = { contentData . assets . links . email } >
180- < Button size = "lg" className = "rounded-full px-8" >
181- Send an Email
182- </ Button >
183- </ a >
184- < a href = { contentData . assets . links . linkedin } target = "_blank" rel = "noopener noreferrer" >
185- < Button size = "lg" variant = "outline" className = "rounded-full px-8" >
186- Connect on LinkedIn
187- </ Button >
188- </ a >
189- { contentData . assets . links . instagram && (
190- < a href = { contentData . assets . links . instagram } target = "_blank" rel = "noopener noreferrer" >
191- < Button size = "lg" variant = "outline" className = "rounded-full px-8" >
192- < Instagram className = "w-5 h-5 mr-2" />
193- Follow on Instagram
194- </ Button >
195- </ a >
196- ) }
197- </ div >
198- </ motion . div >
199- </ div >
200- </ section >
201- </ div >
202- </ div >
203- ) ;
204- }
205-
206144 return (
207145 < div className = "min-h-screen pt-24 lg:pt-32" >
208146 { /* Header */ }
@@ -227,16 +165,42 @@ export function StoriesOfAdventure() {
227165 { /* Stories Grid */ }
228166 < section className = "py-16 lg:py-24" >
229167 < div className = "max-w-7xl mx-auto px-6 lg:px-12" >
230- < div className = "grid md:grid-cols-2 lg:grid-cols-3 gap-8" >
168+ { /* Category Filters */ }
169+ < CategoryFilterBar
170+ categories = { categories }
171+ selectedCategory = { selectedCategory }
172+ onCategoryChange = { ( category ) => {
173+ setSelectedCategory ( category ) ;
174+ // Announce category change to screen readers
175+ const announcement = document . createElement ( 'div' ) ;
176+ announcement . setAttribute ( 'role' , 'status' ) ;
177+ announcement . setAttribute ( 'aria-live' , 'polite' ) ;
178+ announcement . setAttribute ( 'aria-atomic' , 'true' ) ;
179+ announcement . className = 'sr-only' ;
180+ announcement . textContent = `Showing ${ category === 'All' ? 'all' : category } adventures` ;
181+ document . body . appendChild ( announcement ) ;
182+ setTimeout ( ( ) => document . body . removeChild ( announcement ) , 1000 ) ;
183+ } }
184+ />
185+
186+ { filteredStories . length === 0 ? (
187+ < div className = "text-center py-16" >
188+ < p className = "text-gray-500" > No adventures found in this category.</ p >
189+ </ div >
190+ ) : (
191+ < div className = "grid md:grid-cols-2 lg:grid-cols-3 gap-8" >
231192 { stories . map ( ( story , index ) => (
232- < motion . div
193+ < Link
233194 key = { story . id }
234- initial = { { opacity : 0 , y : 30 } }
235- animate = { { opacity : 1 , y : 0 } }
236- transition = { { duration : 0.6 , delay : index * 0.1 } }
237- onClick = { ( ) => setSelectedStory ( story ) }
238- className = "group cursor-pointer"
195+ to = { `/storiesofadventure/${ story . id } ` }
196+ className = "group"
239197 >
198+ < motion . div
199+ initial = { { opacity : 0 , y : 30 } }
200+ animate = { { opacity : 1 , y : 0 } }
201+ transition = { { duration : 0.6 , delay : index * 0.1 } }
202+ className = "cursor-pointer"
203+ >
240204 < div className = "surface-elevated rounded-2xl overflow-hidden transition-all duration-300 hover:shadow-xl hover:scale-[1.02]" >
241205 { /* Thumbnail */ }
242206 < div className = "relative aspect-video overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200" >
@@ -267,9 +231,11 @@ export function StoriesOfAdventure() {
267231 </ div >
268232 </ div >
269233 </ div >
270- </ motion . div >
234+ </ motion . div >
235+ </ Link >
271236 ) ) }
272237 </ div >
238+ ) }
273239 </ div >
274240 </ section >
275241
@@ -303,6 +269,28 @@ export function StoriesOfAdventure() {
303269 </ motion . div >
304270 </ div >
305271 </ section >
272+
273+ { /* Screen reader only class for announcements */ }
274+ < style > { `
275+ .sr-only {
276+ position: absolute;
277+ width: 1px;
278+ height: 1px;
279+ padding: 0;
280+ margin: -1px;
281+ overflow: hidden;
282+ clip: rect(0, 0, 0, 0);
283+ white-space: nowrap;
284+ border-width: 0;
285+ }
286+ .scrollbar-hide {
287+ -ms-overflow-style: none;
288+ scrollbar-width: none;
289+ }
290+ .scrollbar-hide::-webkit-scrollbar {
291+ display: none;
292+ }
293+ ` } </ style >
306294 </ div >
307295 ) ;
308296}
0 commit comments