138 lines
3.9 KiB
Vue
138 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
|
|
import AppButton from './AppButton.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 fileInput = useTemplateRef('fileInput')
|
|
|
|
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
|
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
|
|
|
|
// Internal file list model:
|
|
const files: Ref<FileListItem[]> = ref([])
|
|
|
|
onMounted(() => {
|
|
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
|
// If input initial files change, reset the file selector to just those.
|
|
watch(() => props.initialFiles, () => {
|
|
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
|
})
|
|
// When our internal model changes, update the defined uploaded/removed files models.
|
|
watch(() => files, () => {
|
|
// 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)
|
|
}, { deep: true })
|
|
})
|
|
|
|
function onFileInputChanged(e: Event) {
|
|
if (props.disabled) return
|
|
const inputElement = e.target as HTMLInputElement
|
|
const fileList = inputElement.files
|
|
if (fileList === null) {
|
|
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 = ''
|
|
}
|
|
|
|
function onFileDeleteClicked(idx: number) {
|
|
if (props.disabled) return
|
|
if (idx === 0 && files.value.length === 1) {
|
|
files.value = []
|
|
return
|
|
}
|
|
files.value.splice(idx, 1)
|
|
}
|
|
</script>
|
|
<template>
|
|
<div class="file-selector">
|
|
<div @click.prevent="">
|
|
<div v-for="file, idx in files" :key="idx" class="file-selector-item">
|
|
<div style="display: flex; align-items: center; margin-left: 1rem;">
|
|
<div>
|
|
<div>{{ file.filename }}</div>
|
|
<div style="font-size: 0.75rem;">{{ file.contentType }}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<AppButton v-if="!disabled" icon="trash" button-type="button" @click="onFileDeleteClicked(idx)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<input id="fileInput" type="file" multiple @change="onFileInputChanged" style="display: none;" ref="fileInput"
|
|
:disabled="disabled" />
|
|
<label for="fileInput">
|
|
<AppButton icon="upload" button-type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
|
|
</AppButton>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<style lang="css">
|
|
.file-selector {
|
|
width: 100%;
|
|
}
|
|
|
|
.file-selector-item {
|
|
background-color: var(--bg-secondary);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.25rem;
|
|
border-radius: 0.5rem;
|
|
margin: 0.5rem 0;
|
|
}
|
|
</style>
|