Add map and calendar views (Phase 5)
Map view: Leaflet + OpenStreetMap with markers from NIP-52 geohash tags. Click popup navigates to activity detail. Auto-fits bounds to show all markers. Geohash decoding via ngeohash added to Activity model conversion. Calendar view: Month grid with activity dots (up to 3 per day). Click a day to see activities listed below the calendar. Month navigation with prev/next buttons. New dependencies: leaflet, @types/leaflet, ngeohash, @types/ngeohash. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e0dde90adc
commit
4926c3a313
7 changed files with 408 additions and 11 deletions
43
package-lock.json
generated
43
package-lock.json
generated
|
|
@ -18,8 +18,10 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"light-bolt11-decoder": "^3.2.0",
|
||||
"lucide-vue-next": "^0.474.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"pinia": "^2.3.1",
|
||||
"qr-scanner": "^1.4.2",
|
||||
|
|
@ -48,6 +50,8 @@
|
|||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/node": "^22.18.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/rollup-plugin-visualizer": "^4.2.3",
|
||||
|
|
@ -5065,6 +5069,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||
|
|
@ -5082,6 +5093,23 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ngeohash": {
|
||||
"version": "0.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/ngeohash/-/ngeohash-0.6.8.tgz",
|
||||
"integrity": "sha512-A90x3HMwE1yXbWCnd0ztHzv8rAQPjwTzX2diYI/6OrWm/3oairDaehw5WPWJFgZ+8+J/OuF99IbipmMa2le6tQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz",
|
||||
|
|
@ -9775,6 +9803,12 @@
|
|||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
|
@ -10905,6 +10939,15 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ngeohash": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
|
||||
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nice-try": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"light-bolt11-decoder": "^3.2.0",
|
||||
"lucide-vue-next": "^0.474.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"pinia": "^2.3.1",
|
||||
"qr-scanner": "^1.4.2",
|
||||
|
|
@ -60,6 +62,8 @@
|
|||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/node": "^22.18.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/rollup-plugin-visualizer": "^4.2.3",
|
||||
|
|
|
|||
168
src/modules/activities/components/ActivityCalendarView.vue
Normal file
168
src/modules/activities/components/ActivityCalendarView.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, format, isSameMonth, isSameDay,
|
||||
addMonths, subMonths,
|
||||
} from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import type { Activity } from '../types/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
activities: Activity[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectDate: [date: Date]
|
||||
selectActivity: [activity: Activity]
|
||||
}>()
|
||||
|
||||
const currentMonth = ref(new Date())
|
||||
|
||||
const monthLabel = computed(() => format(currentMonth.value, 'MMMM yyyy'))
|
||||
|
||||
const weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
|
||||
|
||||
const calendarDays = computed(() => {
|
||||
const monthStart = startOfMonth(currentMonth.value)
|
||||
const monthEnd = endOfMonth(currentMonth.value)
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 })
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 })
|
||||
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd })
|
||||
})
|
||||
|
||||
// Map of date string -> activities on that day
|
||||
const activityDayMap = computed(() => {
|
||||
const map = new Map<string, Activity[]>()
|
||||
for (const activity of props.activities) {
|
||||
const key = format(activity.startDate, 'yyyy-MM-dd')
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(activity)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getActivitiesForDay(date: Date): Activity[] {
|
||||
const key = format(date, 'yyyy-MM-dd')
|
||||
return activityDayMap.value.get(key) ?? []
|
||||
}
|
||||
|
||||
function getDotCount(date: Date): number {
|
||||
return Math.min(getActivitiesForDay(date).length, 3)
|
||||
}
|
||||
|
||||
const selectedDay = ref<Date | null>(null)
|
||||
const selectedDayActivities = computed(() => {
|
||||
if (!selectedDay.value) return []
|
||||
return getActivitiesForDay(selectedDay.value)
|
||||
})
|
||||
|
||||
function selectDay(date: Date) {
|
||||
if (selectedDay.value && isSameDay(selectedDay.value, date)) {
|
||||
selectedDay.value = null
|
||||
} else {
|
||||
selectedDay.value = date
|
||||
emit('selectDate', date)
|
||||
}
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
currentMonth.value = subMonths(currentMonth.value, 1)
|
||||
selectedDay.value = null
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentMonth.value = addMonths(currentMonth.value, 1)
|
||||
selectedDay.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Month navigation -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="prevMonth">
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ monthLabel }}</h2>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="nextMonth">
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Weekday headers -->
|
||||
<div class="grid grid-cols-7 gap-0">
|
||||
<div
|
||||
v-for="day in weekDays"
|
||||
:key="day"
|
||||
class="text-center text-xs font-medium text-muted-foreground py-1"
|
||||
>
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="grid grid-cols-7 gap-0">
|
||||
<button
|
||||
v-for="date in calendarDays"
|
||||
:key="date.toISOString()"
|
||||
class="aspect-square flex flex-col items-center justify-center relative p-1 rounded-lg transition-colors"
|
||||
:class="{
|
||||
'text-muted-foreground/40': !isSameMonth(date, currentMonth),
|
||||
'bg-primary text-primary-foreground': selectedDay && isSameDay(date, selectedDay),
|
||||
'bg-muted/50': isSameDay(date, new Date()) && !(selectedDay && isSameDay(date, selectedDay)),
|
||||
'hover:bg-muted': !(selectedDay && isSameDay(date, selectedDay)),
|
||||
}"
|
||||
@click="selectDay(date)"
|
||||
>
|
||||
<span class="text-sm">{{ format(date, 'd') }}</span>
|
||||
<!-- Activity dots -->
|
||||
<div v-if="getDotCount(date) > 0" class="flex gap-0.5 mt-0.5">
|
||||
<div
|
||||
v-for="i in getDotCount(date)"
|
||||
:key="i"
|
||||
class="w-1 h-1 rounded-full"
|
||||
:class="selectedDay && isSameDay(date, selectedDay) ? 'bg-primary-foreground' : 'bg-primary'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected day activities -->
|
||||
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
|
||||
<h3 class="text-sm font-medium text-muted-foreground">
|
||||
{{ format(selectedDay, 'EEEE, MMMM d') }}
|
||||
<span v-if="selectedDayActivities.length > 0" class="ml-1">
|
||||
({{ selectedDayActivities.length }})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="selectedDayActivities.length === 0" class="text-sm text-muted-foreground/70 py-4 text-center">
|
||||
No activities on this day
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="activity in selectedDayActivities"
|
||||
:key="activity.nostrEventId"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-muted cursor-pointer"
|
||||
@click="emit('selectActivity', activity)"
|
||||
>
|
||||
<img
|
||||
v-if="activity.image"
|
||||
:src="activity.image"
|
||||
:alt="activity.title"
|
||||
class="w-12 h-12 rounded object-cover shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{{ activity.type === 'time' ? format(activity.startDate, 'HH:mm') : '' }}
|
||||
{{ activity.location ? `· ${activity.location}` : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
124
src/modules/activities/components/ActivityMap.vue
Normal file
124
src/modules/activities/components/ActivityMap.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import type { Activity } from '../types/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
activities: Activity[]
|
||||
center?: { lat: number; lng: number }
|
||||
zoom?: number
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
let map: L.Map | null = null
|
||||
let markerGroup: L.LayerGroup | null = null
|
||||
|
||||
// Fix Leaflet default icon paths (broken by bundlers)
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
})
|
||||
|
||||
function initMap() {
|
||||
if (!mapContainer.value) return
|
||||
|
||||
const defaultCenter = props.center ?? { lat: 46.6034, lng: 1.8883 } // France
|
||||
const defaultZoom = props.zoom ?? 5
|
||||
|
||||
map = L.map(mapContainer.value).setView(
|
||||
[defaultCenter.lat, defaultCenter.lng],
|
||||
defaultZoom
|
||||
)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
markerGroup = L.layerGroup().addTo(map)
|
||||
|
||||
updateMarkers()
|
||||
}
|
||||
|
||||
function updateMarkers() {
|
||||
if (!map || !markerGroup) return
|
||||
|
||||
markerGroup.clearLayers()
|
||||
|
||||
const geoActivities = props.activities.filter(a => a.coordinates)
|
||||
|
||||
for (const activity of geoActivities) {
|
||||
const { lat, lng } = activity.coordinates!
|
||||
|
||||
const marker = L.marker([lat, lng], { icon: defaultIcon })
|
||||
|
||||
const popupContent = `
|
||||
<div style="min-width: 200px; cursor: pointer;" class="activity-popup">
|
||||
${activity.image ? `<img src="${activity.image}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 4px; margin-bottom: 8px;" />` : ''}
|
||||
<div style="font-weight: 600; font-size: 14px; margin-bottom: 4px;">${escapeHtml(activity.title)}</div>
|
||||
${activity.location ? `<div style="font-size: 12px; color: #888; margin-bottom: 2px;">📍 ${escapeHtml(activity.location)}</div>` : ''}
|
||||
<div style="font-size: 12px; color: #888;">📅 ${activity.startDate.toLocaleDateString()}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
marker.on('click', () => {
|
||||
marker.openPopup()
|
||||
})
|
||||
// Navigate on popup click
|
||||
marker.on('popupopen', () => {
|
||||
const popup = marker.getPopup()
|
||||
if (popup) {
|
||||
const el = popup.getElement()
|
||||
const content = el?.querySelector('.activity-popup')
|
||||
if (content) {
|
||||
(content as HTMLElement).onclick = () => {
|
||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
markerGroup.addLayer(marker)
|
||||
}
|
||||
|
||||
// Fit bounds if there are markers
|
||||
if (geoActivities.length > 0) {
|
||||
const bounds = L.latLngBounds(
|
||||
geoActivities.map(a => [a.coordinates!.lat, a.coordinates!.lng] as L.LatLngTuple)
|
||||
)
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 })
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
watch(() => props.activities, updateMarkers, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="mapContainer" class="w-full h-full min-h-[400px] rounded-lg z-0" />
|
||||
</template>
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import ngeohash from 'ngeohash'
|
||||
import type { ActivityCategory } from './category'
|
||||
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
|
||||
|
||||
|
|
@ -82,6 +83,7 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
|
|||
endDate: event.end ? new Date(event.end * 1000) : undefined,
|
||||
timezone: event.startTzid,
|
||||
location: event.location,
|
||||
coordinates: decodeGeohash(event.geohash),
|
||||
geohash: event.geohash,
|
||||
category,
|
||||
tags: event.hashtags,
|
||||
|
|
@ -117,6 +119,7 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
|
|||
startDate: parseIsoDate(event.start),
|
||||
endDate: event.end ? parseIsoDate(event.end) : undefined,
|
||||
location: event.location,
|
||||
coordinates: decodeGeohash(event.geohash),
|
||||
geohash: event.geohash,
|
||||
category,
|
||||
tags: event.hashtags,
|
||||
|
|
@ -124,3 +127,13 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
|
|||
createdAt: new Date(event.createdAt * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
|
||||
if (!geohash) return undefined
|
||||
try {
|
||||
const { latitude, longitude } = ngeohash.decode(geohash)
|
||||
return { lat: latitude, lng: longitude }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import { CalendarDays } from 'lucide-vue-next'
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
|
||||
import type { Activity } from '../types/activity'
|
||||
|
||||
const router = useRouter()
|
||||
const { allActivities, subscribe } = useActivities()
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
|
||||
function handleSelectActivity(activity: Activity) {
|
||||
router.push({ name: 'activity-detail', params: { id: activity.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-4">Calendar</h1>
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<CalendarDays class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<p class="text-muted-foreground">Calendar view coming in Phase 5</p>
|
||||
</div>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<ActivityCalendarView
|
||||
:activities="allActivities"
|
||||
@select-activity="handleSelectActivity"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,44 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { Map } from 'lucide-vue-next'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import ActivityMap from '../components/ActivityMap.vue'
|
||||
|
||||
const { allActivities, isLoading, subscribe } = useActivities()
|
||||
|
||||
const geoActivities = computed(() =>
|
||||
allActivities.value.filter(a => a.coordinates)
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-4">Map</h1>
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
|
||||
<!-- No geotagged activities -->
|
||||
<div v-else-if="!isLoading && geoActivities.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<p class="text-muted-foreground">Map view coming in Phase 5</p>
|
||||
<p class="text-muted-foreground">No geotagged activities found</p>
|
||||
<p class="text-sm text-muted-foreground/70 mt-1">Activities with location data will appear as markers on the map</p>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<ActivityMap
|
||||
v-else
|
||||
:activities="geoActivities"
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<!-- Activity count -->
|
||||
<div v-if="geoActivities.length > 0" class="px-4 py-2 text-xs text-muted-foreground border-t bg-background">
|
||||
{{ geoActivities.length }} activit{{ geoActivities.length === 1 ? 'y' : 'ies' }} on map
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue