Merge pull request #2306 from rommapp/replace-v-progress

Replace v-progress loaders with skeletons
This commit is contained in:
Georges-Antoine Assi
2025-08-22 11:33:47 -05:00
committed by GitHub
16 changed files with 278 additions and 181 deletions

View File

@@ -18,8 +18,14 @@ storeLanguage.setLanguage(selectedLanguage.value);
<template>
<v-app>
<v-main>
<v-main id="main" class="no-transition">
<router-view />
</v-main>
</v-app>
</template>
<style scoped>
#main.no-transition {
transition: none;
}
</style>

View File

@@ -20,40 +20,40 @@ const unmatchedCoverImage = computed(() =>
<template>
<v-card
id="background-header"
elevation="0"
rounded="0"
class="w-100"
:key="currentRom.updated_at"
v-if="currentRom"
>
<v-img
id="background-image"
:src="currentRom?.path_cover_large || unmatchedCoverImage"
class="background-image"
:src="currentRom?.path_cover_small || unmatchedCoverImage"
cover
>
<template #error>
<v-img :src="missingCoverImage" />
</template>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
:width="2"
:size="40"
color="primary"
indeterminate
/>
</div>
<v-skeleton-loader class="background-skeleton" type="image" />
</template>
</v-img>
</v-card>
</template>
<style scoped>
#background-header {
width: 100%;
}
#background-image {
<style scoped>
.background-image {
height: 18rem;
filter: blur(30px);
}
.background-skeleton {
height: 18rem;
}
</style>
<style>
.background-skeleton .v-skeleton-loader__image {
height: 100%;
}
</style>

View File

@@ -117,27 +117,30 @@ onMounted(() => {
buffer-opacity="0.6"
:buffer-value="achievementsPercentage"
height="32"
><p class="text-shadow">
>
<p class="text-shadow">
{{
Math.max(
Math.ceil(achievementsPercentage),
Math.ceil(achievementsPercentageHardcore),
)
}}%
</p></v-progress-linear
>
</p>
</v-progress-linear>
</v-list-item>
<v-chip
label
:color="showEarned ? 'primary' : 'gray'"
@click="toggleShowEarned"
class="mt-4"
><template #prepend
><v-icon class="mr-2">{{
showEarned ? "mdi-checkbox-outline" : "mdi-checkbox-blank-outline"
}}</v-icon></template
>{{ t("rom.show-earned-only") }}</v-chip
>
<template #prepend>
<v-icon class="mr-2">
{{ showEarned ? "mdi-checkbox-outline" : "mdi-checkbox-blank-outline" }}
</v-icon>
</template>
{{ t("rom.show-earned-only") }}
</v-chip>
<v-list v-if="rom.merged_ra_metadata?.achievements" class="bg-background">
<v-list-item
v-for="achievement in filteredAchievements"

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import RSection from "@/components/common/RSection.vue";
import Skeleton from "@/components/common/Game/Card/Skeleton.vue";
import { views } from "@/utils";
import { ref } from "vue";
import { isNull } from "lodash";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const storedPlatforms = localStorage.getItem("settings.gridPlatforms");
const gridPlatforms = ref(
isNull(storedPlatforms) ? false : storedPlatforms === "true",
);
const PLATFORM_SKELETON_COUNT = 12;
</script>
<template>
<r-section icon="mdi-shimmer" :title="t('common.platforms')">
<template #toolbar-append>
<v-skeleton-loader type="button" />
</template>
<template #content>
<v-row
:class="{ 'flex-nowrap overflow-x-auto': !gridPlatforms }"
class="py-1 overflow-y-hidden"
no-gutters
>
<v-col
v-for="_ in PLATFORM_SKELETON_COUNT"
class="align-self-end pa-1"
:cols="views[0]['size-cols']"
:sm="views[0]['size-sm']"
:md="views[0]['size-md']"
:lg="views[0]['size-lg']"
:xl="views[0]['size-xl']"
>
<v-skeleton-loader
class="platform-skeleton"
type="heading, image"
aspect-ratio="1.2"
/>
</v-col>
</v-row>
</template>
</r-section>
</template>
<style>
.platform-skeleton .v-skeleton-loader__heading {
margin: 8px;
height: 20px;
}
</style>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import InterfaceOption from "@/components/Settings/UserInterface/InterfaceOption.vue";
import RSection from "@/components/common/RSection.vue";
import collectionApi from "@/services/api/collection";
import storeCollections from "@/stores/collections";
import { computed, ref } from "vue";
import { useDisplay } from "vuetify";
@@ -12,7 +11,7 @@ const { t } = useI18n();
const { smAndDown } = useDisplay();
const collectionsStore = storeCollections();
// Initializing refs from localStorage
// Initialize refs from localStorage
// Home
const storedShowStats = localStorage.getItem("settings.showStats");
@@ -233,12 +232,7 @@ const toggleShowVirtualCollections = (value: boolean) => {
const setVirtualCollectionType = async (value: string) => {
virtualCollectionTypeRef.value = value;
localStorage.setItem("settings.virtualCollectionType", value);
await collectionApi
.getVirtualCollections({ type: value })
.then(({ data: virtualCollections }) => {
collectionsStore.setVirtualCollections(virtualCollections);
});
collectionsStore.fetchVirtualCollections(value);
};
const toggleShowStats = (value: boolean) => {

View File

@@ -29,7 +29,7 @@ async function deleteCollection() {
show.value = false;
await collectionApi
.deleteCollection({ collection: collection.value })
.then((response) => {
.then(() => {
emitter?.emit("snackbarShow", {
msg: "Collection deleted",
icon: "mdi-check-bold",

View File

@@ -2,12 +2,13 @@
import type { SearchRomSchema } from "@/__generated__";
import GameCard from "@/components/common/Game/Card/Base.vue";
import RDialog from "@/components/common/RDialog.vue";
import romApi from "@/services/api/rom";
import Skeleton from "@/components/common/Game/Card/Skeleton.vue";
import EmptyManualMatch from "@/components/common/EmptyStates/EmptyManualMatch.vue";
import storeGalleryView from "@/stores/galleryView";
import storeHeartbeat from "@/stores/heartbeat";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import storePlatforms from "@/stores/platforms";
import romApi from "@/services/api/rom";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { computed, inject, onBeforeUnmount, ref } from "vue";
@@ -490,15 +491,10 @@ onBeforeUnmount(() => {
cover
>
<template #placeholder>
<div
class="d-flex align-center justify-center fill-height"
>
<v-progress-circular
color="primary"
:width="2"
indeterminate
/>
</div>
<skeleton
:aspectRatio="computedAspectRatio"
type="image"
/>
</template>
<v-row no-gutters class="text-white pa-1">
<v-avatar class="mr-1" size="30" rounded="1">
@@ -580,19 +576,18 @@ onBeforeUnmount(() => {
<template #footer>
<v-row no-gutters class="text-center">
<v-col>
<v-chip label class="pr-0" size="small"
>{{ t("rom.results-found") }}:<v-chip
color="primary"
class="ml-2 px-2"
label
>{{ !searching ? matchedRoms.length : ""
}}<v-progress-circular
<v-chip label class="pr-0" size="small">
{{ t("rom.results-found") }}:
<v-chip color="primary" class="ml-2 px-2" label>
{{ !searching ? matchedRoms.length : "" }}
<v-progress-circular
v-if="searching"
:width="1"
:size="10"
color="primary"
indeterminate
/></v-chip>
/>
</v-chip>
</v-chip>
</v-col>
</v-row>

View File

@@ -132,9 +132,9 @@ onBeforeUnmount(() => {
:size="20"
indeterminate
/>
<v-icon v-else :color="$route.name == 'scan' ? 'primary' : ''"
>mdi-magnify-scan</v-icon
>
<v-icon v-else :color="$route.name == 'scan' ? 'primary' : ''">
mdi-magnify-scan
</v-icon>
<v-expand-transition>
<span
v-if="withTag"

View File

@@ -2,26 +2,24 @@
import type { SearchCoverSchema } from "@/__generated__";
import RDialog from "@/components/common/RDialog.vue";
import sgdbApi from "@/services/api/sgdb";
import Skeleton from "@/components/common/Game/Card/Skeleton.vue";
import storeGalleryView from "@/stores/galleryView";
import storePlatforms from "@/stores/platforms";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { inject, onBeforeUnmount, ref } from "vue";
import { useDisplay } from "vuetify";
const { lgAndUp } = useDisplay();
const galleryViewStore = storeGalleryView();
const show = ref(false);
const searching = ref(false);
const searchText = ref("");
const coverType = ref("all");
const covers = ref<SearchCoverSchema[]>([]);
const filteredCovers = ref<SearchCoverSchema[]>();
const galleryViewStore = storeGalleryView();
const panels = ref([0]);
const emitter = inject<Emitter<Events>>("emitter");
const coverAspectRatio = ref(
parseFloat(galleryViewStore.defaultAspectRatioCover.toString()),
);
emitter?.on("showSearchCoverDialog", ({ term, aspectRatio = null }) => {
searchText.value = term;
show.value = true;
@@ -30,6 +28,10 @@ emitter?.on("showSearchCoverDialog", ({ term, aspectRatio = null }) => {
if (searchText.value) searchCovers();
});
const coverAspectRatio = ref(
parseFloat(galleryViewStore.defaultAspectRatioCover.toString()),
);
async function searchCovers() {
covers.value = [];
@@ -199,16 +201,7 @@ onBeforeUnmount(() => {
></v-img>
</template>
<template #placeholder>
<div
class="d-flex align-center justify-center fill-height"
>
<v-progress-circular
:width="2"
:size="40"
color="primary"
indeterminate
/>
</div>
<skeleton :aspectRatio="coverAspectRatio" type="image" />
</template>
</v-img>
</v-hover>

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { inject, ref } from "vue";
const show = ref(false);
const scrim = ref(false);
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("showLoadingDialog", (args) => {
show.value = args.loading;
scrim.value = args.scrim;
});
</script>
<template>
<v-dialog :model-value="show" scroll-strategy="none" width="auto" persistent>
<v-progress-circular :width="3" :size="70" color="primary" indeterminate />
</v-dialog>
</template>

View File

@@ -18,8 +18,6 @@ import SelectStateDialog from "@/components/common/Game/Dialog/Asset/SelectState
import DeleteSavesDialog from "@/components/common/Game/Dialog/Asset/DeleteSaves.vue";
import DeleteStatesDialog from "@/components/common/Game/Dialog/Asset/DeleteStates.vue";
import NoteDialog from "@/components/common/Game/Dialog/NoteDialog.vue";
import collectionApi from "@/services/api/collection";
import platformApi from "@/services/api/platform";
import storeCollections from "@/stores/collections";
import storeNavigation from "@/stores/navigation";
import storePlatforms from "@/stores/platforms";
@@ -33,8 +31,7 @@ const platformsStore = storePlatforms();
const collectionsStore = storeCollections();
const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("refreshDrawer", async () => {
const { data: platformData } = await platformApi.getPlatforms();
platformsStore.set(platformData);
platformsStore.fetchPlatforms();
});
const showVirtualCollections = isNull(
@@ -53,47 +50,20 @@ const virtualCollectionTypeRef = ref(
);
onBeforeMount(async () => {
await platformApi
.getPlatforms()
.then(({ data: platforms }) => {
platformsStore.set(platforms);
})
.catch((error) => {
console.error(error);
});
await collectionApi
.getCollections()
.then(({ data: collections }) => {
collectionsStore.setCollections(collections);
collectionsStore.setFavoriteCollection(
collections.find(
(collection) => collection.name.toLowerCase() === "favourites",
),
);
})
.catch((error) => {
console.error(error);
});
await collectionApi
.getSmartCollections()
.then(({ data: smartCollections }) => {
collectionsStore.setSmartCollection(smartCollections);
})
.catch((error) => {
console.error(error);
});
if (showVirtualCollections) {
await collectionApi
.getVirtualCollections({ type: virtualCollectionTypeRef.value })
.then(({ data: virtualCollections }) => {
collectionsStore.setVirtualCollections(virtualCollections);
});
}
await Promise.all([
platformsStore.fetchPlatforms(),
collectionsStore.fetchCollections(),
collectionsStore.fetchSmartCollections(),
showVirtualCollections
? collectionsStore.fetchVirtualCollections(virtualCollectionTypeRef.value)
: Promise.resolve(),
]);
navigationStore.reset();
// Hack to prevent main page transition on first load
const main = document.getElementById("main");
if (main) main.classList.remove("no-transition");
});
</script>

View File

@@ -6,6 +6,7 @@ import type {
import { uniqBy } from "lodash";
import { defineStore } from "pinia";
import type { SimpleRom } from "./roms";
import collectionApi from "@/services/api/collection";
export type Collection = CollectionSchema;
export type VirtualCollection = VirtualCollectionSchema;
@@ -19,6 +20,9 @@ export default defineStore("collections", {
smartCollections: [] as SmartCollection[],
favoriteCollection: {} as Collection | undefined,
filterText: "" as string,
fetchingCollections: false as boolean,
fetchingSmartCollections: false as boolean,
fetchingVirtualCollections: false as boolean,
}),
getters: {
filteredCollections: ({ allCollections, filterText }) =>
@@ -54,6 +58,66 @@ export default defineStore("collections", {
},
);
},
fetchCollections(): Promise<Collection[]> {
if (this.fetchingCollections) return Promise.resolve([]);
this.fetchingCollections = true;
return new Promise((resolve, reject) => {
collectionApi
.getCollections()
.then(({ data: collections }) => {
this.allCollections = collections;
resolve(collections);
})
.catch((error) => {
console.error(error);
reject(error);
})
.finally(() => {
this.fetchingCollections = false;
});
});
},
fetchSmartCollections(): Promise<SmartCollection[]> {
if (this.fetchingSmartCollections) return Promise.resolve([]);
this.fetchingSmartCollections = true;
return new Promise((resolve, reject) => {
collectionApi
.getSmartCollections()
.then(({ data: smartCollections }) => {
this.smartCollections = smartCollections;
resolve(smartCollections);
})
.catch((error) => {
console.error(error);
reject(error);
})
.finally(() => {
this.fetchingSmartCollections = false;
});
});
},
fetchVirtualCollections(type: string): Promise<VirtualCollection[]> {
if (this.fetchingVirtualCollections) return Promise.resolve([]);
this.fetchingVirtualCollections = true;
return new Promise((resolve, reject) => {
collectionApi
.getVirtualCollections({ type })
.then(({ data: virtualCollections }) => {
this.virtualCollections = virtualCollections;
resolve(virtualCollections);
})
.catch((error) => {
console.error(error);
reject(error);
})
.finally(() => {
this.fetchingVirtualCollections = false;
});
});
},
setFavoriteCollection(favoriteCollection: Collection | undefined) {
this.favoriteCollection = favoriteCollection;
},

View File

@@ -1,4 +1,5 @@
import type { PlatformSchema } from "@/__generated__";
import platformApi from "@/services/api/platform";
import { uniqBy } from "lodash";
import { defineStore } from "pinia";
@@ -8,6 +9,7 @@ export default defineStore("platforms", {
state: () => ({
allPlatforms: [] as Platform[],
filterText: "" as string,
fetchingPlatforms: false as boolean,
}),
getters: {
@@ -26,12 +28,33 @@ export default defineStore("platforms", {
)
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
},
actions: {
_reorder() {
this.allPlatforms = uniqBy(this.allPlatforms, "id").sort((a, b) => {
return a.name.localeCompare(b.name);
});
},
fetchPlatforms(): Promise<Platform[]> {
if (this.fetchingPlatforms) return Promise.resolve([]);
this.fetchingPlatforms = true;
return new Promise((resolve, reject) => {
platformApi
.getPlatforms()
.then(({ data: platforms }) => {
this.allPlatforms = platforms;
resolve(platforms);
})
.catch((error) => {
console.error(error);
reject(error);
})
.finally(() => {
this.fetchingPlatforms = false;
});
});
},
set(platforms: Platform[]) {
this.allPlatforms = platforms;
},

View File

@@ -129,8 +129,8 @@ export default defineStore("roms", {
}: {
galleryFilter: GalleryFilterStore;
concat?: boolean;
}) {
if (this.fetchingRoms) return Promise.resolve();
}): Promise<SimpleRom[]> {
if (this.fetchingRoms) return Promise.resolve([]);
this.fetchingRoms = true;
return new Promise((resolve, reject) => {

View File

@@ -5,7 +5,8 @@ import { useI18n } from "vue-i18n";
import Stats from "@/components/Home/Stats.vue";
import Collections from "@/components/Home/Collections.vue";
import Platforms from "@/components/Home/Platforms.vue";
import RecentSkeletonLoader from "@/components/Home/RecentSkeletonLoader.vue";
import PlatformsSkeleton from "@/components/Home/PlatformsSkeleton.vue";
import RecentAddedSkeleton from "@/components/Home/RecentAddedSkeleton.vue";
import RecentAdded from "@/components/Home/RecentAdded.vue";
import ContinuePlaying from "@/components/Home/ContinuePlaying.vue";
import EmptyHome from "@/components/Home/EmptyHome.vue";
@@ -16,15 +17,17 @@ import storeRoms from "@/stores/roms";
const { t } = useI18n();
const romsStore = storeRoms();
const { recentRoms, continuePlayingRoms: recentPlayedRoms } =
storeToRefs(romsStore);
const { recentRoms, continuePlayingRoms } = storeToRefs(romsStore);
const platformsStore = storePlatforms();
const { filledPlatforms } = storeToRefs(platformsStore);
const { filledPlatforms, fetchingPlatforms } = storeToRefs(platformsStore);
const collectionsStore = storeCollections();
const {
filteredCollections,
filteredVirtualCollections,
filteredSmartCollections,
fetchingCollections,
fetchingSmartCollections,
fetchingVirtualCollections,
} = storeToRefs(collectionsStore);
function getSettingValue(key: string, defaultValue: boolean = true): boolean {
@@ -45,28 +48,20 @@ const fetchingContinuePlaying = ref(false);
const isEmpty = computed(
() =>
!fetchingPlatforms.value &&
!fetchingCollections.value &&
!fetchingSmartCollections.value &&
!fetchingVirtualCollections.value &&
!fetchingRecentAdded.value &&
!fetchingContinuePlaying.value &&
recentRoms.value.length === 0 &&
recentPlayedRoms.value.length === 0 &&
continuePlayingRoms.value.length === 0 &&
filledPlatforms.value.length === 0 &&
filteredCollections.value.length === 0 &&
filteredVirtualCollections.value.length === 0 &&
filteredSmartCollections.value.length === 0,
);
const showRecentSkeleton = computed(
() =>
showRecentRoms &&
fetchingRecentAdded.value &&
recentRoms.value.length === 0,
);
const showContinuePlayingSkeleton = computed(
() =>
showContinuePlaying &&
fetchingContinuePlaying.value &&
recentPlayedRoms.value.length === 0,
);
const fetchRecentRoms = async (): Promise<void> => {
try {
fetchingRecentAdded.value = true;
@@ -102,63 +97,83 @@ onMounted(async () => {
</script>
<template>
<template v-if="fetchingRecentAdded || fetchingContinuePlaying">
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
color="primary"
:width="4"
size="120"
indeterminate
/>
</div>
</template>
<template v-if="!fetchingRecentAdded && !fetchingContinuePlaying">
<template v-if="!isEmpty">
<stats v-if="showStats" />
<recent-skeleton-loader
v-if="showRecentSkeleton"
<empty-home v-if="isEmpty" />
<template v-else>
<stats v-if="showStats" />
<template v-if="showRecentRoms">
<recent-added-skeleton
v-if="fetchingRecentAdded && recentRoms.length === 0"
:title="t('home.recently-added')"
class="ma-2"
/>
<recent-added
v-else-if="recentRoms.length > 0 && showRecentRoms"
class="ma-2"
/>
<recent-skeleton-loader
v-if="showContinuePlayingSkeleton"
<recent-added v-else-if="recentRoms.length > 0" class="ma-2" />
</template>
<template v-if="showContinuePlaying">
<recent-added-skeleton
v-if="fetchingContinuePlaying && continuePlayingRoms.length === 0"
:title="t('home.continue-playing')"
class="ma-2"
/>
<continue-playing
v-else-if="recentPlayedRoms.length > 0 && showContinuePlaying"
v-else-if="continuePlayingRoms.length > 0"
class="ma-2"
/>
<platforms
v-if="filledPlatforms.length > 0 && showPlatforms"
</template>
<template v-if="showPlatforms">
<platforms-skeleton
v-if="fetchingPlatforms && filledPlatforms.length === 0"
/>
<platforms v-else-if="filledPlatforms.length > 0" class="ma-2" />
</template>
<template v-if="showCollections">
<recent-added-skeleton
v-if="fetchingCollections && filteredCollections.length === 0"
:title="t('common.collections')"
class="ma-2"
/>
<collections
v-if="filteredCollections.length > 0 && showCollections"
v-if="filteredCollections.length > 0"
:collections="filteredCollections"
:title="t('common.collections')"
setting="gridCollections"
class="ma-2"
/>
</template>
<template v-if="showSmartCollections">
<recent-added-skeleton
v-if="fetchingSmartCollections && filteredSmartCollections.length === 0"
:title="t('common.smart-collections')"
class="ma-2"
/>
<collections
v-if="filteredSmartCollections.length > 0 && showSmartCollections"
v-if="filteredSmartCollections.length > 0"
:collections="filteredSmartCollections"
:title="t('common.smart-collections')"
setting="gridSmartCollections"
class="ma-2"
/>
</template>
<template v-if="showVirtualCollections">
<recent-added-skeleton
v-if="
fetchingVirtualCollections && filteredVirtualCollections.length === 0
"
:title="t('common.virtual-collections')"
class="ma-2"
/>
<collections
v-if="filteredVirtualCollections.length > 0 && showVirtualCollections"
v-if="filteredVirtualCollections.length > 0"
:collections="filteredVirtualCollections"
:title="t('common.virtual-collections')"
setting="gridVirtualCollections"
class="ma-2"
/>
</template>
<empty-home v-else />
</template>
</template>