From 9ac31de49f747f2d0c7f2abc0c0da296de98fe36 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 20 May 2026 01:24:01 +0200 Subject: [PATCH 1/7] fix(activities): reject malformed NIP-52 kind 31922 events at parse time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCalendarDateEvent accepted any non-empty `start` string, letting events with embedded times (e.g. "2026-05-25T10:00") through. Downstream parseIsoDate then split on "-" and produced an Invalid Date, crashing the renderer with "RangeError: Invalid time value". Validate `start` and `end` against YYYY-MM-DD at parse time so bad events are dropped before reaching the view — symmetric with how the time-event parser rejects unparseable timestamps. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/activities/types/nip52.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/activities/types/nip52.ts b/src/modules/activities/types/nip52.ts index 2a57a71..332d054 100644 --- a/src/modules/activities/types/nip52.ts +++ b/src/modules/activities/types/nip52.ts @@ -181,6 +181,14 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n if (!dTag || !title || !start) return null + // NIP-52 kind 31922 requires YYYY-MM-DD. Reject anything else (including + // accidentally-published datetimes) so downstream parseIsoDate cannot + // produce an Invalid Date and crash the renderer. + const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ + if (!ISO_DATE.test(start)) return null + const end = getTagValue(event.tags, 'end') + if (end && !ISO_DATE.test(end)) return null + const participants: Participant[] = event.tags .filter(t => t[0] === 'p') .map(t => ({ @@ -197,7 +205,7 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n content: event.content, image: getTagValue(event.tags, 'image'), start, - end: getTagValue(event.tags, 'end'), + end, location: getTagValue(event.tags, 'location'), geohash: getTagValue(event.tags, 'g'), hashtags: getTagValues(event.tags, 't'), From 691f8df83088d6ed10eb41a0c2d64ff754705976 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 20 May 2026 01:24:15 +0200 Subject: [PATCH 2/7] feat(activities): capture optional start/end time on event creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold a time into the existing event_start_date / event_end_date strings ("2026-05-25" or "2026-05-25T10:00") rather than introducing parallel fields. Presence of "T" toggles which NIP-52 kind the events-extension publisher emits (31922 date-only vs 31923 time-based). CreateEventDialog gets optional HH:MM inputs next to the start date and the (already-collapsible) end date — stacked below sm breakpoint so the iPhone SE doesn't get the time pushed off-screen by the native date input's intrinsic min-width. EventsPage.formatDate shows the time portion when present. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateEventDialog.vue | 89 ++++++++++++++----- src/modules/activities/types/ticket.ts | 4 + src/modules/activities/views/EventsPage.vue | 5 +- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index 3eb5b2a..5f3a605 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -62,7 +62,9 @@ const formSchema = toTypedSchema(z.object({ name: z.string().min(1, "Title is required").max(200, "Title too long"), info: z.string().max(2000, "Description too long").optional().default(''), event_start_date: z.string().min(1, "Start date is required"), + event_start_time: z.string().optional().default(''), 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"), @@ -76,7 +78,9 @@ const form = useForm({ name: '', info: '', event_start_date: '', + event_start_time: '', event_end_date: '', + event_end_time: '', location: '', banner: '', currency: 'sat', @@ -85,6 +89,15 @@ const form = useForm({ } }) +// 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 +// the "T" delimiter. +function foldDateTime(date: string, time: string): string { + if (!date) return '' + return time ? `${date}T${time}` : date +} + const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null @@ -144,13 +157,21 @@ const onSubmit = form.handleSubmit(async (formValues) => { try { const eventData: CreateEventRequest = { name: formValues.name, - event_start_date: formValues.event_start_date, + event_start_date: foldDateTime( + formValues.event_start_date, + formValues.event_start_time + ), wallet: preferredWallet.id, } // Optional fields — only include if provided if (formValues.info) eventData.info = formValues.info - if (formValues.event_end_date) eventData.event_end_date = formValues.event_end_date + if (formValues.event_end_date) { + eventData.event_end_date = foldDateTime( + formValues.event_end_date, + formValues.event_end_time + ) + } if (formValues.location) eventData.location = formValues.location if (formValues.banner) eventData.banner = formValues.banner if (formValues.currency) eventData.currency = formValues.currency @@ -207,16 +228,28 @@ const handleOpenChange = (open: boolean) => { - - - - Start date * - - - - - - + +
+ + + Start date * + + + + + + + + + + Start time + + + + + + +
@@ -329,16 +362,28 @@ const handleOpenChange = (open: boolean) => { - - - End date - - - - Defaults to start date if not set - - - +
+ + + End date + + + + Defaults to start date + + + + + + + End time + + + + + + +
diff --git a/src/modules/activities/types/ticket.ts b/src/modules/activities/types/ticket.ts index 8943421..5384ddf 100644 --- a/src/modules/activities/types/ticket.ts +++ b/src/modules/activities/types/ticket.ts @@ -44,6 +44,10 @@ export interface TicketPaymentStatus { /** * LNbits events extension event (database-backed ticketed event). * Corresponds to the Event model in the events extension. + * + * event_start_date / event_end_date are ISO 8601 — either date-only + * ("2026-05-19") or with a time ("2026-05-19T18:30"). Presence of "T" + * switches the publisher between NIP-52 kind 31922 and 31923. */ export interface TicketedEvent { id: string diff --git a/src/modules/activities/views/EventsPage.vue b/src/modules/activities/views/EventsPage.vue index 0181dc1..ab4feb8 100644 --- a/src/modules/activities/views/EventsPage.vue +++ b/src/modules/activities/views/EventsPage.vue @@ -33,7 +33,10 @@ function formatDate(dateStr: string | null | undefined) { if (!dateStr) return 'Date not available' const date = new Date(dateStr) if (isNaN(date.getTime())) return 'Invalid date' - return format(date, 'MMMM do, yyyy') + // Presence of "T" in the wire value marks a time-based event (NIP-52 + // kind 31923 on our publisher). Show time only when one was set. + const hasTime = dateStr.includes('T') + return format(date, hasTime ? 'MMMM do, yyyy p' : 'MMMM do, yyyy') } function handlePurchaseClick(event: { From 16608e0d60b471c82d4a0723a30b218c06d29293 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 20 May 2026 16:55:47 +0200 Subject: [PATCH 3/7] feat(base): client-side image compression in ImageUploadService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional opt-in resize + re-encode of files before the pict-rs POST, behind a new `compress` option on ImageUploadOptions / `` 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) --- package-lock.json | 57 ++++++++++---- package.json | 1 + src/modules/base/components/ImageUpload.vue | 11 ++- .../base/services/ImageUploadService.ts | 77 ++++++++++++++++++- 4 files changed, 128 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24eae4f..d0beebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index d7db78c..2c2f2dd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/modules/base/components/ImageUpload.vue b/src/modules/base/components/ImageUpload.vue index d85817f..71f96af 100644 --- a/src/modules/base/components/ImageUpload.vue +++ b/src/modules/base/components/ImageUpload.vue @@ -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 diff --git a/src/modules/base/services/ImageUploadService.ts b/src/modules/base/services/ImageUploadService.ts index cabe234..d045a11 100644 --- a/src/modules/base/services/ImageUploadService.ts +++ b/src/modules/base/services/ImageUploadService.ts @@ -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 = { + 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 + // (5–8MB 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 { + if (!options.compress) return file + + const opts: Required = { + ...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 */ From 1727a4cbf041f0bab2cfe5917686f37800f8ee12 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 20 May 2026 16:56:27 +0200 Subject: [PATCH 4/7] feat(activities): banner image upload to img.ariege.io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain "Image URL" text input on the event-creation form with the shared 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) --- .../components/CreateEventDialog.vue | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index 5f3a605..2a9861a 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -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([]) + // 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(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const availableCurrencies = ref(['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,26 @@ const handleOpenChange = (open: boolean) => { - - - - Image URL - - - - - - + +
+

Banner image

+

+ One poster image. Auto-resized to 1920px max edge and re-encoded as WebP. +

+ +
From 93c05104df61465a2b6df017ecbaccca084a3f1a Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 20 May 2026 19:36:38 +0200 Subject: [PATCH 5/7] feat(base): themed DatePicker + TimePicker for activity forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds shadcn-vue Calendar + adds @internationalized/date, then wraps them in two small shared components living alongside ImageUpload for reuse across activity / market / future forms. DatePicker — Popover + Calendar, bridges the wire format (YYYY-MM-DD string) to reka-ui's CalendarDate. Closes on pick (the Calendar primitive doesn't auto-close). Optional min-date for forward-only selection. TimePicker — two shadcn controls in CreateEventDialog for the shared DatePicker / TimePicker components so the form looks like the rest of the shadcn UI instead of browser chrome. While there: - End date auto-mirrors start date on pick (and re-mirrors if the existing end has fallen behind the new start), so a one-day event needs no extra clicks. - Zod superRefine rejects end < start, comparing the folded date+time string so equal-date / later-time is enforced too. - Move End date/time out of the "More options" collapsible into the main form flow (drops Collapsible / Separator / ChevronDown / the showMoreOptions ref). - End time label reads "End time (optional)" to make the field's status obvious. - Banner image label is plain markup (not ) since it's managed via bannerImages ref outside vee-validate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/CreateEventDialog.vue | 176 +++++++++++------- 1 file changed, 107 insertions(+), 69 deletions(-) diff --git a/src/modules/activities/components/CreateEventDialog.vue b/src/modules/activities/components/CreateEventDialog.vue index 2a9861a..1855049 100644 --- a/src/modules/activities/components/CreateEventDialog.vue +++ b/src/modules/activities/components/CreateEventDialog.vue @@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Separator } from '@/components/ui/separator' import { ScrollArea } from '@/components/ui/scroll-area' import { Select, @@ -33,15 +32,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' -import { Calendar, Loader2, ChevronDown, MapPin } from 'lucide-vue-next' +import { Calendar, Loader2, 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 DatePicker from '@/modules/base/components/DatePicker.vue' +import TimePicker from '@/modules/base/components/TimePicker.vue' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { TicketApiService } from '../services/TicketApiService' import type { CreateEventRequest } from '../types/ticket' @@ -60,18 +56,45 @@ const emit = defineEmits<{ const { t } = useI18n() -const formSchema = toTypedSchema(z.object({ - name: z.string().min(1, "Title is required").max(200, "Title too long"), - info: z.string().max(2000, "Description too long").optional().default(''), - event_start_date: z.string().min(1, "Start date is required"), - event_start_time: z.string().optional().default(''), - event_end_date: z.string().optional().default(''), - event_end_time: z.string().optional().default(''), - location: z.string().max(500).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), -})) +// 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 +// the "T" delimiter. Hoisted above the schema so the validation refine +// can reuse it. +function foldDateTime(date: string, time: string): string { + if (!date) return '' + return time ? `${date}T${time}` : date +} + +const formSchema = toTypedSchema( + z + .object({ + name: z.string().min(1, "Title is required").max(200, "Title too long"), + info: z.string().max(2000, "Description too long").optional().default(''), + event_start_date: z.string().min(1, "Start date is required"), + event_start_time: z.string().optional().default(''), + event_end_date: z.string().optional().default(''), + event_end_time: z.string().optional().default(''), + location: z.string().max(500).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), + }) + .superRefine((v, ctx) => { + // End must not precede start. Compare on the folded date+time + // string so equal-date / later-time is enforced too. + if (!v.event_end_date) return + const start = foldDateTime(v.event_start_date, v.event_start_time) + const end = foldDateTime(v.event_end_date, v.event_end_time) + if (start && end && end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['event_end_date'], + message: 'End must be on or after start', + }) + } + }) +) const form = useForm({ validationSchema: formSchema, @@ -94,14 +117,21 @@ interface BannerImage extends UploadedImage { } const bannerImages = ref([]) -// 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 -// the "T" delimiter. -function foldDateTime(date: string, time: string): string { - if (!date) return '' - return time ? `${date}T${time}` : 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 +// requires no extra clicks. Don't overwrite an end date the user +// already set *after* the start — only fill when empty or when the +// existing end has fallen behind the new start. +watch( + () => form.values.event_start_date, + (start, prev) => { + if (!start) return + const end = form.values.event_end_date + if (!end || end < start || end === prev) { + form.setFieldValue('event_end_date', start) + } + } +) const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null @@ -110,7 +140,6 @@ const imageService = injectService(SERVICE_TOKENS.IMAGE_UPLO const availableCurrencies = ref(['sat']) const loadingCurrencies = ref(false) const selectedCategories = ref([]) -const showMoreOptions = ref(false) watch(() => props.open, async (isOpen) => { if (isOpen && ticketApi && !loadingCurrencies.value) { @@ -125,7 +154,6 @@ watch(() => props.open, async (isOpen) => { } if (!isOpen) { selectedCategories.value = [] - showMoreOptions.value = false } }) @@ -240,21 +268,67 @@ const handleOpenChange = (open: boolean) => {
- + Start date * - + - + Start time - + + + + + +
+ + +
+ + + End date + + + + + + + + + + + End time + (optional) + + + @@ -371,42 +445,6 @@ const handleOpenChange = (open: boolean) => {
- - - - - - - - -
- - - End date - - - - Defaults to start date - - - - - - - End time - - - - - - -
-
-
-