feat(front): layout upload modal; use new components

This commit is contained in:
upsiflu 2025-03-19 13:18:04 +01:00
parent e05a5b9d7a
commit 6eb5c14176
4 changed files with 244 additions and 239 deletions

View File

@ -18,7 +18,7 @@ interface Action {
label: string label: string
isDangerous?: boolean isDangerous?: boolean
allowAll?: boolean allowAll?: boolean
confirmColor?: string confirmColor?: 'success' | 'danger'
confirmationMessage?: string confirmationMessage?: string
filterChackable?: (item: any) => boolean filterChackable?: (item: any) => boolean
} }
@ -61,6 +61,13 @@ const checkable = computed(() => {
.map(item => item[props.idField] as string) .map(item => item[props.idField] as string)
}) })
// objects is `any`.
// Can we narrow down this type?
// TODO: Search `action-table` globally and narrow all
// `objectsData` props
// Type Custom = A | B | C
const objects = computed(() => props.objectsData.results.map(object => { const objects = computed(() => props.objectsData.results.map(object => {
return props.customObjects.find(custom => custom[props.idField] === object[props.idField]) return props.customObjects.find(custom => custom[props.idField] === object[props.idField])
?? object ?? object
@ -178,27 +185,27 @@ const gridColumns = computed(() => {
columns += 1 columns += 1
} }
return Array.from({ length: columns }, () => 'auto' as 'auto') return Array.from({ length: columns }, () => 'auto' as const)
}) })
</script> </script>
<template> <template>
<div class="table-wrapper component-action-table"> <div class="table-wrapper component-action-table">
<Table <Table
v-if="objectsData.count > 0" v-if="objectsData.count > 0"
:grid-template-columns="gridColumns" :grid-template-columns="gridColumns"
class="ui compact very basic unstackable table" class="ui compact very basic unstackable table"
> >
<template #header> <template #header>
<label v-if="actions.length > 0"> <label v-if="actions.length > 0">
<div class="ui checkbox"> <div class="ui checkbox">
<!-- TODO (wvffle): Check if we don't have to migrate to v-model --> <!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
<input <input
type="checkbox" type="checkbox"
:aria-label="labels.selectAllItems" :aria-label="labels.selectAllItems"
:disabled="checkable.length === 0" :disabled="checkable.length === 0"
:checked="checkable.length > 0 && checked.length === checkable.length" :checked="checkable.length > 0 && checked.length === checkable.length"
@change="toggleCheckAll" @change="toggleCheckAll"
> >
</div> </div>
</label> </label>
@ -209,14 +216,16 @@ const gridColumns = computed(() => {
v-if="actionUrl && actions.length > 0 || refreshable" v-if="actionUrl && actions.length > 0 || refreshable"
:style="{ gridColumn: `span ${gridColumns.length}`, height: '128px' }" :style="{ gridColumn: `span ${gridColumns.length}`, height: '128px' }"
> >
<Layout <Layout
v-if="actionUrl && actions.length > 0"
stack stack
no-gap no-gap
v-if="actionUrl && actions.length > 0"
> >
<label for="actions-select">{{ t('components.common.ActionTable.label.actions') }}</label> <label for="actions-select">{{ t('components.common.ActionTable.label.actions') }}</label>
<Layout flex class="ui form"> <Layout
flex
class="ui form"
>
<select <select
id="actions-select" id="actions-select"
v-model="currentActionName" v-model="currentActionName"
@ -331,8 +340,8 @@ const gridColumns = computed(() => {
<Spacer grow /> <Spacer grow />
<Layout <Layout
label
v-if="refreshable" v-if="refreshable"
label
class="right floated" class="right floated"
> >
<span v-if="needsRefresh"> <span v-if="needsRefresh">
@ -343,10 +352,10 @@ const gridColumns = computed(() => {
icon="bi-arrow-clockwise" icon="bi-arrow-clockwise"
:title="labels.refresh" :title="labels.refresh"
:aria-label="labels.refresh" :aria-label="labels.refresh"
@click="$emit('refresh')"
style="align-self: end;" style="align-self: end;"
@click="$emit('refresh')"
> >
{{ labels.refresh }} {{ labels.refresh }}
</Button> </Button>
</Layout> </Layout>
</div> </div>

View File

@ -25,6 +25,10 @@ import useSharedLabels from '~/composables/locale/useSharedLabels'
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Slider from '~/components/ui/Slider.vue' import Slider from '~/components/ui/Slider.vue'
import Section from '~/components/ui/Section.vue'
import Pill from '~/components/ui/Pill.vue'
import Layout from '~/components/ui/Layout.vue'
import Table from '~/components/ui/Table.vue'
interface Events { interface Events {
(e: 'uploads-finished', delta: number):void (e: 'uploads-finished', delta: number):void
@ -343,6 +347,9 @@ useEventListener(window, 'beforeunload', (event) => {
event.preventDefault() event.preventDefault()
return (event.returnValue = t('components.library.FileUpload.message.listener')) return (event.returnValue = t('components.library.FileUpload.message.listener'))
}) })
// collapse section
const section = ref(false)
</script> </script>
<template> <template>
@ -361,27 +368,8 @@ useEventListener(window, 'beforeunload', (event) => {
:options="options" :options="options"
:label="t('components.manage.library.UploadsTable.label.visibility')" :label="t('components.manage.library.UploadsTable.label.visibility')"
/> />
<file-upload-widget
ref="upload"
v-model="files"
:data="uploadData"
@input-file="inputFile"
>
<Button
primary
icon="bi bi-upload"
>
{{ t('components.library.FileUpload.label.uploadWidget') }}
</Button>
<p>
{{ t('components.library.FileUpload.label.extensions', {extensions: supportedExtensions.join(', ')}) }}
</p>
</file-upload-widget>
<Alert blue> <Alert blue>
<h2 class="ui header">
{{ t('components.library.FileUpload.header.local') }}
</h2>
<p> <p>
{{ t('components.library.FileUpload.message.local.message') }} {{ t('components.library.FileUpload.message.local.message') }}
</p> </p>
@ -402,9 +390,55 @@ useEventListener(window, 'beforeunload', (event) => {
</ul> </ul>
</Alert> </Alert>
<h2 class="ui header"> <file-upload-widget
{{ t('components.library.FileUpload.header.server') }} ref="upload"
</h2> v-model="files"
:data="uploadData"
@input-file="inputFile"
>
<Button
primary
icon="bi bi-upload"
>
{{ t('components.library.FileUpload.label.uploadWidget') }}
</Button>
<p>
{{ t('components.library.FileUpload.label.extensions', {extensions: supportedExtensions.join(', ')}) }}
</p>
</file-upload-widget>
<!-- Show how many files are uploading and processing -->
<Layout
v-if="files.length > 0"
flex
>
<Layout
flex
gap-8
>
<label>{{ t('components.library.FileUpload.link.uploading') }}</label>
<Pill
v-bind="{
'green': erroredFilesCount === 0,
'red': erroredFilesCount > 0,
'yellow': files.length > uploadedFilesCount + erroredFilesCount
}"
>
{{ uploadedFilesCount + erroredFilesCount }} / {{ files.length }}
</Pill>
</Layout>
<Layout
flex
gap-8
>
<label>{{ t('components.library.FileUpload.link.processing') }}</label>
<Pill>
{{ processedFilesCount }} / {{ processableFiles }}
</Pill>
</Layout>
</Layout>
<Alert <Alert
v-if="fsErrors.length > 0" v-if="fsErrors.length > 0"
red red
@ -421,32 +455,8 @@ useEventListener(window, 'beforeunload', (event) => {
</li> </li>
</ul> </ul>
</Alert> </Alert>
<fs-browser
v-model="fsPath"
:loading="isLoadingFs"
:data="fsStatus"
@import="importFs"
/>
<template v-if="fsStatus && fsStatus.import">
<h3 class="ui header">
{{ t('components.library.FileUpload.header.status') }}
</h3>
<p v-if="fsStatus.import.reference !== importReference">
{{ t('components.library.FileUpload.description.previousImport') }}
</p>
<p v-else>
{{ t('components.library.FileUpload.description.import') }}
</p>
<Button <!-- Show list of processed files -->
v-if="fsStatus.import.status === 'started' || fsStatus.import.status === 'pending'"
secondary
@click="cancelFsScan"
>
{{ t('components.library.FileUpload.button.cancel') }}
</Button>
<fs-logs :data="fsStatus.import" />
</template>
<library-files-table <library-files-table
:needs-refresh="needsRefresh" :needs-refresh="needsRefresh"
@ -455,177 +465,164 @@ useEventListener(window, 'beforeunload', (event) => {
:custom-objects="Object.values(uploads.objects)" :custom-objects="Object.values(uploads.objects)"
@fetch-start="needsRefresh = false" @fetch-start="needsRefresh = false"
/> />
<div class="ui top attached tabular menu">
<a
href=""
:class="['item', {active: currentTab === 'uploads'}]"
@click.prevent="currentTab = 'uploads'"
>
{{ t('components.library.FileUpload.link.uploading') }}
<div
v-if="files.length === 0"
class="ui label"
>
{{ t('components.library.FileUpload.empty.noFiles') }}
</div>
<div
v-else-if="files.length > uploadedFilesCount + erroredFilesCount"
class="ui warning label"
>
{{ uploadedFilesCount + erroredFilesCount }}
<span class="bi bi-slash-circle" />
{{ files.length }}
</div>
<div
v-else
:class="['ui', {'success': erroredFilesCount === 0}, {'danger': erroredFilesCount > 0}, 'label']"
>
{{ uploadedFilesCount + erroredFilesCount }}
<span class="bi bi-slash-circle" />
{{ files.length }}
</div>
</a>
<a
href=""
:class="['item', {active: currentTab === 'processing'}]"
@click.prevent="currentTab = 'processing'"
>
{{ t('components.library.FileUpload.link.processing') }}
<div
v-if="processableFiles === 0"
class="ui label"
>
{{ t('components.library.FileUpload.empty.noFiles') }}
</div>
<div
v-else-if="processableFiles > processedFilesCount"
class="ui warning label"
>
{{ processedFilesCount }}
<span class="bi bi-slash-circle" />
{{ processableFiles }}
</div>
<div
v-else
:class="['ui', {'success': uploads.errored === 0}, {'danger': uploads.errored > 0}, 'label']"
>
{{ processedFilesCount }}
<span class="bi bi-slash-circle" />
{{ processableFiles }}
</div>
</a>
</div>
<div
v-if="files.length > 0"
class="table-wrapper"
>
<table class="ui unstackable table">
<thead>
<tr>
<th class="ten wide">
{{ t('components.library.FileUpload.table.upload.header.filename') }}
</th>
<th>
{{ t('components.library.FileUpload.table.upload.header.size') }}
</th>
<th>
{{ t('components.library.FileUpload.table.upload.header.status') }}
</th>
<th>
{{ t('components.library.FileUpload.table.upload.header.actions') }}
</th>
</tr>
<tr v-if="retryableFiles.length > 1">
<th class="ten wide" />
<th />
<th />
<th>
<Button
tiny
style="float: right;"
@click.prevent="retry(retryableFiles)"
>
{{ t('components.library.FileUpload.button.retry') }}
</Button>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="file in sortedFiles"
:key="file.id"
>
<td :title="file.name">
{{ truncate(file.name ?? '', 60) }}
</td>
<td>{{ humanSize(file.size ?? 0) }}</td>
<td>
<span
v-if="typeof file.error === 'string' && file.error"
class="ui tooltip"
:data-tooltip="labels.tooltips[file.error]"
>
<span class="ui danger icon label">
<i class="bi bi-question-circle-fill" /> {{ file.error }}
</span>
</span>
<span
v-else-if="file.success"
class="ui success label"
>
<span key="1">
{{ t('components.library.FileUpload.table.upload.status.uploaded') }}
</span>
</span>
<span
v-else-if="file.active"
class="ui warning label"
>
<span key="2">
{{ t('components.library.FileUpload.table.upload.status.uploading') }}
</span>
{{ t('components.library.FileUpload.table.upload.progress', {percent: parseFloat(file.progress ?? '0.00')}) }} <!-- Edit the metadata of uploaded files -->
</span>
<span <Table
v-else :class="$style.table"
class="ui label" v-if="files.length > 0"
> :grid-template-columns="['1fr', 'auto', 'auto', 'auto']"
<span key="3"> >
{{ t('components.library.FileUpload.table.upload.status.pending') }} <template #header>
</span> <b class="ten wide">
</span> {{ t('components.library.FileUpload.table.upload.header.filename') }}
</td> </b>
<td> <b>
<template v-if="file.error"> {{ t('components.library.FileUpload.table.upload.header.size') }}
<Button </b>
v-if="retryableFiles.includes(file)" <b>
tiny {{ t('components.library.FileUpload.table.upload.header.status') }}
style="float: right;" </b>
:title="labels.tooltips.retry" <b>
icon="bi-arrow-clockwise" {{ t('components.library.FileUpload.table.upload.header.actions') }}
@click.prevent="retry([file])" </b>
/> </template>
</template>
<template v-else-if="!file.success"> <!-- Retry row -->
<Button <template v-if="retryableFiles.length > 1">
tiny <b> </b>
style="float: right;" <b />
icon="bi-trash-fill" <b />
@click.prevent="upload.remove(file)" <b>
/> <Button
</template> auto
</td> primary
</tr> @click.prevent="retry(retryableFiles)"
</tbody> >
</table> {{ t('components.library.FileUpload.button.retry') }}
</div> </Button>
</b>
</template>
<!-- Rows for each file -->
<template
v-for="file in sortedFiles"
:key="file.id"
>
<b :title="file.name">
{{ truncate(file.name ?? '', 60) }}
</b>
<b>{{ humanSize(file.size ?? 0) }}</b>
<b>
<span
v-if="typeof file.error === 'string' && file.error"
class="ui tooltip"
:data-tooltip="labels.tooltips[file.error]"
>
<span class="ui danger icon label">
<i class="bi bi-question-circle-fill" /> {{ file.error }}
</span>
</span>
<span
v-else-if="file.success"
class="ui success label"
>
<span key="1">
{{ t('components.library.FileUpload.table.upload.status.uploaded') }}
</span>
</span>
<span
v-else-if="file.active"
class="ui warning label"
>
<span key="2">
{{ t('components.library.FileUpload.table.upload.status.uploading') }}
</span>
{{ t('components.library.FileUpload.table.upload.progress', {percent: parseFloat(file.progress ?? '0.00')}) }}
</span>
<span
v-else
class="ui label"
>
<span key="3">
{{ t('components.library.FileUpload.table.upload.status.pending') }}
</span>
</span>
</b>
<b>
<template v-if="file.error">
<Button
v-if="retryableFiles.includes(file)"
square
secondary
:title="labels.tooltips.retry"
icon="bi-arrow-clockwise"
@click.prevent="retry([file])"
/>
</template>
<template v-else-if="!file.success">
<Button
square-small
destructive
icon="bi-trash-fill"
@click.prevent="upload.remove(file)"
/>
</template>
</b>
</template>
</Table>
<!-- Progressive disclosure: Import from server -->
<Section
:h2="t('components.library.FileUpload.header.server')"
align-left
no-items
v-bind="
section
? { collapse: () => { section = false } }
: { expand: () => { section = true } }
"
>
<div style="grid-column: 1 / -1">
<fs-browser
v-model="fsPath"
:loading="isLoadingFs"
:data="fsStatus"
@import="importFs"
/>
<template v-if="fsStatus && fsStatus.import">
<h3 class="ui header">
{{ t('components.library.FileUpload.header.status') }}
</h3>
<p v-if="fsStatus.import.reference !== importReference">
{{ t('components.library.FileUpload.description.previousImport') }}
</p>
<p v-else>
{{ t('components.library.FileUpload.description.import') }}
</p>
<Button
v-if="fsStatus.import.status === 'started' || fsStatus.import.status === 'pending'"
secondary
@click="cancelFsScan"
>
{{ t('components.library.FileUpload.button.cancel') }}
</Button>
<fs-logs :data="fsStatus.import" />
</template>
</div>
</Section>
</template> </template>
<style scoped lang="scss"> <style module lang="scss">
.file-uploads { .file-uploads {
padding: 32px; padding: 32px;
border-radius: var(--fw-border-radius); border-radius: var(--fw-border-radius);
border: 2px dashed var(--border-color); border: 2px dashed var(--border-color);
} }
.table {
b:not(:first-child) { margin-left: 12px; }
}
</style> </style>

View File

@ -10,7 +10,7 @@ import { useStore } from '~/store'
import FileUpload from 'vue-upload-component' import FileUpload from 'vue-upload-component'
const props = defineProps<{ const props = defineProps<{
channel: components['schemas']['Channel']['uuid']; channel?: components['schemas']['Channel']['uuid'];
}>() }>()
const { get } = useCookies() const { get } = useCookies()
@ -94,6 +94,7 @@ export default { inheritAttrs: false }
<file-upload <file-upload
ref="upload" ref="upload"
v-bind="$attrs" v-bind="$attrs"
:class="$style.uploader"
:post-action="store.getters['instance/absoluteUrl']('/api/v2/uploads/')" :post-action="store.getters['instance/absoluteUrl']('/api/v2/uploads/')"
:multiple="true" :multiple="true"
:thread="1" :thread="1"
@ -106,3 +107,10 @@ export default { inheritAttrs: false }
<slot /> <slot />
</file-upload> </file-upload>
</template> </template>
<style module lang="scss">
.uploader label {
background: transparent;
cursor: pointer;
}
</style>

View File

@ -28,6 +28,8 @@ import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
// This is the 'processing' tab in the old upload process
interface Events { interface Events {
(e: 'fetch-start'): void (e: 'fetch-start'): void
} }
@ -141,17 +143,6 @@ const getImportStatusChoice = (importStatus: ImportStatus) => {
<template> <template>
<Layout form> <Layout form>
<div class="fields"> <div class="fields">
<div class="ui six wide field">
<form @submit.prevent="console.log(search);query = search">
<Input
id="files-search"
v-model="search"
search
:label="t('views.content.libraries.FilesTable.label.search')"
:placeholder="labels.searchPlaceholder"
/>
</form>
</div>
<div class="field"> <div class="field">
<label for="import-status"> <label for="import-status">
{{ t('views.content.libraries.FilesTable.label.importStatus') }} {{ t('views.content.libraries.FilesTable.label.importStatus') }}