feat(activities): banner image upload to img.ariege.io

Replace the plain "Image URL" text input on the event-creation form with
the shared <ImageUpload> component, single-file mode with :compress="true".
Files are resized + re-encoded to WebP client-side before hitting pict-rs
so phone-sized posters don't bloat the image server.

The stored `banner` is the canonical pict-rs original URL — the same
shape market uses — so existing display paths (thumbnail/resize URL
builders, NIP-52 "image" tag publishing) need no changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-20 16:56:27 +02:00
commit 1727a4cbf0

View file

@ -41,6 +41,8 @@ import {
import { Calendar, Loader2, ChevronDown, MapPin } from 'lucide-vue-next' import { Calendar, Loader2, ChevronDown, 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 type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket' import type { CreateEventRequest } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category' import { ALL_CATEGORIES } from '../types/category'
@ -66,7 +68,6 @@ const formSchema = toTypedSchema(z.object({
event_end_date: z.string().optional().default(''), event_end_date: z.string().optional().default(''),
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
banner: z.string().optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0), price_per_ticket: z.number().min(0).default(0),
@ -82,13 +83,17 @@ const form = useForm({
event_end_date: '', event_end_date: '',
event_end_time: '', event_end_time: '',
location: '', location: '',
banner: '',
currency: 'sat', currency: 'sat',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 0, price_per_ticket: 0,
} }
}) })
interface BannerImage extends UploadedImage {
isPrimary: boolean
}
const bannerImages = ref<BannerImage[]>([])
// 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")
// into the events-extension wire format: date-only when no time given, // into the events-extension wire format: date-only when no time given,
// ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on // ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on
@ -100,6 +105,7 @@ function foldDateTime(date: string, time: string): string {
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const availableCurrencies = ref<string[]>(['sat']) const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false) const loadingCurrencies = ref(false)
@ -173,7 +179,9 @@ const onSubmit = form.handleSubmit(async (formValues) => {
) )
} }
if (formValues.location) eventData.location = formValues.location if (formValues.location) eventData.location = formValues.location
if (formValues.banner) eventData.banner = formValues.banner if (bannerImages.value.length > 0) {
eventData.banner = imageService.getImageUrl(bannerImages.value[0].alias)
}
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
@ -183,6 +191,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
toastService.success('Event submitted!') toastService.success('Event submitted!')
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
bannerImages.value = []
emit('update:open', false) emit('update:open', false)
emit('event-created') emit('event-created')
} catch (error) { } catch (error) {
@ -197,6 +206,7 @@ const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) { if (!open && !isLoading.value) {
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
bannerImages.value = []
} }
emit('update:open', open) emit('update:open', open)
} }
@ -296,16 +306,26 @@ const handleOpenChange = (open: boolean) => {
</div> </div>
</div> </div>
<!-- Image URL (optional, visible) --> <!-- Banner image (optional). Client-side compressed to ~1MB
<FormField v-slot="{ componentField }" name="banner"> WebP before upload to keep pict-rs storage in check.
<FormItem> Not a vee-validate field managed via bannerImages ref. -->
<FormLabel>Image URL</FormLabel> <div class="space-y-2">
<FormControl> <p class="text-sm font-medium">Banner image</p>
<Input type="url" placeholder="https://example.com/image.jpg" v-bind="componentField" /> <p class="text-xs text-muted-foreground">
</FormControl> One poster image. Auto-resized to 1920px max edge and re-encoded as WebP.
<FormMessage /> </p>
</FormItem> <ImageUpload
</FormField> v-model="bannerImages"
:multiple="false"
:max-files="1"
:max-size-mb="10"
:show-primary-button="false"
:disabled="isLoading"
:allow-camera="true"
:compress="true"
placeholder="Add a poster or banner"
/>
</div>
<!-- Tickets (optional, visible) --> <!-- Tickets (optional, visible) -->
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">