Compare commits

..

2 commits

Author SHA1 Message Date
a4ae61a119 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>
2026-05-20 16:56:27 +02:00
16608e0d60 feat(base): client-side image compression in ImageUploadService
Optional opt-in resize + re-encode of files before the pict-rs POST,
behind a new `compress` option on ImageUploadOptions / `<ImageUpload
:compress>` prop. Pict-rs stores originals at full resolution forever
and `process.webp?resize=…` URLs only shape *delivery* — without
compression, a 5–8 MB phone photo lands on disk untouched.

Defaults when enabled: 1920 px max edge, WebP, q=0.85, target ~1 MB,
Web Worker. Skips compression for files already comfortably under the
size target, and keeps the original if re-encoding would make it larger.

Uses browser-image-compression (MIT, ~50 KB, mature) — handles EXIF
orientation internally (canvas drawImage doesn't auto-rotate, which is
the classic "portrait photo lands sideways" bug we'd otherwise own) and
falls back gracefully on encoder failure (HEIC, etc.).

Default is `compress: false` so existing market and profile call sites
keep current behavior; rollout tracked in #59.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:55:47 +02:00
5 changed files with 160 additions and 31 deletions

57
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@vueuse/components": "^12.5.0",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^13.6.0",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@ -145,7 +146,6 @@
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@ -2651,7 +2651,6 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@ -5718,7 +5717,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -6061,6 +6059,15 @@
"node": ">=8"
}
},
"node_modules/browser-image-compression": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
"license": "MIT",
"dependencies": {
"uzip": "0.20201231.0"
}
},
"node_modules/browserslist": {
"version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
@ -6081,7 +6088,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@ -7628,6 +7634,17 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@ -8381,7 +8398,6 @@
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=10"
}
@ -8918,6 +8934,20 @@
"ms": "^2.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -11733,7 +11763,6 @@
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"peer": true,
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
@ -12377,7 +12406,6 @@
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -13387,8 +13415,7 @@
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@ -13523,7 +13550,6 @@
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@ -13753,7 +13779,6 @@
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -13953,6 +13978,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/uzip": {
"version": "0.20201231.0",
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
"license": "MIT"
},
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@ -14004,7 +14035,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -14229,7 +14259,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.5.13",
@ -14682,7 +14711,6 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -14940,7 +14968,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -48,6 +48,7 @@
"@vueuse/components": "^12.5.0",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^13.6.0",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

View file

@ -41,6 +41,8 @@ import {
import { Calendar, Loader2, ChevronDown, MapPin } 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 type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category'
@ -66,7 +68,6 @@ const formSchema = toTypedSchema(z.object({
event_end_date: z.string().optional().default(''),
event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''),
banner: z.string().optional().default(''),
currency: z.string().default("sat"),
amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0),
@ -82,13 +83,17 @@ const form = useForm({
event_end_date: '',
event_end_time: '',
location: '',
banner: '',
currency: 'sat',
amount_tickets: 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")
// into the events-extension wire format: date-only when no time given,
// 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 ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false)
@ -173,7 +179,9 @@ const onSubmit = form.handleSubmit(async (formValues) => {
)
}
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.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
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!')
resetForm()
selectedCategories.value = []
bannerImages.value = []
emit('update:open', false)
emit('event-created')
} catch (error) {
@ -197,6 +206,7 @@ const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) {
resetForm()
selectedCategories.value = []
bannerImages.value = []
}
emit('update:open', open)
}
@ -296,16 +306,25 @@ const handleOpenChange = (open: boolean) => {
</div>
</div>
<!-- Image URL (optional, visible) -->
<FormField v-slot="{ componentField }" name="banner">
<FormItem>
<FormLabel>Image URL</FormLabel>
<FormControl>
<Input type="url" placeholder="https://example.com/image.jpg" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Banner image (optional). Client-side compressed to ~1MB
WebP before upload to keep pict-rs storage in check. -->
<FormItem>
<FormLabel>Banner image</FormLabel>
<FormDescription class="text-xs">
One poster image. Auto-resized to 1920px max edge and re-encoded as WebP.
</FormDescription>
<ImageUpload
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"
/>
</FormItem>
<!-- Tickets (optional, visible) -->
<div class="grid grid-cols-3 gap-3">

View file

@ -160,7 +160,7 @@ import { Camera, X, Loader2, AlertCircle, Image } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService, UploadedImage } from '../services/ImageUploadService'
import type { ImageUploadService, UploadedImage, CompressOptions } from '../services/ImageUploadService'
interface ImageWithMetadata extends UploadedImage {
isPrimary: boolean
@ -175,6 +175,12 @@ const props = defineProps<{
disabled?: boolean
placeholder?: string
allowCamera?: boolean
/**
* Client-side resize + re-encode before upload. Pass `true` for the
* service defaults (1920px max edge, WebP, ~1MB target), or an object
* to tune individual knobs.
*/
compress?: boolean | CompressOptions
}>()
const emit = defineEmits<{
@ -379,7 +385,8 @@ const uploadFiles = async (files: File[]) => {
try {
const uploadOptions = {
maxSizeMB: maxSizeMB.value
maxSizeMB: maxSizeMB.value,
compress: props.compress,
}
// Upload files with better error handling

View file

@ -1,3 +1,4 @@
import imageCompression from 'browser-image-compression'
import { BaseService } from '@/core/base/BaseService'
import type { ServiceMetadata } from '@/core/base/BaseService'
import appConfig from '@/app.config'
@ -13,10 +14,36 @@ export interface UploadedImage {
}
}
/**
* Client-side compression knobs. Resize first, then re-encode.
*
* Defaults target a typical banner/poster: longest edge 1920px, WebP
* at q=0.85, aim for ~1MB output. EXIF orientation is handled by the
* library (canvas drawImage doesn't auto-rotate, which is the classic
* "portrait photo lands sideways" bug).
*/
export interface CompressOptions {
maxSizeMB?: number
maxWidthOrHeight?: number
initialQuality?: number
fileType?: 'image/webp' | 'image/jpeg'
useWebWorker?: boolean
}
export interface ImageUploadOptions {
maxSizeMB?: number
acceptedTypes?: string[]
generateThumbnail?: boolean
/** Enable client-side resize + re-encode before upload. */
compress?: boolean | CompressOptions
}
const DEFAULT_COMPRESS: Required<CompressOptions> = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
initialQuality: 0.85,
fileType: 'image/webp',
useWebWorker: true,
}
export interface ImageUrlOptions {
@ -76,8 +103,12 @@ export class ImageUploadService extends BaseService {
// Validate file
this.validateFile(file, options)
// Optional client-side resize + re-encode. Keeps phone photos
// (58MB originals) from hitting pict-rs as full-resolution files.
const fileToUpload = await this.maybeCompress(file, options)
const formData = new FormData()
formData.append('images[]', file)
formData.append('images[]', fileToUpload)
const response = await fetch(`${this.baseUrl}/image`, {
method: 'POST',
@ -291,6 +322,50 @@ export class ImageUploadService extends BaseService {
return alias
}
/**
* Resize + re-encode the file when compression is requested. Returns
* the original file untouched when compression is off, the file is
* already smaller than the target, or when re-encoding produces a
* larger blob than the input (rare but possible on already-optimized
* sources).
*
* The library handles EXIF orientation internally and falls back to
* JPEG if the browser can't encode WebP.
*/
private async maybeCompress(file: File, options: ImageUploadOptions): Promise<File> {
if (!options.compress) return file
const opts: Required<CompressOptions> = {
...DEFAULT_COMPRESS,
...(typeof options.compress === 'object' ? options.compress : {}),
}
// Skip if the source is already smaller than the target and within
// dimension limits (we can't cheaply check dimensions without
// decoding, so size-only is the pragmatic short-circuit).
if (file.size <= opts.maxSizeMB * 1024 * 1024 * 0.6) {
this.debug(`Skipping compression: ${file.name} already ${(file.size / 1024 / 1024).toFixed(2)}MB`)
return file
}
try {
const compressed = await imageCompression(file, opts)
if (compressed.size >= file.size) {
this.debug(`Compression made ${file.name} larger; keeping original`)
return file
}
this.debug(
`Compressed ${file.name}: ${(file.size / 1024 / 1024).toFixed(2)}MB → ${(compressed.size / 1024 / 1024).toFixed(2)}MB`
)
return compressed
} catch (err) {
// HEIC / encoder edge cases — fall back to the original file so the
// upload still succeeds. Server-side processing handles delivery.
this.debug('Compression failed, uploading original:', err)
return file
}
}
/**
* Validate file before upload
*/