Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const footerSections = computed<Array<{ label: string; links: FooterLink[] }>>((
name: t('footer.blog'),
href: '/blog',
},
{
name: t('nav.events'),
href: '/events',
},
{
name: t('footer.about'),
href: '/about',
Expand Down
101 changes: 101 additions & 0 deletions app/components/Events/Card.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<script setup lang="ts">
import type { EventSummary } from '~/types/events'
import { formatEventDateRange } from '~/utils/events/format'

const { event } = defineProps<{ event: EventSummary }>()

const { locale } = useI18n()
const dateLabel = computed(() => formatEventDateRange(event.startsAt, event.endsAt, locale.value))

const modeLabel = computed(() => {
if (event.mode === 'virtual') return $t('events.mode.online')
if (event.mode === 'hybrid') return $t('events.mode.hybrid')
return $t('events.mode.in_person')
})

const locationLabel = computed(() => event.location?.name || event.location?.locality)
const shownAttendees = computed(() => event.attendees.slice(0, 5))
const overflow = computed(() => Math.max(0, event.attendeeCount - shownAttendees.value.length))
</script>

<template>
<NuxtLink
:to="{ name: 'events-slug', params: { slug: event.slug } }"
class="group flex flex-col rounded-lg border border-border bg-bg-subtle overflow-hidden transition-colors duration-200 hover:border-fg-subtle"
>
<div class="relative aspect-[16/6] overflow-hidden bg-bg-elevated">
<img
v-if="event.cover"
:src="event.cover"
:alt="event.name"
class="h-full w-full object-cover"
loading="lazy"
/>
<div
v-else
class="h-full w-full bg-gradient-to-br from-accent/25 via-bg-elevated to-bg-subtle"
aria-hidden="true"
/>
<span
class="absolute inset-ie-3 inset-bs-3 font-mono text-xs px-2 py-1 rounded-md bg-bg/80 backdrop-blur text-fg-muted"
>
{{ modeLabel }}
</span>
</div>

<div class="flex flex-col gap-3 p-4">
<div class="flex items-start justify-between gap-3">
<h3
class="font-mono text-fg text-lg leading-tight group-hover:text-accent transition-colors"
>
{{ event.name }}
</h3>
</div>

<div class="flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-sm text-fg-muted">
<span>{{ dateLabel }}</span>
<span v-if="locationLabel" class="inline-flex items-center gap-1">
<span class="i-lucide:map-pin w-3.5 h-3.5" aria-hidden="true" />
{{ locationLabel }}
</span>
</div>

<p v-if="event.description" class="text-sm text-fg-subtle line-clamp-2">
{{ event.description }}
</p>

<div v-if="event.attendeeCount" class="flex items-center gap-2">
<div class="flex -space-i-2">
<template v-for="a in shownAttendees" :key="a.handle || a.name">
<img
v-if="a.avatar"
:src="a.avatar"
:alt="a.name"
:title="a.name"
loading="lazy"
class="w-6 h-6 rounded-full border border-border object-cover bg-bg-elevated"
/>
<span
v-else
class="inline-flex items-center justify-center w-6 h-6 rounded-full border border-border bg-bg-elevated text-[0.6rem] font-mono text-fg-muted"
:title="a.name"
>
{{ a.name.charAt(0) }}
</span>
</template>
</div>
<span v-if="overflow" class="font-mono text-xs text-fg-subtle">+{{ overflow }}</span>
</div>

<div v-if="event.tags.length" class="flex flex-wrap gap-1.5">
<span
v-for="tag in event.tags"
:key="tag"
class="font-mono text-xs text-fg-subtle px-2 py-0.5 rounded bg-bg-elevated"
>
{{ tag }}
</span>
</div>
</div>
</NuxtLink>
</template>
131 changes: 131 additions & 0 deletions app/components/Events/Gallery.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { GalleryImage } from '~/types/events'

const { images, max = 10 } = defineProps<{ images: GalleryImage[]; max?: number }>()

const open = ref(false)
const index = ref(0)

const visible = computed(() => images.slice(0, max))
const remaining = computed(() => Math.max(0, images.length - max))

function openAt(i: number) {
index.value = i
open.value = true
}
function close() {
open.value = false
}
function prev() {
index.value = (index.value - 1 + images.length) % images.length
}
function next() {
index.value = (index.value + 1) % images.length
}

onKeyStroke('Escape', () => open.value && close())
onKeyStroke('ArrowLeft', () => open.value && prev())
onKeyStroke('ArrowRight', () => open.value && next())

watch(open, value => {
if (import.meta.client) document.body.style.overflow = value ? 'hidden' : ''
})
onUnmounted(() => {
if (import.meta.client) document.body.style.overflow = ''
})
</script>

<template>
<div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<button
v-for="(img, i) in visible"
:key="img.url"
type="button"
class="group/tile relative aspect-[4/3] overflow-hidden rounded-lg bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
@click="openAt(i)"
>
<img
:src="img.url"
:alt="img.alt || ''"
loading="lazy"
class="absolute inset-0 h-full w-full object-cover transition-transform duration-500 ease-out group-hover/tile:scale-105"
/>
<span
class="pointer-events-none absolute inset-0 bg-black/0 transition-colors duration-300 group-hover/tile:bg-black/15"
aria-hidden="true"
/>
<span
v-if="i === visible.length - 1 && remaining > 0"
class="absolute inset-0 flex items-center justify-center bg-black/60 font-mono text-lg text-white"
>
+{{ remaining }}
</span>
</button>
</div>

<Teleport to="body">
<div
v-if="open"
role="dialog"
aria-modal="true"
:aria-label="$t('events.gallery')"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
@click.self="close"
>
<button
type="button"
class="absolute inset-ie-4 inset-bs-4 z-10 flex h-9 w-9 items-center justify-center rounded-md text-white/80 hover:text-white hover:bg-white/10"
:aria-label="$t('common.close')"
@click="close"
>
<span class="i-lucide:x w-5 h-5" aria-hidden="true" />
</button>

<button
v-if="images.length > 1"
type="button"
class="absolute inset-is-2 z-10 flex h-10 w-10 items-center justify-center rounded-full text-white/80 hover:text-white hover:bg-white/10"
:aria-label="$t('events.gallery_prev')"
@click="prev"
>
<span class="i-lucide:chevron-left rtl-flip w-6 h-6" aria-hidden="true" />
</button>
<button
v-if="images.length > 1"
type="button"
class="absolute inset-ie-2 z-10 flex h-10 w-10 items-center justify-center rounded-full text-white/80 hover:text-white hover:bg-white/10"
:aria-label="$t('events.gallery_next')"
@click="next"
>
<span class="i-lucide:chevron-right rtl-flip w-6 h-6" aria-hidden="true" />
</button>

<img
:src="images[index]?.url"
:alt="images[index]?.alt || ''"
class="max-h-[85vh] max-w-[90vw] object-contain"
/>

<div class="group/strip absolute inset-x-0 bottom-0 flex justify-center pt-20">
<div
class="flex max-w-full translate-y-full gap-2 overflow-x-auto p-3 transition-transform duration-200 group-hover/strip:translate-y-0"
>
<button
v-for="(img, i) in images"
:key="img.url"
type="button"
class="h-14 w-20 flex-shrink-0 overflow-hidden rounded border-2 transition-colors"
:class="
i === index ? 'border-accent' : 'border-transparent opacity-70 hover:opacity-100'
"
@click="index = i"
>
<img :src="img.url" :alt="img.alt || ''" class="h-full w-full object-cover" />
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
50 changes: 50 additions & 0 deletions app/components/Events/TalkRow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { Talk } from '~/types/events'

const { talk } = defineProps<{ talk: Talk }>()

const speakerLabel = computed(() => talk.speakers.map(s => s.name).join(', '))
</script>

<template>
<div class="flex flex-col gap-2 py-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div class="min-w-0">
<h4 class="font-mono text-fg leading-tight">{{ talk.title }}</h4>
<p v-if="talk.abstract" class="mt-1 text-sm text-fg-subtle">{{ talk.abstract }}</p>
<p v-if="speakerLabel" class="mt-1 font-mono text-xs text-fg-muted">{{ speakerLabel }}</p>
</div>

<div class="flex flex-shrink-0 items-center gap-4 font-mono text-sm">
<LinkBase
v-if="talk.watchUrl"
:to="talk.watchUrl"
variant="link"
noUnderline
class="inline-flex items-center gap-1 text-fg-muted hover:text-accent"
>
<span class="i-lucide:play w-4 h-4" aria-hidden="true" />
{{ $t('events.watch') }}
</LinkBase>
<LinkBase
v-if="talk.slidesUrl"
:to="talk.slidesUrl"
variant="link"
noUnderline
class="inline-flex items-center gap-1 text-fg-muted hover:text-accent"
>
<span class="i-lucide:presentation w-4 h-4" aria-hidden="true" />
{{ $t('events.slides') }}
</LinkBase>
<LinkBase
v-if="talk.pdfUrl"
:to="talk.pdfUrl"
variant="link"
noUnderline
class="inline-flex items-center gap-1 text-fg-muted hover:text-accent"
>
<span class="i-lucide:download w-4 h-4" aria-hidden="true" />
{{ $t('events.pdf') }}
</LinkBase>
</div>
</div>
</template>
13 changes: 13 additions & 0 deletions app/composables/useCommandPaletteGlobalCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,19 @@ export function useCommandPaletteGlobalCommands() {
),
to: { name: 'blog' },
},
{
id: 'events',
group: 'npmx',
label: t('events.title'),
keywords: [t('events.title'), t('events.talks')],
iconClass: 'i-lucide:calendar',
active: route.name === 'events' || `${route.name ?? ''}`.startsWith('events-'),
activeLabel: activeLabel(
route.name === 'events' || `${route.name ?? ''}`.startsWith('events-'),
t('command_palette.here'),
),
to: { name: 'events' },
},
{
id: 'noodles',
group: 'npmx',
Expand Down
38 changes: 38 additions & 0 deletions app/composables/useEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { EventDetail, EventKind, EventSummary } from '~/types/events'
import { isPastEvent } from '~/utils/events/format'

export function useEvents() {
const { locale } = useI18n()
const { data, pending, error } = useFetch<EventDetail[]>('/api/events', {
key: 'events',
default: () => [],
})

const all = computed<EventDetail[]>(() => data.value ?? [])

const upcoming = computed(() =>
all.value.filter(e => !isPastEvent(e)).sort((a, b) => a.startsAt.localeCompare(b.startsAt)),
)

const past = computed(() =>
all.value.filter(e => isPastEvent(e)).sort((a, b) => b.startsAt.localeCompare(a.startsAt)),
)

const kinds = computed<EventKind[]>(() => {
const set = new Set<EventKind>()
for (const e of all.value) set.add(e.kind)
return [...set]
})

function findBySlug(slug: string): EventDetail | undefined {
return all.value.find(e => e.slug === slug)
}

function relatedTo(slug: string, limit = 3): EventSummary[] {
const current = findBySlug(slug)
if (!current) return []
return all.value.filter(e => e.slug !== slug && e.kind === current.kind).slice(0, limit)
}

return { all, pending, error, upcoming, past, kinds, findBySlug, relatedTo, locale }
}
Loading
Loading