Add activities event discovery UI (Phase 1)

Composables (useActivities, useActivityFilters, useActivityDetail) for
subscribing to NIP-52 calendar events, filtering by temporal range and
category, and loading single activity details. Components: ActivityCard
with image/placeholder, date, location, category badge; ActivityList
with responsive grid, loading skeletons, and empty state; TemporalFilterBar
(today/tomorrow/week/month pills); CategoryFilterBar (25 categories);
DatePickerStrip (horizontal week calendar). Full ActivitiesPage with
search, filters, upcoming/past tabs. ActivityDetailPage with hero image,
organizer info, and description. Activities nav link added (no auth required).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-19 18:48:53 +02:00
commit eebc1865c9
11 changed files with 1014 additions and 12 deletions

View file

@ -42,6 +42,14 @@ export function useModularNavigation() {
}) })
} }
if (appConfig.modules.activities?.enabled) {
items.push({
name: t('nav.activities'),
href: '/activities',
requiresAuth: false
})
}
if (appConfig.modules.chat.enabled) { if (appConfig.modules.chat.enabled) {
items.push({ items.push({
name: t('nav.chat'), name: t('nav.chat'),

View file

@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
import type { Activity } from '../types/activity'
const props = defineProps<{
activity: Activity
}>()
const emit = defineEmits<{
click: [activity: Activity]
}>()
const { t } = useI18n()
const dateDisplay = computed(() => {
const a = props.activity
if (a.type === 'date') {
return format(a.startDate, 'EEE, MMM d')
}
const date = format(a.startDate, 'EEE, MMM d')
const time = format(a.startDate, 'HH:mm')
return `${date} \u2022 ${time}`
})
const categoryLabel = computed(() => {
if (!props.activity.category) return null
return t(`activities.categories.${props.activity.category}`, props.activity.category)
})
const priceDisplay = computed(() => {
const info = props.activity.ticketInfo
if (!info) return null
if (info.price === 0) return t('activities.detail.free')
return `${info.price} ${info.currency}`
})
const placeholderBg = computed(() => {
// Generate a consistent hue from the activity title
const hash = props.activity.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const hue = hash % 360
return `hsl(${hue}, 40%, 85%)`
})
</script>
<template>
<Card
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
@click="emit('click', activity)"
>
<!-- Image / Placeholder -->
<div class="relative aspect-[16/9] overflow-hidden">
<img
v-if="activity.image"
:src="activity.image"
:alt="activity.title"
class="w-full h-full object-cover"
loading="lazy"
/>
<div
v-else
class="w-full h-full flex items-center justify-center"
:style="{ backgroundColor: placeholderBg }"
>
<Calendar class="w-12 h-12 text-foreground/20" />
</div>
<!-- Category badge -->
<Badge
v-if="categoryLabel"
variant="secondary"
class="absolute top-2 left-2 text-xs"
>
{{ categoryLabel }}
</Badge>
<!-- Price badge -->
<Badge
v-if="priceDisplay"
class="absolute top-2 right-2 text-xs"
>
{{ priceDisplay }}
</Badge>
</div>
<CardContent class="p-4 flex-1 flex flex-col gap-2">
<!-- Title -->
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight">
{{ activity.title }}
</h3>
<!-- Summary -->
<p
v-if="activity.summary"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ activity.summary }}
</p>
<div class="mt-auto space-y-1.5 pt-2">
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ dateDisplay }}</span>
</div>
<!-- Location -->
<div
v-if="activity.location"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<MapPin class="w-3.5 h-3.5 shrink-0" />
<span class="truncate">{{ activity.location }}</span>
</div>
<!-- Tickets available -->
<div
v-if="activity.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
</span>
</div>
</div>
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Calendar } from 'lucide-vue-next'
import ActivityCard from './ActivityCard.vue'
import type { Activity } from '../types/activity'
defineProps<{
activities: Activity[]
isLoading?: boolean
}>()
const emit = defineEmits<{
select: [activity: Activity]
}>()
const { t } = useI18n()
</script>
<template>
<!-- Loading skeleton -->
<div v-if="isLoading" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="i in 6"
:key="i"
class="rounded-lg border bg-card animate-pulse"
>
<div class="aspect-[16/9] bg-muted" />
<div class="p-4 space-y-3">
<div class="h-5 bg-muted rounded w-3/4" />
<div class="h-4 bg-muted rounded w-full" />
<div class="h-4 bg-muted rounded w-1/2" />
</div>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="activities.length === 0"
class="flex flex-col items-center justify-center py-16 text-center"
>
<Calendar class="w-16 h-16 text-muted-foreground/30 mb-4" />
<h3 class="text-lg font-medium text-foreground mb-1">
{{ t('activities.noActivities') }}
</h3>
<p class="text-sm text-muted-foreground">
Try adjusting your filters or check back later
</p>
</div>
<!-- Activity grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ActivityCard
v-for="activity in activities"
:key="activity.nostrEventId"
:activity="activity"
@click="emit('select', activity)"
/>
</div>
</template>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import type { ActivityCategory } from '../types/category'
import { ALL_CATEGORIES } from '../types/category'
const props = defineProps<{
selected: ActivityCategory[]
}>()
const emit = defineEmits<{
toggle: [category: ActivityCategory]
clear: []
}>()
const { t } = useI18n()
function categoryLabel(cat: ActivityCategory): string {
return t(`activities.categories.${cat}`, cat)
}
</script>
<template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-muted-foreground">Categories</span>
<Button
v-if="props.selected.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="emit('clear')"
>
<X class="w-3 h-3 mr-1" />
Clear
</Button>
</div>
<div class="flex flex-wrap gap-1.5">
<Badge
v-for="cat in ALL_CATEGORIES"
:key="cat"
:variant="props.selected.includes(cat) ? 'default' : 'outline'"
class="cursor-pointer text-xs select-none hover:opacity-80 transition-opacity"
@click="emit('toggle', cat)"
>
{{ categoryLabel(cat) }}
</Badge>
</div>
</div>
</template>

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { addDays, format, isSameDay, startOfWeek } from 'date-fns'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
const props = defineProps<{
/** Currently selected date (if any) */
selectedDate?: Date
}>()
const emit = defineEmits<{
select: [date: Date]
}>()
/** Start of the visible week */
const weekStart = ref(startOfWeek(new Date(), { weekStartsOn: 1 }))
const days = computed(() => {
return Array.from({ length: 7 }, (_, i) => addDays(weekStart.value, i))
})
const isToday = (date: Date) => isSameDay(date, new Date())
const isSelected = (date: Date) => props.selectedDate ? isSameDay(date, props.selectedDate) : false
function prevWeek() {
weekStart.value = addDays(weekStart.value, -7)
}
function nextWeek() {
weekStart.value = addDays(weekStart.value, 7)
}
</script>
<template>
<div class="flex items-center gap-2">
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="prevWeek">
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="flex gap-1 overflow-x-auto flex-1 justify-center">
<button
v-for="day in days"
:key="day.toISOString()"
class="flex flex-col items-center px-2.5 py-1.5 rounded-lg min-w-[3rem] transition-colors"
:class="{
'bg-primary text-primary-foreground': isSelected(day),
'bg-muted/50': isToday(day) && !isSelected(day),
'hover:bg-muted': !isSelected(day),
}"
@click="emit('select', day)"
>
<span class="text-[10px] font-medium uppercase leading-none"
:class="isSelected(day) ? 'text-primary-foreground/70' : 'text-muted-foreground'"
>
{{ format(day, 'EEE') }}
</span>
<span class="text-sm font-semibold leading-tight mt-0.5">
{{ format(day, 'd') }}
</span>
</button>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="nextWeek">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import type { TemporalFilter } from '../types/filters'
const props = defineProps<{
modelValue: TemporalFilter
}>()
const emit = defineEmits<{
'update:modelValue': [value: TemporalFilter]
}>()
const { t } = useI18n()
const options: { value: TemporalFilter; labelKey: string }[] = [
{ value: 'all', labelKey: 'activities.filters.all' },
{ value: 'today', labelKey: 'activities.filters.today' },
{ value: 'tomorrow', labelKey: 'activities.filters.tomorrow' },
{ value: 'this-week', labelKey: 'activities.filters.thisWeek' },
{ value: 'this-month', labelKey: 'activities.filters.thisMonth' },
]
</script>
<template>
<div class="flex flex-wrap gap-2">
<Button
v-for="option in options"
:key="option.value"
:variant="props.modelValue === option.value ? 'default' : 'outline'"
size="sm"
class="rounded-full text-xs"
@click="emit('update:modelValue', option.value)"
>
{{ t(option.labelKey) }}
</Button>
</div>
</template>

View file

@ -0,0 +1,132 @@
import { ref, computed, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters'
/**
* Main composable for activities discovery.
* Subscribes to NIP-52 events via ActivitiesNostrService and manages the activity feed.
*/
export function useActivities() {
const store = useActivitiesStore()
const filters = useActivityFilters()
const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null)
let unsubscribe: (() => void) | null = null
// Filtered and sorted activities
const filteredActivities = computed(() => {
const upcoming = store.upcomingActivities
return filters.applyFilters(upcoming)
})
const pastFilteredActivities = computed(() => {
return filters.applyFilters(store.pastActivities)
})
/**
* Subscribe to NIP-52 calendar events from Nostr relays.
*/
function subscribe(eventFilters?: CalendarEventFilters) {
if (isSubscribed.value) return
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
subscriptionError.value = 'Activities service not available'
return
}
try {
store.isLoading = true
subscriptionError.value = null
unsubscribe = nostrService.subscribeToCalendarEvents(
(activity) => {
store.upsertActivity(activity)
store.isLoading = false
},
eventFilters
)
isSubscribed.value = true
// Set loading to false after a timeout (in case no events arrive)
setTimeout(() => {
store.isLoading = false
}, 5000)
} catch (err) {
subscriptionError.value = err instanceof Error ? err.message : 'Failed to subscribe'
store.isLoading = false
}
}
/**
* One-shot query for calendar events.
*/
async function query(eventFilters?: CalendarEventFilters) {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
subscriptionError.value = 'Activities service not available'
return
}
try {
store.isLoading = true
subscriptionError.value = null
const activities = await nostrService.queryCalendarEvents(eventFilters)
store.upsertActivities(activities)
} catch (err) {
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
} finally {
store.isLoading = false
}
}
/**
* Unsubscribe from relay events.
*/
function stop() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
isSubscribed.value = false
}
/**
* Refresh: stop current subscription and re-subscribe.
*/
function refresh(eventFilters?: CalendarEventFilters) {
stop()
store.clearAll()
subscribe(eventFilters)
}
// Cleanup on unmount
onUnmounted(() => {
stop()
})
return {
// State
activities: filteredActivities,
pastActivities: pastFilteredActivities,
allActivities: computed(() => store.activities),
isLoading: computed(() => store.isLoading),
isSubscribed,
error: subscriptionError,
lastUpdated: computed(() => store.lastUpdated),
// Filter controls (re-exported)
...filters,
// Actions
subscribe,
query,
stop,
refresh,
}
}

View file

@ -0,0 +1,78 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import { useActivitiesStore } from '../stores/activities'
import type { Activity } from '../types/activity'
/**
* Composable for loading a single activity by its d-tag identifier.
* First checks the store cache, then queries relays if not found.
*/
export function useActivityDetail(activityId: string) {
const store = useActivitiesStore()
const isLoading = ref(false)
const error = ref<string | null>(null)
let unsubscribe: (() => void) | null = null
const activity = computed<Activity | undefined>(() =>
store.getActivityById(activityId)
)
async function load() {
// Already in cache
if (activity.value) return
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
error.value = 'Activities service not available'
return
}
try {
isLoading.value = true
error.value = null
// Subscribe and wait for this specific event
unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => {
store.upsertActivity(incoming)
if (incoming.id === activityId) {
isLoading.value = false
}
}
)
// Also do a one-shot query
const results = await nostrService.queryCalendarEvents()
store.upsertActivities(results)
// If we still don't have it after query, stop loading
setTimeout(() => {
isLoading.value = false
if (!activity.value) {
error.value = 'Activity not found'
}
}, 5000)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load activity'
isLoading.value = false
}
}
onMounted(() => {
load()
})
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
}
})
return {
activity,
isLoading,
error,
reload: load,
}
}

View file

@ -0,0 +1,136 @@
import { ref, computed } from 'vue'
import {
startOfDay, endOfDay, startOfWeek, endOfWeek,
startOfMonth, endOfMonth, addDays,
} from 'date-fns'
import type { Activity } from '../types/activity'
import type { ActivityCategory } from '../types/category'
import type { TemporalFilter, ActivityFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters'
/**
* Composable for managing activity filter state and applying filters reactively.
*/
export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const searchQuery = ref('')
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,
categories: selectedCategories.value,
search: searchQuery.value || undefined,
}))
/**
* Apply the current filters to a list of activities.
*/
function applyFilters(activities: Activity[]): Activity[] {
let result = activities
// Temporal filter
result = applyTemporalFilter(result, temporal.value)
// Category filter
if (selectedCategories.value.length > 0) {
result = result.filter(a =>
a.category && selectedCategories.value.includes(a.category)
)
}
// Search filter
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
result = result.filter(a =>
a.title.toLowerCase().includes(query) ||
a.summary?.toLowerCase().includes(query) ||
a.description.toLowerCase().includes(query) ||
a.location?.toLowerCase().includes(query)
)
}
return result
}
function setTemporal(value: TemporalFilter) {
temporal.value = value
}
function toggleCategory(category: ActivityCategory) {
const idx = selectedCategories.value.indexOf(category)
if (idx >= 0) {
selectedCategories.value.splice(idx, 1)
} else {
selectedCategories.value.push(category)
}
}
function clearCategories() {
selectedCategories.value = []
}
function resetFilters() {
temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = []
searchQuery.value = ''
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
searchQuery.value.trim().length > 0
)
return {
// State
temporal,
selectedCategories,
searchQuery,
filters,
hasActiveFilters,
// Actions
applyFilters,
setTemporal,
toggleCategory,
clearCategories,
resetFilters,
}
}
// --- Helpers ---
function applyTemporalFilter(activities: Activity[], filter: TemporalFilter): Activity[] {
if (filter === 'all') return activities
const now = new Date()
let start: Date
let end: Date
switch (filter) {
case 'today':
start = startOfDay(now)
end = endOfDay(now)
break
case 'tomorrow':
start = startOfDay(addDays(now, 1))
end = endOfDay(addDays(now, 1))
break
case 'this-week':
start = startOfWeek(now, { weekStartsOn: 1 })
end = endOfWeek(now, { weekStartsOn: 1 })
break
case 'this-month':
start = startOfMonth(now)
end = endOfMonth(now)
break
default:
return activities
}
return activities.filter(a => {
const activityEnd = a.endDate ?? a.startDate
// Activity overlaps with the filter range
return a.startDate <= end && activityEnd >= start
})
}

View file

@ -1,10 +1,160 @@
<script setup lang="ts"> <script setup lang="ts">
// Phase 1 will implement the full activities feed with filters import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { RefreshCw, Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import DatePickerStrip from '../components/DatePickerStrip.vue'
import ActivityList from '../components/ActivityList.vue'
import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const {
activities,
pastActivities,
isLoading,
error,
temporal,
selectedCategories,
searchQuery,
hasActiveFilters,
toggleCategory,
clearCategories,
resetFilters,
subscribe,
refresh,
} = useActivities()
const filtersOpen = ref(false)
onMounted(() => {
subscribe()
})
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
}
function handleRefresh() {
refresh()
}
</script> </script>
<template> <template>
<div class="container mx-auto px-4 py-6"> <div class="container mx-auto py-6 px-4">
<h1 class="text-2xl font-bold text-foreground">Activities</h1> <!-- Page header -->
<p class="text-muted-foreground mt-2">Coming soon Nostr-native event discovery</p> <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div class="space-y-1">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ t('activities.title') }}
</h1>
</div>
<div class="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" size="sm" @click="handleRefresh" :disabled="isLoading">
<RefreshCw class="w-4 h-4 mr-1.5" :class="{ 'animate-spin': isLoading }" />
Refresh
</Button>
</div>
</div>
<!-- Search bar -->
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search activities..."
class="pl-9"
/>
</div>
<!-- Date picker strip (p'a semana style) -->
<div class="mb-4">
<DatePickerStrip />
</div>
<!-- Temporal filter pills -->
<div class="mb-4">
<TemporalFilterBar v-model="temporal" />
</div>
<!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child>
<Button variant="ghost" size="sm" class="gap-1.5 text-muted-foreground">
<SlidersHorizontal class="w-4 h-4" />
Categories
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
{{ selectedCategories.length }}
</span>
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="mt-2">
<CategoryFilterBar
:selected="selectedCategories"
@toggle="toggleCategory"
@clear="clearCategories"
/>
</CollapsibleContent>
</Collapsible>
<!-- Active filters indicator -->
<div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
<span class="text-xs text-muted-foreground">Filters active</span>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
Clear all
</Button>
</div>
<!-- Error state -->
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
{{ error }}
</div>
<!-- Tabs: Upcoming / Past -->
<Tabs default-value="upcoming" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="upcoming">
Upcoming
<span v-if="activities.length > 0" class="ml-1.5 text-xs opacity-60">
({{ activities.length }})
</span>
</TabsTrigger>
<TabsTrigger value="past">
Past
<span v-if="pastActivities.length > 0" class="ml-1.5 text-xs opacity-60">
({{ pastActivities.length }})
</span>
</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
<ActivityList
:activities="activities"
:is-loading="isLoading"
@select="handleSelectActivity"
/>
</TabsContent>
<TabsContent value="past">
<ActivityList
:activities="pastActivities"
:is-loading="isLoading"
@select="handleSelectActivity"
/>
</TabsContent>
</Tabs>
</div> </div>
</template> </template>

View file

@ -1,13 +1,156 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft, User,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
const route = useRoute() const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const activityId = route.params.id as string const activityId = route.params.id as string
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
const dateDisplay = computed(() => {
if (!activity.value) return ''
const a = activity.value
if (a.type === 'date') {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy')
if (a.endDate) {
return `${start}${format(a.endDate, 'EEEE, MMMM d, yyyy')}`
}
return start
}
const start = format(a.startDate, 'EEEE, MMMM d, yyyy \u2022 HH:mm')
if (a.endDate) {
return `${start}${format(a.endDate, 'HH:mm')}`
}
return start
})
const categoryLabel = computed(() => {
if (!activity.value?.category) return null
return t(`activities.categories.${activity.value.category}`, activity.value.category)
})
function goBack() {
router.push({ name: 'activities' })
}
</script> </script>
<template> <template>
<div class="container mx-auto px-4 py-6"> <div class="container mx-auto py-6 px-4 max-w-3xl">
<h1 class="text-2xl font-bold text-foreground">Activity Detail</h1> <!-- Back button -->
<p class="text-muted-foreground mt-2">Activity: {{ activityId }}</p> <Button variant="ghost" size="sm" class="mb-4 gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<!-- Loading -->
<div v-if="isLoading" class="space-y-4">
<div class="aspect-[16/9] bg-muted rounded-lg animate-pulse" />
<div class="h-8 bg-muted rounded w-3/4 animate-pulse" />
<div class="h-4 bg-muted rounded w-1/2 animate-pulse" />
<div class="h-32 bg-muted rounded animate-pulse" />
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-16">
<h2 class="text-xl font-semibold text-foreground mb-2">Activity not found</h2>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button variant="outline" @click="reload">Retry</Button>
</div>
<!-- Detail content -->
<div v-else-if="activity" class="space-y-6">
<!-- Hero image -->
<div v-if="activity.image" class="rounded-lg overflow-hidden">
<img
:src="activity.image"
:alt="activity.title"
class="w-full aspect-[16/9] object-cover"
/>
</div>
<!-- Title + Category -->
<div>
<div class="flex items-start gap-2 mb-2">
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
{{ categoryLabel }}
</Badge>
<div v-for="tag in activity.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }}
</h1>
<p v-if="activity.summary" class="text-muted-foreground mt-2">
{{ activity.summary }}
</p>
</div>
<Separator />
<!-- Info section -->
<div class="grid gap-4 sm:grid-cols-2">
<!-- When -->
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<Calendar class="w-4 h-4" />
{{ t('activities.detail.when') }}
</div>
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
<p v-if="activity.timezone" class="text-xs text-muted-foreground/70">
{{ activity.timezone }}
</p>
</div>
<!-- Where -->
<div v-if="activity.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<MapPin class="w-4 h-4" />
{{ t('activities.detail.location') }}
</div>
<p class="text-sm text-muted-foreground">{{ activity.location }}</p>
</div>
</div>
<!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<User class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-foreground">
{{ t('activities.detail.organizer') }}
</p>
<p class="text-xs text-muted-foreground font-mono truncate">
{{ activity.organizer.name ?? activity.organizer.pubkey.slice(0, 16) + '...' }}
</p>
</div>
</div>
</div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
</div>
<!-- External references -->
<div v-if="activity.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->
</div>
</div>
</div> </div>
</template> </template>