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:
parent
16608e0d60
commit
1727a4cbf0
1 changed files with 33 additions and 13 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue