Compare commits

...

8 commits

Author SHA1 Message Date
260949d3bf Merge branch 'dev' of git.atitlan.io:aiolabs/webapp into dev 2026-05-20 19:38:04 +02:00
12b24eb384 chore: add .mcp.json and ignore .playwright-mcp/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:38:00 +02:00
012f364a7a feat(activities): themed date/time pickers + end-after-start guard
Swap the native <input type="date|time"> 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 <FormItem>) since it's
  managed via bannerImages ref outside vee-validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:36:52 +02:00
93c05104df feat(base): themed DatePicker + TimePicker for activity forms
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 <Select> dropdowns (HH 00–23, MM at minuteStep
granularity, default 15). Bound to a single "HH:MM" string externally,
clearable. Mobile-first: a tap opens the native sheet / wheel — no
typeahead overlay (reka-ui's Select doesn't expose typeahead on the
closed trigger and the workaround proved brittle against its internal
document-level key handling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:36:38 +02:00
1727a4cbf0 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 17:13:05 +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
691f8df830 feat(activities): capture optional start/end time on event creation
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) <noreply@anthropic.com>
2026-05-20 01:24:15 +02:00
9ac31de49f fix(activities): reject malformed NIP-52 kind 31922 events at parse time
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) <noreply@anthropic.com>
2026-05-20 01:24:01 +02:00
23 changed files with 899 additions and 108 deletions

132
package-lock.json generated
View file

@ -8,11 +8,13 @@
"name": "aio-shadcn-vite",
"version": "0.0.0",
"dependencies": {
"@internationalized/date": "^3.12.1",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@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",
@ -27,7 +29,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.6.0",
"reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1",
@ -145,7 +147,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 +2652,6 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@ -3949,9 +3949,9 @@
}
},
"node_modules/@internationalized/date": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
"integrity": "sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==",
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@ -5718,7 +5718,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 +6060,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 +6089,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@ -7048,9 +7055,9 @@
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
@ -7628,6 +7635,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 +8399,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 +8935,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 +11764,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",
@ -12196,9 +12226,9 @@
}
},
"node_modules/reka-ui": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
"version": "2.9.7",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.7.tgz",
"integrity": "sha512-aX7foYYR20v4+majO58OJJdBNfLMm0eJb448l9N4JVy8JB7GXOr4H/S4a+J1pkcoxZH8Cb7YHpJ855+miAm7sA==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
@ -12206,26 +12236,62 @@
"@internationalized/date": "^3.5.0",
"@internationalized/number": "^3.5.0",
"@tanstack/vue-virtual": "^3.12.0",
"@vueuse/core": "^12.5.0",
"@vueuse/shared": "^12.5.0",
"@vueuse/core": "^14.1.0",
"@vueuse/shared": "^14.1.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.4",
"defu": "^6.1.5",
"ohash": "^2.0.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/zernonia"
},
"peerDependencies": {
"vue": ">= 3.2.0"
"vue": ">= 3.4.0"
}
},
"node_modules/reka-ui/node_modules/@vueuse/shared": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
"node_modules/reka-ui/node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/reka-ui/node_modules/@vueuse/core": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"license": "MIT",
"dependencies": {
"vue": "^3.5.13"
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/reka-ui/node_modules/@vueuse/metadata": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/reka-ui/node_modules/@vueuse/shared": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/require-directory": {
@ -12377,7 +12443,6 @@
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -13387,8 +13452,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 +13587,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 +13816,6 @@
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -13953,6 +14015,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 +14072,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 +14296,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 +14748,6 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -14940,7 +15005,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

@ -43,11 +43,13 @@
"make": "electron-forge make"
},
"dependencies": {
"@internationalized/date": "^3.12.1",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@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",
@ -62,7 +64,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.6.0",
"reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1",

View file

@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { CalendarRootEmits, CalendarRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View file

@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CalendarCellTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View file

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarGridProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGrid, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View file

@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridBodyProps } from "reka-ui"
import { CalendarGridBody } from "reka-ui"
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View file

@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { CalendarGridHeadProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { CalendarGridHead } from "reka-ui"
const props = defineProps<CalendarGridHeadProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarGridRowProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGridRow, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeadCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeadCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell :class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeadCell>
</template>

View file

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeaderProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeader, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeader>
</template>

View file

@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { CalendarHeadingProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeading, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View file

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import { CalendarNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>

View file

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeft } from "lucide-vue-next"
import { CalendarPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>

View file

@ -0,0 +1,12 @@
export { default as Calendar } from "./Calendar.vue"
export { default as CalendarCell } from "./CalendarCell.vue"
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"
export { default as CalendarGrid } from "./CalendarGrid.vue"
export { default as CalendarGridBody } from "./CalendarGridBody.vue"
export { default as CalendarGridHead } from "./CalendarGridHead.vue"
export { default as CalendarGridRow } from "./CalendarGridRow.vue"
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"
export { default as CalendarHeader } from "./CalendarHeader.vue"
export { default as CalendarHeading } from "./CalendarHeading.vue"
export { default as CalendarNextButton } from "./CalendarNextButton.vue"
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"

View file

@ -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,14 +32,13 @@ 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'
import { ALL_CATEGORIES } from '../types/category'
@ -58,17 +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_end_date: 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),
}))
// 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,
@ -76,22 +102,44 @@ const form = useForm({
name: '',
info: '',
event_start_date: '',
event_start_time: '',
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[]>([])
// 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
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([])
const showMoreOptions = ref(false)
watch(() => props.open, async (isOpen) => {
if (isOpen && ticketApi && !loadingCurrencies.value) {
@ -106,7 +154,6 @@ watch(() => props.open, async (isOpen) => {
}
if (!isOpen) {
selectedCategories.value = []
showMoreOptions.value = false
}
})
@ -144,15 +191,25 @@ 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 (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
@ -162,6 +219,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) {
@ -176,6 +234,7 @@ const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) {
resetForm()
selectedCategories.value = []
bannerImages.value = []
}
emit('update:open', open)
}
@ -207,16 +266,74 @@ const handleOpenChange = (open: boolean) => {
</FormItem>
</FormField>
<!-- Start date (required) -->
<FormField v-slot="{ componentField }" name="event_start_date">
<FormItem>
<FormLabel>Start date *</FormLabel>
<FormControl>
<Input type="date" :min="today" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Start date (required) + optional time -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormField v-slot="{ value, handleChange }" name="event_start_date">
<FormItem class="sm:col-span-2 min-w-0">
<FormLabel>Start date *</FormLabel>
<FormControl>
<DatePicker
:model-value="value as string ?? ''"
:min="today"
placeholder="Pick a date"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="event_start_time">
<FormItem class="min-w-0">
<FormLabel>Start time</FormLabel>
<FormControl>
<TimePicker
:model-value="value as string ?? ''"
clearable
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- End date + optional time. Auto-mirrors start date until
the user moves it forward; cross-field rule enforces
end >= start in the Zod schema. -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormField v-slot="{ value, handleChange }" name="event_end_date">
<FormItem class="sm:col-span-2 min-w-0">
<FormLabel>End date</FormLabel>
<FormControl>
<DatePicker
:model-value="value as string ?? ''"
:min="(form.values.event_start_date as string) || today"
placeholder="Pick a date"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="event_end_time">
<FormItem class="min-w-0">
<FormLabel>
End time
<span class="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<TimePicker
:model-value="value as string ?? ''"
clearable
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Description (optional, visible) -->
<FormField v-slot="{ componentField }" name="info">
@ -263,16 +380,26 @@ 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.
Not a vee-validate field managed via bannerImages ref. -->
<div class="space-y-2">
<p class="text-sm font-medium">Banner image</p>
<p class="text-xs text-muted-foreground">
One poster image. Auto-resized to 1920px max edge and re-encoded as WebP.
</p>
<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"
/>
</div>
<!-- Tickets (optional, visible) -->
<div class="grid grid-cols-3 gap-3">
@ -318,30 +445,6 @@ const handleOpenChange = (open: boolean) => {
</FormField>
</div>
<!-- More options (collapsible) -->
<Collapsible v-model:open="showMoreOptions">
<CollapsibleTrigger as-child>
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
More options
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': showMoreOptions }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="space-y-4 pt-2">
<Separator />
<FormField v-slot="{ componentField }" name="event_end_date">
<FormItem>
<FormLabel>End date</FormLabel>
<FormControl>
<Input type="date" :min="today" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">Defaults to start date if not set</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleContent>
</Collapsible>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">

View file

@ -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'),

View file

@ -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

View file

@ -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: {

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CalendarDate, parseDate, type DateValue } from '@internationalized/date'
import { format } from 'date-fns'
import { Calendar as CalendarIcon, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue: string
placeholder?: string
/** Minimum selectable date as YYYY-MM-DD. */
min?: string
disabled?: boolean
clearable?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
// Bridge between the wire format (YYYY-MM-DD string) and reka-ui's
// CalendarDate. Empty string undefined keeps the field optional.
// Closes the popover on user pick to match the shadcn-vue docs example
// the Calendar component doesn't auto-close by itself.
const dateValue = computed<DateValue | undefined>({
get() {
if (!props.modelValue) return undefined
try {
return parseDate(props.modelValue)
} catch {
return undefined
}
},
set(v) {
if (!v) {
emit('update:modelValue', '')
return
}
const d = new CalendarDate(v.year, v.month, v.day)
emit('update:modelValue', d.toString())
open.value = false
},
})
const minDate = computed<DateValue | undefined>(() => {
if (!props.min) return undefined
try {
return parseDate(props.min)
} catch {
return undefined
}
})
const display = computed(() => {
if (!props.modelValue) return ''
const d = new Date(props.modelValue + 'T00:00:00')
if (isNaN(d.getTime())) return ''
return format(d, 'PPP')
})
function clear(e: Event) {
e.stopPropagation()
emit('update:modelValue', '')
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
:disabled="disabled"
:class="cn('w-full justify-start text-left font-normal', !modelValue && 'text-muted-foreground')"
>
<CalendarIcon class="mr-2 h-4 w-4 shrink-0" />
<span class="truncate">{{ display || placeholder || 'Pick a date' }}</span>
<button
v-if="clearable && modelValue && !disabled"
type="button"
class="ml-auto -mr-1 inline-flex h-5 w-5 items-center justify-center rounded-sm opacity-60 hover:opacity-100"
@click="clear"
aria-label="Clear date"
>
<X class="h-3.5 w-3.5" />
</button>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<Calendar v-model="dateValue" :min-value="minDate" initial-focus />
</PopoverContent>
</Popover>
</template>

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

@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock, X } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
/**
* HH:MM time picker two shadcn <Select> dropdowns. Mobile-first:
* tap to pick from the native sheet / wheel. Clear button when set.
*/
const props = defineProps<{
modelValue: string
/** Minute granularity for the dropdown list. Default 15. */
minuteStep?: number
disabled?: boolean
clearable?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const step = computed(() => props.minuteStep ?? 15)
const hours = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'))
const minutes = computed(() =>
Array.from({ length: Math.floor(60 / step.value) }, (_, i) =>
(i * step.value).toString().padStart(2, '0')
)
)
const hour = computed<string>({
get() {
return props.modelValue.split(':')[0] ?? ''
},
set(h) {
const m = props.modelValue.split(':')[1] || '00'
emit('update:modelValue', `${h}:${m}`)
},
})
const minute = computed<string>({
get() {
return props.modelValue.split(':')[1] ?? ''
},
set(m) {
const h = props.modelValue.split(':')[0] || '00'
emit('update:modelValue', `${h}:${m}`)
},
})
const hasValue = computed(() => Boolean(props.modelValue))
function clear() {
emit('update:modelValue', '')
}
</script>
<template>
<div :class="cn('flex items-center gap-1.5', disabled && 'opacity-50')">
<Clock class="h-4 w-4 text-muted-foreground shrink-0" />
<Select v-model="hour" :disabled="disabled">
<SelectTrigger class="flex-1 min-w-0 px-2 tabular-nums" aria-label="Hours">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent class="max-h-56">
<SelectItem v-for="h in hours" :key="h" :value="h" class="tabular-nums">
{{ h }}
</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground select-none">:</span>
<Select v-model="minute" :disabled="disabled">
<SelectTrigger class="flex-1 min-w-0 px-2 tabular-nums" aria-label="Minutes">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent class="max-h-56">
<SelectItem v-for="m in minutes" :key="m" :value="m" class="tabular-nums">
{{ m }}
</SelectItem>
</SelectContent>
</Select>
<Button
v-if="clearable && hasValue && !disabled"
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
@click="clear"
aria-label="Clear time"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
</template>

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
*/