From eebc1865c927c5707402c09d5babc080a1a4d7bd Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Apr 2026 18:48:53 +0200 Subject: [PATCH] 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) --- src/composables/useModularNavigation.ts | 16 +- .../activities/components/ActivityCard.vue | 136 +++++++++++++++ .../activities/components/ActivityList.vue | 59 +++++++ .../components/CategoryFilterBar.vue | 52 ++++++ .../activities/components/DatePickerStrip.vue | 70 ++++++++ .../components/TemporalFilterBar.vue | 38 +++++ .../activities/composables/useActivities.ts | 132 +++++++++++++++ .../composables/useActivityDetail.ts | 78 +++++++++ .../composables/useActivityFilters.ts | 136 +++++++++++++++ .../activities/views/ActivitiesPage.vue | 158 +++++++++++++++++- .../activities/views/ActivityDetailPage.vue | 151 ++++++++++++++++- 11 files changed, 1014 insertions(+), 12 deletions(-) create mode 100644 src/modules/activities/components/ActivityCard.vue create mode 100644 src/modules/activities/components/ActivityList.vue create mode 100644 src/modules/activities/components/CategoryFilterBar.vue create mode 100644 src/modules/activities/components/DatePickerStrip.vue create mode 100644 src/modules/activities/components/TemporalFilterBar.vue create mode 100644 src/modules/activities/composables/useActivities.ts create mode 100644 src/modules/activities/composables/useActivityDetail.ts create mode 100644 src/modules/activities/composables/useActivityFilters.ts diff --git a/src/composables/useModularNavigation.ts b/src/composables/useModularNavigation.ts index af47f66..c57c10c 100644 --- a/src/composables/useModularNavigation.ts +++ b/src/composables/useModularNavigation.ts @@ -42,11 +42,19 @@ 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'), - href: '/chat', - requiresAuth: true + items.push({ + name: t('nav.chat'), + href: '/chat', + requiresAuth: true }) } diff --git a/src/modules/activities/components/ActivityCard.vue b/src/modules/activities/components/ActivityCard.vue new file mode 100644 index 0000000..6503536 --- /dev/null +++ b/src/modules/activities/components/ActivityCard.vue @@ -0,0 +1,136 @@ + + + diff --git a/src/modules/activities/components/ActivityList.vue b/src/modules/activities/components/ActivityList.vue new file mode 100644 index 0000000..4dac127 --- /dev/null +++ b/src/modules/activities/components/ActivityList.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/modules/activities/components/CategoryFilterBar.vue b/src/modules/activities/components/CategoryFilterBar.vue new file mode 100644 index 0000000..8069dcf --- /dev/null +++ b/src/modules/activities/components/CategoryFilterBar.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/modules/activities/components/DatePickerStrip.vue b/src/modules/activities/components/DatePickerStrip.vue new file mode 100644 index 0000000..b9ea5cb --- /dev/null +++ b/src/modules/activities/components/DatePickerStrip.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/modules/activities/components/TemporalFilterBar.vue b/src/modules/activities/components/TemporalFilterBar.vue new file mode 100644 index 0000000..2ccb4ec --- /dev/null +++ b/src/modules/activities/components/TemporalFilterBar.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/modules/activities/composables/useActivities.ts b/src/modules/activities/composables/useActivities.ts new file mode 100644 index 0000000..7d4186b --- /dev/null +++ b/src/modules/activities/composables/useActivities.ts @@ -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(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(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(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, + } +} diff --git a/src/modules/activities/composables/useActivityDetail.ts b/src/modules/activities/composables/useActivityDetail.ts new file mode 100644 index 0000000..e29a272 --- /dev/null +++ b/src/modules/activities/composables/useActivityDetail.ts @@ -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(null) + let unsubscribe: (() => void) | null = null + + const activity = computed(() => + store.getActivityById(activityId) + ) + + async function load() { + // Already in cache + if (activity.value) return + + const nostrService = tryInjectService(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, + } +} diff --git a/src/modules/activities/composables/useActivityFilters.ts b/src/modules/activities/composables/useActivityFilters.ts new file mode 100644 index 0000000..a42016c --- /dev/null +++ b/src/modules/activities/composables/useActivityFilters.ts @@ -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(DEFAULT_FILTERS.temporal) + const selectedCategories = ref([]) + const searchQuery = ref('') + + const filters = computed(() => ({ + 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 + }) +} diff --git a/src/modules/activities/views/ActivitiesPage.vue b/src/modules/activities/views/ActivitiesPage.vue index be3503f..f07e21f 100644 --- a/src/modules/activities/views/ActivitiesPage.vue +++ b/src/modules/activities/views/ActivitiesPage.vue @@ -1,10 +1,160 @@ diff --git a/src/modules/activities/views/ActivityDetailPage.vue b/src/modules/activities/views/ActivityDetailPage.vue index fa33fdf..ea6fa5a 100644 --- a/src/modules/activities/views/ActivityDetailPage.vue +++ b/src/modules/activities/views/ActivityDetailPage.vue @@ -1,13 +1,156 @@