mirror of
https://github.com/koel/koel
synced 2024-11-24 13:13:05 +00:00
feat: add loading skeletons
This commit is contained in:
parent
feff485d95
commit
2951fa3ddb
26 changed files with 545 additions and 117 deletions
|
@ -14,8 +14,13 @@
|
|||
data-testid="album-list"
|
||||
@scroll="scrolling"
|
||||
>
|
||||
<template v-if="showSkeletons">
|
||||
<AlbumCardSkeleton v-for="i in 10" :key="i" :layout="itemLayout"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AlbumCard v-for="album in albums" :key="album.id" :album="album" :layout="itemLayout"/>
|
||||
<ToTopButton/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
@ -27,6 +32,7 @@ import { albumStore, preferenceStore as preferences } from '@/stores'
|
|||
import { useInfiniteScroll } from '@/composables'
|
||||
|
||||
import AlbumCard from '@/components/album/AlbumCard.vue'
|
||||
import AlbumCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ViewModeSwitch from '@/components/ui/ViewModeSwitch.vue'
|
||||
|
||||
|
@ -40,21 +46,22 @@ const {
|
|||
makeScrollable
|
||||
} = useInfiniteScroll(async () => await fetchAlbums())
|
||||
|
||||
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
|
||||
|
||||
watch(viewMode, () => (preferences.albumsViewMode = viewMode.value))
|
||||
|
||||
let initialized = false
|
||||
let loading = false
|
||||
const loading = ref(false)
|
||||
const page = ref<number | null>(1)
|
||||
|
||||
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
|
||||
const moreAlbumsAvailable = computed(() => page.value !== null)
|
||||
const showSkeletons = computed(() => loading.value && albums.value.length === 0)
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
if (loading || !moreAlbumsAvailable.value) return
|
||||
if (loading.value || !moreAlbumsAvailable.value) return
|
||||
|
||||
loading = true
|
||||
loading.value = true
|
||||
page.value = await albumStore.paginate(page.value!)
|
||||
loading = false
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<section id="albumWrapper">
|
||||
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout">
|
||||
<ScreenHeaderSkeleton v-if="loading"/>
|
||||
|
||||
<ScreenHeader v-if="!loading && album" :layout="songs.length === 0 ? 'collapsed' : headerLayout">
|
||||
{{ album.name }}
|
||||
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
|
@ -35,9 +37,10 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongList ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList v-else ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
|
||||
<section v-if="useLastfm && showingInfo" class="info-wrapper">
|
||||
<section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
|
||||
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||
<div class="inner">
|
||||
<AlbumInfo :album="album" mode="full"/>
|
||||
|
@ -48,29 +51,36 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, secondsToHis } from '@/utils'
|
||||
import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
|
||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
import { useSongList } from '@/composables'
|
||||
import router from '@/router'
|
||||
import { DialogBoxKey } from '@/symbols'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
||||
const AlbumInfo = defineAsyncComponent(() => import('@/components/album/AlbumInfo.vue'))
|
||||
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
|
||||
|
||||
const props = defineProps<{ album: Album }>()
|
||||
const { album } = toRefs(props)
|
||||
const dialog = requireInjection(DialogBoxKey)
|
||||
|
||||
const albumSongs = ref<Song[]>([])
|
||||
const props = defineProps<{ album: number }>()
|
||||
const { album: id } = toRefs(props)
|
||||
|
||||
const album = ref<Album>()
|
||||
const songs = ref<Song[]>([])
|
||||
const showingInfo = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
headerLayout,
|
||||
songs,
|
||||
songList,
|
||||
showingControls,
|
||||
isPhone,
|
||||
|
@ -79,13 +89,13 @@ const {
|
|||
playSelected,
|
||||
toggleControls,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(albumSongs, 'album', { columns: ['track', 'title', 'artist', 'length'] })
|
||||
} = useSongList(songs, 'album', { columns: ['track', 'title', 'artist', 'length'] })
|
||||
|
||||
const useLastfm = toRef(commonStore.state, 'use_last_fm')
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
const showingInfo = ref(false)
|
||||
|
||||
const isNormalArtist = computed(() => {
|
||||
if (!album.value) return true
|
||||
return !artistStore.isVarious(album.value.artist_id) && !artistStore.isUnknown(album.value.artist_id)
|
||||
})
|
||||
|
||||
|
@ -93,12 +103,24 @@ const download = () => downloadService.fromAlbum(album.value)
|
|||
const showInfo = () => (showingInfo.value = true)
|
||||
|
||||
onMounted(async () => {
|
||||
albumSongs.value = await songStore.fetchForAlbum(album.value)
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
[album.value, songs.value] = await Promise.all([
|
||||
albumStore.resolve(id.value),
|
||||
songStore.fetchForAlbum(id.value)
|
||||
])
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
dialog.value.error('Failed to load album. Please try again.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
eventBus.on('SONGS_UPDATED', () => {
|
||||
// if the current album has been deleted, go back to the list
|
||||
albumStore.byId(album.value.id) || router.go('albums')
|
||||
albumStore.byId(id.value) || router.go('albums')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -22,7 +22,9 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="showSkeletons"/>
|
||||
<SongList
|
||||
v-else
|
||||
ref="songList"
|
||||
@sort="sort"
|
||||
@scroll-breakpoint="onScrollBreakpoint"
|
||||
|
@ -41,6 +43,7 @@ import { useSongList } from '@/composables'
|
|||
import router from '@/router'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
||||
const totalSongCount = toRef(commonStore.state, 'song_count')
|
||||
const totalDuration = computed(() => secondsToHis(commonStore.state.song_length))
|
||||
|
@ -64,12 +67,13 @@ const {
|
|||
} = useSongList(toRef(songStore.state, 'songs'), 'all-songs')
|
||||
|
||||
let initialized = false
|
||||
let loading = false
|
||||
const loading = ref(false)
|
||||
let sortField: SongListSortField = 'title' // @todo get from query string
|
||||
let sortOrder: SortOrder = 'asc'
|
||||
|
||||
const page = ref<number | null>(1)
|
||||
const moreSongsAvailable = computed(() => page.value !== null)
|
||||
const showSkeletons = computed(() => loading.value && songs.value.length === 0)
|
||||
|
||||
const sort = async (field: SongListSortField, order: SortOrder) => {
|
||||
page.value = 1
|
||||
|
@ -81,11 +85,11 @@ const sort = async (field: SongListSortField, order: SortOrder) => {
|
|||
}
|
||||
|
||||
const fetchSongs = async () => {
|
||||
if (!moreSongsAvailable.value || loading) return
|
||||
if (!moreSongsAvailable.value || loading.value) return
|
||||
|
||||
loading = true
|
||||
loading.value = true
|
||||
page.value = await songStore.paginate(sortField, sortOrder, page.value!)
|
||||
loading = false
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const playAll = async (shuffle: boolean) => {
|
||||
|
|
|
@ -14,8 +14,13 @@
|
|||
data-testid="artist-list"
|
||||
@scroll="scrolling"
|
||||
>
|
||||
<template v-if="showSkeletons">
|
||||
<ArtistCardSkeleton v-for="i in 10" :key="i" :layout="itemLayout"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ArtistCard v-for="artist in artists" :key="artist.id" :artist="artist" :layout="itemLayout"/>
|
||||
<ToTopButton/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
@ -27,6 +32,7 @@ import { artistStore, preferenceStore as preferences } from '@/stores'
|
|||
import { useInfiniteScroll } from '@/composables'
|
||||
|
||||
import ArtistCard from '@/components/artist/ArtistCard.vue'
|
||||
import ArtistCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ViewModeSwitch from '@/components/ui/ViewModeSwitch.vue'
|
||||
|
||||
|
@ -40,21 +46,22 @@ const {
|
|||
makeScrollable
|
||||
} = useInfiniteScroll(async () => await fetchArtists())
|
||||
|
||||
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
|
||||
|
||||
watch(viewMode, () => preferences.artistsViewMode = viewMode.value)
|
||||
|
||||
let initialized = false
|
||||
let loading = false
|
||||
const loading = ref(false)
|
||||
const page = ref<number | null>(1)
|
||||
|
||||
const itemLayout = computed<ArtistAlbumCardLayout>(() => viewMode.value === 'thumbnails' ? 'full' : 'compact')
|
||||
const moreArtistsAvailable = computed(() => page.value !== null)
|
||||
const showSkeletons = computed(() => loading.value && artists.value.length === 0)
|
||||
|
||||
const fetchArtists = async () => {
|
||||
if (loading || !moreArtistsAvailable.value) return
|
||||
if (loading.value || !moreArtistsAvailable.value) return
|
||||
|
||||
loading = true
|
||||
loading.value = true
|
||||
page.value = await artistStore.paginate(page.value!)
|
||||
loading = false
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
eventBus.on('LOAD_MAIN_CONTENT', async (view: MainViewName) => {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<section id="artistWrapper">
|
||||
<ScreenHeader :layout="songs.length === 0 ? 'collapsed' : headerLayout">
|
||||
<ScreenHeaderSkeleton v-if="loading"/>
|
||||
|
||||
<ScreenHeader v-if="!loading && artist" :layout="songs.length === 0 ? 'collapsed' : headerLayout">
|
||||
{{ artist.name }}
|
||||
<ControlsToggle :showing-controls="showingControls" @toggleControls="toggleControls"/>
|
||||
|
||||
|
@ -35,9 +37,10 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongList ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList v-else ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
|
||||
<section class="info-wrapper" v-if="useLastfm && showingInfo">
|
||||
<section v-if="!loading && useLastfm && showingInfo" class="info-wrapper">
|
||||
<CloseModalBtn class="close-modal" @click="showingInfo = false"/>
|
||||
<div class="inner">
|
||||
<ArtistInfo :artist="artist" mode="full"/>
|
||||
|
@ -48,19 +51,27 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, ref, toRef, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, secondsToHis } from '@/utils'
|
||||
import { eventBus, logger, pluralize, requireInjection, secondsToHis } from '@/utils'
|
||||
import { artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
import { useSongList, useThirdPartyServices } from '@/composables'
|
||||
import router from '@/router'
|
||||
import { DialogBoxKey } from '@/symbols'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
||||
const props = defineProps<{ artist: Artist }>()
|
||||
const { artist } = toRefs(props)
|
||||
const dialog = requireInjection(DialogBoxKey)
|
||||
|
||||
const artistSongs = ref<Song[]>([])
|
||||
const props = defineProps<{ artist: number }>()
|
||||
const { artist: id } = toRefs(props)
|
||||
|
||||
const artist = ref<Artist>()
|
||||
const songs = ref<Song[]>([])
|
||||
const showingInfo = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const {
|
||||
SongList,
|
||||
|
@ -68,7 +79,6 @@ const {
|
|||
ControlsToggle,
|
||||
headerLayout,
|
||||
songList,
|
||||
songs,
|
||||
showingControls,
|
||||
isPhone,
|
||||
onPressEnter,
|
||||
|
@ -76,7 +86,7 @@ const {
|
|||
playSelected,
|
||||
toggleControls,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(artistSongs, 'artist', { columns: ['track', 'title', 'album', 'length'] })
|
||||
} = useSongList(songs, 'artist', { columns: ['track', 'title', 'album', 'length'] })
|
||||
|
||||
const ArtistInfo = defineAsyncComponent(() => import('@/components/artist/ArtistInfo.vue'))
|
||||
const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnCloseModal.vue'))
|
||||
|
@ -84,18 +94,28 @@ const CloseModalBtn = defineAsyncComponent(() => import('@/components/ui/BtnClos
|
|||
const { useLastfm } = useThirdPartyServices()
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
||||
const showingInfo = ref(false)
|
||||
|
||||
const download = () => downloadService.fromArtist(artist.value)
|
||||
const showInfo = () => (showingInfo.value = true)
|
||||
|
||||
onMounted(async () => {
|
||||
artistSongs.value = await songStore.fetchForArtist(artist.value)
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
[artist.value, songs.value] = await Promise.all([
|
||||
artistStore.resolve(id.value),
|
||||
songStore.fetchForArtist(id.value)
|
||||
])
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
dialog.value.error('Failed to load artist. Please try again.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
eventBus.on('SONGS_UPDATED', () => {
|
||||
// if the current artist has been deleted, go back to the list
|
||||
artistStore.byId(artist.value.id) || router.go('artists')
|
||||
artistStore.byId(id.value) || router.go('artists')
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList
|
||||
v-if="songs.length"
|
||||
ref="songList"
|
||||
|
@ -63,10 +64,11 @@ import { eventBus, pluralize } from '@/utils'
|
|||
import { commonStore, favoriteStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
import { useSongList } from '@/composables'
|
||||
import { nextTick, toRef } from 'vue'
|
||||
import { nextTick, ref, toRef } from 'vue'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
||||
const {
|
||||
SongList,
|
||||
|
@ -95,9 +97,12 @@ const download = () => downloadService.fromFavorites()
|
|||
const removeSelected = () => selectedSongs.value.length && favoriteStore.unlike(selectedSongs.value)
|
||||
|
||||
let initialized = false
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchSongs = async () => {
|
||||
loading.value = true
|
||||
await favoriteStore.fetch()
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
sort()
|
||||
}
|
||||
|
|
|
@ -33,8 +33,9 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList
|
||||
v-if="songs.length"
|
||||
v-if="!loading && songs.length"
|
||||
ref="songList"
|
||||
@press:delete="removeSelected"
|
||||
@press:enter="onPressEnter"
|
||||
|
@ -73,6 +74,7 @@ import { MessageToasterKey } from '@/symbols'
|
|||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
||||
const toaster = requireInjection(MessageToasterKey)
|
||||
const playlist = ref<Playlist>()
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="fetchingRandomSongs"/>
|
||||
<SongList
|
||||
v-if="songs.length"
|
||||
ref="songList"
|
||||
|
@ -49,7 +50,7 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { faCoffee } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, toRef } from 'vue'
|
||||
import { computed, ref, toRef } from 'vue'
|
||||
import { logger, pluralize, requireInjection } from '@/utils'
|
||||
import { commonStore, queueStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
|
@ -58,6 +59,7 @@ import { DialogBoxKey } from '@/symbols'
|
|||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
||||
const dialog = requireInjection(DialogBoxKey)
|
||||
const controlConfig: Partial<SongListControlsConfig> = { clearQueue: true }
|
||||
|
@ -80,13 +82,16 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(toRef(queueStore.state, 'songs'), 'queue', { sortable: false })
|
||||
|
||||
const fetchingRandomSongs = ref(false)
|
||||
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
|
||||
|
||||
const playAll = (shuffle = true) => playbackService.queueAndPlay(songs.value, shuffle)
|
||||
|
||||
const shuffleSome = async () => {
|
||||
try {
|
||||
fetchingRandomSongs.value = true
|
||||
await queueStore.fetchRandom()
|
||||
fetchingRandomSongs.value = false
|
||||
await playbackService.playFirstInQueue()
|
||||
} catch (e) {
|
||||
dialog.value.error('Failed to fetch songs to play. Please try again.', 'Error')
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<SongListSkeleton v-if="loading"/>
|
||||
<SongList v-if="songs.length" ref="songList" @press:enter="onPressEnter" @scroll-breakpoint="onScrollBreakpoint"/>
|
||||
|
||||
<ScreenEmptyState v-else>
|
||||
|
@ -39,10 +40,11 @@ import { faClock } from '@fortawesome/free-regular-svg-icons'
|
|||
import { eventBus, pluralize } from '@/utils'
|
||||
import { recentlyPlayedStore } from '@/stores'
|
||||
import { useSongList } from '@/composables'
|
||||
import { toRef } from 'vue'
|
||||
import { ref, toRef } from 'vue'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
||||
const recentlyPlayedSongs = toRef(recentlyPlayedStore.state, 'songs')
|
||||
|
||||
|
@ -66,11 +68,14 @@ const {
|
|||
} = useSongList(recentlyPlayedSongs, 'recently-played', { sortable: false })
|
||||
|
||||
let initialized = false
|
||||
let loading = ref(false)
|
||||
|
||||
eventBus.on({
|
||||
'LOAD_MAIN_CONTENT': async (view: MainViewName) => {
|
||||
if (view === 'RecentlyPlayed' && !initialized) {
|
||||
loading.value = true
|
||||
await recentlyPlayedStore.fetch()
|
||||
loading.value = false
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,30 @@
|
|||
<template>
|
||||
<section>
|
||||
<h1>Top Albums</h1>
|
||||
|
||||
<ol v-if="loading" class="two-cols top-album-list">
|
||||
<li v-for="i in 4" :key="i">
|
||||
<AlbumCardSkeleton layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
<template v-else>
|
||||
<ol v-if="albums.length" class="two-cols top-album-list">
|
||||
<li v-for="album in albums" :key="album.id">
|
||||
<AlbumCard :album="album" layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="text-secondary">No albums found.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRef } from 'vue'
|
||||
import { overviewStore } from '@/stores'
|
||||
|
||||
import AlbumCard from '@/components/album/AlbumCard.vue'
|
||||
import AlbumCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
|
||||
|
||||
const albums = toRef(overviewStore.state, 'mostPlayedAlbums')
|
||||
const loading = toRef(overviewStore.state, 'loading')
|
||||
</script>
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
<template>
|
||||
<section>
|
||||
<h1>Top Artists</h1>
|
||||
|
||||
<ol v-if="loading" class="two-cols top-album-list">
|
||||
<li v-for="i in 4" :key="i">
|
||||
<ArtistCardSkeleton layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
<template v-else>
|
||||
<ol v-if="artists.length" class="two-cols top-artist-list">
|
||||
<li v-for="artist in artists" :key="artist.id">
|
||||
<ArtistCard :artist="artist" layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="text-secondary">No artists found.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
@ -15,6 +23,8 @@ import { toRef } from 'vue'
|
|||
import { overviewStore } from '@/stores'
|
||||
|
||||
import ArtistCard from '@/components/artist/ArtistCard.vue'
|
||||
import ArtistCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
|
||||
|
||||
const artists = toRef(overviewStore.state, 'mostPlayedArtists')
|
||||
const loading = toRef(overviewStore.state, 'loading')
|
||||
</script>
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
<template>
|
||||
<section>
|
||||
<h1>Most Played</h1>
|
||||
<ol v-if="loading" class="top-song-list">
|
||||
<li v-for="i in 3" :key="i">
|
||||
<SongCardSkeleton/>
|
||||
</li>
|
||||
</ol>
|
||||
<template v-else>
|
||||
<ol v-if="songs.length" class="top-song-list">
|
||||
<li v-for="song in songs" :key="song.id">
|
||||
<SongCard :song="song"/>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="text-secondary">You don’t seem to have been playing.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
@ -15,6 +22,8 @@ import { toRef } from 'vue'
|
|||
import { overviewStore } from '@/stores'
|
||||
|
||||
import SongCard from '@/components/song/SongCard.vue'
|
||||
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
|
||||
|
||||
const songs = toRef(overviewStore.state, 'mostPlayedSongs')
|
||||
const loading = toRef(overviewStore.state, 'loading')
|
||||
</script>
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
<template>
|
||||
<section>
|
||||
<h1>New Albums</h1>
|
||||
|
||||
<ol v-if="loading" class="recently-added-album-list">
|
||||
<li v-for="i in 2" :key="i">
|
||||
<AlbumCardSkeleton layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
<template v-else>
|
||||
<ol v-if="albums.length" class="recently-added-album-list">
|
||||
<li v-for="album in albums" :key="album.id">
|
||||
<AlbumCard :album="album" layout="compact"/>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-else class="text-secondary">No albums added yet.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
@ -16,6 +23,8 @@ import { toRef } from 'vue'
|
|||
import { overviewStore } from '@/stores'
|
||||
|
||||
import AlbumCard from '@/components/album/AlbumCard.vue'
|
||||
import AlbumCardSkeleton from '@/components/ui/skeletons/ArtistAlbumCardSkeleton.vue'
|
||||
|
||||
const albums = toRef(overviewStore.state, 'recentlyAddedAlbums')
|
||||
const loading = toRef(overviewStore.state, 'loading')
|
||||
</script>
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
<template>
|
||||
<section>
|
||||
<h1>New Songs</h1>
|
||||
<ol v-if="loading" class="recently-added-song-list">
|
||||
<li v-for="i in 3" :key="i">
|
||||
<SongCardSkeleton/>
|
||||
</li>
|
||||
</ol>
|
||||
<template v-else>
|
||||
<ol v-if="songs.length" class="recently-added-song-list">
|
||||
<li v-for="song in songs" :key="song.id">
|
||||
<SongCard :song="song"/>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="text-secondary">No songs added so far.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
@ -15,6 +22,8 @@ import { toRef } from 'vue'
|
|||
import { overviewStore } from '@/stores'
|
||||
|
||||
import SongCard from '@/components/song/SongCard.vue'
|
||||
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
|
||||
|
||||
const songs = toRef(overviewStore.state, 'recentlyAddedSongs')
|
||||
const loading = toRef(overviewStore.state, 'loading')
|
||||
</script>
|
||||
|
|
|
@ -14,25 +14,33 @@
|
|||
</Btn>
|
||||
</h1>
|
||||
|
||||
<ol v-if="loading" class="recent-song-list">
|
||||
<li v-for="i in 3" :key="i">
|
||||
<SongCardSkeleton/>
|
||||
</li>
|
||||
</ol>
|
||||
<template v-else>
|
||||
<ol v-if="songs.length" class="recent-song-list">
|
||||
<li v-for="song in songs" :key="song.id">
|
||||
<SongCard :song="song"/>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-else class="text-secondary">No songs played as of late.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRef } from 'vue'
|
||||
import router from '@/router'
|
||||
import { recentlyPlayedStore } from '@/stores'
|
||||
import { overviewStore, recentlyPlayedStore } from '@/stores'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import SongCard from '@/components/song/SongCard.vue'
|
||||
import SongCardSkeleton from '@/components/ui/skeletons/SongCardSkeleton.vue'
|
||||
|
||||
const songs = toRef(recentlyPlayedStore.excerptState, 'songs')
|
||||
const loading = toRef(overviewStore.state, 'loading')
|
||||
|
||||
const goToRecentlyPlayedScreen = () => router.go('recently-played')
|
||||
</script>
|
||||
|
|
|
@ -8,40 +8,35 @@
|
|||
@contextmenu.prevent="requestContextMenu"
|
||||
@dblclick.prevent="play"
|
||||
>
|
||||
<span :style="{ backgroundImage: `url(${song.album_cover ?? ''}), url(${defaultCover})` }" class="cover">
|
||||
<aside :style="{ backgroundImage: `url(${song.album_cover ?? ''}), url(${defaultCover})` }" class="cover">
|
||||
<a class="control" @click.prevent="changeSongState" data-testid="play-control">
|
||||
<icon :icon="song.playback_state === 'Playing' ? faPause : faPlay" class="text-highlight"/>
|
||||
</a>
|
||||
</span>
|
||||
<span class="main">
|
||||
<span class="details">
|
||||
<span v-if="showPlayCount" :style="{ width: `${song.play_count*100/topPlayCount}%` }" class="play-count"/>
|
||||
{{ song.title }}
|
||||
<span class="by text-secondary">
|
||||
</aside>
|
||||
<main>
|
||||
<div class="details">
|
||||
<h3>{{ song.title }}</h3>
|
||||
<p class="by text-secondary">
|
||||
<a :href="`#!/artist/${song.artist_id}`">{{ song.artist_name }}</a>
|
||||
<template v-if="showPlayCount"> - {{ pluralize(song.play_count, 'play') }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="favorite">
|
||||
- {{ pluralize(song.play_count, 'play') }}
|
||||
</p>
|
||||
</div>
|
||||
<LikeButton :song="song"/>
|
||||
</span>
|
||||
</span>
|
||||
</main>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { toRefs } from 'vue'
|
||||
import { defaultCover, eventBus, pluralize, startDragging } from '@/utils'
|
||||
import { queueStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
|
||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ song: Song, topPlayCount?: number }>(), { topPlayCount: 0 })
|
||||
const { song, topPlayCount } = toRefs(props)
|
||||
|
||||
const showPlayCount = computed(() => Boolean(topPlayCount && song.value.play_count))
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, song.value, 'Song')
|
||||
|
@ -80,12 +75,18 @@ article {
|
|||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.favorite {
|
||||
button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
@ -146,10 +147,10 @@ article {
|
|||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
main {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.play-count {
|
||||
|
@ -162,7 +163,6 @@ article {
|
|||
}
|
||||
|
||||
.by {
|
||||
display: block;
|
||||
font-size: .9rem;
|
||||
opacity: .8;
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<header class="screen-header" :class="layout">
|
||||
<div class="thumbnail-wrapper">
|
||||
<aside class="thumbnail-wrapper">
|
||||
<slot name="thumbnail"></slot>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="right">
|
||||
<main>
|
||||
<div class="heading-wrapper">
|
||||
<h1 class="name">
|
||||
<slot></slot>
|
||||
|
@ -15,7 +15,7 @@
|
|||
</div>
|
||||
|
||||
<slot name="controls"></slot>
|
||||
</div>
|
||||
</main>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
|
@ -53,7 +53,7 @@ header.screen-header {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.right {
|
||||
main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
@ -77,7 +77,7 @@ header.screen-header {
|
|||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<article class="skeleton" :class="layout">
|
||||
<aside class="thumbnail pulse"/>
|
||||
|
||||
<footer>
|
||||
<p class="name pulse"/>
|
||||
<p class="artist pulse"/>
|
||||
<p class="meta pulse"/>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = withDefaults(defineProps<{ layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
max-width: 256px;
|
||||
|
||||
aside {
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
|
||||
p {
|
||||
height: 1.2em;
|
||||
width: 80%;
|
||||
|
||||
&.artist {
|
||||
width: 33%;
|
||||
}
|
||||
|
||||
&.meta {
|
||||
width: 55%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
aside {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<header class="skeleton screen-header expanded">
|
||||
<aside class="thumbnail-wrapper pulse"/>
|
||||
|
||||
<main>
|
||||
<h1 class="pulse"/>
|
||||
<p class="meta pulse"/>
|
||||
<p class="controls pulse"/>
|
||||
</main>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
align-content: stretch;
|
||||
padding: 1.8rem;
|
||||
|
||||
.thumbnail-wrapper {
|
||||
margin-right: 1.5rem;
|
||||
width: 192px;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 50%;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
height: 4rem;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
p {
|
||||
height: 1.2rem;
|
||||
width: 40%;
|
||||
|
||||
&.controls {
|
||||
height: 2.5rem;
|
||||
border-radius: 999px;
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,51 @@
|
|||
<template>
|
||||
<article class="skeleton">
|
||||
<aside class="pulse"/>
|
||||
<main>
|
||||
<div class="details">
|
||||
<h3 class="pulse"/>
|
||||
<p class="pulse"/>
|
||||
</div>
|
||||
</main>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
border-radius: 5px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
|
||||
aside {
|
||||
width: 48px;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h3, p {
|
||||
width: 66%;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
p {
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
102
resources/assets/js/components/ui/skeletons/SongListSkeleton.vue
Normal file
102
resources/assets/js/components/ui/skeletons/SongListSkeleton.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<div class="skeleton">
|
||||
<div class="song-list-header">
|
||||
<span class="track-number">
|
||||
<span class="text"/>
|
||||
</span>
|
||||
<span class="title">
|
||||
<span class="text"/>
|
||||
</span>
|
||||
<span class="artist">
|
||||
<span class="text"/>
|
||||
</span>
|
||||
<span class="album">
|
||||
<span class="text"/>
|
||||
</span>
|
||||
<span class="time">
|
||||
<span class="text"/>
|
||||
</span>
|
||||
<span class="favorite"/>
|
||||
</div>
|
||||
<div v-for="i in 40" :key="i" class="song-item">
|
||||
<span class="track-number">
|
||||
<span class="text pulse"/>
|
||||
</span>
|
||||
<span class="title">
|
||||
<span class="text pulse"/>
|
||||
</span>
|
||||
<span class="artist">
|
||||
<span class="text pulse"/>
|
||||
</span>
|
||||
<span class="album">
|
||||
<span class="text pulse"/>
|
||||
</span>
|
||||
<span class="time">
|
||||
<span class="text pulse"/>
|
||||
</span>
|
||||
<span class="favorite">
|
||||
<span class="text pulse"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
.song-list-header {
|
||||
background: var(--color-bg-secondary);
|
||||
height: 35px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-bg-secondary);
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.song-list-header span span, .pulse {
|
||||
display: inline-block;
|
||||
height: 1.2rem;
|
||||
border-radius: 999px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.song-list-header span span {
|
||||
background: rgba(255, 255, 255, .1);
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
span:not(.text) {
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.track-number {
|
||||
flex-basis: 66px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.artist {
|
||||
flex-basis: 23%;
|
||||
}
|
||||
|
||||
.album {
|
||||
flex-basis: 27%;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex-basis: 96px;
|
||||
padding-right: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.favorite {
|
||||
flex-basis: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,6 @@
|
|||
import isMobile from 'ismobilejs'
|
||||
import { loadMainView, use } from '@/utils'
|
||||
import { albumStore, artistStore, playlistStore, queueStore, songStore, userStore } from '@/stores'
|
||||
import { playlistStore, queueStore, songStore, userStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
|
||||
class Router {
|
||||
|
@ -23,8 +23,8 @@ class Router {
|
|||
'/youtube': () => loadMainView('YouTube'),
|
||||
'/visualizer': () => loadMainView('Visualizer'),
|
||||
'/profile': () => loadMainView('Profile'),
|
||||
'/album/(\\d+)': async (id: number) => loadMainView('Album', await albumStore.resolve(id)),
|
||||
'/artist/(\\d+)': async (id: number) => loadMainView('Artist', await artistStore.resolve(id)),
|
||||
'/album/(\\d+)': async (id: string) => loadMainView('Album', parseInt(id)),
|
||||
'/artist/(\\d+)': async (id: string) => loadMainView('Artist', parseInt(id)),
|
||||
'/playlist/(\\d+)': (id: number) => use(playlistStore.byId(~~id), playlist => loadMainView('Playlist', playlist)),
|
||||
'/song/([a-z0-9]{32})': async (id: string) => {
|
||||
const song = await songStore.resolve(id)
|
||||
|
|
|
@ -12,10 +12,13 @@ export const overviewStore = {
|
|||
recentlyAddedAlbums: [],
|
||||
mostPlayedSongs: [],
|
||||
mostPlayedAlbums: [],
|
||||
mostPlayedArtists: []
|
||||
mostPlayedArtists: [],
|
||||
loading: false
|
||||
}),
|
||||
|
||||
async init () {
|
||||
this.state.loading = true
|
||||
|
||||
const resource = await httpService.get<{
|
||||
most_played_songs: Song[],
|
||||
most_played_albums: Album[],
|
||||
|
@ -32,6 +35,7 @@ export const overviewStore = {
|
|||
recentlyPlayedStore.excerptState.songs = songStore.syncWithVault(resource.recently_played_songs)
|
||||
|
||||
this.refresh()
|
||||
this.state.loading = false
|
||||
},
|
||||
|
||||
refresh () {
|
||||
|
|
|
@ -154,17 +154,21 @@ export const songStore = {
|
|||
})
|
||||
},
|
||||
|
||||
async fetchForAlbum (album: Album) {
|
||||
async fetchForAlbum (album: Album | number) {
|
||||
const id = typeof album === 'number' ? album : album.id
|
||||
|
||||
return await cache.remember<Song[]>(
|
||||
[`album.songs`, album.id],
|
||||
async () => this.syncWithVault(await httpService.get<Song[]>(`albums/${album.id}/songs`))
|
||||
[`album.songs`, id],
|
||||
async () => this.syncWithVault(await httpService.get<Song[]>(`albums/${id}/songs`))
|
||||
)
|
||||
},
|
||||
|
||||
async fetchForArtist (artist: Artist) {
|
||||
async fetchForArtist (artist: Artist | number) {
|
||||
const id = typeof artist === 'number' ? artist : artist.id
|
||||
|
||||
return await cache.remember<Song[]>(
|
||||
['artist.songs', artist.id],
|
||||
async () => this.syncWithVault(await httpService.get<Song[]>(`artists/${artist.id}/songs`))
|
||||
['artist.songs', id],
|
||||
async () => this.syncWithVault(await httpService.get<Song[]>(`artists/${id}/songs`))
|
||||
)
|
||||
},
|
||||
|
||||
|
|
|
@ -9,6 +9,6 @@
|
|||
|
||||
@import "#/vendor/_plyr.scss";
|
||||
@import "#/vendor/_nprogress.scss";
|
||||
@import "#/vendor/_alertify.scss";
|
||||
|
||||
@import "#/partials/_skeleton.scss";
|
||||
@import "#/partials/_shared.scss";
|
||||
|
|
16
resources/assets/sass/partials/_skeleton.scss
Normal file
16
resources/assets/sass/partials/_skeleton.scss
Normal file
|
@ -0,0 +1,16 @@
|
|||
.skeleton {
|
||||
.pulse {
|
||||
animation: skeleton-pulse 2s infinite;
|
||||
background-color: rgba(255, 255, 255, .05);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue