mirror of
https://github.com/JuLi0n21/pwa-player.git
synced 2026-04-19 15:30:05 +00:00
add skeleton loading
This commit is contained in:
@@ -6,7 +6,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
try_files $uri $uri/ /index.html; # Redirect all non-static routes to index.html
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
error_page 404 /index.html;
|
||||
|
||||
@@ -40,7 +40,14 @@ function highlightText(text: string, searchterm: string) {
|
||||
<ul>
|
||||
<li v-for="(song, index) in props.songs" :key="index" class="rounded-lg">
|
||||
<button @click="update(song.hash)" class="flex">
|
||||
<img :src="encodeURI(`${userStore.cloudflareUrl.value}/${song.previewimage ? song.previewimage + '?h=120&w=120' : '/default-bg.png'}`)" class="w-12 h-12" />
|
||||
<img
|
||||
:src="
|
||||
encodeURI(
|
||||
`${userStore.cloudflareUrl.value}/${song.previewimage ? song.previewimage + '?h=120&w=120' : '/default-bg.png'}`,
|
||||
)
|
||||
"
|
||||
class="w-12 h-12"
|
||||
/>
|
||||
<p class="ml-2 overflow-hidden text-ellipsis text-nowrap">
|
||||
<span v-html="highlightText(song.name, search)"></span> -
|
||||
<span v-html="highlightText(song.artist, props.search)"></span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import SongItem from "../components/SongItem.vue";
|
||||
import SongItemSkeleton from "./SongItemSkeleton.vue";
|
||||
import { type Song, mapApiToCollection } from "../script/types";
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
@@ -87,5 +88,9 @@ onBeforeUnmount(() => {
|
||||
<div class="flex-col flex-1 justify-start overflow-y-scroll coll-container">
|
||||
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
||||
</div>
|
||||
|
||||
<template v-if="loading">
|
||||
<SongItemSkeleton v-for="i in 10" :key="'skeleton-' + i" />
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -7,12 +7,15 @@ const props = defineProps<{ collection: CollectionPreview }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink :to="'/collection/' + props.collection.index">
|
||||
<div class="flex border rounded-lg bordercolor">
|
||||
<img class="m-2 rounded-lg w-20 h-20" :src="encodeURI(`${userStore.cloudflareUrl.value}${props.collection.previewimage}`)" loading="lazy" />
|
||||
<div class="flex flex-col">
|
||||
<h3 class="self-start info">{{ props.collection.name }}</h3>
|
||||
<h5 class="self-start text-sm info">{{ props.collection.length }} Songs</h5>
|
||||
<RouterLink :to="'/collection/' + props.collection.index" class="w-full">
|
||||
<div class="flex items-center border rounded-lg h-24 overflow-hidden bordercolor"> <img
|
||||
class="m-2 rounded-lg w-20 h-20"
|
||||
:src="encodeURI(`${userStore.cloudflareUrl.value}${props.collection.previewimage}`)"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="flex flex-col overflow-hidden text-left">
|
||||
<h3 class="overflow-hidden text-ellipsis whitespace-nowrap info">{{ props.collection.name }}</h3>
|
||||
<h5 class="text-sm info">{{ props.collection.length }} Songs</h5>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
24
frontend/src/components/CollectionListItemSkeleton.vue
Normal file
24
frontend/src/components/CollectionListItemSkeleton.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="flex items-center bg-white/5 border rounded-lg h-24 animate-pulse bordercolor">
|
||||
<div class="bg-white/10 m-2 rounded-lg w-20 h-20 shrink-0"></div>
|
||||
<div class="flex flex-col flex-1 justify-center gap-2 p-2 overflow-hidden">
|
||||
<div class="bg-white/10 rounded w-3/4 h-5"></div>
|
||||
<div class="bg-white/5 rounded w-1/4 h-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,12 @@ function updateSong() {
|
||||
<div @click="updateSong" :style="{ borderColor: border }" class="flex m-1 border rounded-lg md:text-xl bordercolor">
|
||||
<img
|
||||
class="m-1 rounded-lg w-14 md:w-24 h-14 md:h-24"
|
||||
:src="props.song?.previewimage ? encodeURI(`${userStore.cloudflareUrl.value}${props.song.previewimage}?h=56&w=56`) : '/default-bg.png'" loading="lazy"
|
||||
:src="
|
||||
props.song?.previewimage
|
||||
? encodeURI(`${userStore.cloudflareUrl.value}${props.song.previewimage}?h=56&w=56`)
|
||||
: '/default-bg.png'
|
||||
"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="flex flex-col overflow-hidden text-left">
|
||||
<p :style="{ color: info }" class="overflow-hidden text-base text-ellipsis text-nowrap info">
|
||||
|
||||
21
frontend/src/components/SongItemSkeleton.vue
Normal file
21
frontend/src/components/SongItemSkeleton.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex bg-white/5 m-1 border rounded-lg md:text-xl animate-pulse bordercolor">
|
||||
<div class="flex-shrink-0 bg-white/10 m-1 rounded-lg w-14 md:w-24 h-14 md:h-24"></div>
|
||||
<div class="flex flex-col flex-1 justify-center gap-2 px-2 overflow-hidden text-left">
|
||||
<div class="bg-white/10 rounded w-3/4 h-4 info"></div>
|
||||
<div class="bg-white/5 rounded w-1/2 h-3 action"></div>
|
||||
<div class="bg-white/5 rounded w-1/4 h-3 action"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info {
|
||||
background-color: currentColor;
|
||||
opacity: 0.15;
|
||||
}
|
||||
.action {
|
||||
background-color: currentColor;
|
||||
opacity: 0.1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { type Song, type CollectionPreview, mapApiToCollectionPreview } from "../script/types";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, onMounted, nextTick } from "vue";
|
||||
import CollectionListItem from "../components/CollectionListItem.vue";
|
||||
import CollectionListItemSkeleton from "../components/CollectionListItemSkeleton.vue";
|
||||
import { useApi } from "@/composables/useApi";
|
||||
|
||||
const { musicApi } = useApi();
|
||||
const api = musicApi.value;
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const collections = ref<CollectionPreview[]>([]);
|
||||
const limit = ref(10);
|
||||
const limit = ref(12);
|
||||
const offset = ref(0);
|
||||
const isLoading = ref(false);
|
||||
|
||||
@@ -16,26 +18,39 @@ const fetchCollections = async () => {
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.musicBackendSearchCollections("", limit.value, offset.value);
|
||||
let songs = mapApiToCollectionPreview(response.data.collections || [], offset.value);
|
||||
collections.value = [...collections.value, ...songs];
|
||||
const newItems = response.data.collections || [];
|
||||
|
||||
if (newItems.length > 0) {
|
||||
let mapped = mapApiToCollectionPreview(newItems, offset.value);
|
||||
collections.value = [...collections.value, ...mapped];
|
||||
offset.value += limit.value;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const container = containerRef.value;
|
||||
if (container && container.scrollHeight <= container.clientHeight) {
|
||||
isLoading.value = false;
|
||||
await fetchCollections();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fetch failed:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCollections();
|
||||
|
||||
const container = document.querySelector(".collection-container");
|
||||
const container = containerRef.value;
|
||||
if (container) {
|
||||
container.addEventListener("scroll", async () => {
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
|
||||
if (scrollTop + clientHeight >= scrollHeight * 0.9 && !isLoading.value) {
|
||||
await fetchCollections();
|
||||
container.addEventListener("scroll", () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoading.value) {
|
||||
fetchCollections();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -44,8 +59,19 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<main class="flex flex-col flex-1 h-full overflow-y-hidden text-center">
|
||||
<div class="flex flex-col overflow-y-scroll collection-container">
|
||||
<CollectionListItem v-for="(collection, index) in collections" :key="index" :collection="collection" />
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="gap-2 grid grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 p-2 overflow-y-auto collection-container"
|
||||
>
|
||||
<CollectionListItem
|
||||
v-for="(collection, index) in collections"
|
||||
:key="index"
|
||||
:collection="collection"
|
||||
/>
|
||||
|
||||
<template v-if="isLoading">
|
||||
<CollectionListItemSkeleton v-for="i in 12" :key="'skeleton-' + i" />
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -9,10 +9,9 @@ const title = computed(() => audioStore.currentSong.value?.name || "Unknown Titl
|
||||
const artist = computed(() => audioStore.currentSong.value?.artist || "Unknown Artist");
|
||||
const bgimg = computed(() => {
|
||||
const preview = audioStore.currentSong.value?.previewimage;
|
||||
return preview
|
||||
? encodeURI(`${userStore.cloudflareUrl.value}${preview}`)
|
||||
: "/default-bg.jpg";
|
||||
});</script>
|
||||
return preview ? encodeURI(`${userStore.cloudflareUrl.value}${preview}`) : "/default-bg.jpg";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import SongItem from "../components/SongItem.vue";
|
||||
import SongItemSkeleton from "../components/SongItemSkeleton.vue";
|
||||
|
||||
import { type Song, type CollectionPreview, mapApiToSongs } from "../script/types";
|
||||
import { type Song, mapApiToSongs } from "../script/types";
|
||||
import { ref, onMounted, nextTick } from "vue";
|
||||
import { useAudio } from "@/composables/useAudio";
|
||||
import { useUser } from "@/composables/useUser";
|
||||
@@ -61,7 +62,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="flex-col flex-1 overflow-y-scroll song-container">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="flex-1 gap-2 grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 p-1 overflow-y-scroll song-container"
|
||||
>
|
||||
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
||||
|
||||
<template v-if="isLoading">
|
||||
<SongItemSkeleton v-for="i in 5" :key="'skel-' + i" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +39,7 @@ type Server struct {
|
||||
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
//time.Sleep(3 * time.Second)
|
||||
log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user