feat(ui): cosmetic tweaks — profile pencil, pills, search, ticket count, map icon, avatar trigger, no overlay animations #105
13 changed files with 99 additions and 31 deletions
|
|
@ -221,3 +221,16 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Disable enter/exit animations on reka-ui overlays (dialog, sheet,
|
||||||
|
* popover, dropdown, tooltip, …) app-wide. They animate via the
|
||||||
|
* data-state open/closed attribute; zeroing the duration keeps the final
|
||||||
|
* state but removes the motion (overlays appear/disappear instantly).
|
||||||
|
* Pulse/spin loaders and CSS transitions (e.g. hovers, the favourite
|
||||||
|
* heart pop) are unaffected.
|
||||||
|
*/
|
||||||
|
[data-state='open'],
|
||||||
|
[data-state='closed'] {
|
||||||
|
animation-duration: 0s !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Check, Copy, Home, LogIn, LogOut, Settings, Zap } from 'lucide-vue-next'
|
import { Check, Copy, Home, LogIn, LogOut, Pencil, Zap } from 'lucide-vue-next'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -104,19 +104,7 @@ async function onLogout() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||||
<Button
|
|
||||||
v-if="isAuthenticated"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 shrink-0"
|
|
||||||
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
|
||||||
@click="editProfileOpen = true"
|
|
||||||
>
|
|
||||||
<Settings class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<SheetDescription v-if="isAuthenticated">
|
<SheetDescription v-if="isAuthenticated">
|
||||||
{{ t('common.nav.profileDescription') }}
|
{{ t('common.nav.profileDescription') }}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
|
|
@ -125,8 +113,8 @@ async function onLogout() {
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<!-- Identity card (logged in) — read-only summary. Editing happens
|
<!-- Identity card (logged in) — summary with an inline edit (pencil)
|
||||||
through the gear button next to the title. -->
|
button that opens the profile form. -->
|
||||||
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
|
<div v-if="isAuthenticated" class="mt-4 rounded-lg border bg-muted/30 p-3 space-y-4">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<Avatar class="h-12 w-12 shrink-0">
|
<Avatar class="h-12 w-12 shrink-0">
|
||||||
|
|
@ -139,6 +127,15 @@ async function onLogout() {
|
||||||
@{{ user.username }}
|
@{{ user.username }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 self-start text-muted-foreground"
|
||||||
|
:aria-label="t('common.nav.editProfile', 'Edit profile')"
|
||||||
|
@click="editProfileOpen = true"
|
||||||
|
>
|
||||||
|
<Pencil class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
||||||
|
|
@ -246,8 +243,8 @@ async function onLogout() {
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit-profile popup (gear icon) — the full form lives here so the
|
<!-- Edit-profile popup (pencil button in the identity card) — the full
|
||||||
sheet stays scannable. -->
|
form lives here so the sheet stays scannable. -->
|
||||||
<Dialog v-model:open="editProfileOpen">
|
<Dialog v-model:open="editProfileOpen">
|
||||||
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@
|
||||||
import { ref, type Component } from 'vue'
|
import { ref, type Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Menu } from 'lucide-vue-next'
|
import { LogIn } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
|
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||||
import ProfileSheetContent from './ProfileSheetContent.vue'
|
import ProfileSheetContent from './ProfileSheetContent.vue'
|
||||||
|
|
||||||
export interface SidebarNavItem {
|
export interface SidebarNavItem {
|
||||||
|
|
@ -35,6 +38,9 @@ const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||||
|
|
||||||
function handleClick(item: SidebarNavItem) {
|
function handleClick(item: SidebarNavItem) {
|
||||||
if (item.path) router.push(item.path)
|
if (item.path) router.push(item.path)
|
||||||
item.onClick?.()
|
item.onClick?.()
|
||||||
|
|
@ -46,11 +52,17 @@ function handleClick(item: SidebarNavItem) {
|
||||||
<Sheet v-model:open="open">
|
<Sheet v-model:open="open">
|
||||||
<SheetTrigger as-child>
|
<SheetTrigger as-child>
|
||||||
<button
|
<button
|
||||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center overflow-hidden rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
||||||
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
||||||
:aria-label="t('common.nav.menu')"
|
:aria-label="isAuthenticated ? t('common.nav.profile') : t('common.nav.login')"
|
||||||
>
|
>
|
||||||
<Menu class="w-5 h-5" />
|
<!-- Logged in: avatar (image, or first initial). Logged out: a
|
||||||
|
login icon. Opens the same profile/menu sheet either way. -->
|
||||||
|
<Avatar v-if="isAuthenticated" class="h-full w-full">
|
||||||
|
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||||
|
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<LogIn v-else class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">
|
<SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
||||||
when: 'When',
|
when: 'When',
|
||||||
tickets: 'Tickets',
|
tickets: 'Tickets',
|
||||||
ticketsAvailable: '{count} tickets available',
|
ticketsAvailable: '{count} tickets available',
|
||||||
|
ticketsRemainingOfTotal: '{count} of {total} tickets left',
|
||||||
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
||||||
unlimitedTickets: 'Unlimited tickets',
|
unlimitedTickets: 'Unlimited tickets',
|
||||||
buyTicket: 'Buy ticket',
|
buyTicket: 'Buy ticket',
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
||||||
when: 'Cuándo',
|
when: 'Cuándo',
|
||||||
tickets: 'Boletos',
|
tickets: 'Boletos',
|
||||||
ticketsAvailable: '{count} boletos disponibles',
|
ticketsAvailable: '{count} boletos disponibles',
|
||||||
|
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
|
||||||
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
||||||
unlimitedTickets: 'Boletos ilimitados',
|
unlimitedTickets: 'Boletos ilimitados',
|
||||||
buyTicket: 'Comprar boleto',
|
buyTicket: 'Comprar boleto',
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
||||||
when: 'Quand',
|
when: 'Quand',
|
||||||
tickets: 'Billets',
|
tickets: 'Billets',
|
||||||
ticketsAvailable: '{count} billets disponibles',
|
ticketsAvailable: '{count} billets disponibles',
|
||||||
|
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
|
||||||
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
||||||
unlimitedTickets: 'Billets illimités',
|
unlimitedTickets: 'Billets illimités',
|
||||||
buyTicket: 'Acheter un billet',
|
buyTicket: 'Acheter un billet',
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export interface LocaleMessages {
|
||||||
when: string
|
when: string
|
||||||
tickets: string
|
tickets: string
|
||||||
ticketsAvailable: string
|
ticketsAvailable: string
|
||||||
|
ticketsRemainingOfTotal: string
|
||||||
ticketsOwned: string
|
ticketsOwned: string
|
||||||
unlimitedTickets: string
|
unlimitedTickets: string
|
||||||
buyTicket: string
|
buyTicket: string
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ const isNonApproved = computed(
|
||||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="event.ticketInfo.available > 0">
|
<span v-else-if="event.ticketInfo.available > 0">
|
||||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-destructive font-medium">
|
<span v-else class="text-destructive font-medium">
|
||||||
{{ t('events.detail.soldOut') }}
|
{{ t('events.detail.soldOut') }}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,13 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
|
||||||
import type { Event } from '../types/event'
|
import type { Event } from '../types/event'
|
||||||
|
|
||||||
|
/** Event enriched with its resolved organizer display name for search. */
|
||||||
|
type SearchableEvent = Event & { organizerName: string }
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
events: Event[]
|
events: Event[]
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -22,12 +27,13 @@ const { dateLocale } = useDateLocale()
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const searchOptions: FuzzySearchOptions<Event> = {
|
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
||||||
fuseOptions: {
|
fuseOptions: {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: 'title', weight: 0.5 },
|
{ name: 'title', weight: 0.5 },
|
||||||
{ name: 'summary', weight: 0.2 },
|
{ name: 'summary', weight: 0.2 },
|
||||||
{ name: 'description', weight: 0.15 },
|
{ name: 'description', weight: 0.15 },
|
||||||
|
{ name: 'organizerName', weight: 0.1 },
|
||||||
{ name: 'location', weight: 0.1 },
|
{ name: 'location', weight: 0.1 },
|
||||||
{ name: 'tags', weight: 0.05 },
|
{ name: 'tags', weight: 0.05 },
|
||||||
],
|
],
|
||||||
|
|
@ -39,7 +45,20 @@ const searchOptions: FuzzySearchOptions<Event> = {
|
||||||
resultLimit: 8,
|
resultLimit: 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsRef = computed(() => props.events)
|
// Organizer display names aren't stored on the event (they're fetched
|
||||||
|
// per-pubkey into the shared ProfileService cache). Read the resolved
|
||||||
|
// name from that same reactive cache so search matches it; the corpus
|
||||||
|
// recomputes as kind-0 metadata lands.
|
||||||
|
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||||
|
|
||||||
|
function organizerNameFor(pubkey: string): string {
|
||||||
|
const p = profileService?.profiles.get(pubkey)
|
||||||
|
return p?.display_name ?? p?.name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchCorpus = computed<SearchableEvent[]>(() =>
|
||||||
|
props.events.map((e) => ({ ...e, organizerName: organizerNameFor(e.organizer.pubkey) })),
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
|
@ -47,7 +66,7 @@ const {
|
||||||
isSearching,
|
isSearching,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
} = useFuzzySearch(eventsRef, searchOptions)
|
} = useFuzzySearch(searchCorpus, searchOptions)
|
||||||
|
|
||||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||||
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||||
|
|
@ -94,6 +113,18 @@ function handleClickOutside(e: MouseEvent) {
|
||||||
watch(isOpen, (open) => {
|
watch(isOpen, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
// Warm the shared profile cache for every organizer in the current
|
||||||
|
// set so their names become searchable (fetches dedupe in the
|
||||||
|
// service; the corpus reacts as kind-0 metadata arrives).
|
||||||
|
if (profileService) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const e of props.events) {
|
||||||
|
const pk = e.organizer.pubkey
|
||||||
|
if (seen.has(pk) || profileService.profiles.get(pk)) continue
|
||||||
|
seen.add(pk)
|
||||||
|
void profileService.getProfile(pk)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex gap-2 overflow-x-auto" style="-ms-overflow-style: none; scrollbar-width: none;">
|
<!-- pb-1 pr-1 keep the theme's offset drop-shadow (neobrut casts a hard 4px
|
||||||
|
shadow down and to the right) from being clipped at the scroll box's
|
||||||
|
bottom/right edges (overflow-x-auto forces overflow-y to auto). -->
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-1 pr-1" style="-ms-overflow-style: none; scrollbar-width: none;">
|
||||||
<Button
|
<Button
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="rounded-full text-xs shrink-0"
|
class="text-xs shrink-0"
|
||||||
@click="emit('update:modelValue', option.value)"
|
@click="emit('update:modelValue', option.value)"
|
||||||
>
|
>
|
||||||
{{ t(option.labelKey) }}
|
{{ t(option.labelKey) }}
|
||||||
|
|
@ -45,7 +48,7 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
||||||
<Button
|
<Button
|
||||||
:variant="props.showPast ? 'default' : 'outline'"
|
:variant="props.showPast ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="rounded-full text-xs shrink-0 gap-1.5"
|
class="text-xs shrink-0 gap-1.5"
|
||||||
@click="emit('toggle-past')"
|
@click="emit('toggle-past')"
|
||||||
>
|
>
|
||||||
<History class="w-3 h-3" />
|
<History class="w-3 h-3" />
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ export interface EventTicketInfo {
|
||||||
available?: number
|
available?: number
|
||||||
/** Running paid count. */
|
/** Running paid count. */
|
||||||
sold: number
|
sold: number
|
||||||
|
/** Total capacity (available + sold). Undefined means unlimited. */
|
||||||
|
total?: number
|
||||||
/** Whether the organizer enabled fiat checkout. */
|
/** Whether the organizer enabled fiat checkout. */
|
||||||
allowFiat: boolean
|
allowFiat: boolean
|
||||||
/** Fiat settle currency when allowFiat is true. */
|
/** Fiat settle currency when allowFiat is true. */
|
||||||
|
|
@ -91,6 +93,9 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
|
||||||
currency: ticket.currency,
|
currency: ticket.currency,
|
||||||
available: ticket.available,
|
available: ticket.available,
|
||||||
sold: ticket.sold,
|
sold: ticket.sold,
|
||||||
|
// Capacity isn't published directly; derive it from remaining + sold.
|
||||||
|
// Undefined `available` means unlimited, so total stays undefined too.
|
||||||
|
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
|
||||||
allowFiat: ticket.allowFiat,
|
allowFiat: ticket.allowFiat,
|
||||||
fiatCurrency: ticket.fiatCurrency,
|
fiatCurrency: ticket.fiatCurrency,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ function goToMyTickets() {
|
||||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ onMounted(() => {
|
||||||
|
|
||||||
<!-- No geotagged events -->
|
<!-- No geotagged events -->
|
||||||
<div v-else-if="!isLoading && geoEvents.length === 0" class="flex-1 flex flex-col items-center justify-center text-center px-4">
|
<div v-else-if="!isLoading && geoEvents.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" />
|
<!-- opacity-30 on the element (not /30 on the colour) so the icon's
|
||||||
|
overlapping fold strokes fade uniformly instead of compounding
|
||||||
|
alpha where they overlap. -->
|
||||||
|
<Map class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||||
<p class="text-muted-foreground">No geotagged events found</p>
|
<p class="text-muted-foreground">No geotagged events found</p>
|
||||||
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
<p class="text-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue