feat: add sidebar

This commit is contained in:
Kasper Seweryn 2023-12-11 15:00:00 +01:00 committed by upsiflu
parent acfc4a687b
commit 3d7ccf8313
11 changed files with 1117 additions and 163 deletions

View File

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

View File

@ -1,13 +1,14 @@
<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'
import { generateTrackCreditStringFromQueue } from '~/utils/utils' import { generateTrackCreditStringFromQueue } from '~/utils/utils'
@ -28,7 +29,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
@ -81,53 +82,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> </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>
<style> <style>
html, body { html, body {
font-size: 16px; font-size: 16px;

102
front/src/LegacyLayout.vue Normal file
View File

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

View File

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

27
front/src/ui/App.vue Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff