mirror of
https://github.com/koel/koel
synced 2024-11-24 05:03:05 +00:00
feat: toggle nav bar
This commit is contained in:
parent
e090ff6aa8
commit
551b5c020a
12 changed files with 148 additions and 70 deletions
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = []
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
26
resources/assets/js/composables/useLocalStorage.ts
Normal file
26
resources/assets/js/composables/useLocalStorage.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
export * from './http'
|
||||
export * from './downloadService'
|
||||
export * from './localStorageService'
|
||||
export * from './playbackService'
|
||||
export * from './youTubeService'
|
||||
export * from './socketService'
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { reactive, ref } from 'vue'
|
||||
import { http, localStorageService } from '@/services'
|
||||
import { http } from '@/services'
|
||||
|
||||
export const defaultPreferences: UserPreferences = {
|
||||
volume: 7,
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue