Merge pull request 'feat(ui): cosmetic tweaks — profile pencil, pills, search, ticket count, map icon, avatar trigger, no overlay animations' (#105) from feat/ui-tweaks-2 into dev
Reviewed-on: #105
This commit is contained in:
commit
c9fc3652bb
13 changed files with 99 additions and 31 deletions
|
|
@ -221,3 +221,16 @@
|
|||
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">
|
||||
import { computed, ref } from 'vue'
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -104,19 +104,7 @@ async function onLogout() {
|
|||
|
||||
<template>
|
||||
<SheetHeader>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<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">
|
||||
{{ t('common.nav.profileDescription') }}
|
||||
</SheetDescription>
|
||||
|
|
@ -125,8 +113,8 @@ async function onLogout() {
|
|||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<!-- Identity card (logged in) — read-only summary. Editing happens
|
||||
through the gear button next to the title. -->
|
||||
<!-- Identity card (logged in) — summary with an inline edit (pencil)
|
||||
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 class="flex items-center gap-3 min-w-0">
|
||||
<Avatar class="h-12 w-12 shrink-0">
|
||||
|
|
@ -139,6 +127,15 @@ async function onLogout() {
|
|||
@{{ user.username }}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- Identifier rows: full-width value with a corner-offset "legend"
|
||||
|
|
@ -246,8 +243,8 @@ async function onLogout() {
|
|||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<!-- Edit-profile popup (gear icon) — the full form lives here so the
|
||||
sheet stays scannable. -->
|
||||
<!-- Edit-profile popup (pencil button in the identity card) — the full
|
||||
form lives here so the sheet stays scannable. -->
|
||||
<Dialog v-model:open="editProfileOpen">
|
||||
<DialogContent class="max-w-md max-h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
import { ref, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||
import ProfileSheetContent from './ProfileSheetContent.vue'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
|
|
@ -35,6 +38,9 @@ const { t } = useI18n()
|
|||
const router = useRouter()
|
||||
const open = ref(false)
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||
|
||||
function handleClick(item: SidebarNavItem) {
|
||||
if (item.path) router.push(item.path)
|
||||
item.onClick?.()
|
||||
|
|
@ -46,11 +52,17 @@ function handleClick(item: SidebarNavItem) {
|
|||
<Sheet v-model:open="open">
|
||||
<SheetTrigger as-child>
|
||||
<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)"
|
||||
: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>
|
||||
</SheetTrigger>
|
||||
<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',
|
||||
tickets: 'Tickets',
|
||||
ticketsAvailable: '{count} tickets available',
|
||||
ticketsRemainingOfTotal: '{count} of {total} tickets left',
|
||||
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
|
||||
unlimitedTickets: 'Unlimited tickets',
|
||||
buyTicket: 'Buy ticket',
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
|||
when: 'Cuándo',
|
||||
tickets: 'Boletos',
|
||||
ticketsAvailable: '{count} boletos disponibles',
|
||||
ticketsRemainingOfTotal: '{count} de {total} boletos restantes',
|
||||
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
|
||||
unlimitedTickets: 'Boletos ilimitados',
|
||||
buyTicket: 'Comprar boleto',
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ const messages: LocaleMessages = {
|
|||
when: 'Quand',
|
||||
tickets: 'Billets',
|
||||
ticketsAvailable: '{count} billets disponibles',
|
||||
ticketsRemainingOfTotal: '{count} sur {total} billets restants',
|
||||
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
|
||||
unlimitedTickets: 'Billets illimités',
|
||||
buyTicket: 'Acheter un billet',
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export interface LocaleMessages {
|
|||
when: string
|
||||
tickets: string
|
||||
ticketsAvailable: string
|
||||
ticketsRemainingOfTotal: string
|
||||
ticketsOwned: string
|
||||
unlimitedTickets: string
|
||||
buyTicket: string
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ const isNonApproved = computed(
|
|||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<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 v-else class="text-destructive font-medium">
|
||||
{{ t('events.detail.soldOut') }}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ import { Input } from '@/components/ui/input'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||
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'
|
||||
|
||||
/** Event enriched with its resolved organizer display name for search. */
|
||||
type SearchableEvent = Event & { organizerName: string }
|
||||
|
||||
const props = defineProps<{
|
||||
events: Event[]
|
||||
}>()
|
||||
|
|
@ -22,12 +27,13 @@ const { dateLocale } = useDateLocale()
|
|||
const isOpen = ref(false)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const searchOptions: FuzzySearchOptions<Event> = {
|
||||
const searchOptions: FuzzySearchOptions<SearchableEvent> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
{ name: 'summary', weight: 0.2 },
|
||||
{ name: 'description', weight: 0.15 },
|
||||
{ name: 'organizerName', weight: 0.1 },
|
||||
{ name: 'location', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.05 },
|
||||
],
|
||||
|
|
@ -39,7 +45,20 @@ const searchOptions: FuzzySearchOptions<Event> = {
|
|||
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 {
|
||||
searchQuery,
|
||||
|
|
@ -47,7 +66,7 @@ const {
|
|||
isSearching,
|
||||
clearSearch,
|
||||
setSearchQuery,
|
||||
} = useFuzzySearch(eventsRef, searchOptions)
|
||||
} = useFuzzySearch(searchCorpus, searchOptions)
|
||||
|
||||
const showResults = 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) => {
|
||||
if (open) {
|
||||
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 {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,16 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
|||
</script>
|
||||
|
||||
<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
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:variant="props.modelValue === option.value ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="rounded-full text-xs shrink-0"
|
||||
class="text-xs shrink-0"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ t(option.labelKey) }}
|
||||
|
|
@ -45,7 +48,7 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
|||
<Button
|
||||
:variant="props.showPast ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="rounded-full text-xs shrink-0 gap-1.5"
|
||||
class="text-xs shrink-0 gap-1.5"
|
||||
@click="emit('toggle-past')"
|
||||
>
|
||||
<History class="w-3 h-3" />
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export interface EventTicketInfo {
|
|||
available?: number
|
||||
/** Running paid count. */
|
||||
sold: number
|
||||
/** Total capacity (available + sold). Undefined means unlimited. */
|
||||
total?: number
|
||||
/** Whether the organizer enabled fiat checkout. */
|
||||
allowFiat: boolean
|
||||
/** Fiat settle currency when allowFiat is true. */
|
||||
|
|
@ -91,6 +93,9 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
|
|||
currency: ticket.currency,
|
||||
available: ticket.available,
|
||||
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,
|
||||
fiatCurrency: ticket.fiatCurrency,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ function goToMyTickets() {
|
|||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
{{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ onMounted(() => {
|
|||
|
||||
<!-- 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">
|
||||
<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-sm text-muted-foreground/70 mt-1">Events with location data will appear as markers on the map</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue