feat: create router for UI v2
This commit is contained in:
parent
ef19232f2c
commit
4f545cee39
|
@ -22,6 +22,7 @@ const ShortcutsModal = defineAsyncComponent(() => import('~/components/Shortcuts
|
|||
const AudioPlayer = defineAsyncComponent(() => import('~/components/audio/Player.vue'))
|
||||
const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue'))
|
||||
const Queue = defineAsyncComponent(() => import('~/components/Queue.vue'))
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
const logger = useLogger()
|
||||
logger.debug('App setup()')
|
||||
|
@ -79,10 +80,12 @@ const { width } = useWindowSize()
|
|||
// NOTE: We're not checking if we're authenticated in the store,
|
||||
// because we want to learn if we are authenticated at all
|
||||
store.dispatch('auth/fetchUser')
|
||||
|
||||
const isUIv2 = useLocalStorage('ui-v2', false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiApp v-if="route.fullPath.startsWith('/ui')" />
|
||||
<UiApp v-if="isUIv2" />
|
||||
<LegacyLayout v-else />
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { forceInstanceChooser } from './guards'
|
||||
import routes from './routes'
|
||||
|
||||
import routesV1 from './routes'
|
||||
import routesV2 from '~/ui/routes'
|
||||
|
||||
const isUIv2 = useLocalStorage('ui-v2', false)
|
||||
const routes = isUIv2.value ? routesV2 : routesV1
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.VUE_APP_ROUTER_BASE_URL as string ?? '/'),
|
||||
|
|
|
@ -7,11 +7,9 @@ import manage from './manage'
|
|||
import store from '~/store'
|
||||
import auth from './auth'
|
||||
import user from './user'
|
||||
import ui from './ui'
|
||||
import { requireLoggedIn } from '~/router/guards'
|
||||
|
||||
export default [
|
||||
...ui,
|
||||
{
|
||||
path: '/',
|
||||
name: 'index',
|
||||
|
|
|
@ -9,18 +9,18 @@ export default [
|
|||
children: [
|
||||
{
|
||||
path: 'upload',
|
||||
name: 'ui.upload',
|
||||
name: 'upload',
|
||||
component: () => import('~/ui/pages/upload.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ui.upload.index',
|
||||
name: 'upload.index',
|
||||
component: () => import('~/ui/pages/upload/index.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'running',
|
||||
name: 'ui.upload.running',
|
||||
name: 'upload.running',
|
||||
component: () => import('~/ui/pages/upload/running.vue'),
|
||||
beforeEnter: (_to, _from, next) => {
|
||||
const uploads = useUploadsStore()
|
||||
|
@ -34,13 +34,13 @@ export default [
|
|||
|
||||
{
|
||||
path: 'history',
|
||||
name: 'ui.upload.history',
|
||||
name: 'upload.history',
|
||||
component: () => import('~/ui/pages/upload/history.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'all',
|
||||
name: 'ui.upload.all',
|
||||
name: 'upload.all',
|
||||
component: () => import('~/ui/pages/upload/all.vue')
|
||||
}
|
||||
]
|
||||
|
|
|
@ -15,8 +15,14 @@ const coverUrl = computed(() => {
|
|||
<template>
|
||||
<div class="cover-art">
|
||||
<Transition mode="out-in">
|
||||
<img v-if="coverUrl" :src="coverUrl" />
|
||||
<Icon v-else icon="bi:disc" />
|
||||
<img
|
||||
v-if="coverUrl"
|
||||
:src="coverUrl"
|
||||
>
|
||||
<Icon
|
||||
v-else
|
||||
icon="bi:disc"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUploadsStore } from '../stores/upload';
|
||||
import { useUploadsStore } from '../stores/upload'
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
|
@ -19,26 +19,51 @@ const uploads = useUploadsStore()
|
|||
<div class="sticky-content">
|
||||
<nav class="quick-actions">
|
||||
<RouterLink to="/">
|
||||
<img src="../../assets/logo/logo.svg" alt="Logo" class="logo" />
|
||||
<img
|
||||
src="../../assets/logo/logo.svg"
|
||||
alt="Logo"
|
||||
class="logo"
|
||||
>
|
||||
</RouterLink>
|
||||
|
||||
<FwButton icon="bi:wrench" color="secondary" variant="ghost" />
|
||||
<FwButton
|
||||
icon="bi:wrench"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
|
||||
<FwButton icon="bi:upload" color="secondary" variant="ghost" :class="[{ active: route.name === 'ui.upload' }, 'icon-only']">
|
||||
<Transition>
|
||||
<div v-if="uploads.currentIndex < uploads.queue.length" class="upload-progress">
|
||||
<div class="progress fake" />
|
||||
<div class="progress" :style="{ maxWidth: `${uploads.progress}%` }" />
|
||||
</div>
|
||||
</Transition>
|
||||
</FwButton>
|
||||
<RouterLink to="/upload">
|
||||
<FwButton
|
||||
icon="bi:upload"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
:class="[{ active: route.name === 'ui.upload' }, 'icon-only']"
|
||||
>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="uploads.currentIndex < uploads.queue.length"
|
||||
class="upload-progress"
|
||||
>
|
||||
<div class="progress fake" />
|
||||
<div
|
||||
class="progress"
|
||||
:style="{ maxWidth: `${uploads.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</FwButton>
|
||||
</RouterLink>
|
||||
|
||||
<FwButton icon="bi:inbox" color="secondary" variant="ghost" />
|
||||
<FwButton
|
||||
icon="bi:inbox"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
|
||||
<a
|
||||
@click.prevent
|
||||
href=""
|
||||
class="avatar"
|
||||
@click.prevent
|
||||
>
|
||||
<img
|
||||
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop"
|
||||
|
@ -66,24 +91,82 @@ const uploads = useUploadsStore()
|
|||
|
||||
<h3>Explore</h3>
|
||||
<nav class="button-list">
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-compass">All Funkwhale</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-music-note-beamed">Music</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-mic">Podcasts</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-compass"
|
||||
>
|
||||
All Funkwhale
|
||||
</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-music-note-beamed"
|
||||
>
|
||||
Music
|
||||
</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-mic"
|
||||
>
|
||||
Podcasts
|
||||
</FwButton>
|
||||
</nav>
|
||||
|
||||
<h3>Library</h3>
|
||||
<div class="pill-list">
|
||||
<FwPill>Music</FwPill>
|
||||
<FwPill outline>Podcasts</FwPill>
|
||||
<FwPill outline>Sharing</FwPill>
|
||||
<FwPill outline>
|
||||
Podcasts
|
||||
</FwPill>
|
||||
<FwPill outline>
|
||||
Sharing
|
||||
</FwPill>
|
||||
</div>
|
||||
<nav class="button-list">
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-collection">Collections</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-person">Artists</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-disc">Albums</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-music-note-list">Playlists</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-question-diamond">Radios</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-heart">Favorites</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-collection"
|
||||
>
|
||||
Collections
|
||||
</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-person"
|
||||
>
|
||||
Artists
|
||||
</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-disc"
|
||||
>
|
||||
Albums
|
||||
</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-music-note-list"
|
||||
>
|
||||
Playlists
|
||||
</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-question-diamond"
|
||||
>
|
||||
Radios
|
||||
</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
icon="bi-heart"
|
||||
>
|
||||
Favorites
|
||||
</FwButton>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
@ -155,7 +238,6 @@ aside {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
> :first-child {
|
||||
margin-right: auto;
|
||||
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref } from 'vue'
|
||||
import { UploadGroup } 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'
|
||||
|
||||
|
||||
defineProps<{ groups: UploadGroup[], isUploading?: boolean }>()
|
||||
|
||||
const openUploadGroup = ref<UploadGroup>()
|
||||
|
@ -19,7 +18,7 @@ const toggle = (group: UploadGroup) => {
|
|||
const labels = {
|
||||
'music-library': 'Music library',
|
||||
'music-channel': 'Music channel',
|
||||
'podcast-channel': 'Podcast channel',
|
||||
'podcast-channel': 'Podcast channel'
|
||||
}
|
||||
|
||||
const getDescription = (group: UploadGroup) => {
|
||||
|
@ -31,9 +30,9 @@ const getDescription = (group: UploadGroup) => {
|
|||
let element = group.type === 'music-library'
|
||||
? metadata.tags.album
|
||||
: metadata.tags.title
|
||||
|
||||
element = acc.length < 3
|
||||
? element
|
||||
|
||||
element = acc.length < 3
|
||||
? element
|
||||
: '...'
|
||||
|
||||
if (!acc.includes(element)) {
|
||||
|
@ -47,62 +46,105 @@ const getDescription = (group: UploadGroup) => {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="upload-group"
|
||||
v-for="group of groups"
|
||||
<div
|
||||
v-for="group of groups"
|
||||
:key="group.guid"
|
||||
class="upload-group"
|
||||
>
|
||||
<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 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>
|
||||
<UseTimeAgo
|
||||
v-slot="{ timeAgo }"
|
||||
:time="group.createdAt"
|
||||
>
|
||||
{{ timeAgo }}
|
||||
</UseTimeAgo>
|
||||
</div>
|
||||
|
||||
|
||||
<FwPill v-if="group.failedCount > 0" color="red">
|
||||
<FwPill
|
||||
v-if="group.failedCount > 0"
|
||||
color="red"
|
||||
>
|
||||
<template #image>
|
||||
<div class="flex items-center justify-center">{{ group.failedCount }}</div>
|
||||
<div class="flex items-center justify-center">
|
||||
{{ group.failedCount }}
|
||||
</div>
|
||||
</template>
|
||||
failed
|
||||
</FwPill>
|
||||
|
||||
<FwPill v-if="group.importedCount > 0" color="blue">
|
||||
<FwPill
|
||||
v-if="group.importedCount > 0"
|
||||
color="blue"
|
||||
>
|
||||
<template #image>
|
||||
<div class="flex items-center justify-center">{{ group.importedCount }}</div>
|
||||
<div class="flex items-center justify-center">
|
||||
{{ group.importedCount }}
|
||||
</div>
|
||||
</template>
|
||||
imported
|
||||
</FwPill>
|
||||
|
||||
<FwPill v-if="group.processingCount > 0" color="secondary">
|
||||
<FwPill
|
||||
v-if="group.processingCount > 0"
|
||||
color="secondary"
|
||||
>
|
||||
<template #image>
|
||||
<div class="flex items-center justify-center">{{ group.processingCount }}</div>
|
||||
<div class="flex items-center justify-center">
|
||||
{{ group.processingCount }}
|
||||
</div>
|
||||
</template>
|
||||
processing
|
||||
</FwPill>
|
||||
|
||||
|
||||
<FwButton
|
||||
@click="toggle(group)"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
class="icon-only"
|
||||
@click="toggle(group)"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="bi:chevron-right" :rotate="group === openUploadGroup ? 1 : 0" />
|
||||
<Icon
|
||||
icon="bi:chevron-right"
|
||||
:rotate="group === openUploadGroup ? 1 : 0"
|
||||
/>
|
||||
</template>
|
||||
</FwButton>
|
||||
</div>
|
||||
|
||||
<div v-if="isUploading" 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
|
||||
v-if="isUploading"
|
||||
class="flex items-center upload-progress"
|
||||
>
|
||||
<FwButton
|
||||
v-if="group.processingCount === 0 && group.failedCount > 0"
|
||||
color="secondary"
|
||||
@click="group.retry()"
|
||||
>
|
||||
Retry
|
||||
</FwButton>
|
||||
<FwButton
|
||||
v-else-if="group.queue.length !== group.importedCount"
|
||||
color="secondary"
|
||||
@click="group.cancel()"
|
||||
>
|
||||
Interrupt
|
||||
</FwButton>
|
||||
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :style="{ width: `${group.progress}%` }" />
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: `${group.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
|
@ -110,7 +152,11 @@ const getDescription = (group: UploadGroup) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<VerticalCollapse @click.stop :open="openUploadGroup === group" class="collapse">
|
||||
<VerticalCollapse
|
||||
:open="openUploadGroup === group"
|
||||
class="collapse"
|
||||
@click.stop
|
||||
>
|
||||
<UploadList :uploads="group.queue" />
|
||||
</VerticalCollapse>
|
||||
</div>
|
||||
|
@ -144,7 +190,6 @@ const getDescription = (group: UploadGroup) => {
|
|||
color: var(--fw-gray-600);
|
||||
}
|
||||
|
||||
|
||||
.upload-progress {
|
||||
font-size: 0.875rem;
|
||||
color: var(--fw-gray-600);
|
||||
|
|
|
@ -14,28 +14,53 @@ defineProps<{
|
|||
|
||||
<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" />
|
||||
<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>
|
||||
<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">
|
||||
<div
|
||||
v-else
|
||||
class="track-title"
|
||||
>
|
||||
{{ track.file.name }}
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="upload-state">
|
||||
<FwTooltip v-if="track.failReason" :tooltip="track.failReason">
|
||||
<FwTooltip
|
||||
v-if="track.failReason"
|
||||
:tooltip="track.failReason"
|
||||
>
|
||||
<FwPill color="red">
|
||||
<template #image>
|
||||
<Icon icon="bi:question" class="h-4 w-4" />
|
||||
<Icon
|
||||
icon="bi:question"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
|
||||
failed
|
||||
</FwPill>
|
||||
</FwTooltip>
|
||||
<FwPill v-else :color="track.importedAt ? 'blue' : 'secondary'">
|
||||
<FwPill
|
||||
v-else
|
||||
:color="track.importedAt ? 'blue' : 'secondary'"
|
||||
>
|
||||
{{
|
||||
track.importedAt
|
||||
? 'imported'
|
||||
|
@ -44,10 +69,21 @@ defineProps<{
|
|||
: 'uploading'
|
||||
}}
|
||||
</FwPill>
|
||||
<div v-if="track.importedAt" class="track-timeago">
|
||||
<UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo>
|
||||
<div
|
||||
v-if="track.importedAt"
|
||||
class="track-timeago"
|
||||
>
|
||||
<UseTimeAgo
|
||||
v-slot="{ timeAgo }"
|
||||
:time="track.importedAt"
|
||||
>
|
||||
{{ timeAgo }}
|
||||
</UseTimeAgo>
|
||||
</div>
|
||||
<div v-else class="track-progress">
|
||||
<div
|
||||
v-else
|
||||
class="track-progress"
|
||||
>
|
||||
{{ bytesToHumanSize(track.file.size / 100 * track.progress) }}
|
||||
/ {{ bytesToHumanSize(track.file.size) }}
|
||||
⋅ {{ track.progress }}%
|
||||
|
@ -55,10 +91,10 @@ defineProps<{
|
|||
</div>
|
||||
<FwButton
|
||||
v-if="track.failReason"
|
||||
@click="track.retry()"
|
||||
icon="bi:arrow-repeat"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@click="track.retry()"
|
||||
/>
|
||||
<FwButton
|
||||
v-else
|
||||
|
|
|
@ -11,7 +11,7 @@ const libraryOpen = computed({
|
|||
get: () => !!uploads.currentUploadGroup,
|
||||
set: (value) => {
|
||||
if (!value) {
|
||||
uploads.currentUploadGroup = undefined
|
||||
uploads.currentUploadGroup = undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -30,7 +30,7 @@ const combinedFileSize = computed(() => bytesToHumanSize(
|
|||
|
||||
// Actions
|
||||
const processFiles = (fileList: FileList) => {
|
||||
if (!uploads.currentUploadGroup) return
|
||||
if (!uploads.currentUploadGroup) return
|
||||
|
||||
for (const file of fileList) {
|
||||
uploads.currentUploadGroup.queueUpload(file)
|
||||
|
@ -71,14 +71,19 @@ const currentFilter = ref(filterItems[0])
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<FwModal v-model="libraryOpen" title="Upload music to library">
|
||||
<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>
|
||||
<FwButton @click="closeAlert">
|
||||
Got it
|
||||
</FwButton>
|
||||
</template>
|
||||
</FwAlert>
|
||||
</template>
|
||||
|
@ -97,12 +102,19 @@ const currentFilter = ref(filterItems[0])
|
|||
{{ queue.length }} files, {{ combinedFileSize }}
|
||||
</div>
|
||||
|
||||
<FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" />
|
||||
<FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" />
|
||||
<FwSelect
|
||||
v-model="currentFilter"
|
||||
icon="bi:filter"
|
||||
:items="filterItems"
|
||||
/>
|
||||
<FwSelect
|
||||
v-model="currentSort"
|
||||
icon="bi:sort-down"
|
||||
:items="sortItems"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UploadList :uploads="queue" />
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Import path -->
|
||||
|
@ -120,7 +132,12 @@ const currentFilter = ref(filterItems[0])
|
|||
</template>
|
||||
|
||||
<template #actions>
|
||||
<FwButton @click="cancel" color="secondary">Cancel</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</FwButton>
|
||||
<FwButton @click="continueInBackground">
|
||||
{{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
|
||||
</FwButton>
|
||||
|
|
|
@ -3,7 +3,10 @@ defineProps<{ open: boolean }>()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="v-collapse" :class="{ open }">
|
||||
<div
|
||||
class="v-collapse"
|
||||
:class="{ open }"
|
||||
>
|
||||
<div class="v-collapse-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -5,4 +5,3 @@ export const bytesToHumanSize = (bytes: number) => {
|
|||
if (i === 0) return `${bytes} ${sizes[i]}`
|
||||
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ export const getCoverUrl = async (tags: Tags): Promise<string | undefined> => {
|
|||
onerror: () => reject(reader.error)
|
||||
})
|
||||
|
||||
reader.readAsDataURL(new File([picture.data], "", { type: picture.type }))
|
||||
reader.readAsDataURL(new File([picture.data], '', { type: picture.type }))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { UseTimeAgo } from '@vueuse/components'
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useUploadsStore } from '~/ui/stores/upload'
|
||||
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||
|
||||
const filesystemStats = reactive({
|
||||
total: 10737418240,
|
||||
used: 3e9,
|
||||
used: 3e9
|
||||
})
|
||||
|
||||
const filesystemProgress = computed(() => {
|
||||
|
@ -19,7 +19,7 @@ const tabs = [
|
|||
{
|
||||
label: 'Music library',
|
||||
icon: 'headphones',
|
||||
description: 'Host music you listen to.',
|
||||
description: 'Host music you listen to.'
|
||||
},
|
||||
{
|
||||
label: 'Music channel',
|
||||
|
@ -29,13 +29,12 @@ const tabs = [
|
|||
{
|
||||
label: 'Podcast channel',
|
||||
icon: 'mic',
|
||||
description: 'Publish podcast you make.',
|
||||
},
|
||||
description: 'Publish podcast you make.'
|
||||
}
|
||||
]
|
||||
|
||||
const currentTab = ref(tabs[0].label)
|
||||
|
||||
|
||||
// Modals
|
||||
const libraryOpen = ref(false)
|
||||
|
||||
|
@ -53,7 +52,6 @@ const processFiles = (fileList: FileList) => {
|
|||
for (const file of fileList) {
|
||||
uploads.queueUpload(file)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
|
@ -78,10 +76,15 @@ const currentFilter = ref(filterItems[0])
|
|||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<h1 class="mr-auto">Upload</h1>
|
||||
<h1 class="mr-auto">
|
||||
Upload
|
||||
</h1>
|
||||
|
||||
<div class="filesystem-stats">
|
||||
<div class="filesystem-stats--progress" :style="`--progress: ${filesystemProgress}%`" />
|
||||
<div
|
||||
class="filesystem-stats--progress"
|
||||
:style="`--progress: ${filesystemProgress}%`"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
{{ bytesToHumanSize(filesystemStats.total) }} total
|
||||
|
||||
|
@ -92,14 +95,14 @@ const currentFilter = ref(filterItems[0])
|
|||
{{ 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"
|
||||
v-for="tab in tabs"
|
||||
:key="tab.label"
|
||||
:title="tab.label"
|
||||
:class="currentTab === tab.label && 'active'"
|
||||
@click="currentTab = tab.label"
|
||||
|
@ -115,15 +118,22 @@ const currentFilter = ref(filterItems[0])
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<FwButton @click="libraryOpen = true">Open library</FwButton>
|
||||
<FwModal v-model="libraryOpen" title="Upload music to library">
|
||||
<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>
|
||||
<FwButton @click="closeAlert">
|
||||
Got it
|
||||
</FwButton>
|
||||
</template>
|
||||
</FwAlert>
|
||||
</template>
|
||||
|
@ -142,18 +152,38 @@ const currentFilter = ref(filterItems[0])
|
|||
{{ 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" />
|
||||
<FwSelect
|
||||
v-model="currentFilter"
|
||||
icon="bi:filter"
|
||||
:items="filterItems"
|
||||
/>
|
||||
<FwSelect
|
||||
v-model="currentSort"
|
||||
icon="bi:sort-down"
|
||||
:items="sortItems"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="file-list">
|
||||
<div v-for="track in uploads.queue" :key="track.id" class="list-track">
|
||||
<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>
|
||||
<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">
|
||||
<div
|
||||
v-else
|
||||
class="track-title"
|
||||
>
|
||||
{{ track.file.name }}
|
||||
</div>
|
||||
</Transition>
|
||||
|
@ -169,10 +199,21 @@ const currentFilter = ref(filterItems[0])
|
|||
: 'uploading'
|
||||
}}
|
||||
</FwPill>
|
||||
<div v-if="track.importedAt" class="track-progress">
|
||||
<UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo>
|
||||
<div
|
||||
v-if="track.importedAt"
|
||||
class="track-progress"
|
||||
>
|
||||
<UseTimeAgo
|
||||
v-slot="{ timeAgo }"
|
||||
:time="track.importedAt"
|
||||
>
|
||||
{{ timeAgo }}
|
||||
</UseTimeAgo>
|
||||
</div>
|
||||
<div v-else class="track-progress">
|
||||
<div
|
||||
v-else
|
||||
class="track-progress"
|
||||
>
|
||||
{{ bytesToHumanSize(track.file.size / 100 * track.progress) }}
|
||||
/ {{ bytesToHumanSize(track.file.size) }}
|
||||
⋅ {{ track.progress }}%
|
||||
|
@ -187,7 +228,6 @@ const currentFilter = ref(filterItems[0])
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Import path -->
|
||||
|
@ -205,7 +245,12 @@ const currentFilter = ref(filterItems[0])
|
|||
</template>
|
||||
|
||||
<template #actions>
|
||||
<FwButton @click="cancel" color="secondary">Cancel</FwButton>
|
||||
<FwButton
|
||||
color="secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</FwButton>
|
||||
<FwButton @click="libraryOpen = false">
|
||||
{{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
|
||||
</FwButton>
|
||||
|
|
|
@ -6,7 +6,7 @@ import UploadModal from '~/ui/components/UploadModal.vue'
|
|||
|
||||
const filesystemStats = reactive({
|
||||
total: 10737418240,
|
||||
used: 3e9,
|
||||
used: 3e9
|
||||
})
|
||||
|
||||
const filesystemProgress = computed(() => {
|
||||
|
@ -35,16 +35,21 @@ const tabs = computed(() => [
|
|||
label: 'All files',
|
||||
key: 'all',
|
||||
enabled: true
|
||||
},
|
||||
}
|
||||
].filter(tab => tab.enabled))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<h1 class="mr-auto">Upload</h1>
|
||||
<h1 class="mr-auto">
|
||||
Upload
|
||||
</h1>
|
||||
|
||||
<div class="filesystem-stats">
|
||||
<div class="filesystem-stats--progress" :style="`--progress: ${filesystemProgress}%`" />
|
||||
<div
|
||||
class="filesystem-stats--progress"
|
||||
:style="`--progress: ${filesystemProgress}%`"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
{{ bytesToHumanSize(filesystemStats.total) }} total
|
||||
|
||||
|
@ -55,12 +60,20 @@ const tabs = computed(() => [
|
|||
{{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mb-4 -ml-2">
|
||||
<RouterLink v-for="tab in tabs" :key="tab.key" :to="`/ui/upload/${tab.key}`" custom #="{ navigate, isExactActive }">
|
||||
<FwPill @click="navigate" :color="isExactActive ? 'primary' : 'secondary'">
|
||||
<RouterLink
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:to="`/ui/upload/${tab.key}`"
|
||||
custom
|
||||
#="{ navigate, isExactActive }"
|
||||
>
|
||||
<FwPill
|
||||
:color="isExactActive ? 'primary' : 'secondary'"
|
||||
@click="navigate"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</FwPill>
|
||||
</RouterLink>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { computed } from 'vue';
|
||||
import { bytesToHumanSize } from '~/ui/composables/bytes';
|
||||
import { useUploadsStore, type UploadGroupEntry } from '~/ui/stores/upload';
|
||||
import { computed } from 'vue'
|
||||
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||
import { useUploadsStore, type UploadGroupEntry } from '~/ui/stores/upload'
|
||||
import CoverArt from '~/ui/components/CoverArt.vue'
|
||||
|
||||
interface Recording {
|
||||
|
@ -49,20 +49,30 @@ const columns = [
|
|||
</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" />
|
||||
<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 v-else
|
||||
id-key="guid"
|
||||
:columns="columns"
|
||||
<FwTable
|
||||
v-else
|
||||
id-key="guid"
|
||||
:columns="columns"
|
||||
:rows="allTracks"
|
||||
>
|
||||
<template #col-title="{ row, value }">
|
||||
<div class="flex items-center">
|
||||
<CoverArt :src="row.metadata" class="mr-2" />
|
||||
<CoverArt
|
||||
:src="row.metadata"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ value }}
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useUploadsStore, type UploadGroupType } from '~/ui/stores/upload'
|
||||
import { ref } from 'vue'
|
||||
|
||||
|
@ -15,23 +15,22 @@ const tabs: Tab[] = [
|
|||
label: 'Music library',
|
||||
icon: 'headphones',
|
||||
description: 'Host music you listen to.',
|
||||
key: 'music-library',
|
||||
key: 'music-library'
|
||||
},
|
||||
{
|
||||
label: 'Music channel',
|
||||
icon: 'music-note-beamed',
|
||||
description: 'Publish music you make.',
|
||||
key: 'music-channel',
|
||||
key: 'music-channel'
|
||||
},
|
||||
{
|
||||
label: 'Podcast channel',
|
||||
icon: 'mic',
|
||||
description: 'Publish podcast you make.',
|
||||
key: 'podcast-channel',
|
||||
},
|
||||
key: 'podcast-channel'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
const currentTab = ref(tabs[0])
|
||||
|
||||
const uploads = useUploadsStore()
|
||||
|
@ -46,7 +45,8 @@ const openLibrary = () => {
|
|||
|
||||
<div class="flex gap-8">
|
||||
<FwCard
|
||||
v-for="tab in tabs" :key="tab.key"
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:title="tab.label"
|
||||
:class="currentTab.key === tab.key && 'active'"
|
||||
@click="currentTab = tab"
|
||||
|
@ -61,7 +61,9 @@ const openLibrary = () => {
|
|||
</FwCard>
|
||||
</div>
|
||||
|
||||
<FwButton @click="openLibrary">Open library</FwButton>
|
||||
<FwButton @click="openLibrary">
|
||||
Open library
|
||||
</FwButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -5,5 +5,8 @@ const uploads = useUploadsStore()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<UploadGroupList :groups="uploads.uploadGroups" :is-uploading="true" />
|
||||
<UploadGroupList
|
||||
:groups="uploads.uploadGroups"
|
||||
:is-uploading="true"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { requireLoggedOut, requireLoggedIn } from '~/router/guards'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
component: () => import('~/views/auth/Login.vue'),
|
||||
props: route => ({ next: route.query.next || '/library' }),
|
||||
beforeEnter: requireLoggedOut({ name: 'library.index' })
|
||||
},
|
||||
{
|
||||
path: 'auth/password/reset',
|
||||
name: 'auth.password-reset',
|
||||
component: () => import('~/views/auth/PasswordReset.vue'),
|
||||
props: route => ({ defaultEmail: route.query.email })
|
||||
},
|
||||
{
|
||||
path: 'auth/callback',
|
||||
name: 'auth.callback',
|
||||
component: () => import('~/views/auth/Callback.vue'),
|
||||
props: route => ({
|
||||
code: route.query.code,
|
||||
state: route.query.state
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'auth/email/confirm',
|
||||
name: 'auth.email-confirm',
|
||||
component: () => import('~/views/auth/EmailConfirm.vue'),
|
||||
props: route => ({ defaultKey: route.query.key })
|
||||
},
|
||||
{
|
||||
path: 'auth/password/reset/confirm',
|
||||
name: 'auth.password-reset-confirm',
|
||||
component: () => import('~/views/auth/PasswordResetConfirm.vue'),
|
||||
props: route => ({
|
||||
defaultUid: route.query.uid,
|
||||
defaultToken: route.query.token
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'authorize',
|
||||
name: 'authorize',
|
||||
component: () => import('~/components/auth/Authorize.vue'),
|
||||
props: route => ({
|
||||
clientId: route.query.client_id,
|
||||
redirectUri: route.query.redirect_uri,
|
||||
scope: route.query.scope,
|
||||
responseType: route.query.response_type,
|
||||
nonce: route.query.nonce,
|
||||
state: route.query.state
|
||||
}),
|
||||
beforeEnter: requireLoggedIn()
|
||||
},
|
||||
{
|
||||
path: 'signup',
|
||||
name: 'signup',
|
||||
component: () => import('~/views/auth/Signup.vue'),
|
||||
props: route => ({ defaultInvitation: route.query.invitation })
|
||||
},
|
||||
{
|
||||
path: 'logout',
|
||||
name: 'logout',
|
||||
component: () => import('~/components/auth/Logout.vue')
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,41 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: 'content',
|
||||
component: () => import('~/views/content/Base.vue'),
|
||||
children: [{
|
||||
path: '',
|
||||
name: 'content.index',
|
||||
component: () => import('~/views/content/Home.vue')
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: 'content/libraries/tracks',
|
||||
component: () => import('~/views/content/Base.vue'),
|
||||
children: [{
|
||||
path: '',
|
||||
name: 'content.libraries.files',
|
||||
component: () => import('~/views/content/libraries/Files.vue'),
|
||||
props: route => ({ query: route.query.q })
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: 'content/libraries',
|
||||
component: () => import('~/views/content/Base.vue'),
|
||||
children: [{
|
||||
path: '',
|
||||
name: 'content.libraries.index',
|
||||
component: () => import('~/views/content/libraries/Home.vue')
|
||||
}]
|
||||
},
|
||||
{
|
||||
path: 'content/remote',
|
||||
component: () => import('~/views/content/Base.vue'),
|
||||
children: [{
|
||||
path: '',
|
||||
name: 'content.remote.index',
|
||||
component: () => import('~/views/content/remote/Home.vue')
|
||||
}]
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,139 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import settings from './settings'
|
||||
import library from './library'
|
||||
import content from './content'
|
||||
import manage from './manage'
|
||||
import auth from './auth'
|
||||
import user from './user'
|
||||
import store from '~/store'
|
||||
import { requireLoggedIn } from '~/router/guards'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/',
|
||||
name: 'root',
|
||||
component: () => import('~/ui/layouts/constrained.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'index',
|
||||
component: () => import('~/components/Home.vue'),
|
||||
beforeEnter (to, from, next) {
|
||||
if (store.state.auth.authenticated) return next('/library')
|
||||
return next()
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/index.html',
|
||||
redirect: to => {
|
||||
const { hash, query } = to
|
||||
return { name: 'index', hash, query }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
name: 'upload',
|
||||
component: () => import('~/ui/pages/upload.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'upload.index',
|
||||
component: () => import('~/ui/pages/upload/index.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'running',
|
||||
name: '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: 'upload.history',
|
||||
component: () => import('~/ui/pages/upload/history.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'all',
|
||||
name: 'upload.all',
|
||||
component: () => import('~/ui/pages/upload/all.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'about',
|
||||
component: () => import('~/components/About.vue')
|
||||
},
|
||||
{
|
||||
// TODO (wvffle): Make it a child of /about to have the active style on the sidebar link
|
||||
path: 'about/pod',
|
||||
name: 'about-pod',
|
||||
component: () => import('~/components/AboutPod.vue')
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('~/views/Notifications.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('~/views/Search.vue')
|
||||
},
|
||||
...auth,
|
||||
...settings,
|
||||
...user,
|
||||
{
|
||||
path: 'favorites',
|
||||
name: 'favorites',
|
||||
component: () => import('~/components/favorites/List.vue'),
|
||||
props: route => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultPage: route.query.page ? +route.query.page : undefined
|
||||
}),
|
||||
beforeEnter: requireLoggedIn()
|
||||
},
|
||||
...content,
|
||||
...manage,
|
||||
...library,
|
||||
{
|
||||
path: 'channels/:id',
|
||||
props: true,
|
||||
component: () => import('~/views/channels/DetailBase.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'channels.detail',
|
||||
component: () => import('~/views/channels/DetailOverview.vue')
|
||||
},
|
||||
{
|
||||
path: 'episodes',
|
||||
name: 'channels.detail.episodes',
|
||||
component: () => import('~/views/channels/DetailEpisodes.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'subscriptions',
|
||||
name: 'subscriptions',
|
||||
component: () => import('~/views/channels/SubscriptionsList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q })
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: '404',
|
||||
component: () => import('~/components/PageNotFound.vue')
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,238 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: 'library',
|
||||
component: () => import('~/components/library/Library.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('~/components/library/Home.vue'),
|
||||
name: 'library.index'
|
||||
},
|
||||
{
|
||||
path: 'me',
|
||||
component: () => import('~/components/library/Home.vue'),
|
||||
name: 'library.me',
|
||||
props: () => ({ scope: 'me' })
|
||||
},
|
||||
{
|
||||
path: 'artists/',
|
||||
name: 'library.artists.browse',
|
||||
component: () => import('~/components/library/Artists.vue'),
|
||||
meta: {
|
||||
paginateBy: 30
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'me/artists',
|
||||
name: 'library.artists.me',
|
||||
component: () => import('~/components/library/Artists.vue'),
|
||||
props: { scope: 'me' },
|
||||
meta: {
|
||||
paginateBy: 30
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'albums/',
|
||||
name: 'library.albums.browse',
|
||||
component: () => import('~/components/library/Albums.vue'),
|
||||
meta: {
|
||||
paginateBy: 25
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'me/albums',
|
||||
name: 'library.albums.me',
|
||||
component: () => import('~/components/library/Albums.vue'),
|
||||
props: { scope: 'me' },
|
||||
meta: {
|
||||
paginateBy: 25
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'podcasts/',
|
||||
name: 'library.podcasts.browse',
|
||||
component: () => import('~/components/library/Podcasts.vue'),
|
||||
meta: {
|
||||
paginateBy: 30
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'radios/',
|
||||
name: 'library.radios.browse',
|
||||
component: () => import('~/components/library/Radios.vue'),
|
||||
meta: {
|
||||
paginateBy: 12
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'me/radios/',
|
||||
name: 'library.radios.me',
|
||||
component: () => import('~/components/library/Radios.vue'),
|
||||
props: { scope: 'me' },
|
||||
meta: {
|
||||
paginateBy: 12
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'radios/build',
|
||||
name: 'library.radios.build',
|
||||
component: () => import('~/components/library/radios/Builder.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'radios/build/:id',
|
||||
name: 'library.radios.edit',
|
||||
component: () => import('~/components/library/radios/Builder.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'radios/:id',
|
||||
name: 'library.radios.detail',
|
||||
component: () => import('~/views/radios/Detail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'playlists/',
|
||||
name: 'library.playlists.browse',
|
||||
component: () => import('~/views/playlists/List.vue'),
|
||||
meta: {
|
||||
paginateBy: 25
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'me/playlists/',
|
||||
name: 'library.playlists.me',
|
||||
component: () => import('~/views/playlists/List.vue'),
|
||||
props: { scope: 'me' },
|
||||
meta: {
|
||||
paginateBy: 25
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id',
|
||||
name: 'library.playlists.detail',
|
||||
component: () => import('~/views/playlists/Detail.vue'),
|
||||
props: route => ({
|
||||
id: route.params.id,
|
||||
defaultEdit: route.query.mode === 'edit'
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'tags/:id',
|
||||
name: 'library.tags.detail',
|
||||
component: () => import('~/components/library/TagDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'artists/:id',
|
||||
component: () => import('~/components/library/ArtistBase.vue'),
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'library.artists.detail',
|
||||
component: () => import('~/components/library/ArtistDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit',
|
||||
name: 'library.artists.edit',
|
||||
component: () => import('~/components/library/ArtistEdit.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit/:editId',
|
||||
name: 'library.artists.edit.detail',
|
||||
component: () => import('~/components/library/EditDetail.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'albums/:id',
|
||||
component: () => import('~/components/library/AlbumBase.vue'),
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'library.albums.detail',
|
||||
component: () => import('~/components/library/AlbumDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit',
|
||||
name: 'library.albums.edit',
|
||||
component: () => import('~/components/library/AlbumEdit.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit/:editId',
|
||||
name: 'library.albums.edit.detail',
|
||||
component: () => import('~/components/library/EditDetail.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'tracks/:id',
|
||||
component: () => import('~/components/library/TrackBase.vue'),
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'library.tracks.detail',
|
||||
component: () => import('~/components/library/TrackDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit',
|
||||
name: 'library.tracks.edit',
|
||||
component: () => import('~/components/library/TrackEdit.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit/:editId',
|
||||
name: 'library.tracks.edit.detail',
|
||||
component: () => import('~/components/library/EditDetail.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'uploads/:id',
|
||||
name: 'library.uploads.detail',
|
||||
props: true,
|
||||
component: () => import('~/components/library/UploadDetail.vue')
|
||||
},
|
||||
{
|
||||
// browse a single library via it's uuid
|
||||
path: ':id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})',
|
||||
props: true,
|
||||
component: () => import('~/views/library/LibraryBase.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'library.detail',
|
||||
component: () => import('~/views/library/DetailOverview.vue')
|
||||
},
|
||||
{
|
||||
path: 'albums',
|
||||
name: 'library.detail.albums',
|
||||
component: () => import('~/views/library/DetailAlbums.vue')
|
||||
},
|
||||
{
|
||||
path: 'tracks',
|
||||
name: 'library.detail.tracks',
|
||||
component: () => import('~/views/library/DetailTracks.vue')
|
||||
},
|
||||
{
|
||||
path: 'edit',
|
||||
name: 'library.detail.edit',
|
||||
component: () => import('~/views/library/Edit.vue')
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
name: 'library.detail.upload',
|
||||
redirect: () => '/upload'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,188 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { hasPermissions } from '~/router/guards'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: 'manage/settings',
|
||||
name: 'manage.settings',
|
||||
beforeEnter: hasPermissions('settings'),
|
||||
component: () => import('~/views/admin/Settings.vue')
|
||||
},
|
||||
{
|
||||
path: 'manage/library',
|
||||
beforeEnter: hasPermissions('library'),
|
||||
component: () => import('~/views/admin/library/Base.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'edits',
|
||||
name: 'manage.library.edits',
|
||||
component: () => import('~/views/admin/library/EditsList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q })
|
||||
},
|
||||
{
|
||||
path: 'artists',
|
||||
name: 'manage.library.artists',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'artists' })
|
||||
},
|
||||
{
|
||||
path: 'artists/:id',
|
||||
name: 'manage.library.artists.detail',
|
||||
component: () => import('~/views/admin/library/ArtistDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'channels',
|
||||
name: 'manage.channels',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'channels' })
|
||||
},
|
||||
{
|
||||
path: 'channels/:id',
|
||||
name: 'manage.channels.detail',
|
||||
component: () => import('~/views/admin/ChannelDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'albums',
|
||||
name: 'manage.library.albums',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'albums' })
|
||||
},
|
||||
{
|
||||
path: 'albums/:id',
|
||||
name: 'manage.library.albums.detail',
|
||||
component: () => import('~/views/admin/library/AlbumDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tracks',
|
||||
name: 'manage.library.tracks',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'tracks' })
|
||||
},
|
||||
{
|
||||
path: 'tracks/:id',
|
||||
name: 'manage.library.tracks.detail',
|
||||
component: () => import('~/views/admin/library/TrackDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'libraries',
|
||||
name: 'manage.library.libraries',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'libraries' })
|
||||
},
|
||||
{
|
||||
path: 'libraries/:id',
|
||||
name: 'manage.library.libraries.detail',
|
||||
component: () => import('~/views/admin/library/LibraryDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'uploads',
|
||||
name: 'manage.library.uploads',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'uploads' })
|
||||
},
|
||||
{
|
||||
path: 'uploads/:id',
|
||||
name: 'manage.library.uploads.detail',
|
||||
component: () => import('~/views/admin/library/UploadDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
name: 'manage.library.tags',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'tags' })
|
||||
},
|
||||
{
|
||||
path: 'tags/:id',
|
||||
name: 'manage.library.tags.detail',
|
||||
component: () => import('~/views/admin/library/TagDetail.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manage/users',
|
||||
beforeEnter: hasPermissions('settings'),
|
||||
component: () => import('~/views/admin/users/Base.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'users',
|
||||
name: 'manage.users.users.list',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ type: 'users' })
|
||||
},
|
||||
{
|
||||
path: 'invitations',
|
||||
name: 'manage.users.invitations.list',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ type: 'invitations' })
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'manage/moderation',
|
||||
beforeEnter: hasPermissions('moderation'),
|
||||
component: () => import('~/views/admin/moderation/Base.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'domains',
|
||||
name: 'manage.moderation.domains.list',
|
||||
component: () => import('~/views/admin/moderation/DomainsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'domains/:id',
|
||||
name: 'manage.moderation.domains.detail',
|
||||
component: () => import('~/views/admin/moderation/DomainsDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
name: 'manage.moderation.accounts.list',
|
||||
component: () => import('~/views/admin/CommonList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q, type: 'accounts' })
|
||||
},
|
||||
{
|
||||
path: 'accounts/:id',
|
||||
name: 'manage.moderation.accounts.detail',
|
||||
component: () => import('~/views/admin/moderation/AccountsDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'manage.moderation.reports.list',
|
||||
component: () => import('~/views/admin/moderation/ReportsList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q }),
|
||||
meta: {
|
||||
paginateBy: 25
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'reports/:id',
|
||||
name: 'manage.moderation.reports.detail',
|
||||
component: () => import('~/views/admin/moderation/ReportDetail.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'requests',
|
||||
name: 'manage.moderation.requests.list',
|
||||
component: () => import('~/views/admin/moderation/RequestsList.vue'),
|
||||
props: route => ({ defaultQuery: route.query.q }),
|
||||
meta: {
|
||||
paginateBy: 25
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'requests/:id',
|
||||
name: 'manage.moderation.requests.detail',
|
||||
component: () => import('~/views/admin/moderation/RequestDetail.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,30 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('~/components/auth/Settings.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings/applications/new',
|
||||
name: 'settings.applications.new',
|
||||
props: route => ({
|
||||
scopes: route.query.scopes,
|
||||
name: route.query.name,
|
||||
redirect_uris: route.query.redirect_uris
|
||||
}),
|
||||
component: () => import('~/components/auth/ApplicationNew.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'settings.plugins',
|
||||
component: () => import('~/views/auth/Plugins.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings/applications/:id/edit',
|
||||
name: 'settings.applications.edit',
|
||||
component: () => import('~/components/auth/ApplicationEdit.vue'),
|
||||
props: true
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,50 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { useUploadsStore } from '~/ui/stores/upload'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/ui',
|
||||
name: 'ui',
|
||||
component: () => import('~/ui/layouts/constrained.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'upload',
|
||||
name: 'upload',
|
||||
component: () => import('~/ui/pages/upload.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'upload.index',
|
||||
component: () => import('~/ui/pages/upload/index.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'running',
|
||||
name: '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: 'upload.history',
|
||||
component: () => import('~/ui/pages/upload/history.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'all',
|
||||
name: 'upload.all',
|
||||
component: () => import('~/ui/pages/upload/all.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
] as RouteRecordRaw[]
|
|
@ -0,0 +1,34 @@
|
|||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import store from '~/store'
|
||||
|
||||
export default [
|
||||
{ suffix: '.full', path: '@:username@:domain' },
|
||||
{ suffix: '', path: '@:username' }
|
||||
].map((route) => {
|
||||
return {
|
||||
path: route.path,
|
||||
name: `profile${route.suffix}`,
|
||||
component: () => import('~/views/auth/ProfileBase.vue'),
|
||||
beforeEnter (to, from, next) {
|
||||
if (!store.state.auth.authenticated && to.query.domain && store.getters['instance/domain'] !== to.query.domain) {
|
||||
return next({ name: 'login', query: { next: to.fullPath } })
|
||||
}
|
||||
|
||||
next()
|
||||
},
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: `profile${route.suffix}.overview`,
|
||||
component: () => import('~/views/auth/ProfileOverview.vue')
|
||||
},
|
||||
{
|
||||
path: 'activity',
|
||||
name: `profile${route.suffix}.activity`,
|
||||
component: () => import('~/views/auth/ProfileActivity.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
}) as RouteRecordRaw[]
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { computed, reactive, readonly, ref, markRaw, toRaw, unref, watch } from 'vue'
|
||||
import { whenever, useWebWorker } from '@vueuse/core'
|
||||
|
@ -108,7 +107,7 @@ export class UploadGroup {
|
|||
return this.queue.filter((entry) => !entry.importedAt && !entry.failReason).length
|
||||
}
|
||||
|
||||
queueUpload(file: File) {
|
||||
queueUpload (file: File) {
|
||||
const entry = new UploadGroupEntry(file, this)
|
||||
this.queue.push(entry)
|
||||
|
||||
|
@ -151,7 +150,7 @@ watch(currentUploadGroup, (_, from) => {
|
|||
})
|
||||
|
||||
// Tag extraction with a Web Worker
|
||||
const { post: retrieveMetadata, data: workerMetadata} = useWebWorker<MetadataParsingResult>(() => new FileMetadataParserWorker())
|
||||
const { post: retrieveMetadata, data: workerMetadata } = useWebWorker<MetadataParsingResult>(() => new FileMetadataParserWorker())
|
||||
whenever(workerMetadata, (reactiveData) => {
|
||||
const data = toRaw(unref(reactiveData))
|
||||
const entry = UploadGroup.entries[data.id]
|
||||
|
@ -198,7 +197,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
|||
window.addEventListener('beforeunload', (event) => {
|
||||
if (isUploading.value) {
|
||||
event.preventDefault()
|
||||
return event.returnValue = 'The upload is still in progress. Are you sure you want to leave?'
|
||||
return (event.returnValue = 'The upload is still in progress. Are you sure you want to leave?')
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -212,7 +211,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
|||
currentIndex: readonly(currentIndex),
|
||||
currentUpload,
|
||||
queue: readonly(uploadQueue),
|
||||
uploadGroups: uploadGroups,
|
||||
uploadGroups,
|
||||
createUploadGroup,
|
||||
currentUploadGroup,
|
||||
progress
|
||||
|
|
|
@ -17,7 +17,6 @@ export interface MetadataParsingFailure {
|
|||
|
||||
export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure
|
||||
|
||||
|
||||
const parse = async (id: string, file: File) => {
|
||||
try {
|
||||
console.log(`[${id}] parsing...`)
|
||||
|
|
Loading…
Reference in New Issue