Refactor(front): implement responsive global page layout and improve mobile UX
closes #2090 #2401 Co-Authored-By: ArneBo <arne@ecobasa.org> Co-Authored-By: Flupsi <upsiflu@gmail.com> Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
parent
a728e48110
commit
eadaa72c27
|
@ -0,0 +1 @@
|
|||
Improve mobile design (#2090)
|
|
@ -1,26 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { QueueTrack } from '~/composables/audio/queue'
|
||||
import { watchEffect, computed, onMounted, nextTick } from 'vue'
|
||||
|
||||
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, watchEffect, defineAsyncComponent } from 'vue'
|
||||
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { type QueueTrack, useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import { useStyleTag, useIntervalFn } from '@vueuse/core'
|
||||
import { color } from '~/composables/color'
|
||||
|
||||
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
|
||||
|
||||
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 PlaylistModal from '~/components/playlists/PlaylistModal.vue'
|
||||
import FilterModal from '~/components/moderation/FilterModal.vue'
|
||||
import ReportModal from '~/components/moderation/ReportModal.vue'
|
||||
import ServiceMessages from '~/components/ServiceMessages.vue'
|
||||
import AudioPlayer from '~/components/audio/Player.vue'
|
||||
import Queue from '~/components/Queue.vue'
|
||||
import Sidebar from '~/ui/components/Sidebar.vue'
|
||||
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
|
||||
import LanguagesModal from '~/ui/modals/Language.vue'
|
||||
import SearchModal from '~/ui/modals/Search.vue'
|
||||
import UploadModal from '~/ui/modals/Upload.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
|
||||
// Fake content
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
document.getElementById('fake-app')?.remove()
|
||||
})
|
||||
|
||||
const logger = useLogger()
|
||||
logger.debug('App setup()')
|
||||
|
@ -28,7 +34,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
|
||||
|
@ -50,10 +56,6 @@ watchEffect(() => {
|
|||
})
|
||||
|
||||
// Styles
|
||||
const customStylesheets = computed(() => {
|
||||
return store.state.instance.frontSettings.additionalStylesheets ?? []
|
||||
})
|
||||
|
||||
useStyleTag(computed(() => store.state.instance.settings.ui.custom_css.value))
|
||||
|
||||
// Fake content
|
||||
|
@ -68,63 +70,74 @@ useIntervalFn(() => {
|
|||
store.commit('ui/computeLastDate')
|
||||
}, 1000 * 60)
|
||||
|
||||
// Shortcuts
|
||||
const [showShortcutsModal, toggleShortcutsModal] = useToggle(false)
|
||||
onKeyboardShortcut('h', () => toggleShortcutsModal())
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// Fetch user data on startup
|
||||
// NOTE: We're not checking if we're authenticated in the store,
|
||||
// because we want to learn if we are authenticated at all
|
||||
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"
|
||||
<div class="funkwhale responsive">
|
||||
<Sidebar />
|
||||
<RouterView
|
||||
v-slot="{ Component }"
|
||||
v-bind="color({}, ['default', 'solid'])()"
|
||||
:class="$style.layout"
|
||||
>
|
||||
|
||||
<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">
|
||||
<Transition
|
||||
v-if="Component"
|
||||
name="main"
|
||||
mode="out-in"
|
||||
>
|
||||
<KeepAlive :max="10">
|
||||
<Suspense>
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
<!-- TODO (wvffle): Add loader -->
|
||||
{{ $t('App.loading') }}
|
||||
<Loader />
|
||||
</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" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
<transition name="queue">
|
||||
<Queue v-show="store.state.ui.queueFocused" />
|
||||
</transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
<AudioPlayer
|
||||
class="funkwhale"
|
||||
v-bind="color({}, ['default', 'solid'])()"
|
||||
/>
|
||||
<ServiceMessages />
|
||||
<LanguagesModal />
|
||||
<ShortcutsModal />
|
||||
<PlaylistModal v-if="store.state.auth.authenticated" />
|
||||
<FilterModal v-if="store.state.auth.authenticated" />
|
||||
<ReportModal />
|
||||
<UploadModal v-if="store.state.auth.authenticated" />
|
||||
<SearchModal />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.responsive {
|
||||
display: grid !important;
|
||||
grid-template-rows: min-content;
|
||||
min-height: calc(100vh - 64px);
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 100% 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Make inert pages (behind modals) unscrollable */
|
||||
body:has(#app[inert="true"]) {
|
||||
overflow:hidden;
|
||||
}
|
||||
</style>
|
||||
<style module>
|
||||
.layout {
|
||||
padding: 32px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -111,6 +111,9 @@ export const useQueue = createGlobalState(() => {
|
|||
const { uploads } = await axios.get(`tracks/${track.id}/`)
|
||||
.then(response => response.data as Track, () => ({ uploads: [] as Upload[] }))
|
||||
|
||||
// TODO: Either make `track` a writable ref or implement the client/cache model
|
||||
// See Issue: https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/2437
|
||||
// @ts-expect-error `track` is read-only
|
||||
track.uploads = uploads
|
||||
}
|
||||
|
||||
|
@ -123,11 +126,11 @@ export const useQueue = createGlobalState(() => {
|
|||
artistId: (track.artist_credit && track.artist_credit[0] && track.artist_credit[0].artist.id) ?? -1,
|
||||
albumId: track.album?.id ?? -1,
|
||||
coverUrl: (
|
||||
(track.cover?.urls)
|
||||
|| (track.album?.cover?.urls)
|
||||
|| ((track.artist_credit && track.artist_credit[0] && track.artist_credit[0].artist && track.artist_credit[0].artist.cover?.urls))
|
||||
|| {}
|
||||
)?.original ?? new URL('../../assets/audio/default-cover.png', import.meta.url).href,
|
||||
track.cover?.urls.original
|
||||
|| track.album?.cover?.urls.original
|
||||
|| track.artist_credit?.[0]?.artist.cover?.urls.original
|
||||
|| new URL('../../assets/audio/default-cover.png', import.meta.url).href
|
||||
).toString(),
|
||||
sources: track.uploads.map(upload => ({
|
||||
uuid: upload.uuid,
|
||||
duration: upload.duration,
|
||||
|
|
|
@ -1,6 +1,17 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { forceInstanceChooser } from './guards'
|
||||
import routes from './routes'
|
||||
|
||||
import routesV1 from './routes'
|
||||
import routesV2 from '~/ui/routes'
|
||||
|
||||
// TODO:
|
||||
// Research...
|
||||
// - "What is the use case for this toggle?"
|
||||
// - "Is Local Storage (persistence on a specific browser
|
||||
// on a specific machine) the right place?"
|
||||
const isUIv2 = useLocalStorage('ui-v2', true)
|
||||
const routes = isUIv2.value ? routesV2 : routesV1
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.VUE_APP_ROUTER_BASE_URL as string ?? '/'),
|
||||
|
|
|
@ -0,0 +1,473 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import { useUploadsStore } from '../stores/upload'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
|
||||
import Logo from '~/components/Logo.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
import Popover from '~/components/ui/Popover.vue'
|
||||
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
const route = useRoute()
|
||||
watch(() => route.path, () => isCollapsed.value = true)
|
||||
|
||||
// Hide the fake app when the real one is loaded
|
||||
onMounted(() => {
|
||||
document.getElementById('fake-app')?.remove()
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { value: searchParameter } = useModal('search')
|
||||
|
||||
const store = useStore()
|
||||
const uploads = useUploadsStore()
|
||||
const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index' : 'index')
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// Search bar focus
|
||||
|
||||
const isFocusingSearch = ref<true | undefined>(undefined)
|
||||
const focusSearch = () => {
|
||||
isFocusingSearch.value = undefined
|
||||
nextTick(() => {
|
||||
isFocusingSearch.value = true
|
||||
})
|
||||
}
|
||||
onKeyboardShortcut(['shift', 'f'], focusSearch, true)
|
||||
onKeyboardShortcut(['ctrl', 'k'], focusSearch, true)
|
||||
onKeyboardShortcut(['/'], focusSearch, true)
|
||||
|
||||
// Admin notifications
|
||||
|
||||
const moderationNotifications = computed(() =>
|
||||
store.state.ui.notifications.pendingReviewEdits
|
||||
+ store.state.ui.notifications.pendingReviewReports
|
||||
+ store.state.ui.notifications.pendingReviewRequests
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout
|
||||
aside
|
||||
default
|
||||
raised
|
||||
solid
|
||||
gap-12
|
||||
:class="[$style.sidebar, $style['sticky-content']]"
|
||||
>
|
||||
<Layout
|
||||
header
|
||||
flex
|
||||
no-gap
|
||||
style="justify-content:space-between; align-items:center; padding-right:8px;"
|
||||
>
|
||||
<Link
|
||||
:to="{name: logoUrl}"
|
||||
:class="$style['logo']"
|
||||
>
|
||||
<i>
|
||||
<Logo />
|
||||
<span class="visually-hidden">{{ t('components.Sidebar.link.home') }}</span>
|
||||
</i>
|
||||
</Link>
|
||||
|
||||
<Layout
|
||||
nav
|
||||
gap-8
|
||||
flex
|
||||
style="align-items: center;"
|
||||
>
|
||||
<Popover
|
||||
v-if="store.state.auth.availablePermissions['settings'] || store.state.auth.availablePermissions['moderation']"
|
||||
v-model="isOpen"
|
||||
raised
|
||||
>
|
||||
<Button
|
||||
v-if="store.state.auth.availablePermissions['settings'] || store.state.auth.availablePermissions['moderation']"
|
||||
round
|
||||
square-small
|
||||
ghost
|
||||
icon="bi-wrench"
|
||||
:aria-pressed="isOpen ? true : undefined"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<div
|
||||
v-if="moderationNotifications > 0"
|
||||
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
|
||||
>
|
||||
{{ moderationNotifications }}
|
||||
</div>
|
||||
</Button>
|
||||
<template #items>
|
||||
<PopoverItem
|
||||
v-if="store.state.auth.availablePermissions['library']"
|
||||
:to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"
|
||||
icon="bi-music-note-beamed"
|
||||
>
|
||||
{{ t('components.Sidebar.link.library') }}
|
||||
<div
|
||||
v-if="store.state.ui.notifications.pendingReviewEdits > 0"
|
||||
:title="t('components.Sidebar.label.edits')"
|
||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
||||
>
|
||||
{{ store.state.ui.notifications.pendingReviewEdits }}
|
||||
</div>
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="store.state.auth.availablePermissions['moderation']"
|
||||
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"
|
||||
icon="bi-megaphone-fill"
|
||||
>
|
||||
{{ t('components.Sidebar.link.moderation') }}
|
||||
<div
|
||||
v-if="store.state.ui.notifications.pendingReviewReports + store.state.ui.notifications.pendingReviewRequests > 0"
|
||||
:title="t('components.Sidebar.label.reports')"
|
||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
||||
>
|
||||
{{ store.state.ui.notifications.pendingReviewReports + store.state.ui.notifications.pendingReviewRequests }}
|
||||
</div>
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="store.state.auth.availablePermissions['settings']"
|
||||
:to="{name: 'manage.users.users.list'}"
|
||||
icon="bi-people-fill"
|
||||
>
|
||||
{{ t('components.Sidebar.link.users') }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="store.state.auth.availablePermissions['settings']"
|
||||
:to="{path: '/manage/settings'}"
|
||||
icon="bi-wrench"
|
||||
>
|
||||
{{ t('components.Sidebar.link.settings') }}
|
||||
</PopoverItem>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<Link
|
||||
v-if="store.state.auth.authenticated"
|
||||
round
|
||||
square-small
|
||||
icon="bi-upload"
|
||||
ghost
|
||||
:to="useModal('upload').to"
|
||||
>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="uploads.currentIndex < uploads.queue.length"
|
||||
:class="$style['upload-progress']"
|
||||
>
|
||||
<div :class="[$style.progress, $style.fake]" />
|
||||
<div
|
||||
:class="$style.progress"
|
||||
:style="{ maxWidth: `${uploads.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Link>
|
||||
|
||||
<UserMenu />
|
||||
|
||||
<Button
|
||||
round
|
||||
ghost
|
||||
square-small
|
||||
icon="bi-list large"
|
||||
class="hide-on-desktop"
|
||||
:class="$style.menu"
|
||||
:aria-pressed="isCollapsed ? undefined : true"
|
||||
@click="isCollapsed=!isCollapsed"
|
||||
/>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<Layout
|
||||
no-gap
|
||||
stack
|
||||
:class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']"
|
||||
>
|
||||
<Input
|
||||
:key="isFocusingSearch ? 1 : 0"
|
||||
v-model="searchParameter"
|
||||
:autofocus="isFocusingSearch"
|
||||
raised
|
||||
autocomplete="search"
|
||||
type="search"
|
||||
icon="bi-search"
|
||||
:placeholder="t('components.audio.SearchBar.placeholder.search')"
|
||||
/>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<!-- Sign up, Log in -->
|
||||
<div
|
||||
v-if="!store.state.auth.authenticated"
|
||||
style="display: contents;"
|
||||
>
|
||||
<Layout
|
||||
flex
|
||||
gap-16
|
||||
>
|
||||
<Link
|
||||
:to="{ name: 'login' }"
|
||||
solid
|
||||
auto
|
||||
grow
|
||||
icon="bi-box-arrow-in-right"
|
||||
class="active"
|
||||
>
|
||||
{{ t('components.common.UserMenu.link.login') }}
|
||||
</Link>
|
||||
<Link
|
||||
:to="{ name: 'signup' }"
|
||||
default
|
||||
solid
|
||||
auto
|
||||
grow
|
||||
icon="bi-person-square"
|
||||
>
|
||||
{{ t('components.common.UserMenu.link.signup') }}
|
||||
</Link>
|
||||
</Layout>
|
||||
<Spacer grow />
|
||||
</div>
|
||||
|
||||
<nav style="display:grid;">
|
||||
<Link
|
||||
to="/library"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
icon="bi-compass"
|
||||
thick-when-active
|
||||
>
|
||||
{{ t('components.Sidebar.header.explore') }}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/library/artists"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
thin-font
|
||||
icon="bi-person-circle"
|
||||
thick-when-active
|
||||
>
|
||||
{{ t('components.Sidebar.link.artists') }}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/library/channels"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
thin-font
|
||||
icon="bi-person-square"
|
||||
thick-when-active
|
||||
>
|
||||
{{ t('components.Sidebar.link.channels') }}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/library/albums"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
thin-font
|
||||
icon="bi-disc"
|
||||
thick-when-active
|
||||
>
|
||||
{{ t('components.Sidebar.link.albums') }}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/library/playlists"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
thin-font
|
||||
icon="bi-music-note-list"
|
||||
thick-when-active
|
||||
>
|
||||
{{ t('components.Sidebar.link.playlists') }}
|
||||
</Link>
|
||||
<Link
|
||||
to="/library/radios"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
thin-font
|
||||
icon="bi-boombox-fill"
|
||||
thick-when-active
|
||||
>
|
||||
{{ t('components.Sidebar.link.radios') }}
|
||||
</Link>
|
||||
<Link
|
||||
to="/library/podcasts"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
thin-font
|
||||
icon="bi-mic"
|
||||
thick-when-active
|
||||
>
|
||||
{{ t('components.Sidebar.link.podcasts') }}
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
ghost
|
||||
full
|
||||
align-text="start"
|
||||
thin-font
|
||||
icon="bi-heart"
|
||||
thick-when-active
|
||||
:disabled="!store.state.auth.authenticated || undefined"
|
||||
>
|
||||
{{ t('components.Sidebar.link.favorites') }}
|
||||
</Link>
|
||||
</nav>
|
||||
<Spacer grow />
|
||||
<Layout
|
||||
nav
|
||||
flex
|
||||
no-gap
|
||||
style="justify-content: center"
|
||||
>
|
||||
<Link
|
||||
thin-font
|
||||
to="/about"
|
||||
>
|
||||
{{ t('components.Sidebar.link.about') }}
|
||||
</Link>
|
||||
<Spacer shrink />
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.sidebar {
|
||||
.logo {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
&.sticky-content {
|
||||
overflow: auto;
|
||||
top: 0;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
aspect-ratio: 1;
|
||||
background: var(--fw-beige-100);
|
||||
border-radius: 100%;
|
||||
|
||||
text-decoration: none !important;
|
||||
color: var(--fw-gray-700);
|
||||
|
||||
> img,
|
||||
> i {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 !important;
|
||||
border-radius: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
> h3 {
|
||||
margin: 0;
|
||||
padding: 0 32px 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.menu-links {
|
||||
// Bottom padding is mainly offsetting player-bar
|
||||
padding: 0 16px 72px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.hide-on-mobile) {
|
||||
max-height: 0px;
|
||||
min-height: 0px;
|
||||
overflow: hidden;
|
||||
transition: all .8s;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
height: 100%;
|
||||
|
||||
:global(.hide-on-desktop) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:global(.hide-on-mobile) {
|
||||
max-height: 100dvh;
|
||||
pointer-events: unset;
|
||||
}
|
||||
|
||||
&.sticky-content {
|
||||
position: sticky;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue