feat: add sidebar
This commit is contained in:
parent
5b374bcbd2
commit
048063b50b
|
@ -24,10 +24,10 @@
|
||||||
"@types/jsmediatags": "3.9.6",
|
"@types/jsmediatags": "3.9.6",
|
||||||
"@vue/runtime-core": "3.3.11",
|
"@vue/runtime-core": "3.3.11",
|
||||||
"@vueuse/components": "10.6.1",
|
"@vueuse/components": "10.6.1",
|
||||||
"@vueuse/core": "10.3.0",
|
"@vueuse/core": "10.6.1",
|
||||||
"@vueuse/integrations": "10.3.0",
|
"@vueuse/integrations": "10.6.1",
|
||||||
"@vueuse/math": "10.3.0",
|
"@vueuse/math": "10.6.1",
|
||||||
"@vueuse/router": "10.3.0",
|
"@vueuse/router": "10.6.1",
|
||||||
"axios": "1.7.2",
|
"axios": "1.7.2",
|
||||||
"axios-auth-refresh": "3.3.6",
|
"axios-auth-refresh": "3.3.6",
|
||||||
"butterchurn": "3.0.0-beta.4",
|
"butterchurn": "3.0.0-beta.4",
|
||||||
|
@ -107,6 +107,7 @@
|
||||||
"sinon": "15.0.2",
|
"sinon": "15.0.2",
|
||||||
"standardized-audio-context-mock": "9.6.32",
|
"standardized-audio-context-mock": "9.6.32",
|
||||||
"typescript": "5.3.3",
|
"typescript": "5.3.3",
|
||||||
|
"unocss": "0.58.0",
|
||||||
"unplugin-vue-macros": "2.4.6",
|
"unplugin-vue-macros": "2.4.6",
|
||||||
"utility-types": "3.10.0",
|
"utility-types": "3.10.0",
|
||||||
"vite": "5.2.12",
|
"vite": "5.2.12",
|
||||||
|
|
|
@ -1,24 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import LegacyLayout from '~/LegacyLayout.vue'
|
||||||
|
import UiApp from '~/ui/App.vue'
|
||||||
|
|
||||||
import type { QueueTrack } from '~/composables/audio/queue'
|
import type { QueueTrack } from '~/composables/audio/queue'
|
||||||
|
|
||||||
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
|
import { watchEffect } from 'vue'
|
||||||
import { computed, nextTick, onMounted, watchEffect, defineAsyncComponent } from 'vue'
|
|
||||||
|
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
|
||||||
const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue'))
|
import { useRoute } from 'vue-router'
|
||||||
const PlaylistModal = defineAsyncComponent(() => import('~/components/playlists/PlaylistModal.vue'))
|
|
||||||
const FilterModal = defineAsyncComponent(() => import('~/components/moderation/FilterModal.vue'))
|
const route = useRoute()
|
||||||
const ReportModal = defineAsyncComponent(() => import('~/components/moderation/ReportModal.vue'))
|
|
||||||
const ServiceMessages = defineAsyncComponent(() => import('~/components/ServiceMessages.vue'))
|
|
||||||
const ShortcutsModal = defineAsyncComponent(() => import('~/components/ShortcutsModal.vue'))
|
|
||||||
const AudioPlayer = defineAsyncComponent(() => import('~/components/audio/Player.vue'))
|
|
||||||
const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue'))
|
|
||||||
const Queue = defineAsyncComponent(() => import('~/components/Queue.vue'))
|
|
||||||
|
|
||||||
const logger = useLogger()
|
const logger = useLogger()
|
||||||
logger.debug('App setup()')
|
logger.debug('App setup()')
|
||||||
|
@ -26,7 +21,7 @@ logger.debug('App setup()')
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
// Tracks
|
// Tracks
|
||||||
const { currentTrack, tracks } = useQueue()
|
const { currentTrack } = useQueue()
|
||||||
const getTrackInformationText = (track: QueueTrack | undefined) => {
|
const getTrackInformationText = (track: QueueTrack | undefined) => {
|
||||||
if (!track) {
|
if (!track) {
|
||||||
return null
|
return null
|
||||||
|
@ -79,53 +74,10 @@ store.dispatch('auth/fetchUser')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<UiApp v-if="route.fullPath.startsWith('/ui')" />
|
||||||
:key="store.state.instance.instanceUrl"
|
<LegacyLayout v-else />
|
||||||
:class="{
|
|
||||||
'has-bottom-player': tracks.length > 0,
|
|
||||||
'queue-focused': store.state.ui.queueFocused
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- here, we display custom stylesheets, if any -->
|
|
||||||
<link
|
|
||||||
v-for="url in customStylesheets"
|
|
||||||
:key="url"
|
|
||||||
rel="stylesheet"
|
|
||||||
property="stylesheet"
|
|
||||||
:href="url"
|
|
||||||
>
|
|
||||||
|
|
||||||
<sidebar
|
|
||||||
:width="width"
|
|
||||||
@show:shortcuts-modal="toggleShortcutsModal"
|
|
||||||
/>
|
|
||||||
<service-messages />
|
|
||||||
<transition name="queue">
|
|
||||||
<queue v-show="store.state.ui.queueFocused" />
|
|
||||||
</transition>
|
|
||||||
|
|
||||||
<router-view v-slot="{ Component }">
|
|
||||||
<template v-if="Component">
|
|
||||||
<keep-alive :max="1">
|
|
||||||
<Suspense>
|
|
||||||
<component :is="Component" />
|
|
||||||
<template #fallback>
|
|
||||||
<!-- TODO (wvffle): Add loader -->
|
|
||||||
{{ $t('App.loading') }}
|
|
||||||
</template>
|
|
||||||
</Suspense>
|
|
||||||
</keep-alive>
|
|
||||||
</template>
|
|
||||||
</router-view>
|
|
||||||
|
|
||||||
<audio-player />
|
|
||||||
<playlist-modal v-if="store.state.auth.authenticated" />
|
|
||||||
<channel-upload-modal v-if="store.state.auth.authenticated" />
|
|
||||||
<filter-modal v-if="store.state.auth.authenticated" />
|
|
||||||
<report-modal />
|
|
||||||
<shortcuts-modal v-model:show="showShortcutsModal" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
|
||||||
|
import { computed, ref, defineAsyncComponent, nextTick, onMounted } from 'vue'
|
||||||
|
|
||||||
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||||
|
|
||||||
|
const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue'))
|
||||||
|
const PlaylistModal = defineAsyncComponent(() => import('~/components/playlists/PlaylistModal.vue'))
|
||||||
|
const FilterModal = defineAsyncComponent(() => import('~/components/moderation/FilterModal.vue'))
|
||||||
|
const ReportModal = defineAsyncComponent(() => import('~/components/moderation/ReportModal.vue'))
|
||||||
|
const SetInstanceModal = defineAsyncComponent(() => import('~/components/SetInstanceModal.vue'))
|
||||||
|
const ServiceMessages = defineAsyncComponent(() => import('~/components/ServiceMessages.vue'))
|
||||||
|
const ShortcutsModal = defineAsyncComponent(() => import('~/components/ShortcutsModal.vue'))
|
||||||
|
const AudioPlayer = defineAsyncComponent(() => import('~/components/audio/Player.vue'))
|
||||||
|
const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue'))
|
||||||
|
const Queue = defineAsyncComponent(() => import('~/components/Queue.vue'))
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
// Tracks
|
||||||
|
const { tracks } = useQueue()
|
||||||
|
|
||||||
|
// Fake content
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
document.getElementById('fake-content')?.classList.add('loaded')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const customStylesheets = computed(() => {
|
||||||
|
return store.state.instance.frontSettings.additionalStylesheets ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
useStyleTag(computed(() => store.state.instance.settings.ui.custom_css.value))
|
||||||
|
|
||||||
|
// Time ago
|
||||||
|
useIntervalFn(() => {
|
||||||
|
// used to redraw ago dates every minute
|
||||||
|
store.commit('ui/computeLastDate')
|
||||||
|
}, 1000 * 60)
|
||||||
|
|
||||||
|
// Shortcuts
|
||||||
|
const [showShortcutsModal, toggleShortcutsModal] = useToggle(false)
|
||||||
|
onKeyboardShortcut('h', () => toggleShortcutsModal())
|
||||||
|
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
const showSetInstanceModal = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:key="store.state.instance.instanceUrl"
|
||||||
|
:class="{
|
||||||
|
'has-bottom-player': tracks.length > 0,
|
||||||
|
'queue-focused': store.state.ui.queueFocused
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- here, we display custom stylesheets, if any -->
|
||||||
|
<link
|
||||||
|
v-for="url in customStylesheets"
|
||||||
|
:key="url"
|
||||||
|
rel="stylesheet"
|
||||||
|
property="stylesheet"
|
||||||
|
:href="url"
|
||||||
|
>
|
||||||
|
|
||||||
|
<sidebar
|
||||||
|
:width="width"
|
||||||
|
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
|
||||||
|
@show:shortcuts-modal="toggleShortcutsModal"
|
||||||
|
/>
|
||||||
|
<set-instance-modal v-model:show="showSetInstanceModal" />
|
||||||
|
<service-messages />
|
||||||
|
<transition name="queue">
|
||||||
|
<queue v-show="store.state.ui.queueFocused" />
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<template v-if="Component">
|
||||||
|
<keep-alive :max="1">
|
||||||
|
<Suspense>
|
||||||
|
<component :is="Component" />
|
||||||
|
<template #fallback>
|
||||||
|
<!-- TODO (wvffle): Add loader -->
|
||||||
|
{{ $t('App.loading') }}
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</keep-alive>
|
||||||
|
</template>
|
||||||
|
</router-view>
|
||||||
|
|
||||||
|
<audio-player />
|
||||||
|
<playlist-modal v-if="store.state.auth.authenticated" />
|
||||||
|
<channel-upload-modal v-if="store.state.auth.authenticated" />
|
||||||
|
<filter-modal v-if="store.state.auth.authenticated" />
|
||||||
|
<report-modal />
|
||||||
|
<shortcuts-modal v-model:show="showShortcutsModal" />
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -16,6 +16,8 @@ import '~/style/_main.scss'
|
||||||
|
|
||||||
import '~/api'
|
import '~/api'
|
||||||
|
|
||||||
|
import 'virtual:uno.css'
|
||||||
|
|
||||||
// NOTE: Set the theme as fast as possible
|
// NOTE: Set the theme as fast as possible
|
||||||
useTheme()
|
useTheme()
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, nextTick } from 'vue'
|
||||||
|
import Sidebar from '~/ui/components/Sidebar.vue'
|
||||||
|
|
||||||
|
// Fake content
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
document.getElementById('fake-app')?.remove()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid">
|
||||||
|
<Sidebar />
|
||||||
|
<main>
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 300px 5fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,230 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useUploadsStore } from '../stores/upload';
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Hide the fake app when the real one is loaded
|
||||||
|
onMounted(() => {
|
||||||
|
document.getElementById('fake-app')?.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside>
|
||||||
|
<div class="sticky-content">
|
||||||
|
<nav class="quick-actions">
|
||||||
|
<RouterLink to="/">
|
||||||
|
<img src="../../assets/logo/logo.svg" alt="Logo" class="logo" />
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<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.currentIndex / uploads.queue.length * 100}%` }" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</FwButton>
|
||||||
|
|
||||||
|
<FwButton icon="bi:inbox" color="secondary" variant="ghost" />
|
||||||
|
|
||||||
|
<a
|
||||||
|
@click.prevent
|
||||||
|
href=""
|
||||||
|
class="avatar"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop"
|
||||||
|
alt=""
|
||||||
|
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
|
||||||
|
>
|
||||||
|
<!--ActorAvatar
|
||||||
|
v-else-if="$store.state.auth.authenticated"
|
||||||
|
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
|
||||||
|
/-->
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
class="cog icon"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="search">
|
||||||
|
<FwInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
icon="bi-search"
|
||||||
|
:placeholder="$t('components.audio.SearchBar.placeholder.search')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h3>Library</h3>
|
||||||
|
<div class="pill-list">
|
||||||
|
<FwPill>Music</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>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
aside {
|
||||||
|
background: var(--fw-beige-300);
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
> .sticky-content {
|
||||||
|
position: sticky;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
> .quick-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
|
||||||
|
.active {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--fw-blue-500);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(svg + span) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
background: var(--fw-blue-500);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-leave-to,
|
||||||
|
&.v-enter-from {
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .progress {
|
||||||
|
height: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
transition: max-width 0.1s ease;
|
||||||
|
background: var(--fw-gray-100);
|
||||||
|
border-radius: 100vh;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.fake {
|
||||||
|
background: var(--fw-blue-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.fake) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
> :first-child {
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar,
|
||||||
|
.logo {
|
||||||
|
height: 30px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: var(--fw-beige-100);
|
||||||
|
border-radius: 100%;
|
||||||
|
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
|
||||||
|
> i {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .search {
|
||||||
|
padding: 0 16px 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> h3 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 32px 8px;
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .pill-list {
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
> nav.button-list {
|
||||||
|
padding: 0 16px 32px;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
margin: 2px 0;
|
||||||
|
|
||||||
|
/* TODO: Fix in UI: When icon is applied, the text should be aligned left */
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
/* TODO: Fix in UI: Add `block` prop that spans 100% width */
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
:deep(i) {
|
||||||
|
font-size: 1.4em;
|
||||||
|
|
||||||
|
/* TODO: Fix in UI: Add margin right to the icon, when content available */
|
||||||
|
margin-right: 1ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -45,7 +45,6 @@ const currentTab = ref(tabs[0].label)
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const libraryOpen = ref(false)
|
const libraryOpen = ref(false)
|
||||||
const libraryModalAlertOpen = ref(true)
|
|
||||||
|
|
||||||
// Server import
|
// Server import
|
||||||
const serverPath = ref('/srv/funkwhale/data/music')
|
const serverPath = ref('/srv/funkwhale/data/music')
|
||||||
|
@ -68,6 +67,20 @@ const cancel = () => {
|
||||||
libraryOpen.value = false
|
libraryOpen.value = false
|
||||||
uploads.cancelAll()
|
uploads.cancelAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const sortItems = reactive([
|
||||||
|
{ label: 'Upload time', value: 'upload-time' },
|
||||||
|
{ label: 'Upload time 2', value: 'upload-time-2' },
|
||||||
|
{ label: 'Upload time 3', value: 'upload-time-3' }
|
||||||
|
])
|
||||||
|
const currentSort = ref(sortItems[0])
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
const filterItems = reactive([
|
||||||
|
{ label: 'All', value: 'all' }
|
||||||
|
])
|
||||||
|
const currentFilter = ref(filterItems[0])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -111,13 +124,13 @@ const cancel = () => {
|
||||||
<div>
|
<div>
|
||||||
<FwButton @click="libraryOpen = true">Open library</FwButton>
|
<FwButton @click="libraryOpen = true">Open library</FwButton>
|
||||||
<FwModal v-model="libraryOpen" title="Upload music to library">
|
<FwModal v-model="libraryOpen" title="Upload music to library">
|
||||||
<template #alert v-if="libraryModalAlertOpen">
|
<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="libraryModalAlertOpen = false">Got it</FwButton>
|
<FwButton @click="closeAlert">Got it</FwButton>
|
||||||
</template>
|
</template>
|
||||||
</FwAlert>
|
</FwAlert>
|
||||||
</template>
|
</template>
|
||||||
|
@ -136,8 +149,8 @@ const cancel = () => {
|
||||||
{{ uploads.queue.length }} files, {{ combinedFileSize }}
|
{{ uploads.queue.length }} files, {{ combinedFileSize }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FwButton color="secondary">All</FwButton>
|
<FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" />
|
||||||
<FwButton color="secondary">Upload time</FwButton>
|
<FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="file-list">
|
<div class="file-list">
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
|
|
||||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||||
import { computed, reactive, readonly, ref, watchEffect, markRaw, toRaw, type Ref } from 'vue'
|
import { computed, reactive, readonly, ref, markRaw, toRaw, unref } from 'vue'
|
||||||
import { whenever, useWebWorker, type UseWebWorkerReturn } from '@vueuse/core'
|
import { whenever, useWebWorker, type UseWebWorkerReturn } from '@vueuse/core'
|
||||||
import { not } from '@vueuse/math'
|
import { not } from '@vueuse/math'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import FileMetadataParserWorker, { type MetadataParsingResult } from '~/ui/workers/file-metadata-parser.ts?worker'
|
import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker'
|
||||||
|
import type { MetadataParsingResult } from '~/ui/workers/file-metadata-parser'
|
||||||
|
|
||||||
import { getCoverUrl, getTags, type Tags } from '~/ui/composables/metadata'
|
import type { Tags } from '~/ui/composables/metadata'
|
||||||
|
|
||||||
interface UploadQueueEntry {
|
interface UploadQueueEntry {
|
||||||
id: string
|
id: number
|
||||||
file: File
|
file: File
|
||||||
|
|
||||||
// Upload info
|
// Upload info
|
||||||
|
@ -38,7 +39,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
||||||
// Tag extraction with a Web Worker
|
// Tag extraction with a Web Worker
|
||||||
const worker = ref<UseWebWorkerReturn<MetadataParsingResult>>()
|
const worker = ref<UseWebWorkerReturn<MetadataParsingResult>>()
|
||||||
const retrieveMetadata = (entry: Pick<UploadQueueEntry, 'id' | 'file'>) => {
|
const retrieveMetadata = (entry: Pick<UploadQueueEntry, 'id' | 'file'>) => {
|
||||||
if (!worker.value) worker.value = useWebWorker<MetadataParsingResult>(FileMetadataParserWorker)
|
if (!worker.value) worker.value = useWebWorker<MetadataParsingResult>(() => new FileMetadataParserWorker())
|
||||||
worker.value.post(entry)
|
worker.value.post(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,18 +48,17 @@ export const useUploadsStore = defineStore('uploads', () => {
|
||||||
worker.value = undefined
|
worker.value = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
whenever(() => worker.value?.data, (reactiveData) => {
|
whenever(() => worker.value?.data, (reactiveData) => {
|
||||||
const data = toRaw(reactiveData)
|
const data = toRaw(unref(reactiveData))
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
const id = data.id as number
|
const id = data.id
|
||||||
const tags = data.tags as Tags
|
const tags = data.tags
|
||||||
const coverUrl = data.coverUrl as string
|
const coverUrl = data.coverUrl
|
||||||
|
|
||||||
uploadQueue[id].tags = markRaw(tags)
|
uploadQueue[id].tags = markRaw(tags)
|
||||||
uploadQueue[id].coverUrl = coverUrl
|
uploadQueue[id].coverUrl = coverUrl
|
||||||
} else {
|
} else {
|
||||||
const id = data.id as number
|
const id = data.id
|
||||||
const entry = uploadQueue[id]
|
const entry = uploadQueue[id]
|
||||||
|
|
||||||
entry.error = data.error
|
entry.error = data.error
|
||||||
|
@ -74,15 +74,15 @@ export const useUploadsStore = defineStore('uploads', () => {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('file', entry.file)
|
body.append('file', entry.file)
|
||||||
|
|
||||||
const uploadProgress = ref(0)
|
|
||||||
|
|
||||||
await axios.post('https://httpbin.org/post', body, {
|
await axios.post('https://httpbin.org/post', body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
},
|
},
|
||||||
signal: entry.abortController.signal,
|
signal: entry.abortController.signal,
|
||||||
onUploadProgress: (e) => {
|
onUploadProgress: (e) => {
|
||||||
entry.progress = Math.floor(e.loaded / e.total * 100)
|
// NOTE: If e.total is absent, we use the file size instead. This is only an approximation, as e.total is the total size of the request, not just the file.
|
||||||
|
// see: https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/total
|
||||||
|
entry.progress = Math.floor(e.loaded / (e.total ?? entry.file.size) * 100)
|
||||||
|
|
||||||
if (entry.progress === 100) {
|
if (entry.progress === 100) {
|
||||||
console.log(`[${entry.id}] upload complete!`)
|
console.log(`[${entry.id}] upload complete!`)
|
||||||
|
@ -127,7 +127,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
if (isUploading.value) {
|
if (isUploading.value) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
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?'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -144,6 +144,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
||||||
return {
|
return {
|
||||||
isUploading,
|
isUploading,
|
||||||
queueUpload,
|
queueUpload,
|
||||||
|
currentIndex: readonly(currentIndex),
|
||||||
currentUpload,
|
currentUpload,
|
||||||
cancelAll,
|
cancelAll,
|
||||||
queue: readonly(uploadQueue)
|
queue: readonly(uploadQueue)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// java String#hashCode
|
// java String#hashCode
|
||||||
export function hashCode (str: string) {
|
export function hashCode (str = 'default') {
|
||||||
let hash = 0
|
let hash = 0
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { visualizer } from 'rollup-plugin-visualizer'
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
import { defineConfig, type PluginOption } from 'vite'
|
import { defineConfig, type PluginOption } from 'vite'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { resolve } from 'path'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import UnoCSS from 'unocss/vite'
|
||||||
|
|
||||||
import manifest from './pwa-manifest.json'
|
import manifest from './pwa-manifest.json'
|
||||||
|
|
||||||
|
@ -26,7 +27,7 @@ export default defineConfig(({ mode }) => ({
|
||||||
|
|
||||||
// https://github.com/intlify/bundle-tools/tree/main/packages/vite-plugin-vue-i18n
|
// https://github.com/intlify/bundle-tools/tree/main/packages/vite-plugin-vue-i18n
|
||||||
VueI18n({
|
VueI18n({
|
||||||
include: resolve(__dirname, './src/locales/**')
|
include: fileURLToPath(new URL('./src/locales/**', import.meta.url))
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// https://github.com/btd/rollup-plugin-visualizer
|
// https://github.com/btd/rollup-plugin-visualizer
|
||||||
|
@ -48,17 +49,21 @@ export default defineConfig(({ mode }) => ({
|
||||||
|
|
||||||
// https://github.com/davidmyersdev/vite-plugin-node-polyfills
|
// https://github.com/davidmyersdev/vite-plugin-node-polyfills
|
||||||
// see: https://github.com/Borewit/music-metadata-browser/issues/836
|
// see: https://github.com/Borewit/music-metadata-browser/issues/836
|
||||||
nodePolyfills()
|
nodePolyfills(),
|
||||||
|
|
||||||
|
|
||||||
|
// https://unocss.dev/
|
||||||
|
UnoCSS()
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
port
|
port
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: [
|
||||||
'#': resolve(__dirname, './src/worker'),
|
{ find: '#', replacement: fileURLToPath(new URL('./src/ui/workers', import.meta.url)) },
|
||||||
'?': resolve(__dirname, './test'),
|
{ find: '?', replacement: fileURLToPath(new URL('./test', import.meta.url)) },
|
||||||
'~': resolve(__dirname, './src')
|
{ find: '~', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
}
|
]
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|
775
front/yarn.lock
775
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue