feat: updating play label on hero panel

This commit is contained in:
Hydra 2024-04-18 22:26:17 +01:00
parent 91b1341271
commit 96e11e6be9
No known key found for this signature in database
40 changed files with 2049 additions and 745 deletions

View File

@ -1,7 +1,7 @@
{
"name": "hydra",
"productName": "Hydra",
"version": "1.0.1",
"version": "1.1.0",
"description": "No bullshit. Just play.",
"main": ".webpack/main",
"repository": {

Binary file not shown.

View File

@ -1,5 +1,5 @@
{
"catalogue": {
"home": {
"featured": "Featured",
"recently_added": "Recently added",
"trending": "Trending",
@ -7,6 +7,7 @@
"no_results": "No results found"
},
"sidebar": {
"home": "Home",
"catalogue": "Catalogue",
"downloads": "Downloads",
"settings": "Settings",
@ -19,6 +20,7 @@
},
"header": {
"search": "Search",
"home": "Home",
"catalogue": "Catalogue",
"downloads": "Downloads",
"search_results": "Search results",
@ -30,6 +32,10 @@
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Next page",
"previous_page": "Previous page"
},
"game_details": {
"open_download_options": "Open download options",
"download_options_zero": "No download option",
@ -64,12 +70,13 @@
"remove_from_library": "Remove from library",
"no_downloads": "No downloads available",
"play_time": "Played for {{amount}}",
"last_time_played": "Played for the last time {{period}}",
"last_time_played": "Last played {{period}}",
"not_played_yet": "You haven't played {{title}} yet",
"next_suggestion": "Next suggestion",
"play": "Play",
"deleting": "Deleting installer…",
"close": "Close"
"close": "Close",
"playing_now": "Playing now"
},
"activation": {
"title": "Activate Hydra",

View File

@ -1,5 +1,5 @@
{
"catalogue": {
"home": {
"featured": "Destacado",
"recently_added": "Recién Añadidos",
"trending": "Tendencias",
@ -15,14 +15,16 @@
"checking_files": "{{title}} ({{percentage}} - Analizando archivos…)",
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Descargando…)",
"filter": "Filtrar biblioteca"
"filter": "Filtrar biblioteca",
"home": "Hogar"
},
"header": {
"search": "Buscar",
"catalogue": "Catálogo",
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes"
"settings": "Ajustes",
"home": "Hogar"
},
"bottom_panel": {
"no_downloads_in_progress": "Sin descargas en progreso",
@ -65,11 +67,12 @@
"next_suggestion": "Siguiente sugerencia",
"play_time": "Jugado por {{cantidad}}",
"install": "Instalar",
"last_time_played": "Jugado por última vez {{period}}",
"play": "Jugar",
"not_played_yet": "Aún no has jugado a {{title}}",
"close": "Cerca",
"deleting": "Eliminando instalador…"
"deleting": "Eliminando instalador…",
"playing_now": "Jugando ahora",
"last_time_played": "Jugado por última vez {{period}}"
},
"activation": {
"title": "Activar Hydra",
@ -127,5 +130,9 @@
"title": "Programas no instalados",
"description": "Los ejecutables de Wine o Lutris no se encontraron en su sistema",
"instructions": "Comprueba la forma correcta de instalar cualquiera de ellos en tu distro Linux para que el juego pueda ejecutarse con normalidad"
},
"catalogue": {
"next_page": "Siguiente página",
"previous_page": "Pagina anterior"
}
}

View File

@ -1,5 +1,5 @@
{
"catalogue": {
"home": {
"featured": "En vedette",
"recently_added": "Récemment ajouté",
"trending": "Tendance",
@ -15,14 +15,16 @@
"checking_files": "{{title}} ({{percentage}} - Vérification des fichiers…)",
"paused": "{{title}} (En pause)",
"downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)",
"filter": "Filtrer la bibliothèque"
"filter": "Filtrer la bibliothèque",
"home": "Maison"
},
"header": {
"search": "Recherche",
"catalogue": "Catalogue",
"downloads": "Téléchargements",
"search_results": "Résultats de la recherche",
"settings": "Paramètres"
"settings": "Paramètres",
"home": "Maison"
},
"bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours",
@ -65,11 +67,12 @@
"next_suggestion": "Suggestion suivante",
"play_time": "Joué pour {{montant}}",
"install": "Installer",
"last_time_played": "Joué pour la dernière fois {{période}}",
"play": "Jouer",
"not_played_yet": "Vous n'avez pas encore joué à {{title}}",
"close": "Fermer",
"deleting": "Suppression du programme d'installation…"
"deleting": "Suppression du programme d'installation…",
"playing_now": "Je joue maintenant",
"last_time_played": "Dernière lecture {{période}}"
},
"activation": {
"title": "Activer Hydra",
@ -127,5 +130,9 @@
"description": "Les exécutables Wine ou Lutris sont introuvables sur votre système",
"instructions": "Vérifiez la bonne façon d'installer l'un d'entre eux sur votre distribution Linux afin que le jeu puisse fonctionner normalement",
"title": "Programmes non installés"
},
"catalogue": {
"next_page": "Page suivante",
"previous_page": "Page précédente"
}
}

View File

@ -1,5 +1,5 @@
{
"catalogue": {
"home": {
"featured": "Destaque",
"recently_added": "Novidades",
"trending": "Populares",
@ -15,14 +15,16 @@
"checking_files": "{{title}} ({{percentage}} - Verificando arquivos…)",
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Baixando…)",
"filter": "Filtrar biblioteca"
"filter": "Filtrar biblioteca",
"home": "Início"
},
"header": {
"search": "Buscar",
"catalogue": "Catálogo",
"downloads": "Downloads",
"search_results": "Resultados da busca",
"settings": "Configurações"
"settings": "Configurações",
"home": "Início"
},
"bottom_panel": {
"no_downloads_in_progress": "Sem downloads em andamento",
@ -65,11 +67,12 @@
"play_time": "Jogado por {{amount}}",
"next_suggestion": "Próxima sugestão",
"install": "Instalar",
"last_time_played": "Jogado pela última vez {{period}}",
"last_time_played": "Jogou por último {{period}}",
"play": "Jogar",
"not_played_yet": "Você ainda não jogou {{title}}",
"close": "Fechar",
"deleting": "Excluindo instalador…"
"deleting": "Excluindo instalador…",
"playing_now": "Jogando agora"
},
"activation": {
"title": "Ativação",
@ -127,5 +130,9 @@
"title": "Programas não instalados",
"description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris",
"instructions": "Verifique a forma correta de instalar algum deles na sua distro Linux para que o jogo possa ser executado normalmente"
},
"catalogue": {
"next_page": "Próxima página",
"previous_page": "Página anterior"
}
}

View File

@ -7,6 +7,7 @@ import {
RepackerFriendlyName,
UserPreferences,
MigrationScript,
SteamGame,
} from "@main/entity";
import type { SqliteConnectionOptions } from "typeorm/driver/sqlite/SqliteConnectionOptions";
@ -24,6 +25,7 @@ export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
UserPreferences,
GameShopCache,
MigrationScript,
SteamGame,
],
...options,
});

View File

@ -5,3 +5,4 @@ export * from "./repacker-friendly-name.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./migration-script.entity";
export * from "./steam-game.entity";

View File

@ -0,0 +1,10 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity("steam_game")
export class SteamGame {
@PrimaryColumn()
id: number;
@Column()
name: string;
}

View File

@ -1,25 +1,22 @@
import { formatName, repackerFormatter } from "@main/helpers";
import { getTrendingGames } from "@main/services";
import type { CatalogueCategory, CatalogueEntry } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import type { CatalogueCategory, CatalogueEntry, GameShop } from "@types";
import { stateManager } from "@main/state-manager";
import { searchGames } from "../helpers/search-games";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import { requestSteam250 } from "@main/services";
const repacks = stateManager.getValue("repacks");
interface GetStringForLookup {
(index: number): string;
}
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const trendingGames = await getTrendingGames();
let i = 0;
const results: CatalogueEntry[] = [];
const getStringForLookup = (index: number) => {
if (category === "trending") return trendingGames[index];
const getStringForLookup = (index: number): string => {
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
@ -30,10 +27,56 @@ const getCatalogue = async (
if (!repacks.length) return [];
const resultSize = 12;
const requestSize = resultSize * 2;
let lookupRequest = [];
while (results.length < resultSize) {
if (category === "trending") {
return getTrendingCatalogue(resultSize);
} else {
return getRecentlyAddedCatalogue(
resultSize,
resultSize,
getStringForLookup
);
}
};
const getTrendingCatalogue = async (
resultSize: number
): Promise<CatalogueEntry[]> => {
const results: CatalogueEntry[] = [];
const trendingGames = await requestSteam250("/30day");
for (
let i = 0;
i < trendingGames.length && results.length < resultSize;
i++
) {
if (!trendingGames[i]) continue;
const { title, objectID } = trendingGames[i];
const repacks = searchRepacks(title);
if (title && repacks.length) {
const catalogueEntry = {
objectID,
title,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", objectID),
};
results.push({ ...catalogueEntry, repacks });
}
}
return results;
};
const getRecentlyAddedCatalogue = async (
resultSize: number,
requestSize: number,
getStringForLookup: GetStringForLookup
): Promise<CatalogueEntry[]> => {
let lookupRequest = [];
const results: CatalogueEntry[] = [];
for (let i = 0; results.length < resultSize; i++) {
const stringForLookup = getStringForLookup(i);
if (!stringForLookup) {
@ -41,9 +84,7 @@ const getCatalogue = async (
continue;
}
lookupRequest.push(searchGames(stringForLookup));
i++;
lookupRequest.push(searchGames({ query: stringForLookup }));
if (lookupRequest.length < requestSize) {
continue;

View File

@ -0,0 +1,32 @@
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
import slice from "lodash/slice";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take?: number,
prevCursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
let results: CatalogueEntry[] = [];
let i = 0;
const batchSize = 100;
while (results.length < take) {
const games = await searchGames({
take: batchSize,
skip: (i + prevCursor) * batchSize,
});
results = [...results, ...games.filter((game) => game.repacks.length)];
i++;
}
return { results: slice(results, 0, take), cursor: prevCursor + i };
};
registerEvent(getGames, {
name: "getGames",
memoize: true,
});

View File

@ -11,10 +11,10 @@ const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
const shuffledList = shuffle(games);
for (const game of shuffledList) {
const repacks = searchRepacks(formatName(game));
const repacks = searchRepacks(formatName(game.title));
if (repacks.length) {
const results = await searchGames(game);
const results = await searchGames({ query: game.title });
if (results.length) {
return results[0].objectID;

View File

@ -2,7 +2,8 @@ import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
registerEvent(
(_event: Electron.IpcMainInvokeEvent, query: string) => searchGames(query),
(_event: Electron.IpcMainInvokeEvent, query: string) =>
searchGames({ query, take: 12 }),
{
name: "searchGames",
memoize: true,

View File

@ -4,8 +4,10 @@ import orderBy from "lodash/orderBy";
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import { searchSteamGame } from "@main/services";
import { stateManager } from "@main/state-manager";
import { steamGameRepository } from "@main/repository";
import { FindManyOptions, Like } from "typeorm";
import { SteamGame } from "@main/entity";
const { Index } = flexSearch;
const repacksIndex = new Index();
@ -32,33 +34,41 @@ export const searchRepacks = (title: string): GameRepack[] => {
);
};
export const searchGames = async (query: string): Promise<CatalogueEntry[]> => {
const formattedName = formatName(query);
export interface SearchGamesArgs {
query?: string;
take?: number;
skip?: number;
}
const steamResults = await searchSteamGame(formattedName);
export const searchGames = async ({
query,
take,
skip,
}: SearchGamesArgs): Promise<CatalogueEntry[]> => {
const options: FindManyOptions<SteamGame> = {};
const results = steamResults.map((result) => ({
objectID: result.objectID,
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", result.objectID),
}));
const gamesIndex = new Index({
tokenize: "full",
});
for (let i = 0; i < results.length; i++) {
const game = results[i];
gamesIndex.add(i, game.title);
if (query) {
options.where = {
name: query ? Like(`%${formatName(query)}%`) : undefined,
};
}
const filteredResults = gamesIndex
.search(query)
.map((index) => results[index as number]);
const steamResults = await steamGameRepository.find({
...options,
take,
skip,
order: { name: "ASC" },
});
const results = steamResults.map((result) => ({
objectID: String(result.id),
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", String(result.id)),
}));
return Promise.all(
filteredResults.map(async (result) => ({
results.map(async (result) => ({
...result,
repacks: searchRepacks(result.title),
}))

View File

@ -24,6 +24,7 @@ import "./library/remove-game";
import "./library/delete-game-folder";
import "./catalogue/get-random-game";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-games";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -7,6 +7,7 @@ import {
RepackerFriendlyName,
UserPreferences,
MigrationScript,
SteamGame,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
@ -25,3 +26,5 @@ export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const migrationScriptRepository =
dataSource.getRepository(MigrationScript);
export const steamGameRepository = dataSource.getRepository(SteamGame);

View File

@ -46,7 +46,9 @@ export const startProcessWatcher = async () => {
const zero = gamesPlaytime.get(game.id);
const delta = performance.now() - zero;
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
}
await gameRepository.update(game.id, {
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
@ -68,7 +70,9 @@ export const startProcessWatcher = async () => {
if (gamesPlaytime.has(game.id)) {
gamesPlaytime.delete(game.id);
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
}
}
await sleep(sleepTime);

View File

@ -1,26 +1,24 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import shuffle from "lodash/shuffle";
import { logger } from "./logger";
const requestSteam250 = async (path: string) => {
return axios
.get(`https://steam250.com${path}`)
.then((response) => response.data);
};
export const requestSteam250 = async (path: string) => {
return axios.get(`https://steam250.com${path}`).then((response) => {
const { window } = new JSDOM(response.data);
const { document } = window;
export const getTrendingGames = async () => {
const response = await requestSteam250("/365day").catch((err) => {
logger.error(err.response, { method: "getTrendingGames" });
throw new Error(err);
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title: HTMLAnchorElement) => {
const steamGameUrl = $title.href;
if (!steamGameUrl) return null;
return {
title: $title.textContent,
objectID: steamGameUrl.split("/").pop(),
};
}
);
});
const { window } = new JSDOM(response);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title) => $title.textContent!
);
};
const steam250Paths = [
@ -32,15 +30,5 @@ const steam250Paths = [
export const getRandomSteam250List = async () => {
const [path] = shuffle(steam250Paths);
const response = await requestSteam250(path).catch((err) => {
logger.error(err.response, { method: "getRandomSteam250List" });
throw new Error(err);
});
const { window } = new JSDOM(response);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title) => $title.textContent!
);
return requestSteam250(path);
};

View File

@ -1,5 +1,4 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import type { SteamAppDetails } from "@types";
@ -34,45 +33,3 @@ export const getSteamAppDetails = async (
throw new Error(err);
});
};
export const searchSteamGame = async (term: string) => {
const searchParams = new URLSearchParams({
start: "0",
count: "12",
sort_by: "_ASC",
/* Games only */
category1: "998",
term: term,
});
const response = await axios.get(
`https://store.steampowered.com/search/results/?${searchParams.toString()}`
);
const { window } = new JSDOM(response.data);
const { document } = window;
const $anchors = Array.from(
document.querySelectorAll("#search_resultsRows a")
);
return $anchors.reduce((prev, $a) => {
const $title = $a.querySelector(".title");
const objectIDs = $a.getAttribute("data-ds-appid");
if (!objectIDs) return prev;
const [objectID] = objectIDs.split(",");
if (!objectID || prev.some((game) => game.objectID === objectID))
return prev;
return [
...prev,
{
name: $title.textContent,
objectID,
},
];
}, []);
};

View File

@ -4,11 +4,12 @@ import { app } from "electron";
import chunk from "lodash/chunk";
import { createDataSource, dataSource } from "@main/data-source";
import { Repack, RepackerFriendlyName } from "@main/entity";
import { Repack, RepackerFriendlyName, SteamGame } from "@main/entity";
import {
migrationScriptRepository,
repackRepository,
repackerFriendlyNameRepository,
steamGameRepository,
} from "@main/repository";
import { MigrationScript } from "@main/entity/migration-script.entity";
import { Like } from "typeorm";
@ -115,11 +116,14 @@ export const resolveDatabaseUpdates = async () => {
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updateRepackerFriendlyNameRepository =
updateDataSource.getRepository(RepackerFriendlyName);
const updateSteamGameRepository = updateDataSource.getRepository(SteamGame);
const [updateRepacks, updateRepackerFriendlyNames] = await Promise.all([
updateRepackRepository.find(),
updateRepackerFriendlyNameRepository.find(),
]);
const [updateRepacks, updateSteamGames, updateRepackerFriendlyNames] =
await Promise.all([
updateRepackRepository.find(),
updateSteamGameRepository.find(),
updateRepackerFriendlyNameRepository.find(),
]);
await runMigrationScripts(updateRepacks);
@ -140,5 +144,16 @@ export const resolveDatabaseUpdates = async () => {
.orIgnore()
.execute();
}
const steamGamesChunks = chunk(updateSteamGames, 800);
for (const chunk of steamGamesChunks) {
await steamGameRepository
.createQueryBuilder()
.insert()
.values(chunk)
.orIgnore()
.execute();
}
});
};

View File

@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld("electron", {
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
getGames: (take?: number, prevCursor?: number) =>
ipcRenderer.invoke("getGames", take, prevCursor),
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -15,7 +15,8 @@ export interface HeaderProps {
}
const pathTitle: Record<string, string> = {
"/": "catalogue",
"/": "home",
"/catalogue": "catalogue",
"/downloads": "downloads",
"/settings": "settings",
};

View File

@ -30,7 +30,7 @@ export const heroMedia = style({
transition: "all ease 0.2s",
selectors: {
[`${hero}:hover &`]: {
transform: "scale(1.05)",
transform: "scale(1.02)",
},
},
});

View File

@ -1,14 +0,0 @@
import { style } from "@vanilla-extract/css";
export const downloadIconWrapper = style({
width: "16px",
height: "12px",
position: "relative",
});
export const downloadIcon = style({
width: "24px",
position: "absolute",
left: "-4px",
top: "-9px",
});

View File

@ -2,7 +2,6 @@ import { useRef } from "react";
import Lottie from "lottie-react";
import downloadingAnimation from "@renderer/assets/lottie/downloading.json";
import * as styles from "./download-icon.css";
export interface DownloadIconProps {
isDownloading: boolean;
@ -12,15 +11,12 @@ export function DownloadIcon({ isDownloading }: DownloadIconProps) {
const lottieRef = useRef(null);
return (
<div className={styles.downloadIconWrapper}>
<Lottie
lottieRef={lottieRef}
animationData={downloadingAnimation}
loop={isDownloading}
autoplay={isDownloading}
className={styles.downloadIcon}
onDOMLoaded={() => lottieRef.current?.setSpeed(1.7)}
/>
</div>
<Lottie
lottieRef={lottieRef}
animationData={downloadingAnimation}
loop={isDownloading}
autoplay={isDownloading}
style={{ width: 16 }}
/>
);
}

View File

@ -1,11 +1,16 @@
import { GearIcon, ListUnorderedIcon } from "@primer/octicons-react";
import { AppsIcon, GearIcon, HomeIcon } from "@primer/octicons-react";
import { DownloadIcon } from "./download-icon";
export const routes = [
{
path: "/",
nameKey: "home",
render: () => <HomeIcon />,
},
{
path: "/catalogue",
nameKey: "catalogue",
render: () => <ListUnorderedIcon />,
render: () => <AppsIcon />,
},
{
path: "/downloads",

View File

@ -45,6 +45,10 @@ declare global {
shop: GameShop,
title: string
) => Promise<HowLongToBeatCategory[] | null>;
getGames: (
take?: number,
prevCursor?: number
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
/* Library */
addGameToLibrary: (

View File

@ -3,13 +3,11 @@ import type { PayloadAction } from "@reduxjs/toolkit";
interface WindowState {
draggingDisabled: boolean;
scrollingDisabled: boolean;
headerTitle: string;
}
const initialState: WindowState = {
draggingDisabled: false,
scrollingDisabled: false,
headerTitle: "",
};
@ -20,14 +18,10 @@ export const windowSlice = createSlice({
toggleDragging: (state, action: PayloadAction<boolean>) => {
state.draggingDisabled = action.payload;
},
toggleScrolling: (state, action: PayloadAction<boolean>) => {
state.scrollingDisabled = action.payload;
},
setHeaderTitle: (state, action: PayloadAction<string>) => {
state.headerTitle = action.payload;
},
},
});
export const { toggleDragging, toggleScrolling, setHeaderTitle } =
windowSlice.actions;
export const { toggleDragging, setHeaderTitle } = windowSlice.actions;

View File

@ -19,11 +19,12 @@ import "react-loading-skeleton/dist/skeleton.css";
import { App } from "./app";
import {
Catalogue,
Home,
Downloads,
GameDetails,
SearchResults,
Settings,
Catalogue,
} from "@renderer/pages";
import { store } from "./store";
@ -41,6 +42,10 @@ const router = createHashRouter([
children: [
{
path: "/",
Component: Home,
},
{
path: "/catalogue",
Component: Catalogue,
},
{

View File

@ -1,141 +1,113 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button, GameCard } from "@renderer/components";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { useTranslation } from "react-i18next";
import { Button, GameCard, Hero } from "@renderer/components";
import type { CatalogueCategory, CatalogueEntry } from "@types";
import type { CatalogueEntry } from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import * as styles from "./catalogue.css";
import { clearSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { vars } from "@renderer/theme.css";
import Lottie from "lottie-react";
const categories: CatalogueCategory[] = ["trending", "recently_added"];
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "../home/home.css";
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
export function Catalogue() {
const dispatch = useAppDispatch();
const { t } = useTranslation("catalogue");
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const contentRef = useRef<HTMLElement>(null);
const cursorRef = useRef<number>(0);
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
const randomGameObjectID = useRef<string | null>(null);
const [searchParams] = useSearchParams();
const cursor = Number(searchParams.get("cursor") ?? 0);
const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, CatalogueEntry[]>
>({
trending: [],
recently_added: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
setIsLoading(true);
window.electron
.getCatalogue(category)
.then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
setIsLoading(false);
});
}, []);
const currentCategory = searchParams.get("category") || categories[0];
const handleSelectCategory = (category: CatalogueCategory) => {
if (category !== currentCategory) {
getCatalogue(category);
navigate(`/?category=${category}`, { replace: true });
}
};
const getRandomGame = useCallback(() => {
setIsLoadingRandomGame(true);
window.electron
.getRandomGame()
.then((objectID) => {
randomGameObjectID.current = objectID;
})
.finally(() => {
setIsLoadingRandomGame(false);
});
}, []);
const handleRandomizerClick = () => {
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
);
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(`/game/${game.shop}/${game.objectID}`);
};
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
setIsLoading(true);
getCatalogue(currentCategory as CatalogueCategory);
getRandomGame();
}, [getCatalogue, currentCategory, getRandomGame]);
setSearchResults([]);
window.electron
.getGames(24, cursor)
.then(({ results, cursor }) => {
return new Promise((resolve) => {
setTimeout(() => {
cursorRef.current = cursor;
setSearchResults(results);
resolve(null);
}, 500);
});
})
.finally(() => {
setIsLoading(false);
});
}, [dispatch, cursor, searchParams]);
const handleNextPage = () => {
const params = new URLSearchParams({
cursor: cursorRef.current.toString(),
});
navigate(`/catalogue?${params.toString()}`);
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
<h2>{t("featured")}</h2>
<section
style={{
padding: `16px 32px`,
display: "flex",
width: "100%",
justifyContent: "space-between",
borderBottom: `1px solid ${vars.color.borderColor}`,
}}
>
<Button
onClick={() => navigate(-1)}
theme="outline"
disabled={cursor === 0 || isLoading}
>
<ArrowLeftIcon />
{t("previous_page")}
</Button>
<Hero />
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}>
{t("next_page")}
<ArrowRightIcon />
</Button>
</section>
<section className={styles.catalogueHeader}>
<div className={styles.catalogueCategories}>
{categories.map((category) => (
<Button
key={category}
theme={currentCategory === category ? "primary" : "outline"}
onClick={() => handleSelectCategory(category)}
>
{t(category)}
</Button>
<section ref={contentRef} className={styles.content}>
<section className={styles.cards}>
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))}
</div>
<Button
onClick={handleRandomizerClick}
theme="outline"
disabled={isLoadingRandomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
loop
/>
</div>
{t("surprise_me")}
</Button>
</section>
<h2>{t(currentCategory)}</h2>
<section className={styles.cards({})}>
{isLoading
? Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))
: catalogue[currentCategory as CatalogueCategory].map((result) => (
{!isLoading && searchResults.length > 0 && (
<>
{searchResults.map((game) => (
<GameCard
key={result.objectID}
game={result}
onClick={() =>
navigate(`/game/${result.shop}/${result.objectID}`)
}
key={game.objectID}
game={game}
onClick={() => handleGameClick(game)}
disabled={!game.repacks.length}
/>
))}
</>
)}
</section>
</section>
</SkeletonTheme>

View File

@ -217,16 +217,19 @@ export const howLongToBeatCategorySkeleton = style({
export const randomizerButton = style({
animationName: slideIn,
animationDuration: "0.4s",
animationDuration: "0.2s",
position: "fixed",
bottom: 26 + 16,
/* Bottom panel height + spacing */
bottom: `${26 + SPACING_UNIT * 2}px`,
/* Scroll bar + spacing */
right: `${9 + SPACING_UNIT * 2}px`,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 3px",
border: `solid 1px ${vars.color.borderColor}`,
backgroundColor: vars.color.darkBackground,
border: `solid 2px ${vars.color.borderColor}`,
backgroundColor: vars.color.background,
":hover": {
backgroundColor: vars.color.background,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 15px 5px",
opacity: 1,
opacity: "1",
},
":active": {
transform: "scale(0.98)",

View File

@ -104,7 +104,9 @@ export function HeroPanel({
window.electron
.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Game executable (.exe)", extensions: ["exe"] }],
filters: [
{ name: "Game executable (.exe)", extensions: ["exe", "app"] },
],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
@ -209,11 +211,15 @@ export function HeroPanel({
})}
</p>
<p>
{t("last_time_played", {
period: lastTimePlayed,
})}
</p>
{isGamePlaying ? (
<p>{t("playing_now")}</p>
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,
})}
</p>
)}
</>
);
}

View File

@ -1,6 +1,6 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT } from "@renderer/theme.css";
export const catalogueCategories = style({
display: "flex",
@ -23,12 +23,4 @@ export const cards = recipe({
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
},
variants: {
searching: {
true: {
pointerEvents: "none",
opacity: vars.opacity.disabled,
},
},
},
});

View File

@ -1,13 +1,12 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export const catalogueCategories = style({
export const homeCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const catalogueHeader = style({
export const homeHeader = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
@ -24,30 +23,20 @@ export const content = style({
overflowY: "auto",
});
export const cards = recipe({
base: {
display: "grid",
gridTemplateColumns: "repeat(1, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
"@media": {
"(min-width: 768px)": {
gridTemplateColumns: "repeat(2, 1fr)",
},
"(min-width: 1250px)": {
gridTemplateColumns: "repeat(3, 1fr)",
},
"(min-width: 1600px)": {
gridTemplateColumns: "repeat(4, 1fr)",
},
export const cards = style({
display: "grid",
gridTemplateColumns: "repeat(1, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
"@media": {
"(min-width: 768px)": {
gridTemplateColumns: "repeat(2, 1fr)",
},
},
variants: {
searching: {
true: {
pointerEvents: "none",
opacity: vars.opacity.disabled,
},
"(min-width: 1250px)": {
gridTemplateColumns: "repeat(3, 1fr)",
},
"(min-width: 1600px)": {
gridTemplateColumns: "repeat(4, 1fr)",
},
},
});

View File

@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components";
import type { CatalogueCategory, CatalogueEntry } from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import * as styles from "./home.css";
import { vars } from "@renderer/theme.css";
import Lottie from "lottie-react";
const categories: CatalogueCategory[] = ["trending", "recently_added"];
export function Home() {
const { t } = useTranslation("home");
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
const randomGameObjectID = useRef<string | null>(null);
const [searchParams] = useSearchParams();
const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, CatalogueEntry[]>
>({
trending: [],
recently_added: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
setIsLoading(true);
window.electron
.getCatalogue(category)
.then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
setIsLoading(false);
});
}, []);
const currentCategory = searchParams.get("category") || categories[0];
const handleSelectCategory = (category: CatalogueCategory) => {
if (category !== currentCategory) {
getCatalogue(category);
navigate(`/?category=${category}`, { replace: true });
}
};
const getRandomGame = useCallback(() => {
setIsLoadingRandomGame(true);
window.electron
.getRandomGame()
.then((objectID) => {
randomGameObjectID.current = objectID;
})
.finally(() => {
setIsLoadingRandomGame(false);
});
}, []);
const handleRandomizerClick = () => {
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
);
};
useEffect(() => {
setIsLoading(true);
getCatalogue(currentCategory as CatalogueCategory);
getRandomGame();
}, [getCatalogue, currentCategory, getRandomGame]);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
<h2>{t("featured")}</h2>
<Hero />
<section className={styles.homeHeader}>
<div className={styles.homeCategories}>
{categories.map((category) => (
<Button
key={category}
theme={currentCategory === category ? "primary" : "outline"}
onClick={() => handleSelectCategory(category)}
>
{t(category)}
</Button>
))}
</div>
<Button
onClick={handleRandomizerClick}
theme="outline"
disabled={isLoadingRandomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
loop
/>
</div>
{t("surprise_me")}
</Button>
</section>
<h2>{t(currentCategory)}</h2>
<section className={styles.cards}>
{isLoading
? Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))
: catalogue[currentCategory as CatalogueCategory].map((result) => (
<GameCard
key={result.objectID}
game={result}
onClick={() =>
navigate(`/game/${result.shop}/${result.objectID}`)
}
/>
))}
</section>
</section>
</SkeletonTheme>
);
}

View File

@ -13,12 +13,12 @@ import { vars } from "@renderer/theme.css";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "./catalogue.css";
import * as styles from "./home.css";
export function SearchResults() {
const dispatch = useAppDispatch();
const { t } = useTranslation("catalogue");
const { t } = useTranslation("home");
const [searchParams] = useSearchParams();
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
@ -54,7 +54,7 @@ export function SearchResults() {
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
<section className={styles.cards({ searching: false })}>
<section className={styles.cards}>
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />

View File

@ -1,5 +1,6 @@
export * from "./catalogue/catalogue";
export * from "./home/home";
export * from "./game-details/game-details";
export * from "./downloads/downloads";
export * from "./catalogue/search-results";
export * from "./home/search-results";
export * from "./settings/settings";
export * from "./catalogue/catalogue";

1073
yarn.lock

File diff suppressed because it is too large Load Diff