Compare commits

..

16 commits

Author SHA1 Message Date
63fc7b3ab8 feat(activities): pending-aware toast + unified pending gate
Rename willGoToPending → willLandInPending, drop the (now-redundant)
isEditMode predicate from the gate so create can reuse it. Toast
copy now confirms the destination explicitly:

  create + pending : "Submitted! Awaiting admin approval — your
                     draft is visible on your feed with a Pending
                     badge."
  edit + pending   : "Updated — awaiting re-approval. Hidden from
                     the public feed until reviewed."

Closes the surprise where a non-admin user under auto_approve=off
got a generic "Event submitted!" and then couldn't tell whether
their post had been accepted or was just waiting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:53:55 +02:00
556b9e5cfe feat(activities): ownership + status badges on cards & detail
ActivityCard now renders a "Yours" badge (top-right corner of the
image) when activity.isMine, and a "Pending review" / "Rejected"
badge (bottom-left) when activity.lnbitsStatus is non-approved. The
creator can spot their own posts at a glance on the main feed —
particularly important for pending drafts that don't exist on
Nostr yet.

ActivityDetailPage echoes both badges next to the category row so
users landing on the detail link of their own draft have the same
signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:53:42 +02:00
dbc8b7abf4 feat(activities): merge own LNbits drafts into the feed
The /activities feed is Nostr-driven, so an event that hasn't been
published yet (typically `proposed` under auto_approve=off) silently
vanishes from the creator's view. Add ticketedEventToActivity (the
LNbits → NIP-52-shape adapter) and call it from useActivities so own
drafts surface alongside Nostr-published activities. Once approved
and published, the relay-sourced Activity has a newer createdAt and
wins on upsert (and lacks lnbitsStatus, so any badge disappears).

Also fire loadOwnEvents from the shell after event-created /
event-updated so a fresh draft shows up immediately, not on the next
subscribe cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:53:24 +02:00
9b5f1273b3 fix(activities): multi-day time-based events show end date too
ActivityDetailPage.dateDisplay rendered the end of a time-based event
as time-only ("19:00 — 21:45"), implying a single calendar day even
when the event actually spanned multiple days. The end date was
silently lost in the display.

Detect whether start and end share the same calendar day; if not,
repeat the full date on the end side so a multi-day event reads as
"Fri May 29 • 19:00 — Sat May 30 • 21:45".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:31:01 +02:00
79be46c33d fix(activities): tighter populate race + log silent feed-fetch fail
CreateEventDialog.populateFromEvent released the watcher guard via
setTimeout(0). Switch to nextTick so the release waits for Vue's
microtask flush — setTimeout(0) only schedules a macrotask, which
can run before vee-validate's batched setValues lands, briefly
unguarding the auto-mirror during populate.

useEvents.fetchAll silently swallowed fetchMyEvents failures so a
flaky probe degraded to "public events only" with no signal in the
console. Add a console.warn so the degradation is debuggable without
toast-spamming users on transient errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:13:34 +02:00
e540feba44 refactor(activities): useApprovalState composable
Both the activities-app shell and EventsPage probed isAdmin /
autoApprove with slightly different code, both needed by the edit
dialog's warning copy. Extract into a single composable so the probe
sequence + re-probe-on-auth-flip behavior lives in one place.

Also clear editingEvent eagerly when the bottom-nav Create tab fires
so a Create tap never inherits a stale Edit selection from a prior
close path that didn't run for any reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:13:22 +02:00
2b376bb244 refactor(base): expose extractFileId, dedupe URL→file-id parsing
Promote ImageUploadService.extractFileId from private to public so
edit-flow consumers don't re-implement the `/image/original/<id>`
parse. Used by market's CreateProductDialog and activities'
CreateEventDialog when re-populating <ImageUpload> from a stored URL.

Also clarify ImageUpload.removeImage: the `delete_token: ''` placeholder
on re-populated images intentionally skips the server-side DELETE.
We don't own the original upload's one-time token, and removing
client-side shouldn't reach back and wipe shared files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:13:08 +02:00
b9bca36b50 feat(activities): edit button on activity detail page
The NIP-52 d-tag we publish for an event is the LNbits event id (set
in nostr_publisher.build_nip52_event), so a single fetchMyEvents call
can tell us whether the displayed activity belongs to the caller.
When it does, show an Edit button next to Bookmark; clicking sets
the store's editingEvent and opens the shell-mounted dialog in edit
mode.

This was the missing surface — users land on /activities/:id when
they tap a posting, not on /events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:01:55 +02:00
345ca073af feat(activities-app): wire shell dialog for edit + approval probes
Probe isAdmin / autoApprove once at auth-ready (re-probe on login)
and feed them plus the store's editingEvent into the shell-mounted
CreateEventDialog. Add handleUpdateEvent that picks the right wallet
admin key from the editing event's wallet id.

Without this the Activities standalone app could only Create — the
existing dialog was create-only at shell level even though the
dialog component itself already supported edit mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:01:55 +02:00
a77bf7ff6c feat(activities): editingEvent in activities store
Carry the LNbits event being edited at store level so the
shell-mounted CreateEventDialog (activities-app/App.vue) can open in
edit mode from anywhere a user surfaces their own event — most
importantly the activity detail page, which is where they actually
land when fixing a posting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:01:55 +02:00
9b1b56e05d feat(activities): status badge + buy-disabled on own pending events
Show a "Pending review" (or "Rejected") badge on the user's own
non-approved events, and disable the Buy Ticket button on any
non-approved event with a "Not yet available" label. Probe
auto_approve via the public endpoint with inkey, not adminkey, so the
warning copy works for non-admin owners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:19 +02:00
01b871e7fa feat(activities): merge own events into the feed
When authenticated, parallel-fetch the caller's own events (any
status) alongside the public approved feed and merge with public-wins
dedup. Without this, an event that drops to `proposed` after a
non-admin edit disappears from the user's view — they couldn't find
it to make a follow-up edit or watch for re-approval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:19 +02:00
3047565920 feat(activities): fetchMyEvents + invoice-key auto_approve probe
`fetchMyEvents` hits the existing all_wallets=true endpoint to surface
the caller's own events regardless of status. `getAutoApprove` now
calls the public probe (invoice-key-gated) added in events extension
v1.3.0-aio.5 so non-admin webapp users get accurate edit-flow copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:55:19 +02:00
af3c9853c0 feat(activities): edit button on user-owned events
Pencil button in the card footer of upcoming events the current user
owns (event.wallet ∈ currentUser.wallets). Clicking opens the same
CreateEventDialog in edit mode, pre-populated with the event.

Probe `is_admin` and `auto_approve` once at mount so the dialog can
render the "going back to pending" warning copy accurately for
non-admin owners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:30:00 +02:00
cd35fae674 feat(activities): dual-mode CreateEventDialog supports edit
Accept optional `event` prop and `onUpdateEvent` handler. Dialog
toggles title, description, submit button text, and a warning Alert
based on edit mode plus an `isAdmin`/`autoApprove` pair the parent
supplies.

On open in edit mode, populate the form from the event — split stored
"YYYY-MM-DD[THH:MM]" back into date+time inputs, restore categories,
and seed bannerImages from the stored URL by extracting the pict-rs
file ID (same pattern as market's CreateProductDialog).

A clearing-the-banner action during edit sends `banner: null` so the
backend wipes the field instead of keeping the old image. Auto-mirror
watcher is guarded against firing during the initial population.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:29:49 +02:00
4bea1a6592 feat(activities): TicketApiService.updateEvent + admin/auto_approve probes
`updateEvent` calls PUT /events/{id} with the event's wallet admin key
— mirrors the backend's `require_admin_key` decorator (different key
than the inkey used by createEvent).

Add `isAdmin` and `getAutoApprove` probes so the dialog can decide
whether to show "edit will go back to pending approval" copy. Both
degrade to `false` on failure, which biases the warning toward being
shown when in doubt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:29:37 +02:00
14 changed files with 699 additions and 79 deletions

View file

@ -7,6 +7,8 @@ import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService'
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 type { TicketApiService } from '@/modules/activities/services/TicketApiService'
import type { CreateEventRequest } from '@/modules/activities/types/ticket'
@ -16,6 +18,11 @@ const route = useRoute()
const { t } = useI18n()
const { isAuthenticated, currentUser } = useAuth()
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.
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate
@ -27,7 +34,12 @@ const tabs = computed<BottomTab[]>(() => [
{
name: t('activities.createNew'),
icon: Plus,
onClick: () => { activitiesStore.showCreateDialog = true },
onClick: () => {
// 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,
},
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
@ -57,14 +69,40 @@ async function handleCreateEvent(eventData: CreateEventRequest) {
if (!invoiceKey) throw new Error('No wallet available. Please log in first.')
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>
<template>
<AppShell :tabs="tabs" :is-active="isActive">
<CreateEventDialog
:open="activitiesStore.showCreateDialog"
@update:open="activitiesStore.showCreateDialog = $event"
:event="activitiesStore.editingEvent"
:is-admin="isAdmin"
:auto-approve="autoApprove"
:on-create-event="handleCreateEvent"
:on-update-event="handleUpdateEvent"
@update:open="handleDialogOpenChange"
@event-created="loadOwnEvents"
@event-updated="loadOwnEvents"
/>
</AppShell>
</template>

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Activity } from '../types/activity'
@ -87,6 +87,17 @@ const placeholderBg = computed(() => {
{{ categoryLabel }}
</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 -->
<Badge
v-if="priceDisplay"
@ -95,6 +106,17 @@ const placeholderBg = computed(() => {
{{ priceDisplay }}
</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>
<CardContent class="p-4 flex-1 flex flex-col gap-2">

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
@ -32,28 +32,49 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Calendar, Loader2, MapPin } from 'lucide-vue-next'
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.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 { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category'
interface Props {
open: boolean
onCreateEvent: (eventData: CreateEventRequest) => Promise<void>
/** When set, dialog opens in edit mode for this event. */
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 emit = defineEmits<{
'update:open': [value: boolean]
'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()
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
@ -117,6 +138,18 @@ interface BannerImage extends UploadedImage {
}
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,
// 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
@ -125,6 +158,7 @@ const bannerImages = ref<BannerImage[]>([])
watch(
() => form.values.event_start_date,
(start, prev) => {
if (isPopulating.value) return
if (!start) return
const end = form.values.event_end_date
if (!end || end < start || end === prev) {
@ -141,18 +175,60 @@ const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([])
watch(() => props.open, async (isOpen) => {
if (isOpen && ticketApi && !loadingCurrencies.value) {
loadingCurrencies.value = true
try {
availableCurrencies.value = await ticketApi.getCurrencies()
} catch (error) {
console.warn('Failed to load currencies:', error)
} finally {
loadingCurrencies.value = false
}
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 = []
}
if (!isOpen) {
// 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) => {
if (isOpen) {
if (ticketApi && !loadingCurrencies.value) {
loadingCurrencies.value = true
try {
availableCurrencies.value = await ticketApi.getCurrencies()
} catch (error) {
console.warn('Failed to load currencies:', error)
} finally {
loadingCurrencies.value = false
}
}
if (props.event) {
await populateFromEvent(props.event)
}
} else {
selectedCategories.value = []
}
})
@ -195,7 +271,11 @@ const onSubmit = form.handleSubmit(async (formValues) => {
formValues.event_start_date,
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
@ -209,21 +289,53 @@ const onSubmit = form.handleSubmit(async (formValues) => {
if (formValues.location) eventData.location = formValues.location
if (bannerImages.value.length > 0) {
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.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
await props.onCreateEvent(eventData)
toastService.success('Event submitted!')
if (isEditMode.value) {
if (!props.onUpdateEvent || !props.event?.id) {
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()
selectedCategories.value = []
bannerImages.value = []
emit('update:open', false)
emit('event-created')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create event'
const errorMessage =
error instanceof Error
? error.message
: isEditMode.value
? 'Failed to update event'
: 'Failed to create event'
toastService.error(errorMessage)
} finally {
isLoading.value = false
@ -246,15 +358,27 @@ const handleOpenChange = (open: boolean) => {
<DialogHeader class="px-6 pt-6 pb-2">
<DialogTitle class="flex items-center gap-2">
<Calendar class="w-5 h-5" />
Create Event
{{ isEditMode ? 'Edit Event' : 'Create Event' }}
</DialogTitle>
<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>
</DialogHeader>
<ScrollArea class="max-h-[70vh] px-6 pb-6">
<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) -->
<FormField v-slot="{ componentField }" name="name">
<FormItem>
@ -451,7 +575,11 @@ const handleOpenChange = (open: boolean) => {
</Button>
<Button type="submit" :disabled="isLoading || !isFormValid">
<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>
</div>
</form>

View file

@ -1,7 +1,11 @@
import { ref, computed, onUnmounted } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { ActivitiesNostrService } 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 { useActivityFilters } from './useActivityFilters'
@ -12,11 +16,55 @@ import { useActivityFilters } from './useActivityFilters'
export function useActivities() {
const store = useActivitiesStore()
const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth()
const isSubscribed = ref(false)
const subscriptionError = ref<string | 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)
const filteredActivities = computed(() => {
const all = store.activities.sort(
@ -43,6 +91,7 @@ export function useActivities() {
unsubscribe = nostrService.subscribeToCalendarEvents(
(activity) => {
tagOwnership(activity)
store.upsertActivity(activity)
store.isLoading = false
},
@ -51,6 +100,10 @@ export function useActivities() {
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)
setTimeout(() => {
store.isLoading = false
@ -75,6 +128,7 @@ export function useActivities() {
store.isLoading = true
subscriptionError.value = null
const activities = await nostrService.queryCalendarEvents(eventFilters)
for (const a of activities) tagOwnership(a)
store.upsertActivities(activities)
} catch (err) {
subscriptionError.value = err instanceof Error ? err.message : 'Failed to query activities'
@ -125,5 +179,6 @@ export function useActivities() {
query,
stop,
refresh,
loadOwnEvents,
}
}

View file

@ -0,0 +1,49 @@
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 }
}

View file

@ -1,14 +1,46 @@
import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket'
export function useEvents() {
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(
() => ticketApi.fetchTicketedEvents() as Promise<TicketedEvent[]>,
fetchAll,
[] as TicketedEvent[],
{
immediate: true,

View file

@ -34,6 +34,20 @@ export class TicketApiService {
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).
* Uses POST /tickets/{event_id} with user_id in body (upstream API).
@ -148,6 +162,62 @@ 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.
*/

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Activity } from '../types/activity'
import type { TicketedEvent } from '../types/ticket'
/**
* Pinia store for cached activities from Nostr relays.
@ -14,6 +15,9 @@ export const useActivitiesStore = defineStore('activities', () => {
/** Toggle by the standalone bottom-nav Create tab; mounted dialog lives
* in activities-app/App.vue so it's available from every route. */
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
const activities = computed(() => Array.from(activitiesMap.value.values()))
@ -88,6 +92,7 @@ export const useActivitiesStore = defineStore('activities', () => {
isLoading,
lastUpdated,
showCreateDialog,
editingEvent,
// Computed
activities,

View file

@ -1,6 +1,7 @@
import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52'
import type { TicketedEvent } from './ticket'
/**
* Unified view model for displaying activities in the UI.
@ -45,6 +46,22 @@ export interface Activity {
isPrivate: boolean
/** Nostr event created_at timestamp */
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 {
@ -128,6 +145,74 @@ 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 {
if (!geohash) return undefined
try {

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
@ -8,13 +8,18 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft,
Calendar, MapPin, ArrowLeft, Pencil,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue'
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 router = useRouter()
@ -24,6 +29,38 @@ const activityId = route.params.id as string
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
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(() => {
if (!activity.value) return ''
const a = activity.value
@ -35,11 +72,18 @@ const dateDisplay = computed(() => {
}
return start
}
const start = format(a.startDate, 'EEEE, MMMM d, yyyy \u2022 HH:mm', opts)
if (a.endDate) {
return `${start}${format(a.endDate, 'HH:mm', opts)}`
}
return start
// Time-based event. If start and end share the same calendar day,
// show end as time-only ("19:00 21:45"). Otherwise repeat the full
// date on the end side so a multi-day event reads unambiguously
// ("May 29 19:00 May 30 21:45").
const FULL = 'EEEE, MMMM d, yyyy \u2022 HH:mm'
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(() => {
@ -60,11 +104,24 @@ function goBack() {
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<BookmarkButton
v-if="activity"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
<div class="flex items-center gap-1.5">
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
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>
<!-- Loading -->
@ -99,6 +156,20 @@ function goBack() {
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
{{ categoryLabel }}
</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">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useEvents } from '../composables/useEvents'
import { useApprovalState } from '../composables/useApprovalState'
import { useAuth } from '@/composables/useAuthService'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@ -10,14 +11,15 @@ import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import CreateEventDialog from '../components/CreateEventDialog.vue'
import { User, LogIn, Plus } from 'lucide-vue-next'
import { User, LogIn, Plus, Pencil } from 'lucide-vue-next'
import { formatEventPrice } from '@/lib/utils/formatting'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
const { isAuthenticated, userDisplay } = useAuth()
const { isAuthenticated, userDisplay, currentUser } = useAuth()
const { isAdmin, autoApprove } = useApprovalState()
const showPurchaseDialog = ref(false)
const selectedEvent = ref<{
@ -27,7 +29,17 @@ const selectedEvent = ref<{
currency: string
} | null>(null)
const showCreateDialog = ref(false)
const showEventDialog = 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) {
if (!dateStr) return 'Date not available'
@ -52,7 +64,6 @@ function handlePurchaseClick(event: {
async function handleCreateEvent(eventData: CreateEventRequest) {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const { currentUser } = useAuth()
const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!invoiceKey) {
throw new Error('No wallet available. Please log in first.')
@ -61,7 +72,36 @@ async function handleCreateEvent(eventData: CreateEventRequest) {
await ticketApi.createEvent(eventData, invoiceKey)
}
function handleEventCreated() {
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 === 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?.()
}
</script>
@ -83,7 +123,7 @@ function handleEventCreated() {
</div>
</div>
<div class="flex gap-2 sm:flex-shrink-0">
<Button v-if="isAuthenticated" variant="default" size="sm" @click="showCreateDialog = true" class="flex-1 sm:flex-none">
<Button v-if="isAuthenticated" variant="default" size="sm" @click="openCreateDialog" class="flex-1 sm:flex-none">
<Plus class="w-4 h-4" />
<span class="ml-2">Create Event</span>
</Button>
@ -105,7 +145,16 @@ function handleEventCreated() {
<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">
<CardHeader>
<CardTitle class="text-foreground">{{ event.name }}</CardTitle>
<div class="flex items-start justify-between gap-2">
<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>
</CardHeader>
<CardContent class="flex-grow">
@ -128,19 +177,33 @@ function handleEventCreated() {
</div>
</div>
</CardContent>
<CardFooter>
<CardFooter class="flex gap-2">
<Button
class="w-full"
class="flex-1"
variant="default"
:disabled="event.amount_tickets <= event.sold || !isAuthenticated"
:disabled="
event.status !== 'approved' ||
event.amount_tickets <= event.sold ||
!isAuthenticated
"
@click="handlePurchaseClick(event)"
>
<span v-if="!isAuthenticated" class="flex items-center gap-2">
<LogIn class="w-4 h-4" />
Login to Purchase
</span>
<span v-else-if="event.status !== 'approved'">Not yet available</span>
<span v-else>Buy Ticket</span>
</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>
</Card>
</div>
@ -189,10 +252,18 @@ function handleEventCreated() {
<PurchaseTicketDialog v-if="selectedEvent" :event="selectedEvent" v-model:is-open="showPurchaseDialog" />
<CreateEventDialog
:open="showCreateDialog"
@update:open="showCreateDialog = $event"
:open="showEventDialog"
:event="editingEvent"
:is-admin="isAdmin"
:auto-approve="autoApprove"
:on-create-event="handleCreateEvent"
@event-created="handleEventCreated"
:on-update-event="handleUpdateEvent"
@update:open="
showEventDialog = $event
handleDialogClosed()
"
@event-created="handleEventChanged"
@event-updated="handleEventChanged"
/>
</div>
</template>

View file

@ -449,7 +449,11 @@ const removeImage = async (imageToRemove: ImageWithMetadata) => {
if (props.disabled) return
try {
// Only try to delete from pict-rs if we have a delete token (newly uploaded images)
// Server-side delete only when we have a delete_token (newly
// 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) {
await imageService.deleteImage(imageToRemove.delete_token, imageToRemove.alias)
}

View file

@ -300,9 +300,12 @@ export class ImageUploadService extends BaseService {
}
/**
* Extract file ID from alias, handling both file IDs and full URLs
* Extract a pict-rs file ID from an alias, accepting both bare IDs
* 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.
*/
private extractFileId(alias: string): string {
extractFileId(alias: string): string {
if (!alias) {
return ''
}

View file

@ -516,30 +516,17 @@ watch(() => props.isOpen, async (isOpen) => {
// Reset form with appropriate initial values
resetForm({ values: initialValues })
// Convert existing image URLs to the format expected by ImageUpload component
// Convert existing image URLs to the format expected by ImageUpload.
// 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) {
// For existing products, we need to convert URLs back to a format ImageUpload can display
uploadedImages.value = props.product.images.map((url, index) => {
let alias = url
// 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: {}
}
})
uploadedImages.value = props.product.images.map((url, index) => ({
alias: imageService.extractFileId(url),
delete_token: '',
isPrimary: index === 0,
details: {} as any,
}))
} else {
uploadedImages.value = []
}