feat: add upload groups
This commit is contained in:
parent
048063b50b
commit
db70d7fa07
|
@ -1,4 +1,5 @@
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
|
@ -10,6 +11,39 @@ export default [
|
||||||
path: 'upload',
|
path: 'upload',
|
||||||
name: 'ui.upload',
|
name: 'ui.upload',
|
||||||
component: () => import('~/ui/pages/upload.vue'),
|
component: () => import('~/ui/pages/upload.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'ui.upload.index',
|
||||||
|
component: () => import('~/ui/pages/upload/index.vue')
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'running',
|
||||||
|
name: 'ui.upload.running',
|
||||||
|
component: () => import('~/ui/pages/upload/running.vue'),
|
||||||
|
beforeEnter: (_to, _from, next) => {
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
if (uploads.uploadGroups.length === 0) {
|
||||||
|
next('/ui/upload')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'history',
|
||||||
|
name: 'ui.upload.history',
|
||||||
|
component: () => import('~/ui/pages/upload/history.vue')
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'all',
|
||||||
|
name: 'ui.upload.all',
|
||||||
|
component: () => import('~/ui/pages/upload/all.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
src?: string | { coverUrl?: string }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const coverUrl = computed(() => {
|
||||||
|
if (typeof props.src === 'string') return props.src
|
||||||
|
return props.src?.coverUrl
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cover-art">
|
||||||
|
<Transition mode="out-in">
|
||||||
|
<img v-if="coverUrl" :src="coverUrl" />
|
||||||
|
<Icon v-else icon="bi:disc" />
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.cover-art {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
background: var(--fw-gray-200);
|
||||||
|
color: var(--fw-gray-500);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from,
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -28,7 +28,7 @@ const uploads = useUploadsStore()
|
||||||
<Transition>
|
<Transition>
|
||||||
<div v-if="uploads.currentIndex < uploads.queue.length" class="upload-progress">
|
<div v-if="uploads.currentIndex < uploads.queue.length" class="upload-progress">
|
||||||
<div class="progress fake" />
|
<div class="progress fake" />
|
||||||
<div class="progress" :style="{ maxWidth: `${uploads.currentIndex / uploads.queue.length * 100}%` }" />
|
<div class="progress" :style="{ maxWidth: `${uploads.progress}%` }" />
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</FwButton>
|
</FwButton>
|
||||||
|
|
|
@ -0,0 +1,144 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadGroupEntry } from '~/ui/stores/upload'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
import { UseTimeAgo } from '@vueuse/components'
|
||||||
|
import CoverArt from '~/ui/components/CoverArt.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
uploads: UploadGroupEntry[]
|
||||||
|
wide?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="file-list">
|
||||||
|
<div v-for="track in uploads" :key="track.id" class="list-track" :class="{ wide }">
|
||||||
|
<CoverArt :src="track.metadata" class="track-cover" />
|
||||||
|
<Transition mode="out-in">
|
||||||
|
<div v-if="track.metadata?.tags" class="track-data">
|
||||||
|
<div class="track-title">{{ track.metadata.tags.title }}</div>
|
||||||
|
{{ track.metadata.tags.artist }} / {{ track.metadata.tags.album }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="track-title">
|
||||||
|
{{ track.file.name }}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<div class="upload-state">
|
||||||
|
<FwPill :color="track.failReason ? 'red' : track.importedAt ? 'blue' : 'secondary'">
|
||||||
|
{{
|
||||||
|
track.failReason
|
||||||
|
? 'failed'
|
||||||
|
: track.importedAt
|
||||||
|
? 'imported'
|
||||||
|
: track.progress === 100
|
||||||
|
? 'processing'
|
||||||
|
: 'uploading'
|
||||||
|
}}
|
||||||
|
</FwPill>
|
||||||
|
<div v-if="track.importedAt" class="track-timeago">
|
||||||
|
<UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo>
|
||||||
|
</div>
|
||||||
|
<div v-else class="track-progress">
|
||||||
|
{{ bytesToHumanSize(track.file.size / 100 * track.progress) }}
|
||||||
|
/ {{ bytesToHumanSize(track.file.size) }}
|
||||||
|
⋅ {{ track.progress }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FwButton
|
||||||
|
v-if="track.failReason"
|
||||||
|
@click="track.retry()"
|
||||||
|
icon="bi:arrow-repeat"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
<FwButton
|
||||||
|
v-else
|
||||||
|
icon="bi:chevron-right"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
:is-loading="!track.importedAt"
|
||||||
|
:disabled="!track.importedAt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5rem 0;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-top: 1px solid var(--fw-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :deep(.track-cover) {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-data,
|
||||||
|
.track-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: translateY(-1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.track-timeago,
|
||||||
|
.track-progress {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-state {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 1ch;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
:deep(.funkwhale.pill) {
|
||||||
|
margin-right: -0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.funkwhale.button):not(:hover) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
.upload-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 1rem;
|
||||||
|
|
||||||
|
.track-timeago,
|
||||||
|
.track-progress {
|
||||||
|
order: -1;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,156 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, reactive } from 'vue'
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
import UploadList from '~/ui/components/UploadList.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
|
||||||
|
const libraryOpen = computed({
|
||||||
|
get: () => !!uploads.currentUploadGroup,
|
||||||
|
set: (value) => {
|
||||||
|
if (!value) {
|
||||||
|
uploads.currentUploadGroup = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server import
|
||||||
|
const serverPath = ref('/srv/funkwhale/data/music')
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const queue = computed(() => {
|
||||||
|
return uploads.currentUploadGroup?.queue ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const combinedFileSize = computed(() => bytesToHumanSize(
|
||||||
|
queue.value.reduce((acc, { file }) => acc + file.size, 0)
|
||||||
|
))
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const processFiles = (fileList: FileList) => {
|
||||||
|
if (!uploads.currentUploadGroup) return
|
||||||
|
|
||||||
|
for (const file of fileList) {
|
||||||
|
uploads.currentUploadGroup.queueUpload(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const cancel = () => {
|
||||||
|
libraryOpen.value = false
|
||||||
|
uploads.currentUploadGroup?.cancel()
|
||||||
|
uploads.currentUploadGroup = undefined
|
||||||
|
|
||||||
|
if (uploads.queue.length > 0) {
|
||||||
|
return router.push('/ui/upload/running')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continueInBackground = () => {
|
||||||
|
libraryOpen.value = false
|
||||||
|
uploads.currentUploadGroup = undefined
|
||||||
|
return router.push('/ui/upload/running')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const sortItems = reactive([
|
||||||
|
{ label: 'Upload time', value: 'upload-time' },
|
||||||
|
{ label: 'Upload time 2', value: 'upload-time-2' },
|
||||||
|
{ label: 'Upload time 3', value: 'upload-time-3' }
|
||||||
|
])
|
||||||
|
const currentSort = ref(sortItems[0])
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
const filterItems = reactive([
|
||||||
|
{ label: 'All', value: 'all' }
|
||||||
|
])
|
||||||
|
const currentFilter = ref(filterItems[0])
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FwModal v-model="libraryOpen" title="Upload music to library">
|
||||||
|
<template #alert="{ closeAlert }">
|
||||||
|
<FwAlert>
|
||||||
|
Before uploading, please ensure your files are tagged properly.
|
||||||
|
We recommend using Picard for that purpose.
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<FwButton @click="closeAlert">Got it</FwButton>
|
||||||
|
</template>
|
||||||
|
</FwAlert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<FwFileInput
|
||||||
|
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a']"
|
||||||
|
multiple
|
||||||
|
auto-reset
|
||||||
|
@files="processFiles"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Upload path -->
|
||||||
|
<div v-if="queue.length > 0">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="file-count">
|
||||||
|
{{ queue.length }} files, {{ combinedFileSize }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" />
|
||||||
|
<FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadList :uploads="queue" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import path -->
|
||||||
|
<template v-else>
|
||||||
|
<label>Import from server directory</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<FwInput
|
||||||
|
v-model="serverPath"
|
||||||
|
class="w-full mr-4"
|
||||||
|
/>
|
||||||
|
<FwButton color="secondary">
|
||||||
|
Import
|
||||||
|
</FwButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<FwButton @click="cancel" color="secondary">Cancel</FwButton>
|
||||||
|
<FwButton @click="continueInBackground">
|
||||||
|
{{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
|
||||||
|
</FwButton>
|
||||||
|
</template>
|
||||||
|
</FwModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
|
||||||
|
> .file-count {
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex:not(.flex-col) {
|
||||||
|
.funkwhale.button {
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ open: boolean }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="v-collapse" :class="{ open }">
|
||||||
|
<div class="v-collapse-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.v-collapse {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.2s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-collapse-body {
|
||||||
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const bytesToHumanSize = (bytes: number) => {
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
if (i === 0) return `${bytes} ${sizes[i]}`
|
||||||
|
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
main {
|
main {
|
||||||
padding: 56px 48px;
|
padding: 56px 48px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
max-width: 48rem;
|
max-width: 78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,402 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, computed } from 'vue'
|
||||||
|
import { UseTimeAgo } from '@vueuse/components'
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
|
||||||
|
const filesystemStats = reactive({
|
||||||
|
total: 10737418240,
|
||||||
|
used: 3e9,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filesystemProgress = computed(() => {
|
||||||
|
if (filesystemStats.used === 0) return 0
|
||||||
|
return filesystemStats.used / filesystemStats.total * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: 'Music library',
|
||||||
|
icon: 'headphones',
|
||||||
|
description: 'Host music you listen to.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Music channel',
|
||||||
|
icon: 'music-note-beamed',
|
||||||
|
description: 'Publish music you make.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Podcast channel',
|
||||||
|
icon: 'mic',
|
||||||
|
description: 'Publish podcast you make.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentTab = ref(tabs[0].label)
|
||||||
|
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const libraryOpen = ref(false)
|
||||||
|
|
||||||
|
// Server import
|
||||||
|
const serverPath = ref('/srv/funkwhale/data/music')
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const combinedFileSize = computed(() => bytesToHumanSize(
|
||||||
|
uploads.queue.reduce((acc, { file }) => acc + file.size, 0)
|
||||||
|
))
|
||||||
|
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
const processFiles = (fileList: FileList) => {
|
||||||
|
console.log('processFiles', fileList)
|
||||||
|
for (const file of fileList) {
|
||||||
|
uploads.queueUpload(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
libraryOpen.value = false
|
||||||
|
uploads.cancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const sortItems = reactive([
|
||||||
|
{ label: 'Upload time', value: 'upload-time' },
|
||||||
|
{ label: 'Upload time 2', value: 'upload-time-2' },
|
||||||
|
{ label: 'Upload time 3', value: 'upload-time-3' }
|
||||||
|
])
|
||||||
|
const currentSort = ref(sortItems[0])
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
const filterItems = reactive([
|
||||||
|
{ label: 'All', value: 'all' }
|
||||||
|
])
|
||||||
|
const currentFilter = ref(filterItems[0])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="mr-auto">Upload</h1>
|
||||||
|
|
||||||
|
<div class="filesystem-stats">
|
||||||
|
<div class="filesystem-stats--progress" :style="`--progress: ${filesystemProgress}%`" />
|
||||||
|
<div class="flex items-center">
|
||||||
|
{{ bytesToHumanSize(filesystemStats.total) }} total
|
||||||
|
|
||||||
|
<div class="filesystem-stats--label full" />
|
||||||
|
{{ bytesToHumanSize(filesystemStats.used) }} used
|
||||||
|
|
||||||
|
<div class="filesystem-stats--label" />
|
||||||
|
{{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p> Select a destination for your audio files: </p>
|
||||||
|
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<FwCard
|
||||||
|
v-for="tab in tabs" :key="tab.label"
|
||||||
|
:title="tab.label"
|
||||||
|
:class="currentTab === tab.label && 'active'"
|
||||||
|
@click="currentTab = tab.label"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<div class="image-icon">
|
||||||
|
<Icon :icon="'bi:' + tab.icon" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ tab.description }}
|
||||||
|
<div class="radio-button" />
|
||||||
|
</FwCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FwButton @click="libraryOpen = true">Open library</FwButton>
|
||||||
|
<FwModal v-model="libraryOpen" title="Upload music to library">
|
||||||
|
<template #alert="{ closeAlert }">
|
||||||
|
<FwAlert>
|
||||||
|
Before uploading, please ensure your files are tagged properly.
|
||||||
|
We recommend using Picard for that purpose.
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<FwButton @click="closeAlert">Got it</FwButton>
|
||||||
|
</template>
|
||||||
|
</FwAlert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<FwFileInput
|
||||||
|
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a']"
|
||||||
|
multiple
|
||||||
|
auto-reset
|
||||||
|
@files="processFiles"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Upload path -->
|
||||||
|
<div v-if="uploads.queue.length > 0">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="file-count">
|
||||||
|
{{ uploads.queue.length }} files, {{ combinedFileSize }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" />
|
||||||
|
<FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-list">
|
||||||
|
<div v-for="track in uploads.queue" :key="track.id" class="list-track">
|
||||||
|
<Transition mode="out-in">
|
||||||
|
<div v-if="track.tags" class="track-data">
|
||||||
|
<div class="track-title">{{ track.tags.title }}</div>
|
||||||
|
{{ track.tags.artist }} / {{ track.tags.album }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="track-title">
|
||||||
|
{{ track.file.name }}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<div class="upload-state">
|
||||||
|
<FwPill :color="track.failReason ? 'red' : track.importedAt ? 'blue' : 'secondary'">
|
||||||
|
{{
|
||||||
|
track.failReason
|
||||||
|
? 'failed'
|
||||||
|
: track.importedAt
|
||||||
|
? 'imported'
|
||||||
|
: track.progress === 100
|
||||||
|
? 'processing'
|
||||||
|
: 'uploading'
|
||||||
|
}}
|
||||||
|
</FwPill>
|
||||||
|
<div v-if="track.importedAt" class="track-progress">
|
||||||
|
<UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo>
|
||||||
|
</div>
|
||||||
|
<div v-else class="track-progress">
|
||||||
|
{{ bytesToHumanSize(track.file.size / 100 * track.progress) }}
|
||||||
|
/ {{ bytesToHumanSize(track.file.size) }}
|
||||||
|
⋅ {{ track.progress }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FwButton
|
||||||
|
icon="bi:chevron-right"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
:is-loading="!track.importedAt"
|
||||||
|
:disabled="!track.importedAt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import path -->
|
||||||
|
<template v-else>
|
||||||
|
<label>Import from server directory</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<FwInput
|
||||||
|
v-model="serverPath"
|
||||||
|
class="w-full mr-4"
|
||||||
|
/>
|
||||||
|
<FwButton color="secondary">
|
||||||
|
Import
|
||||||
|
</FwButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<FwButton @click="cancel" color="secondary">Cancel</FwButton>
|
||||||
|
<FwButton @click="libraryOpen = false">
|
||||||
|
{{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
|
||||||
|
</FwButton>
|
||||||
|
</template>
|
||||||
|
</FwModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-family: Lato, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex:not(.flex-col) {
|
||||||
|
.funkwhale.button {
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats {
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
> .flex {
|
||||||
|
padding: 1ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats--progress {
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid var(--fw-gray-600);
|
||||||
|
border-radius: 100vw;
|
||||||
|
padding: 4px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats--label.full::after,
|
||||||
|
.filesystem-stats--progress::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
background: var(--fw-gray-600);
|
||||||
|
border-radius: 100vw;
|
||||||
|
min-width: 4px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: var(--progress, 100);
|
||||||
|
transition: max-width 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats--label {
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid var(--fw-gray-600);
|
||||||
|
border-radius: 100vw;
|
||||||
|
padding: 2px 3px;
|
||||||
|
width: 2em;
|
||||||
|
margin: 0 1ch 0 3ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funkwhale.card {
|
||||||
|
--fw-card-width: 12.5rem;
|
||||||
|
--fw-border-radius: 1rem;
|
||||||
|
padding: 1.3rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px 2px rgba(#000, 0.1);
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
:deep(.card-content) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-icon {
|
||||||
|
background: var(--fw-pastel-blue-1);
|
||||||
|
color: var(--fw-pastel-blue-3);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.funkwhale.card {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
border: 1px solid var(--fw-gray-700);
|
||||||
|
border-radius: 1rem;
|
||||||
|
position: relative;
|
||||||
|
margin: 0.5rem auto 0;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
background: var(--fw-blue-400);
|
||||||
|
border: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 900;
|
||||||
|
margin: 2rem 0 0.75rem;
|
||||||
|
display: block;
|
||||||
|
color: var(--fw-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
|
||||||
|
> .file-count {
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5rem 0;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-top: 1px solid var(--fw-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-data,
|
||||||
|
.track-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: translateY(-1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.track-progress {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-state {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 1ch;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
:deep(.funkwhale.pill) {
|
||||||
|
margin-right: -0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.funkwhale.button):not(:hover) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,8 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
import { UseTimeAgo } from '@vueuse/components'
|
|
||||||
import { Icon } from '@iconify/vue';
|
|
||||||
import { useUploadsStore } from '~/ui/stores/upload'
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
import UploadModal from '~/ui/components/UploadModal.vue'
|
||||||
|
|
||||||
const filesystemStats = reactive({
|
const filesystemStats = reactive({
|
||||||
total: 10737418240,
|
total: 10737418240,
|
||||||
|
@ -14,73 +14,29 @@ const filesystemProgress = computed(() => {
|
||||||
return filesystemStats.used / filesystemStats.total * 100
|
return filesystemStats.used / filesystemStats.total * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
const bytesToHumanSize = (bytes: number) => {
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
||||||
if (bytes === 0) return '0 B'
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
||||||
if (i === 0) return `${bytes} ${sizes[i]}`
|
|
||||||
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
label: 'Music library',
|
|
||||||
icon: 'headphones',
|
|
||||||
description: 'Host music you listen to.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Music channel',
|
|
||||||
icon: 'music-note-beamed',
|
|
||||||
description: 'Publish music you make.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Podcast channel',
|
|
||||||
icon: 'mic',
|
|
||||||
description: 'Publish podcast you make.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const currentTab = ref(tabs[0].label)
|
|
||||||
|
|
||||||
|
|
||||||
// Modals
|
|
||||||
const libraryOpen = ref(false)
|
|
||||||
|
|
||||||
// Server import
|
|
||||||
const serverPath = ref('/srv/funkwhale/data/music')
|
|
||||||
|
|
||||||
// Upload
|
|
||||||
const combinedFileSize = computed(() => bytesToHumanSize(
|
|
||||||
uploads.queue.reduce((acc, { file }) => acc + file.size, 0)
|
|
||||||
))
|
|
||||||
|
|
||||||
const uploads = useUploadsStore()
|
const uploads = useUploadsStore()
|
||||||
const processFiles = (fileList: FileList) => {
|
const tabs = computed(() => [
|
||||||
console.log('processFiles', fileList)
|
{
|
||||||
for (const file of fileList) {
|
label: 'Running',
|
||||||
uploads.queueUpload(file)
|
key: 'running',
|
||||||
}
|
enabled: uploads.uploadGroups.length > 0
|
||||||
|
},
|
||||||
}
|
{
|
||||||
|
label: 'New',
|
||||||
const cancel = () => {
|
key: '',
|
||||||
libraryOpen.value = false
|
enabled: true
|
||||||
uploads.cancelAll()
|
},
|
||||||
}
|
{
|
||||||
|
label: 'History',
|
||||||
// Sorting
|
key: 'history',
|
||||||
const sortItems = reactive([
|
enabled: true
|
||||||
{ label: 'Upload time', value: 'upload-time' },
|
},
|
||||||
{ label: 'Upload time 2', value: 'upload-time-2' },
|
{
|
||||||
{ label: 'Upload time 3', value: 'upload-time-3' }
|
label: 'All files',
|
||||||
])
|
key: 'all',
|
||||||
const currentSort = ref(sortItems[0])
|
enabled: true
|
||||||
|
},
|
||||||
// Filtering
|
].filter(tab => tab.enabled))
|
||||||
const filterItems = reactive([
|
|
||||||
{ label: 'All', value: 'all' }
|
|
||||||
])
|
|
||||||
const currentFilter = ref(filterItems[0])
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -102,132 +58,17 @@ const currentFilter = ref(filterItems[0])
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p> Select a destination for your audio files: </p>
|
<div class="mb-4 -ml-2">
|
||||||
|
<RouterLink v-for="tab in tabs" :key="tab.key" :to="`/ui/upload/${tab.key}`" custom #="{ navigate, isExactActive }">
|
||||||
<div class="flex justify-between">
|
<FwPill @click="navigate" :color="isExactActive ? 'primary' : 'secondary'">
|
||||||
<FwCard
|
{{ tab.label }}
|
||||||
v-for="tab in tabs" :key="tab.label"
|
|
||||||
:title="tab.label"
|
|
||||||
:class="currentTab === tab.label && 'active'"
|
|
||||||
@click="currentTab = tab.label"
|
|
||||||
>
|
|
||||||
<template #image>
|
|
||||||
<div class="image-icon">
|
|
||||||
<Icon :icon="'bi:' + tab.icon" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
{{ tab.description }}
|
|
||||||
<div class="radio-button" />
|
|
||||||
</FwCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FwButton @click="libraryOpen = true">Open library</FwButton>
|
|
||||||
<FwModal v-model="libraryOpen" title="Upload music to library">
|
|
||||||
<template #alert="{ closeAlert }">
|
|
||||||
<FwAlert>
|
|
||||||
Before uploading, please ensure your files are tagged properly.
|
|
||||||
We recommend using Picard for that purpose.
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<FwButton @click="closeAlert">Got it</FwButton>
|
|
||||||
</template>
|
|
||||||
</FwAlert>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<FwFileInput
|
|
||||||
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a']"
|
|
||||||
multiple
|
|
||||||
auto-reset
|
|
||||||
@files="processFiles"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Upload path -->
|
|
||||||
<div v-if="uploads.queue.length > 0">
|
|
||||||
<div class="list-header">
|
|
||||||
<div class="file-count">
|
|
||||||
{{ uploads.queue.length }} files, {{ combinedFileSize }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" />
|
|
||||||
<FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="file-list">
|
|
||||||
<div v-for="track in uploads.queue" :key="track.id" class="list-track">
|
|
||||||
<div class="track-cover">
|
|
||||||
<Transition mode="out-in">
|
|
||||||
<img
|
|
||||||
v-if="track.coverUrl"
|
|
||||||
:src="track.coverUrl"
|
|
||||||
/>
|
|
||||||
<Icon v-else icon="bi:disc" />
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
<Transition mode="out-in">
|
|
||||||
<div v-if="track.tags" class="track-data">
|
|
||||||
<div class="track-title">{{ track.tags.title }}</div>
|
|
||||||
{{ track.tags.artist }} / {{ track.tags.album }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="track-title">
|
|
||||||
{{ track.file.name }}
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
<div class="upload-state">
|
|
||||||
<FwPill :color="track.failReason ? 'red' : track.importedAt ? 'blue' : 'secondary'">
|
|
||||||
{{
|
|
||||||
track.failReason
|
|
||||||
? 'failed'
|
|
||||||
: track.importedAt
|
|
||||||
? 'imported'
|
|
||||||
: track.progress === 100
|
|
||||||
? 'processing'
|
|
||||||
: 'uploading'
|
|
||||||
}}
|
|
||||||
</FwPill>
|
</FwPill>
|
||||||
<div v-if="track.importedAt" class="track-progress">
|
</RouterLink>
|
||||||
<UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo>
|
|
||||||
</div>
|
|
||||||
<div v-else class="track-progress">
|
|
||||||
{{ bytesToHumanSize(track.file.size / 100 * track.progress) }}
|
|
||||||
/ {{ bytesToHumanSize(track.file.size) }}
|
|
||||||
⋅ {{ track.progress }}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FwButton
|
|
||||||
icon="bi:chevron-right"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
:is-loading="!track.importedAt"
|
|
||||||
:disabled="!track.importedAt"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
<RouterView />
|
||||||
|
|
||||||
<!-- Import path -->
|
<UploadModal />
|
||||||
<template v-else>
|
|
||||||
<label>Import from server directory</label>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<FwInput
|
|
||||||
v-model="serverPath"
|
|
||||||
class="w-full mr-4"
|
|
||||||
/>
|
|
||||||
<FwButton color="secondary">
|
|
||||||
Import
|
|
||||||
</FwButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<FwButton @click="cancel" color="secondary">Cancel</FwButton>
|
|
||||||
<FwButton @click="libraryOpen = false">
|
|
||||||
{{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
|
|
||||||
</FwButton>
|
|
||||||
</template>
|
|
||||||
</FwModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import type { UploadGroupEntry } from '~/ui/stores/upload';
|
||||||
|
|
||||||
|
const allTracks: UploadGroupEntry[] = [
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: '', label: '' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="allTracks.length === 0" class="flex flex-col items-center py-32">
|
||||||
|
<Icon icon="bi:file-earmark-music" class="h-16 w-16" />
|
||||||
|
|
||||||
|
<h3>There is no file in your library</h3>
|
||||||
|
<p>Try uploading some before coming back here!</p>
|
||||||
|
</div>
|
||||||
|
<FwTable :rows="allTracks">
|
||||||
|
|
||||||
|
</FwTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
history
|
||||||
|
</template>
|
|
@ -0,0 +1,125 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { useUploadsStore, type UploadGroupType } from '~/ui/stores/upload'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
key: UploadGroupType
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: Tab[] = [
|
||||||
|
{
|
||||||
|
label: 'Music library',
|
||||||
|
icon: 'headphones',
|
||||||
|
description: 'Host music you listen to.',
|
||||||
|
key: 'music-library',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Music channel',
|
||||||
|
icon: 'music-note-beamed',
|
||||||
|
description: 'Publish music you make.',
|
||||||
|
key: 'music-channel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Podcast channel',
|
||||||
|
icon: 'mic',
|
||||||
|
description: 'Publish podcast you make.',
|
||||||
|
key: 'podcast-channel',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const currentTab = ref(tabs[0])
|
||||||
|
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
const openLibrary = () => {
|
||||||
|
uploads.createUploadGroup(currentTab.value.key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="upload">
|
||||||
|
<p> Select a destination for your audio files: </p>
|
||||||
|
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<FwCard
|
||||||
|
v-for="tab in tabs" :key="tab.key"
|
||||||
|
:title="tab.label"
|
||||||
|
:class="currentTab.key === tab.key && 'active'"
|
||||||
|
@click="currentTab = tab"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<div class="image-icon">
|
||||||
|
<Icon :icon="'bi:' + tab.icon" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ tab.description }}
|
||||||
|
<div class="radio-button" />
|
||||||
|
</FwCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FwButton @click="openLibrary">Open library</FwButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.funkwhale.card {
|
||||||
|
--fw-card-width: 12.5rem;
|
||||||
|
--fw-border-radius: 1rem;
|
||||||
|
padding: 1.3rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px 2px rgba(#000, 0.1);
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
|
||||||
|
:deep(.card-content) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
border: 1px solid var(--fw-gray-700);
|
||||||
|
border-radius: 1rem;
|
||||||
|
position: relative;
|
||||||
|
margin: 0.5rem auto 0;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
background: var(--fw-blue-400);
|
||||||
|
border: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-icon {
|
||||||
|
background: var(--fw-pastel-blue-1);
|
||||||
|
color: var(--fw-pastel-blue-3);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload > .funkwhale.button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,182 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { UploadGroup, useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
import VerticalCollapse from '~/ui/components/VerticalCollapse.vue'
|
||||||
|
import UploadList from '~/ui/components/UploadList.vue'
|
||||||
|
import { UseTimeAgo } from '@vueuse/components'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
|
||||||
|
const openUploadGroup = ref<UploadGroup>()
|
||||||
|
const toggle = (group: UploadGroup) => {
|
||||||
|
openUploadGroup.value = openUploadGroup.value === group
|
||||||
|
? undefined
|
||||||
|
: group
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
'music-library': 'Music library',
|
||||||
|
'music-channel': 'Music channel',
|
||||||
|
'podcast-channel': 'Podcast channel',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDescription = (group: UploadGroup) => {
|
||||||
|
if (group.queue.length === 0) return 'Unknown album'
|
||||||
|
|
||||||
|
return group.queue.reduce((acc, { metadata }) => {
|
||||||
|
if (!metadata) return acc
|
||||||
|
|
||||||
|
let element = group.type === 'music-library'
|
||||||
|
? metadata.tags.album
|
||||||
|
: metadata.tags.title
|
||||||
|
|
||||||
|
element = acc.length < 3
|
||||||
|
? element
|
||||||
|
: '...'
|
||||||
|
|
||||||
|
if (!acc.includes(element)) {
|
||||||
|
acc.push(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, [] as string[]).join(', ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="upload-group"
|
||||||
|
v-for="group of uploads.uploadGroups"
|
||||||
|
:key="group.guid"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="upload-group-header">
|
||||||
|
<div class="upload-group-title">{{ labels[group.type] }}</div>
|
||||||
|
<div class="upload-group-albums">{{ getDescription(group) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeago">
|
||||||
|
<UseTimeAgo :time="group.createdAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<FwPill v-if="group.failedCount > 0" color="red">
|
||||||
|
<template #image>
|
||||||
|
<div class="flex items-center justify-center">{{ group.failedCount }}</div>
|
||||||
|
</template>
|
||||||
|
failed
|
||||||
|
</FwPill>
|
||||||
|
|
||||||
|
<FwPill v-if="group.importedCount > 0" color="blue">
|
||||||
|
<template #image>
|
||||||
|
<div class="flex items-center justify-center">{{ group.importedCount }}</div>
|
||||||
|
</template>
|
||||||
|
imported
|
||||||
|
</FwPill>
|
||||||
|
|
||||||
|
<FwPill v-if="group.processingCount > 0" color="secondary">
|
||||||
|
<template #image>
|
||||||
|
<div class="flex items-center justify-center">{{ group.processingCount }}</div>
|
||||||
|
</template>
|
||||||
|
processing
|
||||||
|
</FwPill>
|
||||||
|
|
||||||
|
|
||||||
|
<FwButton
|
||||||
|
@click="toggle(group)"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
class="icon-only"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="bi:chevron-right" :rotate="group === openUploadGroup ? 1 : 0" />
|
||||||
|
</template>
|
||||||
|
</FwButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center upload-progress">
|
||||||
|
<FwButton v-if="group.processingCount === 0 && group.failedCount > 0" @click="group.retry()" color="secondary">Retry</FwButton>
|
||||||
|
<FwButton v-else-if="group.queue.length !== group.importedCount" @click="group.cancel()" color="secondary">Interrupt</FwButton>
|
||||||
|
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar" :style="{ width: `${group.progress}%` }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0">
|
||||||
|
{{ group.importedCount }} / {{ group.queue.length }} files imported
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VerticalCollapse @click.stop :open="openUploadGroup === group" class="collapse">
|
||||||
|
<UploadList :uploads="group.queue" />
|
||||||
|
</VerticalCollapse>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.upload-group {
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-top: 1px solid var(--fw-gray-200);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-group-header {
|
||||||
|
.upload-group-title {
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-group-albums {
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeago {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
|
||||||
|
> :deep(.funkwhale.button) {
|
||||||
|
margin: 0rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :deep(.funkwhale.button) + .progress {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: var(--fw-gray-200);
|
||||||
|
border-radius: 1rem;
|
||||||
|
margin: 0 1rem 0 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--fw-primary);
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: 0;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,8 +1,8 @@
|
||||||
|
|
||||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||||
import { computed, reactive, readonly, ref, markRaw, toRaw, unref } from 'vue'
|
import { computed, reactive, readonly, ref, markRaw, toRaw, unref, watch } from 'vue'
|
||||||
import { whenever, useWebWorker, type UseWebWorkerReturn } from '@vueuse/core'
|
import { whenever, useWebWorker } from '@vueuse/core'
|
||||||
import { not } from '@vueuse/math'
|
import { nanoid } from 'nanoid'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker'
|
import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker'
|
||||||
|
@ -10,113 +10,184 @@ import type { MetadataParsingResult } from '~/ui/workers/file-metadata-parser'
|
||||||
|
|
||||||
import type { Tags } from '~/ui/composables/metadata'
|
import type { Tags } from '~/ui/composables/metadata'
|
||||||
|
|
||||||
interface UploadQueueEntry {
|
export type UploadGroupType = 'music-library' | 'music-channel' | 'podcast-channel'
|
||||||
id: number
|
export type FailReason = 'missing-tags' | 'upload-failed' | 'upload-cancelled'
|
||||||
file: File
|
|
||||||
|
|
||||||
// Upload info
|
export class UploadGroupEntry {
|
||||||
abortController: AbortController
|
id = nanoid()
|
||||||
progress: number
|
abortController = new AbortController()
|
||||||
|
progress = 0
|
||||||
|
|
||||||
// Import info
|
error?: Error
|
||||||
|
failReason?: FailReason
|
||||||
importedAt?: Date
|
importedAt?: Date
|
||||||
|
|
||||||
// Failure info
|
metadata?: {
|
||||||
failReason?: 'missing-tags' | 'upload-failed'
|
tags: Tags,
|
||||||
error?: Error
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
tags?: Tags
|
|
||||||
coverUrl?: string
|
coverUrl?: string
|
||||||
}
|
|
||||||
|
|
||||||
export const useUploadsStore = defineStore('uploads', () => {
|
|
||||||
const uploadQueue: UploadQueueEntry[] = reactive([])
|
|
||||||
const currentIndex = ref(0)
|
|
||||||
const currentUpload = computed(() => uploadQueue[currentIndex.value])
|
|
||||||
const isUploading = computed(() => !!currentUpload.value)
|
|
||||||
|
|
||||||
// Tag extraction with a Web Worker
|
|
||||||
const worker = ref<UseWebWorkerReturn<MetadataParsingResult>>()
|
|
||||||
const retrieveMetadata = (entry: Pick<UploadQueueEntry, 'id' | 'file'>) => {
|
|
||||||
if (!worker.value) worker.value = useWebWorker<MetadataParsingResult>(() => new FileMetadataParserWorker())
|
|
||||||
worker.value.post(entry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
whenever(not(isUploading), () => {
|
constructor (public file: File, public uploadGroup: UploadGroup) {
|
||||||
worker.value?.terminate()
|
UploadGroup.entries[this.id] = this
|
||||||
worker.value = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
whenever(() => worker.value?.data, (reactiveData) => {
|
|
||||||
const data = toRaw(unref(reactiveData))
|
|
||||||
if (data.status === 'success') {
|
|
||||||
const id = data.id
|
|
||||||
const tags = data.tags
|
|
||||||
const coverUrl = data.coverUrl
|
|
||||||
|
|
||||||
uploadQueue[id].tags = markRaw(tags)
|
|
||||||
uploadQueue[id].coverUrl = coverUrl
|
|
||||||
} else {
|
|
||||||
const id = data.id
|
|
||||||
const entry = uploadQueue[id]
|
|
||||||
|
|
||||||
entry.error = data.error
|
|
||||||
entry.failReason = 'missing-tags'
|
|
||||||
entry.importedAt = new Date()
|
|
||||||
entry.abortController.abort()
|
|
||||||
|
|
||||||
console.warn(`Failed to parse metadata for file ${entry.file.name}:`, data.error)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const upload = async (entry: UploadQueueEntry) => {
|
async upload () {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('file', entry.file)
|
body.append('file', this.file)
|
||||||
|
|
||||||
await axios.post('https://httpbin.org/post', body, {
|
await axios.post(this.uploadGroup.uploadUrl, body, {
|
||||||
headers: {
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
'Content-Type': 'multipart/form-data'
|
signal: this.abortController.signal,
|
||||||
},
|
|
||||||
signal: entry.abortController.signal,
|
|
||||||
onUploadProgress: (e) => {
|
onUploadProgress: (e) => {
|
||||||
// NOTE: If e.total is absent, we use the file size instead. This is only an approximation, as e.total is the total size of the request, not just the file.
|
// NOTE: If e.total is absent, we use the file size instead. This is only an approximation, as e.total is the total size of the request, not just the file.
|
||||||
// see: https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/total
|
// see: https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/total
|
||||||
entry.progress = Math.floor(e.loaded / (e.total ?? entry.file.size) * 100)
|
this.progress = Math.floor(e.loaded / (e.total ?? this.file.size) * 100)
|
||||||
|
|
||||||
if (entry.progress === 100) {
|
if (this.progress === 100) {
|
||||||
console.log(`[${entry.id}] upload complete!`)
|
console.log(`[${this.id}] upload complete!`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`[${entry.id}] import complete!`)
|
console.log(`[${this.id}] import complete!`)
|
||||||
entry.importedAt = new Date()
|
this.importedAt = new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueUpload = async (file: File) => {
|
fail (reason: FailReason, error: Error) {
|
||||||
let id = uploadQueue.length
|
this.error = error
|
||||||
uploadQueue.push({
|
this.failReason = reason
|
||||||
id,
|
this.importedAt = new Date()
|
||||||
file,
|
}
|
||||||
progress: 0,
|
|
||||||
abortController: new AbortController()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
cancel (reason: FailReason = 'upload-cancelled', error: Error = new Error('Upload cancelled')) {
|
||||||
|
this.fail(reason, error)
|
||||||
|
this.abortController.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
retry () {
|
||||||
|
this.error = undefined
|
||||||
|
this.failReason = undefined
|
||||||
|
this.importedAt = undefined
|
||||||
|
this.progress = 0
|
||||||
|
this.abortController = new AbortController()
|
||||||
|
|
||||||
|
if (!this.metadata) {
|
||||||
|
this.fail('missing-tags', new Error('Missing metadata'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadQueue.push(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadGroup {
|
||||||
|
static entries = Object.create(null)
|
||||||
|
|
||||||
|
queue: UploadGroupEntry[] = []
|
||||||
|
createdAt = new Date()
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
public guid: string,
|
||||||
|
public type: UploadGroupType,
|
||||||
|
public uploadUrl: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get progress () {
|
||||||
|
return this.queue.reduce((total, entry) => total + entry.progress, 0) / this.queue.length
|
||||||
|
}
|
||||||
|
|
||||||
|
get failedCount () {
|
||||||
|
return this.queue.filter((entry) => entry.failReason).length
|
||||||
|
}
|
||||||
|
|
||||||
|
get importedCount () {
|
||||||
|
return this.queue.filter((entry) => entry.importedAt && !entry.failReason).length
|
||||||
|
}
|
||||||
|
|
||||||
|
get processingCount () {
|
||||||
|
return this.queue.filter((entry) => !entry.importedAt && !entry.failReason).length
|
||||||
|
}
|
||||||
|
|
||||||
|
queueUpload(file: File) {
|
||||||
|
const entry = new UploadGroupEntry(file, this)
|
||||||
|
this.queue.push(entry)
|
||||||
|
|
||||||
|
const { id, metadata } = entry
|
||||||
|
if (!metadata) {
|
||||||
console.log('sending message to worker', id)
|
console.log('sending message to worker', id)
|
||||||
retrieveMetadata({ id, file })
|
retrieveMetadata({ id, file })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploadQueue.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel () {
|
||||||
|
for (const entry of this.queue) {
|
||||||
|
if (entry.importedAt) continue
|
||||||
|
entry.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retry () {
|
||||||
|
for (const entry of this.queue) {
|
||||||
|
if (!entry.failReason) continue
|
||||||
|
entry.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadQueue: UploadGroupEntry[] = reactive([])
|
||||||
|
const uploadGroups: UploadGroup[] = reactive([])
|
||||||
|
const currentUploadGroup = ref<UploadGroup>()
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
|
||||||
|
// Remove the upload group from the list if there are no uploads
|
||||||
|
watch(currentUploadGroup, (_, from) => {
|
||||||
|
if (from && from.queue.length === 0) {
|
||||||
|
const index = uploadGroups.indexOf(from)
|
||||||
|
if (index === -1) return
|
||||||
|
uploadGroups.splice(index, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tag extraction with a Web Worker
|
||||||
|
const { post: retrieveMetadata, data: workerMetadata} = useWebWorker<MetadataParsingResult>(() => new FileMetadataParserWorker())
|
||||||
|
whenever(workerMetadata, (reactiveData) => {
|
||||||
|
const data = toRaw(unref(reactiveData))
|
||||||
|
const entry = UploadGroup.entries[data.id]
|
||||||
|
console.log(data, entry)
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
entry.metadata = {
|
||||||
|
tags: markRaw(data.tags),
|
||||||
|
coverUrl: data.coverUrl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.cancel('missing-tags', data.error)
|
||||||
|
console.warn(`Failed to parse metadata for file ${entry.file.name}:`, data.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useUploadsStore = defineStore('uploads', () => {
|
||||||
|
const createUploadGroup = async (type: UploadGroupType) => {
|
||||||
|
// TODO: API call
|
||||||
|
const uploadGroup = new UploadGroup('guid:' + nanoid(), type, 'https://httpbin.org/post')
|
||||||
|
uploadGroups.push(uploadGroup)
|
||||||
|
currentUploadGroup.value = uploadGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUpload = computed(() => uploadQueue[currentIndex.value])
|
||||||
|
const isUploading = computed(() => !!currentUpload.value)
|
||||||
|
|
||||||
// Upload the file whenever it is available
|
// Upload the file whenever it is available
|
||||||
whenever(currentUpload, (entry) => upload(entry).catch((error) => {
|
whenever(currentUpload, (entry) => entry.upload().catch((error) => {
|
||||||
// The tags were missing, so we have cancelled the upload
|
// The tags were missing, so we have cancelled the upload
|
||||||
if (error.code === 'ERR_CANCELED') {
|
if (error.code === 'ERR_CANCELED') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.error = error
|
entry.fail('upload-failed', error)
|
||||||
entry.failReason = 'upload-failed'
|
|
||||||
entry.importedAt = new Date()
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
// Move to the next upload despite failing
|
// Move to the next upload despite failing
|
||||||
|
@ -131,23 +202,20 @@ export const useUploadsStore = defineStore('uploads', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelAll = () => {
|
const progress = computed(() => {
|
||||||
for (const upload of uploadQueue) {
|
return uploadGroups.reduce((acc, group) => acc + group.progress, 0) / uploadGroups.length
|
||||||
upload.abortController.abort()
|
})
|
||||||
}
|
|
||||||
|
|
||||||
uploadQueue.length = 0
|
|
||||||
currentIndex.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return public API
|
// Return public API
|
||||||
return {
|
return {
|
||||||
isUploading,
|
isUploading,
|
||||||
queueUpload,
|
|
||||||
currentIndex: readonly(currentIndex),
|
currentIndex: readonly(currentIndex),
|
||||||
currentUpload,
|
currentUpload,
|
||||||
cancelAll,
|
queue: readonly(uploadQueue),
|
||||||
queue: readonly(uploadQueue)
|
uploadGroups: uploadGroups,
|
||||||
|
createUploadGroup,
|
||||||
|
currentUploadGroup,
|
||||||
|
progress
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -3,14 +3,14 @@
|
||||||
import { getCoverUrl, getTags, type Tags } from '~/ui/composables/metadata'
|
import { getCoverUrl, getTags, type Tags } from '~/ui/composables/metadata'
|
||||||
|
|
||||||
export interface MetadataParsingSuccess {
|
export interface MetadataParsingSuccess {
|
||||||
id: number
|
id: string
|
||||||
status: 'success'
|
status: 'success'
|
||||||
tags: Tags
|
tags: Tags
|
||||||
coverUrl: string | undefined
|
coverUrl: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetadataParsingFailure {
|
export interface MetadataParsingFailure {
|
||||||
id: number
|
id: string
|
||||||
status: 'failure'
|
status: 'failure'
|
||||||
error: Error
|
error: Error
|
||||||
}
|
}
|
||||||
|
@ -18,32 +18,19 @@ export interface MetadataParsingFailure {
|
||||||
export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure
|
export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure
|
||||||
|
|
||||||
|
|
||||||
const parse = async (id: number, file: File) => {
|
const parse = async (id: string, file: File) => {
|
||||||
try {
|
try {
|
||||||
console.log(`[${id}] parsing...`)
|
console.log(`[${id}] parsing...`)
|
||||||
const tags = await getTags(file)
|
const tags = await getTags(file)
|
||||||
console.log(`[${id}] tags:`, tags)
|
console.log(`[${id}] tags:`, tags)
|
||||||
const coverUrl = await getCoverUrl(tags)
|
const coverUrl = await getCoverUrl(tags)
|
||||||
|
|
||||||
postMessage({
|
postMessage({ id, status: 'success', tags, coverUrl })
|
||||||
id,
|
|
||||||
status: 'success',
|
|
||||||
tags,
|
|
||||||
coverUrl
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
postMessage({
|
postMessage({ id, status: 'failure', error })
|
||||||
id,
|
|
||||||
status: 'failure',
|
|
||||||
error
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = []
|
|
||||||
let queuePromise = Promise.resolve()
|
|
||||||
addEventListener('message', async (event) => {
|
addEventListener('message', async (event) => {
|
||||||
const id = event.data.id as number
|
parse(event.data.id, event.data.file)
|
||||||
const file = event.data.file as File
|
|
||||||
parse(id, file)
|
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue