mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-28 04:25:03 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -279,6 +279,8 @@ export function createModel (builder: Builder): void {
|
||||
)
|
||||
|
||||
builder.mixin(activity.class.Reaction, core.class.Class, core.mixin.TxAccessLevel, {
|
||||
createAccessLevel: AccountRole.Guest,
|
||||
updateAccessLevel: AccountRole.Guest,
|
||||
removeAccessLevel: AccountRole.Guest
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
Photo,
|
||||
SavedAttachments
|
||||
} from '@hcengineering/attachment'
|
||||
import { IndexKind, type Blob, type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
|
||||
import { AccountRole, IndexKind, type Blob, type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
|
||||
import {
|
||||
Hidden,
|
||||
Index,
|
||||
@@ -124,6 +124,10 @@ export function createModel (builder: Builder): void {
|
||||
editor: attachment.component.Attachments
|
||||
})
|
||||
|
||||
builder.mixin(attachment.class.Attachment, core.class.Class, core.mixin.TxAccessLevel, {
|
||||
createAccessLevel: AccountRole.Guest
|
||||
})
|
||||
|
||||
builder.mixin(attachment.class.Photo, core.class.Class, view.mixin.CollectionEditor, {
|
||||
editor: attachment.component.Photos
|
||||
})
|
||||
|
||||
@@ -1046,7 +1046,7 @@ export function createModel (builder: Builder): void {
|
||||
label: print.string.PrintToPDF,
|
||||
icon: print.icon.Print,
|
||||
category: view.category.General,
|
||||
input: 'focus', // NOTE: should only work for one doc for now, not bulk
|
||||
input: 'selection',
|
||||
target,
|
||||
context: { mode: ['context', 'browser'], group: 'tools' },
|
||||
visibilityTester: documents.function.CanPrintDocument,
|
||||
@@ -1069,6 +1069,11 @@ export function createModel (builder: Builder): void {
|
||||
export function defineNotifications (builder: Builder): void {
|
||||
builder.mixin(documents.class.ControlledDocument, core.class.Class, activity.mixin.ActivityDoc, {})
|
||||
|
||||
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
|
||||
ofClass: documents.class.ControlledDocument,
|
||||
components: { input: { component: chunter.component.ChatMessageInput } }
|
||||
})
|
||||
|
||||
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
|
||||
ofClass: documents.class.DocumentComment,
|
||||
components: { input: { component: chunter.component.ChatMessageInput } }
|
||||
|
||||
@@ -21,7 +21,7 @@ export function createModel (builder: Builder): void {
|
||||
label: print.string.PrintToPDF,
|
||||
icon: print.icon.Print,
|
||||
category: view.category.General,
|
||||
input: 'focus', // NOTE: should only work for one doc for now, not bulk
|
||||
input: 'selection',
|
||||
target: core.class.Doc,
|
||||
context: { mode: ['context', 'browser'], group: 'tools' },
|
||||
visibilityTester: print.function.CanPrint
|
||||
|
||||
+3
-2
@@ -286,8 +286,9 @@
|
||||
props={{
|
||||
object: $controlledDocument,
|
||||
showCommenInput: true,
|
||||
boundary,
|
||||
focusIndex: 1000
|
||||
boundary: boundary ?? undefined,
|
||||
focusIndex: 1000,
|
||||
shouldScroll: false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Tisk do PDF"
|
||||
"PrintToPDF": "Tisk do PDF",
|
||||
"PrintingDocumentOf": "Tisk dokumentu {current} z {total}",
|
||||
"DownloadAll": "Stáhnout vše",
|
||||
"PrintFailed": "Tisk se nezdařil"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Als PDF drucken"
|
||||
"PrintToPDF": "Als PDF drucken",
|
||||
"PrintingDocumentOf": "Dokument {current} von {total} wird gedruckt",
|
||||
"DownloadAll": "Alle herunterladen",
|
||||
"PrintFailed": "Druck fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Print to PDF"
|
||||
"PrintToPDF": "Print to PDF",
|
||||
"PrintingDocumentOf": "Printing document {current} of {total}",
|
||||
"DownloadAll": "Download all",
|
||||
"PrintFailed": "Print failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Imprimir en PDF"
|
||||
"PrintToPDF": "Imprimir en PDF",
|
||||
"PrintingDocumentOf": "Imprimiendo documento {current} de {total}",
|
||||
"DownloadAll": "Descargar todo",
|
||||
"PrintFailed": "Error al imprimir"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Imprimer en PDF"
|
||||
"PrintToPDF": "Imprimer en PDF",
|
||||
"PrintingDocumentOf": "Impression du document {current} sur {total}",
|
||||
"DownloadAll": "Tout télécharger",
|
||||
"PrintFailed": "Échec de l'impression"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Stampa in PDF"
|
||||
"PrintToPDF": "Stampa in PDF",
|
||||
"PrintingDocumentOf": "Stampa documento {current} di {total}",
|
||||
"DownloadAll": "Scarica tutto",
|
||||
"PrintFailed": "Stampa non riuscita"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "PDFとして印刷"
|
||||
"PrintToPDF": "PDFとして印刷",
|
||||
"PrintingDocumentOf": "ドキュメント {current} / {total} を印刷中",
|
||||
"DownloadAll": "すべてダウンロード",
|
||||
"PrintFailed": "印刷に失敗しました"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Imprimir em PDF"
|
||||
"PrintToPDF": "Imprimir em PDF",
|
||||
"PrintingDocumentOf": "Imprimindo documento {current} de {total}",
|
||||
"DownloadAll": "Descarregar tudo",
|
||||
"PrintFailed": "Falha na impressão"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "Печать в PDF"
|
||||
"PrintToPDF": "Печать в PDF",
|
||||
"PrintingDocumentOf": "Печать документа {current} из {total}",
|
||||
"DownloadAll": "Скачать все",
|
||||
"PrintFailed": "Ошибка печати"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "PDF'e yazdır"
|
||||
"PrintToPDF": "PDF'e yazdır",
|
||||
"PrintingDocumentOf": "Belge yazdırılıyor {current} / {total}",
|
||||
"DownloadAll": "Tümünü indir",
|
||||
"PrintFailed": "Yazdırma başarısız"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"string": {
|
||||
"PrintToPDF": "打印为 PDF"
|
||||
"PrintToPDF": "打印为 PDF",
|
||||
"PrintingDocumentOf": "正在打印文档 {current} / {total}",
|
||||
"DownloadAll": "全部下载",
|
||||
"PrintFailed": "打印失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import type { Blob, Doc, Ref } from '@hcengineering/core'
|
||||
import presentation, { Card, getClient, PDFViewer } from '@hcengineering/presentation'
|
||||
import view from '@hcengineering/view'
|
||||
import { Button, Label, Loading, Scroller } from '@hcengineering/ui'
|
||||
|
||||
import print from '../plugin'
|
||||
import { type PdfResult, printAll, downloadPdf, downloadAllPdfs } from '../printUtils'
|
||||
|
||||
export let objects: Doc[] = []
|
||||
export let signed: boolean = false
|
||||
|
||||
const client = getClient()
|
||||
|
||||
let results: PdfResult[] = []
|
||||
let currentIndex = 0
|
||||
let cancelled = false
|
||||
let processing = true
|
||||
let downloadAllLoading = false
|
||||
let selectedResult: PdfResult | undefined = undefined
|
||||
|
||||
$: viewerFile = (selectedResult !== undefined ? selectedResult.blobId : undefined) as Ref<Blob> | undefined
|
||||
|
||||
$: total = objects.length
|
||||
$: successCount = results.filter((r) => r.error === undefined).length
|
||||
$: failedList = results.filter((r) => r.error !== undefined)
|
||||
|
||||
function cancel (): void {
|
||||
cancelled = true
|
||||
close()
|
||||
}
|
||||
|
||||
function openPdf (result: PdfResult): void {
|
||||
if (result.error !== undefined) return
|
||||
selectedResult = result
|
||||
}
|
||||
|
||||
async function doDownloadAll (): Promise<void> {
|
||||
downloadAllLoading = true
|
||||
try {
|
||||
await downloadAllPdfs(results)
|
||||
} finally {
|
||||
downloadAllLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function close (): void {
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (objects.length > 0) {
|
||||
results = []
|
||||
currentIndex = 0
|
||||
cancelled = false
|
||||
processing = true
|
||||
void printAll(client, objects, signed, {
|
||||
onProgress: (current) => {
|
||||
currentIndex = current
|
||||
},
|
||||
getCancelled: () => cancelled
|
||||
}).then((r) => {
|
||||
results = r
|
||||
processing = false
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (selectedResult !== undefined) {
|
||||
selectedResult = undefined
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if selectedResult === undefined}
|
||||
<div class="flex p-4">
|
||||
<Card
|
||||
label={print.string.PrintToPDF}
|
||||
okAction={() => {}}
|
||||
canSave={false}
|
||||
hideFooter={true}
|
||||
width="medium"
|
||||
on:close={close}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if processing && currentIndex <= total}
|
||||
<div class="flex flex-col gap-2 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<Label label={print.string.PrintingDocumentOf} params={{ current: currentIndex, total }} />
|
||||
<div class="flex pl-2">
|
||||
<Loading shrink={true} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button kind="secondary" label={presentation.string.Cancel} on:click={cancel} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="results-scroller">
|
||||
<Scroller>
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
{#each results as result}
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 py-1 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span class="truncate flex-1 min-w-0 secondary-textColor" title={result.title ?? ''}>
|
||||
{#if result.error !== undefined}
|
||||
{result.title} – <Label label={print.string.PrintFailed} />
|
||||
{:else}
|
||||
{result.title}
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex gap-1 shrink-0 w-[11rem] justify-end">
|
||||
{#if result.error === undefined}
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="small"
|
||||
label={presentation.string.Download}
|
||||
on:click={() => {
|
||||
downloadPdf(result)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="small"
|
||||
label={view.string.Open}
|
||||
on:click={() => {
|
||||
openPdf(result)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
{#if successCount > 0}
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button
|
||||
kind="primary"
|
||||
label={print.string.DownloadAll}
|
||||
disabled={downloadAllLoading}
|
||||
on:click={doDownloadAll}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if failedList.length > 0}
|
||||
<p class="secondary-textColor text-sm">
|
||||
{successCount} succeeded, {failedList.length} failed.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{:else}
|
||||
<PDFViewer
|
||||
file={viewerFile}
|
||||
name={selectedResult.title}
|
||||
contentType="application/pdf"
|
||||
showIcon={false}
|
||||
isLoading={false}
|
||||
on:close={() => {
|
||||
selectedResult = undefined
|
||||
}}
|
||||
on:fullsize
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.results-scroller {
|
||||
max-height: 16rem;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -7,21 +7,37 @@ import { showPopup } from '@hcengineering/ui'
|
||||
import { getPrintBaseURL } from '@hcengineering/print'
|
||||
|
||||
import PrintToPDF from './components/PrintToPDF.svelte'
|
||||
import PrintBulkToPDF from './components/PrintBulkToPDF.svelte'
|
||||
import DOCXViewer from './components/DOCXViewer.svelte'
|
||||
|
||||
export async function print (
|
||||
object: Doc,
|
||||
object: Doc | Doc[],
|
||||
evt: Event,
|
||||
props: {
|
||||
signed: boolean
|
||||
}
|
||||
): Promise<void> {
|
||||
const signed = props?.signed ?? false
|
||||
const docs = Array.isArray(object) ? object : [object]
|
||||
|
||||
if (docs.length === 0) {
|
||||
return
|
||||
}
|
||||
if (docs.length === 1) {
|
||||
showPopup(
|
||||
PrintToPDF,
|
||||
{
|
||||
object: docs[0],
|
||||
signed
|
||||
},
|
||||
'float'
|
||||
)
|
||||
return
|
||||
}
|
||||
showPopup(
|
||||
PrintToPDF,
|
||||
PrintBulkToPDF,
|
||||
{
|
||||
object,
|
||||
objects: docs,
|
||||
signed
|
||||
},
|
||||
'float'
|
||||
@@ -42,6 +58,7 @@ export async function canPrint (): Promise<boolean> {
|
||||
export default async (): Promise<Resources> => ({
|
||||
component: {
|
||||
PrintToPDF,
|
||||
PrintBulkToPDF,
|
||||
DOCXViewer
|
||||
},
|
||||
actionImpl: {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import type { Class, Client, Doc, Ref } from '@hcengineering/core'
|
||||
import type { Location } from '@hcengineering/ui'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import guest, { type PublicLink, createPublicLink } from '@hcengineering/guest'
|
||||
import view from '@hcengineering/view'
|
||||
import { getDocTitle, getObjectLinkFragment } from '@hcengineering/view-resources'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation, { getFileUrl } from '@hcengineering/presentation'
|
||||
import { printToPDF } from '@hcengineering/print'
|
||||
import { signPDF } from '@hcengineering/sign'
|
||||
|
||||
export interface PdfResult {
|
||||
blobId: string
|
||||
title: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PrintAllOptions {
|
||||
onProgress: (current: number, total?: number) => void
|
||||
getCancelled: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a public link for a document (for print/share).
|
||||
* Returns the link if it exists or after creating it; the link's url may be set asynchronously by the server.
|
||||
*/
|
||||
export async function ensurePublicLink (client: Client, doc: Doc): Promise<PublicLink | undefined> {
|
||||
const existing = await client.findOne(guest.class.PublicLink, { attachedTo: doc._id })
|
||||
if (existing?.url !== undefined && existing.url !== '') {
|
||||
return existing
|
||||
}
|
||||
if (existing === undefined) {
|
||||
const location = await getObjectLocation(client, doc)
|
||||
await createPublicLink(client as any, doc, location)
|
||||
}
|
||||
const link = await client.findOne(guest.class.PublicLink, { attachedTo: doc._id })
|
||||
return link?.url !== undefined && link.url !== '' ? link : undefined
|
||||
}
|
||||
|
||||
async function getObjectLocation (client: Client, obj: Doc): Promise<Location> {
|
||||
const hierarchy = client.getHierarchy()
|
||||
const panelComponent = hierarchy.classHierarchyMixin(obj._class, view.mixin.ObjectPanel)
|
||||
const comp = panelComponent?.component ?? view.component.EditDoc
|
||||
return await getObjectLinkFragment(hierarchy, obj, {}, comp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a document title suitable for use as PDF filename (with .pdf extension).
|
||||
*/
|
||||
export async function getDocTitleForPdf (
|
||||
client: Client,
|
||||
docId: Ref<Doc>,
|
||||
docClass: Ref<Class<Doc>>,
|
||||
doc?: Doc
|
||||
): Promise<string> {
|
||||
const value = (await getDocTitle(client, docId, docClass, doc)) ?? ''
|
||||
return value !== '' ? value + '.pdf' : 'document.pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* Print multiple documents to PDF (one per document). Calls onProgress and getCancelled for progress and cancellation.
|
||||
*/
|
||||
export async function printAll (
|
||||
client: Client,
|
||||
docs: Doc[],
|
||||
signed: boolean,
|
||||
options: PrintAllOptions
|
||||
): Promise<PdfResult[]> {
|
||||
const token = getMetadata(presentation.metadata.Token) ?? ''
|
||||
const results: PdfResult[] = []
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
if (options.getCancelled()) break
|
||||
options.onProgress(i + 1, docs.length)
|
||||
const doc = docs[i]
|
||||
try {
|
||||
const link = await ensurePublicLink(client, doc)
|
||||
if (link?.url === undefined || link.url === '') {
|
||||
results.push({ blobId: '', title: '', error: 'Could not get public link' })
|
||||
continue
|
||||
}
|
||||
let blobId: string = await printToPDF(link.url, token)
|
||||
if (signed) {
|
||||
blobId = await signPDF(blobId, token)
|
||||
}
|
||||
const title = await getDocTitleForPdf(client, doc._id, doc._class, doc)
|
||||
results.push({ blobId, title, error: undefined })
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
const title = await getDocTitleForPdf(client, doc._id, doc._class, doc).catch(() => 'document')
|
||||
results.push({ blobId: '', title, error: err?.message ?? String(err) })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger browser download of a single PDF result.
|
||||
*/
|
||||
export function downloadPdf (result: PdfResult): void {
|
||||
if (result.error !== undefined) return
|
||||
const url = getFileUrl(result.blobId, result.title)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = result.title
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all successful PDFs at once by triggering a download for each file
|
||||
* with a short delay between each so the browser does not block them.
|
||||
*/
|
||||
export async function downloadAllPdfs (results: PdfResult[]): Promise<void> {
|
||||
const successResults = results.filter((r) => r.error === undefined)
|
||||
if (successResults.length === 0) return
|
||||
for (let i = 0; i < successResults.length; i++) {
|
||||
const r = successResults[i]
|
||||
downloadPdf(r)
|
||||
if (i < successResults.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,14 @@ export const printId = 'print' as Plugin
|
||||
|
||||
export const print = plugin(printId, {
|
||||
string: {
|
||||
PrintToPDF: '' as IntlString
|
||||
PrintToPDF: '' as IntlString,
|
||||
PrintingDocumentOf: '' as IntlString,
|
||||
DownloadAll: '' as IntlString,
|
||||
PrintFailed: '' as IntlString
|
||||
},
|
||||
component: {
|
||||
PrintToPDF: '' as AnyComponent,
|
||||
PrintBulkToPDF: '' as AnyComponent,
|
||||
DOCXViewer: '' as AnyComponent
|
||||
},
|
||||
icon: {
|
||||
|
||||
Reference in New Issue
Block a user