feat: add sidebar

This commit is contained in:
Kasper Seweryn 2023-12-11 15:00:00 +01:00 committed by ArneBo
parent 5b374bcbd2
commit 048063b50b
11 changed files with 1120 additions and 172 deletions

View File

@ -24,10 +24,10 @@
"@types/jsmediatags": "3.9.6",
"@vue/runtime-core": "3.3.11",
"@vueuse/components": "10.6.1",
"@vueuse/core": "10.3.0",
"@vueuse/integrations": "10.3.0",
"@vueuse/math": "10.3.0",
"@vueuse/router": "10.3.0",
"@vueuse/core": "10.6.1",
"@vueuse/integrations": "10.6.1",
"@vueuse/math": "10.6.1",
"@vueuse/router": "10.6.1",
"axios": "1.7.2",
"axios-auth-refresh": "3.3.6",
"butterchurn": "3.0.0-beta.4",
@ -107,6 +107,7 @@
"sinon": "15.0.2",
"standardized-audio-context-mock": "9.6.32",
"typescript": "5.3.3",
"unocss": "0.58.0",
"unplugin-vue-macros": "2.4.6",
"utility-types": "3.10.0",
"vite": "5.2.12",

View File

@ -1,24 +1,19 @@
<script setup lang="ts">
import LegacyLayout from '~/LegacyLayout.vue'
import UiApp from '~/ui/App.vue'
import type { QueueTrack } from '~/composables/audio/queue'
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
import { computed, nextTick, onMounted, watchEffect, defineAsyncComponent } from 'vue'
import { watchEffect } from 'vue'
import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import useLogger from '~/composables/useLogger'
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 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'))
import { useRoute } from 'vue-router'
const route = useRoute()
const logger = useLogger()
logger.debug('App setup()')
@ -26,7 +21,7 @@ logger.debug('App setup()')
const store = useStore()
// Tracks
const { currentTrack, tracks } = useQueue()
const { currentTrack } = useQueue()
const getTrackInformationText = (track: QueueTrack | undefined) => {
if (!track) {
return null
@ -79,53 +74,10 @@ store.dispatch('auth/fetchUser')
</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: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>
<UiApp v-if="route.fullPath.startsWith('/ui')" />
<LegacyLayout v-else />
</template>
<style>
html, body {
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 'virtual:uno.css'
// NOTE: Set the theme as fast as possible
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
const libraryOpen = ref(false)
const libraryModalAlertOpen = ref(true)
// Server import
const serverPath = ref('/srv/funkwhale/data/music')
@ -68,6 +67,20 @@ const cancel = () => {
libraryOpen.value = false
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>
<template>
@ -111,13 +124,13 @@ const cancel = () => {
<div>
<FwButton @click="libraryOpen = true">Open library</FwButton>
<FwModal v-model="libraryOpen" title="Upload music to library">
<template #alert v-if="libraryModalAlertOpen">
<template #alert="{ closeAlert }">
<FwAlert>
Before uploading, please ensure your files are tagged properly.
We recommend using Picard for that purpose.
<template #actions>
<FwButton @click="libraryModalAlertOpen = false">Got it</FwButton>
<FwButton @click="closeAlert">Got it</FwButton>
</template>
</FwAlert>
</template>
@ -136,8 +149,8 @@ const cancel = () => {
{{ uploads.queue.length }} files, {{ combinedFileSize }}
</div>
<FwButton color="secondary">All</FwButton>
<FwButton color="secondary">Upload time</FwButton>
<FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" />
<FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" />
</div>
<div class="file-list">

View File

@ -1,16 +1,17 @@
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 { not } from '@vueuse/math'
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 {
id: string
id: number
file: File
// Upload info
@ -38,7 +39,7 @@ export const useUploadsStore = defineStore('uploads', () => {
// Tag extraction with a Web Worker
const worker = ref<UseWebWorkerReturn<MetadataParsingResult>>()
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)
}
@ -47,18 +48,17 @@ export const useUploadsStore = defineStore('uploads', () => {
worker.value = undefined
})
whenever(() => worker.value?.data, (reactiveData) => {
const data = toRaw(reactiveData)
const data = toRaw(unref(reactiveData))
if (data.status === 'success') {
const id = data.id as number
const tags = data.tags as Tags
const coverUrl = data.coverUrl as string
const id = data.id
const tags = data.tags
const coverUrl = data.coverUrl
uploadQueue[id].tags = markRaw(tags)
uploadQueue[id].coverUrl = coverUrl
} else {
const id = data.id as number
const id = data.id
const entry = uploadQueue[id]
entry.error = data.error
@ -74,15 +74,15 @@ export const useUploadsStore = defineStore('uploads', () => {
const body = new FormData()
body.append('file', entry.file)
const uploadProgress = ref(0)
await axios.post('https://httpbin.org/post', body, {
headers: {
'Content-Type': 'multipart/form-data'
},
signal: entry.abortController.signal,
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) {
console.log(`[${entry.id}] upload complete!`)
@ -127,7 +127,7 @@ export const useUploadsStore = defineStore('uploads', () => {
window.addEventListener('beforeunload', (event) => {
if (isUploading.value) {
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 {
isUploading,
queueUpload,
currentIndex: readonly(currentIndex),
currentUpload,
cancelAll,
queue: readonly(uploadQueue)

View File

@ -1,5 +1,5 @@
// java String#hashCode
export function hashCode (str: string) {
export function hashCode (str = 'default') {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)

View File

@ -1,7 +1,8 @@
import { visualizer } from 'rollup-plugin-visualizer'
import { defineConfig, type PluginOption } from 'vite'
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'
@ -26,7 +27,7 @@ export default defineConfig(({ mode }) => ({
// https://github.com/intlify/bundle-tools/tree/main/packages/vite-plugin-vue-i18n
VueI18n({
include: resolve(__dirname, './src/locales/**')
include: fileURLToPath(new URL('./src/locales/**', import.meta.url))
}),
// https://github.com/btd/rollup-plugin-visualizer
@ -48,17 +49,21 @@ export default defineConfig(({ mode }) => ({
// https://github.com/davidmyersdev/vite-plugin-node-polyfills
// see: https://github.com/Borewit/music-metadata-browser/issues/836
nodePolyfills()
nodePolyfills(),
// https://unocss.dev/
UnoCSS()
],
server: {
port
},
resolve: {
alias: {
'#': resolve(__dirname, './src/worker'),
'?': resolve(__dirname, './test'),
'~': resolve(__dirname, './src')
}
alias: [
{ find: '#', replacement: fileURLToPath(new URL('./src/ui/workers', import.meta.url)) },
{ find: '?', replacement: fileURLToPath(new URL('./test', import.meta.url)) },
{ find: '~', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
]
},
build: {
sourcemap: true,

File diff suppressed because it is too large Load Diff