Compare commits
2 commits
691f8df830
...
a4ae61a119
| Author | SHA1 | Date | |
|---|---|---|---|
| a4ae61a119 | |||
| 16608e0d60 |
5 changed files with 160 additions and 31 deletions
57
package-lock.json
generated
57
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// (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<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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue