feat: Implemented filtering and searching
This commit is contained in:
parent
4806f3487a
commit
444d8c4c27
49 changed files with 841 additions and 290 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
|
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 z from "zod";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
@ -20,6 +20,7 @@ import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmula
|
||||||
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
||||||
import { host } from "@/bun/utils/host";
|
import { host } from "@/bun/utils/host";
|
||||||
import { LaunchGameJob } from "../jobs/launch-game-job";
|
import { LaunchGameJob } from "../jobs/launch-game-job";
|
||||||
|
import { cores } from "../emulatorjs/emulatorjs";
|
||||||
|
|
||||||
// A custom jimp that supports webp
|
// A custom jimp that supports webp
|
||||||
const Jimp = createJimp({
|
const Jimp = createJimp({
|
||||||
|
|
@ -134,12 +135,24 @@ export default new Elysia()
|
||||||
.get('/games', async ({ query, set }) =>
|
.get('/games', async ({ query, set }) =>
|
||||||
{
|
{
|
||||||
const games: FrontEndGameType[] = [];
|
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')
|
if (query.source === 'store')
|
||||||
{
|
{
|
||||||
const shuffledGames = await getShuffledStoreGames();
|
const shuffledGames = await getShuffledStoreGames();
|
||||||
set.headers['x-max-items'] = shuffledGames.length;
|
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))
|
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
|
||||||
.map(async (e) =>
|
.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)
|
if (query.source)
|
||||||
{
|
{
|
||||||
where.push(eq(schema.games.source, 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.
|
// Collections are just a remote thing for now.
|
||||||
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
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 =>
|
games.push(...remoteGames.map(g =>
|
||||||
{
|
{
|
||||||
if (localGameExistsPredicate(g))
|
if (localGameExistsPredicate(g))
|
||||||
|
|
@ -233,37 +251,74 @@ export default new Elysia()
|
||||||
|
|
||||||
} else
|
} 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);
|
return convertLocalToFrontend(g);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
if (query.localOnly !== true)
|
||||||
const remoteGameSet = new Set<string>();
|
|
||||||
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
|
||||||
games.push(...remoteGames.filter(g =>
|
|
||||||
{
|
{
|
||||||
if (localGameExistsPredicate(g))
|
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
||||||
|
const remoteGameSet = new Set<string>();
|
||||||
|
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}`;
|
metadata.genres.forEach((g: string) => filterSets.genres.add(g));
|
||||||
if (remoteGameSet.has(igdbId)) return false;
|
|
||||||
remoteGameSet.add(igdbId);
|
|
||||||
}
|
}
|
||||||
|
if (metadata.age_ratings && Array.isArray(metadata.age_ratings))
|
||||||
if (g.ra_id)
|
|
||||||
{
|
{
|
||||||
const raId = `ra@${g.ra_id}`;
|
metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g));
|
||||||
if (remoteGameSet.has(raId)) return false;
|
|
||||||
remoteGameSet.add(raId);
|
|
||||||
}
|
}
|
||||||
|
if (metadata.companies && Array.isArray(metadata.companies))
|
||||||
return true;
|
{
|
||||||
}));
|
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':
|
case 'name':
|
||||||
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
||||||
break;
|
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,
|
query: GameListFilterSchema,
|
||||||
})
|
})
|
||||||
|
|
@ -341,8 +407,22 @@ export default new Elysia()
|
||||||
return {
|
return {
|
||||||
name: 'EMULATORJS',
|
name: 'EMULATORJS',
|
||||||
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
|
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
|
||||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
logo: 'https://emulatorjs.org/logo/EmulatorJS.png',
|
||||||
systems: [],
|
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,
|
gameCount: 0,
|
||||||
integrations: []
|
integrations: []
|
||||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||||
|
|
@ -536,8 +616,8 @@ export default new Elysia()
|
||||||
const sourceData = await getSourceGameDetailed(source, id);
|
const sourceData = await getSourceGameDetailed(source, id);
|
||||||
if (!sourceData) return status("Not Found");
|
if (!sourceData) return status("Not Found");
|
||||||
|
|
||||||
const sourceCompaniesSet = new Set(sourceData.companies);
|
const sourceCompaniesSet = new Set(sourceData.metadata.companies);
|
||||||
const sourceGenresSet = new Set(sourceData.genres);
|
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;
|
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}`));
|
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 shuffledGames = await getShuffledStoreGames();
|
||||||
const storeGames = await Promise.all(shuffledGames
|
const storeGames = await Promise.all(shuffledGames
|
||||||
|
|
@ -559,7 +639,7 @@ export default new Elysia()
|
||||||
const system = path.dirname(g.path);
|
const system = path.dirname(g.path);
|
||||||
const id = path.basename(g.path, path.extname(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;
|
return false;
|
||||||
|
|
||||||
if (esSystem)
|
if (esSystem)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,19 @@ export async function fixSource (source: string, id: string)
|
||||||
|
|
||||||
if (foundGame)
|
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;
|
return true;
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
|
|
@ -82,6 +94,9 @@ export async function validateGameSource (source: string, id: string): Promise<{
|
||||||
if (!localGame) return { valid: true };
|
if (!localGame) return { valid: true };
|
||||||
if (localGame.source && localGame.source_id)
|
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 });
|
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) return { valid: false, reason: "Source Missing", localGame };
|
||||||
if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
|
if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
platform_display_name: g.platform?.name ?? "Local",
|
platform_display_name: g.platform?.name ?? null,
|
||||||
id: { id: String(g.id), source: 'local' },
|
id: { id: String(g.id), source: 'local' },
|
||||||
updated_at: g.created_at,
|
updated_at: g.created_at,
|
||||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||||
|
|
@ -45,17 +45,24 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||||
slug: g.slug,
|
slug: g.slug,
|
||||||
name: g.name,
|
name: g.name,
|
||||||
platform_id: g.platform_id,
|
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;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
||||||
platform?: typeof schema.platforms.$inferSelect | null;
|
platform?: { name: string | null, slug: string | null; } | null;
|
||||||
screenshotIds?: number[];
|
screenshotIds?: number[];
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
|
|
||||||
|
const exists = await checkInstalled(g.path_fs);
|
||||||
|
const fileSize = await calculateSize(g.path_fs);
|
||||||
|
|
||||||
const game: FrontEndGameTypeDetailed = {
|
const game: FrontEndGameTypeDetailed = {
|
||||||
platform_display_name: g.platform?.name ?? "Local",
|
platform_display_name: g.platform?.name ?? "Local",
|
||||||
id: { id: String(g.id), source: '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_id: g.platform_id,
|
||||||
platform_slug: g.platform?.slug ?? null,
|
platform_slug: g.platform?.slug ?? null,
|
||||||
summary: g.summary,
|
summary: g.summary,
|
||||||
fs_size_bytes: 0,
|
fs_size_bytes: fileSize,
|
||||||
missing: false,
|
missing: !exists,
|
||||||
local: true
|
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;
|
return game;
|
||||||
|
|
@ -107,7 +123,10 @@ export async function convertStoreToFrontend (system: string, id: string, storeG
|
||||||
name: storeGame.title,
|
name: storeGame.title,
|
||||||
platform_id: null,
|
platform_id: null,
|
||||||
platform_slug: rommSystem?.sourceSlug ?? system,
|
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;
|
return game;
|
||||||
|
|
@ -131,6 +150,15 @@ export async function convertStoreToFrontendDetailed (system: string, id: string
|
||||||
fs_size_bytes: size,
|
fs_size_bytes: size,
|
||||||
missing: false,
|
missing: false,
|
||||||
local: false,
|
local: false,
|
||||||
|
metadata: {
|
||||||
|
genres: storeGame.tags,
|
||||||
|
companies: [],
|
||||||
|
game_modes: [],
|
||||||
|
age_ratings: [],
|
||||||
|
player_count: "",
|
||||||
|
average_rating: null,
|
||||||
|
first_release_date: null
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return detailed;
|
return detailed;
|
||||||
|
|
@ -148,29 +176,7 @@ export async function getLocalGameDetailed (match: any)
|
||||||
|
|
||||||
if (localGame)
|
if (localGame)
|
||||||
{
|
{
|
||||||
const exists = await checkInstalled(localGame.path_fs);
|
return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) });
|
||||||
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 undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ export class GameHooks
|
||||||
fetchGames = new AsyncSeriesHook<[ctx: {
|
fetchGames = new AsyncSeriesHook<[ctx: {
|
||||||
query: GameListFilterType;
|
query: GameListFilterType;
|
||||||
games: FrontEndGameTypeWithIds[];
|
games: FrontEndGameTypeWithIds[];
|
||||||
|
filters: FrontEndFilterSets;
|
||||||
|
}]>(['ctx']);
|
||||||
|
fetchFilters = new AsyncSeriesHook<[ctx: {
|
||||||
|
filters: FrontEndFilterSets;
|
||||||
}]>(['ctx']);
|
}]>(['ctx']);
|
||||||
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string;
|
source: string;
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
if (typeof filename === 'string')
|
if (typeof filename === 'string')
|
||||||
{
|
{
|
||||||
console.log("Save File Changed", filename);
|
console.log("Save File Changed", filename);
|
||||||
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
|
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath!, shared: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
||||||
import { config, events } from "@/bun/api/app";
|
import { config, events } from "@/bun/api/app";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
|
@ -16,6 +16,12 @@ import { validateGameSource } from "@/bun/api/games/services/statusService";
|
||||||
export default class RommIntegration implements PluginType
|
export default class RommIntegration implements PluginType
|
||||||
{
|
{
|
||||||
isSteamDeck = false;
|
isSteamDeck = false;
|
||||||
|
orderByMap: Record<string, string> = {
|
||||||
|
added: "created_at",
|
||||||
|
activity: "created_at",
|
||||||
|
name: "name",
|
||||||
|
release: "metadatum.first_release_date"
|
||||||
|
};
|
||||||
|
|
||||||
async updateClient ()
|
async updateClient ()
|
||||||
{
|
{
|
||||||
|
|
@ -49,8 +55,11 @@ export default class RommIntegration implements PluginType
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
id: { id: String(rom.id), source: 'romm' },
|
id: { id: String(rom.id), source: 'romm' },
|
||||||
path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`,
|
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),
|
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,
|
slug: rom.slug,
|
||||||
platform_id: rom.platform_id,
|
platform_id: rom.platform_id,
|
||||||
platform_display_name: rom.platform_display_name,
|
platform_display_name: rom.platform_display_name,
|
||||||
|
|
@ -74,11 +83,17 @@ export default class RommIntegration implements PluginType
|
||||||
fs_size_bytes: rom.fs_size_bytes,
|
fs_size_bytes: rom.fs_size_bytes,
|
||||||
local: false,
|
local: false,
|
||||||
missing: rom.missing_from_fs,
|
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,
|
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();
|
const userData = await getCurrentUserApiUsersMeGet();
|
||||||
|
|
@ -119,26 +134,32 @@ export default class RommIntegration implements PluginType
|
||||||
|
|
||||||
load (ctx: PluginContextType)
|
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'))
|
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
||||||
{
|
{
|
||||||
|
|
||||||
const orderByMap: Record<string, string> = {
|
|
||||||
added: "created_at",
|
|
||||||
activity: "created_at",
|
|
||||||
name: "name"
|
|
||||||
};
|
|
||||||
|
|
||||||
const rommGames = await getRomsApiRomsGet({
|
const rommGames = await getRomsApiRomsGet({
|
||||||
query: {
|
query: {
|
||||||
platform_ids: query.platform_id ? [query.platform_id] : undefined,
|
platform_ids: query.platform_id ? [query.platform_id] : undefined,
|
||||||
collection_id: query.collection_id,
|
collection_id: query.collection_id,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
offset: query.offset,
|
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
|
}, 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 =>
|
games.push(...rommGames.data.items.map(g =>
|
||||||
{
|
{
|
||||||
const game: FrontEndGameTypeWithIds = {
|
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 }) =>
|
ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) =>
|
||||||
{
|
{
|
||||||
if (service !== 'romm') return;
|
if (service !== 'romm') return;
|
||||||
|
|
@ -277,10 +308,10 @@ export default class RommIntegration implements PluginType
|
||||||
const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug);
|
const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug);
|
||||||
if (rommPlatform)
|
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)
|
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) })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,15 @@ export const games = sqliteTable('games', {
|
||||||
path_fs: text("path_fs"),
|
path_fs: text("path_fs"),
|
||||||
last_played: integer("last_played", { mode: 'timestamp' }),
|
last_played: integer("last_played", { mode: 'timestamp' }),
|
||||||
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
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(),
|
slug: text("slug").unique(),
|
||||||
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
|
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
|
||||||
cover: blob("cover", { mode: 'buffer' }),
|
cover: blob("cover", { mode: 'buffer' }),
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,22 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
});
|
});
|
||||||
const emulatesParsed = await getAllStoreEmulatorPackages();
|
const emulatesParsed = await getAllStoreEmulatorPackages();
|
||||||
let frontEndEmulators = await Promise.all(emulatesParsed
|
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) =>
|
.map(async (emulator) =>
|
||||||
{
|
{
|
||||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
|
|
@ -77,7 +92,8 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
limit: z.coerce.number().optional(),
|
limit: z.coerce.number().optional(),
|
||||||
missing: z.stringbool().optional().describe("Show Only Non Installed emulators"),
|
missing: z.stringbool().optional().describe("Show Only Non Installed emulators"),
|
||||||
orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(),
|
orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(),
|
||||||
related: z.string().optional()
|
related: z.string().optional(),
|
||||||
|
search: z.string().optional()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.get('/games/featured', async () =>
|
.get('/games/featured', async () =>
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,7 @@ export function GameCardSkeleton ()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameCardFocusHandler = (id: string, node: HTMLElement, details: FocusDetails) => void;
|
export interface GameCardParams extends FocusParams
|
||||||
|
|
||||||
export interface GameCardParams
|
|
||||||
{
|
{
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string | JSX.Element;
|
subtitle: string | JSX.Element;
|
||||||
|
|
@ -31,7 +29,6 @@ export interface GameCardParams
|
||||||
id: string;
|
id: string;
|
||||||
badges?: JSX.Element[];
|
badges?: JSX.Element[];
|
||||||
className?: string;
|
className?: string;
|
||||||
onFocus?: GameCardFocusHandler;
|
|
||||||
onBlur?: (id: string) => void;
|
onBlur?: (id: string) => void;
|
||||||
clickFocuses?: boolean;
|
clickFocuses?: boolean;
|
||||||
previewClassName?: string;
|
previewClassName?: string;
|
||||||
|
|
@ -39,14 +36,14 @@ export interface GameCardParams
|
||||||
|
|
||||||
export default function CardElement (data: GameCardParams & InteractParams)
|
export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
{
|
{
|
||||||
const handleAction = () =>
|
const handleAction = (event?: Event) =>
|
||||||
{
|
{
|
||||||
data.onAction?.();
|
data.onAction?.({ event, focusKey });
|
||||||
oneShot('click');
|
oneShot('click');
|
||||||
};
|
};
|
||||||
const { ref, focused, focusSelf } = useFocusable({
|
const { ref, focused, focusSelf, focusKey } = useFocusable({
|
||||||
focusKey: data.focusKey,
|
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,
|
onEnterPress: handleAction,
|
||||||
onBlur: () => data.onBlur?.(data.id),
|
onBlur: () => data.onBlur?.(data.id),
|
||||||
});
|
});
|
||||||
|
|
@ -63,10 +60,10 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
scrollSnapAlign: isPointer ? "center" : "none"
|
scrollSnapAlign: isPointer ? "center" : "none"
|
||||||
}}
|
}}
|
||||||
onFocus={focusSelf}
|
onFocus={focusSelf}
|
||||||
onClick={() =>
|
onClick={(e) =>
|
||||||
{
|
{
|
||||||
focusSelf();
|
focusSelf();
|
||||||
handleAction();
|
handleAction(e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
className={twMerge(
|
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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ import
|
||||||
useFocusable,
|
useFocusable,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { GameMeta } from "../../shared/constants";
|
import { GameMeta } from "../../shared/constants";
|
||||||
import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement";
|
import CardElement, { GameCardParams } from "./CardElement";
|
||||||
import { JSX } from "react";
|
import { JSX } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||||
import { oneShot } from "../scripts/audio/audio";
|
import { oneShot } from "../scripts/audio/audio";
|
||||||
import { GamepadButtonEvent } from "../scripts/gamepads";
|
|
||||||
|
|
||||||
export interface GameMetaExtra extends GameMeta
|
export interface GameMetaExtra extends GameMeta
|
||||||
{
|
{
|
||||||
|
|
@ -26,13 +25,14 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
||||||
preview = data.game.previewUrl;
|
preview = data.game.previewUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAction = (e?: Event) =>
|
const handleAction = (ctx: InteractParamsArgs) =>
|
||||||
{
|
{
|
||||||
data.game.onSelect?.();
|
data.game.onSelect?.();
|
||||||
data.onAction?.();
|
data.onAction?.({ event, focusKey: data.game.focusKey });
|
||||||
oneShot('click');
|
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 (
|
return (
|
||||||
<CardElement
|
<CardElement
|
||||||
|
|
@ -42,10 +42,10 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
||||||
title={data.game.title}
|
title={data.game.title}
|
||||||
subtitle={data.game.subtitle ?? ""}
|
subtitle={data.game.subtitle ?? ""}
|
||||||
srcset={data.game.previewSrcset}
|
srcset={data.game.previewSrcset}
|
||||||
onFocus={(id, node, details) =>
|
onFocus={(focusKey, node, details) =>
|
||||||
{
|
{
|
||||||
data.game.onFocus?.(details);
|
data.game.onFocus?.(focusKey, node, details);
|
||||||
data.onFocus?.(id, node, details);
|
data.onFocus?.(focusKey, node, details);
|
||||||
}}
|
}}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
|
@ -61,16 +61,18 @@ export function CardList (data: {
|
||||||
games: GameMetaExtra[];
|
games: GameMetaExtra[];
|
||||||
grid?: boolean;
|
grid?: boolean;
|
||||||
onSelectGame?: (id: string) => void;
|
onSelectGame?: (id: string) => void;
|
||||||
onGameFocus?: GameCardFocusHandler;
|
focus?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
finalElement?: JSX.Element;
|
finalElement?: JSX.Element;
|
||||||
saveChildFocus?: 'session' | 'local';
|
saveChildFocus?: 'session' | 'local';
|
||||||
})
|
} & FocusParams)
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusKey: data.id,
|
focusKey: data.id,
|
||||||
forceFocus: true,
|
forceFocus: true,
|
||||||
autoRestoreFocus: true
|
autoRestoreFocus: true,
|
||||||
|
focusable: data.games.length > 0,
|
||||||
|
preferredChildFocusKey: data.focus
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -92,7 +94,7 @@ export function CardList (data: {
|
||||||
>
|
>
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
{data.games.map((g, i) => <LocalCardElement
|
{data.games.map((g, i) => <LocalCardElement
|
||||||
key={g.id} onFocus={data.onGameFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
|
key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
|
||||||
{data.finalElement}
|
{data.finalElement}
|
||||||
</FocusContext.Provider>
|
</FocusContext.Provider>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { RPC_URL } from "@/shared/constants";
|
import { RPC_URL } from "@/shared/constants";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { CardList, GameMetaExtra } from "./CardList";
|
import { CardList, GameMetaExtra } from "./CardList";
|
||||||
import { GameCardFocusHandler } from "./CardElement";
|
|
||||||
import { getCollectionsQuery } from "@queries/romm";
|
import { getCollectionsQuery } from "@queries/romm";
|
||||||
import { useRouter } from "@tanstack/react-router";
|
import { useRouter } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,50 @@
|
||||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { StickyHeaderUI } from './Header';
|
import { HeaderButton, StickyHeaderUI } from './Header';
|
||||||
import { GameList } from './GameList';
|
import { GameList } from './GameList';
|
||||||
import { Search, Settings2 } from 'lucide-react';
|
import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react';
|
||||||
import { JSX, Suspense } from 'react';
|
import { JSX, Suspense, useRef, useState } from 'react';
|
||||||
import { FloatingShortcuts } from './Shortcuts';
|
import { FloatingShortcuts } from './Shortcuts';
|
||||||
import { AutoFocus } from './AutoFocus';
|
import { AutoFocus } from './AutoFocus';
|
||||||
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
|
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
|
||||||
import { GameListFilterType } from '@/shared/constants';
|
import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
|
||||||
import { GameCardFocusHandler } from './CardElement';
|
|
||||||
import { HandleGoBack } from '../scripts/utils';
|
import { HandleGoBack } from '../scripts/utils';
|
||||||
import LoadingCardList from './LoadingCardList';
|
import LoadingCardList from './LoadingCardList';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { gameQuery } from '../scripts/queries/romm';
|
import { gameQuery } from '../scripts/queries/romm';
|
||||||
import { useRouter } from '@tanstack/react-router';
|
import { useNavigate, useRouter } from '@tanstack/react-router';
|
||||||
import SelectMenu from './SelectMenu';
|
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
|
export interface CollectionsDetailParams
|
||||||
{
|
{
|
||||||
id?: string;
|
id?: string;
|
||||||
setBackground?: (url: string) => void;
|
setBackground?: (url: string) => void;
|
||||||
filters?: GameListFilterType;
|
filters?: GameListFilterType;
|
||||||
builder?: () => Promise<{ filter?: GameListFilterType, title?: JSX.Element; }>;
|
setLocalFilter: (filter: GameListFilterType) => void,
|
||||||
|
localFilter: GameListFilterType,
|
||||||
headerTitle?: JSX.Element;
|
headerTitle?: JSX.Element;
|
||||||
|
headerChildren?: any;
|
||||||
title?: JSX.Element;
|
title?: JSX.Element;
|
||||||
footer?: JSX.Element;
|
footer?: JSX.Element;
|
||||||
focus?: string;
|
focus?: string;
|
||||||
countHit?: number;
|
countHint?: number;
|
||||||
|
headerButtons?: HeaderButton[];
|
||||||
|
headerButtonElements?: JSX.Element | JSX.Element[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
{
|
{
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const builtData = useQuery({
|
const [filterValues, setFilterValues] = useState<FrontEndFilterLists>();
|
||||||
queryKey: ['filter', data.id], queryFn: async () =>
|
|
||||||
{
|
|
||||||
return data.builder?.() ?? { filter: data.filters, title: data.title };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const queryClient = useQueryClient();
|
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({
|
const { ref, focusSelf } = useFocusable({
|
||||||
focusKey,
|
focusKey,
|
||||||
preferredChildFocusKey: `${focusKey}-list`
|
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]);
|
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('@');
|
const [source, id] = cardId.split('@');
|
||||||
queryClient.prefetchQuery(gameQuery(source, id));
|
queryClient.prefetchQuery(gameQuery(source, id));
|
||||||
|
|
||||||
|
|
@ -61,22 +66,27 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
return (
|
return (
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<div ref={ref} className='absolute w-screen h-screen overflow-y-scroll'>
|
<div ref={ref} className='absolute w-screen h-screen overflow-y-scroll'>
|
||||||
<StickyHeaderUI title={data.headerTitle} buttons={[{ id: "search", icon: <Search /> }, { id: "filter", icon: <Settings2 /> }]} ref={ref} />
|
<StickyHeaderUI title={data.headerTitle} buttonElements={data.headerButtonElements} buttons={data.headerButtons} ref={ref} >
|
||||||
<div className="w-full grow rounded-2xl justify-center mask-alpha sm:portrait:mask-t-from-transparent md:landscape:mask-t-from-transparent mask-t-to-20 mask-t-to-black">
|
{data.headerChildren}
|
||||||
<div className="relative h-fit w-full md:px-6 pt-4 pb-32">
|
</StickyHeaderUI>
|
||||||
{builtData.data?.filter && data.title}
|
<div className="w-full grow justify-center mask-alpha sm:portrait:mask-t-from-transparent md:landscape:mask-t-from-transparent mask-t-to-20 mask-t-to-black">
|
||||||
{(builtData.data?.filter || (!data.filters && !data.builder)) && <Suspense fallback={<LoadingCardList grid placeholderCount={data.countHit ?? 8} id={`${focusKey}-list`} />}>
|
<div className="relative h-fit w-full md:pr-6 pt-4 pb-32 pl-16">
|
||||||
|
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div>
|
||||||
|
<div className='mobile:hidden bg-noise'></div>
|
||||||
|
<div className='mobile:hidden bg-dots'></div>
|
||||||
|
{finalFilter && data.title}
|
||||||
|
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
|
||||||
<GameList
|
<GameList
|
||||||
|
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
||||||
grid
|
grid
|
||||||
filters={builtData.data?.filter}
|
setFilterValues={setFilterValues}
|
||||||
|
filters={finalFilter}
|
||||||
onFocus={handleScroll}
|
onFocus={handleScroll}
|
||||||
|
focus={data.focus}
|
||||||
id={`${focusKey}-list`}>
|
id={`${focusKey}-list`}>
|
||||||
</GameList>
|
</GameList>
|
||||||
<AutoFocus parentKey={focusKey} focus={focusSelf} />
|
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={100} />
|
||||||
</Suspense>}
|
</Suspense>}
|
||||||
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300'></div>
|
|
||||||
<div className='mobile:hidden bg-noise z-1'></div>
|
|
||||||
<div className='mobile:hidden bg-dots z-1'></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer className="px-2 pb-2 fixed bottom-0 w-full h-12 flex items-center justify-between">
|
<footer className="px-2 pb-2 fixed bottom-0 w-full h-12 flex items-center justify-between">
|
||||||
|
|
@ -85,6 +95,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
</div>
|
</div>
|
||||||
<FloatingShortcuts />
|
<FloatingShortcuts />
|
||||||
</footer>
|
</footer>
|
||||||
|
<div className='fixed left-2 top-24 bottom-0 sm:w-10 md:w-14'>
|
||||||
|
<SideFilters id='filter-btns' localFilter={data.localFilter} setLocalFilter={data.setLocalFilter} filterValues={filterValues} filters={data.filters} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SelectMenu rootFocusKey={focusKey} />
|
<SelectMenu rootFocusKey={focusKey} />
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
|
|
|
||||||
7
src/mainview/components/Constants.tsx
Normal file
7
src/mainview/components/Constants.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Gamepad2, HardDrive, Store } from "lucide-react";
|
||||||
|
|
||||||
|
export const sourceIconMap: Record<string, any> = {
|
||||||
|
store: <Store />,
|
||||||
|
local: <HardDrive />,
|
||||||
|
romm: <Gamepad2 />
|
||||||
|
};
|
||||||
|
|
@ -35,7 +35,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
||||||
const handleAction = () =>
|
const handleAction = () =>
|
||||||
{
|
{
|
||||||
if (data.disabled === true) return;
|
if (data.disabled === true) return;
|
||||||
data.action?.({ close: context.close, focus: focusSelf });
|
data.action?.({ close: context.close, focus: focusSelf, selected: data.selected });
|
||||||
oneShot('click');
|
oneShot('click');
|
||||||
};
|
};
|
||||||
const { ref, focusSelf, focusKey } = useFocusable({
|
const { ref, focusSelf, focusKey } = useFocusable({
|
||||||
|
|
@ -82,7 +82,7 @@ export interface DialogEntry
|
||||||
icon?: string | JSX.Element;
|
icon?: string | JSX.Element;
|
||||||
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
||||||
selected?: boolean;
|
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[];
|
shortcuts?: Shortcut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +102,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
|
||||||
{
|
{
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
data.onClose?.();
|
data.onClose?.();
|
||||||
|
oneShot('closeContext');
|
||||||
if (newSourceFocusKey)
|
if (newSourceFocusKey)
|
||||||
{
|
{
|
||||||
setFocus(newSourceFocusKey, { instant: true });
|
setFocus(newSourceFocusKey, { instant: true });
|
||||||
|
|
@ -118,7 +119,12 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
|
||||||
return {
|
return {
|
||||||
dialog,
|
dialog,
|
||||||
open,
|
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 = () =>
|
const handleClose = () =>
|
||||||
{
|
{
|
||||||
data.close(false);
|
data.close(false);
|
||||||
oneShot('closeContext');
|
|
||||||
};
|
};
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -161,7 +166,7 @@ export function ContextDialog (data: {
|
||||||
}] : [], [data.open]);
|
}] : [], [data.open]);
|
||||||
|
|
||||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||||
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
twMerge("fixed modal cursor-pointer bg-base-300/80 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||||
classNames({ "opacity-0": !data.open }), data.backdropClassName)
|
classNames({ "opacity-0": !data.open }), data.backdropClassName)
|
||||||
}
|
}
|
||||||
onClick={handleClose}>
|
onClick={handleClose}>
|
||||||
|
|
@ -169,7 +174,7 @@ export function ContextDialog (data: {
|
||||||
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto backdrop-blur-2xl",
|
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl",
|
||||||
data.open ? "animate-scale-delayed" : "opacity-0",
|
data.open ? "animate-scale-delayed" : "opacity-0",
|
||||||
data.className)
|
data.className)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,28 @@ import { GameMetaExtra, CardList } from "./CardList";
|
||||||
import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants";
|
import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { HardDrive } from "lucide-react";
|
import { HardDrive } from "lucide-react";
|
||||||
import { JSX, useContext } from "react";
|
import { JSX, Ref, useContext, useEffect } from "react";
|
||||||
import { GameCardFocusHandler } from "./CardElement";
|
|
||||||
import { useLocalSetting } from "../scripts/utils";
|
import { useLocalSetting } from "../scripts/utils";
|
||||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||||
import { allGamesQuery } from "@queries/romm";
|
import { allGamesQuery } from "@queries/romm";
|
||||||
|
|
||||||
export interface GameListParams
|
export interface GameListParams extends FocusParams
|
||||||
{
|
{
|
||||||
id: string,
|
id: string,
|
||||||
filters?: GameListFilterType,
|
filters?: GameListFilterType,
|
||||||
grid?: boolean,
|
grid?: boolean,
|
||||||
setBackground?: (url: string) => void;
|
setBackground?: (url: string) => void;
|
||||||
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
|
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
|
||||||
onFocus?: GameCardFocusHandler;
|
focus?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
finalElement?: JSX.Element;
|
finalElement?: JSX.Element;
|
||||||
saveChildFocus?: "session" | "local";
|
saveChildFocus?: "session" | "local";
|
||||||
|
setFilterValues?: (filters: FrontEndFilterLists) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameList (data: GameListParams)
|
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 navigator = useNavigate();
|
||||||
const blur = useLocalSetting('backgroundBlur');
|
const blur = useLocalSetting('backgroundBlur');
|
||||||
const backgroundContext = useContext(AnimatedBackgroundContext);
|
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)
|
function handleDefaultSelect (g: FrontEndGameType)
|
||||||
{
|
{
|
||||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } });
|
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"
|
type="game"
|
||||||
grid={data.grid}
|
grid={data.grid}
|
||||||
className={data.className}
|
className={data.className}
|
||||||
onGameFocus={data.onFocus}
|
onFocus={data.onFocus}
|
||||||
finalElement={data.finalElement}
|
finalElement={data.finalElement}
|
||||||
saveChildFocus={data.saveChildFocus}
|
saveChildFocus={data.saveChildFocus}
|
||||||
|
focus={data.focus}
|
||||||
games={games.data?.games
|
games={games.data?.games
|
||||||
.map(
|
.map(
|
||||||
(g) =>
|
(g) =>
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div onClick={handleSelect} ref={ref} style={{ viewTimelineName: "header-accounts" }} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-hover ">
|
return <div onClick={handleSelect} ref={ref} style={{ viewTimelineName: "header-accounts" }} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-primary focusable-hover ">
|
||||||
{accounts?.map(a => <HeaderAvatar
|
{accounts?.map(a => <HeaderAvatar
|
||||||
key={`header-avatar-${a.id}`}
|
key={`header-avatar-${a.id}`}
|
||||||
id={`account-${a.id}`}
|
id={`account-${a.id}`}
|
||||||
|
|
@ -260,7 +260,8 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
||||||
</div>
|
</div>
|
||||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
{data.buttonElements}
|
||||||
|
{data.buttons?.map(b => <RoundButton
|
||||||
key={b.id}
|
key={b.id}
|
||||||
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
|
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
|
||||||
id={b.id}
|
id={b.id}
|
||||||
|
|
@ -306,7 +307,7 @@ export function HeaderUI (data: HeaderUIParams)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string; } & HeaderUIParams)
|
export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string; children?: any; } & HeaderUIParams)
|
||||||
{
|
{
|
||||||
const [isStuck, setIsStuck] = useState(false);
|
const [isStuck, setIsStuck] = useState(false);
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
|
|
@ -317,6 +318,7 @@ export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string;
|
||||||
<div ref={sentinelRef} className="h-0" />
|
<div ref={sentinelRef} className="h-0" />
|
||||||
<div ref={headerRef} className={twMerge('sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15', data.className)}>
|
<div ref={headerRef} className={twMerge('sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15', data.className)}>
|
||||||
<HeaderUI focusable={!isStuck} {...data} />
|
<HeaderUI focusable={!isStuck} {...data} />
|
||||||
|
{data.children}
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
102
src/mainview/components/HeaderSearchField.tsx
Normal file
102
src/mainview/components/HeaderSearchField.tsx
Normal file
|
|
@ -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<HTMLInputElement>(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 <label ref={ref} onFocus={data.onInputFocus} className='input rounded-full input-lg w-full max-w-xs has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content'>
|
||||||
|
<Search />
|
||||||
|
<input
|
||||||
|
onBlur={e =>
|
||||||
|
{
|
||||||
|
data.setShowInput(false);
|
||||||
|
setLocalSearch(data.search);
|
||||||
|
}}
|
||||||
|
autoFocus={data.compact}
|
||||||
|
ref={inputRef}
|
||||||
|
value={localSearch ?? ""}
|
||||||
|
onChange={v => setLocalSearch(v.target.value)}
|
||||||
|
type='search'
|
||||||
|
placeholder='Search'
|
||||||
|
/>
|
||||||
|
</label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div ref={ref} className='flex items-center'>
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
{(!data.compact || showInput) && <SearchInput autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
|
||||||
|
{data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,9 @@ import { useIntersectionObserver } from "usehooks-ts";
|
||||||
|
|
||||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams)
|
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)
|
if (data.lastId && focused)
|
||||||
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
|
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
|
||||||
};
|
};
|
||||||
|
|
@ -18,8 +18,6 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr
|
||||||
onEnterPress: handleAction
|
onEnterPress: handleAction
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { ref: intersct } = useIntersectionObserver({
|
const { ref: intersct } = useIntersectionObserver({
|
||||||
initialIsIntersecting: true,
|
initialIsIntersecting: true,
|
||||||
rootMargin: "20%",
|
rootMargin: "20%",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { CardList, GameMetaExtra } from "./CardList";
|
||||||
import { rommApi } from "../scripts/clientApi";
|
import { rommApi } from "../scripts/clientApi";
|
||||||
import { JSX, useMemo } from "react";
|
import { JSX, useMemo } from "react";
|
||||||
import { HardDrive } from "lucide-react";
|
import { HardDrive } from "lucide-react";
|
||||||
import { GameCardFocusHandler } from "./CardElement";
|
|
||||||
import { mobileCheck } from "../scripts/utils";
|
import { mobileCheck } from "../scripts/utils";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
|
@ -13,11 +12,10 @@ export function PlatformsList (data: {
|
||||||
id: string,
|
id: string,
|
||||||
setBackground: (url: string) => void;
|
setBackground: (url: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
onFocus?: GameCardFocusHandler;
|
|
||||||
grid?: boolean;
|
grid?: boolean;
|
||||||
onSelect?: (source: string, id: string) => void;
|
onSelect?: (source: string, id: string) => void;
|
||||||
saveChildFocus?: "session" | "local";
|
saveChildFocus?: "session" | "local";
|
||||||
})
|
} & FocusParams)
|
||||||
{
|
{
|
||||||
const isMobile = mobileCheck();
|
const isMobile = mobileCheck();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -88,7 +86,7 @@ export function PlatformsList (data: {
|
||||||
id={data.id}
|
id={data.id}
|
||||||
grid={data.grid}
|
grid={data.grid}
|
||||||
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
|
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
|
||||||
onGameFocus={data.onFocus}
|
onFocus={data.onFocus}
|
||||||
games={platformsMapped}
|
games={platformsMapped}
|
||||||
onSelectGame={(id) =>
|
onSelectGame={(id) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import { twMerge } from "tailwind-merge";
|
||||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
|
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
|
||||||
{
|
{
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
const { ref, focusSelf } = useFocusable({
|
const { ref, focusSelf, focusKey } = useFocusable({
|
||||||
focusKey: `screenshot-${data.index}`,
|
focusKey: `screenshot-${data.index}`,
|
||||||
onEnterPress: () => data.onAction?.(),
|
onEnterPress: () => data.onAction?.({ focusKey }),
|
||||||
onFocus: (e, p, details) =>
|
onFocus: (e, p, details) =>
|
||||||
{
|
{
|
||||||
data.setFocused?.(data.index);
|
data.setFocused?.(data.index);
|
||||||
|
|
@ -23,7 +23,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
|
||||||
}); 4096;
|
}); 4096;
|
||||||
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
|
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
|
||||||
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
|
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
|
||||||
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.(e.nativeEvent)}> <Fullscreen /> </div>
|
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}> <Fullscreen /> </div>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { FOCUS_KEYS } from "../scripts/types";
|
||||||
export default function SelectMenu (data: { rootFocusKey: string; })
|
export default function SelectMenu (data: { rootFocusKey: string; })
|
||||||
{
|
{
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const routeState = useRouterState();
|
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
|
|
||||||
const options: DialogEntry[] = [
|
const options: DialogEntry[] = [
|
||||||
|
|
@ -85,7 +84,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
|
||||||
];
|
];
|
||||||
const { dialog, setOpen, open } = useContextDialog('select-menu', {
|
const { dialog, setOpen, open } = useContextDialog('select-menu', {
|
||||||
content: <ContextList showCloseButton={false} options={options} />,
|
content: <ContextList showCloseButton={false} options={options} />,
|
||||||
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 ?? '')
|
preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '')
|
||||||
});
|
});
|
||||||
useShortcuts(data.rootFocusKey, () => [{
|
useShortcuts(data.rootFocusKey, () => [{
|
||||||
|
|
|
||||||
147
src/mainview/components/SideFilters.tsx
Normal file
147
src/mainview/components/SideFilters.tsx
Normal file
|
|
@ -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 <div className="tooltip tooltip-right" data-tip={data.tooltip}>
|
||||||
|
<RoundButton
|
||||||
|
id={data.id}
|
||||||
|
onAction={handleAction}
|
||||||
|
className={classNames('sm:p-2 md:p-3 drop-shadow-md!', { "border-4 border-primary": data.isActive })}
|
||||||
|
>
|
||||||
|
{data.icon}
|
||||||
|
</RoundButton>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: <ContextList options={([
|
||||||
|
{ stat: "name", icon: <ArrowDownAz /> },
|
||||||
|
{ stat: "activity", icon: <ClockArrowDown /> },
|
||||||
|
{ stat: "added", icon: <CalendarArrowDown /> },
|
||||||
|
{ stat: "release", icon: <Rocket /> },
|
||||||
|
] 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: <ContextList options={["romm"]
|
||||||
|
.map<DialogEntry>(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: <HardDrive />,
|
||||||
|
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: <ContextList options={data.filterValues?.genres.map(g => ({
|
||||||
|
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: <ContextList options={data.filterValues?.age_ratings.map(a => ({
|
||||||
|
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 <div className='flex flex-col gap-2' ref={ref}>
|
||||||
|
<FocusContext value={focusKey} >
|
||||||
|
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
|
||||||
|
<FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={ageRatingFilterDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} />
|
||||||
|
<FilterButton tooltip='Genre' id='filter-genre' dialog={genreFilterDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} />
|
||||||
|
{!data.filters?.source &&
|
||||||
|
<FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} />
|
||||||
|
}
|
||||||
|
{Object.values(data.localFilter).some(v => v !== undefined) &&
|
||||||
|
<>
|
||||||
|
<div className="divider m-0"></div>
|
||||||
|
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{orderByDialog.dialog}
|
||||||
|
{sourceFilterDialog.dialog}
|
||||||
|
{genreFilterDialog.dialog}
|
||||||
|
{ageRatingFilterDialog.dialog}
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ export default function StatList (data: {
|
||||||
content = <div key={`label-items-${i}`} className="flex flex-wrap gap-2">{s.content.map((c, ci) => <span key={`label-items-${i}-${ci}`} className={twMerge("rounded-3xl bg-base-200 px-3 py-1", data.elementClassName)}>{c}</span>)}</div>;
|
content = <div key={`label-items-${i}`} className="flex flex-wrap gap-2">{s.content.map((c, ci) => <span key={`label-items-${i}-${ci}`} className={twMerge("rounded-3xl bg-base-200 px-3 py-1", data.elementClassName)}>{c}</span>)}</div>;
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
content = <div key={`label-element-${i}`} className={twMerge("flex gap-2 rounded-2xl bg-base-200 px-3 py-2", data.elementClassName)}>{s.icon}{s.content}</div>;
|
content = <div key={`label-element-${i}`} className={twMerge("flex break-after-all gap-2 rounded-2xl bg-base-200 px-3 py-2", data.elementClassName)}>{s.icon}{s.content}</div>;
|
||||||
}
|
}
|
||||||
return [<Label key={`label-${i}`} id={`${data.id}-label-${i}`} label={s.label} />, <div key={`content-${i}`}>{content}</div>];
|
return [<Label key={`label-${i}`} id={`${data.id}-label-${i}`} label={s.label} />, <div key={`content-${i}`}>{content}</div>];
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,10 @@ export default function ActionButton (data: {
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
tooltip?: string,
|
tooltip?: string,
|
||||||
tooltip_type?: 'accent' | 'error';
|
tooltip_type?: 'accent' | 'error';
|
||||||
onAction?: () => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
})
|
} & InteractParams)
|
||||||
{
|
{
|
||||||
const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
const { ref, focusKey } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: () => data.onAction?.({ focusKey }), focusable: data.disabled !== true });
|
||||||
const styles = {
|
const styles = {
|
||||||
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||||
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||||
|
|
@ -29,7 +28,7 @@ export default function ActionButton (data: {
|
||||||
<button
|
<button
|
||||||
disabled={data.disabled}
|
disabled={data.disabled}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={data.onAction}
|
onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}
|
||||||
data-tooltip={data.tooltip}
|
data-tooltip={data.tooltip}
|
||||||
data-tooltip-type={data.tooltip_type}
|
data-tooltip-type={data.tooltip_type}
|
||||||
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
|
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import ActionButtons from "./ActionButtons";
|
||||||
import prettyMilliseconds from 'pretty-ms';
|
import prettyMilliseconds from 'pretty-ms';
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { validateSourceQuery } from "@/mainview/scripts/queries/romm";
|
import { validateSourceQuery } from "@/mainview/scripts/queries/romm";
|
||||||
|
import { sourceIconMap } from "../Constants";
|
||||||
|
|
||||||
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; })
|
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; })
|
||||||
{
|
{
|
||||||
|
|
@ -20,12 +21,6 @@ export function DetailElement (data: { icon: JSX.Element; tooltip?: string | nul
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceIconMap: Record<string, any> = {
|
|
||||||
store: <Store />,
|
|
||||||
local: <HardDrive />,
|
|
||||||
romm: <Gamepad2 />
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Details (data: {
|
export default function Details (data: {
|
||||||
game?: FrontEndGameTypeDetailed,
|
game?: FrontEndGameTypeDetailed,
|
||||||
source: string,
|
source: string,
|
||||||
|
|
@ -81,7 +76,7 @@ export default function Details (data: {
|
||||||
<DetailElement icon={platformCoverImg ? <img className="size-6" src={platformCoverImg.href}></img> : <div className="skeleton size-6 rounded-full shrink-0"></div>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</DetailElement>
|
<DetailElement icon={platformCoverImg ? <img className="size-6" src={platformCoverImg.href}></img> : <div className="skeleton size-6 rounded-full shrink-0"></div>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</DetailElement>
|
||||||
{data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && <DetailElement tooltip={"Save Backup"} icon={<CloudUpload />} />}
|
{data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && <DetailElement tooltip={"Save Backup"} icon={<CloudUpload />} />}
|
||||||
<DetailElement tooltip={validation?.reason} icon={
|
<DetailElement tooltip={validation?.reason} icon={
|
||||||
validation ? validation.valid ? sourceIconMap[data.game?.source ?? ''] : <TriangleAlert className="text-error" /> : <span className="loading loading-spinner loading-lg"></span>
|
validation ? validation.valid ? sourceIconMap[data.game?.source ?? data.game?.id.source ?? ''] : <TriangleAlert className="text-error" /> : <span className="loading loading-spinner loading-lg"></span>
|
||||||
} >
|
} >
|
||||||
{data.game?.source ?? data.game?.id.source}
|
{data.game?.source ?? data.game?.id.source}
|
||||||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</DetailElement>
|
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</DetailElement>
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@ export function Button (data: {
|
||||||
tooltipType?: "base" | "accent" | "error" | "warning";
|
tooltipType?: "base" | "accent" | "error" | "warning";
|
||||||
} & InteractParams & FocusParams)
|
} & InteractParams & FocusParams)
|
||||||
{
|
{
|
||||||
const handleAction = (e?: any) =>
|
const handleAction = (event?: Event) =>
|
||||||
{
|
{
|
||||||
data.onAction?.(e);
|
data.onAction?.({ event, focusKey });
|
||||||
oneShot('click');
|
oneShot('click');
|
||||||
};
|
};
|
||||||
const { ref, focused, focusKey } = useFocusable({
|
const { ref, focused, focusKey } = useFocusable({
|
||||||
|
|
@ -50,12 +50,12 @@ export function Button (data: {
|
||||||
|
|
||||||
if (data.shortcutLabel)
|
if (data.shortcutLabel)
|
||||||
{
|
{
|
||||||
useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: data.onAction, button: GamePadButtonCode.A }], [data.shortcutLabel]);
|
useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: handleAction, button: GamePadButtonCode.A }], [data.shortcutLabel]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button
|
return <button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={handleAction}
|
onClick={e => handleAction(e.nativeEvent)}
|
||||||
disabled={data.disabled}
|
disabled={data.disabled}
|
||||||
data-tooltip={data.tooltip}
|
data-tooltip={data.tooltip}
|
||||||
data-tooltip-type={data.tooltipType}
|
data-tooltip-type={data.tooltipType}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
{
|
{
|
||||||
data.setLocalValue(e);
|
data.setLocalValue(String(e));
|
||||||
}}
|
}}
|
||||||
value={data.localValue}
|
value={data.localValue}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; l
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
type={data.type}
|
type={data.type}
|
||||||
onChange={v => field.handleChange(v)}
|
onChange={v => field.handleChange(String(v))}
|
||||||
placeholder={data.placeholder}
|
placeholder={data.placeholder}
|
||||||
className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })}
|
className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export function SettingsOption (data: {
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState<string | boolean | undefined>();
|
const [localValue, setLocalValue] = useState<string | number | boolean | undefined>();
|
||||||
const { data: serverValue } = useQuery(getSettingQuery(data.id));
|
const { data: serverValue } = useQuery(getSettingQuery(data.id));
|
||||||
const setMutation = useMutation(setSettingMutation(data.id));
|
const setMutation = useMutation(setSettingMutation(data.id));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import
|
||||||
useFocusable,
|
useFocusable,
|
||||||
FocusContext,
|
FocusContext,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { ChevronRight, Joystick } from "lucide-react";
|
import { ChevronRight, Joystick, LayoutGrid } from "lucide-react";
|
||||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
||||||
import FocusDots from "../FocusDots";
|
import FocusDots from "../FocusDots";
|
||||||
|
|
@ -26,9 +26,9 @@ function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (detail
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={data.onAction}
|
onClick={data.onAction}
|
||||||
className={"flex focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:animate-scale-small p-4 justify-center items-center min-w-2xs gap-2 hover:bg-base-300 cursor-pointer"}
|
className={"flex focusable focusable-hover focusable-info bg-base-100 rounded-4xl transition-shadow focused:animate-scale-small p-4 justify-center items-center min-w-2xs gap-2 cursor-pointer"}
|
||||||
>
|
>
|
||||||
See All Emulators <ChevronRight />
|
<LayoutGrid /> See All Emulators <ChevronRight />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,9 @@ export function StoreEmulatorCard (data: {
|
||||||
>
|
>
|
||||||
<div className="bg-primary in-data-[full-support=false]:bg-warning in-data-[full-support=false]:text-warning-content in-aria-disabled:bg-base-200 in-aria-disabled:text-base-content text-primary-content rounded-full p-1.5"><WandSparkles className="size-5" /></div>
|
<div className="bg-primary in-data-[full-support=false]:bg-warning in-data-[full-support=false]:text-warning-content in-aria-disabled:bg-base-200 in-aria-disabled:text-base-content text-primary-content rounded-full p-1.5"><WandSparkles className="size-5" /></div>
|
||||||
</div>}
|
</div>}
|
||||||
{data.emulator.validSources.slice(0, 3).map(s =>
|
{data.emulator.validSources.slice(0, 3).map((s, i) =>
|
||||||
{
|
{
|
||||||
return <div className="tooltip" data-tip={s.type}>
|
return <div key={i} className="tooltip" data-tip={s.type}>
|
||||||
<div data-source={s.type} className="flex items-center justify-center rounded-full p-1 size-8 bg-base-300 text-base-content data-[source=store]:bg-success data-[source=store]:text-success-content">
|
<div data-source={s.type} className="flex items-center justify-center rounded-full p-1 size-8 bg-base-300 text-base-content data-[source=store]:bg-success data-[source=store]:text-success-content">
|
||||||
{emulatorStatusIcons[s.type]}
|
{emulatorStatusIcons[s.type]}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { mobileCheck, useLocalSetting } from "../scripts/utils";
|
||||||
import useActiveControl from "../scripts/gamepads";
|
import useActiveControl from "../scripts/gamepads";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import AppCommunication from "../components/AppCommunication";
|
import AppCommunication from "../components/AppCommunication";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
|
|
@ -32,6 +34,9 @@ function RootComponent ()
|
||||||
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
const queryDevOptions = useLocalSetting('showQueryDevOptions');
|
||||||
|
const routerDevOptions = useLocalSetting('showRouterDevOptions');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
|
<div data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
|
||||||
<AppCommunication>
|
<AppCommunication>
|
||||||
|
|
@ -39,12 +44,8 @@ function RootComponent ()
|
||||||
</AppCommunication>
|
</AppCommunication>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<Toaster containerStyle={{ viewTimelineName: 'toasters', viewTransitionName: 'notifications' }} />
|
<Toaster containerStyle={{ viewTimelineName: 'toasters', viewTransitionName: 'notifications' }} />
|
||||||
{/*import.meta.env.DEV && !isMobile &&
|
{queryDevOptions && <ReactQueryDevtools buttonPosition="top-right" />}
|
||||||
<>
|
{routerDevOptions && <TanStackRouterDevtools position="top-left" />}
|
||||||
<TanStackRouterDevtools position="top-left" />
|
|
||||||
<ReactQueryDevtools buttonPosition="top-right" />
|
|
||||||
</>
|
|
||||||
*/}
|
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,14 @@ import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||||
import { getCollectionQuery } from '@queries/romm';
|
import { getCollectionQuery } from '@queries/romm';
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
import { GameListFilterType } from '@/shared/constants';
|
||||||
|
import { useLocalStorage } from 'usehooks-ts';
|
||||||
|
|
||||||
export const Route = createFileRoute('/collection/$source/$id')({
|
export const Route = createFileRoute('/collection/$source/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: zodValidator(z.object({ countHint: z.number().optional() }))
|
validateSearch: zodValidator(z.object({
|
||||||
|
countHint: z.number().optional()
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
|
|
@ -18,8 +22,16 @@ function RouteComponent ()
|
||||||
const { countHint } = Route.useSearch();
|
const { countHint } = Route.useSearch();
|
||||||
const { data: collection } = useQuery(getCollectionQuery(source, id));
|
const { data: collection } = useQuery(getCollectionQuery(source, id));
|
||||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||||
|
const [filter, setFilter] = useLocalStorage<GameListFilterType>("collection-filter", {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollectionsDetail countHit={countHint} setBackground={animatedBgContext.setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id), collection_source: source }} />
|
<CollectionsDetail
|
||||||
|
localFilter={filter}
|
||||||
|
setLocalFilter={setFilter}
|
||||||
|
countHint={countHint}
|
||||||
|
setBackground={animatedBgContext.setBackground}
|
||||||
|
title={<div className="divider font-semibold text-2xl">{collection?.name}</div>}
|
||||||
|
filters={{ collection_id: Number(id), collection_source: source }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import { GamesSection } from "@/mainview/components/store/GamesSection";
|
||||||
import Details from "@/mainview/components/game/Details";
|
import Details from "@/mainview/components/game/Details";
|
||||||
import { AutoFocus } from "@/mainview/components/AutoFocus";
|
import { AutoFocus } from "@/mainview/components/AutoFocus";
|
||||||
import SelectMenu from "@/mainview/components/SelectMenu";
|
import SelectMenu from "@/mainview/components/SelectMenu";
|
||||||
import { stat } from "node:fs";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/game/$source/$id")({
|
export const Route = createFileRoute("/game/$source/$id")({
|
||||||
loader: async ({ params, context }) =>
|
loader: async ({ params, context }) =>
|
||||||
|
|
@ -97,12 +96,12 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; })
|
||||||
{
|
{
|
||||||
if (data.game.path_fs)
|
if (data.game.path_fs)
|
||||||
stats.push({ label: "Location", content: data.game.path_fs, icon: <Folder /> });
|
stats.push({ label: "Location", content: data.game.path_fs, icon: <Folder /> });
|
||||||
if (data.game.companies)
|
if (data.game.metadata.companies)
|
||||||
stats.push({ label: "Companies", content: data.game.companies });
|
stats.push({ label: "Companies", content: data.game.metadata.companies });
|
||||||
if (data.game.genres)
|
if (data.game.metadata.genres)
|
||||||
stats.push({ label: 'Genres', content: data.game.genres });
|
stats.push({ label: 'Genres', content: data.game.metadata.genres });
|
||||||
if (data.game.release_date)
|
if (data.game.metadata.first_release_date)
|
||||||
stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: <Calendar /> });
|
stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: <Calendar /> });
|
||||||
if (data.game.emulators)
|
if (data.game.emulators)
|
||||||
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
|
stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) });
|
||||||
if (data.game.source)
|
if (data.game.source)
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,36 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
import z from 'zod';
|
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')({
|
export const Route = createFileRoute('/games')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
|
validateSearch: zodValidator(z.object({
|
||||||
|
focus: z.string().optional(),
|
||||||
|
search: z.string().optional()
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { focus } = Route.useSearch();
|
const { focus } = Route.useSearch();
|
||||||
|
const { search } = Route.useSearch();
|
||||||
|
const [filter, setFilter] = useSessionStorage<GameListFilterType>('all-games-filters', {});
|
||||||
|
|
||||||
return <CollectionsDetail focus={focus} id='all-games' />;
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
setFilter(v => ({ ...v, search }));
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return <CollectionsDetail
|
||||||
|
headerButtonElements={<HeaderSearchField onSubmit={v => setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />}
|
||||||
|
localFilter={filter}
|
||||||
|
setLocalFilter={setFilter}
|
||||||
|
focus={focus}
|
||||||
|
id='all-games'
|
||||||
|
/>;
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ import { gameQuery } from "../scripts/queries/romm";
|
||||||
import { oneShot } from "../scripts/audio/audio";
|
import { oneShot } from "../scripts/audio/audio";
|
||||||
import { FloatingShortcuts } from "../components/Shortcuts";
|
import { FloatingShortcuts } from "../components/Shortcuts";
|
||||||
import SelectMenu from "../components/SelectMenu";
|
import SelectMenu from "../components/SelectMenu";
|
||||||
|
import HeaderSearchField from "../components/HeaderSearchField";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: ConsoleHomeUI,
|
component: ConsoleHomeUI,
|
||||||
|
|
@ -232,13 +233,13 @@ function MainMenu ()
|
||||||
>
|
>
|
||||||
<FocusContext.Provider value={focusKey}>
|
<FocusContext.Provider value={focusKey}>
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.type } })}
|
onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.event?.type } })}
|
||||||
icon={<Gamepad2 />}
|
icon={<Gamepad2 />}
|
||||||
label="Home"
|
label="Home"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
/>
|
/>
|
||||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
<CircleIcon icon={<MessageSquare />} label="News" />
|
||||||
<CircleIcon type="info" icon={<Store />} onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.type } })} label="Shop" />
|
<CircleIcon type="info" icon={<Store />} onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" />
|
||||||
<CircleIcon icon={<Image />} label="Album" />
|
<CircleIcon icon={<Image />} label="Album" />
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
icon={<Gamepad2 />}
|
icon={<Gamepad2 />}
|
||||||
|
|
@ -247,7 +248,7 @@ function MainMenu ()
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
onAction={(e) =>
|
onAction={(e) =>
|
||||||
{
|
{
|
||||||
router.navigate({ to: '/settings/accounts', state: { eventType: e?.type } });
|
router.navigate({ to: '/settings/accounts', state: { eventType: e?.event?.type } });
|
||||||
}}
|
}}
|
||||||
icon={<Settings />}
|
icon={<Settings />}
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
|
@ -264,9 +265,9 @@ function CircleIcon (data: {
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
} & InteractParams)
|
} & InteractParams)
|
||||||
{
|
{
|
||||||
const handleAction = (e?: Event) =>
|
const handleAction = (event?: Event) =>
|
||||||
{
|
{
|
||||||
data.onAction?.(e);
|
data.onAction?.({ event, focusKey });
|
||||||
oneShot('click');
|
oneShot('click');
|
||||||
};
|
};
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
|
|
@ -313,10 +314,13 @@ export default function ConsoleHomeUI ()
|
||||||
if (mobileCheck())
|
if (mobileCheck())
|
||||||
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
|
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
|
||||||
headerButtons.push(
|
headerButtons.push(
|
||||||
{ id: "search-header-button", icon: <Search /> },
|
|
||||||
{ id: "power-button", icon: <Power />, external: true, action: () => close.mutate(), className: "focusable-error!" },
|
{ id: "power-button", icon: <Power />, external: true, action: () => close.mutate(), className: "focusable-error!" },
|
||||||
{ id: "settings-header-button", icon: <Settings />, external: true, action: () => router.navigate({ to: "/settings/accounts" }) }
|
{ id: "settings-header-button", icon: <Settings />, external: true, action: () => router.navigate({ to: "/settings/accounts" }) }
|
||||||
);
|
);
|
||||||
|
const handleSearch = (search: string | undefined) =>
|
||||||
|
{
|
||||||
|
router.navigate({ to: '/games', search: { search } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className="grid grid-cols-3 sm:landscape:grid-rows-[3rem_minmax(var(--game-card-height-safe),1fr)_4rem] md:landscape:grid-rows-[5rem_4rem_minmax(var(--game-card-height-safe),1fr)_6rem_6rem] gap-1 portrait:grid-rows-[3rem_4rem_minmax(var(--game-card-height-safe),1fr)] max-h-screen overflow-clip">
|
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className="grid grid-cols-3 sm:landscape:grid-rows-[3rem_minmax(var(--game-card-height-safe),1fr)_4rem] md:landscape:grid-rows-[5rem_4rem_minmax(var(--game-card-height-safe),1fr)_6rem_6rem] gap-1 portrait:grid-rows-[3rem_4rem_minmax(var(--game-card-height-safe),1fr)] max-h-screen overflow-clip">
|
||||||
|
|
@ -334,7 +338,7 @@ export default function ConsoleHomeUI ()
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex sm:landscape:col-span-2 sm:portrait:col-start-2 sm:portrait:col-span-2 sm:portrait:row-start-1 md:col-start-3 md:col-span-1 justify-end md:pr-2 md:pt-2">
|
<div className="flex sm:landscape:col-span-2 sm:portrait:col-start-2 sm:portrait:col-span-2 sm:portrait:row-start-1 md:col-start-3 md:col-span-1 justify-end md:pr-2 md:pt-2">
|
||||||
<HeaderStatusBar buttons={headerButtons} />
|
<HeaderStatusBar buttons={headerButtons} buttonElements={<HeaderSearchField compact id={"header-search-field"} search={undefined} onSubmit={handleSearch} />} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 min-h-0 landscape:flex landscape:items-center-safe">
|
<div className="col-span-3 min-h-0 landscape:flex landscape:items-center-safe">
|
||||||
<HomeList
|
<HomeList
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { RPC_URL } from "../../shared/constants";
|
import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants";
|
||||||
import { platformQuery } from "@queries/romm";
|
import { platformQuery } from "@queries/romm";
|
||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
|
|
||||||
export const Route = createFileRoute("/platform/$source/$id")({
|
export const Route = createFileRoute("/platform/$source/$id")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: zodValidator(z.object({ countHint: z.number().optional() }))
|
validateSearch: zodValidator(z.object({
|
||||||
|
countHint: z.number().optional()
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
function PlatformTitle (data: {})
|
function PlatformTitle (data: {})
|
||||||
|
|
@ -29,11 +32,14 @@ function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { source, id } = Route.useParams();
|
const { source, id } = Route.useParams();
|
||||||
const { countHint } = Route.useSearch();
|
const { countHint } = Route.useSearch();
|
||||||
|
const [filter, setFilter] = useLocalStorage<GameListFilterType>("platforms-filters", {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<CollectionsDetail
|
<CollectionsDetail
|
||||||
countHit={countHint}
|
localFilter={filter}
|
||||||
|
setLocalFilter={setFilter}
|
||||||
|
countHint={countHint}
|
||||||
title={<PlatformTitle />}
|
title={<PlatformTitle />}
|
||||||
filters={{ platform_id: Number(id), platform_source: source }}
|
filters={{ platform_id: Number(id), platform_source: source }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ function EmulatorPath (data: { id: string; })
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
{
|
{
|
||||||
setLocalValue(v);
|
setLocalValue(v as string);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}}
|
}}
|
||||||
value={localValue}
|
value={localValue}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { LocalOption } from '@/mainview/components/options/LocalOption';
|
import { LocalOption } from '@/mainview/components/options/LocalOption';
|
||||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Terminal } from 'lucide-react';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/interface')({
|
export const Route = createFileRoute('/settings/interface')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -22,6 +23,11 @@ function RouteComponent ()
|
||||||
<LocalOption id='soundEffects' label="Sounds" type='checkbox'></LocalOption>
|
<LocalOption id='soundEffects' label="Sounds" type='checkbox'></LocalOption>
|
||||||
<LocalOption id='soundEffectsVolume' min={0} max={100} step={10} label="Sounds" type='range'></LocalOption>
|
<LocalOption id='soundEffectsVolume' min={0} max={100} step={10} label="Sounds" type='range'></LocalOption>
|
||||||
<LocalOption id='hapticsEffects' label="Haptics" type='checkbox'></LocalOption>
|
<LocalOption id='hapticsEffects' label="Haptics" type='checkbox'></LocalOption>
|
||||||
|
{import.meta.env.DEV && <>
|
||||||
|
<div className="divider">Dev Settings<Terminal /></div>
|
||||||
|
<LocalOption id='showQueryDevOptions' label="Show Query Options" type='checkbox'></LocalOption>
|
||||||
|
<LocalOption id='showRouterDevOptions' label="Show Router Options" type='checkbox'></LocalOption>
|
||||||
|
</>}
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</ul>;
|
</ul>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ function Plugin (data: {
|
||||||
<div className='text-sm text-base-content/40'>{data.plugin.name} ({data.plugin.version})</div>
|
<div className='text-sm text-base-content/40'>{data.plugin.name} ({data.plugin.version})</div>
|
||||||
</div>
|
</div>
|
||||||
</div>} className='flex p-4 bg-base-200 rounded-3xl'>
|
</div>} className='flex p-4 bg-base-200 rounded-3xl'>
|
||||||
<OptionInput onChange={data.setEnabled} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />
|
<OptionInput onChange={v => data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />
|
||||||
<Button id={`${data.plugin.name}-details`} ><Search /> Details</Button>
|
<Button id={`${data.plugin.name}-details`} ><Search /> Details</Button>
|
||||||
</OptionSpace>;
|
</OptionSpace>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -356,40 +356,6 @@ export function RouteComponent ()
|
||||||
});
|
});
|
||||||
|
|
||||||
const stats: StatEntry[] = [];
|
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: <div className="flex flex-col grow">
|
|
||||||
<div className="flex grow flex-wrap justify-between gap-1">
|
|
||||||
<div className="flex gap-1">{emulatorStatusIcons[s.type]}{s.type}</div>
|
|
||||||
<div className="text-base-content/40">{s.binPath}</div>
|
|
||||||
</div>
|
|
||||||
{emulator.integrations.some(i => i.source?.type === s.type) && <div className="divider m-0"></div>}
|
|
||||||
{emulator.integrations.filter(i => i.source?.type === s.type).map(i =>
|
|
||||||
{
|
|
||||||
return <div key={i.id} className="flex flex-wrap justify-between gap-1">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Puzzle />
|
|
||||||
<div>{i.id}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap text-base-content/40">
|
|
||||||
{i.capabilities?.map(c => <><div className="divider divider-horizontal"></div><div className="flex gap-1">{capabilityIconMap[c]}{c}</div></>)}
|
|
||||||
</div>
|
|
||||||
</div>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
}]));
|
|
||||||
if (emulator.bios)
|
|
||||||
stats.push({
|
|
||||||
label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div>
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedBackground ref={ref} className="" scrolling>
|
<AnimatedBackground ref={ref} className="" scrolling>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { storeEmulatorsQuery } from '@queries/store';
|
import { storeEmulatorsQuery } from '@queries/store';
|
||||||
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
||||||
|
import { useSessionStorage } from 'usehooks-ts';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/emulators')({
|
export const Route = createFileRoute('/store/tab/emulators')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -18,13 +19,14 @@ export const Route = createFileRoute('/store/tab/emulators')({
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { focus } = useSearch({ from: '/store/tab' });
|
const { focus } = Route.useSearch();
|
||||||
|
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({
|
const { ref, focusKey, focusSelf } = useFocusable({
|
||||||
focusKey: "main-area",
|
focusKey: "main-area",
|
||||||
preferredChildFocusKey: focus
|
preferredChildFocusKey: focus
|
||||||
});
|
});
|
||||||
const storeContext = useContext(StoreContext);
|
const storeContext = useContext(StoreContext);
|
||||||
const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true });
|
const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true });
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,43 @@
|
||||||
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
import { Gamepad2 } from 'lucide-react';
|
import { Gamepad2, HardDrive } from 'lucide-react';
|
||||||
import { useContext, useEffect } from 'react';
|
import { JSX, useContext, useEffect, useState } from 'react';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||||
import { storeGamesInfiniteQuery } from '@queries/store';
|
import { storeGamesInfiniteQuery } from '@queries/store';
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
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')({
|
export const Route = createFileRoute('/store/tab/games')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: InvalidStoreError
|
errorComponent: InvalidStoreError,
|
||||||
|
validateSearch: zodValidator(z.object({
|
||||||
|
search: z.string().optional()
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { focus } = useSearch({ from: '/store/tab' });
|
const { focus } = Route.useSearch();
|
||||||
|
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
||||||
|
const navigator = useNavigate();
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
||||||
|
const [filter, setFilter] = useSessionStorage<GameListFilterType>('store-games-filters', {});
|
||||||
|
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter));
|
||||||
|
const [filterValues, setFilterValues] = useState<FrontEndFilterLists>();
|
||||||
|
|
||||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery);
|
useEffect(() =>
|
||||||
const storeContext = useContext(StoreContext);
|
{
|
||||||
|
setFilter(v => ({ ...v, search }));
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -38,6 +54,11 @@ function RouteComponent ()
|
||||||
node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' });
|
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 <>
|
return <>
|
||||||
<section ref={ref} className="px-6 py-4 animate-slide-up">
|
<section ref={ref} className="px-6 py-4 animate-slide-up">
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
|
|
@ -47,19 +68,8 @@ function RouteComponent ()
|
||||||
Games
|
Games
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[21rem] py-2 md:px-4 gap-4 justify-center-safe">
|
<div className="pl-12">
|
||||||
{data?.pages.flatMap((page) => (
|
<CardList grid finalElement={<LoadMoreButton
|
||||||
page.data.map((g, i) => <FrontEndGameCard onFocus={(k, n, d) =>
|
|
||||||
{
|
|
||||||
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) => <div key={i} className="flex flex-col gap-4">
|
|
||||||
<div className="skeleton grow w-full"></div>
|
|
||||||
<div className="skeleton h-4 w-[80%]"></div>
|
|
||||||
<div className="skeleton h-4 w-[40%]"></div>
|
|
||||||
</div>)}
|
|
||||||
<LoadMoreButton
|
|
||||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
|
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
isFetching={isFetchingNextPage || isFetching}
|
isFetching={isFetchingNextPage || isFetching}
|
||||||
|
|
@ -68,7 +78,40 @@ function RouteComponent ()
|
||||||
if (isFetchingNextPage || isFetching)
|
if (isFetchingNextPage || isFetching)
|
||||||
return;
|
return;
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}} />
|
}} />} games={data?.pages.flatMap((page) => page.data.map((g) =>
|
||||||
|
{
|
||||||
|
const badges: JSX.Element[] = [];
|
||||||
|
if (g.id.source === 'local')
|
||||||
|
{
|
||||||
|
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: (
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
{!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
||||||
|
<p className="opacity-80">{g.platform_display_name}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
previewUrl: previewUrl.href,
|
||||||
|
badges: badges,
|
||||||
|
onSelect: () => handleDefaultSelect(g),
|
||||||
|
onFocus: (k, n, d) => handleFocus(k, n, d)
|
||||||
|
} satisfies GameMetaExtra as GameMetaExtra;
|
||||||
|
})
|
||||||
|
) ?? []} id={'store-games'} />
|
||||||
|
</div>
|
||||||
|
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
|
||||||
|
<SideFilters id='filter-btns' localFilter={filter} setLocalFilter={setFilter} filterValues={filterValues} filters={{ source: 'store' }} />
|
||||||
</div>
|
</div>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||||
import { FilterUI } from '@/mainview/components/Filters';
|
import { FilterUI } from '@/mainview/components/Filters';
|
||||||
import { HeaderUI } from '@/mainview/components/Header';
|
import { HeaderUI } from '@/mainview/components/Header';
|
||||||
|
import HeaderSearchField from '@/mainview/components/HeaderSearchField';
|
||||||
import SelectMenu from '@/mainview/components/SelectMenu';
|
import SelectMenu from '@/mainview/components/SelectMenu';
|
||||||
import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts';
|
import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts';
|
||||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||||
|
|
@ -13,7 +14,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useMatchRoute, useRouter } from '@tanstack/react-router';
|
import { useMatchRoute, useRouter } from '@tanstack/react-router';
|
||||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import { useSessionStorage } from 'usehooks-ts';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab')({
|
export const Route = createFileRoute('/store/tab')({
|
||||||
|
|
@ -95,6 +97,8 @@ function RouteComponent ()
|
||||||
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
|
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
|
||||||
games: { label: "Games", selected: useIsSettings('games') }
|
games: { label: "Games", selected: useIsSettings('games') }
|
||||||
};
|
};
|
||||||
|
const [search, setSearch] = useSessionStorage<string | undefined>(`${router.history.location.pathname}-search`, undefined);
|
||||||
|
const [, setGamesSearch] = useSessionStorage<string | undefined>(`/store/tab/games-search`, undefined);
|
||||||
|
|
||||||
const handleDetails = (type: string, source: string, id: string, focus: string) =>
|
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();
|
const isMobile = mobileCheck();
|
||||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||||
|
|
||||||
|
|
@ -129,7 +146,7 @@ function RouteComponent ()
|
||||||
<div className="relative flex flex-col min-h-screen text-base-content z-10" >
|
<div className="relative flex flex-col min-h-screen text-base-content z-10" >
|
||||||
<div ref={sentinelRef} className="h-0" />
|
<div ref={sentinelRef} className="h-0" />
|
||||||
<div ref={headerRef} className='sticky p-2 group top-0 not-mobile:data-stuck:backdrop-blur-xl z-15 mobile:data-stuck:bg-base-300'>
|
<div ref={headerRef} className='sticky p-2 group top-0 not-mobile:data-stuck:backdrop-blur-xl z-15 mobile:data-stuck:bg-base-300'>
|
||||||
<HeaderUI />
|
<HeaderUI buttonElements={<HeaderSearchField compact={useIsSettings('')} id={'store-search'} search={search} onSubmit={handleSearch} />} />
|
||||||
</div>
|
</div>
|
||||||
<TopArea filters={filters} />
|
<TopArea filters={filters} />
|
||||||
<StoreOutlet />
|
<StoreOutlet />
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query";
|
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||||
import { rommApi, storeApi } from "../clientApi";
|
import { rommApi, storeApi } from "../clientApi";
|
||||||
|
import { GameListFilterType } from "@/shared/constants";
|
||||||
|
|
||||||
|
|
||||||
export const storeEmulatorsQuery = queryOptions({
|
export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({
|
||||||
queryKey: ['store-emulators'], queryFn: async () =>
|
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));
|
if (error) throw new Error(JSON.stringify(error.value));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
@ -42,14 +43,14 @@ export const storeEmulatorDeleteMutation = mutationOptions({
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryKey: ['store-games'],
|
queryKey: ['store-games', filter],
|
||||||
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
||||||
queryFn: async (data) =>
|
queryFn: async (data) =>
|
||||||
{
|
{
|
||||||
const pageParam = data.pageParam as number;
|
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;
|
if (error) throw error;
|
||||||
return { data: games.games, nextPage: pageParam + 1 };
|
return { data: games.games, nextPage: pageParam + 1 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ export function useShortcutContext ()
|
||||||
return { shortcuts: array };
|
return { shortcuts: array };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps: DependencyList)
|
export function useShortcuts (focusKey: string, build: () => Shortcut[], deps?: DependencyList)
|
||||||
{
|
{
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -211,6 +211,6 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps
|
||||||
|
|
||||||
markDirtyThrottled();
|
markDirtyThrottled();
|
||||||
};
|
};
|
||||||
}, [...deps, focusKey]);
|
}, [focusKey, ...deps ?? []]);
|
||||||
|
|
||||||
}
|
}
|
||||||
8
src/mainview/types.d.ts
vendored
8
src/mainview/types.d.ts
vendored
|
|
@ -50,9 +50,15 @@ declare interface FocusParams
|
||||||
onFocus?: (focusKey: string, node: HTMLElement, details: Record<string, any>) => void;
|
onFocus?: (focusKey: string, node: HTMLElement, details: Record<string, any>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare interface InteractParamsArgs
|
||||||
|
{
|
||||||
|
event?: Event,
|
||||||
|
focusKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
declare interface InteractParams
|
declare interface InteractParams
|
||||||
{
|
{
|
||||||
onAction?: (e?: Event) => void;
|
onAction?: (ctx: InteractParamsArgs) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare interface FilterOption extends FocusParams, InteractParams
|
declare interface FilterOption extends FocusParams, InteractParams
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,10 @@ export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`;
|
||||||
export const STORE_VERSION = "^0";
|
export const STORE_VERSION = "^0";
|
||||||
|
|
||||||
export const DefaultRommStaleTime = 60 * 1000; // A minute
|
export const DefaultRommStaleTime = 60 * 1000; // A minute
|
||||||
export interface GameMeta
|
export interface GameMeta extends FocusParams
|
||||||
{
|
{
|
||||||
id: string,
|
id: string,
|
||||||
onSelect?: () => void,
|
onSelect?: () => void,
|
||||||
onFocus?: (details: FocusDetails) => void,
|
|
||||||
title: string,
|
title: string,
|
||||||
subtitle: string | JSX.Element,
|
subtitle: string | JSX.Element,
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
|
|
@ -46,7 +45,9 @@ export const LocalSettingsSchema = z.object({
|
||||||
theme: z.enum(['dark', 'light', 'auto']).default('auto'),
|
theme: z.enum(['dark', 'light', 'auto']).default('auto'),
|
||||||
soundEffects: z.boolean().default(true),
|
soundEffects: z.boolean().default(true),
|
||||||
soundEffectsVolume: z.number().min(0).max(100).default(50),
|
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({
|
export const GameListFilterSchema = z.object({
|
||||||
|
|
@ -56,9 +57,14 @@ export const GameListFilterSchema = z.object({
|
||||||
collection_id: z.coerce.number().optional(),
|
collection_id: z.coerce.number().optional(),
|
||||||
collection_source: z.string().optional(),
|
collection_source: z.string().optional(),
|
||||||
limit: z.coerce.number().optional(),
|
limit: z.coerce.number().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
offset: z.coerce.number().optional(),
|
offset: z.coerce.number().optional(),
|
||||||
source: z.string().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() });
|
export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
|
||||||
|
|
|
||||||
40
src/shared/types..d.ts
vendored
40
src/shared/types..d.ts
vendored
|
|
@ -57,17 +57,15 @@ declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare interface FrontEndGameTypeDetailed extends FrontEndGameType
|
declare interface FrontEndGameTypeDetailed extends Exclude<FrontEndGameType, "metadata">
|
||||||
{
|
{
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
fs_size_bytes: number | null;
|
fs_size_bytes: number | null;
|
||||||
missing: boolean;
|
missing: boolean;
|
||||||
local: boolean;
|
local: boolean;
|
||||||
genres?: string[];
|
|
||||||
companies?: string[];
|
|
||||||
release_date?: Date;
|
|
||||||
imdb_id?: number;
|
imdb_id?: number;
|
||||||
ra_id?: number;
|
ra_id?: number;
|
||||||
|
metadata: FrontEndGameMetadataDetailed,
|
||||||
emulators?: FrontEndGameTypeDetailedEmulator[],
|
emulators?: FrontEndGameTypeDetailedEmulator[],
|
||||||
achievements?: {
|
achievements?: {
|
||||||
unlocked: number;
|
unlocked: number;
|
||||||
|
|
@ -162,6 +160,39 @@ declare interface FrontEndGameTypeWithIds extends FrontEndGameType
|
||||||
ra_id: number | null;
|
ra_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare interface FrontEndFilterSets
|
||||||
|
{
|
||||||
|
age_ratings: Set<string>,
|
||||||
|
player_counts: Set<string>,
|
||||||
|
languages: Set<string>,
|
||||||
|
companies: Set<string>,
|
||||||
|
genres: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
declare interface FrontEndGameType
|
||||||
{
|
{
|
||||||
platform_display_name: string | null,
|
platform_display_name: string | null,
|
||||||
|
|
@ -173,6 +204,7 @@ declare interface FrontEndGameType
|
||||||
path_cover: string | null,
|
path_cover: string | null,
|
||||||
last_played: Date | null,
|
last_played: Date | null,
|
||||||
updated_at: Date,
|
updated_at: Date,
|
||||||
|
metadata: FrontEndGameMetadata,
|
||||||
slug: string | null,
|
slug: string | null,
|
||||||
name: string | null,
|
name: string | null,
|
||||||
platform_id: number | null,
|
platform_id: number | null,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue