add skeleton loading

This commit is contained in:
2026-03-12 21:39:11 +01:00
parent dba1fda6ca
commit ab0038402e
12 changed files with 133 additions and 34 deletions

View File

@@ -6,7 +6,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; 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; error_page 404 /index.html;

View File

@@ -40,7 +40,14 @@ function highlightText(text: string, searchterm: string) {
<ul> <ul>
<li v-for="(song, index) in props.songs" :key="index" class="rounded-lg"> <li v-for="(song, index) in props.songs" :key="index" class="rounded-lg">
<button @click="update(song.hash)" class="flex"> <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"> <p class="ml-2 overflow-hidden text-ellipsis text-nowrap">
<span v-html="highlightText(song.name, search)"></span> - <span v-html="highlightText(song.name, search)"></span> -
<span v-html="highlightText(song.artist, props.search)"></span> <span v-html="highlightText(song.artist, props.search)"></span>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import SongItem from "../components/SongItem.vue"; import SongItem from "../components/SongItem.vue";
import SongItemSkeleton from "./SongItemSkeleton.vue";
import { type Song, mapApiToCollection } from "../script/types"; import { type Song, mapApiToCollection } from "../script/types";
import { ref, onMounted, onBeforeUnmount } from "vue"; import { ref, onMounted, onBeforeUnmount } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
@@ -87,5 +88,9 @@ onBeforeUnmount(() => {
<div class="flex-col flex-1 justify-start overflow-y-scroll coll-container"> <div class="flex-col flex-1 justify-start overflow-y-scroll coll-container">
<SongItem v-for="(song, index) in songs" :key="index" :song="song" /> <SongItem v-for="(song, index) in songs" :key="index" :song="song" />
</div> </div>
<template v-if="loading">
<SongItemSkeleton v-for="i in 10" :key="'skeleton-' + i" />
</template>
</main> </main>
</template> </template>

View File

@@ -7,12 +7,15 @@ const props = defineProps<{ collection: CollectionPreview }>();
</script> </script>
<template> <template>
<RouterLink :to="'/collection/' + props.collection.index"> <RouterLink :to="'/collection/' + props.collection.index" class="w-full">
<div class="flex border rounded-lg bordercolor"> <div class="flex items-center border rounded-lg h-24 overflow-hidden bordercolor"> <img
<img class="m-2 rounded-lg w-20 h-20" :src="encodeURI(`${userStore.cloudflareUrl.value}${props.collection.previewimage}`)" loading="lazy" /> class="m-2 rounded-lg w-20 h-20"
<div class="flex flex-col"> :src="encodeURI(`${userStore.cloudflareUrl.value}${props.collection.previewimage}`)"
<h3 class="self-start info">{{ props.collection.name }}</h3> loading="lazy"
<h5 class="self-start text-sm info">{{ props.collection.length }} Songs</h5> />
<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>
</div> </div>
</RouterLink> </RouterLink>

View 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>

View File

@@ -21,7 +21,12 @@ function updateSong() {
<div @click="updateSong" :style="{ borderColor: border }" class="flex m-1 border rounded-lg md:text-xl bordercolor"> <div @click="updateSong" :style="{ borderColor: border }" class="flex m-1 border rounded-lg md:text-xl bordercolor">
<img <img
class="m-1 rounded-lg w-14 md:w-24 h-14 md:h-24" 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"> <div class="flex flex-col overflow-hidden text-left">
<p :style="{ color: info }" class="overflow-hidden text-base text-ellipsis text-nowrap info"> <p :style="{ color: info }" class="overflow-hidden text-base text-ellipsis text-nowrap info">

View 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>

View File

@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Song, type CollectionPreview, mapApiToCollectionPreview } from "../script/types"; 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 CollectionListItem from "../components/CollectionListItem.vue";
import CollectionListItemSkeleton from "../components/CollectionListItemSkeleton.vue";
import { useApi } from "@/composables/useApi"; import { useApi } from "@/composables/useApi";
const { musicApi } = useApi(); const { musicApi } = useApi();
const api = musicApi.value; const api = musicApi.value;
const containerRef = ref<HTMLElement | null>(null);
const collections = ref<CollectionPreview[]>([]); const collections = ref<CollectionPreview[]>([]);
const limit = ref(10); const limit = ref(12);
const offset = ref(0); const offset = ref(0);
const isLoading = ref(false); const isLoading = ref(false);
@@ -16,26 +18,39 @@ const fetchCollections = async () => {
if (isLoading.value) return; if (isLoading.value) return;
isLoading.value = true; isLoading.value = true;
try {
const response = await api.musicBackendSearchCollections("", limit.value, offset.value); const response = await api.musicBackendSearchCollections("", limit.value, offset.value);
let songs = mapApiToCollectionPreview(response.data.collections || [], offset.value); const newItems = response.data.collections || [];
collections.value = [...collections.value, ...songs];
if (newItems.length > 0) {
let mapped = mapApiToCollectionPreview(newItems, offset.value);
collections.value = [...collections.value, ...mapped];
offset.value += limit.value; offset.value += limit.value;
await nextTick();
const container = containerRef.value;
if (container && container.scrollHeight <= container.clientHeight) {
isLoading.value = false; isLoading.value = false;
await fetchCollections();
}
}
} catch (error) {
console.error("Fetch failed:", error);
} finally {
isLoading.value = false;
}
}; };
onMounted(async () => { onMounted(async () => {
await fetchCollections(); await fetchCollections();
const container = document.querySelector(".collection-container"); const container = containerRef.value;
if (container) { if (container) {
container.addEventListener("scroll", async () => { container.addEventListener("scroll", () => {
const scrollTop = container.scrollTop; const { scrollTop, scrollHeight, clientHeight } = container;
const scrollHeight = container.scrollHeight; if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoading.value) {
const clientHeight = container.clientHeight; fetchCollections();
if (scrollTop + clientHeight >= scrollHeight * 0.9 && !isLoading.value) {
await fetchCollections();
} }
}); });
} }
@@ -44,8 +59,19 @@ onMounted(async () => {
<template> <template>
<main class="flex flex-col flex-1 h-full overflow-y-hidden text-center"> <main class="flex flex-col flex-1 h-full overflow-y-hidden text-center">
<div class="flex flex-col overflow-y-scroll collection-container"> <div
<CollectionListItem v-for="(collection, index) in collections" :key="index" :collection="collection" /> 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> </div>
</main> </main>
</template> </template>

View File

@@ -9,10 +9,9 @@ const title = computed(() => audioStore.currentSong.value?.name || "Unknown Titl
const artist = computed(() => audioStore.currentSong.value?.artist || "Unknown Artist"); const artist = computed(() => audioStore.currentSong.value?.artist || "Unknown Artist");
const bgimg = computed(() => { const bgimg = computed(() => {
const preview = audioStore.currentSong.value?.previewimage; const preview = audioStore.currentSong.value?.previewimage;
return preview return preview ? encodeURI(`${userStore.cloudflareUrl.value}${preview}`) : "/default-bg.jpg";
? encodeURI(`${userStore.cloudflareUrl.value}${preview}`) });
: "/default-bg.jpg"; </script>
});</script>
<template> <template>
<header> <header>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import SongItem from "../components/SongItem.vue"; 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 { ref, onMounted, nextTick } from "vue";
import { useAudio } from "@/composables/useAudio"; import { useAudio } from "@/composables/useAudio";
import { useUser } from "@/composables/useUser"; import { useUser } from "@/composables/useUser";
@@ -61,7 +62,14 @@ onMounted(async () => {
</script> </script>
<template> <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" /> <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> </div>
</template> </template>

View File

@@ -39,6 +39,7 @@ type Server struct {
func logRequests(next http.Handler) http.Handler { func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })