kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
				
				
				
			Migrate FileUpload component and fix uploading files
							rodzic
							
								
									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) | ||||
|  |  | |||
		Ładowanie…
	
		Reference in New Issue
	
	 wvffle
						wvffle