working just to letter with pagination

This commit is contained in:
Georges-Antoine Assi
2025-03-21 22:44:47 -04:00
parent 05a78e03ce
commit 35dfedd22f
17 changed files with 155 additions and 52 deletions

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from io import BytesIO
from shutil import rmtree
from stat import S_IFREG
from typing import Any
from typing import Any, TypeVar
from urllib.parse import quote
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
@@ -50,6 +50,8 @@ from utils.hashing import crc32_to_hex
from utils.nginx import FileRedirectResponse, ZipContentLine, ZipResponse
from utils.router import APIRouter
T = TypeVar("T")
router = APIRouter(
prefix="/roms",
tags=["roms"],
@@ -118,6 +120,10 @@ async def add_rom(request: Request):
return Response(status_code=status.HTTP_201_CREATED)
class CustomLimitOffsetPage(LimitOffsetPage[T]):
char_index: dict[str, int]
@protected_route(router.get, "", [Scope.ROMS_READ])
def get_roms(
request: Request,
@@ -139,7 +145,7 @@ def get_roms(
selected_status: str | None = None,
selected_region: str | None = None,
selected_language: str | None = None,
) -> LimitOffsetPage[SimpleRomSchema]:
) -> CustomLimitOffsetPage[SimpleRomSchema]:
"""Get roms endpoint
Args:
@@ -167,12 +173,14 @@ def get_roms(
list[RomSchema | SimpleRomSchema]: List of ROMs stored in the database
"""
# Get the base roms query
query = db_rom_handler.get_roms_query(
user_id=request.user.id,
order_by=order_by.lower(),
order_dir=order_dir.lower(),
)
# Filter down the query
query = db_rom_handler.filter_roms(
query=query,
user_id=request.user.id,
@@ -194,6 +202,10 @@ def get_roms(
selected_language=selected_language,
)
# Get the char index for the roms
char_index = db_rom_handler.get_char_index(query=query)
char_index_dict = {char: index for (char, index) in char_index}
with sync_session.begin() as session:
return paginate(
session,
@@ -201,6 +213,7 @@ def get_roms(
transformer=lambda items: [
SimpleRomSchema.from_orm_with_request(i, request) for i in items
],
additional_data={"char_index": char_index_dict},
)

View File

@@ -1,12 +1,12 @@
import functools
from collections.abc import Iterable
from typing import Sequence
from typing import List, Sequence, Tuple
from config import ROMM_DB_DRIVER
from decorators.database import begin_session
from models.collection import Collection, VirtualCollection
from models.rom import Rom, RomFile, RomMetadata, RomUser
from sqlalchemy import and_, delete, func, or_, select, text, update
from sqlalchemy import Row, and_, delete, func, or_, select, text, update
from sqlalchemy.orm import Query, Session, selectinload
from .base_handler import DBBaseHandler
@@ -386,6 +386,26 @@ class DBRomsHandler(DBBaseHandler):
)
return session.scalars(roms).all()
@begin_session
def get_char_index(
self, query: Query, session: Session = None
) -> List[Row[Tuple[str, int]]]:
# Get the row number and first letter for each item
subquery = query.add_columns(
func.lower(func.substring(Rom.name, 1, 1)).label("letter"),
func.row_number().over(order_by=Rom.name).label("position"),
).subquery()
# Get the minimum position for each letter
return (
session.query(
subquery.c.letter, func.min(subquery.c.position - 1).label("position")
)
.group_by(subquery.c.letter)
.order_by(subquery.c.letter)
.all()
)
@begin_session
@with_details
def get_rom_by_fs_name(

View File

@@ -15,6 +15,7 @@ export type { Body_update_rom_api_roms__id__put } from './models/Body_update_rom
export type { Body_update_user_api_users__id__put } from './models/Body_update_user_api_users__id__put';
export type { CollectionSchema } from './models/CollectionSchema';
export type { ConfigResponse } from './models/ConfigResponse';
export type { CustomLimitOffsetPage_SimpleRomSchema_ } from './models/CustomLimitOffsetPage_SimpleRomSchema_';
export type { DetailedRomSchema } from './models/DetailedRomSchema';
export type { EmulationDict } from './models/EmulationDict';
export type { FilesystemDict } from './models/FilesystemDict';
@@ -25,7 +26,6 @@ export type { HTTPValidationError } from './models/HTTPValidationError';
export type { IGDBAgeRating } from './models/IGDBAgeRating';
export type { IGDBMetadataPlatform } from './models/IGDBMetadataPlatform';
export type { IGDBRelatedGame } from './models/IGDBRelatedGame';
export type { LimitOffsetPage_SimpleRomSchema_ } from './models/LimitOffsetPage_SimpleRomSchema_';
export type { MessageResponse } from './models/MessageResponse';
export type { MetadataSourcesDict } from './models/MetadataSourcesDict';
export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform';

View File

@@ -3,10 +3,11 @@
/* tslint:disable */
/* eslint-disable */
import type { SimpleRomSchema } from './SimpleRomSchema';
export type LimitOffsetPage_SimpleRomSchema_ = {
export type CustomLimitOffsetPage_SimpleRomSchema_ = {
items: Array<SimpleRomSchema>;
total: (number | null);
limit: (number | null);
offset: (number | null);
char_index: Record<string, number>;
};

View File

@@ -6,6 +6,7 @@ import FilterTextField from "@/components/Gallery/AppBar/common/FilterTextField.
import GalleryViewBtn from "@/components/Gallery/AppBar/common/GalleryViewBtn.vue";
import RAvatar from "@/components/common/Collection/RAvatar.vue";
import SelectingBtn from "@/components/Gallery/AppBar/common/SelectingBtn.vue";
import CharIndexBar from "@/components/Gallery/AppBar/common/CharIndexBar.vue";
import { storeToRefs } from "pinia";
import storeNavigation from "@/stores/navigation";
import storeRoms from "@/stores/roms";
@@ -46,6 +47,7 @@ const { currentCollection } = storeToRefs(romsStore);
</template>
</v-app-bar>
<char-index-bar />
<collection-info-drawer />
<filter-drawer />
</template>

View File

@@ -7,6 +7,7 @@ import FilterDrawer from "@/components/Gallery/AppBar/common/FilterDrawer/Base.v
import FilterTextField from "@/components/Gallery/AppBar/common/FilterTextField.vue";
import GalleryViewBtn from "@/components/Gallery/AppBar/common/GalleryViewBtn.vue";
import SelectingBtn from "@/components/Gallery/AppBar/common/SelectingBtn.vue";
import CharIndexBar from "@/components/Gallery/AppBar/common/CharIndexBar.vue";
import PlatformIcon from "@/components/common/Platform/Icon.vue";
import storeNavigation from "@/stores/navigation";
import storeRoms from "@/stores/roms";
@@ -51,6 +52,7 @@ const { activePlatformInfoDrawer } = storeToRefs(navigationStore);
</template>
</v-app-bar>
<char-index-bar />
<platform-info-drawer />
<filter-drawer hide-platforms />
<firmware-drawer />
@@ -60,15 +62,18 @@ const { activePlatformInfoDrawer } = storeToRefs(navigationStore);
.gallery-app-bar-desktop {
width: calc(100% - 76px) !important;
}
.gallery-app-bar-mobile {
width: calc(100% - 16px) !important;
}
.platform-icon {
transition:
filter 0.15s ease-in-out,
transform 0.15s ease-in-out;
filter: drop-shadow(0px 0px 1px rgba(var(--v-theme-primary)));
}
.platform-icon:hover,
.platform-icon.active {
filter: drop-shadow(0px 0px 3px rgba(var(--v-theme-primary)));

View File

@@ -4,6 +4,7 @@ import FilterDrawer from "@/components/Gallery/AppBar/common/FilterDrawer/Base.v
import GalleryViewBtn from "@/components/Gallery/AppBar/common/GalleryViewBtn.vue";
import SearchTextField from "@/components/Gallery/AppBar/Search/SearchTextField.vue";
import SelectingBtn from "@/components/Gallery/AppBar/common/SelectingBtn.vue";
import CharIndexBar from "@/components/Gallery/AppBar/common/CharIndexBar.vue";
import SearchBtn from "@/components/Gallery/AppBar/Search/SearchBtn.vue";
import { useDisplay } from "vuetify";
@@ -33,6 +34,7 @@ const { xs, smAndDown } = useDisplay();
</template>
</v-app-bar>
<char-index-bar />
<filter-drawer />
</template>

View File

@@ -48,8 +48,9 @@ async function refetchRoms() {
// Update URL with search term
router.replace({ query: { search: searchTerm.value } });
romsStore.resetPagination();
romsStore
.refetchRoms(galleryFilterStore)
.fetchRoms(galleryFilterStore, false)
.catch((error) => {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms: ${error}`,

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import storeRoms from "@/stores/roms";
import storeGalleryFilter from "@/stores/galleryFilter";
import type { Events } from "@/types/emitter";
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject, watch } from "vue";
const romsStore = storeRoms();
const galleryFilterStore = storeGalleryFilter();
const emitter = inject<Emitter<Events>>("emitter");
const { characterIndex, selectedCharacter, fetchingRoms } =
storeToRefs(romsStore);
async function fetchRoms() {
if (fetchingRoms.value) return;
emitter?.emit("showLoadingDialog", {
loading: true,
scrim: false,
});
romsStore
.fetchRoms(galleryFilterStore, false)
.then(() => {
emitter?.emit("showLoadingDialog", {
loading: false,
scrim: false,
});
})
.catch((error) => {
emitter?.emit("snackbarShow", {
msg: `Couldn't fetch roms: ${error}`,
icon: "mdi-close-circle",
color: "red",
timeout: 4000,
});
emitter?.emit("showLoadingDialog", {
loading: false,
scrim: false,
});
});
}
watch(
selectedCharacter,
() => {
if (!selectedCharacter.value) return;
romsStore.resetPagination();
romsStore.fetchOffset = characterIndex.value[selectedCharacter.value];
fetchRoms();
},
{ immediate: true },
);
</script>
<template>
<v-toolbar
elevation="0"
density="compact"
rounded
height="100%"
class="position-fixed bg-surface mt-4 char-index-toolbar"
>
<v-tabs v-model="selectedCharacter" bg-color="surface" direction="vertical">
<v-tab
v-for="char in Object.keys(characterIndex)"
:key="char"
:value="char"
class="py-4"
>
{{ char }}
</v-tab>
</v-tabs>
</v-toolbar>
</template>
<style scoped>
.char-index-toolbar {
right: 8px;
z-index: 1010;
transform: translateY(0px);
height: fit-content;
max-height: calc(100vh - 74px);
width: 48px;
overflow-y: scroll;
scrollbar-width: none;
}
</style>

View File

@@ -54,7 +54,8 @@ const emitter = inject<Emitter<Events>>("emitter");
emitter?.on("filter", onFilterChange);
async function onFilterChange() {
romsStore.refetchRoms(galleryFilterStore);
romsStore.resetPagination();
romsStore.fetchRoms(galleryFilterStore, false);
emitter?.emit("updateDataTablePages", null);
}

View File

@@ -272,6 +272,7 @@ function onDownload() {
width: 100%;
z-index: 1000;
pointer-events: none;
padding-right: 72px !important;
}
.sticky-bottom * {
pointer-events: auto; /* Re-enables pointer events for all child elements */

View File

@@ -11,7 +11,7 @@ const { currentView } = storeToRefs(galleryViewStore);
<template>
<v-row no-gutters>
<v-col>
<v-row v-if="currentView != 2" no-gutters class="mx-1 mt-3">
<v-row v-if="currentView != 2" no-gutters class="mx-1 mt-3 mr-15">
<v-col
v-for="_ in 60"
class="pa-1 align-self-end"

View File

@@ -10,7 +10,7 @@ import type { DetailedRom, SimpleRom } from "@/stores/roms";
import { getDownloadPath, getStatusKeyForText } from "@/utils";
import type { AxiosProgressEvent } from "axios";
import storeHeartbeat from "@/stores/heartbeat";
import { type LimitOffsetPage_SimpleRomSchema_ as GetRomsResponse } from "@/__generated__/models/LimitOffsetPage_SimpleRomSchema_";
import { type CustomLimitOffsetPage_SimpleRomSchema_ as GetRomsResponse } from "@/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_";
export const romApi = api;

View File

@@ -30,6 +30,8 @@ const defaultRomsState = {
fetchLimit: 72,
fetchOffset: 0,
fetchTotalRoms: 0,
characterIndex: {} as Record<string, number>,
selectedCharacter: null as string | null,
};
export default defineStore("roms", {
@@ -104,7 +106,7 @@ export default defineStore("roms", {
setCurrentVirtualCollection(collection: VirtualCollection | null) {
this.currentVirtualCollection = collection;
},
fetchRoms(galleryFilter: GalleryFilterStore) {
fetchRoms(galleryFilter: GalleryFilterStore, concat = true) {
if (this.fetchingRoms) return Promise.resolve();
this.fetchingRoms = true;
@@ -121,48 +123,13 @@ export default defineStore("roms", {
limit: this.fetchLimit,
offset: this.fetchOffset,
})
.then(({ data: { items, offset, total } }) => {
.then(({ data: { items, offset, total, char_index } }) => {
if (offset !== null) this.fetchOffset = offset + this.fetchLimit;
if (total !== null) this.fetchTotalRoms = total;
// These need to happen in exactly this order
this.allRoms = this.allRoms.concat(items);
this._reorder();
resolve(items);
})
.catch((error) => {
reject(error);
})
.finally(() => {
this.fetchingRoms = false;
});
});
},
refetchRoms(galleryFilter: GalleryFilterStore) {
if (this.fetchingRoms) return Promise.resolve();
this.fetchingRoms = true;
this.resetPagination();
return new Promise((resolve, reject) => {
romApi
.getRoms({
...galleryFilter.$state,
platformId:
this.currentPlatform?.id ??
galleryFilter.selectedPlatform?.id ??
null,
collectionId: this.currentCollection?.id ?? null,
virtualCollectionId: this.currentVirtualCollection?.id ?? null,
limit: this.fetchLimit,
offset: this.fetchOffset,
})
.then(({ data: { items, offset, total } }) => {
if (offset !== null) this.fetchOffset = offset + this.fetchLimit;
if (total !== null) this.fetchTotalRoms = total;
// These need to happen in exactly this order
this.allRoms = items;
this.allRoms = concat ? this.allRoms.concat(items) : items;
this.characterIndex = char_index;
this._reorder();
resolve(items);

View File

@@ -287,7 +287,7 @@ onBeforeUnmount(() => {
</template>
<template v-else>
<template v-if="filteredRoms.length > 0">
<v-row v-if="currentView != 2" class="mx-1 mt-3" no-gutters>
<v-row v-if="currentView != 2" class="mx-1 mt-3 mr-15" no-gutters>
<!-- Gallery cards view -->
<!-- v-show instead of v-if to avoid recalculate on view change -->
<v-col

View File

@@ -229,7 +229,7 @@ onBeforeUnmount(() => {
</template>
<template v-else>
<template v-if="filteredRoms.length > 0">
<v-row v-if="currentView != 2" class="pb-2 mx-1 mt-3" no-gutters>
<v-row v-if="currentView != 2" class="mx-1 mt-3 mr-15" no-gutters>
<!-- Gallery cards view -->
<v-col
v-for="rom in filteredRoms"

View File

@@ -148,7 +148,7 @@ onBeforeUnmount(() => {
</template>
<template v-else>
<template v-if="filteredRoms.length > 0">
<v-row v-if="currentView != 2" class="mx-1 mt-3" no-gutters>
<v-row v-if="currentView != 2" class="mx-1 mt-3 mr-15" no-gutters>
<!-- Gallery cards view -->
<v-col
v-for="rom in filteredRoms"