Compare commits
No commits in common. "443c8b6a37e52d982d20f8d27db855762bf378e1" and "4f7a5dcd886e821f4f336ac283906503606de847" have entirely different histories.
443c8b6a37
...
4f7a5dcd88
18 changed files with 521 additions and 366 deletions
|
|
@ -1,122 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, Copy, Home, LogIn, LogOut, Settings, Zap } from 'lucide-vue-next'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Home, LogIn } from 'lucide-vue-next'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { useAuth } from '@/composables/useAuthService'
|
||||
import { useCurrentUserAvatar } from '@/composables/useCurrentUserAvatar'
|
||||
import { toastService } from '@/core/services/ToastService'
|
||||
import PreferencesRow from './PreferencesRow.vue'
|
||||
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, user, logout } = useAuth()
|
||||
const { isAuthenticated, user } = useAuth()
|
||||
const { pictureUrl, displayName, fallbackInitial } = useCurrentUserAvatar()
|
||||
|
||||
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 npubPreview = computed(() => {
|
||||
const pubkey = user.value?.pubkey
|
||||
if (!pubkey) return ''
|
||||
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
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`
|
||||
})
|
||||
|
||||
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() {
|
||||
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>
|
||||
|
||||
<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>
|
||||
<SheetTitle>{{ t('common.nav.profile') }}</SheetTitle>
|
||||
<SheetDescription v-if="isAuthenticated">
|
||||
{{ t('common.nav.profileDescription') }}
|
||||
</SheetDescription>
|
||||
|
|
@ -125,64 +41,15 @@ async function onLogout() {
|
|||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<!-- Identity card (logged in) — read-only summary. Editing happens
|
||||
through the gear button next to the title. -->
|
||||
<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 ?? ''" />
|
||||
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</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>
|
||||
<!-- Identity card (logged in) -->
|
||||
<div v-if="isAuthenticated" class="mt-4 flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
|
||||
<Avatar class="h-12 w-12">
|
||||
<AvatarImage v-if="pictureUrl" :src="pictureUrl" :alt="displayName ?? ''" />
|
||||
<AvatarFallback>{{ fallbackInitial || '?' }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{{ displayName || user?.username }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">{{ npubPreview }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -205,7 +72,7 @@ async function onLogout() {
|
|||
<PreferencesRow layout="list" />
|
||||
</div>
|
||||
|
||||
<!-- Logged-out: prominent log-in CTA -->
|
||||
<!-- Logged-out: prominent log-in CTA in place of ProfileSettings -->
|
||||
<div v-if="!isAuthenticated" class="mt-6">
|
||||
<Separator class="mb-4" />
|
||||
<Button class="w-full" @click="goLogin">
|
||||
|
|
@ -214,49 +81,9 @@ async function onLogout() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Logged-in: log-out button stays visible without opening the edit popup. -->
|
||||
<!-- Logged-in: full profile management form -->
|
||||
<div v-else class="mt-6">
|
||||
<Separator class="mb-4" />
|
||||
<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>
|
||||
<ProfileSettings />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const open = ref(false)
|
|||
</template>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto overflow-x-hidden">
|
||||
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
|
||||
<ProfileSheetContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ function handleClick(item: SidebarNavItem) {
|
|||
<Menu 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">
|
||||
<SheetContent side="right" class="w-80 sm:w-96 overflow-y-auto">
|
||||
<ProfileSheetContent>
|
||||
<template v-if="props.items.length" #app-nav>
|
||||
<nav class="mt-4 space-y-1">
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ const tabs = computed<BottomTab[]>(() => [
|
|||
},
|
||||
isActive: () => inFeedRoute() && !onlyHosting.value,
|
||||
},
|
||||
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
||||
{
|
||||
name: t('events.filters.myTickets'),
|
||||
icon: Ticket,
|
||||
|
|
@ -89,6 +88,7 @@ const tabs = computed<BottomTab[]>(() => [
|
|||
isActive: () => inFeedRoute() && onlyHosting.value,
|
||||
disabled: !isAuthenticated.value,
|
||||
},
|
||||
{ name: t('events.nav.map'), icon: Map, path: '/events/map' },
|
||||
{
|
||||
name: t('events.nav.favorites'),
|
||||
icon: Heart,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ const messages: LocaleMessages = {
|
|||
},
|
||||
detail: {
|
||||
getTicket: 'Get Ticket',
|
||||
going: 'Going',
|
||||
maybe: 'Maybe',
|
||||
notGoing: 'Not Going',
|
||||
contactOrganizer: 'Contact Organizer',
|
||||
organizer: 'Organizer',
|
||||
location: 'Location',
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ const messages: LocaleMessages = {
|
|||
},
|
||||
detail: {
|
||||
getTicket: 'Obtener boleto',
|
||||
going: 'Voy',
|
||||
maybe: 'Tal vez',
|
||||
notGoing: 'No voy',
|
||||
contactOrganizer: 'Contactar organizador',
|
||||
organizer: 'Organizador',
|
||||
location: 'Ubicación',
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ const messages: LocaleMessages = {
|
|||
},
|
||||
detail: {
|
||||
getTicket: 'Obtenir un billet',
|
||||
going: 'Présent',
|
||||
maybe: 'Peut-être',
|
||||
notGoing: 'Absent',
|
||||
contactOrganizer: "Contacter l'organisateur",
|
||||
organizer: 'Organisateur',
|
||||
location: 'Lieu',
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export interface LocaleMessages {
|
|||
categories: Record<string, string>
|
||||
detail: {
|
||||
getTicket: string
|
||||
going: string
|
||||
maybe: string
|
||||
notGoing: string
|
||||
contactOrganizer: string
|
||||
organizer: string
|
||||
location: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
<template>
|
||||
<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">
|
||||
<!-- Profile Picture -->
|
||||
<FormField name="picture">
|
||||
|
|
@ -8,38 +17,33 @@
|
|||
<FormDescription>
|
||||
Upload a profile picture. This will be published to your Nostr profile.
|
||||
</FormDescription>
|
||||
<!-- 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">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current picture preview -->
|
||||
<div v-if="currentPictureUrl" class="relative shrink-0">
|
||||
<div v-if="currentPictureUrl" class="relative">
|
||||
<img
|
||||
:src="currentPictureUrl"
|
||||
alt="Profile picture"
|
||||
class="h-20 w-20 rounded-full object-cover border-2 border-border"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border shrink-0">
|
||||
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border">
|
||||
<User class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Upload component. Avatars are small; tighten the
|
||||
default compress knobs so a 4K phone photo lands as
|
||||
a ~200 KB 512px WebP. -->
|
||||
<div class="flex-1 min-w-0 w-full">
|
||||
<ImageUpload
|
||||
v-model="uploadedPicture"
|
||||
:multiple="false"
|
||||
:max-files="1"
|
||||
:max-size-mb="5"
|
||||
:disabled="isUpdating"
|
||||
:allow-camera="true"
|
||||
:compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }"
|
||||
placeholder="Upload picture"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<ImageUpload
|
||||
v-model="uploadedPicture"
|
||||
:multiple="false"
|
||||
:max-files="1"
|
||||
:max-size-mb="5"
|
||||
:disabled="isUpdating"
|
||||
:allow-camera="true"
|
||||
:compress="{ maxWidthOrHeight: 512, maxSizeMB: 0.2 }"
|
||||
placeholder="Upload picture"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -87,6 +91,26 @@
|
|||
</FormItem>
|
||||
</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 -->
|
||||
<div v-if="updateError" class="text-sm text-destructive">
|
||||
{{ updateError }}
|
||||
|
|
@ -111,6 +135,34 @@
|
|||
Your profile is broadcast to Nostr automatically when you save changes.
|
||||
</p>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -121,7 +173,8 @@ import { toTypedSchema } from '@vee-validate/zod'
|
|||
import * as z from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { User } from 'lucide-vue-next'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { User, Zap, Hash } from 'lucide-vue-next'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
|
|
@ -131,13 +184,27 @@ import {
|
|||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
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 { useRouter } from 'vue-router'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
// Services
|
||||
const { user, updateProfile } = useAuth()
|
||||
const { user, updateProfile, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||
const toast = useToast()
|
||||
|
||||
|
|
@ -157,14 +224,14 @@ const lightningDomain = computed(() =>
|
|||
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
|
||||
)
|
||||
|
||||
// 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.
|
||||
// Computed previews
|
||||
const nip05Preview = computed(() => {
|
||||
const username = form.values.username || currentUsername.value || 'username'
|
||||
return `${username}@${lightningDomain.value}`
|
||||
})
|
||||
|
||||
const lightningAddress = computed(() => nip05Preview.value)
|
||||
|
||||
// Form schema
|
||||
const profileFormSchema = toTypedSchema(z.object({
|
||||
username: z.string()
|
||||
|
|
@ -260,4 +327,17 @@ const updateUserProfile = async (formData: any) => {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -432,7 +432,7 @@ const handleOpenChange = (open: boolean) => {
|
|||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="max-w-lg max-h-[90vh] p-0 overflow-x-hidden">
|
||||
<DialogContent class="max-w-lg max-h-[90vh] p-0">
|
||||
<DialogHeader class="px-6 pt-6 pb-2">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Calendar class="w-5 h-5" />
|
||||
|
|
@ -611,13 +611,9 @@ const handleOpenChange = (open: boolean) => {
|
|||
fiat amounts convert at checkout using current rates.
|
||||
</p>
|
||||
</div>
|
||||
<!-- 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">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<FormField v-slot="{ componentField }" name="amount_tickets">
|
||||
<FormItem class="min-w-0">
|
||||
<FormItem>
|
||||
<FormLabel>Tickets</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
|
||||
|
|
@ -628,7 +624,7 @@ const handleOpenChange = (open: boolean) => {
|
|||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="price_per_ticket">
|
||||
<FormItem class="min-w-0">
|
||||
<FormItem>
|
||||
<FormLabel>Price</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
|
||||
|
|
@ -639,11 +635,11 @@ const handleOpenChange = (open: boolean) => {
|
|||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="currency">
|
||||
<FormItem class="col-span-2 sm:col-span-1 min-w-0">
|
||||
<FormItem>
|
||||
<FormLabel>Price currency</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="sat" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -743,26 +739,12 @@ const handleOpenChange = (open: boolean) => {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Actions. Stack full-width on narrow viewports so a wide
|
||||
localized "Submit Event" label can't push the Cancel
|
||||
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"
|
||||
>
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full sm:w-auto"
|
||||
:disabled="isLoading || !isFormValid"
|
||||
>
|
||||
<Button type="submit" :disabled="isLoading || !isFormValid">
|
||||
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
|
||||
{{
|
||||
isLoading
|
||||
|
|
|
|||
81
src/modules/events/components/RSVPButton.vue
Normal file
81
src/modules/events/components/RSVPButton.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<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>
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { History } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { TemporalFilter } from '../types/filters'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: TemporalFilter
|
||||
showPast: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TemporalFilter]
|
||||
'toggle-past': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
|
@ -37,19 +34,5 @@ const options: { value: TemporalFilter; labelKey: string }[] = [
|
|||
>
|
||||
{{ t(option.labelKey) }}
|
||||
</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>
|
||||
</template>
|
||||
|
|
|
|||
248
src/modules/events/composables/useRSVP.ts
Normal file
248
src/modules/events/composables/useRSVP.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12,8 +12,10 @@ import {
|
|||
} from 'lucide-vue-next'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||
import RSVPButton from '../components/RSVPButton.vue'
|
||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||
import { NIP52_KINDS } from '../types/nip52'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
|
|
@ -283,6 +285,19 @@ function goToMyTickets() {
|
|||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</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
|
||||
OUTSIDE the ticketInfo gate so it appears even when the
|
||||
event was published without AIO ticket tags — a host always
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, CalendarDays, Plus } from 'lucide-vue-next'
|
||||
import brandAppLogoUrl from '@brand-app-logo?url'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||
|
|
@ -45,11 +45,10 @@ const {
|
|||
const filtersOpen = ref(false)
|
||||
|
||||
// Badge count on the Filters trigger so the user can see at a glance
|
||||
// that hidden toggles (categories) are currently active even when the
|
||||
// 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.
|
||||
// that hidden toggles (past-events, categories) are currently active
|
||||
// even when the collapsible is closed.
|
||||
const filterCount = computed(
|
||||
() => selectedCategories.value.length,
|
||||
() => selectedCategories.value.length + (showPast.value ? 1 : 0),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -76,16 +75,8 @@ function openCalendar() {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto py-4 px-4">
|
||||
<!-- Page header — brand-kit logo (per-standalone override or
|
||||
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"
|
||||
/>
|
||||
<!-- Page header -->
|
||||
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
|
||||
{{ t('events.title') }}
|
||||
</h1>
|
||||
|
||||
|
|
@ -121,11 +112,10 @@ function openCalendar() {
|
|||
|
||||
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
|
||||
column; only the temporal pills scroll horizontally. The
|
||||
Filters icon (with a count badge when categories are active)
|
||||
opens a collapsible that hosts category chips below. Past is
|
||||
a pill at the end of the temporal strip and doesn't live in
|
||||
the dropdown anymore. Hidden in the Hosting view — the
|
||||
operator's roster doesn't need them. -->
|
||||
Filters icon (with a count badge when past-events or any
|
||||
categories are active) opens a collapsible that hosts the
|
||||
past-events toggle + category chips below. Hidden in the
|
||||
Hosting view — the operator's roster doesn't need them. -->
|
||||
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0 flex flex-col items-center gap-0.5">
|
||||
|
|
@ -158,15 +148,20 @@ function openCalendar() {
|
|||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 pt-0.5">
|
||||
<TemporalFilterBar
|
||||
:model-value="temporal"
|
||||
:show-past="showPast"
|
||||
@update:model-value="setTemporal"
|
||||
@toggle-past="togglePast"
|
||||
/>
|
||||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent class="mt-3">
|
||||
<CollapsibleContent class="mt-3 space-y-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
|
||||
:selected="selectedCategories"
|
||||
@toggle="toggleCategory"
|
||||
|
|
|
|||
9
src/vite-env.d.ts
vendored
9
src/vite-env.d.ts
vendored
|
|
@ -1,11 +1,2 @@
|
|||
/// <reference types="vite/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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
/**
|
||||
|
|
@ -43,55 +43,6 @@ export const brandAlias = {
|
|||
'@brand': BRAND_DIR,
|
||||
} 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
|
||||
* app's own label, or returns the bare brand when no label is given.
|
||||
|
|
|
|||
|
|
@ -5,13 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import {
|
||||
brand,
|
||||
brandAlias,
|
||||
brandAppLogoAliasEntry,
|
||||
brandAssetsPlugin,
|
||||
brandManifestName,
|
||||
} from './vite-branding'
|
||||
import { brand, brandAlias, brandAssetsPlugin, brandManifestName } from './vite-branding'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to events.html
|
||||
|
|
@ -123,14 +117,10 @@ export default defineConfig(({ mode }) => ({
|
|||
}),
|
||||
],
|
||||
resolve: {
|
||||
// Array form so we can mix the per-standalone logo regex (needs to
|
||||
// match `@brand-app-logo?url` query suffix) with the bare string
|
||||
// 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)) },
|
||||
],
|
||||
alias: {
|
||||
...brandAlias,
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-events',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue