feat: create router for UI v2

merge-requests/2666/merge^2
Kasper Seweryn 2024-01-25 00:38:38 +01:00 zatwierdzone przez Georg Krause
rodzic 443a535758
commit c55a684039
27 zmienionych plików z 1198 dodań i 147 usunięć

Wyświetl plik

@ -11,9 +11,7 @@ import { useStore } from '~/store'
import useLogger from '~/composables/useLogger'
import { useRoute } from 'vue-router'
const route = useRoute()
import { useLocalStorage } from '@vueuse/core'
const logger = useLogger()
logger.debug('App setup()')
@ -46,10 +44,12 @@ watchEffect(() => {
// 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>

Wyświetl plik

@ -1,5 +1,11 @@
import { useLocalStorage } from '@vueuse/core'
import { createRouter, createWebHistory } from 'vue-router'
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
export default createRouter({
history: createWebHistory(import.meta.env.VUE_APP_ROUTER_BASE_URL as string ?? '/'),

Wyświetl plik

@ -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',

Wyświetl plik

@ -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')
}
]

Wyświetl plik

@ -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>

Wyświetl plik

@ -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;

Wyświetl plik

@ -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);

Wyświetl plik

@ -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

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -5,4 +5,3 @@ export const bytesToHumanSize = (bytes: number) => {
if (i === 0) return `${bytes} ${sizes[i]}`
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`
}

Wyświetl plik

@ -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 }))
})
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -5,5 +5,8 @@ const uploads = useUploadsStore()
</script>
<template>
<UploadGroupList :groups="uploads.uploadGroups" :is-uploading="true" />
<UploadGroupList
:groups="uploads.uploadGroups"
:is-uploading="true"
/>
</template>

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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[]

Wyświetl plik

@ -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

Wyświetl plik

@ -17,7 +17,6 @@ export interface MetadataParsingFailure {
export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure
const parse = async (id: string, file: File) => {
try {
console.log(`[${id}] parsing...`)