From 444d8c4c278c6032b37f44a884cb6d7bf0b54c85 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 12 Apr 2026 22:19:24 +0300 Subject: [PATCH] feat: Implemented filtering and searching --- src/bun/api/games/games.ts | 140 +++++++++++++---- src/bun/api/games/services/statusService.ts | 17 +- src/bun/api/games/services/utils.ts | 68 ++++---- src/bun/api/hooks/games.ts | 4 + src/bun/api/jobs/launch-game-job.ts | 2 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 63 ++++++-- src/bun/api/schema/app.ts | 10 +- src/bun/api/store/store.ts | 20 ++- src/mainview/components/CardElement.tsx | 17 +- src/mainview/components/CardList.tsx | 26 ++-- src/mainview/components/CollectionList.tsx | 1 - src/mainview/components/CollectionsDetail.tsx | 67 ++++---- src/mainview/components/Constants.tsx | 7 + src/mainview/components/ContextDialog.tsx | 17 +- src/mainview/components/GameList.tsx | 18 ++- src/mainview/components/Header.tsx | 8 +- src/mainview/components/HeaderSearchField.tsx | 102 ++++++++++++ src/mainview/components/LoadMoreButton.tsx | 6 +- src/mainview/components/PlatformsList.tsx | 6 +- src/mainview/components/Screenshots.tsx | 6 +- src/mainview/components/SelectMenu.tsx | 3 +- src/mainview/components/SideFilters.tsx | 147 ++++++++++++++++++ src/mainview/components/StatList.tsx | 2 +- src/mainview/components/game/ActionButton.tsx | 7 +- src/mainview/components/game/Details.tsx | 9 +- src/mainview/components/options/Button.tsx | 8 +- .../components/options/PathSettingsOption.tsx | 2 +- .../components/options/SettingsAppForm.tsx | 2 +- .../components/options/SettingsOption.tsx | 2 +- .../components/store/EmulatorsSection.tsx | 6 +- .../components/store/StoreEmulatorCard.tsx | 4 +- src/mainview/routes/__root.tsx | 13 +- .../routes/collection.$source.$id.tsx | 16 +- src/mainview/routes/game/$source.$id.tsx | 13 +- src/mainview/routes/games.tsx | 25 ++- src/mainview/routes/index.tsx | 18 ++- src/mainview/routes/platform.$source.$id.tsx | 12 +- src/mainview/routes/settings/emulators.tsx | 2 +- src/mainview/routes/settings/interface.tsx | 6 + src/mainview/routes/settings/plugins.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 34 ---- src/mainview/routes/store/tab/emulators.tsx | 6 +- src/mainview/routes/store/tab/games.tsx | 87 ++++++++--- src/mainview/routes/store/tab/route.tsx | 21 ++- src/mainview/scripts/queries/store.ts | 13 +- src/mainview/scripts/shortcuts.ts | 4 +- src/mainview/types.d.ts | 8 +- src/shared/constants.ts | 14 +- src/shared/types..d.ts | 40 ++++- 49 files changed, 841 insertions(+), 290 deletions(-) create mode 100644 src/mainview/components/Constants.tsx create mode 100644 src/mainview/components/HeaderSearchField.tsx create mode 100644 src/mainview/components/SideFilters.tsx diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 6d4f6b2..8e489cf 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; -import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; +import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; @@ -20,6 +20,7 @@ import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmula import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; +import { cores } from "../emulatorjs/emulatorjs"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -134,12 +135,24 @@ export default new Elysia() .get('/games', async ({ query, set }) => { const games: FrontEndGameType[] = []; + const filterSets: FrontEndFilterSets = { + age_ratings: new Set(), + player_counts: new Set(), + languages: new Set(), + companies: new Set(), + genres: new Set() + }; if (query.source === 'store') { const shuffledGames = await getShuffledStoreGames(); set.headers['x-max-items'] = shuffledGames.length; - const storeGames = await Promise.all(shuffledGames + const storeGames = await Promise.all(shuffledGames.filter(g => + { + if (query.search) + return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); + return true; + }) .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) .map(async (e) => { @@ -185,6 +198,11 @@ export default new Elysia() } } + if (query.search) + { + where.push(like(schema.games.name, query.search)); + } + if (query.source) { where.push(eq(schema.games.source, query.source)); @@ -218,7 +236,7 @@ export default new Elysia() { // Collections are just a remote thing for now. const remoteGames: FrontEndGameTypeWithIds[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); games.push(...remoteGames.map(g => { if (localGameExistsPredicate(g)) @@ -233,37 +251,74 @@ export default new Elysia() } else { - games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => + games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g => + { + if (query.genres && query.genres.length > 0) + { + if (!g.metadata) return false; + if (!g.metadata.genres) return false; + if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; + } + + return true; + }).map(g => { return convertLocalToFrontend(g); })); - const remoteGames: FrontEndGameTypeWithIds[] = []; - const remoteGameSet = new Set(); - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => + if (query.localOnly !== true) { - if (localGameExistsPredicate(g)) + const remoteGames: FrontEndGameTypeWithIds[] = []; + const remoteGameSet = new Set(); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); + games.push(...remoteGames.filter(g => { - return false; - } + if (localGameExistsPredicate(g)) + { + return false; + } - if (g.igdb_id) + if (g.igdb_id) + { + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); + } + + return true; + })); + } else + { + await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e)); + } + + localGames.map(g => + { + const metadata: any = g.metadata; + if (metadata.genres && Array.isArray(metadata.genres)) { - const igdbId = `igdb@${g.igdb_id}`; - if (remoteGameSet.has(igdbId)) return false; - remoteGameSet.add(igdbId); + metadata.genres.forEach((g: string) => filterSets.genres.add(g)); } - - if (g.ra_id) + if (metadata.age_ratings && Array.isArray(metadata.age_ratings)) { - const raId = `ra@${g.ra_id}`; - if (remoteGameSet.has(raId)) return false; - remoteGameSet.add(raId); + metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g)); } - - return true; - })); + if (metadata.companies && Array.isArray(metadata.companies)) + { + metadata.companies.forEach((g: string) => filterSets.companies.add(g)); + } + if (metadata.player_count) + { + filterSets.player_counts.add(metadata.player_count); + } + }); } } @@ -280,11 +335,22 @@ export default new Elysia() case 'name': games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); break; + case "release": + games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0)); + break; } } - return { games }; + const filterLists: FrontEndFilterLists = { + age_ratings: Array.from(filterSets.age_ratings), + player_counts: Array.from(filterSets.player_counts), + languages: Array.from(filterSets.languages), + companies: Array.from(filterSets.companies), + genres: Array.from(filterSets.genres) + }; + + return { games, filters: filterLists }; }, { query: GameListFilterSchema, }) @@ -341,8 +407,22 @@ export default new Elysia() return { name: 'EMULATORJS', validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], - logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, - systems: [], + logo: 'https://emulatorjs.org/logo/EmulatorJS.png', + systems: await Promise.all(Object.keys(cores).map(async c => + { + const mapping = await emulatorsDb.query.systemMappings.findFirst({ + where (fields, operators) + { + return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c)); + }, columns: { sourceSlug: true } + }); + const system: EmulatorSystem = { + id: c, + name: c, + iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg` + }; + return system; + })), gameCount: 0, integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; @@ -536,8 +616,8 @@ export default new Elysia() const sourceData = await getSourceGameDetailed(source, id); if (!sourceData) return status("Not Found"); - const sourceCompaniesSet = new Set(sourceData.companies); - const sourceGenresSet = new Set(sourceData.genres); + const sourceCompaniesSet = new Set(sourceData.metadata.companies); + const sourceGenresSet = new Set(sourceData.metadata.genres); const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; @@ -550,7 +630,7 @@ export default new Elysia() const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`)); - games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata }))); + games.push(...localGames.map(g => convertLocalToFrontend(g))); const shuffledGames = await getShuffledStoreGames(); const storeGames = await Promise.all(shuffledGames @@ -559,7 +639,7 @@ export default new Elysia() const system = path.dirname(g.path); const id = path.basename(g.path, path.extname(g.path)); - if (localGamesSourceSet.has(`${system}@${id}`)) + if (localGamesSourceSet.has(`store@${system}@${id}`)) return false; if (esSystem) diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 0255d7b..53ec3de 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -60,7 +60,19 @@ export async function fixSource (source: string, id: string) if (foundGame) { - await db.update(appSchema.games).set({ source: foundGame.id.source, source_id: foundGame.id.id }).where(eq(appSchema.games.id, valid.localGame.id)); + await db.update(appSchema.games).set({ + source: foundGame.id.source, + source_id: foundGame.id.id, + metadata: { + age_ratings: foundGame.metadata.age_ratings, + genres: foundGame.metadata.genres, + player_count: foundGame.metadata.player_count ?? undefined, + companies: foundGame.metadata.companies, + game_modes: foundGame.metadata.game_modes, + average_rating: foundGame.metadata.average_rating ?? undefined, + first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, valid.localGame.id)); return true; } else { @@ -82,6 +94,9 @@ export async function validateGameSource (source: string, id: string): Promise<{ if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { + // Store should be immutable + if (localGame.source === 'store') return { valid: true, localGame }; + const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index cb53377..955d1e7 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -32,7 +32,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { }) { const game: FrontEndGameType = { - platform_display_name: g.platform?.name ?? "Local", + platform_display_name: g.platform?.name ?? null, id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, path_cover: `/api/romm/game/local/${g.id}/cover`, @@ -45,17 +45,24 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { slug: g.slug, name: g.name, platform_id: g.platform_id, - platform_slug: g.platform?.slug ?? null + platform_slug: g.platform?.slug ?? null, + metadata: { + first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null + } }; return game; } -export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { - platform?: typeof schema.platforms.$inferSelect | null; +export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { + platform?: { name: string | null, slug: string | null; } | null; screenshotIds?: number[]; }) { + + const exists = await checkInstalled(g.path_fs); + const fileSize = await calculateSize(g.path_fs); + const game: FrontEndGameTypeDetailed = { platform_display_name: g.platform?.name ?? "Local", id: { id: String(g.id), source: 'local' }, @@ -72,9 +79,18 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel platform_id: g.platform_id, platform_slug: g.platform?.slug ?? null, summary: g.summary, - fs_size_bytes: 0, - missing: false, - local: true + fs_size_bytes: fileSize, + missing: !exists, + local: true, + metadata: { + genres: g.metadata.genres ?? [], + companies: g.metadata.companies ?? [], + game_modes: g.metadata.game_modes ?? [], + age_ratings: g.metadata.age_ratings ?? [], + player_count: g.metadata.player_count ?? null, + average_rating: g.metadata.average_rating ?? null, + first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null + } }; return game; @@ -107,7 +123,10 @@ export async function convertStoreToFrontend (system: string, id: string, storeG name: storeGame.title, platform_id: null, platform_slug: rommSystem?.sourceSlug ?? system, - paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [] + paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], + metadata: { + first_release_date: null + } }; return game; @@ -131,6 +150,15 @@ export async function convertStoreToFrontendDetailed (system: string, id: string fs_size_bytes: size, missing: false, local: false, + metadata: { + genres: storeGame.tags, + companies: [], + game_modes: [], + age_ratings: [], + player_count: "", + average_rating: null, + first_release_date: null + } }; return detailed; @@ -148,29 +176,7 @@ export async function getLocalGameDetailed (match: any) if (localGame) { - const exists = await checkInstalled(localGame.path_fs); - const fileSize = await calculateSize(localGame.path_fs); - const game: FrontEndGameTypeDetailed = { - path_cover: `/api/romm/game/local/${localGame.id}/cover`, - updated_at: localGame.created_at, - id: { id: String(localGame.id), source: 'local' }, - path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, - fs_size_bytes: fileSize ?? null, - paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), - local: true, - missing: !exists, - platform_display_name: localGame.platform?.name, - summary: localGame.summary, - source: localGame.source, - source_id: localGame.source_id, - path_fs: localGame.path_fs, - last_played: localGame.last_played, - slug: localGame.slug, - name: localGame.name, - platform_id: localGame.platform_id, - platform_slug: localGame.platform.slug - }; - return game; + return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) }); } return undefined; diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index b53a00f..f1f4a6a 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -37,6 +37,10 @@ export class GameHooks fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; games: FrontEndGameTypeWithIds[]; + filters: FrontEndFilterSets; + }]>(['ctx']); + fetchFilters = new AsyncSeriesHook<[ctx: { + filters: FrontEndFilterSets; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { source: string; diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index b60cb76..bcea594 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -168,7 +168,7 @@ export class LaunchGameJob implements IJob = { + added: "created_at", + activity: "created_at", + name: "name", + release: "metadatum.first_release_date" + }; async updateClient () { @@ -49,8 +55,11 @@ export default class RommIntegration implements PluginType const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, - last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, + last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.created_at), + metadata: { + first_release_date: rom.metadatum.first_release_date !== null ? new Date(rom.metadatum.first_release_date) : null, + }, slug: rom.slug, platform_id: rom.platform_id, platform_display_name: rom.platform_display_name, @@ -74,11 +83,17 @@ export default class RommIntegration implements PluginType fs_size_bytes: rom.fs_size_bytes, local: false, missing: rom.missing_from_fs, - genres: rom.metadatum.genres, - companies: rom.metadatum.companies, - release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined, imdb_id: rom.igdb_id ?? undefined, - ra_id: rom.ra_id ?? undefined + ra_id: rom.ra_id ?? undefined, + metadata: { + age_ratings: rom.metadatum.age_ratings, + genres: rom.metadatum.genres, + companies: rom.metadatum.companies, + game_modes: rom.metadatum.game_modes, + player_count: rom.metadatum.player_count, + average_rating: rom.metadatum.average_rating, + first_release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : null + } }; const userData = await getCurrentUserApiUsersMeGet(); @@ -119,26 +134,32 @@ export default class RommIntegration implements PluginType load (ctx: PluginContextType) { - ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) => { if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { - const orderByMap: Record = { - added: "created_at", - activity: "created_at", - name: "name" - }; - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: query.platform_id ? [query.platform_id] : undefined, collection_id: query.collection_id, limit: query.limit, offset: query.offset, - order_by: orderByMap[query.orderBy ?? ''] + order_by: this.orderByMap[query.orderBy ?? ''], + with_filter_values: true, + genres: query.genres, + genres_logic: "all", + age_ratings: query.age_ratings, + search_term: query.search, }, throwOnError: true }); + + rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r)); + rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r)); + rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r)); + rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r)); + rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r)); + games.push(...rommGames.data.items.map(g => { const game: FrontEndGameTypeWithIds = { @@ -151,6 +172,16 @@ export default class RommIntegration implements PluginType } }); + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) => + { + const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); + rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r)); + rommFilters.data.companies.forEach(r => filters.companies.add(r)); + rommFilters.data.languages.forEach(r => filters.languages.add(r)); + rommFilters.data.player_counts.forEach(r => filters.player_counts.add(r)); + rommFilters.data.genres.forEach(r => filters.genres.add(r)); + }); + ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => { if (service !== 'romm') return; @@ -277,10 +308,10 @@ export default class RommIntegration implements PluginType const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug); if (rommPlatform) { - const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } }); + const rommGames = await getRomsApiRomsGet({ query: { genres: game.metadata.genres, genres_logic: 'any' } }); if (rommGames.data) { - games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum }))); + games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g) }))); } } } diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index fafa32a..35c9c5a 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -11,7 +11,15 @@ export const games = sqliteTable('games', { path_fs: text("path_fs"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), - metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`), + metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ + genres?: string[], + companies?: string[], + game_modes?: string[], + age_ratings?: string[]; + player_count?: string; + first_release_date?: number; + average_rating?: number; + }>().notNull(), slug: text("slug").unique(), platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 2a1c42e..5145232 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -25,7 +25,22 @@ export const store = new Elysia({ prefix: '/api/store' }) }); const emulatesParsed = await getAllStoreEmulatorPackages(); let frontEndEmulators = await Promise.all(emulatesParsed - .filter(e => e.os.includes(process.platform as any)) + .filter(e => + { + if (!e.os.includes(process.platform as any)) return false; + if (query.search) + { + const lowerCaseSearch = query.search.toLocaleLowerCase(); + + if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch))) + { + return true; + } + + return false; + } + return true; + }) .map(async (emulator) => { const systems = await buildStoreFrontendEmulatorSystems(emulator); @@ -77,7 +92,8 @@ export const store = new Elysia({ prefix: '/api/store' }) limit: z.coerce.number().optional(), missing: z.stringbool().optional().describe("Show Only Non Installed emulators"), orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(), - related: z.string().optional() + related: z.string().optional(), + search: z.string().optional() }) }) .get('/games/featured', async () => diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index 885d073..fdbee68 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -18,9 +18,7 @@ export function GameCardSkeleton () ); } -export type GameCardFocusHandler = (id: string, node: HTMLElement, details: FocusDetails) => void; - -export interface GameCardParams +export interface GameCardParams extends FocusParams { title: string; subtitle: string | JSX.Element; @@ -31,7 +29,6 @@ export interface GameCardParams id: string; badges?: JSX.Element[]; className?: string; - onFocus?: GameCardFocusHandler; onBlur?: (id: string) => void; clickFocuses?: boolean; previewClassName?: string; @@ -39,14 +36,14 @@ export interface GameCardParams export default function CardElement (data: GameCardParams & InteractParams) { - const handleAction = () => + const handleAction = (event?: Event) => { - data.onAction?.(); + data.onAction?.({ event, focusKey }); oneShot('click'); }; - const { ref, focused, focusSelf } = useFocusable({ + const { ref, focused, focusSelf, focusKey } = useFocusable({ focusKey: data.focusKey, - onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details), + onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current as any, details), onEnterPress: handleAction, onBlur: () => data.onBlur?.(data.id), }); @@ -63,10 +60,10 @@ export default function CardElement (data: GameCardParams & InteractParams) scrollSnapAlign: isPointer ? "center" : "none" }} onFocus={focusSelf} - onClick={() => + onClick={(e) => { focusSelf(); - handleAction(); + handleAction(e.nativeEvent); }} className={twMerge( "relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 2744585..671018f 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -4,12 +4,11 @@ import useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import { GameMeta } from "../../shared/constants"; -import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement"; +import CardElement, { GameCardParams } from "./CardElement"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; -import { GamepadButtonEvent } from "../scripts/gamepads"; export interface GameMetaExtra extends GameMeta { @@ -26,13 +25,14 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara preview = data.game.previewUrl; } - const handleAction = (e?: Event) => + const handleAction = (ctx: InteractParamsArgs) => { data.game.onSelect?.(); - data.onAction?.(); + data.onAction?.({ event, focusKey: data.game.focusKey }); oneShot('click'); }; - useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); + + useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]); return ( + onFocus={(focusKey, node, details) => { - data.game.onFocus?.(details); - data.onFocus?.(id, node, details); + data.game.onFocus?.(focusKey, node, details); + data.onFocus?.(focusKey, node, details); }} onAction={handleAction} preview={preview} @@ -61,16 +61,18 @@ export function CardList (data: { games: GameMetaExtra[]; grid?: boolean; onSelectGame?: (id: string) => void; - onGameFocus?: GameCardFocusHandler; + focus?: string; className?: string; finalElement?: JSX.Element; saveChildFocus?: 'session' | 'local'; -}) +} & FocusParams) { const { ref, focusKey } = useFocusable({ focusKey: data.id, forceFocus: true, - autoRestoreFocus: true + autoRestoreFocus: true, + focusable: data.games.length > 0, + preferredChildFocusKey: data.focus }); return ( @@ -92,7 +94,7 @@ export function CardList (data: { > {data.games.map((g, i) => data.onSelectGame?.(g.id)} i={i} />)} + key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)} {data.finalElement} diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 15b8d51..121be98 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -1,7 +1,6 @@ import { RPC_URL } from "@/shared/constants"; import { useSuspenseQuery } from "@tanstack/react-query"; import { CardList, GameMetaExtra } from "./CardList"; -import { GameCardFocusHandler } from "./CardElement"; import { getCollectionsQuery } from "@queries/romm"; import { useRouter } from "@tanstack/react-router"; diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 717e986..0b9e0e1 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,44 +1,50 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { StickyHeaderUI } from './Header'; +import { HeaderButton, StickyHeaderUI } from './Header'; import { GameList } from './GameList'; -import { Search, Settings2 } from 'lucide-react'; -import { JSX, Suspense } from 'react'; +import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react'; +import { JSX, Suspense, useRef, useState } from 'react'; import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterType } from '@/shared/constants'; -import { GameCardFocusHandler } from './CardElement'; +import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { gameQuery } from '../scripts/queries/romm'; -import { useRouter } from '@tanstack/react-router'; +import { useNavigate, useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; +import { RoundButton } from './RoundButton'; +import { ContextList, DialogEntry, useContextDialog } from './ContextDialog'; +import classNames from 'classnames'; +import { sourceIconMap } from './Constants'; +import { stat } from 'fs-extra'; +import { FilterUI } from './Filters'; +import SideFilters from './SideFilters'; export interface CollectionsDetailParams { id?: string; setBackground?: (url: string) => void; filters?: GameListFilterType; - builder?: () => Promise<{ filter?: GameListFilterType, title?: JSX.Element; }>; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, headerTitle?: JSX.Element; + headerChildren?: any; title?: JSX.Element; footer?: JSX.Element; focus?: string; - countHit?: number; + countHint?: number; + headerButtons?: HeaderButton[]; + headerButtonElements?: JSX.Element | JSX.Element[]; } export function CollectionsDetail (data: CollectionsDetailParams) { const router = useRouter(); - const builtData = useQuery({ - queryKey: ['filter', data.id], queryFn: async () => - { - return data.builder?.() ?? { filter: data.filters, title: data.title }; - } - }); + const [filterValues, setFilterValues] = useState(); const queryClient = useQueryClient(); - const focusKey = `game-list-${data.id}-${data?.filters ? Object.values(data?.filters).map(f => String(f)).join(",") : ''}`; + const finalFilter = { ...data.localFilter, ...data.filters }; + const focusKey = `game-list-${data.id}`; const { ref, focusSelf } = useFocusable({ focusKey, preferredChildFocusKey: `${focusKey}-list` @@ -46,9 +52,8 @@ export function CollectionsDetail (data: CollectionsDetailParams) useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); - const handleScroll: GameCardFocusHandler = (cardId, node, details) => + const handleScroll: FocusParams['onFocus'] = (cardId, node, details) => { - const [source, id] = cardId.split('@'); queryClient.prefetchQuery(gameQuery(source, id)); @@ -61,22 +66,27 @@ export function CollectionsDetail (data: CollectionsDetailParams) return (
- }, { id: "filter", icon: }]} ref={ref} /> -
-
- {builtData.data?.filter && data.title} - {(builtData.data?.filter || (!data.filters && !data.builder)) && }> + + {data.headerChildren} + +
+
+
+
+
+ {finalFilter && data.title} + {}> - + } -
-
-
@@ -85,6 +95,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
+
+ +
diff --git a/src/mainview/components/Constants.tsx b/src/mainview/components/Constants.tsx new file mode 100644 index 0000000..f6de5ae --- /dev/null +++ b/src/mainview/components/Constants.tsx @@ -0,0 +1,7 @@ +import { Gamepad2, HardDrive, Store } from "lucide-react"; + +export const sourceIconMap: Record = { + store: , + local: , + romm: +}; \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 6024311..b0acd8f 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -35,7 +35,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class const handleAction = () => { if (data.disabled === true) return; - data.action?.({ close: context.close, focus: focusSelf }); + data.action?.({ close: context.close, focus: focusSelf, selected: data.selected }); oneShot('click'); }; const { ref, focusSelf, focusKey } = useFocusable({ @@ -82,7 +82,7 @@ export interface DialogEntry icon?: string | JSX.Element; type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error'; selected?: boolean; - action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void; + action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; selected?: boolean; }) => void; shortcuts?: Shortcut[]; } @@ -102,6 +102,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla { setOpen(false); data.onClose?.(); + oneShot('closeContext'); if (newSourceFocusKey) { setFocus(newSourceFocusKey, { instant: true }); @@ -118,7 +119,12 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla return { dialog, open, - setOpen: handleClose + setOpen: handleClose, + setToggle: (focNewSourceFocusKey?: string | undefined) => + { + if (open) handleClose(false, focNewSourceFocusKey); + else handleClose(true, focNewSourceFocusKey); + } }; } @@ -142,7 +148,6 @@ export function ContextDialog (data: { const handleClose = () => { data.close(false); - oneShot('closeContext'); }; useEffect(() => { @@ -161,7 +166,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return @@ -169,7 +174,7 @@ export function ContextDialog (data: {
void; onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; - onFocus?: GameCardFocusHandler; + focus?: string; className?: string; finalElement?: JSX.Element; saveChildFocus?: "session" | "local"; + setFilterValues?: (filters: FrontEndFilterLists) => void; } export function GameList (data: GameListParams) { - const games = useSuspenseQuery({ ...allGamesQuery(data.filters), staleTime: DefaultRommStaleTime }); + const games = useSuspenseQuery({ ...allGamesQuery(data.filters), queryKey: ['games', data.filters ?? 'all'], staleTime: DefaultRommStaleTime }); const navigator = useNavigate(); const blur = useLocalSetting('backgroundBlur'); const backgroundContext = useContext(AnimatedBackgroundContext); @@ -48,6 +48,11 @@ export function GameList (data: GameListParams) } }; + useEffect(() => + { + data.setFilterValues?.(games.data.filters); + }, [games.data.filters]); + function handleDefaultSelect (g: FrontEndGameType) { navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); @@ -60,9 +65,10 @@ export function GameList (data: GameListParams) type="game" grid={data.grid} className={data.className} - onGameFocus={data.onFocus} + onFocus={data.onFocus} finalElement={data.finalElement} saveChildFocus={data.saveChildFocus} + focus={data.focus} games={games.data?.games .map( (g) => diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index cc17aba..3a2e8dd 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -234,7 +234,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) }); } - return
+ return
{accounts?.map(a => {!!data.buttons &&
}
- {data.buttonElements ?? data.buttons?.map(b => ; className?: string; } & HeaderUIParams) +export function StickyHeaderUI (data: { ref: RefObject; className?: string; children?: any; } & HeaderUIParams) { const [isStuck, setIsStuck] = useState(false); const headerRef = useRef(null); @@ -317,6 +318,7 @@ export function StickyHeaderUI (data: { ref: RefObject; className?: string;
+ {data.children}
; } \ No newline at end of file diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx new file mode 100644 index 0000000..3845987 --- /dev/null +++ b/src/mainview/components/HeaderSearchField.tsx @@ -0,0 +1,102 @@ +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Ref, RefObject, useEffect, useRef, useState } from "react"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; +import { Search } from "lucide-react"; +import { RoundButton } from "./RoundButton"; +import { useEventListener } from "usehooks-ts"; + +function SearchInput (data: { + id: string; + autoSearch?: boolean; + search: string | undefined; + compact: boolean | undefined; + onInputFocus: () => void; + setShowInput: (show: boolean) => void; + onSubmit: (search: string | undefined) => void; +} & FocusParams) +{ + const { ref, focusKey } = useFocusable({ + onBlur: () => inputRef.current?.blur(), + onFocus: (l, p, d) => + { + data.onFocus?.(focusKey, ref.current, { ...d, inputRef }); + if (data.autoSearch) inputRef.current?.focus(); + }, + focusKey: data.id, + onEnterPress: () => + { + if (document.activeElement === inputRef.current) + { + if (inputRef.current) + data.onSubmit?.(inputRef.current.value); + } else + { + inputRef.current?.focus(); + } + } + }); + + const inputRef = useRef(null); + const [localSearch, setLocalSearch] = useState(data.search); + + useEffect(() => + { + setLocalSearch(data.search ?? ""); + }, [data.search]); + + useShortcuts(focusKey, () => document.activeElement === inputRef.current ? [{ + label: "Cancel", + button: GamePadButtonCode.B, action (e) + { + inputRef.current?.blur(); + oneShot('returnGeneric'); + }, + }] : [], [inputRef.current, document.activeElement]); + + useEventListener('search' as any, e => + { + data.onSubmit?.(undefined); + }, inputRef as any); + + return ; +} + +export default function HeaderSearchField (data: { + id: string; + autoSearch?: boolean; + search: string | undefined, + onSubmit: (search: string | undefined) => void; + compact?: boolean; +} & FocusParams) +{ + const [showInput, setShowInput] = useState(false); + + const { ref, focusKey, focusSelf } = useFocusable({ + focusKey: data.id, + focusBoundaryDirections: ['left', "right"], + isFocusBoundary: data.compact && showInput + }); + + return
+ + {(!data.compact || showInput) && } + {data.compact && !showInput && setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} >} + +
; +} \ No newline at end of file diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index afcd9b7..0a70f93 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -4,9 +4,9 @@ import { useIntersectionObserver } from "usehooks-ts"; export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) { - const handleAction = (e?: Event) => + const handleAction = (event?: Event) => { - data.onAction?.(e); + data.onAction?.({ event, focusKey }); if (data.lastId && focused) setFocus(FOCUS_KEYS.GAME_CARD(data.lastId)); }; @@ -18,8 +18,6 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr onEnterPress: handleAction }); - - const { ref: intersct } = useIntersectionObserver({ initialIsIntersecting: true, rootMargin: "20%", diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 48af4a3..039e20a 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -5,7 +5,6 @@ import { CardList, GameMetaExtra } from "./CardList"; import { rommApi } from "../scripts/clientApi"; import { JSX, useMemo } from "react"; import { HardDrive } from "lucide-react"; -import { GameCardFocusHandler } from "./CardElement"; import { mobileCheck } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; @@ -13,11 +12,10 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; - onFocus?: GameCardFocusHandler; grid?: boolean; onSelect?: (source: string, id: string) => void; saveChildFocus?: "session" | "local"; -}) +} & FocusParams) { const isMobile = mobileCheck(); const navigate = useNavigate(); @@ -88,7 +86,7 @@ export function PlatformsList (data: { id={data.id} grid={data.grid} className={twMerge('*:aspect-8/10! md:py-12', data.className)} - onGameFocus={data.onFocus} + onFocus={data.onFocus} games={platformsMapped} onSelectGame={(id) => { diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index ae5af90..42d76d3 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -12,9 +12,9 @@ import { twMerge } from "tailwind-merge"; function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) { const imageRef = useRef(null); - const { ref, focusSelf } = useFocusable({ + const { ref, focusSelf, focusKey } = useFocusable({ focusKey: `screenshot-${data.index}`, - onEnterPress: () => data.onAction?.(), + onEnterPress: () => data.onAction?.({ focusKey }), onFocus: (e, p, details) => { data.setFocused?.(data.index); @@ -23,7 +23,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n }); 4096; return
focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" /> -
data.onAction?.(e.nativeEvent)}>
+
data.onAction?.({ event: e.nativeEvent, focusKey })}>
; } diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index 4e68b68..4ad4878 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -9,7 +9,6 @@ import { FOCUS_KEYS } from "../scripts/types"; export default function SelectMenu (data: { rootFocusKey: string; }) { const navigate = useNavigate(); - const routeState = useRouterState(); const matchRoute = useMatchRoute(); const options: DialogEntry[] = [ @@ -85,7 +84,7 @@ export default function SelectMenu (data: { rootFocusKey: string; }) ]; const { dialog, setOpen, open } = useContextDialog('select-menu', { content: , - className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none', + className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen', preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '') }); useShortcuts(data.rootFocusKey, () => [{ diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx new file mode 100644 index 0000000..117577c --- /dev/null +++ b/src/mainview/components/SideFilters.tsx @@ -0,0 +1,147 @@ +import { GameListFilterType } from "@/shared/constants"; +import { RoundButton } from "./RoundButton"; +import classNames from "classnames"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation"; +import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react"; +import { sourceIconMap } from "./Constants"; +import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog"; + +function FilterButton (data: { + id: string, + filters?: GameListFilterType, + tooltip: string, + icon: any; + dialog: { + setToggle: (focNewSourceFocusKey?: string | undefined) => void; + }; + isActive: boolean; +}) +{ + const handleAction = () => data.dialog.setToggle(data.id); + useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]); + return
+ + {data.icon} + +
; +} + +export default function SideFilters (data: { + id: string, + filters?: GameListFilterType; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, + filterValues: FrontEndFilterLists | undefined; +}) +{ + + const { ref, focusKey } = useFocusable({ focusKey: data.id }); + + const orderByDialog = useContextDialog('order-by-dialog', { + content: }, + { stat: "activity", icon: }, + { stat: "added", icon: }, + { stat: "release", icon: }, + ] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[]) + .map(o => ({ + content: o.stat, + icon: o.icon, + selected: data.localFilter.orderBy === o.stat, + id: `sort-by-${o.stat}`, + type: 'primary', + action (ctx) + { + data.setLocalFilter({ ...data.localFilter, orderBy: o.stat }); + ctx.close(); + }, + }))} />, + preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}` + }); + + const sourceFilterDialog = useContextDialog('source-filter-dialog', { + content: (o => ({ + content: o, + icon: sourceIconMap[o], + selected: data.localFilter.source === o, + id: `source-filter-${o}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined }); + else data.setLocalFilter({ ...data.localFilter, source: o }); + ctx.close(); + }, + })).concat({ + content: "Local Only", + icon: , + selected: data.localFilter.localOnly === true, + id: `source-filter-local`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, localOnly: undefined }); + else data.setLocalFilter({ ...data.localFilter, localOnly: true }); + ctx.close(); + }, + })} />, + preferredChildFocusKey: `source-filter-${data.localFilter.source}` + }); + + const genreFilterDialog = useContextDialog('genre-filter-dialog', { + content: ({ + content: g, + selected: data.localFilter.genres?.includes(g), + id: `genre-filter-${g}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres?.filter(genre => genre !== g) ?? []] }); + else data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres ?? [], g] }); + ctx.close(); + }, + }))} /> + }); + + const ageRatingFilterDialog = useContextDialog('age-rating-filter-dialog', { + content: ({ + content: a, + selected: data.localFilter.age_ratings?.includes(a), + id: `age-rating-filter-${a}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings?.filter(age => age !== a) ?? []] }); + else data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings ?? [], a] }); + ctx.close(); + }, + }))} /> + }); + + return
+ + } /> + 0} icon={} /> + 0} icon={} /> + {!data.filters?.source && + } /> + } + {Object.values(data.localFilter).some(v => v !== undefined) && + <> +
+ data.setLocalFilter({})} className='p-3 drop-shadow-md!' > + + } + {orderByDialog.dialog} + {sourceFilterDialog.dialog} + {genreFilterDialog.dialog} + {ageRatingFilterDialog.dialog} +
+
; +} \ No newline at end of file diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index de1c231..bdc9a02 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -37,7 +37,7 @@ export default function StatList (data: { content =
{s.content.map((c, ci) => {c})}
; } else { - content =
{s.icon}{s.content}
; + content =
{s.icon}{s.content}
; } return [
; } diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index d9b81f7..47c07c0 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -95,9 +95,9 @@ export function StoreEmulatorCard (data: { >
} - {data.emulator.validSources.slice(0, 3).map(s => + {data.emulator.validSources.slice(0, 3).map((s, i) => { - return
+ return
{emulatorStatusIcons[s.type]}
diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 2687614..fbe2f26 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -6,6 +6,8 @@ import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; import { useEffect } from "react"; import AppCommunication from "../components/AppCommunication"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -32,6 +34,9 @@ function RootComponent () }, [theme]); + const queryDevOptions = useLocalSetting('showQueryDevOptions'); + const routerDevOptions = useLocalSetting('showRouterDevOptions'); + return (
@@ -39,12 +44,8 @@ function RootComponent () - {/*import.meta.env.DEV && !isMobile && - <> - - - - */} + {queryDevOptions && } + {routerDevOptions && }
); } diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx index 2f62d91..3b73d25 100644 --- a/src/mainview/routes/collection.$source.$id.tsx +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -6,10 +6,14 @@ import { AnimatedBackgroundContext } from '../scripts/contexts'; import { getCollectionQuery } from '@queries/romm'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@/shared/constants'; +import { useLocalStorage } from 'usehooks-ts'; export const Route = createFileRoute('/collection/$source/$id')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ countHint: z.number().optional() })) + validateSearch: zodValidator(z.object({ + countHint: z.number().optional() + })) }); function RouteComponent () @@ -18,8 +22,16 @@ function RouteComponent () const { countHint } = Route.useSearch(); const { data: collection } = useQuery(getCollectionQuery(source, id)); const animatedBgContext = useContext(AnimatedBackgroundContext); + const [filter, setFilter] = useLocalStorage("collection-filter", {}); return ( - {collection?.name}
} filters={{ collection_id: Number(id), collection_source: source }} /> + {collection?.name}
} + filters={{ collection_id: Number(id), collection_source: source }} + /> ); } diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 42d23f1..fa54647 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -23,7 +23,6 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; -import { stat } from "node:fs"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -97,12 +96,12 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) { if (data.game.path_fs) stats.push({ label: "Location", content: data.game.path_fs, icon: }); - if (data.game.companies) - stats.push({ label: "Companies", content: data.game.companies }); - if (data.game.genres) - stats.push({ label: 'Genres', content: data.game.genres }); - if (data.game.release_date) - stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); + if (data.game.metadata.companies) + stats.push({ label: "Companies", content: data.game.metadata.companies }); + if (data.game.metadata.genres) + stats.push({ label: 'Genres', content: data.game.metadata.genres }); + if (data.game.metadata.first_release_date) + stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); if (data.game.source) diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index d1071fa..3742e83 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -2,15 +2,36 @@ import { createFileRoute } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@/shared/constants'; +import { useSessionStorage } from 'usehooks-ts'; +import HeaderSearchField from '../components/HeaderSearchField'; +import { useEffect, useState } from 'react'; +import { setFocus } from '@noriginmedia/norigin-spatial-navigation'; export const Route = createFileRoute('/games')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ + focus: z.string().optional(), + search: z.string().optional() + })) }); function RouteComponent () { const { focus } = Route.useSearch(); + const { search } = Route.useSearch(); + const [filter, setFilter] = useSessionStorage('all-games-filters', {}); - return ; + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); + + return setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />} + localFilter={filter} + setLocalFilter={setFilter} + focus={focus} + id='all-games' + />; } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index eee1194..1e758ad 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -47,6 +47,7 @@ import { gameQuery } from "../scripts/queries/romm"; import { oneShot } from "../scripts/audio/audio"; import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; +import HeaderSearchField from "../components/HeaderSearchField"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -232,13 +233,13 @@ function MainMenu () > router.navigate({ to: "/games", state: { eventType: e?.type } })} + onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.event?.type } })} icon={} label="Home" type="secondary" /> } label="News" /> - } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.type } })} label="Shop" /> + } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" /> } label="Album" /> } @@ -247,7 +248,7 @@ function MainMenu () { - router.navigate({ to: '/settings/accounts', state: { eventType: e?.type } }); + router.navigate({ to: '/settings/accounts', state: { eventType: e?.event?.type } }); }} icon={} label="Settings" @@ -264,9 +265,9 @@ function CircleIcon (data: { icon?: JSX.Element; } & InteractParams) { - const handleAction = (e?: Event) => + const handleAction = (event?: Event) => { - data.onAction?.(e); + data.onAction?.({ event, focusKey }); oneShot('click'); }; const { ref, focusKey } = useFocusable({ @@ -313,10 +314,13 @@ export default function ConsoleHomeUI () if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); headerButtons.push( - { id: "search-header-button", icon: }, { id: "power-button", icon: , external: true, action: () => close.mutate(), className: "focusable-error!" }, { id: "settings-header-button", icon: , external: true, action: () => router.navigate({ to: "/settings/accounts" }) } ); + const handleSearch = (search: string | undefined) => + { + router.navigate({ to: '/games', search: { search } }); + }; return ( @@ -334,7 +338,7 @@ export default function ConsoleHomeUI () />
- + } />
("platforms-filters", {}); return (
} filters={{ platform_id: Number(id), platform_source: source }} /> diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index f9921a3..8f52831 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -148,7 +148,7 @@ function EmulatorPath (data: { id: string; }) autocomplete="off" onChange={(v) => { - setLocalValue(v); + setLocalValue(v as string); setDirty(true); }} value={localValue} diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index 9b930e4..ee0ec8f 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -1,6 +1,7 @@ import { LocalOption } from '@/mainview/components/options/LocalOption'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { createFileRoute } from '@tanstack/react-router'; +import { Terminal } from 'lucide-react'; export const Route = createFileRoute('/settings/interface')({ component: RouteComponent, @@ -22,6 +23,11 @@ function RouteComponent () + {import.meta.env.DEV && <> +
Dev Settings
+ + + } ; } diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx index 7b1a8da..c0c7dab 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -28,7 +28,7 @@ function Plugin (data: {
{data.plugin.name} ({data.plugin.version})
} className='flex p-4 bg-base-200 rounded-3xl'> - + data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" /> ; } diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index ad65539..13a1295 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -356,40 +356,6 @@ export function RouteComponent () }); const stats: StatEntry[] = []; - if (emulator) - { - if (emulator.keywords) - stats.push({ label: "Tags", content: emulator.keywords }); - if (emulator.storeDownloadInfo) - stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` }); - stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); - stats.push(...emulator.validSources.flatMap(s => [{ - label: "Source", content:
-
-
{emulatorStatusIcons[s.type]}{s.type}
-
{s.binPath}
-
- {emulator.integrations.some(i => i.source?.type === s.type) &&
} - {emulator.integrations.filter(i => i.source?.type === s.type).map(i => - { - return
-
- -
{i.id}
-
-
- {i.capabilities?.map(c => <>
{capabilityIconMap[c]}{c}
)} -
-
; - })} -
- }])); - if (emulator.bios) - stats.push({ - label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
Missing
- }); - - } return ( diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 524e20a..67a5724 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -10,6 +10,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { storeEmulatorsQuery } from '@queries/store'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { useSessionStorage } from 'usehooks-ts'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, @@ -18,13 +19,14 @@ export const Route = createFileRoute('/store/tab/emulators')({ function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true }); + const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true }); useEffect(() => { diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 7aee585..9f7da33 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -1,27 +1,43 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { Gamepad2 } from 'lucide-react'; -import { useContext, useEffect } from 'react'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { Gamepad2, HardDrive } from 'lucide-react'; +import { JSX, useContext, useEffect, useState } from 'react'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import FrontEndGameCard from '@/mainview/components/FrontEndGameCard'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; import { StoreContext } from '@/mainview/scripts/contexts'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { CardList, GameMetaExtra } from '@/mainview/components/CardList'; +import { GameListFilterType, RPC_URL } from '@/shared/constants'; +import { useSessionStorage } from 'usehooks-ts'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; +import SideFilters from '@/mainview/components/SideFilters'; export const Route = createFileRoute('/store/tab/games')({ component: RouteComponent, - errorComponent: InvalidStoreError + errorComponent: InvalidStoreError, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) }); function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const navigator = useNavigate(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); + const [filter, setFilter] = useSessionStorage('store-games-filters', {}); + const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter)); + const [filterValues, setFilterValues] = useState(); - const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery); - const storeContext = useContext(StoreContext); + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); useEffect(() => { @@ -38,6 +54,11 @@ function RouteComponent () node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }; + function handleDefaultSelect (g: FrontEndGameType) + { + navigator({ to: '/game/$source/$id', params: { id: g.id.id, source: g.id.source } }); + }; + return <>
@@ -47,19 +68,8 @@ function RouteComponent () Games
-
- {data?.pages.flatMap((page) => ( - page.data.map((g, i) => - { - storeContext.prefetchDetails('game', g.id.source, g.id.id); - handleFocus(k, n, d); - }} key={g.id.id} game={g} index={i} />)) - ) ?? Array.from({ length: 20 }).map((_, i) =>
-
-
-
-
)} - + + }} />} games={data?.pages.flatMap((page) => page.data.map((g) => + { + const badges: JSX.Element[] = []; + if (g.id.source === 'local') + { + badges.push(); + } + + const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); + previewUrl.searchParams.delete('ts'); + + const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + + return { + id: `${g.id.source}@${g.id.id}`, + focusKey: `${g.id.source}@${g.id.id}`, + title: g.name ?? "", + subtitle: ( +
+ {!!g.path_platform_cover && } +

{g.platform_display_name}

+
+ ), + previewUrl: previewUrl.href, + badges: badges, + onSelect: () => handleDefaultSelect(g), + onFocus: (k, n, d) => handleFocus(k, n, d) + } satisfies GameMetaExtra as GameMetaExtra; + }) + ) ?? []} id={'store-games'} /> +
+
+
diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index fd2f76b..f459099 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,6 +1,7 @@ import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; +import HeaderSearchField from '@/mainview/components/HeaderSearchField'; import SelectMenu from '@/mainview/components/SelectMenu'; import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { StoreContext } from '@/mainview/scripts/contexts'; @@ -13,7 +14,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; +import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; export const Route = createFileRoute('/store/tab')({ @@ -95,6 +97,8 @@ function RouteComponent () emulators: { label: "Emulators", selected: useIsSettings('emulators') }, games: { label: "Games", selected: useIsSettings('games') } }; + const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); + const [, setGamesSearch] = useSessionStorage(`/store/tab/games-search`, undefined); const handleDetails = (type: string, source: string, id: string, focus: string) => { @@ -120,6 +124,19 @@ function RouteComponent () } }; + const handleSearch = (search: string | undefined) => + { + if (filters['home'].selected) + { + setGamesSearch(search); + router.navigate({ to: '/store/tab/games', replace: true, viewTransition: { types: ['slide-up'] } }); + } else + { + setSearch(search); + } + + }; + const isMobile = mobileCheck(); useStickyDataAttr(headerRef, sentinelRef, ref); @@ -129,7 +146,7 @@ function RouteComponent ()
- + } />
diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index 4a881cd..648f9ef 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -1,11 +1,12 @@ import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query"; import { rommApi, storeApi } from "../clientApi"; +import { GameListFilterType } from "@/shared/constants"; -export const storeEmulatorsQuery = queryOptions({ - queryKey: ['store-emulators'], queryFn: async () => +export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({ + queryKey: ['store-emulators', filters], queryFn: async () => { - const { data, error } = await storeApi.api.store.emulators.get(); + const { data, error } = await storeApi.api.store.emulators.get({ query: { search: filters.search } }); if (error) throw new Error(JSON.stringify(error.value)); return data; } @@ -42,14 +43,14 @@ export const storeEmulatorDeleteMutation = mutationOptions({ if (error) throw error; } }); -export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ +export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ initialPageParam: 0, - queryKey: ['store-games'], + queryKey: ['store-games', filter], getNextPageParam: (lastPage, pages) => lastPage.nextPage, queryFn: async (data) => { const pageParam = data.pageParam as number; - const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); + const { data: games, error } = await rommApi.api.romm.games.get({ query: { ...filter, source: 'store', offset: pageParam * 10, limit: 10 } }); if (error) throw error; return { data: games.games, nextPage: pageParam + 1 }; } diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index c198037..35316b7 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -191,7 +191,7 @@ export function useShortcutContext () return { shortcuts: array }; } -export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps: DependencyList) +export function useShortcuts (focusKey: string, build: () => Shortcut[], deps?: DependencyList) { useEffect(() => { @@ -211,6 +211,6 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps markDirtyThrottled(); }; - }, [...deps, focusKey]); + }, [focusKey, ...deps ?? []]); } \ No newline at end of file diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts index 34803b0..9100029 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -50,9 +50,15 @@ declare interface FocusParams onFocus?: (focusKey: string, node: HTMLElement, details: Record) => void; } +declare interface InteractParamsArgs +{ + event?: Event, + focusKey?: string; +} + declare interface InteractParams { - onAction?: (e?: Event) => void; + onAction?: (ctx: InteractParamsArgs) => void; } declare interface FilterOption extends FocusParams, InteractParams diff --git a/src/shared/constants.ts b/src/shared/constants.ts index faefce1..5d7b307 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -17,11 +17,10 @@ export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; export const STORE_VERSION = "^0"; export const DefaultRommStaleTime = 60 * 1000; // A minute -export interface GameMeta +export interface GameMeta extends FocusParams { id: string, onSelect?: () => void, - onFocus?: (details: FocusDetails) => void, title: string, subtitle: string | JSX.Element, previewUrl?: string; @@ -46,7 +45,9 @@ export const LocalSettingsSchema = z.object({ theme: z.enum(['dark', 'light', 'auto']).default('auto'), soundEffects: z.boolean().default(true), soundEffectsVolume: z.number().min(0).max(100).default(50), - hapticsEffects: z.boolean().default(true) + hapticsEffects: z.boolean().default(true), + showRouterDevOptions: z.boolean().default(false), + showQueryDevOptions: z.boolean().default(false), }); export const GameListFilterSchema = z.object({ @@ -56,9 +57,14 @@ export const GameListFilterSchema = z.object({ collection_id: z.coerce.number().optional(), collection_source: z.string().optional(), limit: z.coerce.number().optional(), + search: z.string().optional(), offset: z.coerce.number().optional(), source: z.string().optional(), - orderBy: z.literal(['added', 'activity', 'name']).optional() + localOnly: z.coerce.boolean().optional(), + orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), + age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), }); export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 077ddd6..ca0fc49 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -57,17 +57,15 @@ declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator } -declare interface FrontEndGameTypeDetailed extends FrontEndGameType +declare interface FrontEndGameTypeDetailed extends Exclude { summary: string | null; fs_size_bytes: number | null; missing: boolean; local: boolean; - genres?: string[]; - companies?: string[]; - release_date?: Date; imdb_id?: number; ra_id?: number; + metadata: FrontEndGameMetadataDetailed, emulators?: FrontEndGameTypeDetailedEmulator[], achievements?: { unlocked: number; @@ -162,6 +160,39 @@ declare interface FrontEndGameTypeWithIds extends FrontEndGameType ra_id: number | null; } +declare interface FrontEndFilterSets +{ + age_ratings: Set, + player_counts: Set, + languages: Set, + companies: Set, + genres: Set; +} + +declare interface FrontEndFilterLists +{ + age_ratings: string[], + player_counts: string[], + languages: string[], + companies: string[], + genres: string[]; +} + +declare interface FrontEndGameMetadata +{ + first_release_date: Date | null; +} + +declare interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata +{ + genres: string[], + companies: string[], + game_modes: string[], + age_ratings: string[]; + player_count: string | null; + average_rating: number | null; +} + declare interface FrontEndGameType { platform_display_name: string | null, @@ -173,6 +204,7 @@ declare interface FrontEndGameType path_cover: string | null, last_played: Date | null, updated_at: Date, + metadata: FrontEndGameMetadata, slug: string | null, name: string | null, platform_id: number | null,