Compare commits
No commits in common. "63fc7b3ab8656aaab05889589b44e584815f134f" and "e5f0202a4a1a069172d47e286877bf86fd6430e3" have entirely different histories.
63fc7b3ab8
...
e5f0202a4a
14 changed files with 79 additions and 699 deletions
|
|
@ -7,8 +7,6 @@ import AppShell from '@/components/layout/AppShell.vue'
|
||||||
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
import type { BottomTab } from '@/components/layout/BottomNav.vue'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
import { useActivitiesStore } from '@/modules/activities/stores/activities'
|
||||||
import { useActivities } from '@/modules/activities/composables/useActivities'
|
|
||||||
import { useApprovalState } from '@/modules/activities/composables/useApprovalState'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
import type { TicketApiService } from '@/modules/activities/services/TicketApiService'
|
||||||
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
|
||||||
|
|
@ -18,11 +16,6 @@ const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
const { isAuthenticated, currentUser } = useAuth()
|
||||||
const activitiesStore = useActivitiesStore()
|
const activitiesStore = useActivitiesStore()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
|
||||||
// Used to merge own LNbits drafts into the activities feed right after
|
|
||||||
// the user creates or edits an event — otherwise the new draft only
|
|
||||||
// surfaces on the next ActivitiesPage subscribe cycle.
|
|
||||||
const { loadOwnEvents } = useActivities()
|
|
||||||
|
|
||||||
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
// Settings dropped — theme/lang/currency now live in the shared profile sheet.
|
||||||
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
|
||||||
|
|
@ -34,12 +27,7 @@ const tabs = computed<BottomTab[]>(() => [
|
||||||
{
|
{
|
||||||
name: t('activities.createNew'),
|
name: t('activities.createNew'),
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
onClick: () => {
|
onClick: () => { activitiesStore.showCreateDialog = true },
|
||||||
// Defensively clear any lingering edit selection so the Create
|
|
||||||
// tap always opens in Create mode regardless of a prior Edit.
|
|
||||||
activitiesStore.editingEvent = null
|
|
||||||
activitiesStore.showCreateDialog = true
|
|
||||||
},
|
|
||||||
disabled: !isAuthenticated.value,
|
disabled: !isAuthenticated.value,
|
||||||
},
|
},
|
||||||
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
|
||||||
|
|
@ -69,40 +57,14 @@ async function handleCreateEvent(eventData: CreateEventRequest) {
|
||||||
if (!invoiceKey) throw new Error('No wallet available. Please log in first.')
|
if (!invoiceKey) throw new Error('No wallet available. Please log in first.')
|
||||||
await ticketApi.createEvent(eventData, invoiceKey)
|
await ticketApi.createEvent(eventData, invoiceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest) {
|
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
|
||||||
// PUT /events/{id} requires the event's wallet admin key.
|
|
||||||
const wallet = (currentUser.value?.wallets ?? []).find(
|
|
||||||
(w) => w.id === activitiesStore.editingEvent?.wallet,
|
|
||||||
)
|
|
||||||
const adminKey = wallet?.adminkey
|
|
||||||
if (!adminKey) {
|
|
||||||
throw new Error("Can't find the admin key for this event's wallet.")
|
|
||||||
}
|
|
||||||
await ticketApi.updateEvent(eventId, eventData, adminKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDialogOpenChange(open: boolean) {
|
|
||||||
activitiesStore.showCreateDialog = open
|
|
||||||
// Closing always clears the edit selection so the next "+ Create"
|
|
||||||
// opens clean instead of inheriting the last-edited event.
|
|
||||||
if (!open) activitiesStore.editingEvent = null
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell :tabs="tabs" :is-active="isActive">
|
<AppShell :tabs="tabs" :is-active="isActive">
|
||||||
<CreateEventDialog
|
<CreateEventDialog
|
||||||
:open="activitiesStore.showCreateDialog"
|
:open="activitiesStore.showCreateDialog"
|
||||||
:event="activitiesStore.editingEvent"
|
@update:open="activitiesStore.showCreateDialog = $event"
|
||||||
:is-admin="isAdmin"
|
|
||||||
:auto-approve="autoApprove"
|
|
||||||
:on-create-event="handleCreateEvent"
|
:on-create-event="handleCreateEvent"
|
||||||
:on-update-event="handleUpdateEvent"
|
|
||||||
@update:open="handleDialogOpenChange"
|
|
||||||
@event-created="loadOwnEvents"
|
|
||||||
@event-updated="loadOwnEvents"
|
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next'
|
import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
|
||||||
import BookmarkButton from './BookmarkButton.vue'
|
import BookmarkButton from './BookmarkButton.vue'
|
||||||
import { useDateLocale } from '../composables/useDateLocale'
|
import { useDateLocale } from '../composables/useDateLocale'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Activity } from '../types/activity'
|
||||||
|
|
@ -87,17 +87,6 @@ const placeholderBg = computed(() => {
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- Ownership badge — the creator can spot their own events at a
|
|
||||||
glance on the feed. -->
|
|
||||||
<Badge
|
|
||||||
v-if="activity.isMine"
|
|
||||||
variant="outline"
|
|
||||||
class="absolute bottom-2 right-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
|
||||||
>
|
|
||||||
<User class="w-3 h-3" />
|
|
||||||
Yours
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<!-- Price badge -->
|
<!-- Price badge -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="priceDisplay"
|
v-if="priceDisplay"
|
||||||
|
|
@ -106,17 +95,6 @@ const placeholderBg = computed(() => {
|
||||||
{{ priceDisplay }}
|
{{ priceDisplay }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- Pending/rejected overlay for the creator's own non-approved
|
|
||||||
drafts. Only present when the activity originated from a
|
|
||||||
local LNbits event (Nostr-sourced activities have no
|
|
||||||
lnbitsStatus). -->
|
|
||||||
<Badge
|
|
||||||
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
|
||||||
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
|
||||||
class="absolute bottom-2 left-2 text-xs capitalize"
|
|
||||||
>
|
|
||||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
@ -32,49 +32,28 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next'
|
import { Calendar, Loader2, MapPin } from 'lucide-vue-next'
|
||||||
import { toastService } from '@/core/services/ToastService'
|
import { toastService } from '@/core/services/ToastService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
||||||
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
import DatePicker from '@/modules/base/components/DatePicker.vue'
|
||||||
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
import TimePicker from '@/modules/base/components/TimePicker.vue'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
||||||
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
import type { CreateEventRequest } from '../types/ticket'
|
||||||
import { ALL_CATEGORIES } from '../types/category'
|
import { ALL_CATEGORIES } from '../types/category'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
/** When set, dialog opens in edit mode for this event. */
|
onCreateEvent: (eventData: CreateEventRequest) => Promise<void>
|
||||||
event?: TicketedEvent | null
|
|
||||||
/** Create handler. Required when not editing. */
|
|
||||||
onCreateEvent?: (eventData: CreateEventRequest) => Promise<void>
|
|
||||||
/** Update handler. Required when editing. */
|
|
||||||
onUpdateEvent?: (eventId: string, eventData: CreateEventRequest) => Promise<void>
|
|
||||||
/** Whether the current user is an LNbits admin. Drives the
|
|
||||||
* "edit will go back to pending approval" warning copy. */
|
|
||||||
isAdmin?: boolean
|
|
||||||
/** Whether the events extension has auto_approve enabled. */
|
|
||||||
autoApprove?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:open': [value: boolean]
|
'update:open': [value: boolean]
|
||||||
'event-created': []
|
'event-created': []
|
||||||
'event-updated': []
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isEditMode = computed(() => Boolean(props.event?.id))
|
|
||||||
// True when the submission will land in `proposed` status: a non-admin
|
|
||||||
// owner with the extension's auto_approve toggle off. Same gate for
|
|
||||||
// create and edit; the edit path also keys the in-form warning banner
|
|
||||||
// on this so the user sees the consequence before submitting.
|
|
||||||
const willLandInPending = computed(
|
|
||||||
() => !props.isAdmin && !props.autoApprove
|
|
||||||
)
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
|
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
|
||||||
|
|
@ -138,18 +117,6 @@ interface BannerImage extends UploadedImage {
|
||||||
}
|
}
|
||||||
const bannerImages = ref<BannerImage[]>([])
|
const bannerImages = ref<BannerImage[]>([])
|
||||||
|
|
||||||
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back
|
|
||||||
// into separate date + time pieces for the form inputs.
|
|
||||||
function splitDateTime(value: string | null | undefined): { date: string; time: string } {
|
|
||||||
if (!value) return { date: '', time: '' }
|
|
||||||
const [date, time = ''] = value.split('T')
|
|
||||||
return { date, time: time.slice(0, 5) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// When `true`, suppress the auto-mirror watcher so we don't clobber an
|
|
||||||
// edit-mode population with start-date side effects mid-setValues.
|
|
||||||
const isPopulating = ref(false)
|
|
||||||
|
|
||||||
// Auto-mirror end date to start: when the user picks a start date,
|
// Auto-mirror end date to start: when the user picks a start date,
|
||||||
// surface that same date in the end-date picker so a one-day event
|
// surface that same date in the end-date picker so a one-day event
|
||||||
// requires no extra clicks. Don't overwrite an end date the user
|
// requires no extra clicks. Don't overwrite an end date the user
|
||||||
|
|
@ -158,7 +125,6 @@ const isPopulating = ref(false)
|
||||||
watch(
|
watch(
|
||||||
() => form.values.event_start_date,
|
() => form.values.event_start_date,
|
||||||
(start, prev) => {
|
(start, prev) => {
|
||||||
if (isPopulating.value) return
|
|
||||||
if (!start) return
|
if (!start) return
|
||||||
const end = form.values.event_end_date
|
const end = form.values.event_end_date
|
||||||
if (!end || end < start || end === prev) {
|
if (!end || end < start || end === prev) {
|
||||||
|
|
@ -175,60 +141,18 @@ const availableCurrencies = ref<string[]>(['sat'])
|
||||||
const loadingCurrencies = ref(false)
|
const loadingCurrencies = ref(false)
|
||||||
const selectedCategories = ref<string[]>([])
|
const selectedCategories = ref<string[]>([])
|
||||||
|
|
||||||
async function populateFromEvent(event: TicketedEvent) {
|
|
||||||
isPopulating.value = true
|
|
||||||
const start = splitDateTime(event.event_start_date)
|
|
||||||
const end = splitDateTime(event.event_end_date)
|
|
||||||
form.setValues({
|
|
||||||
name: event.name,
|
|
||||||
info: event.info ?? '',
|
|
||||||
event_start_date: start.date,
|
|
||||||
event_start_time: start.time,
|
|
||||||
event_end_date: end.date,
|
|
||||||
event_end_time: end.time,
|
|
||||||
location: event.location ?? '',
|
|
||||||
currency: event.currency ?? 'sat',
|
|
||||||
amount_tickets: event.amount_tickets ?? 0,
|
|
||||||
price_per_ticket: event.price_per_ticket ?? 0,
|
|
||||||
})
|
|
||||||
selectedCategories.value = [...(event.categories ?? [])]
|
|
||||||
if (event.banner) {
|
|
||||||
// Re-render the stored banner via its pict-rs file ID. delete_token
|
|
||||||
// is intentionally empty: we don't own the original upload's token
|
|
||||||
// and removing the image on the client should NOT delete the
|
|
||||||
// server-side file (it may be the user changing their mind about
|
|
||||||
// re-using it, or the same image referenced elsewhere).
|
|
||||||
const alias = imageService.extractFileId(event.banner)
|
|
||||||
bannerImages.value = [
|
|
||||||
{ alias, isPrimary: true, delete_token: '', details: {} as any },
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
bannerImages.value = []
|
|
||||||
}
|
|
||||||
// Release the watcher guard after Vue's microtask queue drains so
|
|
||||||
// vee-validate's batched setValues lands before user input can drive
|
|
||||||
// the auto-mirror. nextTick is more reliable than setTimeout(0) here
|
|
||||||
// — it waits for the DOM tick *after* all current microtasks.
|
|
||||||
await nextTick()
|
|
||||||
isPopulating.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, async (isOpen) => {
|
watch(() => props.open, async (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen && ticketApi && !loadingCurrencies.value) {
|
||||||
if (ticketApi && !loadingCurrencies.value) {
|
loadingCurrencies.value = true
|
||||||
loadingCurrencies.value = true
|
try {
|
||||||
try {
|
availableCurrencies.value = await ticketApi.getCurrencies()
|
||||||
availableCurrencies.value = await ticketApi.getCurrencies()
|
} catch (error) {
|
||||||
} catch (error) {
|
console.warn('Failed to load currencies:', error)
|
||||||
console.warn('Failed to load currencies:', error)
|
} finally {
|
||||||
} finally {
|
loadingCurrencies.value = false
|
||||||
loadingCurrencies.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (props.event) {
|
}
|
||||||
await populateFromEvent(props.event)
|
if (!isOpen) {
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -271,11 +195,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
formValues.event_start_date,
|
formValues.event_start_date,
|
||||||
formValues.event_start_time
|
formValues.event_start_time
|
||||||
),
|
),
|
||||||
}
|
wallet: preferredWallet.id,
|
||||||
if (!isEditMode.value) {
|
|
||||||
// Wallet binds at creation. The backend ignores the field on
|
|
||||||
// update so we leave it off the edit payload for clean wire.
|
|
||||||
eventData.wallet = preferredWallet.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional fields — only include if provided
|
// Optional fields — only include if provided
|
||||||
|
|
@ -289,53 +209,21 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
||||||
if (formValues.location) eventData.location = formValues.location
|
if (formValues.location) eventData.location = formValues.location
|
||||||
if (bannerImages.value.length > 0) {
|
if (bannerImages.value.length > 0) {
|
||||||
eventData.banner = imageService.getImageUrl(bannerImages.value[0].alias)
|
eventData.banner = imageService.getImageUrl(bannerImages.value[0].alias)
|
||||||
} else if (isEditMode.value) {
|
|
||||||
// User cleared the banner during edit — propagate the null so the
|
|
||||||
// backend wipes the field instead of keeping the old image.
|
|
||||||
eventData.banner = null
|
|
||||||
}
|
}
|
||||||
if (formValues.currency) eventData.currency = formValues.currency
|
if (formValues.currency) eventData.currency = formValues.currency
|
||||||
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
|
||||||
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
||||||
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
||||||
|
|
||||||
if (isEditMode.value) {
|
await props.onCreateEvent(eventData)
|
||||||
if (!props.onUpdateEvent || !props.event?.id) {
|
toastService.success('Event submitted!')
|
||||||
toastService.error('Update handler missing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await props.onUpdateEvent(props.event.id, eventData)
|
|
||||||
toastService.success(
|
|
||||||
willLandInPending.value
|
|
||||||
? 'Updated — awaiting re-approval. Hidden from the public feed until reviewed.'
|
|
||||||
: 'Event updated!'
|
|
||||||
)
|
|
||||||
emit('event-updated')
|
|
||||||
} else {
|
|
||||||
if (!props.onCreateEvent) {
|
|
||||||
toastService.error('Create handler missing')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await props.onCreateEvent(eventData)
|
|
||||||
toastService.success(
|
|
||||||
willLandInPending.value
|
|
||||||
? 'Submitted! Awaiting admin approval — your draft is visible on your feed with a Pending badge.'
|
|
||||||
: 'Event submitted!'
|
|
||||||
)
|
|
||||||
emit('event-created')
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm()
|
resetForm()
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
bannerImages.value = []
|
bannerImages.value = []
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
|
emit('event-created')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create event'
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: isEditMode.value
|
|
||||||
? 'Failed to update event'
|
|
||||||
: 'Failed to create event'
|
|
||||||
toastService.error(errorMessage)
|
toastService.error(errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
@ -358,27 +246,15 @@ const handleOpenChange = (open: boolean) => {
|
||||||
<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" />
|
||||||
{{ isEditMode ? 'Edit Event' : 'Create Event' }}
|
Create Event
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{{
|
Only a title and start date are required.
|
||||||
isEditMode
|
|
||||||
? 'Update event details. Tickets already sold are not affected.'
|
|
||||||
: 'Only a title and start date are required.'
|
|
||||||
}}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea class="max-h-[70vh] px-6 pb-6">
|
<ScrollArea class="max-h-[70vh] px-6 pb-6">
|
||||||
<form @submit="onSubmit" class="space-y-4">
|
<form @submit="onSubmit" class="space-y-4">
|
||||||
<Alert v-if="isEditMode && willLandInPending" variant="default" class="border-orange-500/40 bg-orange-500/5">
|
|
||||||
<AlertCircle class="h-4 w-4 text-orange-500" />
|
|
||||||
<AlertDescription>
|
|
||||||
Saving will resubmit for approval. The event will be removed
|
|
||||||
from public feeds until reviewed.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<!-- Title (required) -->
|
<!-- Title (required) -->
|
||||||
<FormField v-slot="{ componentField }" name="name">
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
|
@ -575,11 +451,7 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" :disabled="isLoading || !isFormValid">
|
<Button type="submit" :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 ? 'Submitting...' : 'Submit Event' }}
|
||||||
isLoading
|
|
||||||
? (isEditMode ? 'Saving...' : 'Submitting...')
|
|
||||||
: (isEditMode ? 'Save changes' : 'Submit Event')
|
|
||||||
}}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
|
||||||
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
|
import type { CalendarEventFilters } from '../services/ActivitiesNostrService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
|
||||||
import { ticketedEventToActivity } from '../types/activity'
|
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
import { useActivityFilters } from './useActivityFilters'
|
import { useActivityFilters } from './useActivityFilters'
|
||||||
|
|
||||||
|
|
@ -16,55 +12,11 @@ import { useActivityFilters } from './useActivityFilters'
|
||||||
export function useActivities() {
|
export function useActivities() {
|
||||||
const store = useActivitiesStore()
|
const store = useActivitiesStore()
|
||||||
const filters = useActivityFilters()
|
const filters = useActivityFilters()
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
|
||||||
|
|
||||||
const isSubscribed = ref(false)
|
const isSubscribed = ref(false)
|
||||||
const subscriptionError = ref<string | null>(null)
|
const subscriptionError = ref<string | null>(null)
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge the caller's own LNbits events (any status) into the feed.
|
|
||||||
*
|
|
||||||
* The `/activities` feed is Nostr-driven, so an event that hasn't
|
|
||||||
* been published yet — typically because it's still `proposed` under
|
|
||||||
* auto_approve=off — would silently vanish from the creator's view
|
|
||||||
* until an admin approves it. Pull own events from the events
|
|
||||||
* extension and upsert them as Activities so users see their own
|
|
||||||
* drafts with a Pending-review badge.
|
|
||||||
*
|
|
||||||
* Once an event is approved and the Nostr relay delivers the kind
|
|
||||||
* 31922/31923 event, the relay-sourced Activity has a newer
|
|
||||||
* createdAt and wins on upsert (it lacks `lnbitsStatus`, so the
|
|
||||||
* badge disappears).
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Stamp `isMine` on a Nostr-sourced activity when the organizer
|
|
||||||
* pubkey matches the logged-in user's Nostr key. LNbits drafts come
|
|
||||||
* pre-tagged via the adapter.
|
|
||||||
*/
|
|
||||||
function tagOwnership(activity: { organizer: { pubkey: string }; isMine?: boolean }) {
|
|
||||||
const myPubkey = currentUser.value?.pubkey
|
|
||||||
if (myPubkey && activity.organizer.pubkey === myPubkey) {
|
|
||||||
activity.isMine = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOwnEvents() {
|
|
||||||
if (!isAuthenticated.value) return
|
|
||||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
|
||||||
if (!invoiceKey) return
|
|
||||||
const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
|
|
||||||
if (!ticketApi) return
|
|
||||||
try {
|
|
||||||
const mine = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
|
||||||
for (const ev of mine) {
|
|
||||||
store.upsertActivity(ticketedEventToActivity(ev))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[useActivities] loadOwnEvents failed:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtered and sorted activities (from all activities, filters handle time range)
|
// Filtered and sorted activities (from all activities, filters handle time range)
|
||||||
const filteredActivities = computed(() => {
|
const filteredActivities = computed(() => {
|
||||||
const all = store.activities.sort(
|
const all = store.activities.sort(
|
||||||
|
|
@ -91,7 +43,6 @@ export function useActivities() {
|
||||||
|
|
||||||
unsubscribe = nostrService.subscribeToCalendarEvents(
|
unsubscribe = nostrService.subscribeToCalendarEvents(
|
||||||
(activity) => {
|
(activity) => {
|
||||||
tagOwnership(activity)
|
|
||||||
store.upsertActivity(activity)
|
store.upsertActivity(activity)
|
||||||
store.isLoading = false
|
store.isLoading = false
|
||||||
},
|
},
|
||||||
|
|
@ -100,10 +51,6 @@ export function useActivities() {
|
||||||
|
|
||||||
isSubscribed.value = true
|
isSubscribed.value = true
|
||||||
|
|
||||||
// Best-effort merge of own LNbits events (any status) so the
|
|
||||||
// creator sees their own pending drafts on the feed too.
|
|
||||||
loadOwnEvents()
|
|
||||||
|
|
||||||
// Set loading to false after a timeout (in case no events arrive)
|
// Set loading to false after a timeout (in case no events arrive)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
store.isLoading = false
|
store.isLoading = false
|
||||||
|
|
@ -128,7 +75,6 @@ export function useActivities() {
|
||||||
store.isLoading = true
|
store.isLoading = true
|
||||||
subscriptionError.value = null
|
subscriptionError.value = null
|
||||||
const activities = await nostrService.queryCalendarEvents(eventFilters)
|
const activities = await nostrService.queryCalendarEvents(eventFilters)
|
||||||
for (const a of activities) tagOwnership(a)
|
|
||||||
store.upsertActivities(activities)
|
store.upsertActivities(activities)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
|
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
|
||||||
|
|
@ -179,6 +125,5 @@ export function useActivities() {
|
||||||
query,
|
query,
|
||||||
stop,
|
stop,
|
||||||
refresh,
|
refresh,
|
||||||
loadOwnEvents,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { onMounted, ref, watch } from 'vue'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe the events extension for the caller's approval-flow context:
|
|
||||||
*
|
|
||||||
* - `autoApprove` — is the global `auto_approve` toggle on? Reads the
|
|
||||||
* invoice-key-gated public probe (v1.3.0-aio.5+), so non-admin
|
|
||||||
* wallet holders get an accurate answer.
|
|
||||||
* - `isAdmin` — is the caller an LNbits admin? Tries the admin-only
|
|
||||||
* `/events/all` endpoint; a 200 means yes.
|
|
||||||
*
|
|
||||||
* Both refs default to `false` (the safer assumption for warning UI
|
|
||||||
* — biased toward showing the "edit will go back to pending" copy
|
|
||||||
* when in doubt). Probe re-runs whenever auth flips to authenticated.
|
|
||||||
*
|
|
||||||
* Used by every surface that opens the edit-mode CreateEventDialog
|
|
||||||
* (activities-app/App.vue shell mount, activities EventsPage). Keeps
|
|
||||||
* the probe logic single-source-of-truth.
|
|
||||||
*/
|
|
||||||
export function useApprovalState() {
|
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
|
||||||
const isAdmin = ref(false)
|
|
||||||
const autoApprove = ref(false)
|
|
||||||
|
|
||||||
async function probe() {
|
|
||||||
if (!isAuthenticated.value) {
|
|
||||||
isAdmin.value = false
|
|
||||||
autoApprove.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const wallet = currentUser.value?.wallets?.[0]
|
|
||||||
if (!wallet?.inkey) return
|
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
|
||||||
autoApprove.value = await ticketApi.getAutoApprove(wallet.inkey)
|
|
||||||
if (wallet.adminkey) {
|
|
||||||
isAdmin.value = await ticketApi.isAdmin(wallet.adminkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(probe)
|
|
||||||
watch(isAuthenticated, (yes) => {
|
|
||||||
if (yes) probe()
|
|
||||||
})
|
|
||||||
|
|
||||||
return { isAdmin, autoApprove, probe }
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +1,14 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAsyncState } from '@vueuse/core'
|
import { useAsyncState } from '@vueuse/core'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
import type { TicketedEvent } from '../types/ticket'
|
||||||
|
|
||||||
export function useEvents() {
|
export function useEvents() {
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
|
||||||
|
|
||||||
// When authenticated, also fetch the user's own events (any status)
|
|
||||||
// and merge them into the feed. Otherwise an event that drops to
|
|
||||||
// `proposed` after a non-admin edit disappears from the user's view
|
|
||||||
// entirely — they'd be unable to find it to make a follow-up edit or
|
|
||||||
// monitor its approval status. Public approved events from other
|
|
||||||
// users take precedence on dedup (server is the source of truth for
|
|
||||||
// the public view).
|
|
||||||
const fetchAll = async (): Promise<TicketedEvent[]> => {
|
|
||||||
const publicEvents = (await ticketApi.fetchTicketedEvents()) as TicketedEvent[]
|
|
||||||
|
|
||||||
if (!isAuthenticated.value) return publicEvents
|
|
||||||
|
|
||||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
|
||||||
if (!invoiceKey) return publicEvents
|
|
||||||
|
|
||||||
try {
|
|
||||||
const myEvents = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
|
||||||
const seen = new Set(publicEvents.map((e) => e.id))
|
|
||||||
const own = myEvents.filter((e) => !seen.has(e.id))
|
|
||||||
return [...publicEvents, ...own]
|
|
||||||
} catch (err) {
|
|
||||||
// Falling back to just the public feed is acceptable — the user
|
|
||||||
// can still browse, they just won't see their own pending events.
|
|
||||||
// Log so a flaky probe is debuggable from the console without
|
|
||||||
// toast-spamming the user on every transient failure.
|
|
||||||
console.warn('[useEvents] fetchMyEvents failed, showing public feed only:', err)
|
|
||||||
return publicEvents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState(
|
const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState(
|
||||||
fetchAll,
|
() => ticketApi.fetchTicketedEvents() as Promise<TicketedEvent[]>,
|
||||||
[] as TicketedEvent[],
|
[] as TicketedEvent[],
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
|
|
||||||
|
|
@ -34,20 +34,6 @@ export class TicketApiService {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the authenticated user's own events across all their wallets,
|
|
||||||
* regardless of status. Lets the webapp show the user's pending /
|
|
||||||
* rejected events alongside the public approved feed — without this
|
|
||||||
* a user who edits under `auto_approve=false` loses sight of their
|
|
||||||
* own event the moment it drops to `proposed`.
|
|
||||||
*/
|
|
||||||
async fetchMyEvents(invoiceKey: string): Promise<any[]> {
|
|
||||||
return this.request('/events/api/v1/events?all_wallets=true', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'X-API-KEY': invoiceKey },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request a ticket purchase (creates a Lightning invoice).
|
* Request a ticket purchase (creates a Lightning invoice).
|
||||||
* Uses POST /tickets/{event_id} with user_id in body (upstream API).
|
* Uses POST /tickets/{event_id} with user_id in body (upstream API).
|
||||||
|
|
@ -162,62 +148,6 @@ export class TicketApiService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing event. Requires the event's wallet admin key.
|
|
||||||
* Status is re-derived server-side from admin/auto_approve — a non-
|
|
||||||
* admin owner editing under `auto_approve=false` lands back at
|
|
||||||
* `proposed` regardless of the current state.
|
|
||||||
*/
|
|
||||||
async updateEvent(
|
|
||||||
eventId: string,
|
|
||||||
eventData: CreateEventRequest,
|
|
||||||
adminKey: string,
|
|
||||||
): Promise<TicketedEvent> {
|
|
||||||
return this.request(`/events/api/v1/events/${eventId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-API-KEY': adminKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(eventData),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe whether the current user has LNbits admin privileges. The
|
|
||||||
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin",
|
|
||||||
* any other response means "not admin".
|
|
||||||
*/
|
|
||||||
async isAdmin(adminKey: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await this.request('/events/api/v1/events/all', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'X-API-KEY': adminKey },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the extension's auto_approve flag. Hits the public probe
|
|
||||||
* (invoice-key-gated, available to any wallet holder), so non-admin
|
|
||||||
* users see the real value and the edit-flow copy is accurate.
|
|
||||||
* Degrades to `false` on failure — the safer default for warning UI.
|
|
||||||
*/
|
|
||||||
async getAutoApprove(invoiceKey: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const settings = await this.request('/events/api/v1/events/settings/public', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'X-API-KEY': invoiceKey },
|
|
||||||
})
|
|
||||||
return Boolean(settings?.auto_approve)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available currencies from LNbits.
|
* Fetch available currencies from LNbits.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Activity } from '../types/activity'
|
import type { Activity } from '../types/activity'
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pinia store for cached activities from Nostr relays.
|
* Pinia store for cached activities from Nostr relays.
|
||||||
|
|
@ -15,9 +14,6 @@ export const useActivitiesStore = defineStore('activities', () => {
|
||||||
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives
|
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives
|
||||||
* in activities-app/App.vue so it's available from every route. */
|
* in activities-app/App.vue so it's available from every route. */
|
||||||
const showCreateDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
/** When set, the shell-mounted CreateEventDialog opens in edit mode
|
|
||||||
* for this LNbits event. Cleared when the dialog closes. */
|
|
||||||
const editingEvent = ref<TicketedEvent | null>(null)
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const activities = computed(() => Array.from(activitiesMap.value.values()))
|
const activities = computed(() => Array.from(activitiesMap.value.values()))
|
||||||
|
|
@ -92,7 +88,6 @@ export const useActivitiesStore = defineStore('activities', () => {
|
||||||
isLoading,
|
isLoading,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
showCreateDialog,
|
showCreateDialog,
|
||||||
editingEvent,
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
activities,
|
activities,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import ngeohash from 'ngeohash'
|
import ngeohash from 'ngeohash'
|
||||||
import type { ActivityCategory } from './category'
|
import type { ActivityCategory } from './category'
|
||||||
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
|
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
|
||||||
import type { TicketedEvent } from './ticket'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified view model for displaying activities in the UI.
|
* Unified view model for displaying activities in the UI.
|
||||||
|
|
@ -46,22 +45,6 @@ export interface Activity {
|
||||||
isPrivate: boolean
|
isPrivate: boolean
|
||||||
/** Nostr event created_at timestamp */
|
/** Nostr event created_at timestamp */
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
/**
|
|
||||||
* LNbits approval status, when the activity came from the events
|
|
||||||
* extension rather than a Nostr relay. Undefined for activities
|
|
||||||
* sourced from Nostr (approved by definition — only published
|
|
||||||
* events make it onto relays). Used to render a "Pending review"
|
|
||||||
* badge for the creator's own non-approved drafts.
|
|
||||||
*/
|
|
||||||
lnbitsStatus?: 'approved' | 'proposed' | 'rejected'
|
|
||||||
/**
|
|
||||||
* Belongs to the current user. Set by the adapter for own LNbits
|
|
||||||
* drafts and by the activities-subscribe callback when the Nostr
|
|
||||||
* organizer pubkey matches the logged-in user. Used to render a
|
|
||||||
* "Yours" badge on the feed so the creator can spot their events
|
|
||||||
* at a glance.
|
|
||||||
*/
|
|
||||||
isMine?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizerInfo {
|
export interface OrganizerInfo {
|
||||||
|
|
@ -145,74 +128,6 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an LNbits TicketedEvent to an Activity view model.
|
|
||||||
*
|
|
||||||
* Used to surface the caller's own pending events on the activities
|
|
||||||
* feed alongside Nostr-published activities. Once an event is approved
|
|
||||||
* and published, the Nostr-derived Activity (newer createdAt) wins on
|
|
||||||
* upsert in the activities store and this draft version is replaced.
|
|
||||||
*
|
|
||||||
* The wire format for dates mirrors how nostr_publisher emits NIP-52:
|
|
||||||
* - "YYYY-MM-DD" → date-based (kind 31922 on publish)
|
|
||||||
* - "YYYY-MM-DDTHH:MM..." → time-based (kind 31923 on publish)
|
|
||||||
*/
|
|
||||||
export function ticketedEventToActivity(
|
|
||||||
event: TicketedEvent,
|
|
||||||
organizer?: Partial<OrganizerInfo>,
|
|
||||||
): Activity {
|
|
||||||
const hasTime = event.event_start_date.includes('T')
|
|
||||||
const startDate = hasTime
|
|
||||||
? new Date(event.event_start_date)
|
|
||||||
: parseDateOnly(event.event_start_date)
|
|
||||||
const endRaw = event.event_end_date
|
|
||||||
const endDate = endRaw
|
|
||||||
? endRaw.includes('T')
|
|
||||||
? new Date(endRaw)
|
|
||||||
: parseDateOnly(endRaw)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const category = event.categories?.[0] as ActivityCategory | undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
// No published Nostr event yet for pending drafts; reuse the LNbits
|
|
||||||
// id as a placeholder. Approved + published versions will overwrite
|
|
||||||
// this with the real Nostr event id.
|
|
||||||
nostrEventId: event.id,
|
|
||||||
type: hasTime ? 'time' : 'date',
|
|
||||||
organizer: {
|
|
||||||
// Pending events have no Nostr pubkey yet. Empty string is fine
|
|
||||||
// — the card layer falls back gracefully and the OrganizerCard
|
|
||||||
// is only shown for approved (Nostr-sourced) activities anyway.
|
|
||||||
pubkey: '',
|
|
||||||
...organizer,
|
|
||||||
},
|
|
||||||
title: event.name,
|
|
||||||
description: event.info ?? '',
|
|
||||||
image: event.banner ?? undefined,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
location: event.location ?? undefined,
|
|
||||||
category,
|
|
||||||
tags: event.categories ?? [],
|
|
||||||
isPrivate: false,
|
|
||||||
// event.time is the LNbits creation timestamp (ISO string after
|
|
||||||
// FastAPI serialization). new Date() handles both ISO strings and
|
|
||||||
// numeric epoch — same shape used in useEvents sorting.
|
|
||||||
createdAt: new Date(event.time) || new Date(),
|
|
||||||
lnbitsStatus: event.status as Activity['lnbitsStatus'],
|
|
||||||
// fetchMyEvents only returns the caller's own events, so anything
|
|
||||||
// reaching this adapter is by definition mine.
|
|
||||||
isMine: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDateOnly(dateStr: string): Date {
|
|
||||||
const [year, month, day] = dateStr.split('-').map(Number)
|
|
||||||
return new Date(Date.UTC(year, month - 1, day))
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
|
function decodeGeohash(geohash?: string): { lat: number; lng: number } | undefined {
|
||||||
if (!geohash) return undefined
|
if (!geohash) return undefined
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
@ -8,18 +8,13 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
Calendar, MapPin, ArrowLeft, Pencil,
|
Calendar, MapPin, ArrowLeft,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useActivityDetail } from '../composables/useActivityDetail'
|
import { useActivityDetail } from '../composables/useActivityDetail'
|
||||||
import BookmarkButton from '../components/BookmarkButton.vue'
|
import BookmarkButton from '../components/BookmarkButton.vue'
|
||||||
import RSVPButton from '../components/RSVPButton.vue'
|
import RSVPButton from '../components/RSVPButton.vue'
|
||||||
import OrganizerCard from '../components/OrganizerCard.vue'
|
import OrganizerCard from '../components/OrganizerCard.vue'
|
||||||
import { NIP52_KINDS } from '../types/nip52'
|
import { NIP52_KINDS } from '../types/nip52'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
|
||||||
import type { TicketedEvent } from '../types/ticket'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -29,38 +24,6 @@ const activityId = route.params.id as string
|
||||||
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
|
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
|
||||||
const { dateLocale } = useDateLocale()
|
const { dateLocale } = useDateLocale()
|
||||||
|
|
||||||
// Owner-edit affordance: the NIP-52 d-tag we use for the activity id is
|
|
||||||
// the same as the LNbits event id (set at publish time in
|
|
||||||
// nostr_publisher.build_nip52_event). Look the user's own events up
|
|
||||||
// once and offer an Edit button on a match.
|
|
||||||
const { isAuthenticated, currentUser } = useAuth()
|
|
||||||
const activitiesStore = useActivitiesStore()
|
|
||||||
const ownedLnbitsEvent = ref<TicketedEvent | null>(null)
|
|
||||||
|
|
||||||
async function loadOwnedEvent() {
|
|
||||||
ownedLnbitsEvent.value = null
|
|
||||||
if (!isAuthenticated.value) return
|
|
||||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
|
||||||
if (!invoiceKey) return
|
|
||||||
try {
|
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
|
||||||
const mine = await ticketApi.fetchMyEvents(invoiceKey)
|
|
||||||
ownedLnbitsEvent.value =
|
|
||||||
(mine as TicketedEvent[]).find((e) => e.id === activityId) ?? null
|
|
||||||
} catch {
|
|
||||||
ownedLnbitsEvent.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadOwnedEvent)
|
|
||||||
watch(isAuthenticated, () => loadOwnedEvent())
|
|
||||||
|
|
||||||
function openEditDialog() {
|
|
||||||
if (!ownedLnbitsEvent.value) return
|
|
||||||
activitiesStore.editingEvent = ownedLnbitsEvent.value
|
|
||||||
activitiesStore.showCreateDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateDisplay = computed(() => {
|
const dateDisplay = computed(() => {
|
||||||
if (!activity.value) return ''
|
if (!activity.value) return ''
|
||||||
const a = activity.value
|
const a = activity.value
|
||||||
|
|
@ -72,18 +35,11 @@ const dateDisplay = computed(() => {
|
||||||
}
|
}
|
||||||
return start
|
return start
|
||||||
}
|
}
|
||||||
// Time-based event. If start and end share the same calendar day,
|
const start = format(a.startDate, 'EEEE, MMMM d, yyyy \u2022 HH:mm', opts)
|
||||||
// show end as time-only ("19:00 — 21:45"). Otherwise repeat the full
|
if (a.endDate) {
|
||||||
// date on the end side so a multi-day event reads unambiguously
|
return `${start} — ${format(a.endDate, 'HH:mm', opts)}`
|
||||||
// ("May 29 • 19:00 — May 30 • 21:45").
|
}
|
||||||
const FULL = 'EEEE, MMMM d, yyyy \u2022 HH:mm'
|
return start
|
||||||
const start = format(a.startDate, FULL, opts)
|
|
||||||
if (!a.endDate) return start
|
|
||||||
const sameDay =
|
|
||||||
a.startDate.getFullYear() === a.endDate.getFullYear() &&
|
|
||||||
a.startDate.getMonth() === a.endDate.getMonth() &&
|
|
||||||
a.startDate.getDate() === a.endDate.getDate()
|
|
||||||
return `${start} — ${format(a.endDate, sameDay ? 'HH:mm' : FULL, opts)}`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
const categoryLabel = computed(() => {
|
||||||
|
|
@ -104,24 +60,11 @@ function goBack() {
|
||||||
<ArrowLeft class="w-4 h-4" />
|
<ArrowLeft class="w-4 h-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center gap-1.5">
|
<BookmarkButton
|
||||||
<Button
|
v-if="activity"
|
||||||
v-if="ownedLnbitsEvent"
|
:pubkey="activity.organizer.pubkey"
|
||||||
variant="ghost"
|
:d-tag="activity.id"
|
||||||
size="sm"
|
/>
|
||||||
class="gap-1.5"
|
|
||||||
@click="openEditDialog"
|
|
||||||
aria-label="Edit event"
|
|
||||||
>
|
|
||||||
<Pencil class="w-4 h-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<BookmarkButton
|
|
||||||
v-if="activity"
|
|
||||||
:pubkey="activity.organizer.pubkey"
|
|
||||||
:d-tag="activity.id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
|
|
@ -156,20 +99,6 @@ function goBack() {
|
||||||
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
|
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
|
||||||
{{ categoryLabel }}
|
{{ categoryLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
|
||||||
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
|
|
||||||
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
|
||||||
class="shrink-0 mt-1 capitalize"
|
|
||||||
>
|
|
||||||
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="activity.isMine"
|
|
||||||
variant="outline"
|
|
||||||
class="shrink-0 mt-1"
|
|
||||||
>
|
|
||||||
Yours
|
|
||||||
</Badge>
|
|
||||||
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
<div v-for="tag in activity.tags.slice(1)" :key="tag">
|
||||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useEvents } from '../composables/useEvents'
|
import { useEvents } from '../composables/useEvents'
|
||||||
import { useApprovalState } from '../composables/useApprovalState'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
@ -11,15 +10,14 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
|
||||||
import CreateEventDialog from '../components/CreateEventDialog.vue'
|
import CreateEventDialog from '../components/CreateEventDialog.vue'
|
||||||
import { User, LogIn, Plus, Pencil } from 'lucide-vue-next'
|
import { User, LogIn, Plus } from 'lucide-vue-next'
|
||||||
import { formatEventPrice } from '@/lib/utils/formatting'
|
import { formatEventPrice } from '@/lib/utils/formatting'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
import type { CreateEventRequest } from '../types/ticket'
|
||||||
|
|
||||||
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
|
||||||
const { isAuthenticated, userDisplay, currentUser } = useAuth()
|
const { isAuthenticated, userDisplay } = useAuth()
|
||||||
const { isAdmin, autoApprove } = useApprovalState()
|
|
||||||
|
|
||||||
const showPurchaseDialog = ref(false)
|
const showPurchaseDialog = ref(false)
|
||||||
const selectedEvent = ref<{
|
const selectedEvent = ref<{
|
||||||
|
|
@ -29,17 +27,7 @@ const selectedEvent = ref<{
|
||||||
currency: string
|
currency: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const showEventDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
// `null` ↔ create mode; populated ↔ edit mode.
|
|
||||||
const editingEvent = ref<TicketedEvent | null>(null)
|
|
||||||
|
|
||||||
const myWalletIds = computed(
|
|
||||||
() => new Set((currentUser.value?.wallets ?? []).map((w) => w.id))
|
|
||||||
)
|
|
||||||
|
|
||||||
function canEdit(event: TicketedEvent): boolean {
|
|
||||||
return isAuthenticated.value && myWalletIds.value.has(event.wallet)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null | undefined) {
|
function formatDate(dateStr: string | null | undefined) {
|
||||||
if (!dateStr) return 'Date not available'
|
if (!dateStr) return 'Date not available'
|
||||||
|
|
@ -64,6 +52,7 @@ function handlePurchaseClick(event: {
|
||||||
|
|
||||||
async function handleCreateEvent(eventData: CreateEventRequest) {
|
async function handleCreateEvent(eventData: CreateEventRequest) {
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
const { currentUser } = useAuth()
|
||||||
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
|
||||||
if (!invoiceKey) {
|
if (!invoiceKey) {
|
||||||
throw new Error('No wallet available. Please log in first.')
|
throw new Error('No wallet available. Please log in first.')
|
||||||
|
|
@ -72,36 +61,7 @@ async function handleCreateEvent(eventData: CreateEventRequest) {
|
||||||
await ticketApi.createEvent(eventData, invoiceKey)
|
await ticketApi.createEvent(eventData, invoiceKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateEvent(eventId: string, eventData: CreateEventRequest) {
|
function handleEventCreated() {
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
|
||||||
// PUT /events/{id} requires the event's wallet admin key.
|
|
||||||
const wallet = (currentUser.value?.wallets ?? []).find(
|
|
||||||
(w) => w.id === editingEvent.value?.wallet,
|
|
||||||
)
|
|
||||||
const adminKey = wallet?.adminkey
|
|
||||||
if (!adminKey) {
|
|
||||||
throw new Error("Can't find the admin key for this event's wallet.")
|
|
||||||
}
|
|
||||||
await ticketApi.updateEvent(eventId, eventData, adminKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDialog() {
|
|
||||||
editingEvent.value = null
|
|
||||||
showEventDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDialog(event: TicketedEvent) {
|
|
||||||
editingEvent.value = event
|
|
||||||
showEventDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDialogClosed() {
|
|
||||||
// Reset the edit selection so a subsequent "New Event" click opens
|
|
||||||
// clean instead of inheriting the last-edited event.
|
|
||||||
if (!showEventDialog.value) editingEvent.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEventChanged() {
|
|
||||||
refresh?.()
|
refresh?.()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -123,7 +83,7 @@ function handleEventChanged() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 sm:flex-shrink-0">
|
<div class="flex gap-2 sm:flex-shrink-0">
|
||||||
<Button v-if="isAuthenticated" variant="default" size="sm" @click="openCreateDialog" class="flex-1 sm:flex-none">
|
<Button v-if="isAuthenticated" variant="default" size="sm" @click="showCreateDialog = true" class="flex-1 sm:flex-none">
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
<span class="ml-2">Create Event</span>
|
<span class="ml-2">Create Event</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -145,16 +105,7 @@ function handleEventChanged() {
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card v-for="event in upcomingEvents" :key="event.id" class="flex flex-col">
|
<Card v-for="event in upcomingEvents" :key="event.id" class="flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<CardTitle class="text-foreground">{{ event.name }}</CardTitle>
|
||||||
<CardTitle class="text-foreground">{{ event.name }}</CardTitle>
|
|
||||||
<Badge
|
|
||||||
v-if="canEdit(event) && event.status !== 'approved'"
|
|
||||||
:variant="event.status === 'rejected' ? 'destructive' : 'secondary'"
|
|
||||||
class="shrink-0 capitalize"
|
|
||||||
>
|
|
||||||
{{ event.status === 'rejected' ? 'Rejected' : 'Pending review' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{{ event.info }}</CardDescription>
|
<CardDescription>{{ event.info }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="flex-grow">
|
<CardContent class="flex-grow">
|
||||||
|
|
@ -177,33 +128,19 @@ function handleEventChanged() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter class="flex gap-2">
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
class="flex-1"
|
class="w-full"
|
||||||
variant="default"
|
variant="default"
|
||||||
:disabled="
|
:disabled="event.amount_tickets <= event.sold || !isAuthenticated"
|
||||||
event.status !== 'approved' ||
|
|
||||||
event.amount_tickets <= event.sold ||
|
|
||||||
!isAuthenticated
|
|
||||||
"
|
|
||||||
@click="handlePurchaseClick(event)"
|
@click="handlePurchaseClick(event)"
|
||||||
>
|
>
|
||||||
<span v-if="!isAuthenticated" class="flex items-center gap-2">
|
<span v-if="!isAuthenticated" class="flex items-center gap-2">
|
||||||
<LogIn class="w-4 h-4" />
|
<LogIn class="w-4 h-4" />
|
||||||
Login to Purchase
|
Login to Purchase
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="event.status !== 'approved'">Not yet available</span>
|
|
||||||
<span v-else>Buy Ticket</span>
|
<span v-else>Buy Ticket</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
v-if="canEdit(event)"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Edit event"
|
|
||||||
@click="openEditDialog(event)"
|
|
||||||
>
|
|
||||||
<Pencil class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,18 +189,10 @@ function handleEventChanged() {
|
||||||
|
|
||||||
<PurchaseTicketDialog v-if="selectedEvent" :event="selectedEvent" v-model:is-open="showPurchaseDialog" />
|
<PurchaseTicketDialog v-if="selectedEvent" :event="selectedEvent" v-model:is-open="showPurchaseDialog" />
|
||||||
<CreateEventDialog
|
<CreateEventDialog
|
||||||
:open="showEventDialog"
|
:open="showCreateDialog"
|
||||||
:event="editingEvent"
|
@update:open="showCreateDialog = $event"
|
||||||
:is-admin="isAdmin"
|
|
||||||
:auto-approve="autoApprove"
|
|
||||||
:on-create-event="handleCreateEvent"
|
:on-create-event="handleCreateEvent"
|
||||||
:on-update-event="handleUpdateEvent"
|
@event-created="handleEventCreated"
|
||||||
@update:open="
|
|
||||||
showEventDialog = $event
|
|
||||||
handleDialogClosed()
|
|
||||||
"
|
|
||||||
@event-created="handleEventChanged"
|
|
||||||
@event-updated="handleEventChanged"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -449,11 +449,7 @@ const removeImage = async (imageToRemove: ImageWithMetadata) => {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Server-side delete only when we have a delete_token (newly
|
// Only try to delete from pict-rs if we have a delete token (newly uploaded images)
|
||||||
// uploaded this session). Pre-existing images re-populated from a
|
|
||||||
// stored URL ship `delete_token: ''` by convention — we don't own
|
|
||||||
// the original upload's one-time token, and removing on the client
|
|
||||||
// shouldn't reach back and wipe the server-side file.
|
|
||||||
if (imageToRemove.delete_token) {
|
if (imageToRemove.delete_token) {
|
||||||
await imageService.deleteImage(imageToRemove.delete_token, imageToRemove.alias)
|
await imageService.deleteImage(imageToRemove.delete_token, imageToRemove.alias)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,12 +300,9 @@ export class ImageUploadService extends BaseService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a pict-rs file ID from an alias, accepting both bare IDs
|
* Extract file ID from alias, handling both file IDs and full URLs
|
||||||
* and full `/image/original/<id>` URLs. Public so callers
|
|
||||||
* re-populating uploads from stored URLs (edit flows) don't have to
|
|
||||||
* re-implement the parse.
|
|
||||||
*/
|
*/
|
||||||
extractFileId(alias: string): string {
|
private extractFileId(alias: string): string {
|
||||||
if (!alias) {
|
if (!alias) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -516,17 +516,30 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||||
// Reset form with appropriate initial values
|
// Reset form with appropriate initial values
|
||||||
resetForm({ values: initialValues })
|
resetForm({ values: initialValues })
|
||||||
|
|
||||||
// Convert existing image URLs to the format expected by ImageUpload.
|
// Convert existing image URLs to the format expected by ImageUpload component
|
||||||
// delete_token is intentionally empty for pre-existing images: see
|
|
||||||
// ImageUploadService.deleteImage gate — removing on the client
|
|
||||||
// should not delete the server-side file.
|
|
||||||
if (props.product?.images && props.product.images.length > 0) {
|
if (props.product?.images && props.product.images.length > 0) {
|
||||||
uploadedImages.value = props.product.images.map((url, index) => ({
|
// For existing products, we need to convert URLs back to a format ImageUpload can display
|
||||||
alias: imageService.extractFileId(url),
|
uploadedImages.value = props.product.images.map((url, index) => {
|
||||||
delete_token: '',
|
let alias = url
|
||||||
isPrimary: index === 0,
|
|
||||||
details: {} as any,
|
// If it's a full pict-rs URL, extract just the file ID
|
||||||
}))
|
if (url.includes('/image/original/')) {
|
||||||
|
const parts = url.split('/image/original/')
|
||||||
|
if (parts.length > 1 && parts[1]) {
|
||||||
|
alias = parts[1]
|
||||||
|
}
|
||||||
|
} else if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
// Keep full URLs as-is
|
||||||
|
alias = url
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alias: alias,
|
||||||
|
delete_token: '',
|
||||||
|
isPrimary: index === 0,
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
uploadedImages.value = []
|
uploadedImages.value = []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue