Migrate FileUpload component and fix uploading files
This commit is contained in:
parent
1c395c01b0
commit
68f2450c93
|
@ -28,6 +28,7 @@ module.exports = {
|
||||||
|
|
||||||
// NOTE: Handled by typescript
|
// NOTE: Handled by typescript
|
||||||
'no-undef': 'off',
|
'no-undef': 'off',
|
||||||
|
'no-redeclare': 'off',
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'no-use-before-define': 'off',
|
'no-use-before-define': 'off',
|
||||||
|
|
||||||
|
|
|
@ -105,7 +105,7 @@ const uploadedSize = computed(() => {
|
||||||
|
|
||||||
for (const file of uploadedFiles.value) {
|
for (const file of uploadedFiles.value) {
|
||||||
if (file._fileObj && !file.error) {
|
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,
|
response: upload,
|
||||||
__filename: null,
|
__filename: null,
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
progress: '1.00',
|
progress: '100.00',
|
||||||
name: upload.source?.replace('upload://', '') ?? '',
|
name: upload.source?.replace('upload://', '') ?? '',
|
||||||
active: false,
|
active: false,
|
||||||
removed: removed.has(upload.uuid),
|
removed: removed.has(upload.uuid),
|
||||||
|
@ -561,7 +561,7 @@ const labels = computed(() => ({
|
||||||
Pending
|
Pending
|
||||||
</translate>
|
</translate>
|
||||||
· {{ humanSize(file.size ?? 0) }}
|
· {{ humanSize(file.size ?? 0) }}
|
||||||
· {{ parseFloat(file.progress ?? '0') * 100 }}%
|
· {{ parseFloat(file.progress ?? '0') }}%
|
||||||
</template>
|
</template>
|
||||||
· <a @click.stop.prevent="remove(file)">
|
· <a @click.stop.prevent="remove(file)">
|
||||||
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
|
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<div class="table-wrapper component-action-table">
|
<div class="table-wrapper component-action-table">
|
||||||
<table class="ui compact very basic unstackable table">
|
<table class="ui compact very basic unstackable table">
|
||||||
|
@ -34,8 +192,8 @@
|
||||||
class="ui dropdown"
|
class="ui dropdown"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="(action, key) in actions"
|
v-for="action in actions"
|
||||||
:key="key"
|
:key="action.name"
|
||||||
:value="action.name"
|
:value="action.name"
|
||||||
>
|
>
|
||||||
{{ action.label }}
|
{{ action.label }}
|
||||||
|
@ -44,9 +202,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<dangerous-button
|
<dangerous-button
|
||||||
v-if="selectAll || currentAction.isDangerous"
|
v-if="selectAll || currentAction?.isDangerous"
|
||||||
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
|
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
|
||||||
:confirm-color="currentAction.confirmColor || 'success'"
|
:confirm-color="currentAction?.confirmColor ?? 'success'"
|
||||||
:aria-label="labels.performAction"
|
:aria-label="labels.performAction"
|
||||||
@confirm="launchAction"
|
@confirm="launchAction"
|
||||||
>
|
>
|
||||||
|
@ -68,8 +226,8 @@
|
||||||
</template>
|
</template>
|
||||||
<template #modal-content>
|
<template #modal-content>
|
||||||
<p>
|
<p>
|
||||||
<template v-if="currentAction.confirmationMessage">
|
<template v-if="currentAction?.confirmationMessage">
|
||||||
{{ currentAction.confirmationMessage }}
|
{{ currentAction?.confirmationMessage }}
|
||||||
</template>
|
</template>
|
||||||
<translate
|
<translate
|
||||||
v-else
|
v-else
|
||||||
|
@ -89,9 +247,9 @@
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
:disabled="checked.length === 0 || null"
|
:disabled="checked.length === 0"
|
||||||
:aria-label="labels.performAction"
|
:aria-label="labels.performAction"
|
||||||
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
|
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
|
||||||
@click="launchAction"
|
@click="launchAction"
|
||||||
>
|
>
|
||||||
<translate translate-context="Content/*/Button.Label/Short, Verb">
|
<translate translate-context="Content/*/Button.Label/Short, Verb">
|
||||||
|
@ -120,7 +278,7 @@
|
||||||
>
|
>
|
||||||
%{ count } on %{ total } selected
|
%{ count } on %{ total } selected
|
||||||
</translate>
|
</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
|
<a
|
||||||
v-if="!selectAll"
|
v-if="!selectAll"
|
||||||
href=""
|
href=""
|
||||||
|
@ -150,7 +308,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="actionErrors.length > 0"
|
v-if="errors.length > 0"
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
class="ui negative message"
|
||||||
>
|
>
|
||||||
|
@ -161,7 +319,7 @@
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
v-for="(error, key) in actionErrors"
|
v-for="(error, key) in errors"
|
||||||
:key="key"
|
:key="key"
|
||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
|
@ -169,14 +327,14 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="actionResult"
|
v-if="result"
|
||||||
class="ui positive message"
|
class="ui positive message"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
<translate
|
<translate
|
||||||
translate-context="Content/*/Paragraph"
|
translate-context="Content/*/Paragraph"
|
||||||
:translate-n="actionResult.updated"
|
:translate-n="result.updated"
|
||||||
:translate-params="{count: actionResult.updated, action: actionResult.action}"
|
:translate-params="{count: result.updated, action: result.action}"
|
||||||
translate-plural="Action %{ action } was launched successfully on %{ count } elements"
|
translate-plural="Action %{ action } was launched successfully on %{ count } elements"
|
||||||
>
|
>
|
||||||
Action %{ action } was launched successfully on %{ count } element
|
Action %{ action } was launched successfully on %{ count } element
|
||||||
|
@ -185,7 +343,7 @@
|
||||||
|
|
||||||
<slot
|
<slot
|
||||||
name="action-success-footer"
|
name="action-success-footer"
|
||||||
:result="actionResult"
|
:result="result"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -198,7 +356,7 @@
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:aria-label="labels.selectAllItems"
|
:aria-label="labels.selectAllItems"
|
||||||
:disabled="checkable.length === 0 || null"
|
:disabled="checkable.length === 0"
|
||||||
:checked="checkable.length > 0 && checked.length === checkable.length"
|
:checked="checkable.length > 0 && checked.length === checkable.length"
|
||||||
@change="toggleCheckAll"
|
@change="toggleCheckAll"
|
||||||
>
|
>
|
||||||
|
@ -220,9 +378,9 @@
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:aria-label="labels.selectItem"
|
:aria-label="labels.selectItem"
|
||||||
:disabled="checkable.indexOf(getId(obj)) === -1 || null"
|
:disabled="checkable.indexOf(obj[idField]) === -1"
|
||||||
:checked="checked.indexOf(getId(obj)) > -1"
|
:checked="checked.indexOf(obj[idField]) > -1"
|
||||||
@click="toggleCheck($event, getId(obj), index)"
|
@click="toggleCheck($event, obj[idField], index)"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<slot
|
<slot
|
||||||
|
@ -234,166 +392,3 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|
|
@ -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>
|
<template>
|
||||||
<div class="component-file-upload">
|
<div class="component-file-upload">
|
||||||
<div class="ui top attached tabular menu">
|
<div class="ui top attached tabular menu">
|
||||||
|
@ -102,7 +388,7 @@
|
||||||
ref="upload"
|
ref="upload"
|
||||||
v-model="files"
|
v-model="files"
|
||||||
:class="['ui', 'icon', 'basic', 'button']"
|
:class="['ui', 'icon', 'basic', 'button']"
|
||||||
:post-action="uploadUrl"
|
:post-action="$store.getters['instance/absoluteUrl']('/api/v1/uploads/')"
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
:data="uploadData"
|
:data="uploadData"
|
||||||
:drop="true"
|
:drop="true"
|
||||||
|
@ -117,10 +403,14 @@
|
||||||
</translate>
|
</translate>
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
<i><translate
|
<i>
|
||||||
translate-context="Content/Library/Paragraph"
|
<translate
|
||||||
:translate-params="{extensions: supportedExtensions.join(', ')}"
|
translate-context="Content/Library/Paragraph"
|
||||||
>Supported extensions: %{ extensions }</translate></i>
|
:translate-params="{extensions: supportedExtensions.join(', ')}"
|
||||||
|
>
|
||||||
|
Supported extensions: %{ extensions }
|
||||||
|
</translate>
|
||||||
|
</i>
|
||||||
</file-upload-widget>
|
</file-upload-widget>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -174,14 +464,14 @@
|
||||||
:key="file.id"
|
:key="file.id"
|
||||||
>
|
>
|
||||||
<td :title="file.name">
|
<td :title="file.name">
|
||||||
{{ truncate(file.name, 60) }}
|
{{ truncate(file.name ?? '', 60) }}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ humanSize(file.size) }}</td>
|
<td>{{ humanSize(file.size ?? 0) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
v-if="file.error"
|
v-if="typeof file.error === 'string' && file.error"
|
||||||
class="ui tooltip"
|
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">
|
<span class="ui danger icon label">
|
||||||
<i class="question circle outline icon" /> {{ file.error }}
|
<i class="question circle outline icon" /> {{ file.error }}
|
||||||
|
@ -203,23 +493,29 @@
|
||||||
<translate
|
<translate
|
||||||
key="2"
|
key="2"
|
||||||
translate-context="Content/Library/Table"
|
translate-context="Content/Library/Table"
|
||||||
>Uploading…</translate>
|
>
|
||||||
({{ parseInt(file.progress) }}%)
|
Uploading…
|
||||||
|
</translate>
|
||||||
|
({{ parseFloat(file.progress ?? '0.00') }}%)
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="ui label"
|
class="ui label"
|
||||||
><translate
|
>
|
||||||
key="3"
|
<translate
|
||||||
translate-context="Content/Library/*/Short"
|
key="3"
|
||||||
>Pending</translate></span>
|
translate-context="Content/Library/*/Short"
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</translate>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="file.error">
|
<template v-if="file.error">
|
||||||
<button
|
<button
|
||||||
v-if="retryableFiles.indexOf(file) > -1"
|
v-if="retryableFiles.includes(file)"
|
||||||
class="ui tiny basic icon right floated button"
|
class="ui tiny basic icon right floated button"
|
||||||
:title="labels.retry"
|
:title="labels.tooltips.retry"
|
||||||
@click.prevent="retry([file])"
|
@click.prevent="retry([file])"
|
||||||
>
|
>
|
||||||
<i class="redo icon" />
|
<i class="redo icon" />
|
||||||
|
@ -228,7 +524,7 @@
|
||||||
<template v-else-if="!file.success">
|
<template v-else-if="!file.success">
|
||||||
<button
|
<button
|
||||||
class="ui tiny basic danger icon right floated 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" />
|
<i class="delete icon" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -275,7 +571,7 @@
|
||||||
Import status
|
Import status
|
||||||
</translate>
|
</translate>
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="fsStatus.import.reference != importReference">
|
<p v-if="fsStatus.import.reference !== importReference">
|
||||||
<translate translate-context="Content/Library/Paragraph">
|
<translate translate-context="Content/Library/Paragraph">
|
||||||
Results of your previous import:
|
Results of your previous import:
|
||||||
</translate>
|
</translate>
|
||||||
|
@ -309,305 +605,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { VueUploadItem } from 'vue-upload-component'
|
import type { VueUploadItem } from 'vue-upload-component'
|
||||||
|
|
||||||
import FileUpload from 'vue-upload-component'
|
import { useCookies } from '@vueuse/integrations/useCookies'
|
||||||
import { getCookie } from '~/utils'
|
import { computed, ref, watch, getCurrentInstance } from 'vue'
|
||||||
import { computed, getCurrentInstance } from 'vue'
|
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import FileUpload from 'vue-upload-component'
|
||||||
|
|
||||||
|
const { get } = useCookies()
|
||||||
const instance = getCurrentInstance()
|
const instance = getCurrentInstance()
|
||||||
const attrs = instance?.attrs ?? {}
|
const attrs = instance?.attrs ?? {}
|
||||||
|
|
||||||
|
@ -19,7 +21,7 @@ const headers = computed(() => {
|
||||||
headers.Authorization ??= store.getters['auth/header']
|
headers.Authorization ??= store.getters['auth/header']
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrf = getCookie('csrftoken')
|
const csrf = get('csrftoken')
|
||||||
if (csrf) headers['X-CSRFToken'] = csrf
|
if (csrf) headers['X-CSRFToken'] = csrf
|
||||||
|
|
||||||
return headers
|
return headers
|
||||||
|
@ -58,6 +60,21 @@ const uploadAction = async (file: VueUploadItem, self: any): Promise<VueUploadIt
|
||||||
if (file.postAction) return self.uploadHtml4(file)
|
if (file.postAction) return self.uploadHtml4(file)
|
||||||
return Promise.reject(new Error('No action configured'))
|
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>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
@ -66,9 +83,18 @@ export default { inheritAttrs: false }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- <component
|
||||||
|
ref="fileUpload"
|
||||||
|
:is="FileUpload"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component> -->
|
||||||
<file-upload
|
<file-upload
|
||||||
|
ref="upload"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:custom-action="uploadAction"
|
:custom-action="uploadAction"
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
/>
|
>
|
||||||
|
<slot />
|
||||||
|
</file-upload>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,10 +1,58 @@
|
||||||
import type { WebSocketEvent } from '~/types'
|
import type { Notification } from '~/types'
|
||||||
import type { WebSocketEventName } from '~/store/ui'
|
|
||||||
|
|
||||||
import store from '~/store'
|
import store from '~/store'
|
||||||
import { tryOnScopeDispose } from '@vueuse/core'
|
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()}`
|
const id = `${+new Date() + Math.random()}`
|
||||||
store.commit('ui/addWebsocketEventHandler', { eventName, handler, id })
|
store.commit('ui/addWebsocketEventHandler', { eventName, handler, id })
|
||||||
|
|
||||||
|
@ -15,3 +63,5 @@ export default (eventName: WebSocketEventName, handler: (event: WebSocketEvent)
|
||||||
tryOnScopeDispose(stop)
|
tryOnScopeDispose(stop)
|
||||||
return stop
|
return stop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default useWebSocketHandler
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { InitModule, ListenWSEvent, PendingReviewEditsWSEvent, PendingReviewReportsWSEvent, PendingReviewRequestsWSEvent } from '~/types'
|
import type { InitModule } from '~/types'
|
||||||
|
|
||||||
import { watchEffect, watch } from 'vue'
|
import { watchEffect, watch } from 'vue'
|
||||||
import { useWebSocket, whenever } from '@vueuse/core'
|
import { useWebSocket, whenever } from '@vueuse/core'
|
||||||
|
@ -37,28 +37,28 @@ export const install: InitModule = ({ store }) => {
|
||||||
useWebSocketHandler('mutation.created', (event) => {
|
useWebSocketHandler('mutation.created', (event) => {
|
||||||
store.commit('ui/incrementNotifications', {
|
store.commit('ui/incrementNotifications', {
|
||||||
type: 'pendingReviewEdits',
|
type: 'pendingReviewEdits',
|
||||||
value: (event as PendingReviewEditsWSEvent).pending_review_count
|
value: event.pending_review_count
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
useWebSocketHandler('mutation.updated', (event) => {
|
useWebSocketHandler('mutation.updated', (event) => {
|
||||||
store.commit('ui/incrementNotifications', {
|
store.commit('ui/incrementNotifications', {
|
||||||
type: 'pendingReviewEdits',
|
type: 'pendingReviewEdits',
|
||||||
value: (event as PendingReviewEditsWSEvent).pending_review_count
|
value: event.pending_review_count
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
useWebSocketHandler('report.created', (event) => {
|
useWebSocketHandler('report.created', (event) => {
|
||||||
store.commit('ui/incrementNotifications', {
|
store.commit('ui/incrementNotifications', {
|
||||||
type: 'pendingReviewReports',
|
type: 'pendingReviewReports',
|
||||||
value: (event as PendingReviewReportsWSEvent).unresolved_count
|
value: event.unresolved_count
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
useWebSocketHandler('user_request.created', (event) => {
|
useWebSocketHandler('user_request.created', (event) => {
|
||||||
store.commit('ui/incrementNotifications', {
|
store.commit('ui/incrementNotifications', {
|
||||||
type: 'pendingReviewRequests',
|
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
|
const { current } = store.state.radios
|
||||||
|
|
||||||
if (current.clientOnly) {
|
if (current.clientOnly) {
|
||||||
CLIENT_RADIOS[current.type].handleListen(current, event as ListenWSEvent, store)
|
CLIENT_RADIOS[current.type].handleListen(current, event, store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -306,7 +306,7 @@ const store: Module<State, RootState> = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
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
|
state.websocketEventsHandlers[eventName][id] = handler
|
||||||
},
|
},
|
||||||
removeWebsocketEventHandler: (state, { eventName, id }: { eventName: WebSocketEventName, id: string }) => {
|
removeWebsocketEventHandler: (state, { eventName, id }: { eventName: WebSocketEventName, id: string }) => {
|
||||||
|
|
|
@ -231,37 +231,6 @@ export interface RateLimitStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket stuff
|
// 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
|
// FS Browser
|
||||||
export interface FSEntry {
|
export interface FSEntry {
|
||||||
|
@ -272,6 +241,13 @@ export interface FSEntry {
|
||||||
export interface FileSystem {
|
export interface FileSystem {
|
||||||
root: boolean
|
root: boolean
|
||||||
content: FSEntry[]
|
content: FSEntry[]
|
||||||
|
import: FSLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FSLogs {
|
||||||
|
status: 'pending' | 'started'
|
||||||
|
reference: unknown // TODO (wvffle): Find correct type
|
||||||
|
logs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content stuff
|
// Content stuff
|
||||||
|
@ -312,13 +288,6 @@ export interface Upload {
|
||||||
import_metadata?: Record<string, string>
|
import_metadata?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileSystem Logs
|
|
||||||
export interface FSLogs {
|
|
||||||
status: 'pending' | 'started'
|
|
||||||
reference: unknown // TODO (wvffle): Find correct type
|
|
||||||
logs: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profile stuff
|
// Profile stuff
|
||||||
export interface Actor {
|
export interface Actor {
|
||||||
id: string
|
id: string
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Notification, InboxItemAddedWSEvent } from '~/types'
|
import type { Notification } from '~/types'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
@ -48,7 +48,7 @@ watch(filters, fetchData, { immediate: true })
|
||||||
|
|
||||||
useWebSocketHandler('inbox.item_added', (event) => {
|
useWebSocketHandler('inbox.item_added', (event) => {
|
||||||
notifications.count += 1
|
notifications.count += 1
|
||||||
notifications.results.unshift(markRaw((event as InboxItemAddedWSEvent).item))
|
notifications.results.unshift(markRaw((event.item)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const instanceSupportMessageDelay = ref(60)
|
const instanceSupportMessageDelay = ref(60)
|
||||||
|
|
Loading…
Reference in New Issue