feat: toggle nav bar

This commit is contained in:
Phan An 2024-03-15 16:09:50 +01:00
parent e090ff6aa8
commit 551b5c020a
12 changed files with 148 additions and 70 deletions

View file

@ -19,6 +19,7 @@ const ModalWrapper = defineAsyncComponent(() => import('@/components/layout/Moda
<style lang="scss">
#mainWrapper {
position: relative;
display: flex;
flex: 1;
height: 0; // fix a flex-box bug https://github.com/philipwalton/flexbugs/issues/197#issuecomment-378908438

View file

@ -1,5 +1,12 @@
<template>
<nav id="sidebar" v-koel-clickaway="closeIfMobile" :class="{ showing: mobileShowing }" class="side side-nav">
<nav
id="sidebar"
v-koel-clickaway="closeIfMobile"
:class="{ collapsed, 'tmp-showing': tmpShowing, showing: mobileShowing }"
class="side side-nav"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<section class="search-wrapper">
<SearchForm />
</section>
@ -35,6 +42,11 @@
<section v-if="!isPlus && isAdmin" class="plus-wrapper">
<BtnUpgradeToPlus />
</section>
<button class="btn-toggle" @click.prevent="toggleNavbar">
<Icon v-if="collapsed" :icon="faAngleRight" />
<Icon v-else :icon="faAngleLeft" />
</button>
</nav>
</template>
@ -47,13 +59,14 @@ import {
faTags,
faTools,
faUpload,
faPlus,
faUsers
faUsers,
faAngleLeft,
faAngleRight
} from '@fortawesome/free-solid-svg-icons'
import { computed, ref } from 'vue'
import { eventBus } from '@/utils'
import { useAuthorization, useKoelPlus, useRouter, useThirdPartyServices, useUpload } from '@/composables'
import { useAuthorization, useKoelPlus, useRouter, useThirdPartyServices, useUpload, useLocalStorage } from '@/composables'
import SidebarItem from './SidebarItem.vue'
import QueueSidebarItem from './QueueSidebarItem.vue'
@ -67,7 +80,9 @@ const { useYouTube } = useThirdPartyServices()
const { isAdmin } = useAuthorization()
const { allowsUpload } = useUpload()
const { isPlus } = useKoelPlus()
const { get: lsGet, set: lsSet } = useLocalStorage()
const collapsed = ref(lsGet('sidebar-collapsed', false))
const mobileShowing = ref(false)
const youTubePlaying = ref(false)
@ -75,6 +90,35 @@ const showYouTube = computed(() => useYouTube.value && youTubePlaying.value)
const showManageSection = computed(() => isAdmin.value || allowsUpload.value)
const closeIfMobile = () => (mobileShowing.value = false)
const toggleNavbar = () => {
collapsed.value = !collapsed.value
lsSet('sidebar-collapsed', collapsed.value)
}
let tmpShowingHandler: number | undefined
const tmpShowing = ref(false)
const onMouseEnter = () => {
if (!collapsed.value) return;
tmpShowingHandler = window.setTimeout(() => {
if (!collapsed.value) return
tmpShowing.value = true
}, 500)
}
const onMouseLeave = (e: MouseEvent) => {
if (!e.relatedTarget) {
return
}
if (tmpShowingHandler) {
clearTimeout(tmpShowingHandler)
tmpShowingHandler = undefined
}
tmpShowing.value = false
}
onRouteChanged(_ => (mobileShowing.value = false))
@ -91,12 +135,32 @@ nav {
position: relative;
width: var(--sidebar-width);
background-color: var(--color-bg-secondary);
overflow: auto;
overflow-x: hidden;
-ms-overflow-style: -ms-autohiding-scrollbar;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
will-change: width;
&.collapsed {
transition: width .2s;
width: 24px;
> *:not(.btn-toggle) {
display: none;
}
&.tmp-showing {
position: absolute;
background-color: var(--color-bg-primary);
width: var(--sidebar-width);
height: 100vh;
z-index: 100;
> *:not(.btn-toggle) {
display: block;
}
}
}
form[role=search] {
min-height: 38px;
@ -200,6 +264,28 @@ nav {
}
}
.btn-toggle {
width: 24px;
aspect-ratio: 1 / 1;
position: absolute;
color: var(--color-text-secondary);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
right: -12px;
top: 30px;
z-index: 5;
&:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-secondary);
}
@media screen and (max-width: 768px) {
display: none;
}
}
@media screen and (max-width: 768px) {
@include themed-background();
transform: translateX(-100vw);
@ -214,5 +300,6 @@ nav {
transform: translateX(0);
}
}
}
</style>

View file

@ -62,8 +62,8 @@ import { faVolumeOff } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRef, watch } from 'vue'
import { logger, pluralize, secondsToHumanReadable } from '@/utils'
import { commonStore, queueStore, songStore } from '@/stores'
import { localStorageService, playbackService } from '@/services'
import { useMessageToaster, useKoelPlus, useRouter, useSongList, useSongListControls } from '@/composables'
import { playbackService } from '@/services'
import { useMessageToaster, useKoelPlus, useRouter, useSongList, useSongListControls, useLocalStorage } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
@ -94,6 +94,7 @@ const { SongListControls, config } = useSongListControls('Songs')
const { toastError } = useMessageToaster()
const { go, onScreenActivated } = useRouter()
const { isPlus } = useKoelPlus()
const { get: lsGet, set: lsSet } = useLocalStorage()
let initialized = false
const loading = ref(false)
@ -104,10 +105,10 @@ const page = ref<number | null>(1)
const moreSongsAvailable = computed(() => page.value !== null)
const showSkeletons = computed(() => loading.value && songs.value.length === 0)
const ownSongsOnly = ref(isPlus.value ? Boolean(localStorageService.get('own-songs-only')) : false)
const ownSongsOnly = ref(isPlus.value ? Boolean(lsGet('own-songs-only')) : false)
watch(ownSongsOnly, async value => {
localStorageService.set('own-songs-only', value)
lsSet('own-songs-only', value)
page.value = 1
songStore.state.songs = []

View file

@ -5,6 +5,7 @@ export * from './useDragAndDrop'
export * from './useFloatingUi'
export * from './useInfiniteScroll'
export * from './useKoelPlus'
export * from './useLocalStorage'
export * from './useMessageToaster'
export * from './useModal'
export * from './useNetworkStatus'

View file

@ -0,0 +1,26 @@
import { useAuthorization } from '@/composables'
import { get as baseGet, remove as baseRemove, set as baseSet } from 'local-storage'
export const useLocalStorage = (namespaced = true) => {
let namespace = ''
if (namespaced) {
const { currentUser } = useAuthorization()
namespace = `${currentUser.value.id}::`
}
const get = <T> (key: string, defaultValue: T | null = null): T | null => {
const value = baseGet<T>(namespace + key)
return value === null ? defaultValue : value
}
const set = (key: string, value: any) => baseSet(namespace + key, value)
const remove = (key: string) => baseRemove(namespace + key)
return {
get,
set,
remove
}
}

View file

@ -1,32 +1,33 @@
import UnitTestCase from '@/__tests__/UnitTestCase'
import { expect, it } from 'vitest'
import { authService, http, localStorageService, UpdateCurrentProfileData } from '@/services'
import { authService, http, UpdateCurrentProfileData } from '@/services'
import { useLocalStorage } from '@/composables'
import factory from '@/__tests__/factory'
import { userStore } from '@/stores'
new class extends UnitTestCase {
protected test () {
const { get: lsGet, set: lsSet } = useLocalStorage(false)
it('gets the token', () => {
const mock = this.mock(localStorageService, 'get')
authService.getApiToken()
expect(mock).toHaveBeenCalledWith('api-token')
lsSet('api-token', 'foo')
expect(authService.getApiToken()).toBe('foo')
})
it.each([['foo', true], [null, false]])('checks if the token exists', (token, exists) => {
this.mock(localStorageService, 'get', token)
lsSet('api-token', token)
expect(authService.hasApiToken()).toBe(exists)
})
it('sets the token', () => {
const mock = this.mock(localStorageService, 'set')
authService.setApiToken('foo')
expect(mock).toHaveBeenCalledWith('api-token', 'foo')
expect(lsGet('api-token')).toBe('foo')
})
it('destroys the token', () => {
const mock = this.mock(localStorageService, 'remove')
lsSet('api-token', 'foo')
authService.destroy()
expect(mock).toHaveBeenCalledWith('api-token')
expect(lsGet('api-token')).toBeNull()
})
it('logs in', async () => {

View file

@ -1,6 +1,7 @@
import { merge } from 'lodash'
import { http, localStorageService } from '@/services'
import { http } from '@/services'
import { userStore } from '@/stores'
import { useLocalStorage } from '@/composables'
export interface UpdateCurrentProfileData {
current_password: string | null
@ -18,6 +19,8 @@ export interface CompositeToken {
const API_TOKEN_STORAGE_KEY = 'api-token'
const AUDIO_TOKEN_STORAGE_KEY = 'audio-token'
const { get: lsGet, set: lsSet, remove: lsRemove } = useLocalStorage(false) // authentication local storage data aren't namespaced
export const authService = {
async login (email: string, password: string) {
const token = await http.post<CompositeToken>('me', { email, password })
@ -37,24 +40,24 @@ export const authService = {
merge(userStore.current, (await http.put<User>('me', data)))
},
getApiToken: () => localStorageService.get(API_TOKEN_STORAGE_KEY),
getApiToken: () => lsGet(API_TOKEN_STORAGE_KEY),
hasApiToken () {
return Boolean(this.getApiToken())
},
setApiToken: (token: string) => localStorageService.set(API_TOKEN_STORAGE_KEY, token),
setApiToken: (token: string) => lsSet(API_TOKEN_STORAGE_KEY, token),
destroy: () => {
localStorageService.remove(API_TOKEN_STORAGE_KEY)
localStorageService.remove(AUDIO_TOKEN_STORAGE_KEY)
lsRemove(API_TOKEN_STORAGE_KEY)
lsRemove(AUDIO_TOKEN_STORAGE_KEY)
},
setAudioToken: (token: string) => localStorageService.set(AUDIO_TOKEN_STORAGE_KEY, token),
setAudioToken: (token: string) => lsSet(AUDIO_TOKEN_STORAGE_KEY, token),
getAudioToken: () => {
// for backward compatibility, we first try to get the audio token, and fall back to the (full-privileged) API token
return localStorageService.get(AUDIO_TOKEN_STORAGE_KEY) || localStorageService.get(API_TOKEN_STORAGE_KEY)
return lsGet(AUDIO_TOKEN_STORAGE_KEY) || lsGet(API_TOKEN_STORAGE_KEY)
},
requestResetPasswordLink: async (email: string) => await http.post('forgot-password', { email }),

View file

@ -1,6 +1,5 @@
export * from './http'
export * from './downloadService'
export * from './localStorageService'
export * from './playbackService'
export * from './youTubeService'
export * from './socketService'

View file

@ -1,30 +0,0 @@
import { get, remove, set } from 'local-storage'
import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase'
import { localStorageService } from './localStorageService'
new class extends UnitTestCase {
protected test () {
it('gets an existing item from local storage', () => {
set('foo', 'bar')
expect(localStorageService.get('foo')).toBe('bar')
})
it('returns the default value for a non exising item', () => {
remove('foo')
expect(localStorageService.get('foo', 42)).toBe(42)
})
it('sets an item into local storage', () => {
remove('foo')
localStorageService.set('foo', 42)
expect(get('foo')).toBe(42)
})
it('correctly removes an item from local storage', () => {
set('foo', 42)
localStorageService.remove('foo')
expect(get('foo')).toBeNull()
})
}
}

View file

@ -1,12 +0,0 @@
import { get as baseGet, remove as baseRemove, set as baseSet } from 'local-storage'
export const localStorageService = {
get: <T> (key: string, defaultValue: T | null = null): T | null => {
const value = baseGet<T>(key)
return value === null ? defaultValue : value
},
set: (key: string, value: any) => baseSet(key, value),
remove: (key: string) => baseRemove(key)
}

View file

@ -1,5 +1,5 @@
import { reactive, ref } from 'vue'
import { http, localStorageService } from '@/services'
import { http } from '@/services'
export const defaultPreferences: UserPreferences = {
volume: 7,

View file

@ -3,6 +3,7 @@
--color-text-secondary: rgba(255, 255, 255, .7);
--color-bg-primary: #181818;
--color-bg-secondary: rgba(255, 255, 255, .025);
--color-border: var(--color-bg-secondary);
--color-highlight: #ff7d2e;
--color-accent: var(--color-highlight);
--color-bg-context-menu: var(--color-bg-primary);