Skip to content

Commit 60ed319

Browse files
committed
Add shareable links and filters to Stories of Adventure
- Convert Stories of Adventure to individual shareable pages with unique URLs - Add StoryOfAdventureDetail component with share functionality - Add theme-based category filters (Mountains, National Parks, Lakes, Forests, Seasons, Cycling, Running) - Update navigation to use routing instead of modals - Add share and copy link buttons to adventure story detail pages
1 parent ebbab08 commit 60ed319

File tree

3 files changed

+397
-139
lines changed

3 files changed

+397
-139
lines changed

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ProjectDetail } from './components/pages/ProjectDetail';
99
import { Impact } from './components/pages/Impact';
1010
import { StoryDetail } from './components/pages/StoryDetail';
1111
import { StoriesOfAdventure } from './components/pages/StoriesOfAdventure';
12+
import { StoryOfAdventureDetail } from './components/pages/StoryOfAdventureDetail';
1213
import { Accessibility } from './components/pages/Accessibility';
1314
import { Contact } from './components/pages/Contact';
1415
import { Photography } from './components/pages/Photography';
@@ -29,6 +30,7 @@ export default function App() {
2930
<Route path="/impact" element={<Impact />} />
3031
<Route path="/impact/:storyId" element={<StoryDetail />} />
3132
<Route path="/storiesofadventure" element={<StoriesOfAdventure />} />
33+
<Route path="/storiesofadventure/:storyId" element={<StoryOfAdventureDetail />} />
3234
<Route path="/accessibility" element={<Accessibility />} />
3335
<Route path="/contact" element={<Contact />} />
3436
<Route path="/photography" element={<Photography />} />

src/components/pages/StoriesOfAdventure.tsx

Lines changed: 127 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,61 @@
1-
import { useState } from 'react';
1+
import { useState, useMemo } from 'react';
22
import { 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';
45
import { Button } from '../ui/button';
5-
import { InstagramFrame } from '../ui/InstagramFrame';
66
import contentData from '../../data/content';
77
import { 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+
959
const iconMap: Record<string, typeof Globe> = {
1060
'Globe': Globe,
1161
'BookOpen': BookOpen,
@@ -40,11 +90,26 @@ interface Story {
4090
}
4191

4292
export 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

Comments
 (0)