Merge pull request #43 from tmshv/fix/api-image-simplify
API image simplify
This commit is contained in:
22 files changed
+175
-254
No files matched your search
@@ -1,35 +1,16 @@
|
||||
import { Request, Response } from 'express'
|
||||
import Document from '../core/Document'
|
||||
import { encodeFile } from '../factory/file'
|
||||
import { encodeFile, encodeFileUrl } from '../factory/file'
|
||||
|
||||
export async function getItem(req: Request, res: Response) {
|
||||
const id = req.params.slug
|
||||
const document = await Document.findById(id)
|
||||
|
||||
if (document) {
|
||||
res.json(encodeFile(document))
|
||||
const slug = req.params.slug
|
||||
const item = await Document.findBySlug(slug)
|
||||
if (item) {
|
||||
res.json(encodeFile(item))
|
||||
} else {
|
||||
res.status(404)
|
||||
res.json({
|
||||
error: `Resource ${id} not found`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAll(req: Request, res: Response) {
|
||||
const documents = await Document.find({ hidden: false })
|
||||
if (documents) {
|
||||
const items = documents
|
||||
.map(encodeFile)
|
||||
.filter(x => x.imageUrl) // skip documents without preview image; needs to be refactored
|
||||
|
||||
res.json({
|
||||
items,
|
||||
})
|
||||
} else {
|
||||
res.status(404)
|
||||
res.json({
|
||||
error: `Not found`
|
||||
error: `Resource ${slug} not found`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,7 +19,7 @@ export async function getUrls(req: Request, res: Response) {
|
||||
const data = await Document.find({})
|
||||
|
||||
if (data) {
|
||||
const items = data.map(x => `/document/${x.id}`)
|
||||
const items = data.map(encodeFileUrl)
|
||||
res.json({
|
||||
items
|
||||
})
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import Image from '../core/Image'
|
||||
import { encodeImage } from '../factory/image'
|
||||
import { Request, Response } from 'express'
|
||||
import { oid } from '../db'
|
||||
|
||||
export async function getItem(req: Request, res: Response) {
|
||||
const slug = req.params.slug
|
||||
const id = oid(slug)
|
||||
if (!id) {
|
||||
res.status(400)
|
||||
return res.json({
|
||||
error: `Failed to parse ${slug} as ObjectId`
|
||||
})
|
||||
}
|
||||
|
||||
const image = await Image.findById(id)
|
||||
if (image) {
|
||||
res.json(encodeImage(image))
|
||||
} else {
|
||||
res.status(404)
|
||||
res.json({
|
||||
error: `Resource ${slug} not found`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function getItemByFile(req: Request, res: Response) {
|
||||
const file = req.query.file
|
||||
const image = await Image.findByFile(file)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ export default class Document {
|
||||
return Promise.all(items.map(processDocument))
|
||||
}
|
||||
|
||||
static async findById(id) {
|
||||
const data = await findOne(store(), { id })
|
||||
static async findBySlug(slug: string) {
|
||||
const data = await findOne(store(), { id: slug })
|
||||
if (!data) return null
|
||||
|
||||
return processDocument(data)
|
||||
@@ -21,22 +21,22 @@ export default class Document {
|
||||
public id: any
|
||||
public hash: any
|
||||
public file: any
|
||||
public fileInfo: any
|
||||
public fileInfo: FileInfo
|
||||
public type: any
|
||||
public title: any
|
||||
public category: any
|
||||
public url: any
|
||||
public preview: any
|
||||
public preview: Image
|
||||
public viewUrl: any
|
||||
|
||||
constructor(data) {
|
||||
constructor(data: { preview: Image, [name: string]: any }) {
|
||||
this.id = data.id
|
||||
this.hash = data.hash
|
||||
this.file = data.file
|
||||
// In the future: use fileInfo only
|
||||
this.fileInfo = data.fileInfo
|
||||
? new DocumentFile(data.fileInfo)
|
||||
: new DocumentFile(data.file)
|
||||
? new FileInfo(data.fileInfo)
|
||||
: new FileInfo(data.file)
|
||||
this.type = data.type
|
||||
this.title = data.title
|
||||
this.category = data.category
|
||||
@@ -47,9 +47,9 @@ export default class Document {
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentFile {
|
||||
public name: any
|
||||
public size: any
|
||||
class FileInfo {
|
||||
public name: string
|
||||
public size: number
|
||||
|
||||
constructor(data) {
|
||||
this.name = data.name
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Data from './Data'
|
||||
import ImageArtifactType from './ImageArtifactType'
|
||||
import { findOne } from '../lib/store'
|
||||
import { ObjectId } from 'mongodb'
|
||||
|
||||
@@ -47,47 +46,9 @@ export default class Image {
|
||||
this.artifacts = mapOf(ImageArtifact, artifacts)
|
||||
}
|
||||
|
||||
getArtifact(size) {
|
||||
getArtifact(size: string) {
|
||||
return this.artifacts.get(size)
|
||||
}
|
||||
|
||||
findArtifact(sizes) {
|
||||
for (let size of sizes) {
|
||||
const a = this.getArtifact(size)
|
||||
if (a) return a
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
getPicture(size) {
|
||||
const retinaSize = ImageArtifactType.retina(size)
|
||||
|
||||
const a = this.getArtifact(size)
|
||||
const a2 = this.getArtifact(retinaSize)
|
||||
const srcset = !a2
|
||||
? []
|
||||
: [{
|
||||
url: a2.url,
|
||||
density: ImageArtifactType.RETINA_DENSITY,
|
||||
}]
|
||||
|
||||
return {
|
||||
src: a.url,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
set: srcset,
|
||||
}
|
||||
}
|
||||
|
||||
getSrc(): string | null {
|
||||
const a = this.getArtifact(ImageArtifactType.ORIGINAL)
|
||||
if (!a) {
|
||||
return null
|
||||
}
|
||||
|
||||
return a.url
|
||||
}
|
||||
}
|
||||
|
||||
class ImageArtifact {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
class ImageArtifactType {
|
||||
constructor() {
|
||||
throw new Error('Cannot create instance of ImageArtifactType')
|
||||
}
|
||||
}
|
||||
|
||||
ImageArtifactType.SMALL = 'small'
|
||||
ImageArtifactType.MEDIUM = 'medium'
|
||||
ImageArtifactType.BIG = 'big'
|
||||
ImageArtifactType.LARGE = 'large'
|
||||
ImageArtifactType.ORIGIN = 'original'
|
||||
ImageArtifactType.ORIGINAL = 'original'
|
||||
|
||||
ImageArtifactType.FACEBOOK = 'fb'
|
||||
|
||||
ImageArtifactType.RETINA_DENSITY = 2
|
||||
ImageArtifactType.retina = type => `${type}@${ImageArtifactType.RETINA_DENSITY}`
|
||||
|
||||
module.exports = ImageArtifactType
|
||||
@@ -20,14 +20,6 @@ export default class Page {
|
||||
return Promise.all(items.map(processPage))
|
||||
}
|
||||
|
||||
static async findById(id) {
|
||||
const item = await findOne(store(), { id })
|
||||
|
||||
return !item
|
||||
? null
|
||||
: processPage(item)
|
||||
}
|
||||
|
||||
static async findByUrl(url: string) {
|
||||
const item = await findOne(store(), { url })
|
||||
|
||||
|
||||
@@ -12,14 +12,25 @@ export async function connect(dbName: string, mongoUri: string) {
|
||||
return db
|
||||
}
|
||||
|
||||
export function collection(name) {
|
||||
export function collection(name: string) {
|
||||
return db.collection(name)
|
||||
}
|
||||
|
||||
export function id(i) {
|
||||
if (typeof i === 'string') return new ObjectId(i)
|
||||
else if (i instanceof ObjectId) return i
|
||||
else if (i instanceof Object) return '_id' in i ? new ObjectId(i._id) : null
|
||||
export type ObjectIdLike = ObjectId | string
|
||||
export function oid(i: ObjectIdLike | { _id: ObjectIdLike }): ObjectId | null {
|
||||
if (i instanceof ObjectId) {
|
||||
return i
|
||||
} else if (typeof i === 'string') {
|
||||
try {
|
||||
return new ObjectId(i)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
} else if (i instanceof Object) {
|
||||
return '_id' in i ? oid(i._id) : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
import ImageArtifactType from '../core/ImageArtifactType'
|
||||
import Document from '../core/Document'
|
||||
import { encodeImage, ImageDto } from './image'
|
||||
|
||||
const sizes = [
|
||||
ImageArtifactType.MEDIUM,
|
||||
ImageArtifactType.BIG,
|
||||
ImageArtifactType.ORIGIN,
|
||||
]
|
||||
|
||||
export function encodeFilePreview(file, sizes) {
|
||||
const preview = file.preview
|
||||
if (!preview) return null
|
||||
|
||||
const image = preview.findArtifact(sizes)
|
||||
if (!image) return null
|
||||
|
||||
return image.url
|
||||
}
|
||||
|
||||
export function encodeFile(file) {
|
||||
const imageUrl = encodeFilePreview(file, sizes)
|
||||
const category = (file.category || '')
|
||||
.replace('Documents/', '')
|
||||
.replace('Pages/', '')
|
||||
.replace('Articles /', '')
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
category,
|
||||
title: file.title,
|
||||
fileName: file.fileInfo.name,
|
||||
fileSize: file.fileInfo.size,
|
||||
fileUrl: file.url,
|
||||
url: file.viewUrl,
|
||||
export type FileDto = {
|
||||
url: string
|
||||
slug: string
|
||||
name: string
|
||||
cover?: ImageDto
|
||||
file: {
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
src: string
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeFile(file: Document): FileDto {
|
||||
const cover = file.preview ? encodeImage(file.preview) : null
|
||||
|
||||
return {
|
||||
url: encodeFileUrl(file),
|
||||
slug: file.id,
|
||||
cover,
|
||||
name: file.title,
|
||||
file: {
|
||||
type: 'application/pdf',
|
||||
name: file.fileInfo.name,
|
||||
size: file.fileInfo.size,
|
||||
src: file.url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeFileUrl(file: Document): string {
|
||||
return `/document/${file.id}`
|
||||
}
|
||||
@@ -1,48 +1,22 @@
|
||||
import ImageArtifactType from '../core/ImageArtifactType'
|
||||
import Image from '../core/Image'
|
||||
|
||||
export type ImageArtifactDto = {
|
||||
height: number
|
||||
width: number
|
||||
src: string
|
||||
set: Array<{
|
||||
density: number
|
||||
url: string
|
||||
}>
|
||||
}
|
||||
const ORIGINAL = 'original'
|
||||
|
||||
export type ImageDto = {
|
||||
artifacts: {
|
||||
original: ImageArtifactDto,
|
||||
large: ImageArtifactDto,
|
||||
big: ImageArtifactDto,
|
||||
medium: ImageArtifactDto,
|
||||
small: ImageArtifactDto,
|
||||
fb: ImageArtifactDto,
|
||||
},
|
||||
hash: string
|
||||
file: string
|
||||
width: number
|
||||
height: number
|
||||
src: string
|
||||
}
|
||||
|
||||
export function encodeImage(image: Image): ImageDto {
|
||||
const artifacts = [
|
||||
ImageArtifactType.SMALL,
|
||||
ImageArtifactType.MEDIUM,
|
||||
ImageArtifactType.BIG,
|
||||
ImageArtifactType.LARGE,
|
||||
ImageArtifactType.ORIGIN,
|
||||
ImageArtifactType.ORIGINAL,
|
||||
ImageArtifactType.FACEBOOK = 'fb'
|
||||
].reduce((acc, size) => {
|
||||
return {
|
||||
...acc,
|
||||
[size]: image.getPicture(size),
|
||||
}
|
||||
}, {})
|
||||
const a = image.getArtifact(ORIGINAL)
|
||||
if (!image) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
artifacts: artifacts as any,
|
||||
hash: image.hash,
|
||||
file: image.file,
|
||||
src: a.url,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
}
|
||||
}
|
||||
@@ -9,29 +9,24 @@ type Token = {
|
||||
}
|
||||
|
||||
type PageDto = {
|
||||
id: string
|
||||
title: string
|
||||
date?: string
|
||||
url: string
|
||||
data: string
|
||||
description: string
|
||||
preview?: ImageDto
|
||||
cover?: ImageDto
|
||||
tokens: Token[]
|
||||
tags: Tag[]
|
||||
featured: boolean
|
||||
breadcrumb: Breadcrumb
|
||||
coverSrc?: string
|
||||
}
|
||||
|
||||
export function encodePage(page: Page): PageDto {
|
||||
const preview = page.preview ? encodeImage(page.preview) : null
|
||||
const cover = page.preview ? encodeImage(page.preview) : null
|
||||
const date = page.date ? page.date.toString() : null
|
||||
const breadcrumb = page.getBreadcrumb()
|
||||
|
||||
const coverSrc = page.preview ? page.preview.getSrc() : null
|
||||
|
||||
return {
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
url: page.url,
|
||||
data: page.data,
|
||||
@@ -39,9 +34,8 @@ export function encodePage(page: Page): PageDto {
|
||||
tokens: page.tokens,
|
||||
tags: page.tags,
|
||||
featured: page.featured,
|
||||
preview,
|
||||
cover,
|
||||
date,
|
||||
breadcrumb,
|
||||
coverSrc,
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ async function main() {
|
||||
}
|
||||
|
||||
const app = express()
|
||||
app.get('/files', file.getAll)
|
||||
app.get('/files/urls', file.getUrls)
|
||||
app.get('/files/:slug', file.getItem)
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ image_sizes = [
|
||||
|
||||
('large', 2500, 1000),
|
||||
('large@2', 5000, 2000),
|
||||
# ('large', 2500, 1000),
|
||||
# ('large@2', 5000, 2000),
|
||||
|
||||
('big', 1500, 667),
|
||||
('big@2', 3000, 1334),
|
||||
@@ -58,12 +60,18 @@ image_sizes = [
|
||||
|
||||
('small', 250, 175),
|
||||
('small@2', 500, 350),
|
||||
# ('small', 250, 175),
|
||||
# ('small@2', 500, 350),
|
||||
|
||||
('og', 968, 504),
|
||||
('og@2', 1936, 1008),
|
||||
# ('og', 968, 504),
|
||||
# ('og@2', 1936, 1008),
|
||||
|
||||
('fb', 1200, 630),
|
||||
('fb@2', 2400, 1260),
|
||||
# ('fb', 1200, 630),
|
||||
# ('fb@2', 2400, 1260),
|
||||
]
|
||||
|
||||
date_formats = [
|
||||
|
||||
@@ -86,7 +86,7 @@ async def create_post(provider: Provider, folder: str, md: str, sizes):
|
||||
text = data['caption'] if data['caption'] else data['file']
|
||||
url = f'/document/{document.id}'
|
||||
|
||||
image_url = document.preview.get_size('small')['url']
|
||||
image_url = document.preview.get_src()
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export const getStaticProps = async (ctx: any) => {
|
||||
const breadcrumbSize = page?.breadcrumb?.length ?? 0
|
||||
const breadcrumb = breadcrumbSize < 2 ? null : page.breadcrumb
|
||||
const meta = (new MetaBuilder())
|
||||
.setImage(page.preview)
|
||||
.setImage(page.cover)
|
||||
.setTitle(page.title)
|
||||
.setDescription(description)
|
||||
.build()
|
||||
|
||||
@@ -109,15 +109,13 @@ export const getStaticProps = async (ctx: NextPageContext) => {
|
||||
const data: CardData[] = pages
|
||||
.filter(Boolean)
|
||||
.map(x => {
|
||||
const image = x.preview.artifacts.large
|
||||
|
||||
return {
|
||||
title: x.title,
|
||||
caption: x.description,
|
||||
href: x.url,
|
||||
imageAlt: title,
|
||||
imageSrc: image.src,
|
||||
imageSrcSet: imageSrcSet(image.src, [ImageSize.medium, ImageSize.small])
|
||||
imageSrc: x.cover.src,
|
||||
imageSrcSet: imageSrcSet(x.cover.src, [ImageSize.medium, ImageSize.small])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+14
-12
@@ -1,16 +1,15 @@
|
||||
import React from 'react'
|
||||
import Head from 'next/head'
|
||||
import { App } from 'src/components/App'
|
||||
import Document from 'src/components/Document'
|
||||
import { File } from 'src/components/File'
|
||||
import { Share } from 'src/components/Share'
|
||||
import { meta } from 'src/lib/meta'
|
||||
import { MetaBuilder } from 'src/lib/meta'
|
||||
import { Meta } from 'src/components/Meta'
|
||||
import { createApiUrl, requestGet, IResponseItems } from 'src/next-lib'
|
||||
import { NextPage } from 'next'
|
||||
import { IMeta } from 'src/types'
|
||||
import { IMeta, FileDefinition } from 'src/types'
|
||||
|
||||
type Props = {
|
||||
data: any //(file)
|
||||
data: FileDefinition
|
||||
meta: IMeta
|
||||
}
|
||||
|
||||
@@ -26,25 +25,28 @@ const Page: NextPage<Props> = props => {
|
||||
showAuthor={true}
|
||||
>
|
||||
<Head>
|
||||
<title>{props.data.title}</title>
|
||||
<title>{props.data.name}</title>
|
||||
<Meta meta={props.meta} />
|
||||
</Head>
|
||||
<Document {...props.data} />
|
||||
<File {...props.data} />
|
||||
<Share />
|
||||
</App>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticProps = async (ctx: any) => {
|
||||
const id = ctx.params.slug
|
||||
const data = await requestGet(createApiUrl(`/api/files/${id}`), {})
|
||||
const slug = ctx.params.slug
|
||||
const data = await requestGet<FileDefinition>(createApiUrl(`/api/files/${slug}`), null)
|
||||
const meta = (new MetaBuilder())
|
||||
.setImage(data.cover)
|
||||
.setTitle(data.name)
|
||||
.setDescription(data.name)
|
||||
.build()
|
||||
|
||||
return {
|
||||
props: {
|
||||
data,
|
||||
meta: meta({
|
||||
// title: file.meta,
|
||||
})
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ const Index: NextPage<IProps> = props => {
|
||||
title: item.title,
|
||||
featured: item.featured,
|
||||
date: new Date(item.date),
|
||||
coverSrc: item.coverSrc,
|
||||
coverSrc: item.cover?.src,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
const React = require('react')
|
||||
const {ext, size} = require('../lib/file')
|
||||
const {titleCase} = require('../lib/string')
|
||||
|
||||
const Document = ({title, url, imageUrl, fileUrl, fileName, fileSize}) => (
|
||||
<div className="document">
|
||||
<div className="document__image">
|
||||
<a href={fileUrl} target="_blank" className="invisible">
|
||||
<img src={imageUrl} alt={title}/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="document__file">
|
||||
<span>{titleCase(title)}</span>
|
||||
</div>
|
||||
|
||||
<div className="document__file-info">
|
||||
<a href={fileUrl} target="_blank">{ext(fileName)} ({size(fileSize)})</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
module.exports = Document
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FileDefinition } from 'src/types'
|
||||
import { ext, size } from 'src/lib/file'
|
||||
import { titleCase } from 'src/lib/string'
|
||||
|
||||
export const File: React.SFC<FileDefinition> = props => (
|
||||
<div className="document">
|
||||
<h1>{titleCase(props.name)}</h1>
|
||||
|
||||
{!props.cover?.src ? null : (
|
||||
<div className="document__image">
|
||||
<a href={props.file.src} target="_blank" className="invisible">
|
||||
<img src={props.cover.src} alt={props.name} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="document__file">
|
||||
<span>{titleCase(props.name)}</span>
|
||||
</div>
|
||||
|
||||
<div className="document__file-info">
|
||||
<a href={props.file.src} target="_blank">{ext(props.file.name)} ({size(props.file.size)})</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
+3
-4
@@ -66,11 +66,10 @@ export class MetaBuilder {
|
||||
return null
|
||||
}
|
||||
|
||||
const artifact = this.image.artifacts.fb
|
||||
return {
|
||||
image: artifact.src,
|
||||
imageWidth: artifact.width,
|
||||
imageHeight: artifact.height,
|
||||
image: this.image.src,
|
||||
imageWidth: this.image.width,
|
||||
imageHeight: this.image.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@ export async function getPagesCardsByTags(tags: string[]): Promise<PageCardDto[]
|
||||
'title',
|
||||
'featured',
|
||||
'date',
|
||||
'coverSrc'
|
||||
'cover'
|
||||
]
|
||||
|
||||
const items = await getPagesByTags<PageCardDto>(tags, { fields, skip, limit, sortBy: '-date' })
|
||||
|
||||
+16
-20
@@ -21,7 +21,20 @@ export type PageCardDto = {
|
||||
title: string
|
||||
featured: boolean
|
||||
date: string
|
||||
coverSrc: string
|
||||
cover?: ImageDefinition
|
||||
}
|
||||
|
||||
export type FileDefinition = {
|
||||
url: string
|
||||
slug: string
|
||||
name: string
|
||||
cover?: ImageDefinition
|
||||
file: {
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
src: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface IPage {
|
||||
@@ -30,7 +43,7 @@ export interface IPage {
|
||||
url: string
|
||||
data: string
|
||||
date: string
|
||||
preview: ImageDefinition
|
||||
cover: ImageDefinition
|
||||
description: string
|
||||
tokens: any[]
|
||||
tags: ITag[]
|
||||
@@ -85,25 +98,8 @@ export enum ImageSize {
|
||||
thumbnail = 200,
|
||||
}
|
||||
|
||||
export type ImageArtifact = {
|
||||
export type ImageDefinition = {
|
||||
width: number
|
||||
height: number
|
||||
src: string
|
||||
set: Array<{
|
||||
density: number
|
||||
url: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type ImageDefinition = {
|
||||
file: string
|
||||
hash: string
|
||||
artifacts: {
|
||||
original: ImageArtifact
|
||||
large?: ImageArtifact
|
||||
big?: ImageArtifact
|
||||
medium?: ImageArtifact
|
||||
small?: ImageArtifact
|
||||
fb?: ImageArtifact
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user