Compare commits

...

8 commits

Author SHA1 Message Date
443c8b6a37 feat(activities): brand-kit logo + app name in the events page header
Replace the bare "Events" h1 with the brand-kit logo paired with the
standalone's localized name. Deployers get per-standalone logo
control via branding/<dep>/icons/events/logo.{svg,png}; the
component itself stays brand-agnostic.

Brand-kit plumbing:

- `resolveAppLogo(app?)` in vite-branding.ts mirrors the resolution
  chain pwa-assets.config.ts already uses for PWA icons
  (per-standalone svg → png → global svg → png).
- `brandAppLogoAliasEntry(app)` returns a vite alias array entry. A
  regex matches `@brand-app-logo` with or without a `?url` query so
  the file resolves cleanly under either form.
- vite.events.config.ts switches its resolve.alias to the array form
  so the per-standalone regex doesn't clash with the bare `@brand`
  string alias.

Component side: a single `import brandAppLogoUrl from '@brand-app-logo?url'`
gives EventsPage the best-resolved logo without any fallback chain
in the component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 18:29:39 +02:00
5e3d77efec feat(activities): seat Map right after Home in the bottom nav
Map is a primary discovery surface for events, so it earns the second
slot next to Home instead of sitting at the tail of the auth-gated
tabs (My tickets, Hosting). Unchanged auth gating on the other tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:56:18 +02:00
60369ce1b1 feat(activities): drop RSVP buttons from EventDetailPage
The Going/Maybe/Not going row was redundant: the bookmark heart count
already signals casual interest and ticket sales answer "who's
actually going". Cuts an affordance whose value-add was unclear and
whose visual weight competed with the buy-ticket CTA right below it.

Removes the RSVPButton component, the useRSVP composable, and the
i18n strings that only fed those buttons. Keeps NIP52_KINDS.RSVP and
the CalendarRSVP type as protocol documentation in case we revisit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:54:40 +02:00
6d4a9f8c22 fix(activities): keep CreateEvent form actions inside narrow dialogs
Two narrow-viewport overflow points:

- Pricing row was a hard grid-cols-3 even at 320–360px viewports, so
  the Currency select was getting pinched. Drop to grid-cols-2 with
  Currency spanning both cols on small screens, lift to grid-cols-3
  at sm+.

- Action row was justify-end with no wrap and no width fallback, so a
  wide localized "Submit Event" label could push Cancel out of the
  dialog. Stack full-width on mobile (flex-col-reverse so Submit is
  under the thumb), back to inline at sm+.

Also belt-and-suspenders: overflow-x-hidden on the dialog content so
any future runaway child can't induce a horizontal scroll inside the
dialog body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:44:23 +02:00
c6455b3235 feat(activities): move Past pill to end of the temporal strip
Past used to live in the Filters collapsible — a dropdown row of its
own for a single boolean toggle. Hoist it onto the temporal strip
right after This month so it's discoverable alongside the time-window
pills without claiming any extra vertical space. Composes orthogonally
with the temporal pills the same way as before.

Drop the now-redundant past-count contribution to the Filters badge —
the pill carries its own pressed state on the strip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:42:11 +02:00
7af0f364cb fix(sidebar): full-width identity values with corner-offset legend badge
Restyle the Lightning / npub rows so the value gets the entire row
and the field-name label sits as a small badge straddling the top
border (fieldset-legend pattern). Long bech32 / username@domain
strings now have room to render without truncation crowding.

The bolt icon picks up a yellow tint so the Lightning row reads at
a glance alongside the neutral npub row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:41:58 +02:00
00f6a99f92 refactor(sidebar): show identity inline, move edit form into gear popup
Read-only identity (avatar, display name, @username, copyable
Lightning address, copyable npub) stays visible in the sheet so the
user can grab their address without opening any form. The full edit
form moves behind a gear-icon dialog. Log out stays in the sheet
footer so signing out doesn't require opening the popup.

Lightning Address and NIP-05 share the same `username@domain` in this
stack — the @username row above the card already signals NIP-05, so
the copyable address row is labeled just "Lightning".

The picture-upload row now stacks the preview above the upload widget
on narrow viewports so the Gallery/Camera buttons stay inside the
sheet/dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:26:25 +02:00
2c7597c25f fix(sidebar): clip horizontal overflow on profile sheets
Defensive guard against content (long addresses, code spans, etc.)
forcing a horizontal scroll inside the hamburger / profile sheet.
The root cause for individual cards is addressed separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:12:01 +02:00
18 changed files with 366 additions and 521 deletions

View file

@ -1,38 +1,122 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Home, LogIn } from 'lucide-vue-next' import { Check, Copy, Home, LogIn, LogOut, Settings, Zap } from 'lucide-vue-next'
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'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet' import { SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar' import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
import { toastService } from '@/core/services/ToastService'
import PreferencesRow from './PreferencesRow.vue' import PreferencesRow from './PreferencesRow.vue'
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue' import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const { isAuthenticated, user } = useAuth() const { isAuthenticated, user, logout } = useAuth()
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar() const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
const npubPreview = computed(() => { const lightningDomain = computed(
() => import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname,
)
// Lightning Address and NIP-05 share the same identifier in this app
// both are `username@domain`. The `@username` row above the identity
// card already signals NIP-05, so this row is labeled just "Lightning".
const lightningAddress = computed(() => {
const username = user.value?.username
if (!username) return ''
return `${username}@${lightningDomain.value}`
})
const npub = computed(() => {
const pubkey = user.value?.pubkey const pubkey = user.value?.pubkey
if (!pubkey) return '' if (!pubkey) return ''
return `${pubkey.slice(0, 8)}${pubkey.slice(-8)}` try {
return nip19.npubEncode(pubkey)
} catch {
return pubkey
}
})
const npubPreview = computed(() => {
const value = npub.value
if (!value) return ''
return value.length > 24 ? `${value.slice(0, 12)}${value.slice(-8)}` : value
}) })
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/') const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
const copiedField = ref<string | null>(null)
async function copyToClipboard(text: string, field: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
copiedField.value = field
toastService.success(t('common.nav.copied', 'Copied to clipboard'))
setTimeout(() => {
if (copiedField.value === field) copiedField.value = null
}, 1500)
} catch (err) {
console.error('Copy failed:', err)
toastService.error(t('common.nav.copyFailed', 'Failed to copy'))
}
}
const editProfileOpen = ref(false)
function goLogin() { function goLogin() {
router.push('/login') router.push('/login')
} }
async function onLogout() {
try {
await logout()
toastService.success(t('common.nav.loggedOut', 'Logged out'))
router.push('/login')
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to log out'
toastService.error(`${t('common.nav.logoutFailed', 'Logout failed')}: ${msg}`)
}
}
</script> </script>
<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>
@ -41,15 +125,64 @@ function goLogin() {
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<!-- Identity card (logged in) --> <!-- Identity card (logged in) read-only summary. Editing happens
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3"> through the gear button next to the title. -->
<Avatar class="h-12 w-12"> <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">
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" /> <AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback> <AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
</Avatar> </Avatar>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p> <p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p> <p v-if="displayName && user?.username" class="text-xs text-muted-foreground truncate">
@{{ user.username }}
</p>
</div>
</div>
<!-- Identifier rows: full-width value with a corner-offset "legend"
badge straddling the top border (fieldset-legend pattern). The
value gets the entire row so long bech32 / username@domain
strings have room to render. -->
<div class="space-y-3 pt-1">
<!-- Lightning Address this is also the NIP-05 in this stack,
but the @username above already signals the NIP-05. -->
<button
v-if="lightningAddress"
type="button"
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
:aria-label="t('common.nav.copyLightning', 'Copy Lightning address')"
@click="copyToClipboard(lightningAddress, 'lightning')"
>
<span class="absolute -top-2 left-2 inline-flex items-center gap-1 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
<Zap class="w-3 h-3 text-yellow-500 fill-yellow-500" />
{{ t('common.nav.lightning', 'Lightning') }}
</span>
<span class="block truncate pr-6 text-xs font-mono">{{ lightningAddress }}</span>
<component
:is="copiedField === 'lightning' ? Check : Copy"
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
/>
</button>
<!-- npub copy the full bech32 even though we display a preview. -->
<button
v-if="npub"
type="button"
class="relative w-full rounded-md border bg-background/60 px-3 pt-3 pb-2 text-left hover:bg-background transition-colors min-w-0"
:aria-label="t('common.nav.copyNpub', 'Copy npub')"
@click="copyToClipboard(npub, 'npub')"
>
<span class="absolute -top-2 left-2 inline-flex items-center rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground leading-none">
{{ t('common.nav.npub', 'npub') }}
</span>
<span class="block truncate pr-6 text-xs font-mono">{{ npubPreview }}</span>
<component
:is="copiedField === 'npub' ? Check : Copy"
class="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground"
/>
</button>
</div> </div>
</div> </div>
@ -72,7 +205,7 @@ function goLogin() {
<PreferencesRow layout="list" /> <PreferencesRow layout="list" />
</div> </div>
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings --> <!-- Logged-out: prominent log-in CTA -->
<div v-if="!isAuthenticated" class="mt-6"> <div v-if="!isAuthenticated" class="mt-6">
<Separator class="mb-4" /> <Separator class="mb-4" />
<Button class="w-full" @click="goLogin"> <Button class="w-full" @click="goLogin">
@ -81,9 +214,49 @@ function goLogin() {
</Button> </Button>
</div> </div>
<!-- Logged-in: full profile management form --> <!-- Logged-in: log-out button stays visible without opening the edit popup. -->
<div v-else class="mt-6"> <div v-else class="mt-6">
<Separator class="mb-4" /> <Separator class="mb-4" />
<ProfileSettings /> <AlertDialog>
<AlertDialogTrigger as-child>
<Button variant="destructive" class="w-full">
<LogOut class="mr-2 h-4 w-4" />
{{ t('common.nav.logOut', 'Log out') }}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Log out of {{ user?.username || 'your account' }}?
</AlertDialogTitle>
<AlertDialogDescription>
{{ t('common.nav.logOutConfirmDescription', "You'll need to sign in again to access your wallet, post in the forum, place orders, or use any feature that needs your account.") }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.nav.cancel', 'Cancel') }}</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="onLogout"
>
{{ t('common.nav.logOut', 'Log out') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<!-- Edit-profile popup (gear icon) 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>
<DialogTitle>{{ t('common.nav.editProfile', 'Edit profile') }}</DialogTitle>
<DialogDescription>
{{ t('common.nav.editProfileDescription', 'Update your display name and profile picture.') }}
</DialogDescription>
</DialogHeader>
<ProfileSettings />
</DialogContent>
</Dialog>
</template> </template>

View file

@ -50,7 +50,7 @@ const open = ref(false)
</template> </template>
</button> </button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto"> <SheetContent side="bottom" class="h-[90vh] overflow-y-auto overflow-x-hidden">
<ProfileSheetContent /> <ProfileSheetContent />
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View file

@ -53,7 +53,7 @@ function handleClick(item: SidebarNavItem) {
<Menu class="w-5 h-5" /> <Menu class="w-5 h-5" />
</button> </button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" class="w-80 sm:w-96 overflow-y-auto"> <SheetContent side="right" class="w-80 sm:w-96 max-w-full overflow-y-auto overflow-x-hidden">
<ProfileSheetContent> <ProfileSheetContent>
<template v-if="props.items.length" #app-nav> <template v-if="props.items.length" #app-nav>
<nav class="mt-4 space-y-1"> <nav class="mt-4 space-y-1">

View file

@ -51,6 +51,7 @@ const tabs = computed<BottomTab[]>(() => [
}, },
isActive: () => inFeedRoute() && !onlyHosting.value, isActive: () => inFeedRoute() && !onlyHosting.value,
}, },
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
{ {
name: t('events.filters.myTickets'), name: t('events.filters.myTickets'),
icon: Ticket, icon: Ticket,
@ -88,7 +89,6 @@ const tabs = computed<BottomTab[]>(() => [
isActive: () => inFeedRoute() && onlyHosting.value, isActive: () => inFeedRoute() && onlyHosting.value,
disabled: !isAuthenticated.value, disabled: !isAuthenticated.value,
}, },
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
{ {
name: t('events.nav.favorites'), name: t('events.nav.favorites'),
icon: Heart, icon: Heart,

View file

@ -101,9 +101,6 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Get Ticket', getTicket: 'Get Ticket',
going: 'Going',
maybe: 'Maybe',
notGoing: 'Not Going',
contactOrganizer: 'Contact Organizer', contactOrganizer: 'Contact Organizer',
organizer: 'Organizer', organizer: 'Organizer',
location: 'Location', location: 'Location',

View file

@ -101,9 +101,6 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Obtener boleto', getTicket: 'Obtener boleto',
going: 'Voy',
maybe: 'Tal vez',
notGoing: 'No voy',
contactOrganizer: 'Contactar organizador', contactOrganizer: 'Contactar organizador',
organizer: 'Organizador', organizer: 'Organizador',
location: 'Ubicación', location: 'Ubicación',

View file

@ -101,9 +101,6 @@ const messages: LocaleMessages = {
}, },
detail: { detail: {
getTicket: 'Obtenir un billet', getTicket: 'Obtenir un billet',
going: 'Présent',
maybe: 'Peut-être',
notGoing: 'Absent',
contactOrganizer: "Contacter l'organisateur", contactOrganizer: "Contacter l'organisateur",
organizer: 'Organisateur', organizer: 'Organisateur',
location: 'Lieu', location: 'Lieu',

View file

@ -76,9 +76,6 @@ export interface LocaleMessages {
categories: Record<string, string> categories: Record<string, string>
detail: { detail: {
getTicket: string getTicket: string
going: string
maybe: string
notGoing: string
contactOrganizer: string contactOrganizer: string
organizer: string organizer: string
location: string location: string

View file

@ -1,14 +1,5 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div>
<h3 class="text-lg font-medium">Profile Settings</h3>
<p class="text-sm text-muted-foreground">
Manage your profile information and Nostr identity
</p>
</div>
<Separator />
<form @submit="onSubmit" class="space-y-6"> <form @submit="onSubmit" class="space-y-6">
<!-- Profile Picture --> <!-- Profile Picture -->
<FormField name="picture"> <FormField name="picture">
@ -17,22 +8,26 @@
<FormDescription> <FormDescription>
Upload a profile picture. This will be published to your Nostr profile. Upload a profile picture. This will be published to your Nostr profile.
</FormDescription> </FormDescription>
<div class="flex items-center gap-4"> <!-- Stack preview + upload on narrow viewports so the upload
component's Gallery/Camera buttons don't push out of the
sheet/dialog. -->
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-4">
<!-- Current picture preview --> <!-- Current picture preview -->
<div v-if="currentPictureUrl" class="relative"> <div v-if="currentPictureUrl" class="relative shrink-0">
<img <img
:src="currentPictureUrl" :src="currentPictureUrl"
alt="Profile picture" alt="Profile picture"
class="h-20 w-20 rounded-full object-cover border-2 border-border" class="h-20 w-20 rounded-full object-cover border-2 border-border"
/> />
</div> </div>
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border"> <div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border shrink-0">
<User class="h-10 w-10 text-muted-foreground" /> <User class="h-10 w-10 text-muted-foreground" />
</div> </div>
<!-- Upload component. Avatars are small; tighten the <!-- Upload component. Avatars are small; tighten the
default compress knobs so a 4K phone photo lands as default compress knobs so a 4K phone photo lands as
a ~200 KB 512px WebP. --> a ~200 KB 512px WebP. -->
<div class="flex-1 min-w-0 w-full">
<ImageUpload <ImageUpload
v-model="uploadedPicture" v-model="uploadedPicture"
:multiple="false" :multiple="false"
@ -45,6 +40,7 @@
accept="image/*" accept="image/*"
/> />
</div> </div>
</div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
@ -91,26 +87,6 @@
</FormItem> </FormItem>
</FormField> </FormField>
<!-- Lightning Address / NIP-05 (read-only info) -->
<div class="rounded-lg border p-4 space-y-2 bg-muted/50">
<h4 class="text-sm font-medium">Nostr Identity</h4>
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<Zap class="h-4 w-4 text-yellow-500" />
<span class="text-muted-foreground">Lightning Address:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ lightningAddress }}</code>
</div>
<div class="flex items-center gap-2">
<Hash class="h-4 w-4 text-purple-500" />
<span class="text-muted-foreground">NIP-05:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ nip05Preview }}</code>
</div>
</div>
<p class="text-xs text-muted-foreground mt-2">
These identifiers are automatically derived from your username
</p>
</div>
<!-- Error Display --> <!-- Error Display -->
<div v-if="updateError" class="text-sm text-destructive"> <div v-if="updateError" class="text-sm text-destructive">
{{ updateError }} {{ updateError }}
@ -135,34 +111,6 @@
Your profile is broadcast to Nostr automatically when you save changes. Your profile is broadcast to Nostr automatically when you save changes.
</p> </p>
</form> </form>
<Separator />
<div class="flex flex-col gap-2">
<AlertDialog>
<AlertDialogTrigger as-child>
<Button variant="destructive" class="w-full">
<LogOut class="mr-2 h-4 w-4" />
Log out
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Log out of {{ user?.username || 'your account' }}?</AlertDialogTitle>
<AlertDialogDescription>
You'll need to sign in again to access your wallet, post in the
forum, place orders, or use any feature that needs your account.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="onLogout" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Log out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div> </div>
</template> </template>
@ -173,8 +121,7 @@ import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod' import * as z from 'zod'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator' import { User } from 'lucide-vue-next'
import { User, Zap, Hash } from 'lucide-vue-next'
import { import {
FormControl, FormControl,
FormDescription, FormDescription,
@ -184,27 +131,13 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import ImageUpload from './ImageUpload.vue' import ImageUpload from './ImageUpload.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { LogOut } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useRouter } from 'vue-router'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService' import type { ImageUploadService } from '../services/ImageUploadService'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
// Services // Services
const { user, updateProfile, logout } = useAuth() const { user, updateProfile } = useAuth()
const router = useRouter()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const toast = useToast() const toast = useToast()
@ -224,14 +157,14 @@ const lightningDomain = computed(() =>
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
) )
// Computed previews // Live preview of the user's NIP-05 / Lightning address shown in the
// username field's helper text so the consequence of a future rename is
// visible inline.
const nip05Preview = computed(() => { const nip05Preview = computed(() => {
const username = form.values.username || currentUsername.value || 'username' const username = form.values.username || currentUsername.value || 'username'
return `${username}@${lightningDomain.value}` return `${username}@${lightningDomain.value}`
}) })
const lightningAddress = computed(() => nip05Preview.value)
// Form schema // Form schema
const profileFormSchema = toTypedSchema(z.object({ const profileFormSchema = toTypedSchema(z.object({
username: z.string() username: z.string()
@ -327,17 +260,4 @@ const updateUserProfile = async (formData: any) => {
isUpdating.value = false isUpdating.value = false
} }
} }
// Log out + redirect to /login on this app's origin.
const onLogout = async () => {
try {
await logout()
toast.success('Logged out')
router.push('/login')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to log out'
console.error('Error logging out:', error)
toast.error(`Logout failed: ${errorMessage}`)
}
}
</script> </script>

View file

@ -432,7 +432,7 @@ const handleOpenChange = (open: boolean) => {
<template> <template>
<Dialog :open="open" @update:open="handleOpenChange"> <Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="max-w-lg max-h-[90vh] p-0"> <DialogContent class="max-w-lg max-h-[90vh] p-0 overflow-x-hidden">
<DialogHeader class="px-6 pt-6 pb-2"> <DialogHeader class="px-6 pt-6 pb-2">
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<Calendar class="w-5 h-5" /> <Calendar class="w-5 h-5" />
@ -611,9 +611,13 @@ const handleOpenChange = (open: boolean) => {
fiat amounts convert at checkout using current rates. fiat amounts convert at checkout using current rates.
</p> </p>
</div> </div>
<div class="grid grid-cols-3 gap-3"> <!-- Pricing grid: 2 cols on narrow phones (Tickets/Price share
a row, Currency takes its own), 3 cols once we have the
breathing room. Plain `grid-cols-3` was overflowing the
dialog on 360px-class viewports. -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem> <FormItem class="min-w-0">
<FormLabel>Tickets</FormLabel> <FormLabel>Tickets</FormLabel>
<FormControl> <FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" /> <Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
@ -624,7 +628,7 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket"> <FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem> <FormItem class="min-w-0">
<FormLabel>Price</FormLabel> <FormLabel>Price</FormLabel>
<FormControl> <FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" /> <Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
@ -635,11 +639,11 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem> <FormItem class="col-span-2 sm:col-span-1 min-w-0">
<FormLabel>Price currency</FormLabel> <FormLabel>Price currency</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger> <SelectTrigger class="w-full">
<SelectValue placeholder="sat" /> <SelectValue placeholder="sat" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -739,12 +743,26 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<!-- Actions --> <!-- Actions. Stack full-width on narrow viewports so a wide
<div class="flex justify-end gap-3 pt-2"> localized "Submit Event" label can't push the Cancel
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading"> button out of the dialog. Submit reads top-down on
mobile (flex-col-reverse) to keep the primary action
under the thumb. -->
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
class="w-full sm:w-auto"
@click="handleOpenChange(false)"
:disabled="isLoading"
>
Cancel Cancel
</Button> </Button>
<Button type="submit" :disabled="isLoading || !isFormValid"> <Button
type="submit"
class="w-full sm:w-auto"
:disabled="isLoading || !isFormValid"
>
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ {{
isLoading isLoading

View file

@ -1,81 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Check, HelpCircle, X } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService'
import { useRSVP } from '../composables/useRSVP'
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
const props = defineProps<{
pubkey: string
dTag: string
kind?: number
}>()
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { getMyRSVP, getRSVPCount, setRSVP, isPending } = useRSVP()
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const myStatus = computed(() => getMyRSVP(eventKind.value, props.pubkey, props.dTag))
const goingCount = computed(() => getRSVPCount(eventKind.value, props.pubkey, props.dTag))
const pending = computed(() => isPending(eventKind.value, props.pubkey, props.dTag))
const buttons: { status: RSVPStatus; labelKey: string; icon: any }[] = [
{ status: 'accepted', labelKey: 'events.detail.going', icon: Check },
{ status: 'tentative', labelKey: 'events.detail.maybe', icon: HelpCircle },
{ status: 'declined', labelKey: 'events.detail.notGoing', icon: X },
]
const statusLabel: Record<RSVPStatus, string> = {
accepted: "You're going",
tentative: 'Marked as maybe',
declined: "You're not going",
}
async function handleClick(status: RSVPStatus) {
if (!isAuthenticated.value) {
toast.info('Log in to RSVP', {
action: {
label: 'Log in',
onClick: () => router.push('/login'),
},
})
return
}
const published = await setRSVP(eventKind.value, props.pubkey, props.dTag, status)
if (published) {
toast.success(statusLabel[published])
} else if (!pending.value) {
// setRSVP returned null AND we're no longer pending publish failed
// (vs. throttled, where pending was true at the time of the click).
toast.error("Couldn't save RSVP — try again")
}
}
</script>
<template>
<div class="space-y-2">
<div class="flex gap-2">
<Button
v-for="btn in buttons"
:key="btn.status"
:variant="myStatus === btn.status ? 'default' : 'outline'"
:disabled="pending"
size="sm"
class="flex-1 gap-1.5"
@click="handleClick(btn.status)"
>
<component :is="btn.icon" class="w-3.5 h-3.5" />
{{ t(btn.labelKey) }}
</Button>
</div>
<p v-if="goingCount > 0" class="text-xs text-muted-foreground">
{{ goingCount }} {{ goingCount === 1 ? 'person' : 'people' }} going
</p>
</div>
</template>

View file

@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { History } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { TemporalFilter } from '../types/filters' import type { TemporalFilter } from '../types/filters'
const props = defineProps<{ const props = defineProps<{
modelValue: TemporalFilter modelValue: TemporalFilter
showPast: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: TemporalFilter] 'update:modelValue': [value: TemporalFilter]
'toggle-past': []
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@ -34,5 +37,19 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
> >
{{ t(option.labelKey) }} {{ t(option.labelKey) }}
</Button> </Button>
<!-- Past pill sits at the end of the same scrollable strip so it's
discoverable alongside the time-window pills, without claiming
a dropdown row of its own. Composes orthogonally with the
temporal pills: e.g. "This Week" + Past narrows to days
already past this week. -->
<Button
:variant="props.showPast ? 'default' : 'outline'"
size="sm"
class="rounded-full text-xs shrink-0 gap-1.5"
@click="emit('toggle-past')"
>
<History class="w-3 h-3" />
{{ t('events.filters.past', 'Past') }}
</Button>
</div> </div>
</template> </template>

View file

@ -1,248 +0,0 @@
import { ref, onMounted, onUnmounted } from 'vue'
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
/**
* NIP-52 RSVP (kind 31925) for responding to calendar events.
*
* Each RSVP is an addressable event with:
* d-tag: unique identifier for this RSVP
* a-tag: reference to the calendar event (kind:pubkey:d-tag)
* status tag: 'accepted' | 'declined' | 'tentative'
*/
interface RSVPEntry {
status: RSVPStatus
eventId: string
createdAt: number
}
// Cache: eventCoord -> user's own (latest) RSVP entry
const rsvpCache = ref<Map<string, RSVPEntry>>(new Map())
// Cache: eventCoord -> (pubkey -> latest RSVP entry from that pubkey).
// NIP-52 RSVPs (kind 31925) are replaceable per (kind, pubkey, d-tag), so a
// user's earlier RSVP for an event is superseded by their later one. The
// "going" count is derived from this map (count of pubkeys whose *latest*
// RSVP has status === 'accepted'), not by summing every accepted event seen
// — that would double-count replacements and never decrement on flip.
const rsvpStates = ref<Map<string, Map<string, RSVPEntry>>>(new Map())
const isLoaded = ref(false)
// Coords with an in-flight publish — used to disable RSVP buttons so fast
// clicks don't race each other.
const pendingCoords = ref<Set<string>>(new Set())
// Last successfully-published `created_at` per coord. NIP-01 created_at is
// integer seconds, so two clicks in the same wall-clock second produce the
// same timestamp and most relays treat the second one as a duplicate /
// older replacement and silently drop it. We bump past the previous
// timestamp so each click is strictly newer.
const lastPublishAt = new Map<string, number>()
function upsertRSVPState(coord: string, pubkey: string, entry: RSVPEntry) {
let inner = rsvpStates.value.get(coord)
if (!inner) {
inner = new Map()
}
const existing = inner.get(pubkey)
if (existing && existing.createdAt >= entry.createdAt) return
inner.set(pubkey, entry)
// Re-set on the outer map so the ref's reactive proxy notifies dependents
// (Vue 3's deep reevent doesn't reach into nested Map values).
rsvpStates.value.set(coord, inner)
}
export function useRSVP() {
const { isAuthenticated, currentUser } = useAuth()
let unsubscribe: (() => void) | null = null
/**
* Get the user's RSVP status for an event.
*/
function getMyRSVP(eventKind: number, pubkey: string, dTag: string): RSVPStatus | null {
const coord = `${eventKind}:${pubkey}:${dTag}`
return rsvpCache.value.get(coord)?.status ?? null
}
/**
* RSVP count for an event = number of pubkeys whose latest RSVP for
* this event has status 'accepted'.
*/
function getRSVPCount(eventKind: number, pubkey: string, dTag: string): number {
const coord = `${eventKind}:${pubkey}:${dTag}`
const inner = rsvpStates.value.get(coord)
if (!inner) return 0
let count = 0
for (const entry of inner.values()) {
if (entry.status === 'accepted') count++
}
return count
}
/**
* Load the user's RSVPs and counts for visible events from relays.
*/
function loadRSVPs() {
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return
// Subscribe to all RSVPs (for counts) and filter user's own
unsubscribe = relayHub.subscribe({
id: `rsvps-${Date.now()}`,
filters: [{
kinds: [NIP52_KINDS.RSVP],
limit: 500,
}],
onEvent: (event: NostrEvent) => {
const aTag = event.tags.find(t => t[0] === 'a')?.[1]
if (!aTag) return
const statusTag = event.tags.find(t => t[0] === 'status')?.[1] as RSVPStatus | undefined
// Also check 'l' tag pattern used by some clients
const lStatus = event.tags.find(t => t[0] === 'l' && t[2] === 'status')?.[1] as RSVPStatus | undefined
const status = statusTag ?? lStatus
if (!status || !['accepted', 'declined', 'tentative'].includes(status)) return
const entry: RSVPEntry = {
status,
eventId: event.id,
createdAt: event.created_at,
}
// Per-pubkey latest-wins state — drives the count.
upsertRSVPState(aTag, event.pubkey, entry)
// User's own RSVP cache (used by getMyRSVP).
if (currentUser.value?.pubkey && event.pubkey === currentUser.value.pubkey) {
const existing = rsvpCache.value.get(aTag)
if (!existing || event.created_at > existing.createdAt) {
rsvpCache.value.set(aTag, entry)
}
}
},
onEose: () => {
isLoaded.value = true
},
})
}
/**
* Whether a publish is currently in flight for the given event. Bind
* to the RSVP buttons' `:disabled` so users can't queue racing clicks.
*/
function isPending(eventKind: number, pubkey: string, dTag: string): boolean {
const coord = `${eventKind}:${pubkey}:${dTag}`
return pendingCoords.value.has(coord)
}
/**
* Publish an RSVP for an event.
* Clicking the same status again removes the RSVP (publishes 'declined').
*
* Returns the status that was published on success, or null if the publish
* was rejected, blocked, or threw caller should toast accordingly.
*/
async function setRSVP(
eventKind: number,
eventPubkey: string,
eventDTag: string,
status: RSVPStatus
): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
const coord = `${eventKind}:${eventPubkey}:${eventDTag}`
// Throttle: refuse a second click while the first is still publishing.
if (pendingCoords.value.has(coord)) return null
// Toggle: if already this status, decline instead.
const currentStatus = getMyRSVP(eventKind, eventPubkey, eventDTag)
const newStatus = currentStatus === status ? 'declined' : status
const dTag = `rsvp-${eventDTag}`
// Strictly-monotonic created_at per coord so two clicks in the same
// wall-clock second don't both stamp the same timestamp (relays would
// dedupe the second one as a non-newer replacement).
const now = Math.floor(Date.now() / 1000)
const previous = lastPublishAt.get(coord) ?? 0
const createdAt = Math.max(now, previous + 1)
const template: EventTemplate = {
kind: NIP52_KINDS.RSVP,
created_at: createdAt,
content: '',
tags: [
['d', dTag],
['a', coord],
['status', newStatus],
['L', 'status'],
['l', newStatus, 'status'],
['p', eventPubkey],
],
}
let signedEvent: NostrEvent
try {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useRSVP] signEventViaLnbits failed:', err)
return null
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return null
pendingCoords.value.add(coord)
try {
const result = await relayHub.publishEvent(signedEvent)
if (!result || result.success <= 0) {
// No relay accepted the event — leave caches untouched so the UI
// continues to reflect the last known-good state.
return null
}
const entry: RSVPEntry = {
status: newStatus,
eventId: signedEvent.id,
createdAt: signedEvent.created_at,
}
// Update both the user-scoped cache and the all-users state so the
// count flips immediately rather than waiting for the relay to echo
// our own event back through the subscription.
rsvpCache.value.set(coord, entry)
if (currentUser.value.pubkey) {
upsertRSVPState(coord, currentUser.value.pubkey, entry)
}
lastPublishAt.set(coord, signedEvent.created_at)
return newStatus
} finally {
pendingCoords.value.delete(coord)
}
}
onMounted(() => {
if (!isLoaded.value) {
loadRSVPs()
}
})
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
}
})
return {
getMyRSVP,
getRSVPCount,
setRSVP,
isPending,
isLoaded,
loadRSVPs,
}
}

View file

@ -12,10 +12,8 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useEventDetail } from '../composables/useEventDetail' import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue' import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useEventsStore } from '../stores/events' import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -285,19 +283,6 @@ function goToMyTickets() {
<p class="whitespace-pre-wrap">{{ event.description }}</p> <p class="whitespace-pre-wrap">{{ event.description }}</p>
</div> </div>
<!-- RSVP hidden for the host since RSVPing to your own event
is a noise affordance. -->
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every event, leaving RSVPs
on date-based events pointing at a non-existent event coord. -->
<RSVPButton
v-if="!ownedLnbitsEvent"
:pubkey="event.organizer.pubkey"
:d-tag="event.id"
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Host's primary CTA is to scan tickets at the door. Lives <!-- Host's primary CTA is to scan tickets at the door. Lives
OUTSIDE the ticketInfo gate so it appears even when the OUTSIDE the ticketInfo gate so it appears even when the
event was published without AIO ticket tags a host always event was published without AIO ticket tags a host always

View file

@ -8,8 +8,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Separator } from '@/components/ui/separator' import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next' import brandAppLogoUrl from '@brand-app-logo?url'
import { useEvents } from '../composables/useEvents' import { useEvents } from '../composables/useEvents'
import { useEventsStore } from '../stores/events' import { useEventsStore } from '../stores/events'
import EventSearchOverlay from '../components/EventSearchOverlay.vue' import EventSearchOverlay from '../components/EventSearchOverlay.vue'
@ -45,10 +45,11 @@ const {
const filtersOpen = ref(false) const filtersOpen = ref(false)
// Badge count on the Filters trigger so the user can see at a glance // Badge count on the Filters trigger so the user can see at a glance
// that hidden toggles (past-events, categories) are currently active // that hidden toggles (categories) are currently active even when the
// even when the collapsible is closed. // collapsible is closed. Past lives on the temporal strip now and
// has its own visible pressed state, so it doesn't need to count here.
const filterCount = computed( const filterCount = computed(
() => selectedCategories.value.length + (showPast.value ? 1 : 0), () => selectedCategories.value.length,
) )
onMounted(() => { onMounted(() => {
@ -75,8 +76,16 @@ function openCalendar() {
<template> <template>
<div class="container mx-auto py-4 px-4"> <div class="container mx-auto py-4 px-4">
<!-- Page header --> <!-- Page header brand-kit logo (per-standalone override or
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground"> global) paired with the standalone's localized name. Resolved
at build time via @brand-app-logo so deployers can override
without touching this component. -->
<h1 class="mb-3 flex items-center gap-2 text-xl sm:text-2xl font-bold text-foreground">
<img
:src="brandAppLogoUrl"
:alt="t('events.title')"
class="h-7 w-7 sm:h-8 sm:w-8 shrink-0"
/>
{{ t('events.title') }} {{ t('events.title') }}
</h1> </h1>
@ -112,10 +121,11 @@ function openCalendar() {
<!-- Filters trigger + Clear-all stay stationary in a left-aligned <!-- Filters trigger + Clear-all stay stationary in a left-aligned
column; only the temporal pills scroll horizontally. The column; only the temporal pills scroll horizontally. The
Filters icon (with a count badge when past-events or any Filters icon (with a count badge when categories are active)
categories are active) opens a collapsible that hosts the opens a collapsible that hosts category chips below. Past is
past-events toggle + category chips below. Hidden in the a pill at the end of the temporal strip and doesn't live in
Hosting view the operator's roster doesn't need them. --> the dropdown anymore. Hidden in the Hosting view the
operator's roster doesn't need them. -->
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3"> <Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="shrink-0 flex flex-col items-center gap-0.5"> <div class="shrink-0 flex flex-col items-center gap-0.5">
@ -148,20 +158,15 @@ function openCalendar() {
</Button> </Button>
</div> </div>
<div class="flex-1 min-w-0 pt-0.5"> <div class="flex-1 min-w-0 pt-0.5">
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" /> <TemporalFilterBar
:model-value="temporal"
:show-past="showPast"
@update:model-value="setTemporal"
@toggle-past="togglePast"
/>
</div> </div>
</div> </div>
<CollapsibleContent class="mt-3 space-y-3"> <CollapsibleContent class="mt-3">
<Button
:variant="showPast ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="togglePast"
>
<History class="w-3.5 h-3.5" />
{{ t('events.filters.pastEvents', 'Past events') }}
</Button>
<Separator />
<CategoryFilterBar <CategoryFilterBar
:selected="selectedCategories" :selected="selectedCategories"
@toggle="toggleCategory" @toggle="toggleCategory"

9
src/vite-env.d.ts vendored
View file

@ -1,2 +1,11 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" /> /// <reference types="vite-plugin-pwa/client" />
// Brand-kit alias for the active standalone's logo. Resolved at build
// time by vite-branding.ts (per-standalone override or global). The
// `?url` import returns the asset's URL string just like any other
// vite static asset.
declare module '@brand-app-logo?url' {
const src: string
export default src
}

View file

@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { join, resolve } from 'node:path'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
/** /**
@ -43,6 +43,55 @@ export const brandAlias = {
'@brand': BRAND_DIR, '@brand': BRAND_DIR,
} as const } as const
/**
* Resolution order for the in-app logo of a given standalone. Mirrors
* what pwa-assets.config.ts does for PWA icons: per-standalone override
* first (SVG then PNG), then the brand's primary logo (SVG then PNG).
*
* Returned path is absolute so vite alias can map directly to it.
*/
export function resolveAppLogo(app?: string): string {
const candidates: string[] = []
if (app) {
candidates.push(
join(BRAND_DIR, 'icons', app, 'logo.svg'),
join(BRAND_DIR, 'icons', app, 'logo.png'),
)
}
candidates.push(
join(BRAND_DIR, 'logo.svg'),
join(BRAND_DIR, 'logo.png'),
)
const found = candidates.find((p) => existsSync(p))
if (!found) {
throw new Error(
`No brand logo found for app="${app ?? ''}". Tried:\n ${candidates.join('\n ')}\n` +
`See branding/README.md for the brand kit contract.`,
)
}
return found
}
/**
* Standalone-aware brand-logo alias entry. Append to a vite config's
* `resolve.alias` array alongside the rest of the alias map. The
* regex matches `@brand-app-logo` with or without a `?url` query so
* `import logoUrl from '@brand-app-logo?url'` resolves to the active
* standalone's logo file (per-app override or global), with no
* fallback chain in the component itself.
*
* Note: when used with the object form of resolve.alias, a bare
* `@brand` entry would shadow this combine the two as an array
* (see vite.events.config.ts).
*/
export function brandAppLogoAliasEntry(app?: string) {
const resolved = resolveAppLogo(app)
return {
find: /^@brand-app-logo(\?.*)?$/,
replacement: `${resolved}$1`,
} as const
}
/** /**
* PWA manifest name for a standalone. Combines the brand name with the * PWA manifest name for a standalone. Combines the brand name with the
* app's own label, or returns the bare brand when no label is given. * app's own label, or returns the bare brand when no label is given.

View file

@ -5,7 +5,13 @@ import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { brand, brandAlias, brandAssetsPlugin, brandManifestName } from './vite-branding' import {
brand,
brandAlias,
brandAppLogoAliasEntry,
brandAssetsPlugin,
brandManifestName,
} from './vite-branding'
/** /**
* Plugin to rewrite dev server requests to events.html * Plugin to rewrite dev server requests to events.html
@ -117,10 +123,14 @@ export default defineConfig(({ mode }) => ({
}), }),
], ],
resolve: { resolve: {
alias: { // Array form so we can mix the per-standalone logo regex (needs to
...brandAlias, // match `@brand-app-logo?url` query suffix) with the bare string
'@': fileURLToPath(new URL('./src', import.meta.url)), // aliases without one shadowing the other.
}, alias: [
brandAppLogoAliasEntry('events'),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
}, },
build: { build: {
outDir: 'dist-events', outDir: 'dist-events',