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
|
||||
'no-undef': 'off',
|
||||
'no-redeclare': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-use-before-define': 'off',
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue