import { html } from 'lit' import { classMap } from 'lit/directives/class-map.js' import { ifDefined } from 'lit/directives/if-defined.js' import { customElement, state } from 'lit/decorators.js' import { defaultFileUploadStrings } from 'shared-types' import { navigateCyclic, trapTabInside, type IFileAndTransfer } from 'shared-utils/fileupload' import '@/components/alert' import '@/components/icon' import '@/components/input-wrapper' import '@/components/modal' import '@/components/progressbar' import { PktFileUploadBase } from './fileupload-base' import { createCommentOperation, createRemoveOperation, createRenameOperation, } from './fileupload-operations' import { renderFilenameQueueItem, renderThumbnailQueueItem } from './fileupload-renderers' import type { FileItem, IPktFileUpload, TQueueItemOperation, TQueueOperationContext, TQueueOperationLabel, TFileComment, TFileTransfer, TFileUploadItemRenderer, TTransferCancelledDetail, TTransferProgress, TFileValidateDetail, TFileValidator, TUploadStrategy, } from './fileupload-types' export type { FileItem, IPktFileUpload, TQueueItemOperation, TQueueOperationContext, TQueueOperationLabel, TFileComment, TFileTransfer, TFileUploadItemRenderer, TTransferCancelledDetail, TTransferProgress, TFileValidateDetail, TFileValidator, TUploadStrategy, } type FileImageFailureFlags = { thumbnail?: boolean; preview?: boolean } @customElement('pkt-fileupload') export class PktFileUpload extends PktFileUploadBase implements IPktFileUpload { @state() private activeOperationByFileId: Record = {} @state() private isPreviewModalOpen = false @state() private previewCurrentIndex = 0 @state() private failedImageFileIds: Record = {} render() { const content = this.renderContent() if (!this.label) return content return html` ${content} ` } private renderContent() { const filesAndTransfers = this.getSortedFilesAndTransfers() const previewableImages = this.getPreviewableImages(filesAndTransfers) const supportedFormatsText = this.getSupportedFormatsText() const helptextId = !this.label && this.helptext ? `${this.id}-helptext` : undefined const errorId = this.hasEffectiveError ? `${this.id}-error` : undefined const describedBy = [helptextId, errorId].filter(Boolean).join(' ') || undefined const isThumbnailView = this.itemRenderer === 'thumbnail' const dropZoneCopy = isThumbnailView ? this.multiple ? { dragInactive: defaultFileUploadStrings.dropZoneDragMultipleThumbnail, dragActive: defaultFileUploadStrings.dropZoneDragActiveMultipleThumbnail, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogMultipleThumbnail, } : { dragInactive: defaultFileUploadStrings.dropZoneDragSingleThumbnail, dragActive: defaultFileUploadStrings.dropZoneDragActiveSingleThumbnail, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogSingleThumbnail, } : this.multiple ? { dragInactive: defaultFileUploadStrings.dropZoneDragMultiple, dragActive: defaultFileUploadStrings.dropZoneDragActiveMultiple, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogMultiple, } : { dragInactive: defaultFileUploadStrings.dropZoneDragSingle, dragActive: defaultFileUploadStrings.dropZoneDragActiveSingle, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogSingle, } return html`
${this.getSrUploadedMessage()}
${this.getSrErrorsMessage()}
${this.uploadStrategy === 'custom' ? filesAndTransfers.map( (fileItem) => html``, ) : null}

${this.isDragActive ? `${dropZoneCopy.dragActive} ...` : html`${dropZoneCopy.dragInactive} `}

${supportedFormatsText ? html`

${defaultFileUploadStrings.supportedFormatsPrefix} ${supportedFormatsText}

` : null}
${this.hasEffectiveError ? html` ${this.effectiveErrorMessage} ` : null} ${filesAndTransfers.length > 0 ? html`
    ${filesAndTransfers.map((file) => this.renderQueueItem(file, previewableImages))}
` : null} ${this.renderPreviewModal(previewableImages)}
` } private renderQueueItem(file: IFileAndTransfer, previewableImages: IFileAndTransfer[]) { const fileSize = typeof file.file?.size === 'number' ? this.formatFileSize(file.file.size) : '' const operations = this.getQueueItemOperations() if (this.itemRenderer === 'thumbnail') { const canOpenPreview = this.canOpenPreview(file) const thumbnailUrl = this.failedImageFileIds[file.fileId]?.thumbnail ? undefined : this.getThumbnailUrl(file) return renderThumbnailQueueItem({ file, inputName: this.name, disabled: this.disabled, fileSize, operations, activeOperationId: this.activeOperationByFileId[file.fileId], onActivateOperation: (fileId, operationId) => this.activateOperation(fileId, operationId), onCloseOperation: (fileId) => this.closeOperation(fileId), getFileAttribute: (fileId: string, name: string) => this.getFileAttribute(fileId, name), setFileAttribute: (fileId: string, name: string, value: unknown) => this.setFileAttribute(fileId, name, value), thumbnailUrl, previewEnabled: this.enableImagePreview, canOpenPreview, transferProgress: file.progress, transferErrorMessage: file.errorMessage, transferShowProgress: file.showProgress, transferLastProgress: file.lastProgress, loadingText: 'Laster opp', truncateTail: this.truncateTail, onCancel: (fileId) => this.cancelTransfer(fileId), onOpenPreview: (fileId) => this.openPreview(fileId, previewableImages), onThumbnailImageError: (fileId) => this.markImageFailed(fileId, 'thumbnail'), }) } return renderFilenameQueueItem({ file, inputName: this.name, disabled: this.disabled, fileSize, operations, activeOperationId: this.activeOperationByFileId[file.fileId], onActivateOperation: (fileId, operationId) => this.activateOperation(fileId, operationId), onCloseOperation: (fileId) => this.closeOperation(fileId), getFileAttribute: (fileId: string, name: string) => this.getFileAttribute(fileId, name), setFileAttribute: (fileId: string, name: string, value: unknown) => this.setFileAttribute(fileId, name, value), transferProgress: file.progress, transferErrorMessage: file.errorMessage, transferShowProgress: file.showProgress, transferLastProgress: file.lastProgress, loadingText: 'Laster opp', truncateTail: this.truncateTail, onCancel: (fileId) => this.cancelTransfer(fileId), }) } private markImageFailed(fileId: string, kind: keyof FileImageFailureFlags): void { this.failedImageFileIds = { ...this.failedImageFileIds, [fileId]: { ...this.failedImageFileIds[fileId], [kind]: true }, } } private getPreviewableImages(filesAndTransfers: IFileAndTransfer[]): IFileAndTransfer[] { if (!this.enableImagePreview) return [] return filesAndTransfers.filter((item) => item.progress === 'done' && this.isImageFile(item)) } private canOpenPreview(file: IFileAndTransfer): boolean { return this.enableImagePreview && file.progress === 'done' && this.isImageFile(file) } private openPreview(fileId: string, previewableImages: IFileAndTransfer[]): void { const index = previewableImages.findIndex((item) => item.fileId === fileId) if (index < 0) return this.previewCurrentIndex = index this.isPreviewModalOpen = true this.failedImageFileIds = { ...this.failedImageFileIds, [fileId]: { ...this.failedImageFileIds[fileId], preview: false }, } } private closePreview = (): void => { this.isPreviewModalOpen = false } private navigatePreview(direction: 'prev' | 'next', previewableImages: IFileAndTransfer[]): void { if (previewableImages.length === 0) return this.previewCurrentIndex = navigateCyclic( this.previewCurrentIndex, direction, previewableImages.length, ) } private onPreviewKeyDown = ( event: KeyboardEvent, previewableImages: IFileAndTransfer[], ): void => { if (!this.isPreviewModalOpen) return if (event.key === 'Tab') { trapTabInside(event, event.currentTarget as HTMLElement | null) return } switch (event.key) { case 'ArrowLeft': event.preventDefault() this.navigatePreview('prev', previewableImages) break case 'ArrowRight': event.preventDefault() this.navigatePreview('next', previewableImages) break case 'Escape': event.preventDefault() this.closePreview() break default: break } } private renderPreviewModal(previewableImages: IFileAndTransfer[]) { if (!this.enableImagePreview || previewableImages.length === 0) return null const currentImage = previewableImages[this.previewCurrentIndex] if (!currentImage) return null const hasMultipleImages = previewableImages.length > 1 const imageUrl = this.getThumbnailUrl(currentImage) const hasImage = !!imageUrl && !this.failedImageFileIds[currentImage.fileId]?.preview const filename = currentImage.attributes?.targetFilename || currentImage.file?.name || 'Forhåndsvisning' return html` this.onPreviewKeyDown(event, previewableImages)} >
${hasMultipleImages ? html`` : null}
${hasImage ? html`${`${filename} this.markImageFailed(currentImage.fileId, 'preview')} />` : html``}
${hasMultipleImages ? html` ${this.previewCurrentIndex + 1} / ${previewableImages.length} ` : null} ${hasMultipleImages ? html`` : null}
` } private getQueueItemOperations(): TQueueItemOperation[] { const operations: TQueueItemOperation[] = [] if (this.renameFilesEnabled && this.itemRenderer !== 'thumbnail') { operations.push(createRenameOperation()) } if (this.addCommentsEnabled && this.itemRenderer !== 'thumbnail') { operations.push(createCommentOperation()) } operations.push(...this.extraOperations) operations.push(createRemoveOperation((fileId) => this.removeFileItem(fileId))) return operations } private activateOperation(fileId: string, operationId: string): void { this.activeOperationByFileId = { ...this.activeOperationByFileId, [fileId]: operationId, } } private closeOperation(fileId: string): void { this.activeOperationByFileId = { ...this.activeOperationByFileId, [fileId]: undefined, } } private getFileAttribute(fileId: string, name: string): T | undefined { const currentFile = this.getCurrentFiles().find((item) => item.fileId === fileId) if (!currentFile) return undefined return (currentFile.attributes?.[name] as T | undefined) ?? undefined } private setFileAttribute(fileId: string, name: string, value: unknown): void { const currentFile = this.getCurrentFiles().find((item) => item.fileId === fileId) if (!currentFile) return const nextAttributes = { ...currentFile.attributes, [name]: value, } if (value === undefined) { delete nextAttributes[name] } this.updateFileItem(fileId, { attributes: nextAttributes, }) } }