finnow/web-app/src/components/common/FileSelector.vue

137 lines
3.9 KiB
Vue

<script setup lang="ts">
import { onMounted, ref, shallowRef, useTemplateRef, watch, type Ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'
import AttachmentRow from './AttachmentRow.vue'
interface ExistingFile {
id: number
filename: string
contentType: string
size: number
}
abstract class FileListItem {
constructor(
public readonly filename: string,
public readonly contentType: string,
public readonly size: number,
) { }
}
class ExistingFileListItem extends FileListItem {
public readonly id: number
constructor(file: ExistingFile) {
super(file.filename, file.contentType, file.size)
this.id = file.id
}
}
class NewFileListItem extends FileListItem {
public readonly file: File
constructor(file: File) {
super(file.name, file.type, file.size)
this.file = file
}
}
interface Props {
disabled?: boolean
initialFiles?: ExistingFile[]
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
initialFiles: () => [],
})
const previousInitialFiles: Ref<string> = ref('{}')
const fileInput = useTemplateRef('fileInput')
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
// Internal file list model:
const files = shallowRef<FileListItem[]>([])
onMounted(() => {
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
previousInitialFiles.value = JSON.stringify(props.initialFiles)
// If input initial files change, reset the file selector to just those.
watch(
() => props.initialFiles,
() => {
const prevJson = previousInitialFiles.value
const newJson = JSON.stringify(props.initialFiles)
if (prevJson !== newJson) {
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
previousInitialFiles.value = newJson
}
},
)
})
function syncModelsWithInternalFileList() {
// Compute the set of uploaded files as just any newly uploaded file list item.
uploadedFiles.value = files.value
.filter((f) => f instanceof NewFileListItem)
.map((f) => f.file)
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
const retainedExistingFileIds = files.value
.filter((f) => f instanceof ExistingFileListItem)
.map((f) => f.id)
removedFiles.value = props.initialFiles
.filter((f) => !retainedExistingFileIds.includes(f.id))
.map((f) => f.id)
}
function onFileInputChanged(e: Event) {
if (props.disabled) return
const inputElement = e.target as HTMLInputElement
const fileList = inputElement.files
if (fileList === null) {
files.value = []
syncModelsWithInternalFileList()
return
}
for (let i = 0; i < fileList?.length; i++) {
const file = fileList.item(i)
if (file !== null) {
files.value = [...files.value, new NewFileListItem(file)]
}
}
// Reset the input element after we've consumed the selected files.
inputElement.value = ''
syncModelsWithInternalFileList()
}
function onFileDeleteClicked(idx: number) {
if (props.disabled) return
if (idx === 0 && files.value.length === 1) {
files.value = []
} else {
files.value.splice(idx, 1)
}
syncModelsWithInternalFileList()
}
</script>
<template>
<div class="file-selector">
<div @click.prevent="">
<AttachmentRow v-for="(file, idx) in files" :key="idx" :attachment="file" @deleted="onFileDeleteClicked(idx)" />
</div>
<div>
<input id="fileInput" type="file" capture="environment" accept="image/*,text/*,.pdf,.doc,.odt,.docx,.xlsx"
multiple @change="onFileInputChanged" style="display: none" ref="fileInput" :disabled="disabled" />
<label for="fileInput">
<AppButton icon="upload" type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
</AppButton>
</label>
</div>
</div>
</template>
<style lang="css">
.file-selector {
width: 100%;
}
</style>