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 AudioPlayer = defineAsyncComponent(() => import('~/components/audio/Player.vue')) | ||||||
| const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue')) | const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue')) | ||||||
| const Queue = defineAsyncComponent(() => import('~/components/Queue.vue')) | const Queue = defineAsyncComponent(() => import('~/components/Queue.vue')) | ||||||
|  | import { useLocalStorage } from '@vueuse/core' | ||||||
| 
 | 
 | ||||||
| const logger = useLogger() | const logger = useLogger() | ||||||
| logger.debug('App setup()') | logger.debug('App setup()') | ||||||
|  | @ -79,10 +80,12 @@ const { width } = useWindowSize() | ||||||
| // NOTE: We're not checking if we're authenticated in the store, | // NOTE: We're not checking if we're authenticated in the store, | ||||||
| //       because we want to learn if we are authenticated at all | //       because we want to learn if we are authenticated at all | ||||||
| store.dispatch('auth/fetchUser') | store.dispatch('auth/fetchUser') | ||||||
|  | 
 | ||||||
|  | const isUIv2 = useLocalStorage('ui-v2', false) | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <UiApp v-if="route.fullPath.startsWith('/ui')" /> |   <UiApp v-if="isUIv2" /> | ||||||
|   <LegacyLayout v-else /> |   <LegacyLayout v-else /> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,12 @@ | ||||||
|  | import { useLocalStorage } from '@vueuse/core' | ||||||
| import { createRouter, createWebHistory } from 'vue-router' | import { createRouter, createWebHistory } from 'vue-router' | ||||||
| import { forceInstanceChooser } from './guards' | 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({ | const router = createRouter({ | ||||||
|   history: createWebHistory(import.meta.env.VUE_APP_ROUTER_BASE_URL as string ?? '/'), |   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 store from '~/store' | ||||||
| import auth from './auth' | import auth from './auth' | ||||||
| import user from './user' | import user from './user' | ||||||
| import ui from './ui' |  | ||||||
| import { requireLoggedIn } from '~/router/guards' | import { requireLoggedIn } from '~/router/guards' | ||||||
| 
 | 
 | ||||||
| export default [ | export default [ | ||||||
|   ...ui, |  | ||||||
|   { |   { | ||||||
|     path: '/', |     path: '/', | ||||||
|     name: 'index', |     name: 'index', | ||||||
|  |  | ||||||
|  | @ -9,18 +9,18 @@ export default [ | ||||||
|     children: [ |     children: [ | ||||||
|       { |       { | ||||||
|         path: 'upload', |         path: 'upload', | ||||||
|         name: 'ui.upload', |         name: 'upload', | ||||||
|         component: () => import('~/ui/pages/upload.vue'), |         component: () => import('~/ui/pages/upload.vue'), | ||||||
|         children: [ |         children: [ | ||||||
|           { |           { | ||||||
|             path: '', |             path: '', | ||||||
|             name: 'ui.upload.index', |             name: 'upload.index', | ||||||
|             component: () => import('~/ui/pages/upload/index.vue') |             component: () => import('~/ui/pages/upload/index.vue') | ||||||
|           }, |           }, | ||||||
| 
 | 
 | ||||||
|           { |           { | ||||||
|             path: 'running', |             path: 'running', | ||||||
|             name: 'ui.upload.running', |             name: 'upload.running', | ||||||
|             component: () => import('~/ui/pages/upload/running.vue'), |             component: () => import('~/ui/pages/upload/running.vue'), | ||||||
|             beforeEnter: (_to, _from, next) => { |             beforeEnter: (_to, _from, next) => { | ||||||
|               const uploads = useUploadsStore() |               const uploads = useUploadsStore() | ||||||
|  | @ -34,13 +34,13 @@ export default [ | ||||||
| 
 | 
 | ||||||
|           { |           { | ||||||
|             path: 'history', |             path: 'history', | ||||||
|             name: 'ui.upload.history', |             name: 'upload.history', | ||||||
|             component: () => import('~/ui/pages/upload/history.vue') |             component: () => import('~/ui/pages/upload/history.vue') | ||||||
|           }, |           }, | ||||||
| 
 | 
 | ||||||
|           { |           { | ||||||
|             path: 'all', |             path: 'all', | ||||||
|             name: 'ui.upload.all', |             name: 'upload.all', | ||||||
|             component: () => import('~/ui/pages/upload/all.vue') |             component: () => import('~/ui/pages/upload/all.vue') | ||||||
|           } |           } | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  | @ -15,8 +15,14 @@ const coverUrl = computed(() => { | ||||||
| <template> | <template> | ||||||
|   <div class="cover-art"> |   <div class="cover-art"> | ||||||
|     <Transition mode="out-in"> |     <Transition mode="out-in"> | ||||||
|       <img v-if="coverUrl" :src="coverUrl" /> |       <img | ||||||
|       <Icon v-else icon="bi:disc" /> |         v-if="coverUrl" | ||||||
|  |         :src="coverUrl" | ||||||
|  |       > | ||||||
|  |       <Icon | ||||||
|  |         v-else | ||||||
|  |         icon="bi:disc" | ||||||
|  |       /> | ||||||
|     </Transition> |     </Transition> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { ref, onMounted } from 'vue' | import { ref, onMounted } from 'vue' | ||||||
| import { useRoute } from 'vue-router' | import { useRoute } from 'vue-router' | ||||||
| import { useUploadsStore } from '../stores/upload'; | import { useUploadsStore } from '../stores/upload' | ||||||
| 
 | 
 | ||||||
| const searchQuery = ref('') | const searchQuery = ref('') | ||||||
| 
 | 
 | ||||||
|  | @ -19,26 +19,51 @@ const uploads = useUploadsStore() | ||||||
|     <div class="sticky-content"> |     <div class="sticky-content"> | ||||||
|       <nav class="quick-actions"> |       <nav class="quick-actions"> | ||||||
|         <RouterLink to="/"> |         <RouterLink to="/"> | ||||||
|           <img src="../../assets/logo/logo.svg" alt="Logo" class="logo" /> |           <img | ||||||
|  |             src="../../assets/logo/logo.svg" | ||||||
|  |             alt="Logo" | ||||||
|  |             class="logo" | ||||||
|  |           > | ||||||
|         </RouterLink> |         </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']"> |         <RouterLink to="/upload"> | ||||||
|  |           <FwButton | ||||||
|  |             icon="bi:upload" | ||||||
|  |             color="secondary" | ||||||
|  |             variant="ghost" | ||||||
|  |             :class="[{ active: route.name === 'ui.upload' }, 'icon-only']" | ||||||
|  |           > | ||||||
|             <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.progress}%` }" /> |                 <div | ||||||
|  |                   class="progress" | ||||||
|  |                   :style="{ maxWidth: `${uploads.progress}%` }" | ||||||
|  |                 /> | ||||||
|               </div> |               </div> | ||||||
|             </Transition> |             </Transition> | ||||||
|           </FwButton> |           </FwButton> | ||||||
|  |         </RouterLink> | ||||||
| 
 | 
 | ||||||
|         <FwButton icon="bi:inbox" color="secondary" variant="ghost" /> |         <FwButton | ||||||
|  |           icon="bi:inbox" | ||||||
|  |           color="secondary" | ||||||
|  |           variant="ghost" | ||||||
|  |         /> | ||||||
| 
 | 
 | ||||||
|         <a |         <a | ||||||
|           @click.prevent |  | ||||||
|           href="" |           href="" | ||||||
|           class="avatar" |           class="avatar" | ||||||
|  |           @click.prevent | ||||||
|         > |         > | ||||||
|           <img |           <img | ||||||
|             v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop" |             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> |       <h3>Explore</h3> | ||||||
|       <nav class="button-list"> |       <nav class="button-list"> | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-compass">All Funkwhale</FwButton> |         <FwButton | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-music-note-beamed">Music</FwButton> |           color="secondary" | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-mic">Podcasts</FwButton> |           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> |       </nav> | ||||||
| 
 | 
 | ||||||
|       <h3>Library</h3> |       <h3>Library</h3> | ||||||
|       <div class="pill-list"> |       <div class="pill-list"> | ||||||
|         <FwPill>Music</FwPill> |         <FwPill>Music</FwPill> | ||||||
|         <FwPill outline>Podcasts</FwPill> |         <FwPill outline> | ||||||
|         <FwPill outline>Sharing</FwPill> |           Podcasts | ||||||
|  |         </FwPill> | ||||||
|  |         <FwPill outline> | ||||||
|  |           Sharing | ||||||
|  |         </FwPill> | ||||||
|       </div> |       </div> | ||||||
|       <nav class="button-list"> |       <nav class="button-list"> | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-collection">Collections</FwButton> |         <FwButton | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-person">Artists</FwButton> |           color="secondary" | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-disc">Albums</FwButton> |           variant="ghost" | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-music-note-list">Playlists</FwButton> |           icon="bi-collection" | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-question-diamond">Radios</FwButton> |         > | ||||||
|         <FwButton color="secondary" variant="ghost" icon="bi-heart">Favorites</FwButton> |           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> |       </nav> | ||||||
|     </div> |     </div> | ||||||
|   </aside> |   </aside> | ||||||
|  | @ -155,7 +238,6 @@ aside { | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|       > :first-child { |       > :first-child { | ||||||
|         margin-right: auto; |         margin-right: auto; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,12 +1,11 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { ref } from 'vue'; | import { ref } from 'vue' | ||||||
| import { UploadGroup } from '~/ui/stores/upload' | import { UploadGroup } from '~/ui/stores/upload' | ||||||
| import VerticalCollapse from '~/ui/components/VerticalCollapse.vue' | import VerticalCollapse from '~/ui/components/VerticalCollapse.vue' | ||||||
| import UploadList from '~/ui/components/UploadList.vue' | import UploadList from '~/ui/components/UploadList.vue' | ||||||
| import { UseTimeAgo } from '@vueuse/components' | import { UseTimeAgo } from '@vueuse/components' | ||||||
| import { Icon } from '@iconify/vue' | import { Icon } from '@iconify/vue' | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| defineProps<{ groups: UploadGroup[], isUploading?: boolean }>() | defineProps<{ groups: UploadGroup[], isUploading?: boolean }>() | ||||||
| 
 | 
 | ||||||
| const openUploadGroup = ref<UploadGroup>() | const openUploadGroup = ref<UploadGroup>() | ||||||
|  | @ -19,7 +18,7 @@ const toggle = (group: UploadGroup) => { | ||||||
| const labels = { | const labels = { | ||||||
|   'music-library': 'Music library', |   'music-library': 'Music library', | ||||||
|   'music-channel': 'Music channel', |   'music-channel': 'Music channel', | ||||||
|   'podcast-channel': 'Podcast channel', |   'podcast-channel': 'Podcast channel' | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const getDescription = (group: UploadGroup) => { | const getDescription = (group: UploadGroup) => { | ||||||
|  | @ -48,61 +47,104 @@ const getDescription = (group: UploadGroup) => { | ||||||
| <template> | <template> | ||||||
|   <div> |   <div> | ||||||
|     <div |     <div | ||||||
|       class="upload-group" |  | ||||||
|       v-for="group of groups" |       v-for="group of groups" | ||||||
|       :key="group.guid" |       :key="group.guid" | ||||||
|  |       class="upload-group" | ||||||
|     > |     > | ||||||
|       <div class="flex items-center"> |       <div class="flex items-center"> | ||||||
|         <div class="upload-group-header"> |         <div class="upload-group-header"> | ||||||
|           <div class="upload-group-title">{{ labels[group.type] }}</div> |           <div class="upload-group-title"> | ||||||
|           <div class="upload-group-albums">{{ getDescription(group) }}</div> |             {{ labels[group.type] }} | ||||||
|  |           </div> | ||||||
|  |           <div class="upload-group-albums"> | ||||||
|  |             {{ getDescription(group) }} | ||||||
|  |           </div> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <div class="timeago"> |         <div class="timeago"> | ||||||
|           <UseTimeAgo :time="group.createdAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo> |           <UseTimeAgo | ||||||
|  |             v-slot="{ timeAgo }" | ||||||
|  |             :time="group.createdAt" | ||||||
|  |           > | ||||||
|  |             {{ timeAgo }} | ||||||
|  |           </UseTimeAgo> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
| 
 |         <FwPill | ||||||
|         <FwPill v-if="group.failedCount > 0" color="red"> |           v-if="group.failedCount > 0" | ||||||
|  |           color="red" | ||||||
|  |         > | ||||||
|           <template #image> |           <template #image> | ||||||
|             <div class="flex items-center justify-center">{{ group.failedCount }}</div> |             <div class="flex items-center justify-center"> | ||||||
|  |               {{ group.failedCount }} | ||||||
|  |             </div> | ||||||
|           </template> |           </template> | ||||||
|           failed |           failed | ||||||
|         </FwPill> |         </FwPill> | ||||||
| 
 | 
 | ||||||
|         <FwPill v-if="group.importedCount > 0" color="blue"> |         <FwPill | ||||||
|  |           v-if="group.importedCount > 0" | ||||||
|  |           color="blue" | ||||||
|  |         > | ||||||
|           <template #image> |           <template #image> | ||||||
|             <div class="flex items-center justify-center">{{ group.importedCount }}</div> |             <div class="flex items-center justify-center"> | ||||||
|  |               {{ group.importedCount }} | ||||||
|  |             </div> | ||||||
|           </template> |           </template> | ||||||
|           imported |           imported | ||||||
|         </FwPill> |         </FwPill> | ||||||
| 
 | 
 | ||||||
|         <FwPill v-if="group.processingCount > 0" color="secondary"> |         <FwPill | ||||||
|  |           v-if="group.processingCount > 0" | ||||||
|  |           color="secondary" | ||||||
|  |         > | ||||||
|           <template #image> |           <template #image> | ||||||
|             <div class="flex items-center justify-center">{{ group.processingCount }}</div> |             <div class="flex items-center justify-center"> | ||||||
|  |               {{ group.processingCount }} | ||||||
|  |             </div> | ||||||
|           </template> |           </template> | ||||||
|           processing |           processing | ||||||
|         </FwPill> |         </FwPill> | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|         <FwButton |         <FwButton | ||||||
|           @click="toggle(group)" |  | ||||||
|           variant="ghost" |           variant="ghost" | ||||||
|           color="secondary" |           color="secondary" | ||||||
|           class="icon-only" |           class="icon-only" | ||||||
|  |           @click="toggle(group)" | ||||||
|         > |         > | ||||||
|           <template #icon> |           <template #icon> | ||||||
|             <Icon icon="bi:chevron-right" :rotate="group === openUploadGroup ? 1 : 0" /> |             <Icon | ||||||
|  |               icon="bi:chevron-right" | ||||||
|  |               :rotate="group === openUploadGroup ? 1 : 0" | ||||||
|  |             /> | ||||||
|           </template> |           </template> | ||||||
|         </FwButton> |         </FwButton> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div v-if="isUploading" class="flex items-center upload-progress"> |       <div | ||||||
|         <FwButton v-if="group.processingCount === 0 && group.failedCount > 0" @click="group.retry()" color="secondary">Retry</FwButton> |         v-if="isUploading" | ||||||
|         <FwButton v-else-if="group.queue.length !== group.importedCount" @click="group.cancel()" color="secondary">Interrupt</FwButton> |         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"> | ||||||
|           <div class="progress-bar" :style="{ width: `${group.progress}%` }" /> |           <div | ||||||
|  |             class="progress-bar" | ||||||
|  |             :style="{ width: `${group.progress}%` }" | ||||||
|  |           /> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <div class="shrink-0"> |         <div class="shrink-0"> | ||||||
|  | @ -110,7 +152,11 @@ const getDescription = (group: UploadGroup) => { | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <VerticalCollapse @click.stop :open="openUploadGroup === group" class="collapse"> |       <VerticalCollapse | ||||||
|  |         :open="openUploadGroup === group" | ||||||
|  |         class="collapse" | ||||||
|  |         @click.stop | ||||||
|  |       > | ||||||
|         <UploadList :uploads="group.queue" /> |         <UploadList :uploads="group.queue" /> | ||||||
|       </VerticalCollapse> |       </VerticalCollapse> | ||||||
|     </div> |     </div> | ||||||
|  | @ -144,7 +190,6 @@ const getDescription = (group: UploadGroup) => { | ||||||
|   color: var(--fw-gray-600); |   color: var(--fw-gray-600); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| .upload-progress { | .upload-progress { | ||||||
|   font-size: 0.875rem; |   font-size: 0.875rem; | ||||||
|   color: var(--fw-gray-600); |   color: var(--fw-gray-600); | ||||||
|  |  | ||||||
|  | @ -14,28 +14,53 @@ defineProps<{ | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <div class="file-list"> |   <div class="file-list"> | ||||||
|     <div v-for="track in uploads" :key="track.id" class="list-track" :class="{ wide }"> |     <div | ||||||
|       <CoverArt :src="track.metadata" class="track-cover" /> |       v-for="track in uploads" | ||||||
|  |       :key="track.id" | ||||||
|  |       class="list-track" | ||||||
|  |       :class="{ wide }" | ||||||
|  |     > | ||||||
|  |       <CoverArt | ||||||
|  |         :src="track.metadata" | ||||||
|  |         class="track-cover" | ||||||
|  |       /> | ||||||
|       <Transition mode="out-in"> |       <Transition mode="out-in"> | ||||||
|         <div v-if="track.metadata?.tags" class="track-data"> |         <div | ||||||
|           <div class="track-title">{{ track.metadata.tags.title }}</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 }} |           {{ track.metadata.tags.artist }} / {{ track.metadata.tags.album }} | ||||||
|         </div> |         </div> | ||||||
|         <div v-else class="track-title"> |         <div | ||||||
|  |           v-else | ||||||
|  |           class="track-title" | ||||||
|  |         > | ||||||
|           {{ track.file.name }} |           {{ track.file.name }} | ||||||
|         </div> |         </div> | ||||||
|       </Transition> |       </Transition> | ||||||
|       <div class="upload-state"> |       <div class="upload-state"> | ||||||
|         <FwTooltip v-if="track.failReason" :tooltip="track.failReason"> |         <FwTooltip | ||||||
|  |           v-if="track.failReason" | ||||||
|  |           :tooltip="track.failReason" | ||||||
|  |         > | ||||||
|           <FwPill color="red"> |           <FwPill color="red"> | ||||||
|             <template #image> |             <template #image> | ||||||
|               <Icon icon="bi:question" class="h-4 w-4" /> |               <Icon | ||||||
|  |                 icon="bi:question" | ||||||
|  |                 class="h-4 w-4" | ||||||
|  |               /> | ||||||
|             </template> |             </template> | ||||||
| 
 | 
 | ||||||
|             failed |             failed | ||||||
|           </FwPill> |           </FwPill> | ||||||
|         </FwTooltip> |         </FwTooltip> | ||||||
|         <FwPill v-else :color="track.importedAt ? 'blue' : 'secondary'"> |         <FwPill | ||||||
|  |           v-else | ||||||
|  |           :color="track.importedAt ? 'blue' : 'secondary'" | ||||||
|  |         > | ||||||
|           {{ |           {{ | ||||||
|             track.importedAt |             track.importedAt | ||||||
|               ? 'imported' |               ? 'imported' | ||||||
|  | @ -44,10 +69,21 @@ defineProps<{ | ||||||
|                 : 'uploading' |                 : 'uploading' | ||||||
|           }} |           }} | ||||||
|         </FwPill> |         </FwPill> | ||||||
|         <div v-if="track.importedAt" class="track-timeago"> |         <div | ||||||
|           <UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo> |           v-if="track.importedAt" | ||||||
|  |           class="track-timeago" | ||||||
|  |         > | ||||||
|  |           <UseTimeAgo | ||||||
|  |             v-slot="{ timeAgo }" | ||||||
|  |             :time="track.importedAt" | ||||||
|  |           > | ||||||
|  |             {{ timeAgo }} | ||||||
|  |           </UseTimeAgo> | ||||||
|         </div> |         </div> | ||||||
|         <div v-else class="track-progress"> |         <div | ||||||
|  |           v-else | ||||||
|  |           class="track-progress" | ||||||
|  |         > | ||||||
|           {{ bytesToHumanSize(track.file.size / 100 * track.progress) }} |           {{ bytesToHumanSize(track.file.size / 100 * track.progress) }} | ||||||
|           / {{ bytesToHumanSize(track.file.size) }} |           / {{ bytesToHumanSize(track.file.size) }} | ||||||
|           ⋅ {{ track.progress }}% |           ⋅ {{ track.progress }}% | ||||||
|  | @ -55,10 +91,10 @@ defineProps<{ | ||||||
|       </div> |       </div> | ||||||
|       <FwButton |       <FwButton | ||||||
|         v-if="track.failReason" |         v-if="track.failReason" | ||||||
|         @click="track.retry()" |  | ||||||
|         icon="bi:arrow-repeat" |         icon="bi:arrow-repeat" | ||||||
|         variant="ghost" |         variant="ghost" | ||||||
|         color="secondary" |         color="secondary" | ||||||
|  |         @click="track.retry()" | ||||||
|       /> |       /> | ||||||
|       <FwButton |       <FwButton | ||||||
|         v-else |         v-else | ||||||
|  |  | ||||||
|  | @ -71,14 +71,19 @@ const currentFilter = ref(filterItems[0]) | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <FwModal v-model="libraryOpen" title="Upload music to library"> |   <FwModal | ||||||
|  |     v-model="libraryOpen" | ||||||
|  |     title="Upload music to library" | ||||||
|  |   > | ||||||
|     <template #alert="{ closeAlert }"> |     <template #alert="{ closeAlert }"> | ||||||
|       <FwAlert> |       <FwAlert> | ||||||
|         Before uploading, please ensure your files are tagged properly. |         Before uploading, please ensure your files are tagged properly. | ||||||
|         We recommend using Picard for that purpose. |         We recommend using Picard for that purpose. | ||||||
| 
 | 
 | ||||||
|         <template #actions> |         <template #actions> | ||||||
|           <FwButton @click="closeAlert">Got it</FwButton> |           <FwButton @click="closeAlert"> | ||||||
|  |             Got it | ||||||
|  |           </FwButton> | ||||||
|         </template> |         </template> | ||||||
|       </FwAlert> |       </FwAlert> | ||||||
|     </template> |     </template> | ||||||
|  | @ -97,12 +102,19 @@ const currentFilter = ref(filterItems[0]) | ||||||
|           {{ queue.length }} files, {{ combinedFileSize }} |           {{ queue.length }} files, {{ combinedFileSize }} | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" /> |         <FwSelect | ||||||
|         <FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" /> |           v-model="currentFilter" | ||||||
|  |           icon="bi:filter" | ||||||
|  |           :items="filterItems" | ||||||
|  |         /> | ||||||
|  |         <FwSelect | ||||||
|  |           v-model="currentSort" | ||||||
|  |           icon="bi:sort-down" | ||||||
|  |           :items="sortItems" | ||||||
|  |         /> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <UploadList :uploads="queue" /> |       <UploadList :uploads="queue" /> | ||||||
| 
 |  | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <!-- Import path --> |     <!-- Import path --> | ||||||
|  | @ -120,7 +132,12 @@ const currentFilter = ref(filterItems[0]) | ||||||
|     </template> |     </template> | ||||||
| 
 | 
 | ||||||
|     <template #actions> |     <template #actions> | ||||||
|       <FwButton @click="cancel" color="secondary">Cancel</FwButton> |       <FwButton | ||||||
|  |         color="secondary" | ||||||
|  |         @click="cancel" | ||||||
|  |       > | ||||||
|  |         Cancel | ||||||
|  |       </FwButton> | ||||||
|       <FwButton @click="continueInBackground"> |       <FwButton @click="continueInBackground"> | ||||||
|         {{ uploads.queue.length ? 'Continue in background' : 'Save and close' }} |         {{ uploads.queue.length ? 'Continue in background' : 'Save and close' }} | ||||||
|       </FwButton> |       </FwButton> | ||||||
|  |  | ||||||
|  | @ -3,7 +3,10 @@ defineProps<{ open: boolean }>() | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <div class="v-collapse" :class="{ open }"> |   <div | ||||||
|  |     class="v-collapse" | ||||||
|  |     :class="{ open }" | ||||||
|  |   > | ||||||
|     <div class="v-collapse-body"> |     <div class="v-collapse-body"> | ||||||
|       <slot /> |       <slot /> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|  | @ -5,4 +5,3 @@ export const bytesToHumanSize = (bytes: number) => { | ||||||
|   if (i === 0) return `${bytes} ${sizes[i]}` |   if (i === 0) return `${bytes} ${sizes[i]}` | ||||||
|   return `${(bytes / 1024 ** i).toFixed(1)} ${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) |       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"> | <script setup lang="ts"> | ||||||
| import { reactive, ref, computed } from 'vue' | import { reactive, ref, computed } from 'vue' | ||||||
| import { UseTimeAgo } from '@vueuse/components' | import { UseTimeAgo } from '@vueuse/components' | ||||||
| import { Icon } from '@iconify/vue'; | import { Icon } from '@iconify/vue' | ||||||
| import { useUploadsStore } from '~/ui/stores/upload' | import { useUploadsStore } from '~/ui/stores/upload' | ||||||
| import { bytesToHumanSize } from '~/ui/composables/bytes' | import { bytesToHumanSize } from '~/ui/composables/bytes' | ||||||
| 
 | 
 | ||||||
| const filesystemStats = reactive({ | const filesystemStats = reactive({ | ||||||
|   total: 10737418240, |   total: 10737418240, | ||||||
|   used: 3e9, |   used: 3e9 | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| const filesystemProgress = computed(() => { | const filesystemProgress = computed(() => { | ||||||
|  | @ -19,7 +19,7 @@ const tabs = [ | ||||||
|   { |   { | ||||||
|     label: 'Music library', |     label: 'Music library', | ||||||
|     icon: 'headphones', |     icon: 'headphones', | ||||||
|     description: 'Host music you listen to.', |     description: 'Host music you listen to.' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     label: 'Music channel', |     label: 'Music channel', | ||||||
|  | @ -29,13 +29,12 @@ const tabs = [ | ||||||
|   { |   { | ||||||
|     label: 'Podcast channel', |     label: 'Podcast channel', | ||||||
|     icon: 'mic', |     icon: 'mic', | ||||||
|     description: 'Publish podcast you make.', |     description: 'Publish podcast you make.' | ||||||
|   }, |   } | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| const currentTab = ref(tabs[0].label) | const currentTab = ref(tabs[0].label) | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| // Modals | // Modals | ||||||
| const libraryOpen = ref(false) | const libraryOpen = ref(false) | ||||||
| 
 | 
 | ||||||
|  | @ -53,7 +52,6 @@ const processFiles = (fileList: FileList) => { | ||||||
|   for (const file of fileList) { |   for (const file of fileList) { | ||||||
|     uploads.queueUpload(file) |     uploads.queueUpload(file) | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const cancel = () => { | const cancel = () => { | ||||||
|  | @ -78,10 +76,15 @@ const currentFilter = ref(filterItems[0]) | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <div class="flex items-center"> |   <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"> | ||||||
|       <div class="filesystem-stats--progress" :style="`--progress: ${filesystemProgress}%`" /> |       <div | ||||||
|  |         class="filesystem-stats--progress" | ||||||
|  |         :style="`--progress: ${filesystemProgress}%`" | ||||||
|  |       /> | ||||||
|       <div class="flex items-center"> |       <div class="flex items-center"> | ||||||
|         {{ bytesToHumanSize(filesystemStats.total) }} total |         {{ bytesToHumanSize(filesystemStats.total) }} total | ||||||
| 
 | 
 | ||||||
|  | @ -92,14 +95,14 @@ const currentFilter = ref(filterItems[0]) | ||||||
|         {{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available |         {{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
| 
 |  | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
|   <p> Select a destination for your audio files: </p> |   <p> Select a destination for your audio files: </p> | ||||||
| 
 | 
 | ||||||
|   <div class="flex justify-between"> |   <div class="flex justify-between"> | ||||||
|     <FwCard |     <FwCard | ||||||
|       v-for="tab in tabs" :key="tab.label" |       v-for="tab in tabs" | ||||||
|  |       :key="tab.label" | ||||||
|       :title="tab.label" |       :title="tab.label" | ||||||
|       :class="currentTab === tab.label && 'active'" |       :class="currentTab === tab.label && 'active'" | ||||||
|       @click="currentTab = tab.label" |       @click="currentTab = tab.label" | ||||||
|  | @ -115,15 +118,22 @@ const currentFilter = ref(filterItems[0]) | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
|   <div> |   <div> | ||||||
|     <FwButton @click="libraryOpen = true">Open library</FwButton> |     <FwButton @click="libraryOpen = true"> | ||||||
|     <FwModal v-model="libraryOpen" title="Upload music to library"> |       Open library | ||||||
|  |     </FwButton> | ||||||
|  |     <FwModal | ||||||
|  |       v-model="libraryOpen" | ||||||
|  |       title="Upload music to library" | ||||||
|  |     > | ||||||
|       <template #alert="{ closeAlert }"> |       <template #alert="{ closeAlert }"> | ||||||
|         <FwAlert> |         <FwAlert> | ||||||
|           Before uploading, please ensure your files are tagged properly. |           Before uploading, please ensure your files are tagged properly. | ||||||
|           We recommend using Picard for that purpose. |           We recommend using Picard for that purpose. | ||||||
| 
 | 
 | ||||||
|           <template #actions> |           <template #actions> | ||||||
|             <FwButton @click="closeAlert">Got it</FwButton> |             <FwButton @click="closeAlert"> | ||||||
|  |               Got it | ||||||
|  |             </FwButton> | ||||||
|           </template> |           </template> | ||||||
|         </FwAlert> |         </FwAlert> | ||||||
|       </template> |       </template> | ||||||
|  | @ -142,18 +152,38 @@ const currentFilter = ref(filterItems[0]) | ||||||
|             {{ uploads.queue.length }} files, {{ combinedFileSize }} |             {{ uploads.queue.length }} files, {{ combinedFileSize }} | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|           <FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" /> |           <FwSelect | ||||||
|           <FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" /> |             v-model="currentFilter" | ||||||
|  |             icon="bi:filter" | ||||||
|  |             :items="filterItems" | ||||||
|  |           /> | ||||||
|  |           <FwSelect | ||||||
|  |             v-model="currentSort" | ||||||
|  |             icon="bi:sort-down" | ||||||
|  |             :items="sortItems" | ||||||
|  |           /> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <div class="file-list"> |         <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"> |             <Transition mode="out-in"> | ||||||
|               <div v-if="track.tags" class="track-data"> |               <div | ||||||
|                 <div class="track-title">{{ track.tags.title }}</div> |                 v-if="track.tags" | ||||||
|  |                 class="track-data" | ||||||
|  |               > | ||||||
|  |                 <div class="track-title"> | ||||||
|  |                   {{ track.tags.title }} | ||||||
|  |                 </div> | ||||||
|                 {{ track.tags.artist }} / {{ track.tags.album }} |                 {{ track.tags.artist }} / {{ track.tags.album }} | ||||||
|               </div> |               </div> | ||||||
|               <div v-else class="track-title"> |               <div | ||||||
|  |                 v-else | ||||||
|  |                 class="track-title" | ||||||
|  |               > | ||||||
|                 {{ track.file.name }} |                 {{ track.file.name }} | ||||||
|               </div> |               </div> | ||||||
|             </Transition> |             </Transition> | ||||||
|  | @ -169,10 +199,21 @@ const currentFilter = ref(filterItems[0]) | ||||||
|                         : 'uploading' |                         : 'uploading' | ||||||
|                 }} |                 }} | ||||||
|               </FwPill> |               </FwPill> | ||||||
|               <div v-if="track.importedAt" class="track-progress"> |               <div | ||||||
|                 <UseTimeAgo :time="track.importedAt" v-slot="{ timeAgo }">{{ timeAgo }}</UseTimeAgo> |                 v-if="track.importedAt" | ||||||
|  |                 class="track-progress" | ||||||
|  |               > | ||||||
|  |                 <UseTimeAgo | ||||||
|  |                   v-slot="{ timeAgo }" | ||||||
|  |                   :time="track.importedAt" | ||||||
|  |                 > | ||||||
|  |                   {{ timeAgo }} | ||||||
|  |                 </UseTimeAgo> | ||||||
|               </div> |               </div> | ||||||
|               <div v-else class="track-progress"> |               <div | ||||||
|  |                 v-else | ||||||
|  |                 class="track-progress" | ||||||
|  |               > | ||||||
|                 {{ bytesToHumanSize(track.file.size / 100 * track.progress) }} |                 {{ bytesToHumanSize(track.file.size / 100 * track.progress) }} | ||||||
|                 / {{ bytesToHumanSize(track.file.size) }} |                 / {{ bytesToHumanSize(track.file.size) }} | ||||||
|                 ⋅ {{ track.progress }}% |                 ⋅ {{ track.progress }}% | ||||||
|  | @ -187,7 +228,6 @@ const currentFilter = ref(filterItems[0]) | ||||||
|             /> |             /> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
| 
 |  | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <!-- Import path --> |       <!-- Import path --> | ||||||
|  | @ -205,7 +245,12 @@ const currentFilter = ref(filterItems[0]) | ||||||
|       </template> |       </template> | ||||||
| 
 | 
 | ||||||
|       <template #actions> |       <template #actions> | ||||||
|         <FwButton @click="cancel" color="secondary">Cancel</FwButton> |         <FwButton | ||||||
|  |           color="secondary" | ||||||
|  |           @click="cancel" | ||||||
|  |         > | ||||||
|  |           Cancel | ||||||
|  |         </FwButton> | ||||||
|         <FwButton @click="libraryOpen = false"> |         <FwButton @click="libraryOpen = false"> | ||||||
|           {{ uploads.queue.length ? 'Continue in background' : 'Save and close' }} |           {{ uploads.queue.length ? 'Continue in background' : 'Save and close' }} | ||||||
|         </FwButton> |         </FwButton> | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ import UploadModal from '~/ui/components/UploadModal.vue' | ||||||
| 
 | 
 | ||||||
| const filesystemStats = reactive({ | const filesystemStats = reactive({ | ||||||
|   total: 10737418240, |   total: 10737418240, | ||||||
|   used: 3e9, |   used: 3e9 | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| const filesystemProgress = computed(() => { | const filesystemProgress = computed(() => { | ||||||
|  | @ -35,16 +35,21 @@ const tabs = computed(() => [ | ||||||
|     label: 'All files', |     label: 'All files', | ||||||
|     key: 'all', |     key: 'all', | ||||||
|     enabled: true |     enabled: true | ||||||
|   }, |   } | ||||||
| ].filter(tab => tab.enabled)) | ].filter(tab => tab.enabled)) | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <div class="flex items-center"> |   <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"> | ||||||
|       <div class="filesystem-stats--progress" :style="`--progress: ${filesystemProgress}%`" /> |       <div | ||||||
|  |         class="filesystem-stats--progress" | ||||||
|  |         :style="`--progress: ${filesystemProgress}%`" | ||||||
|  |       /> | ||||||
|       <div class="flex items-center"> |       <div class="flex items-center"> | ||||||
|         {{ bytesToHumanSize(filesystemStats.total) }} total |         {{ bytesToHumanSize(filesystemStats.total) }} total | ||||||
| 
 | 
 | ||||||
|  | @ -55,12 +60,20 @@ const tabs = computed(() => [ | ||||||
|         {{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available |         {{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
| 
 |  | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
|   <div class="mb-4 -ml-2"> |   <div class="mb-4 -ml-2"> | ||||||
|     <RouterLink v-for="tab in tabs" :key="tab.key" :to="`/ui/upload/${tab.key}`" custom #="{ navigate, isExactActive }"> |     <RouterLink | ||||||
|       <FwPill @click="navigate" :color="isExactActive ? 'primary' : 'secondary'"> |       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 }} |         {{ tab.label }} | ||||||
|       </FwPill> |       </FwPill> | ||||||
|     </RouterLink> |     </RouterLink> | ||||||
|  |  | ||||||
|  | @ -1,8 +1,8 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { Icon } from '@iconify/vue' | import { Icon } from '@iconify/vue' | ||||||
| import { computed } from 'vue'; | import { computed } from 'vue' | ||||||
| import { bytesToHumanSize } from '~/ui/composables/bytes'; | import { bytesToHumanSize } from '~/ui/composables/bytes' | ||||||
| import { useUploadsStore, type UploadGroupEntry } from '~/ui/stores/upload'; | import { useUploadsStore, type UploadGroupEntry } from '~/ui/stores/upload' | ||||||
| import CoverArt from '~/ui/components/CoverArt.vue' | import CoverArt from '~/ui/components/CoverArt.vue' | ||||||
| 
 | 
 | ||||||
| interface Recording { | interface Recording { | ||||||
|  | @ -49,20 +49,30 @@ const columns = [ | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <div v-if="allTracks.length === 0" class="flex flex-col items-center py-32"> |   <div | ||||||
|     <Icon icon="bi:file-earmark-music" class="h-16 w-16" /> |     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> |     <h3>There is no file in your library</h3> | ||||||
|     <p>Try uploading some before coming back here!</p> |     <p>Try uploading some before coming back here!</p> | ||||||
|   </div> |   </div> | ||||||
|   <FwTable v-else  |   <FwTable | ||||||
|  |     v-else | ||||||
|     id-key="guid" |     id-key="guid" | ||||||
|     :columns="columns" |     :columns="columns" | ||||||
|     :rows="allTracks" |     :rows="allTracks" | ||||||
|   > |   > | ||||||
|     <template #col-title="{ row, value }"> |     <template #col-title="{ row, value }"> | ||||||
|       <div class="flex items-center"> |       <div class="flex items-center"> | ||||||
|         <CoverArt :src="row.metadata" class="mr-2" /> |         <CoverArt | ||||||
|  |           :src="row.metadata" | ||||||
|  |           class="mr-2" | ||||||
|  |         /> | ||||||
|         {{ value }} |         {{ value }} | ||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { Icon } from '@iconify/vue'; | import { Icon } from '@iconify/vue' | ||||||
| import { useUploadsStore, type UploadGroupType } from '~/ui/stores/upload' | import { useUploadsStore, type UploadGroupType } from '~/ui/stores/upload' | ||||||
| import { ref } from 'vue' | import { ref } from 'vue' | ||||||
| 
 | 
 | ||||||
|  | @ -15,23 +15,22 @@ const tabs: Tab[] = [ | ||||||
|     label: 'Music library', |     label: 'Music library', | ||||||
|     icon: 'headphones', |     icon: 'headphones', | ||||||
|     description: 'Host music you listen to.', |     description: 'Host music you listen to.', | ||||||
|     key: 'music-library', |     key: 'music-library' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     label: 'Music channel', |     label: 'Music channel', | ||||||
|     icon: 'music-note-beamed', |     icon: 'music-note-beamed', | ||||||
|     description: 'Publish music you make.', |     description: 'Publish music you make.', | ||||||
|     key: 'music-channel', |     key: 'music-channel' | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     label: 'Podcast channel', |     label: 'Podcast channel', | ||||||
|     icon: 'mic', |     icon: 'mic', | ||||||
|     description: 'Publish podcast you make.', |     description: 'Publish podcast you make.', | ||||||
|     key: 'podcast-channel', |     key: 'podcast-channel' | ||||||
|   }, |   } | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| const currentTab = ref(tabs[0]) | const currentTab = ref(tabs[0]) | ||||||
| 
 | 
 | ||||||
| const uploads = useUploadsStore() | const uploads = useUploadsStore() | ||||||
|  | @ -46,7 +45,8 @@ const openLibrary = () => { | ||||||
| 
 | 
 | ||||||
|     <div class="flex gap-8"> |     <div class="flex gap-8"> | ||||||
|       <FwCard |       <FwCard | ||||||
|         v-for="tab in tabs" :key="tab.key" |         v-for="tab in tabs" | ||||||
|  |         :key="tab.key" | ||||||
|         :title="tab.label" |         :title="tab.label" | ||||||
|         :class="currentTab.key === tab.key && 'active'" |         :class="currentTab.key === tab.key && 'active'" | ||||||
|         @click="currentTab = tab" |         @click="currentTab = tab" | ||||||
|  | @ -61,7 +61,9 @@ const openLibrary = () => { | ||||||
|       </FwCard> |       </FwCard> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <FwButton @click="openLibrary">Open library</FwButton> |     <FwButton @click="openLibrary"> | ||||||
|  |       Open library | ||||||
|  |     </FwButton> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,5 +5,8 @@ const uploads = useUploadsStore() | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|   <UploadGroupList :groups="uploads.uploadGroups" :is-uploading="true" /> |   <UploadGroupList | ||||||
|  |     :groups="uploads.uploadGroups" | ||||||
|  |     :is-uploading="true" | ||||||
|  |   /> | ||||||
| </template> | </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 { defineStore, acceptHMRUpdate } from 'pinia' | ||||||
| import { computed, reactive, readonly, ref, markRaw, toRaw, unref, watch } from 'vue' | import { computed, reactive, readonly, ref, markRaw, toRaw, unref, watch } from 'vue' | ||||||
| import { whenever, useWebWorker } from '@vueuse/core' | import { whenever, useWebWorker } from '@vueuse/core' | ||||||
|  | @ -108,7 +107,7 @@ export class UploadGroup { | ||||||
|     return this.queue.filter((entry) => !entry.importedAt && !entry.failReason).length |     return this.queue.filter((entry) => !entry.importedAt && !entry.failReason).length | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   queueUpload(file: File) { |   queueUpload (file: File) { | ||||||
|     const entry = new UploadGroupEntry(file, this) |     const entry = new UploadGroupEntry(file, this) | ||||||
|     this.queue.push(entry) |     this.queue.push(entry) | ||||||
| 
 | 
 | ||||||
|  | @ -151,7 +150,7 @@ watch(currentUploadGroup, (_, from) => { | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| // Tag extraction with a Web Worker
 | // 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) => { | whenever(workerMetadata, (reactiveData) => { | ||||||
|   const data = toRaw(unref(reactiveData)) |   const data = toRaw(unref(reactiveData)) | ||||||
|   const entry = UploadGroup.entries[data.id] |   const entry = UploadGroup.entries[data.id] | ||||||
|  | @ -198,7 +197,7 @@ export const useUploadsStore = defineStore('uploads', () => { | ||||||
|   window.addEventListener('beforeunload', (event) => { |   window.addEventListener('beforeunload', (event) => { | ||||||
|     if (isUploading.value) { |     if (isUploading.value) { | ||||||
|       event.preventDefault() |       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), |     currentIndex: readonly(currentIndex), | ||||||
|     currentUpload, |     currentUpload, | ||||||
|     queue: readonly(uploadQueue), |     queue: readonly(uploadQueue), | ||||||
|     uploadGroups: uploadGroups, |     uploadGroups, | ||||||
|     createUploadGroup, |     createUploadGroup, | ||||||
|     currentUploadGroup, |     currentUploadGroup, | ||||||
|     progress |     progress | ||||||
|  |  | ||||||
|  | @ -17,7 +17,6 @@ export interface MetadataParsingFailure { | ||||||
| 
 | 
 | ||||||
| export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure | export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| const parse = async (id: string, file: File) => { | const parse = async (id: string, file: File) => { | ||||||
|   try { |   try { | ||||||
|     console.log(`[${id}] parsing...`) |     console.log(`[${id}] parsing...`) | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	 Kasper Seweryn
						Kasper Seweryn