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:
parent
e98356ffa0
commit
eebc1865c9
11 changed files with 1014 additions and 12 deletions
|
|
@ -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) {
|
||||
items.push({
|
||||
name: t('nav.chat'),
|
||||
|
|
|
|||
136
src/modules/activities/components/ActivityCard.vue
Normal file
136
src/modules/activities/components/ActivityCard.vue
Normal 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>
|
||||
59
src/modules/activities/components/ActivityList.vue
Normal file
59
src/modules/activities/components/ActivityList.vue
Normal 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>
|
||||
52
src/modules/activities/components/CategoryFilterBar.vue
Normal file
52
src/modules/activities/components/CategoryFilterBar.vue
Normal 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>
|
||||
70
src/modules/activities/components/DatePickerStrip.vue
Normal file
70
src/modules/activities/components/DatePickerStrip.vue
Normal 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>
|
||||
38
src/modules/activities/components/TemporalFilterBar.vue
Normal file
38
src/modules/activities/components/TemporalFilterBar.vue
Normal 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>
|
||||
132
src/modules/activities/composables/useActivities.ts
Normal file
132
src/modules/activities/composables/useActivities.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
78
src/modules/activities/composables/useActivityDetail.ts
Normal file
78
src/modules/activities/composables/useActivityDetail.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
136
src/modules/activities/composables/useActivityFilters.ts
Normal file
136
src/modules/activities/composables/useActivityFilters.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -1,10 +1,160 @@
|
|||
<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>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Activities</h1>
|
||||
<p class="text-muted-foreground mt-2">Coming soon — Nostr-native event discovery</p>
|
||||
<div class="container mx-auto py-6 px-4">
|
||||
<!-- Page header -->
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,156 @@
|
|||
<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 router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Activity Detail</h1>
|
||||
<p class="text-muted-foreground mt-2">Activity: {{ activityId }}</p>
|
||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
||||
<!-- Back button -->
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue