Skip to content

Commit 7bfc0b2

Browse files
committed
feat: add speaking events page and configuration for public speaking engagements
1 parent 7faf87a commit 7bfc0b2

3 files changed

Lines changed: 461 additions & 0 deletions

File tree

app/pages/speaking.vue

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
<script setup lang="ts">
2+
interface SpeakingEvent {
3+
name?: string
4+
date?: string
5+
event?: string
6+
type?: 'conference' | 'meetup' | 'podcast' | 'webinar'
7+
format?: 'talk' | 'workshop' | 'panel' | 'keynote' | 'lightning'
8+
location?: string
9+
url?: string
10+
slides?: string
11+
image?: string
12+
speakers?: string[]
13+
}
14+
15+
const { data: page } = await useAsyncData('speaking', () => queryCollection('speaking').first())
16+
17+
const title = page.value?.seo?.title || page.value?.title || 'Speaking'
18+
const description = page.value?.seo?.description || page.value?.description
19+
20+
useSeoMeta({
21+
title,
22+
ogTitle: title,
23+
description,
24+
ogDescription: description
25+
})
26+
27+
defineOgImageComponent('Saas')
28+
29+
// Sort events by date (most recent first) and filter out incomplete entries
30+
const events = computed(() => {
31+
if (!page.value?.events) return []
32+
return [...page.value.events]
33+
.filter((e: SpeakingEvent) => e.name && e.event)
34+
.sort((a: SpeakingEvent, b: SpeakingEvent) => {
35+
if (!a.date) return 1
36+
if (!b.date) return -1
37+
return new Date(b.date).getTime() - new Date(a.date).getTime()
38+
})
39+
})
40+
41+
const upcomingEvents = computed(() =>
42+
events.value.filter((e: SpeakingEvent) => e.date && new Date(e.date) > new Date())
43+
)
44+
45+
const pastEvents = computed(() =>
46+
events.value.filter((e: SpeakingEvent) => !e.date || new Date(e.date) <= new Date())
47+
)
48+
49+
// Group past events by year with timeline items
50+
const eventsByYear = computed(() => {
51+
const groups: Record<string, SpeakingEvent[]> = {}
52+
pastEvents.value.forEach((event: SpeakingEvent) => {
53+
const year = event.date ? new Date(event.date).getFullYear().toString() : 'Unknown'
54+
if (!groups[year]) groups[year] = []
55+
groups[year].push(event)
56+
})
57+
return Object.entries(groups)
58+
.sort(([a], [b]) => Number(b) - Number(a))
59+
.map(([year, yearEvents]) => ({
60+
year,
61+
timelineItems: yearEvents.map((event, index) => ({
62+
date: formatDate(event.date),
63+
title: event.event,
64+
icon: getTypeIcon(event.type),
65+
value: index,
66+
talkName: event.name,
67+
format: event.format,
68+
location: event.location,
69+
isOnline: event.location === 'Online',
70+
url: event.url,
71+
slides: event.slides
72+
}))
73+
}))
74+
})
75+
76+
// Stats
77+
const stats = computed(() => ({
78+
totalTalks: events.value.length,
79+
conferences: events.value.filter((e: SpeakingEvent) => e.type === 'conference').length,
80+
years: new Set(events.value.map((e: SpeakingEvent) => e.date ? new Date(e.date).getFullYear() : null).filter(Boolean)).size,
81+
podcasts: events.value.filter((e: SpeakingEvent) => e.type === 'podcast').length
82+
}))
83+
84+
// Icon based on event type
85+
function getTypeIcon(type?: string) {
86+
switch (type) {
87+
case 'podcast': return 'i-lucide-mic'
88+
case 'webinar': return 'i-lucide-video'
89+
case 'meetup': return 'i-lucide-users'
90+
default: return 'i-lucide-presentation'
91+
}
92+
}
93+
94+
// Format badge color
95+
function getFormatColor(format?: string) {
96+
switch (format) {
97+
case 'workshop': return 'warning'
98+
case 'panel': return 'info'
99+
case 'keynote': return 'error'
100+
case 'lightning': return 'neutral'
101+
case 'interview': return 'secondary'
102+
default: return 'success'
103+
}
104+
}
105+
106+
// Format display label
107+
function getFormatLabel(format?: string) {
108+
switch (format) {
109+
case 'workshop': return 'hands-on lab'
110+
default: return format
111+
}
112+
}
113+
114+
function formatDate(date?: string) {
115+
if (!date) return ''
116+
return new Date(date).toLocaleDateString('en', {
117+
year: 'numeric',
118+
month: 'short',
119+
day: 'numeric'
120+
})
121+
}
122+
</script>
123+
124+
<template>
125+
<UContainer>
126+
<UPageHeader
127+
:title="page?.title || 'Speaking'"
128+
:description="page?.description"
129+
class="py-[50px]"
130+
>
131+
<template #headline>
132+
<UBadge
133+
variant="subtle"
134+
icon="i-lucide-mic"
135+
label="Public Speaking"
136+
/>
137+
</template>
138+
</UPageHeader>
139+
140+
<UPageBody>
141+
<!-- Stats Overview -->
142+
<section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12">
143+
<UPageCard variant="soft" class="text-center">
144+
<div class="text-3xl font-bold text-primary">
145+
{{ stats.totalTalks }}
146+
</div>
147+
<div class="text-muted text-sm">
148+
Speaking Events
149+
</div>
150+
</UPageCard>
151+
<UPageCard variant="soft" class="text-center">
152+
<div class="text-3xl font-bold text-primary">
153+
{{ stats.conferences }}
154+
</div>
155+
<div class="text-muted text-sm">
156+
Conferences
157+
</div>
158+
</UPageCard>
159+
<UPageCard variant="soft" class="text-center">
160+
<div class="text-3xl font-bold text-primary">
161+
{{ stats.years }}
162+
</div>
163+
<div class="text-muted text-sm">
164+
Years Speaking
165+
</div>
166+
</UPageCard>
167+
<UPageCard variant="soft" class="text-center">
168+
<div class="text-3xl font-bold text-primary">
169+
{{ stats.podcasts }}
170+
</div>
171+
<div class="text-muted text-sm">
172+
Podcasts
173+
</div>
174+
</UPageCard>
175+
</section>
176+
177+
<!-- Upcoming Events -->
178+
<section v-if="upcomingEvents.length" class="mb-16">
179+
<h2 class="text-2xl font-bold mb-6 flex items-center gap-2">
180+
<UIcon name="i-lucide-calendar-clock" class="text-primary" />
181+
Upcoming Events
182+
</h2>
183+
<UPageGrid>
184+
<UPageCard
185+
v-for="event in upcomingEvents"
186+
:key="`${event.event}-${event.date}`"
187+
:title="event.name"
188+
:description="event.event"
189+
:icon="getTypeIcon(event.type)"
190+
:to="event.url"
191+
:target="event.url ? '_blank' : undefined"
192+
highlight
193+
highlight-color="primary"
194+
variant="outline"
195+
>
196+
<template #footer>
197+
<div class="flex items-center justify-between gap-2 flex-wrap">
198+
<UBadge
199+
:label="formatDate(event.date)"
200+
variant="subtle"
201+
icon="i-lucide-calendar"
202+
/>
203+
<UBadge
204+
v-if="event.location"
205+
:label="event.location"
206+
color="neutral"
207+
variant="soft"
208+
icon="i-lucide-map-pin"
209+
/>
210+
</div>
211+
</template>
212+
</UPageCard>
213+
</UPageGrid>
214+
</section>
215+
216+
<!-- Past Events as Timeline grouped by Year -->
217+
<section>
218+
<h2 class="text-2xl font-bold mb-8 flex items-center gap-2 justify-center">
219+
<UIcon name="i-lucide-history" class="text-primary" />
220+
Past Talks
221+
</h2>
222+
223+
<div class="max-w-2xl mx-auto space-y-12">
224+
<div v-for="{ year, timelineItems } in eventsByYear" :key="year">
225+
<!-- Year Header -->
226+
<div class="flex items-center gap-4 mb-6">
227+
<div class="h-px flex-1 bg-default" />
228+
<span class="text-xl font-bold text-primary px-4 py-1 rounded-full bg-primary/10">
229+
{{ year }}
230+
</span>
231+
<div class="h-px flex-1 bg-default" />
232+
</div>
233+
234+
<!-- Timeline for this year -->
235+
<UTimeline
236+
:items="timelineItems"
237+
:default-value="timelineItems.length - 1"
238+
color="primary"
239+
size="md"
240+
>
241+
<template #description="{ item }">
242+
<UPageCard
243+
variant="subtle"
244+
class="mt-1"
245+
:ui="{ container: 'p-3 sm:p-4' }"
246+
>
247+
<div class="flex items-center justify-between gap-4">
248+
<div class="flex-1 min-w-0">
249+
<div class="flex items-start gap-2">
250+
<h4 class="font-semibold text-highlighted">
251+
{{ item.talkName }}
252+
</h4>
253+
<UBadge
254+
v-if="item.format"
255+
:label="getFormatLabel(item.format)"
256+
:color="getFormatColor(item.format)"
257+
variant="subtle"
258+
size="sm"
259+
class="capitalize shrink-0"
260+
/>
261+
</div>
262+
<p v-if="item.location" class="text-muted text-sm mt-0.5 flex items-center gap-1">
263+
<UIcon
264+
:name="item.isOnline ? 'i-lucide-globe' : 'i-lucide-map-pin'"
265+
class="size-3.5"
266+
/>
267+
{{ item.location }}
268+
</p>
269+
</div>
270+
<div v-if="item.url || item.slides" class="flex items-center gap-2 shrink-0">
271+
<UButton
272+
v-if="item.url"
273+
:to="item.url"
274+
target="_blank"
275+
:icon="item.icon === 'i-lucide-mic' ? 'i-lucide-headphones' : 'i-lucide-play-circle'"
276+
:label="item.icon === 'i-lucide-mic' ? 'Listen' : 'Watch'"
277+
size="xs"
278+
variant="soft"
279+
color="primary"
280+
/>
281+
<UButton
282+
v-if="item.slides"
283+
:to="item.slides"
284+
target="_blank"
285+
icon="i-lucide-presentation"
286+
label="Slides"
287+
size="xs"
288+
variant="outline"
289+
color="neutral"
290+
/>
291+
</div>
292+
</div>
293+
</UPageCard>
294+
</template>
295+
</UTimeline>
296+
</div>
297+
</div>
298+
</section>
299+
300+
<!-- Contact CTA -->
301+
<section class="mt-16">
302+
<UPageCard variant="subtle" class="text-center max-w-2xl mx-auto">
303+
<div class="flex flex-col items-center gap-4 py-4">
304+
<div class="p-3 rounded-full bg-primary/10">
305+
<UIcon name="i-lucide-message-circle" class="size-8 text-primary" />
306+
</div>
307+
<h3 class="text-xl font-semibold text-highlighted">
308+
Want me to speak at your event?
309+
</h3>
310+
<p class="text-muted max-w-md">
311+
I'd love to share knowledge about Cloud, Infrastructure as Code, Pulumi, and .NET development at your conference or meetup.
312+
</p>
313+
<UButton
314+
to="/contact"
315+
label="Get in Touch"
316+
icon="i-lucide-send"
317+
size="lg"
318+
class="mt-2"
319+
/>
320+
</div>
321+
</UPageCard>
322+
</section>
323+
</UPageBody>
324+
</UContainer>
325+
</template>

content.config.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ export const collections = {
136136
image: createImageSchema().optional()
137137
})
138138
}),
139+
speaking: defineCollection({
140+
source: '2.speaking.yml',
141+
type: 'page',
142+
schema: z.object({
143+
align: z.string().optional(),
144+
events: z.array(
145+
z.object({
146+
name: z.string().optional(),
147+
date: z.string().optional(),
148+
event: z.string().optional(),
149+
type: z.enum(['conference', 'meetup', 'podcast', 'webinar']).optional(),
150+
format: z.enum(['talk', 'workshop', 'panel', 'keynote', 'lightning']).optional(),
151+
location: z.string().optional(),
152+
url: z.string().optional(),
153+
slides: z.string().optional(),
154+
image: z.string().optional(),
155+
speakers: z.array(z.string()).optional()
156+
})
157+
).optional()
158+
})
159+
}),
139160
content: defineCollection({
140161
source: '*.md',
141162
type: 'page',

0 commit comments

Comments
 (0)