Migrate FileUpload component and fix uploading files

This commit is contained in:
wvffle 2022-08-03 12:42:03 +00:00 committed by Georg Krause
parent 1c395c01b0
commit 68f2450c93
10 changed files with 597 additions and 562 deletions

View File

@ -28,6 +28,7 @@ module.exports = {
// NOTE: Handled by typescript
'no-undef': 'off',
'no-redeclare': 'off',
'no-unused-vars': 'off',
'no-use-before-define': 'off',

View File

@ -105,7 +105,7 @@ const uploadedSize = computed(() => {
for (const file of uploadedFiles.value) {
if (file._fileObj && !file.error) {
uploaded += (file.size ?? 0) * +(file.progress ?? 0)
uploaded += (file.size ?? 0) * +(file.progress ?? 0) / 100
}
}
@ -191,7 +191,7 @@ const uploadedFiles = computed(() => {
response: upload,
__filename: null,
size: upload.size,
progress: '1.00',
progress: '100.00',
name: upload.source?.replace('upload://', '') ?? '',
active: false,
removed: removed.has(upload.uuid),
@ -561,7 +561,7 @@ const labels = computed(() => ({
Pending
</translate>
· {{ humanSize(file.size ?? 0) }}
· {{ parseFloat(file.progress ?? '0') * 100 }}%
· {{ parseFloat(file.progress ?? '0') }}%
</template>
· <a @click.stop.prevent="remove(file)">
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>

View File

@ -1,3 +1,161 @@
<script setup lang="ts">
import type { BackendError } from '~/types'
import { ref, computed, reactive, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import axios from 'axios'
interface Action {
name: string
label: string
isDangerous?: boolean
allowAll?: boolean
confirmColor?: string
confirmationMessage?: string
filterChackable?: (item: never) => boolean
}
interface Emits {
(e: 'action-launched', data: any): void
(e: 'refresh'): void
}
interface Props {
objectsData: { results: [], count: number }
actions: [Action]
actionUrl: string
idField?: string
refreshable?: boolean
needsRefresh?: boolean
filters?: object
customObjects?: Record<string, unknown>[]
}
const emit = defineEmits<Emits>()
const props = withDefaults(defineProps<Props>(), {
idField: 'id',
refreshable: false,
needsRefresh: false,
filters: () => ({}),
customObjects: () => []
})
const { $pgettext } = useGettext()
const currentActionName = ref(props.actions[0]?.name ?? null)
const currentAction = computed(() => props.actions.find(action => action.name === currentActionName.value))
const checkable = computed(() => {
if (!currentAction.value) return []
return props.objectsData.results
.filter(currentAction.value.filterChackable ?? (() => true))
.map(item => item[props.idField] as string)
})
const objects = computed(() => props.objectsData.results.map(object => {
return props.customObjects.find(custom => custom[props.idField] === object[props.idField])
?? object
}))
const selectAll = ref(false)
const checked = reactive([] as string[])
const affectedObjectsCount = computed(() => selectAll.value ? props.objectsData.count : checked.length)
watch(() => props.objectsData, () => {
checked.length = 0
selectAll.value = false
})
// We update checked status as some actions have specific filters
// on what is checkable or not
watch(currentActionName, () => {
const ableToCheck = checkable.value
const replace = checked.filter(object => ableToCheck.includes(object))
checked.length = 0
checked.push(...replace)
})
const lastCheckedIndex = ref(-1)
const toggleCheckAll = () => {
lastCheckedIndex.value = -1
if (checked.length === checkable.value.length) {
checked.length = 0
return
}
checked.length = 0
checked.push(...checkable.value)
}
const toggleCheck = (event: MouseEvent, id: string, index: number) => {
const affectedIds = new Set([id])
const wasChecked = checked.includes(id)
if (wasChecked) {
selectAll.value = false
}
// Add inbetween ids to the list of affected ids
if (event.shiftKey && lastCheckedIndex.value !== -1) {
const boundaries = [index, lastCheckedIndex.value].sort((a, b) => a - b)
for (const object of props.objectsData.results.slice(boundaries[0], boundaries[1] + 1)) {
affectedIds.add(object[props.idField])
}
}
for (const id of affectedIds) {
const isChecked = checked.includes(id)
if (!wasChecked && !isChecked && checkable.value.includes(id)) {
checked.push(id)
continue
}
if (wasChecked && isChecked) {
checked.splice(checked.indexOf(id), 1)
}
}
lastCheckedIndex.value = index
}
const labels = computed(() => ({
refresh: $pgettext('Content/*/Button.Tooltip/Verb', 'Refresh table content'),
selectAllItems: $pgettext('Content/*/Select/Verb', 'Select all items'),
performAction: $pgettext('Content/*/Button.Label', 'Perform actions'),
selectItem: $pgettext('Content/*/Select/Verb', 'Select')
}))
const errors = ref([] as string[])
const isLoading = ref(false)
const result = ref()
const launchAction = async () => {
isLoading.value = true
result.value = undefined
errors.value = []
try {
const response = await axios.post(props.actionUrl, {
action: currentActionName.value,
filters: props.filters,
objects: !selectAll.value
? checked
: 'all'
})
result.value = response.data
emit('action-launched', response.data)
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
</script>
<template>
<div class="table-wrapper component-action-table">
<table class="ui compact very basic unstackable table">
@ -34,8 +192,8 @@
class="ui dropdown"
>
<option
v-for="(action, key) in actions"
:key="key"
v-for="action in actions"
:key="action.name"
:value="action.name"
>
{{ action.label }}
@ -44,9 +202,9 @@
</div>
<div class="field">
<dangerous-button
v-if="selectAll || currentAction.isDangerous"
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
:confirm-color="currentAction.confirmColor || 'success'"
v-if="selectAll || currentAction?.isDangerous"
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
:confirm-color="currentAction?.confirmColor ?? 'success'"
:aria-label="labels.performAction"
@confirm="launchAction"
>
@ -68,8 +226,8 @@
</template>
<template #modal-content>
<p>
<template v-if="currentAction.confirmationMessage">
{{ currentAction.confirmationMessage }}
<template v-if="currentAction?.confirmationMessage">
{{ currentAction?.confirmationMessage }}
</template>
<translate
v-else
@ -89,9 +247,9 @@
</dangerous-button>
<button
v-else
:disabled="checked.length === 0 || null"
:disabled="checked.length === 0"
:aria-label="labels.performAction"
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
@click="launchAction"
>
<translate translate-context="Content/*/Button.Label/Short, Verb">
@ -120,7 +278,7 @@
>
%{ count } on %{ total } selected
</translate>
<template v-if="currentAction.allowAll && checkable.length > 0 && checkable.length === checked.length">
<template v-if="currentAction?.allowAll && checkable.length > 0 && checkable.length === checked.length">
<a
v-if="!selectAll"
href=""
@ -150,7 +308,7 @@
</div>
</div>
<div
v-if="actionErrors.length > 0"
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
@ -161,7 +319,7 @@
</h4>
<ul class="list">
<li
v-for="(error, key) in actionErrors"
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
@ -169,14 +327,14 @@
</ul>
</div>
<div
v-if="actionResult"
v-if="result"
class="ui positive message"
>
<p>
<translate
translate-context="Content/*/Paragraph"
:translate-n="actionResult.updated"
:translate-params="{count: actionResult.updated, action: actionResult.action}"
:translate-n="result.updated"
:translate-params="{count: result.updated, action: result.action}"
translate-plural="Action %{ action } was launched successfully on %{ count } elements"
>
Action %{ action } was launched successfully on %{ count } element
@ -185,7 +343,7 @@
<slot
name="action-success-footer"
:result="actionResult"
:result="result"
/>
</div>
</div>
@ -198,7 +356,7 @@
<input
type="checkbox"
:aria-label="labels.selectAllItems"
:disabled="checkable.length === 0 || null"
:disabled="checkable.length === 0"
:checked="checkable.length > 0 && checked.length === checkable.length"
@change="toggleCheckAll"
>
@ -220,9 +378,9 @@
<input
type="checkbox"
:aria-label="labels.selectItem"
:disabled="checkable.indexOf(getId(obj)) === -1 || null"
:checked="checked.indexOf(getId(obj)) > -1"
@click="toggleCheck($event, getId(obj), index)"
:disabled="checkable.indexOf(obj[idField]) === -1"
:checked="checked.indexOf(obj[idField]) > -1"
@click="toggleCheck($event, obj[idField], index)"
>
</td>
<slot
@ -234,166 +392,3 @@
</table>
</div>
</template>
<script>
import axios from 'axios'
export default {
components: {},
props: {
actionUrl: { type: String, required: false, default: null },
idField: { type: String, required: false, default: 'id' },
refreshable: { type: Boolean, required: false, default: false },
needsRefresh: { type: Boolean, required: false, default: false },
objectsData: { type: Object, required: true },
actions: { type: Array, required: true, default: () => { return [] } },
filters: { type: Object, required: false, default: () => { return {} } },
customObjects: { type: Array, required: false, default: () => { return [] } }
},
data () {
const d = {
checked: [],
actionLoading: false,
actionResult: null,
actionErrors: [],
currentActionName: null,
selectAll: false,
lastCheckedIndex: -1
}
if (this.actions.length > 0) {
d.currentActionName = this.actions[0].name
}
return d
},
computed: {
currentAction () {
const self = this
return this.actions.filter((a) => {
return a.name === self.currentActionName
})[0]
},
checkable () {
const self = this
if (!this.currentAction) {
return []
}
let objs = this.objectsData.results
const filter = this.currentAction.filterCheckable
if (filter) {
objs = objs.filter((o) => {
return filter(o)
})
}
return objs.map((o) => { return self.getId(o) })
},
objects () {
const self = this
return this.objectsData.results.map((o) => {
const custom = self.customObjects.filter((co) => {
return self.getId(co) === self.getId(o)
})[0]
if (custom) {
return custom
}
return o
})
},
labels () {
return {
refresh: this.$pgettext('Content/*/Button.Tooltip/Verb', 'Refresh table content'),
selectAllItems: this.$pgettext('Content/*/Select/Verb', 'Select all items'),
performAction: this.$pgettext('Content/*/Button.Label', 'Perform actions'),
selectItem: this.$pgettext('Content/*/Select/Verb', 'Select')
}
},
affectedObjectsCount () {
if (this.selectAll) {
return this.objectsData.count
}
return this.checked.length
}
},
watch: {
objectsData: {
handler () {
this.checked = []
this.selectAll = false
},
deep: true
},
currentActionName () {
// we update checked status as some actions have specific filters
// on what is checkable or not
const self = this
this.checked = this.checked.filter(r => {
return self.checkable.indexOf(r) > -1
})
}
},
methods: {
toggleCheckAll () {
this.lastCheckedIndex = -1
if (this.checked.length === this.checkable.length) {
// we uncheck
this.checked = []
} else {
this.checked = this.checkable.map(i => { return i })
}
},
toggleCheck (event, id, index) {
const self = this
let affectedIds = [id]
let newValue = null
if (this.checked.indexOf(id) > -1) {
// we uncheck
this.selectAll = false
newValue = false
} else {
newValue = true
}
if (event.shiftKey && this.lastCheckedIndex > -1) {
// we also add inbetween ids to the list of affected ids
const idxs = [index, this.lastCheckedIndex]
idxs.sort((a, b) => a - b)
const objs = this.objectsData.results.slice(idxs[0], idxs[1] + 1)
affectedIds = affectedIds.concat(objs.map((o) => { return o.id }))
}
affectedIds.forEach((i) => {
const checked = self.checked.indexOf(i) > -1
if (newValue && !checked && self.checkable.indexOf(i) > -1) {
return self.checked.push(i)
}
if (!newValue && checked) {
self.checked.splice(self.checked.indexOf(i), 1)
}
})
this.lastCheckedIndex = index
},
launchAction () {
const self = this
self.actionLoading = true
self.result = null
self.actionErrors = []
const payload = {
action: this.currentActionName,
filters: this.filters
}
if (this.selectAll) {
payload.objects = 'all'
} else {
payload.objects = this.checked
}
axios.post(this.actionUrl, payload).then((response) => {
self.actionResult = response.data
self.actionLoading = false
self.$emit('action-launched', response.data)
}, error => {
self.actionLoading = false
self.actionErrors = error.backendErrors
})
},
getId (obj) {
return obj[this.idField]
}
}
}
</script>

View File

@ -1,3 +1,289 @@
<script setup lang="ts">
import type { VueUploadItem } from 'vue-upload-component'
import type { BackendError, Library, FileSystem } from '~/types'
import { computed, ref, reactive, watch, nextTick } from 'vue'
import { useEventListener, useIntervalFn } from '@vueuse/core'
import { humanSize, truncate } from '~/utils/filters'
import { useGettext } from 'vue3-gettext'
import { sortBy } from 'lodash-es'
import { useStore } from '~/store'
import axios from 'axios'
import LibraryFilesTable from '~/views/content/libraries/FilesTable.vue'
import useWebSocketHandler from '~/composables/useWebSocketHandler'
import updateQueryString from '~/composables/updateQueryString'
import FileUploadWidget from './FileUploadWidget.vue'
import FsBrowser from './FsBrowser.vue'
import FsLogs from './FsLogs.vue'
interface Emits {
(e: 'uploads-finished', delta: number):void
}
interface Props {
library: Library
defaultImportReference?: string
}
const emit = defineEmits<Emits>()
const props = withDefaults(defineProps<Props>(), {
defaultImportReference: ''
})
const { $pgettext } = useGettext()
const store = useStore()
const upload = ref()
const currentTab = ref('uploads')
const supportedExtensions = computed(() => store.state.ui.supportedExtensions)
const labels = computed(() => ({
tooltips: {
denied: $pgettext('Content/Library/Help text', 'Upload denied, ensure the file is not too big and that you have not reached your quota'),
server: $pgettext('Content/Library/Help text', 'Cannot upload this file, ensure it is not too big'),
network: $pgettext('Content/Library/Help text', 'A network error occurred while uploading this file'),
timeout: $pgettext('Content/Library/Help text', 'Upload timeout, please try again'),
retry: $pgettext('*/*/*/Verb', 'Retry'),
extension: $pgettext(
'Content/Library/Help text',
'Invalid file type, ensure you are uploading an audio file. Supported file extensions are %{ extensions }',
{ extensions: supportedExtensions.value.join(', ') }
)
}
}))
const uploads = reactive({
pending: 0,
finished: 0,
skipped: 0,
errored: 0,
objects: {} as Record<string, any>
})
//
// File counts
//
const files = ref([] as VueUploadItem[])
const processedFilesCount = computed(() => uploads.skipped + uploads.errored + uploads.finished)
const uploadedFilesCount = computed(() => files.value.filter(file => file.success).length)
const retryableFiles = computed(() => files.value.filter(file => file.error))
const erroredFilesCount = computed(() => retryableFiles.value.length)
const processableFiles = computed(() => uploads.pending
+ uploads.skipped
+ uploads.errored
+ uploads.finished
+ uploadedFilesCount.value
)
//
// Uploading
//
const importReference = ref(props.defaultImportReference || new Date().toISOString())
history.replaceState(history.state, '', updateQueryString(location.href, 'import', importReference.value))
const uploadData = computed(() => ({
library: props.library.uuid,
import_reference: importReference
}))
watch(() => uploads.finished, (newValue, oldValue) => {
if (newValue > oldValue) {
emit('uploads-finished', newValue - oldValue)
}
})
//
// Upload status
//
const fetchStatus = async () => {
for (const status of Object.keys(uploads)) {
if (status === 'objects') continue
try {
const response = await axios.get('uploads/', {
params: {
import_reference: importReference.value,
import_status: status,
page_size: 1
}
})
uploads[status as keyof typeof uploads] = response.data.count
} catch (error) {
// TODO (wvffle): Handle error
}
}
}
fetchStatus()
const needsRefresh = ref(false)
useWebSocketHandler('import.status_updated', async (event) => {
if (event.upload.import_reference !== importReference.value) {
return
}
// TODO (wvffle): Why?
await nextTick()
uploads[event.old_status] -= 1
uploads[event.new_status] += 1
uploads.objects[event.upload.uuid] = event.upload
needsRefresh.value = true
})
//
// Files
//
const sortedFiles = computed(() => {
const filesToSort = files.value
return [
...sortBy(filesToSort.filter(file => file.errored), ['name']),
...sortBy(filesToSort.filter(file => !file.errored && !file.success), ['name']),
...sortBy(filesToSort.filter(file => file.success), ['name'])
]
})
const hasActiveUploads = computed(() => files.value.some(file => file.active))
//
// Quota status
//
const quotaStatus = ref()
const uploadedSize = computed(() => {
let uploaded = 0
for (const file of files.value) {
if (!file.error) {
uploaded += (file.size ?? 0) * +(file.progress ?? 0) / 100
}
}
return uploaded
})
const remainingSpace = computed(() => Math.max(
(quotaStatus.value?.remaining ?? 0) - uploadedSize.value / 1e6,
0
))
watch(remainingSpace, space => {
if (space <= 0) {
upload.value.active = false
}
})
const isLoadingQuota = ref(false)
const fetchQuota = async () => {
isLoadingQuota.value = true
try {
const response = await axios.get('users/me/')
quotaStatus.value = response.data.quota_status
} catch (error) {
// TODO (wvffle): Handle error
}
isLoadingQuota.value = false
}
fetchQuota()
//
// Filesystem
//
const fsPath = reactive([])
const fsStatus = ref({
import: {
status: 'pending'
}
} as FileSystem)
watch(fsPath, () => fetchFilesystem(true))
const { pause, resume } = useIntervalFn(() => {
fetchFilesystem(false)
}, 5000, { immediate: false })
const isLoadingFs = ref(false)
const fetchFilesystem = async (updateLoading: boolean) => {
if (updateLoading) isLoadingFs.value = true
pause()
try {
const response = await axios.get('libraries/fs-import', { params: { path: fsPath.join('/') } })
fsStatus.value = response.data
} catch (error) {
// TODO (wvffle): Handle error
}
if (updateLoading) isLoadingFs.value = false
if (store.state.auth.availablePermissions.library) resume()
}
if (store.state.auth.availablePermissions.library) {
fetchFilesystem(true)
}
const fsErrors = ref([] as string[])
const importFs = async () => {
isLoadingFs.value = true
try {
const response = await axios.post('libraries/fs-import', {
path: fsPath.join('/'),
library: props.library.uuid,
import_reference: importReference.value
})
fsStatus.value = response.data
} catch (error) {
fsErrors.value = (error as BackendError).backendErrors
}
isLoadingFs.value = false
}
// TODO (wvffle): Maybe use AbortController?
const cancelFsScan = async () => {
try {
await axios.delete('libraries/fs-import')
fetchFilesystem(false)
} catch (error) {
// TODO (wvffle): Handle error
}
}
const inputFile = (newFile: VueUploadItem) => {
if (!newFile) return
if (remainingSpace.value < (newFile.size ?? Infinity) / 1e6) {
newFile.error = 'denied'
} else {
upload.value.active = true
}
}
const retry = (files: VueUploadItem[]) => {
for (const file of files) {
upload.value.update(file, { error: '', progress: '0.00' })
}
upload.value.active = true
}
//
// Before unload
//
useEventListener(window, 'beforeunload', (event) => {
if (!hasActiveUploads.value) return null
event.preventDefault()
return (event.returnValue = $pgettext('*/*/*', 'This page is asking you to confirm that you want to leave - data you have entered may not be saved.'))
})
</script>
<template>
<div class="component-file-upload">
<div class="ui top attached tabular menu">
@ -102,7 +388,7 @@
ref="upload"
v-model="files"
:class="['ui', 'icon', 'basic', 'button']"
:post-action="uploadUrl"
:post-action="$store.getters['instance/absoluteUrl']('/api/v1/uploads/')"
:multiple="true"
:data="uploadData"
:drop="true"
@ -117,10 +403,14 @@
</translate>
<br>
<br>
<i><translate
<i>
<translate
translate-context="Content/Library/Paragraph"
:translate-params="{extensions: supportedExtensions.join(', ')}"
>Supported extensions: %{ extensions }</translate></i>
>
Supported extensions: %{ extensions }
</translate>
</i>
</file-upload-widget>
</div>
<div
@ -174,14 +464,14 @@
:key="file.id"
>
<td :title="file.name">
{{ truncate(file.name, 60) }}
{{ truncate(file.name ?? '', 60) }}
</td>
<td>{{ humanSize(file.size) }}</td>
<td>{{ humanSize(file.size ?? 0) }}</td>
<td>
<span
v-if="file.error"
v-if="typeof file.error === 'string' && file.error"
class="ui tooltip"
:data-tooltip="labels.tooltips[file.error]"
:data-tooltip="labels.tooltips[file.error as keyof typeof labels.tooltips]"
>
<span class="ui danger icon label">
<i class="question circle outline icon" /> {{ file.error }}
@ -203,23 +493,29 @@
<translate
key="2"
translate-context="Content/Library/Table"
>Uploading</translate>
({{ parseInt(file.progress) }}%)
>
Uploading
</translate>
({{ parseFloat(file.progress ?? '0.00') }}%)
</span>
<span
v-else
class="ui label"
><translate
>
<translate
key="3"
translate-context="Content/Library/*/Short"
>Pending</translate></span>
>
Pending
</translate>
</span>
</td>
<td>
<template v-if="file.error">
<button
v-if="retryableFiles.indexOf(file) > -1"
v-if="retryableFiles.includes(file)"
class="ui tiny basic icon right floated button"
:title="labels.retry"
:title="labels.tooltips.retry"
@click.prevent="retry([file])"
>
<i class="redo icon" />
@ -228,7 +524,7 @@
<template v-else-if="!file.success">
<button
class="ui tiny basic danger icon right floated button"
@click.prevent="$refs.upload.remove(file)"
@click.prevent="upload.remove(file)"
>
<i class="delete icon" />
</button>
@ -275,7 +571,7 @@
Import status
</translate>
</h3>
<p v-if="fsStatus.import.reference != importReference">
<p v-if="fsStatus.import.reference !== importReference">
<translate translate-context="Content/Library/Paragraph">
Results of your previous import:
</translate>
@ -309,305 +605,3 @@
</div>
</div>
</template>
<script>
import { sortBy, debounce } from 'lodash-es'
import axios from 'axios'
import FileUploadWidget from './FileUploadWidget.vue'
import FsBrowser from './FsBrowser.vue'
import FsLogs from './FsLogs.vue'
import LibraryFilesTable from '~/views/content/libraries/FilesTable.vue'
import moment from 'moment'
import { humanSize, truncate } from '~/utils/filters'
export default {
components: {
FileUploadWidget,
LibraryFilesTable,
FsBrowser,
FsLogs
},
props: {
library: { type: Object, required: true },
defaultImportReference: { type: String, required: false, default: '' }
},
setup () {
return { humanSize, truncate }
},
data () {
const importReference = this.defaultImportReference || moment().format()
// Since $router.replace is pushing the same route, it raises NavigationDuplicated
this.$router.replace({ query: { import: importReference } }).catch((error) => {
if (error.name !== 'NavigationDuplicated') {
throw error
}
})
return {
files: [],
needsRefresh: false,
currentTab: 'uploads',
uploadUrl: this.$store.getters['instance/absoluteUrl']('/api/v1/uploads/'),
importReference,
isLoadingQuota: false,
quotaStatus: null,
uploads: {
pending: 0,
finished: 0,
skipped: 0,
errored: 0,
objects: {}
},
processTimestamp: new Date(),
fsStatus: {},
fsPath: [],
isLoadingFs: false,
fsInterval: null,
fsErrors: []
}
},
computed: {
supportedExtensions () {
return this.$store.state.ui.supportedExtensions
},
labels () {
const denied = this.$pgettext('Content/Library/Help text',
'Upload denied, ensure the file is not too big and that you have not reached your quota'
)
const server = this.$pgettext('Content/Library/Help text',
'Cannot upload this file, ensure it is not too big'
)
const network = this.$pgettext('Content/Library/Help text',
'A network error occurred while uploading this file'
)
const timeout = this.$pgettext('Content/Library/Help text', 'Upload timeout, please try again')
const extension = this.$pgettext('Content/Library/Help text',
'Invalid file type, ensure you are uploading an audio file. Supported file extensions are %{ extensions }'
)
return {
tooltips: {
denied,
server,
network,
timeout,
retry: this.$pgettext('*/*/*/Verb', 'Retry'),
extension: this.$gettextInterpolate(extension, {
extensions: this.supportedExtensions.join(', ')
})
}
}
},
uploadedFilesCount () {
return this.files.filter(f => {
return f.success
}).length
},
uploadingFilesCount () {
return this.files.filter(f => {
return !f.success && !f.error
}).length
},
erroredFilesCount () {
return this.files.filter(f => {
return f.error
}).length
},
retryableFiles () {
return this.files.filter(f => {
return f.error
})
},
processableFiles () {
return (
this.uploads.pending
+ this.uploads.skipped
+ this.uploads.errored
+ this.uploads.finished
+ this.uploadedFilesCount
)
},
processedFilesCount () {
return (
this.uploads.skipped + this.uploads.errored + this.uploads.finished
)
},
uploadData: function () {
return {
library: this.library.uuid,
import_reference: this.importReference
}
},
sortedFiles () {
// return errored files on top
return sortBy(this.files.map(f => {
let statusIndex = 0
if (f.errored) {
statusIndex = -1
}
if (f.success) {
statusIndex = 1
}
f.statusIndex = statusIndex
return f
}), ['statusIndex', 'name'])
},
hasActiveUploads () {
return this.sortedFiles.filter((f) => { return f.active }).length > 0
},
remainingSpace () {
if (!this.quotaStatus) {
return 0
}
return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000)))
},
uploadedSize () {
let uploaded = 0
this.files.forEach((f) => {
if (!f.error) {
uploaded += f.size * (f.progress / 100)
}
})
return uploaded
}
},
watch: {
importReference: debounce(function () {
this.$router.replace({ query: { import: this.importReference } })
}, 500),
remainingSpace (newValue) {
if (newValue <= 0) {
this.$refs.upload.active = false
}
},
'uploads.finished' (v, o) {
if (v > o) {
this.$emit('uploads-finished', v - o)
}
},
'fsPath' () {
this.fetchFs(true)
}
},
created () {
this.fetchStatus()
if (this.$store.state.auth.availablePermissions.library) {
this.fetchFs(true)
this.fsInterval = setInterval(() => {
this.fetchFs(false)
}, 5000)
}
this.fetchQuota()
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'import.status_updated',
id: 'fileUpload',
handler: this.handleImportEvent
})
window.onbeforeunload = e => this.onBeforeUnload(e)
},
unmounted () {
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'import.status_updated',
id: 'fileUpload'
})
window.onbeforeunload = null
if (this.fsInterval) {
clearInterval(this.fsInterval)
}
},
methods: {
onBeforeUnload (e = {}) {
const returnValue = ('This page is asking you to confirm that you want to leave - data you have entered may not be saved.')
if (!this.hasActiveUploads) return null
Object.assign(e, {
returnValue
})
return returnValue
},
fetchQuota () {
const self = this
self.isLoadingQuota = true
axios.get('users/me/').then((response) => {
self.quotaStatus = response.data.quota_status
self.isLoadingQuota = false
})
},
fetchFs (updateLoading) {
const self = this
if (updateLoading) {
self.isLoadingFs = true
}
axios.get('libraries/fs-import', { params: { path: this.fsPath.join('/') } }).then((response) => {
self.fsStatus = response.data
if (updateLoading) {
self.isLoadingFs = false
}
})
},
importFs () {
const self = this
self.isLoadingFs = true
const payload = {
path: this.fsPath.join('/'),
library: this.library.uuid,
import_reference: this.importReference
}
axios.post('libraries/fs-import', payload).then((response) => {
self.fsStatus = response.data
self.isLoadingFs = false
}, error => {
self.isLoadingFs = false
self.fsErrors = error.backendErrors
})
},
async cancelFsScan () {
await axios.delete('libraries/fs-import')
this.fetchFs()
},
inputFile (newFile, oldFile) {
if (!newFile) {
return
}
if (this.remainingSpace < newFile.size / (1000 * 1000)) {
newFile.error = 'denied'
} else {
this.$refs.upload.active = true
}
},
fetchStatus () {
const self = this
const statuses = ['pending', 'errored', 'skipped', 'finished']
statuses.forEach(status => {
axios
.get('uploads/', {
params: {
import_reference: self.importReference,
import_status: status,
page_size: 1
}
})
.then(response => {
self.uploads[status] = response.data.count
})
})
},
handleImportEvent (event) {
const self = this
if (event.upload.import_reference !== self.importReference) {
return
}
this.$nextTick(() => {
self.uploads[event.old_status] -= 1
self.uploads[event.new_status] += 1
self.uploads.objects[event.upload.uuid] = event.upload
self.needsRefresh = true
})
},
retry (files) {
files.forEach((file) => {
this.$refs.upload.update(file, { error: '', progress: '0.00' })
})
this.$refs.upload.active = true
}
}
}
</script>

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import type { VueUploadItem } from 'vue-upload-component'
import FileUpload from 'vue-upload-component'
import { getCookie } from '~/utils'
import { computed, getCurrentInstance } from 'vue'
import { useCookies } from '@vueuse/integrations/useCookies'
import { computed, ref, watch, getCurrentInstance } from 'vue'
import { useStore } from '~/store'
import FileUpload from 'vue-upload-component'
const { get } = useCookies()
const instance = getCurrentInstance()
const attrs = instance?.attrs ?? {}
@ -19,7 +21,7 @@ const headers = computed(() => {
headers.Authorization ??= store.getters['auth/header']
}
const csrf = getCookie('csrftoken')
const csrf = get('csrftoken')
if (csrf) headers['X-CSRFToken'] = csrf
return headers
@ -58,6 +60,21 @@ const uploadAction = async (file: VueUploadItem, self: any): Promise<VueUploadIt
if (file.postAction) return self.uploadHtml4(file)
return Promise.reject(new Error('No action configured'))
}
// NOTE: We need to expose the data and methods that we use
const upload = ref()
const active = ref(false)
watch(active, () => (upload.value.active = active.value))
const update = (file: VueUploadItem, data: Partial<VueUploadItem>) => upload.value.update(file, data)
const remove = (file: VueUploadItem) => upload.value.remove(file)
defineExpose({
active,
update,
remove
})
</script>
<script lang="ts">
@ -66,9 +83,18 @@ export default { inheritAttrs: false }
</script>
<template>
<!-- <component
ref="fileUpload"
:is="FileUpload"
>
<slot />
</component> -->
<file-upload
ref="upload"
v-bind="$attrs"
:custom-action="uploadAction"
:headers="headers"
/>
>
<slot />
</file-upload>
</template>

View File

@ -1,10 +1,58 @@
import type { WebSocketEvent } from '~/types'
import type { WebSocketEventName } from '~/store/ui'
import type { Notification } from '~/types'
import store from '~/store'
import { tryOnScopeDispose } from '@vueuse/core'
export default (eventName: WebSocketEventName, handler: (event: WebSocketEvent) => void) => {
export interface ImportStatusWS {
old_status: 'pending' | 'skipped' | 'finished' | 'errored'
new_status: 'pending' | 'skipped' | 'finished' | 'errored'
upload: {
import_reference: string
uuid: string
}
}
export interface ListenWsEventObject {
local_id: string
}
export interface ListenWS {
actor: ListenWsEventObject
object: ListenWsEventObject
}
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
// export interface ListenWSEvent extends Listening {
// type: 'Listen'
// }
export interface PendingReviewEdits {
pending_review_count: number
}
export interface PendingReviewReports {
unresolved_count: number
}
export interface PendingReviewRequests {
pending_count: number
}
export interface InboxItemAdded {
item: Notification
}
type stopFn = () => void
function useWebSocketHandler (eventName: 'inbox.item_added', handler: (event: InboxItemAdded) => void): stopFn
function useWebSocketHandler (eventName: 'report.created', handler: (event: PendingReviewReports) => void): stopFn
function useWebSocketHandler (eventName: 'mutation.created', handler: (event: PendingReviewEdits) => void): stopFn
function useWebSocketHandler (eventName: 'mutation.updated', handler: (event: PendingReviewEdits) => void): stopFn
function useWebSocketHandler (eventName: 'import.status_updated', handler: (event: ImportStatusWS) => void): stopFn
function useWebSocketHandler (eventName: 'user_request.created', handler: (event: PendingReviewRequests) => void): stopFn
function useWebSocketHandler (eventName: 'Listen', handler: (event: ListenWS) => void): stopFn
function useWebSocketHandler (eventName: string, handler: (event: any) => void): stopFn {
const id = `${+new Date() + Math.random()}`
store.commit('ui/addWebsocketEventHandler', { eventName, handler, id })
@ -15,3 +63,5 @@ export default (eventName: WebSocketEventName, handler: (event: WebSocketEvent)
tryOnScopeDispose(stop)
return stop
}
export default useWebSocketHandler

View File

@ -1,4 +1,4 @@
import type { InitModule, ListenWSEvent, PendingReviewEditsWSEvent, PendingReviewReportsWSEvent, PendingReviewRequestsWSEvent } from '~/types'
import type { InitModule } from '~/types'
import { watchEffect, watch } from 'vue'
import { useWebSocket, whenever } from '@vueuse/core'
@ -37,28 +37,28 @@ export const install: InitModule = ({ store }) => {
useWebSocketHandler('mutation.created', (event) => {
store.commit('ui/incrementNotifications', {
type: 'pendingReviewEdits',
value: (event as PendingReviewEditsWSEvent).pending_review_count
value: event.pending_review_count
})
})
useWebSocketHandler('mutation.updated', (event) => {
store.commit('ui/incrementNotifications', {
type: 'pendingReviewEdits',
value: (event as PendingReviewEditsWSEvent).pending_review_count
value: event.pending_review_count
})
})
useWebSocketHandler('report.created', (event) => {
store.commit('ui/incrementNotifications', {
type: 'pendingReviewReports',
value: (event as PendingReviewReportsWSEvent).unresolved_count
value: event.unresolved_count
})
})
useWebSocketHandler('user_request.created', (event) => {
store.commit('ui/incrementNotifications', {
type: 'pendingReviewRequests',
value: (event as PendingReviewRequestsWSEvent).pending_count
value: event.pending_count
})
})
@ -67,7 +67,7 @@ export const install: InitModule = ({ store }) => {
const { current } = store.state.radios
if (current.clientOnly) {
CLIENT_RADIOS[current.type].handleListen(current, event as ListenWSEvent, store)
CLIENT_RADIOS[current.type].handleListen(current, event, store)
}
}
})

View File

@ -306,7 +306,7 @@ const store: Module<State, RootState> = {
}
},
mutations: {
addWebsocketEventHandler: (state, { eventName, id, handler }: { eventName: WebSocketEventName, id: string, handler: () => void}) => {
addWebsocketEventHandler: (state, { eventName, id, handler }: { eventName: WebSocketEventName, id: string, handler: (event: any) => void}) => {
state.websocketEventsHandlers[eventName][id] = handler
},
removeWebsocketEventHandler: (state, { eventName, id }: { eventName: WebSocketEventName, id: string }) => {

View File

@ -231,37 +231,6 @@ export interface RateLimitStatus {
}
// WebSocket stuff
export interface PendingReviewEditsWSEvent {
pending_review_count: number
}
export interface PendingReviewReportsWSEvent {
unresolved_count: number
}
export interface PendingReviewRequestsWSEvent {
pending_count: number
}
export interface InboxItemAddedWSEvent {
item: Notification
}
export interface ListenWsEventObject {
local_id: string
}
export interface ListenWSEvent {
actor: ListenWsEventObject
object: ListenWsEventObject
}
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
// export interface ListenWSEvent extends Listening {
// type: 'Listen'
// }
export type WebSocketEvent = PendingReviewEditsWSEvent | PendingReviewReportsWSEvent | PendingReviewRequestsWSEvent | ListenWSEvent | InboxItemAddedWSEvent
// FS Browser
export interface FSEntry {
@ -272,6 +241,13 @@ export interface FSEntry {
export interface FileSystem {
root: boolean
content: FSEntry[]
import: FSLogs
}
export interface FSLogs {
status: 'pending' | 'started'
reference: unknown // TODO (wvffle): Find correct type
logs: string[]
}
// Content stuff
@ -312,13 +288,6 @@ export interface Upload {
import_metadata?: Record<string, string>
}
// FileSystem Logs
export interface FSLogs {
status: 'pending' | 'started'
reference: unknown // TODO (wvffle): Find correct type
logs: string[]
}
// Profile stuff
export interface Actor {
id: string

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Notification, InboxItemAddedWSEvent } from '~/types'
import type { Notification } from '~/types'
import axios from 'axios'
import moment from 'moment'
@ -48,7 +48,7 @@ watch(filters, fetchData, { immediate: true })
useWebSocketHandler('inbox.item_added', (event) => {
notifications.count += 1
notifications.results.unshift(markRaw((event as InboxItemAddedWSEvent).item))
notifications.results.unshift(markRaw((event.item)))
})
const instanceSupportMessageDelay = ref(60)