Merge branch 'main' into feature/better-repack-modal

This commit is contained in:
ChristoferMendes 2024-05-13 08:27:31 -03:00
commit 0d089bb5c4
50 changed files with 851 additions and 507 deletions

View File

@ -6,7 +6,7 @@ module.exports = {
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended", "plugin:jsx-a11y/recommended",
"@electron-toolkit/eslint-config-ts/recommended", "@electron-toolkit/eslint-config-ts/recommended",
"prettier", "plugin:prettier/recommended",
], ],
rules: { rules: {
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",

View File

@ -1,6 +1,6 @@
name: Lint name: Lint
on: [pull_request, push] on: [pull_request]
jobs: jobs:
lint: lint:
@ -21,9 +21,6 @@ jobs:
- name: Validate current commit (last commit) with commitlint - name: Validate current commit (last commit) with commitlint
run: npx commitlint --last --verbose run: npx commitlint --last --verbose
- name: Check formatting
run: yarn format:check
- name: Typecheck - name: Typecheck
run: yarn typecheck run: yarn typecheck

View File

@ -12,7 +12,6 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

View File

View File

@ -88,7 +88,6 @@
"repacks_modal_description": "Абярыце рэпак, які хочаце сьцягнуць", "repacks_modal_description": "Абярыце рэпак, які хочаце сьцягнуць",
"downloads_path": "Шлях сьцягваньня", "downloads_path": "Шлях сьцягваньня",
"select_folder_hint": "Каб зьмяніць папку па змоўчаньні, адкрыйце", "select_folder_hint": "Каб зьмяніць папку па змоўчаньні, адкрыйце",
"settings": "Налады Hydra",
"download_now": "Сьцягнуць зараз", "download_now": "Сьцягнуць зараз",
"installation_instructions": "Інструкцыя ўсталёўкі", "installation_instructions": "Інструкцыя ўсталёўкі",
"installation_instructions_description": "Усталёўка гэтай гульні патрабуе дадатковых крокаў", "installation_instructions_description": "Усталёўка гэтай гульні патрабуе дадатковых крокаў",

View File

@ -0,0 +1,174 @@
{
"home": {
"featured": "Anbefalet",
"recently_added": "Nyligt tilføjet",
"trending": "Trender",
"surprise_me": "Overrask mig",
"no_results": "Ingen resultater fundet"
},
"sidebar": {
"catalogue": "Katalog",
"downloads": "Downloads",
"settings": "Indstillinger",
"my_library": "Mit bibliotek",
"downloading_metadata": "{{title}} (Downloader metadata…)",
"checking_files": "{{title}} ({{percentage}} - Tjekker filer…)",
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filtrer bibliotek",
"follow_us": "Følg os",
"home": "Hjem",
"discord": "Tilslut dig vores Discord",
"telegram": "Tilslut dig vores Telegram",
"x": "Følg på X",
"github": "Bidrag på GitHub"
},
"header": {
"search": "Søg spil",
"home": "Hjem",
"catalogue": "Katalog",
"downloads": "Downloads",
"search_results": "Søge resultater",
"settings": "Indstillinger"
},
"bottom_panel": {
"no_downloads_in_progress": "Ingen downloads igang",
"downloading_metadata": "Downloader {{title}} metadata…",
"checking_files": "Tjekker {{title}} filer… ({{percentage}} færdig)",
"downloading": "Downloader {{title}}… ({{percentage}} færdig) - Konklusion {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Næste side",
"previous_page": "Tidligere side"
},
"game_details": {
"open_download_options": "Åben download muligheder",
"download_options_zero": "Ingen download mulighed",
"download_options_one": "{{count}} download mulighed",
"download_options_other": "{{count}} download muligheder",
"updated_at": "Opdateret {{updated_at}}",
"install": "Installér",
"resume": "Fortsæt",
"pause": "Pause",
"cancel": "Annullér",
"remove": "Fjern",
"remove_from_list": "Fjern",
"space_left_on_disk": "{{space}} tilbage på harddisken",
"eta": "Konklusion {{eta}}",
"downloading_metadata": "Downloader metadata…",
"checking_files": "Tjekker filer…",
"filter": "Filtrer repacks",
"requirements": "System behov",
"minimum": "Mindste",
"recommended": "Anbefalet",
"no_minimum_requirements": "{{title}} angiver ikke mindste behov informationer",
"no_recommended_requirements": "{{title}} angiver ikke anbefalet behov informationer",
"paused_progress": "{{progress}} (Pauset)",
"release_date": "Offentliggjort den {{date}}",
"publisher": "Udgivet af {{publisher}}",
"copy_link_to_clipboard": "Kopier link",
"copied_link_to_clipboard": "Link kopieret",
"hours": "timer",
"minutes": "minutter",
"amount_hours": "{{amount}} timer",
"amount_minutes": "{{amount}} minutter",
"accuracy": "{{accuracy}}% nøjagtighed",
"add_to_library": "Tilføj til bibliotek",
"remove_from_library": "Fjern fra bibliotek",
"no_downloads": "Ingen downloads tilgængelige",
"play_time": "Spillet i {{amount}}",
"last_time_played": "Sidst spillet {{period}}",
"not_played_yet": "Du har ikke spillet {{title}} endnu",
"next_suggestion": "Næste forslag",
"play": "Spil",
"deleting": "Sletter installatør…",
"close": "Luk",
"playing_now": "Spiller nu",
"change": "Ændré",
"repacks_modal_description": "Vælg den repack du vil downloade",
"downloads_path": "Downloads sti",
"select_folder_hint": "For at ændre standard mappen, gå til <0>Instillingerne</0>",
"download_now": "Download nu",
"installation_instructions": "Installations Instrukser",
"installation_instructions_description": "Yderligere skridt er krævet for at installere dette spil",
"online_fix_instruction": "OnlineFix spil kræver et kodeord for at kunne blive udpakket. Når krævet, brug det følgende kodeord:",
"dodi_installation_instruction": "Når du åbner DODI installatør, tryk på op-knappen på dit tastatur <0 /> for at starte installations processen:",
"dont_show_it_again": "Vis ikke igen",
"copy_to_clipboard": "Kopier",
"copied_to_clipboard": "Kopieret",
"got_it": "Forstået"
},
"activation": {
"title": "Aktivér Hydra",
"installation_id": "Installations ID:",
"enter_activation_code": "Indtast din aktiverings kode",
"message": "Hvis du ikke ved hvor du skal spørge om dette, burde du ikke have dette.",
"activate": "Aktivér",
"loading": "Loader…"
},
"downloads": {
"resume": "Fortsæt",
"pause": "Pause",
"eta": "Konklusion {{eta}}",
"paused": "Pauset",
"verifying": "Verificerer…",
"completed_at": "Færdiggjort på {{date}}",
"completed": "Færdigt",
"cancelled": "Annulleret",
"download_again": "Download igen",
"cancel": "Annullér",
"filter": "Filtrer downloadet spil",
"remove": "Fjern",
"downloading_metadata": "Downloader metadata…",
"checking_files": "Tjekker filer…",
"starting_download": "Starter download…",
"deleting": "Sletter installatør…",
"delete": "Fjern installatør",
"remove_from_list": "Fjern",
"delete_modal_title": "Er du sikker?",
"delete_modal_description": "Dette vil fjerne alle installations filerne fra din computer",
"install": "Installér",
"real_debrid": "Real Debrid",
"torrent": "Torrent"
},
"settings": {
"downloads_path": "Downloads sti",
"change": "Opdatering",
"notifications": "Notifikationer",
"enable_download_notifications": "Når et download bliver færdigt",
"enable_repack_list_notifications": "Når en ny repack bliver tilføjet",
"telemetry": "Telemetri",
"telemetry_description": "Slå anonymt brugs statistik til",
"real_debrid_api_token_description": "Real Debrid API token",
"quit_app_instead_hiding": "Afslut Hydra instedet for at minimere til processlinjen",
"launch_with_system": "Åben Hydra ved start af systemet",
"general": "Generelt",
"behavior": "Opførsel",
"enable_real_debrid": "Slå Real Debrid til",
"real_debrid": "Real Debrid",
"real_debrid_api_token_hint": "Du kan få din API nøgle <0>her</0>.",
"save_changes": "Gem ændringer"
},
"notifications": {
"download_complete": "Download færdig",
"game_ready_to_install": "{{title}} er klar til at installeret",
"repack_list_updated": "Repack liste opdateret",
"repack_count_one": "{{count}} repack tilføjet",
"repack_count_other": "{{count}} repacks tilføjet"
},
"system_tray": {
"open": "Åben Hydra",
"quit": "Afslut"
},
"game_card": {
"no_downloads": "Ingen downloads tilgængelig"
},
"binary_not_found_modal": {
"title": "Programmer ikke installeret",
"description": "Wine eller Lutris eksekverbare blev ikke fundet på dit system",
"instructions": "Tjek den korrekte måde at installere nogle af dem, på din Linux distribution, så spillet kan køre normalt"
},
"modal": {
"close": "Luk knap"
}
}

View File

@ -86,7 +86,6 @@
"playing_now": "Playing now", "playing_now": "Playing now",
"change": "Change", "change": "Change",
"repacks_modal_description": "Choose the repack you want to download", "repacks_modal_description": "Choose the repack you want to download",
"downloads_path": "Downloads path",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>", "select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now", "download_now": "Download now",
"installation_instructions": "Installation Instructions", "installation_instructions": "Installation Instructions",
@ -98,7 +97,14 @@
"copied_to_clipboard": "Copied", "copied_to_clipboard": "Copied",
"got_it": "Got it", "got_it": "Got it",
"multi_language": "Multi Language", "multi_language": "Multi Language",
"multiplayer": "Multi Player" "multiplayer": "Multi Player",
"no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options",
"download_path": "Download path",
"previous_screenshot": "Previous screenshot",
"next_screenshot": "Next screenshot",
"screenshot": "Screenshot {{number}}",
"open_screenshot": "Open screenshot {{number}}"
}, },
"activation": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",
@ -141,7 +147,7 @@
"enable_repack_list_notifications": "When a new repack is added", "enable_repack_list_notifications": "When a new repack is added",
"telemetry": "Telemetry", "telemetry": "Telemetry",
"telemetry_description": "Enable anonymous usage statistics", "telemetry_description": "Enable anonymous usage statistics",
"real_debrid_api_token_description": "Real Debrid API token", "real_debrid_api_token_label": "Real Debrid API token",
"quit_app_instead_hiding": "Quit Hydra instead of minimizing to tray", "quit_app_instead_hiding": "Quit Hydra instead of minimizing to tray",
"launch_with_system": "Launch Hydra on system start-up", "launch_with_system": "Launch Hydra on system start-up",
"general": "General", "general": "General",

View File

@ -85,7 +85,6 @@
"repacks_modal_description": "Selecciona el repack que quieres descargar", "repacks_modal_description": "Selecciona el repack que quieres descargar",
"downloads_path": "Ruta de descarga", "downloads_path": "Ruta de descarga",
"select_folder_hint": "Para cambiar la carpeta predeterminada, accede a", "select_folder_hint": "Para cambiar la carpeta predeterminada, accede a",
"settings": "Ajustes",
"download_now": "Descargar ahora", "download_now": "Descargar ahora",
"installation_instructions": "Instrucciones de instalación", "installation_instructions": "Instrucciones de instalación",
"installation_instructions_description": "Se requieren de pasos adicionales para instalar este juego", "installation_instructions_description": "Se requieren de pasos adicionales para instalar este juego",

View File

@ -88,7 +88,6 @@
"repacks_modal_description": "Pilih repack yang kamu ingin unduh", "repacks_modal_description": "Pilih repack yang kamu ingin unduh",
"downloads_path": "Lokasi Unduhan", "downloads_path": "Lokasi Unduhan",
"select_folder_hint": "Untuk merubah folder bawaan, akses melalui", "select_folder_hint": "Untuk merubah folder bawaan, akses melalui",
"settings": "Pengaturan",
"download_now": "Unduh sekarang", "download_now": "Unduh sekarang",
"installation_instructions": "Instruksi Instalasi", "installation_instructions": "Instruksi Instalasi",
"installation_instructions_description": "Langkah tambahan dibutuhkan untuk meng-instal game ini", "installation_instructions_description": "Langkah tambahan dibutuhkan untuk meng-instal game ini",

View File

@ -11,3 +11,4 @@ export { default as tr } from "./tr/translation.json";
export { default as be } from "./be/translation.json"; export { default as be } from "./be/translation.json";
export { default as uk } from "./uk/translation.json"; export { default as uk } from "./uk/translation.json";
export { default as id } from "./id/translation.json"; export { default as id } from "./id/translation.json";
export { default as da } from "./da/translation.json";

View File

@ -88,7 +88,6 @@
"repacks_modal_description": "Scegli il repack che vuoi scaricare", "repacks_modal_description": "Scegli il repack che vuoi scaricare",
"downloads_path": "Percorso dei download", "downloads_path": "Percorso dei download",
"select_folder_hint": "Per cambiare la cartella predefinita, accedi alle", "select_folder_hint": "Per cambiare la cartella predefinita, accedi alle",
"settings": "Impostazioni",
"download_now": "Scarica ora", "download_now": "Scarica ora",
"installation_instructions": "Istruzioni di installazione", "installation_instructions": "Istruzioni di installazione",
"installation_instructions_description": "Sono necessari passaggi aggiuntivi per installare questo gioco", "installation_instructions_description": "Sono necessari passaggi aggiuntivi per installare questo gioco",

View File

@ -139,7 +139,7 @@
"enable_repack_list_notifications": "Wanneer een nieuwe herverpakking wordt toegevoegd", "enable_repack_list_notifications": "Wanneer een nieuwe herverpakking wordt toegevoegd",
"telemetry": "Telemetrie", "telemetry": "Telemetrie",
"telemetry_description": "Schakel anonieme gebruiksstatistieken in", "telemetry_description": "Schakel anonieme gebruiksstatistieken in",
"real_debrid_api_token_description": "Real Debrid API token", "real_debrid_api_token_label": "Real Debrid API token",
"quit_app_instead_hiding": "Sluit Hydra af in plaats van te minimaliseren naar de lade", "quit_app_instead_hiding": "Sluit Hydra af in plaats van te minimaliseren naar de lade",
"launch_with_system": "Start Hydra bij het opstarten van het systeem", "launch_with_system": "Start Hydra bij het opstarten van het systeem",
"general": "Algemeen", "general": "Algemeen",

View File

@ -82,7 +82,6 @@
"playing_now": "Jogando agora", "playing_now": "Jogando agora",
"change": "Mudar", "change": "Mudar",
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"downloads_path": "Diretório do download",
"select_folder_hint": "Para trocar a pasta padrão, acesse a <0>Tela de Configurações</0>", "select_folder_hint": "Para trocar a pasta padrão, acesse a <0>Tela de Configurações</0>",
"download_now": "Baixe agora", "download_now": "Baixe agora",
"installation_instructions": "Instruções de Instalação", "installation_instructions": "Instruções de Instalação",
@ -94,7 +93,14 @@
"copied_to_clipboard": "Copiado", "copied_to_clipboard": "Copiado",
"got_it": "Entendi", "got_it": "Entendi",
"multi_language": "Multi Idioma", "multi_language": "Multi Idioma",
"multiplayer": "Multijogador" "multiplayer": "Multijogador",
"no_shop_details": "Não foi possível obter os detalhes da loja.",
"download_options": "Opções de download",
"download_path": "Diretório de download",
"previous_screenshot": "Captura de tela anterior",
"next_screenshot": "Próxima captura de tela",
"screenshot": "Captura de tela {{number}}",
"open_screenshot": "Ver captura de tela {{number}}"
}, },
"activation": { "activation": {
"title": "Ativação", "title": "Ativação",
@ -125,7 +131,9 @@
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador", "delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
"delete_modal_title": "Tem certeza?", "delete_modal_title": "Tem certeza?",
"deleting": "Excluindo instalador…", "deleting": "Excluindo instalador…",
"install": "Instalar" "install": "Instalar",
"torrent": "Torrent",
"real_debrid": "Real Debrid"
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",
@ -135,6 +143,7 @@
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"telemetry": "Telemetria", "telemetry": "Telemetria",
"telemetry_description": "Habilitar estatísticas de uso anônimas", "telemetry_description": "Habilitar estatísticas de uso anônimas",
"real_debrid_api_token_label": "Token de API do Real Debrid",
"quit_app_instead_hiding": "Fechar o aplicativo em vez de minimizá-lo", "quit_app_instead_hiding": "Fechar o aplicativo em vez de minimizá-lo",
"launch_with_system": "Iniciar aplicativo na inicialização do sistema", "launch_with_system": "Iniciar aplicativo na inicialização do sistema",
"general": "Geral", "general": "Geral",

View File

@ -88,7 +88,6 @@
"repacks_modal_description": "Выберите репак для загрузки", "repacks_modal_description": "Выберите репак для загрузки",
"downloads_path": "Путь загрузок", "downloads_path": "Путь загрузок",
"select_folder_hint": "Изменить папку по умолчанию", "select_folder_hint": "Изменить папку по умолчанию",
"settings": "Настройки Hydra",
"download_now": "Загрузить сейчас", "download_now": "Загрузить сейчас",
"installation_instructions": "Инструкция по установке", "installation_instructions": "Инструкция по установке",
"installation_instructions_description": "Для установки этой игры требуются дополнительные шаги", "installation_instructions_description": "Для установки этой игры требуются дополнительные шаги",

View File

@ -88,7 +88,6 @@
"repacks_modal_description": "İndirmek istediğiiniz repacki seçin", "repacks_modal_description": "İndirmek istediğiiniz repacki seçin",
"downloads_path": "İndirme yolu", "downloads_path": "İndirme yolu",
"select_folder_hint": "Varsayılan klasörü değiştirmek için ulaşmanız gereken ayar", "select_folder_hint": "Varsayılan klasörü değiştirmek için ulaşmanız gereken ayar",
"settings": "Ayarlar",
"download_now": "Şimdi", "download_now": "Şimdi",
"installation_instructions": "Kurulum", "installation_instructions": "Kurulum",
"installation_instructions_description": "Bu oyunu kurmak için ek adımlar gerekiyor", "installation_instructions_description": "Bu oyunu kurmak için ek adımlar gerekiyor",

View File

@ -88,7 +88,6 @@
"repacks_modal_description": "Виберіть репак, який хочете завантажити", "repacks_modal_description": "Виберіть репак, який хочете завантажити",
"downloads_path": "Шлях завантажень", "downloads_path": "Шлях завантажень",
"select_folder_hint": "Щоб змінити теку за замовчуванням, відкрийте", "select_folder_hint": "Щоб змінити теку за замовчуванням, відкрийте",
"settings": "Налаштування Hydra",
"download_now": "Завантажити зараз", "download_now": "Завантажити зараз",
"installation_instructions": "Інструкція зі встановлення", "installation_instructions": "Інструкція зі встановлення",
"installation_instructions_description": "Для встановлення цієї гри потрібні додаткові кроки", "installation_instructions_description": "Для встановлення цієї гри потрібні додаткові кроки",

View File

@ -1,10 +1,32 @@
import { gameShopCacheRepository } from "@main/repository"; import { gameShopCacheRepository, steamGameRepository } from "@main/repository";
import { getSteamAppDetails } from "@main/services"; import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types"; import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { searchRepacks } from "../helpers/search-games";
const getLocalizedSteamAppDetails = (
objectID: string,
language: string
): Promise<ShopDetails | null> => {
if (language === "english") {
return getSteamAppDetails(objectID, language);
}
return Promise.all([
steamGameRepository.findOne({ where: { id: Number(objectID) } }),
getSteamAppDetails(objectID, language),
]).then(([steamGame, localizedAppDetails]) => {
if (steamGame && localizedAppDetails) {
return {
...localizedAppDetails,
name: steamGame.name,
};
}
return null;
});
};
const getGameShopDetails = async ( const getGameShopDetails = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -17,27 +39,21 @@ const getGameShopDetails = async (
where: { objectID, language }, where: { objectID, language },
}); });
const result = Promise.all([ const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
getSteamAppDetails(objectID, "english"), (result) => {
getSteamAppDetails(objectID, language),
]).then(([appDetails, localizedAppDetails]) => {
if (appDetails && localizedAppDetails) {
gameShopCacheRepository.upsert( gameShopCacheRepository.upsert(
{ {
objectID, objectID,
shop: "steam", shop: "steam",
language, language,
serializedData: JSON.stringify({ serializedData: JSON.stringify(result),
...localizedAppDetails,
name: appDetails.name,
}),
}, },
["objectID"] ["objectID"]
); );
}
return [appDetails, localizedAppDetails]; return result;
}); }
);
const cachedGame = cachedData?.serializedData const cachedGame = cachedData?.serializedData
? (JSON.parse(cachedData?.serializedData) as SteamAppDetails) ? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
@ -46,21 +62,11 @@ const getGameShopDetails = async (
if (cachedGame) { if (cachedGame) {
return { return {
...cachedGame, ...cachedGame,
repacks: searchRepacks(cachedGame.name),
objectID, objectID,
} as ShopDetails; } as ShopDetails;
} }
return result.then(([appDetails, localizedAppDetails]) => { return Promise.resolve(appDetails);
if (!appDetails || !localizedAppDetails) return null;
return {
...localizedAppDetails,
name: appDetails.name,
repacks: searchRepacks(appDetails.name),
objectID,
} as ShopDetails;
});
} }
throw new Error("Not implemented"); throw new Error("Not implemented");

View File

@ -1,9 +1,10 @@
import { shuffle } from "lodash-es"; import { shuffle } from "lodash-es";
import { Steam250Game, getSteam250List } from "@main/services"; import { getSteam250List } from "@main/services";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games"; import { searchGames, searchRepacks } from "../helpers/search-games";
import type { Steam250Game } from "@types";
const state = { games: Array<Steam250Game>(), index: 0 }; const state = { games: Array<Steam250Game>(), index: 0 };
@ -25,8 +26,6 @@ const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
return ""; return "";
} }
const resultObjectId = state.games[state.index].objectID;
state.index += 1; state.index += 1;
if (state.index == state.games.length) { if (state.index == state.games.length) {
@ -34,7 +33,7 @@ const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
state.games = shuffle(state.games); state.games = shuffle(state.games);
} }
return resultObjectId; return state.games[state.index];
}; };
registerEvent(getRandomGame, { registerEvent(getRandomGame, {

View File

@ -0,0 +1,14 @@
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
const searchGameRepacks = (
_event: Electron.IpcMainInvokeEvent,
query: string
) => {
return searchRepacks(query);
};
registerEvent(searchGameRepacks, {
name: "searchGameRepacks",
memoize: true,
});

View File

@ -8,6 +8,7 @@ import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game"; import "./catalogue/get-random-game";
import "./catalogue/search-games"; import "./catalogue/search-games";
import "./catalogue/repacks/get-magnet-health"; import "./catalogue/repacks/get-magnet-health";
import "./catalogue/search-game-repacks";
import "./hardware/get-disk-free-space"; import "./hardware/get-disk-free-space";
import "./library/add-game-to-library"; import "./library/add-game-to-library";
import "./library/close-game"; import "./library/close-game";

View File

@ -12,8 +12,7 @@ export class RealDebridClient {
private static instance: AxiosInstance; private static instance: AxiosInstance;
static async addMagnet(magnet: string) { static async addMagnet(magnet: string) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams({ magnet });
searchParams.append("magnet", magnet);
const response = await this.instance.post<RealDebridAddMagnet>( const response = await this.instance.post<RealDebridAddMagnet>(
"/torrents/addMagnet", "/torrents/addMagnet",
@ -31,8 +30,7 @@ export class RealDebridClient {
} }
static async selectAllFiles(id: string) { static async selectAllFiles(id: string) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams({ files: "all" });
searchParams.append("files", "all");
await this.instance.post( await this.instance.post(
`/torrents/selectFiles/${id}`, `/torrents/selectFiles/${id}`,
@ -41,8 +39,7 @@ export class RealDebridClient {
} }
static async unrestrictLink(link: string) { static async unrestrictLink(link: string) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams({ link });
searchParams.append("link", link);
const response = await this.instance.post<RealDebridUnrestrictLink>( const response = await this.instance.post<RealDebridUnrestrictLink>(
"/unrestrict/link", "/unrestrict/link",

View File

@ -1,10 +1,7 @@
import axios from "axios"; import axios from "axios";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
export interface Steam250Game { import type { Steam250Game } from "@types";
title: string;
objectID: string;
}
export const requestSteam250 = async (path: string) => { export const requestSteam250 = async (path: string) => {
return axios return axios

View File

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

View File

@ -1,9 +1,7 @@
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
export const card = recipe({ export const card = style({
base: {
width: "100%", width: "100%",
height: "180px", height: "180px",
boxShadow: "0px 0px 15px 0px #000000", boxShadow: "0px 0px 15px 0px #000000",
@ -16,17 +14,6 @@ export const card = recipe({
":active": { ":active": {
opacity: vars.opacity.active, opacity: vars.opacity.active,
}, },
},
variants: {
disabled: {
true: {
pointerEvents: "none",
boxShadow: "none",
opacity: vars.opacity.disabled,
filter: "grayscale(50%)",
},
},
},
}); });
export const backdrop = style({ export const backdrop = style({
@ -48,7 +35,7 @@ export const cover = style({
zIndex: "-1", zIndex: "-1",
transition: "all ease 0.2s", transition: "all ease 0.2s",
selectors: { selectors: {
[`${card({})}:hover &`]: { [`${card}:hover &`]: {
transform: "scale(1.05)", transform: "scale(1.05)",
}, },
}, },
@ -64,7 +51,7 @@ export const content = style({
transition: "all ease 0.2s", transition: "all ease 0.2s",
transform: "translateY(24px)", transform: "translateY(24px)",
selectors: { selectors: {
[`${card({})}:hover &`]: { [`${card}:hover &`]: {
transform: "translateY(0px)", transform: "translateY(0px)",
}, },
}, },

View File

@ -14,7 +14,6 @@ export interface GameCardProps
HTMLButtonElement HTMLButtonElement
> { > {
game: CatalogueEntry; game: CatalogueEntry;
disabled?: boolean;
} }
const shopIcon = { const shopIcon = {
@ -22,7 +21,7 @@ const shopIcon = {
steam: <SteamLogo className={styles.shopIcon} />, steam: <SteamLogo className={styles.shopIcon} />,
}; };
export function GameCard({ game, disabled, ...props }: GameCardProps) { export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card"); const { t } = useTranslation("game_card");
const repackersFriendlyNames = useAppSelector( const repackersFriendlyNames = useAppSelector(
@ -34,12 +33,7 @@ export function GameCard({ game, disabled, ...props }: GameCardProps) {
); );
return ( return (
<button <button {...props} type="button" className={styles.card}>
{...props}
type="button"
className={styles.card({ disabled })}
disabled={disabled}
>
<div className={styles.backdrop}> <div className={styles.backdrop}>
<img src={game.cover} alt={game.title} className={styles.cover} /> <img src={game.cover} alt={game.title} className={styles.cover} />

View File

@ -6,7 +6,7 @@ export const hero = style({
height: "280px", height: "280px",
minHeight: "280px", minHeight: "280px",
maxHeight: "280px", maxHeight: "280px",
borderRadius: "8px", borderRadius: "4px",
color: "#DADBE1", color: "#DADBE1",
overflow: "hidden", overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000", boxShadow: "0px 0px 15px 0px #000000",
@ -45,6 +45,7 @@ export const description = style({
textAlign: "left", textAlign: "left",
fontFamily: "'Fira Sans', sans-serif", fontFamily: "'Fira Sans', sans-serif",
lineHeight: "20px", lineHeight: "20px",
marginTop: `${SPACING_UNIT * 2}px`,
}); });
export const content = style({ export const content = style({

View File

@ -2,20 +2,28 @@ import { useNavigate } from "react-router-dom";
import * as styles from "./hero.css"; import * as styles from "./hero.css";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ShopDetails } from "@types"; import { ShopDetails } from "@types";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers"; import {
buildGameDetailsPath,
getSteamLanguage,
steamUrlBuilder,
} from "@renderer/helpers";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const FEATURED_GAME_TITLE = "Horizon Forbidden West™ Complete Edition";
const FEATURED_GAME_ID = "2420110"; const FEATURED_GAME_ID = "2420110";
export function Hero() { export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] = const [featuredGameDetails, setFeaturedGameDetails] =
useState<ShopDetails | null>(null); useState<ShopDetails | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
setIsLoading(true);
window.electron window.electron
.getGameShopDetails( .getGameShopDetails(
FEATURED_GAME_ID, FEATURED_GAME_ID,
@ -24,19 +32,30 @@ export function Hero() {
) )
.then((result) => { .then((result) => {
setFeaturedGameDetails(result); setFeaturedGameDetails(result);
})
.finally(() => {
setIsLoading(false);
}); });
}, [i18n.language]); }, [i18n.language]);
return ( return (
<button <button
type="button" type="button"
onClick={() => navigate(`/game/steam/${FEATURED_GAME_ID}`)} onClick={() =>
navigate(
buildGameDetailsPath({
title: FEATURED_GAME_TITLE,
objectID: FEATURED_GAME_ID,
shop: "steam",
})
)
}
className={styles.hero} className={styles.hero}
> >
<div className={styles.backdrop}> <div className={styles.backdrop}>
<img <img
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg" src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
alt={featuredGameDetails?.name} alt={FEATURED_GAME_TITLE}
className={styles.heroMedia} className={styles.heroMedia}
/> />
@ -44,13 +63,14 @@ export function Hero() {
<img <img
src={steamUrlBuilder.logo(FEATURED_GAME_ID)} src={steamUrlBuilder.logo(FEATURED_GAME_ID)}
width="250px" width="250px"
alt={featuredGameDetails?.name} alt={FEATURED_GAME_TITLE}
style={{ marginBottom: 16 }}
/> />
{!isLoading && featuredGameDetails && (
<p className={styles.description}> <p className={styles.description}>
{featuredGameDetails?.short_description} {featuredGameDetails?.short_description}
</p> </p>
)}
</div> </div>
</div> </div>
</button> </button>

View File

@ -15,6 +15,7 @@ import XLogo from "@renderer/assets/x-icon.svg?react";
import * as styles from "./sidebar.css"; import * as styles from "./sidebar.css";
import { GameStatus, GameStatusHelper } from "@shared"; import { GameStatus, GameStatusHelper } from "@shared";
import { buildGameDetailsPath } from "@renderer/helpers";
const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250; const SIDEBAR_INITIAL_WIDTH = 250;
@ -209,9 +210,7 @@ export function Sidebar() {
type="button" type="button"
className={styles.menuItemButton} className={styles.menuItemButton}
onClick={() => onClick={() =>
handleSidebarItemClick( handleSidebarItemClick(buildGameDetailsPath(game))
`/game/${game.shop}/${game.objectID}`
)
} }
> >
<img <img

View File

@ -2,9 +2,11 @@ import type {
CatalogueCategory, CatalogueCategory,
CatalogueEntry, CatalogueEntry,
Game, Game,
GameRepack,
GameShop, GameShop,
HowLongToBeatCategory, HowLongToBeatCategory,
ShopDetails, ShopDetails,
Steam250Game,
TorrentProgress, TorrentProgress,
UserPreferences, UserPreferences,
} from "@types"; } from "@types";
@ -40,7 +42,7 @@ declare global {
shop: GameShop, shop: GameShop,
language: string language: string
) => Promise<ShopDetails | null>; ) => Promise<ShopDetails | null>;
getRandomGame: () => Promise<string>; getRandomGame: () => Promise<Steam250Game>;
getHowLongToBeat: ( getHowLongToBeat: (
objectID: string, objectID: string,
shop: GameShop, shop: GameShop,
@ -50,6 +52,7 @@ declare global {
take?: number, take?: number,
prevCursor?: number prevCursor?: number
) => Promise<{ results: CatalogueEntry[]; cursor: number }>; ) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
/* Library */ /* Library */
addGameToLibrary: ( addGameToLibrary: (

View File

@ -1,3 +1,5 @@
import type { CatalogueEntry } from "@types";
export const steamUrlBuilder = { export const steamUrlBuilder = {
library: (objectID: string) => library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`, `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
@ -25,6 +27,15 @@ export const getSteamLanguage = (language: string) => {
if (language.startsWith("it")) return "italian"; if (language.startsWith("it")) return "italian";
if (language.startsWith("hu")) return "hungarian"; if (language.startsWith("hu")) return "hungarian";
if (language.startsWith("pl")) return "polish"; if (language.startsWith("pl")) return "polish";
if (language.startsWith("da")) return "danish";
return "english"; return "english";
}; };
export const buildGameDetailsPath = (
game: Pick<CatalogueEntry, "title" | "shop" | "objectID">,
params: Record<string, string> = {}
) => {
const searchParams = new URLSearchParams({ title: game.title, ...params });
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
};

View File

@ -1,6 +1,18 @@
import { formatDistance } from "date-fns"; import { formatDistance } from "date-fns";
import type { FormatDistanceOptions } from "date-fns"; import type { FormatDistanceOptions } from "date-fns";
import { ptBR, enUS, es, fr, pl, hu, tr, ru, it, be } from "date-fns/locale"; import {
ptBR,
enUS,
es,
fr,
pl,
hu,
tr,
ru,
it,
be,
da,
} from "date-fns/locale";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export function useDate() { export function useDate() {
@ -18,6 +30,7 @@ export function useDate() {
if (language.startsWith("ru")) return ru; if (language.startsWith("ru")) return ru;
if (language.startsWith("it")) return it; if (language.startsWith("it")) return it;
if (language.startsWith("be")) return be; if (language.startsWith("be")) return be;
if (language.startsWith("da")) return da;
return enUS; return enUS;
}; };

View File

@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "../home/home.css"; import * as styles from "../home/home.css";
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react"; import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
export function Catalogue() { export function Catalogue() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -31,7 +32,7 @@ export function Catalogue() {
const handleGameClick = (game: CatalogueEntry) => { const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch()); dispatch(clearSearch());
navigate(`/game/${game.shop}/${game.objectID}`); navigate(buildGameDetailsPath(game));
}; };
useEffect(() => { useEffect(() => {

View File

@ -11,7 +11,7 @@ import * as styles from "./game-details.css";
const OPEN_HYDRA_URL = "https://open.hydralauncher.site"; const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
export interface DescriptionHeaderProps { export interface DescriptionHeaderProps {
gameDetails: ShopDetails | null; gameDetails: ShopDetails;
} }
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) { export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
@ -64,7 +64,7 @@ export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
date: gameDetails?.release_date.date, date: gameDetails?.release_date.date,
})} })}
</p> </p>
<p>{t("publisher", { publisher: gameDetails?.publishers[0] })}</p> <p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
</section> </section>
<Button <Button

View File

@ -1,3 +1,4 @@
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
@ -13,7 +14,7 @@ export const gallerySliderMedia = style({
width: "100%", width: "100%",
height: "100%", height: "100%",
display: "block", display: "block",
flexShrink: 0, flexShrink: "0",
flexGrow: "0", flexGrow: "0",
transition: "translate 0.3s ease-in-out", transition: "translate 0.3s ease-in-out",
borderRadius: "4px", borderRadius: "4px",
@ -54,42 +55,77 @@ export const gallerySliderPreview = style({
}, },
}); });
export const gallerySliderMediaPreview = style({ export const mediaPreviewButton = recipe({
base: {
cursor: "pointer", cursor: "pointer",
width: "20%", width: "20%",
height: "20%", height: "20%",
display: "block", display: "block",
flexShrink: 0, flexShrink: "0",
flexGrow: 0, flexGrow: "0",
opacity: 0.3, opacity: "0.3",
transition: "translate 0.3s ease-in-out, opacity 0.2s ease", transition: "translate 0.3s ease-in-out, opacity 0.2s ease",
borderRadius: "4px", borderRadius: "4px",
border: `solid 1px ${vars.color.border}`, border: `solid 1px ${vars.color.border}`,
":hover": { ":hover": {
opacity: "0.8",
},
},
variants: {
active: {
true: {
opacity: "1", opacity: "1",
}, },
}); },
export const gallerySliderMediaPreviewActive = style({
opacity: 1,
});
export const gallerySliderButton = style({
all: "unset",
display: "block",
position: "absolute",
top: 0,
bottom: 0,
padding: "1rem",
cursor: "pointer",
transition: "background-color 100ms ease-in-out",
":hover": {
backgroundColor: "rgb(0, 0, 0, 0.2)",
}, },
}); });
export const gallerySliderIcons = style({ export const mediaPreview = style({
fill: vars.color.muted, width: "100%",
width: "2rem", height: "100%",
height: "2rem", display: "flex",
flex: "1",
});
export const gallerySliderButton = recipe({
base: {
position: "absolute",
alignSelf: "center",
cursor: "pointer",
backgroundColor: "rgba(0, 0, 0, 0.4)",
transition: "all 0.2s ease-in-out",
borderRadius: "50%",
color: vars.color.muted,
width: "48px",
height: "48px",
":hover": {
backgroundColor: "rgba(0, 0, 0, 0.6)",
},
":active": {
transform: "scale(0.95)",
},
},
variants: {
direction: {
left: {
left: "0",
marginLeft: `${SPACING_UNIT}px`,
transform: `translateX(${-(48 + SPACING_UNIT)}px)`,
},
right: {
right: "0",
marginRight: `${SPACING_UNIT}px`,
transform: `translateX(${48 + SPACING_UNIT}px)`,
},
},
visible: {
true: {
transform: "translateX(0)",
opacity: "1",
},
false: {
opacity: "0",
},
},
},
}); });

View File

@ -1,17 +1,25 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ShopDetails, SteamMovies, SteamScreenshot } from "@types";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react"; import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import type { ShopDetails } from "@types";
import * as styles from "./gallery-slider.css"; import * as styles from "./gallery-slider.css";
import { useTranslation } from "react-i18next";
export interface GallerySliderProps { export interface GallerySliderProps {
gameDetails: ShopDetails | null; gameDetails: ShopDetails;
} }
export function GallerySlider({ gameDetails }: GallerySliderProps) { export function GallerySlider({ gameDetails }: GallerySliderProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const mediaContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation("game_details");
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
const hasMovies = gameDetails && gameDetails.movies?.length;
const [mediaCount] = useState<number>(() => { const [mediaCount] = useState<number>(() => {
if (gameDetails) {
if (gameDetails.screenshots && gameDetails.movies) { if (gameDetails.screenshots && gameDetails.movies) {
return gameDetails.screenshots.length + gameDetails.movies.length; return gameDetails.screenshots.length + gameDetails.movies.length;
} else if (gameDetails.movies) { } else if (gameDetails.movies) {
@ -19,13 +27,12 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
} else if (gameDetails.screenshots) { } else if (gameDetails.screenshots) {
return gameDetails.screenshots.length; return gameDetails.screenshots.length;
} }
}
return 0; return 0;
}); });
const [mediaIndex, setMediaIndex] = useState<number>(0); const [mediaIndex, setMediaIndex] = useState<number>(0);
const [arrowShow, setArrowShow] = useState(false); const [showArrows, setShowArrows] = useState(false);
const showNextImage = () => { const showNextImage = () => {
setMediaIndex((index: number) => { setMediaIndex((index: number) => {
@ -47,6 +54,20 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
setMediaIndex(0); setMediaIndex(0);
}, [gameDetails]); }, [gameDetails]);
useEffect(() => {
if (hasMovies && mediaContainerRef.current) {
mediaContainerRef.current.childNodes.forEach((node, index) => {
if (node instanceof HTMLVideoElement) {
if (index == mediaIndex) {
node.play();
} else {
node.pause();
}
}
});
}
}, [hasMovies, mediaContainerRef, mediaIndex]);
useEffect(() => { useEffect(() => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
@ -57,92 +78,107 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
} }
}, [gameDetails, mediaIndex, mediaCount]); }, [gameDetails, mediaIndex, mediaCount]);
const hasScreenshots = gameDetails && gameDetails.screenshots.length; const previews = useMemo(() => {
const hasMovies = gameDetails && gameDetails.movies?.length; const screenshotPreviews =
gameDetails?.screenshots.map(({ id, path_thumbnail }) => ({
id,
thumbnail: path_thumbnail,
})) ?? [];
if (gameDetails?.movies) {
const moviePreviews = gameDetails.movies.map(({ id, thumbnail }) => ({
id,
thumbnail,
}));
return [...moviePreviews, ...screenshotPreviews];
}
return screenshotPreviews;
}, [gameDetails]);
return ( return (
<> <>
{hasScreenshots && ( {hasScreenshots && (
<div className={styles.gallerySliderContainer}> <div className={styles.gallerySliderContainer}>
<div <div
onMouseEnter={() => setArrowShow(true)} onMouseEnter={() => setShowArrows(true)}
onMouseLeave={() => setArrowShow(false)} onMouseLeave={() => setShowArrows(false)}
className={styles.gallerySliderAnimationContainer} className={styles.gallerySliderAnimationContainer}
ref={mediaContainerRef}
> >
{gameDetails.movies && {gameDetails.movies &&
gameDetails.movies.map((video: SteamMovies) => ( gameDetails.movies.map((video) => (
<video <video
key={video.id} key={video.id}
controls controls
className={styles.gallerySliderMedia} className={styles.gallerySliderMedia}
poster={video.thumbnail} poster={video.thumbnail}
style={{ translate: `${-100 * mediaIndex}%` }} style={{ translate: `${-100 * mediaIndex}%` }}
autoPlay
loop loop
muted muted
tabIndex={-1}
> >
<source src={video.webm.max.replace("http", "https")} /> <source src={video.mp4.max.replace("http", "https")} />
</video> </video>
))} ))}
{gameDetails.screenshots &&
gameDetails.screenshots.map( {hasScreenshots &&
(image: SteamScreenshot, i: number) => ( gameDetails.screenshots.map((image, i) => (
<img <img
key={"image-" + i} key={image.id}
className={styles.gallerySliderMedia} className={styles.gallerySliderMedia}
src={image.path_full} src={image.path_full}
style={{ translate: `${-100 * mediaIndex}%` }} style={{ translate: `${-100 * mediaIndex}%` }}
alt={t("screenshot", { number: i + 1 })}
/> />
) ))}
)}
{arrowShow && (
<>
<button <button
onClick={showPrevImage} onClick={showPrevImage}
type="button" type="button"
className={styles.gallerySliderButton} className={styles.gallerySliderButton({
style={{ left: 0 }} visible: showArrows,
direction: "left",
})}
aria-label={t("previous_screenshot")}
tabIndex={0}
> >
<ChevronLeftIcon className={styles.gallerySliderIcons} /> <ChevronLeftIcon size={36} />
</button> </button>
<button <button
onClick={showNextImage} onClick={showNextImage}
type="button" type="button"
className={styles.gallerySliderButton} className={styles.gallerySliderButton({
style={{ right: 0 }} visible: showArrows,
direction: "right",
})}
aria-label={t("next_screenshot")}
tabIndex={0}
> >
<ChevronRightIcon className={styles.gallerySliderIcons} /> <ChevronRightIcon size={36} />
</button> </button>
</>
)}
</div> </div>
<div className={styles.gallerySliderPreview} ref={scrollContainerRef}> <div className={styles.gallerySliderPreview} ref={scrollContainerRef}>
{hasMovies && {previews.map((media, i) => (
gameDetails.movies?.map((video: SteamMovies, i: number) => ( <button
<img key={media.id}
key={video.id} type="button"
className={styles.mediaPreviewButton({
active: mediaIndex === i,
})}
onClick={() => setMediaIndex(i)} onClick={() => setMediaIndex(i)}
src={video.thumbnail} aria-label={t("open_screenshot", { number: i + 1 })}
className={`${styles.gallerySliderMediaPreview} ${mediaIndex === i ? styles.gallerySliderMediaPreviewActive : ""}`} >
/>
))}
{gameDetails.screenshots &&
gameDetails.screenshots.map(
(image: SteamScreenshot, i: number) => (
<img <img
key={"image-thumb-" + i} src={media.thumbnail}
onClick={() => className={styles.mediaPreview}
setMediaIndex( alt={t("screenshot", { number: i + 1 })}
i + (gameDetails.movies ? gameDetails.movies.length : 0)
)
}
className={`${styles.gallerySliderMediaPreview} ${mediaIndex === i + (gameDetails.movies ? gameDetails.movies.length : 0) ? styles.gallerySliderMediaPreviewActive : ""}`}
src={image.path_full}
/> />
) </button>
)} ))}
</div> </div>
</div> </div>
)} )}

View File

@ -1,7 +1,10 @@
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { Button } from "@renderer/components"; import { Button } from "@renderer/components";
import * as styles from "./game-details.css"; import * as styles from "./game-details.css";
import * as sidebarStyles from "./sidebar/sidebar.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ShareAndroidIcon } from "@primer/octicons-react"; import { ShareAndroidIcon } from "@primer/octicons-react";
@ -43,41 +46,41 @@ export function GameDetailsSkeleton() {
<Skeleton /> <Skeleton />
</div> </div>
</div> </div>
<div className={styles.contentSidebar}> <div className={sidebarStyles.contentSidebar}>
<div className={styles.contentSidebarTitle}> <div className={sidebarStyles.contentSidebarTitle}>
<h3>HowLongToBeat</h3> <h3>HowLongToBeat</h3>
</div> </div>
<ul className={styles.howLongToBeatCategoriesList}> <ul className={sidebarStyles.howLongToBeatCategoriesList}>
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<Skeleton <Skeleton
key={index} key={index}
className={styles.howLongToBeatCategorySkeleton} className={sidebarStyles.howLongToBeatCategorySkeleton}
/> />
))} ))}
</ul> </ul>
<div <div
className={styles.contentSidebarTitle} className={sidebarStyles.contentSidebarTitle}
style={{ border: "none" }} style={{ border: "none" }}
> >
<h3>{t("requirements")}</h3> <h3>{t("requirements")}</h3>
</div> </div>
<div className={styles.requirementButtonContainer}> <div className={sidebarStyles.requirementButtonContainer}>
<Button <Button
className={styles.requirementButton} className={sidebarStyles.requirementButton}
theme="primary" theme="primary"
disabled disabled
> >
{t("minimum")} {t("minimum")}
</Button> </Button>
<Button <Button
className={styles.requirementButton} className={sidebarStyles.requirementButton}
theme="outline" theme="outline"
disabled disabled
> >
{t("recommended")} {t("recommended")}
</Button> </Button>
</div> </div>
<div className={styles.requirementsDetailsSkeleton}> <div className={sidebarStyles.requirementsDetailsSkeleton}>
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} height={20} /> <Skeleton key={index} height={20} />
))} ))}

View File

@ -79,62 +79,6 @@ export const descriptionContent = style({
height: "100%", height: "100%",
}); });
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`,
width: "100%",
height: "100%",
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "200px",
},
"(min-width: 1024px)": {
maxWidth: "300px",
width: "100%",
},
"(min-width: 1280px)": {
width: "100%",
maxWidth: "400px",
},
},
});
export const contentSidebarTitle = style({
height: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
backgroundColor: vars.color.background,
});
export const requirementButtonContainer = style({
width: "100%",
display: "flex",
});
export const requirementButton = style({
border: `solid 1px ${vars.color.border};`,
borderLeft: "none",
borderRight: "none",
borderRadius: "0",
width: "100%",
});
export const requirementsDetails = style({
padding: `${SPACING_UNIT * 2}px`,
lineHeight: "22px",
fontFamily: "'Fira Sans', sans-serif",
fontSize: "16px",
});
export const requirementsDetailsSkeleton = style({
display: "flex",
flexDirection: "column",
gap: "8px",
padding: `${SPACING_UNIT * 2}px`,
fontSize: "16px",
});
export const description = style({ export const description = style({
userSelect: "text", userSelect: "text",
lineHeight: "22px", lineHeight: "22px",
@ -183,34 +127,6 @@ export const descriptionHeaderInfo = style({
flexDirection: "column", flexDirection: "column",
}); });
export const howLongToBeatCategoriesList = style({
margin: "0",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
});
export const howLongToBeatCategory = style({
display: "flex",
flexDirection: "column",
gap: "4px",
backgroundColor: vars.color.background,
borderRadius: "8px",
padding: `8px 16px`,
border: `solid 1px ${vars.color.border}`,
});
export const howLongToBeatCategoryLabel = style({
color: vars.color.muted,
});
export const howLongToBeatCategorySkeleton = style({
border: `solid 1px ${vars.color.border}`,
borderRadius: "8px",
height: "76px",
});
export const randomizerButton = style({ export const randomizerButton = style({
animationName: slideIn, animationName: slideIn,
animationDuration: "0.2s", animationDuration: "0.2s",
@ -260,8 +176,3 @@ globalStyle(`${description} img`, {
globalStyle(`${description} a`, { globalStyle(`${description} a`, {
color: vars.color.bodyText, color: vars.color.bodyText,
}); });
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.bodyText,
});

View File

@ -3,18 +3,21 @@ import { average } from "color.js";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import type { import {
Game, Steam250Game,
GameRepack, type Game,
GameShop, type GameRepack,
HowLongToBeatCategory, type GameShop,
ShopDetails, type ShopDetails,
SteamAppDetails,
} from "@types"; } from "@types";
import { Button } from "@renderer/components"; import { Button } from "@renderer/components";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers"; import {
buildGameDetailsPath,
getSteamLanguage,
steamUrlBuilder,
} from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks"; import { useAppDispatch, useDownload } from "@renderer/hooks";
import starsAnimation from "@renderer/assets/lottie/stars.json"; import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -26,7 +29,6 @@ import { DescriptionHeader } from "./description-header";
import { GameDetailsSkeleton } from "./game-details-skeleton"; import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css"; import * as styles from "./game-details.css";
import { HeroPanel } from "./hero"; import { HeroPanel } from "./hero";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { RepacksModal } from "./repacks-modal"; import { RepacksModal } from "./repacks-modal";
import { vars } from "../../theme.css"; import { vars } from "../../theme.css";
@ -37,18 +39,16 @@ import {
OnlineFixInstallationGuide, OnlineFixInstallationGuide,
} from "./installation-guides"; } from "./installation-guides";
import { GallerySlider } from "./gallery-slider"; import { GallerySlider } from "./gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
export function GameDetails() { export function GameDetails() {
const { objectID, shop } = useParams(); const { objectID, shop } = useParams();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false); const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [color, setColor] = useState({ dark: "", light: "" }); const [color, setColor] = useState({ dark: "", light: "" });
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null); const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [howLongToBeat, setHowLongToBeat] = useState<{ const [repacks, setRepacks] = useState<GameRepack[]>([]);
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
const [game, setGame] = useState<Game | null>(null); const [game, setGame] = useState<Game | null>(null);
const [isGamePlaying, setIsGamePlaying] = useState(false); const [isGamePlaying, setIsGamePlaying] = useState(false);
@ -56,12 +56,12 @@ export function GameDetails() {
null | "onlinefix" | "DODI" null | "onlinefix" | "DODI"
>(null); >(null);
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const fromRandomizer = searchParams.get("fromRandomizer");
const title = searchParams.get("title")!;
const { t, i18n } = useTranslation("game_details"); const { t, i18n } = useTranslation("game_details");
const [showRepacksModal, setShowRepacksModal] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false);
@ -90,37 +90,35 @@ export function GameDetails() {
useEffect(() => { useEffect(() => {
getGame(); getGame();
}, [getGame, gameDownloading?.id]); }, [getGame, gameDownloading?.id]);
useEffect(() => { useEffect(() => {
setGame(null); setGame(null);
setIsLoading(true); setIsLoading(true);
setIsGamePlaying(false); setIsGamePlaying(false);
dispatch(setHeaderTitle("")); dispatch(setHeaderTitle(title));
window.electron window.electron.getRandomGame().then((randomGame) => {
.getGameShopDetails(objectID!, "steam", getSteamLanguage(i18n.language)) setRandomGame(randomGame);
.then((result) => {
if (!result) {
navigate(-1);
return;
}
window.electron
.getHowLongToBeat(objectID!, "steam", result.name)
.then((data) => {
setHowLongToBeat({ isLoading: false, data });
}); });
setGameDetails(result); Promise.all([
dispatch(setHeaderTitle(result.name)); window.electron.getGameShopDetails(
setIsLoadingRandomGame(false); objectID!,
"steam",
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(title),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
getGame(); getGame();
setHowLongToBeat({ isLoading: true, data: null }); }, [getGame, dispatch, navigate, title, objectID, i18n.language]);
}, [getGame, dispatch, navigate, objectID, i18n.language]);
const isGameDownloading = gameDownloading?.id === game?.id; const isGameDownloading = gameDownloading?.id === game?.id;
@ -154,11 +152,10 @@ export function GameDetails() {
repack: GameRepack, repack: GameRepack,
downloadPath: string downloadPath: string
) => { ) => {
if (gameDetails) {
return startDownload( return startDownload(
repack.id, repack.id,
gameDetails.objectID, objectID!,
gameDetails.name, title,
shop as GameShop, shop as GameShop,
downloadPath downloadPath
).then(() => { ).then(() => {
@ -177,32 +174,27 @@ export function GameDetails() {
setShowInstructionsModal("DODI"); setShowInstructionsModal("DODI");
} }
}); });
};
const handleRandomizerClick = () => {
if (randomGame) {
navigate(
buildGameDetailsPath(
{ ...randomGame, shop: "steam" },
{ fromRandomizer: "1" }
)
);
} }
}; };
const handleRandomizerClick = async () => {
setIsLoadingRandomGame(true);
const randomGameObjectID = await window.electron.getRandomGame();
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(`/game/steam/${randomGameObjectID}?${searchParams.toString()}`);
};
const fromRandomizer = searchParams.get("fromRandomizer");
return ( return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444"> <SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
{gameDetails && (
<RepacksModal <RepacksModal
visible={showRepacksModal} visible={showRepacksModal}
gameDetails={gameDetails} repacks={repacks}
startDownload={handleStartDownload} startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)} onClose={() => setShowRepacksModal(false)}
/> />
)}
<OnlineFixInstallationGuide <OnlineFixInstallationGuide
visible={showInstructionsModal === "onlinefix"} visible={showInstructionsModal === "onlinefix"}
@ -240,7 +232,9 @@ export function GameDetails() {
<HeroPanel <HeroPanel
game={game} game={game}
color={color.dark} color={color.dark}
gameDetails={gameDetails} objectID={objectID!}
title={title}
repacks={repacks}
openRepacksModal={() => setShowRepacksModal(true)} openRepacksModal={() => setShowRepacksModal(true)}
getGame={getGame} getGame={getGame}
isGamePlaying={isGamePlaying} isGamePlaying={isGamePlaying}
@ -248,63 +242,22 @@ export function GameDetails() {
<div className={styles.descriptionContainer}> <div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}> <div className={styles.descriptionContent}>
<DescriptionHeader gameDetails={gameDetails} /> {gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
{gameDetails && <GallerySlider gameDetails={gameDetails} />}
<GallerySlider gameDetails={gameDetails} />
<div <div
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: gameDetails?.about_the_game ?? "", __html: gameDetails?.about_the_game ?? t("no_shop_details"),
}} }}
className={styles.description} className={styles.description}
/> />
</div> </div>
<div className={styles.contentSidebar}> <Sidebar
<HowLongToBeatSection objectID={objectID!}
howLongToBeatData={howLongToBeat.data} title={title}
isLoading={howLongToBeat.isLoading} gameDetails={gameDetails}
/> />
<div
className={styles.contentSidebarTitle}
style={{ border: "none" }}
>
<h3>{t("requirements")}</h3>
</div>
<div className={styles.requirementButtonContainer}>
<Button
className={styles.requirementButton}
onClick={() => setActiveRequirement("minimum")}
theme={
activeRequirement === "minimum" ? "primary" : "outline"
}
>
{t("minimum")}
</Button>
<Button
className={styles.requirementButton}
onClick={() => setActiveRequirement("recommended")}
theme={
activeRequirement === "recommended" ? "primary" : "outline"
}
>
{t("recommended")}
</Button>
</div>
<div
className={styles.requirementsDetails}
dangerouslySetInnerHTML={{
__html:
gameDetails?.pc_requirements?.[activeRequirement] ??
t(`no_${activeRequirement}_requirements`, {
title: gameDetails?.name,
}),
}}
/>
</div>
</div> </div>
</section> </section>
)} )}
@ -314,7 +267,7 @@ export function GameDetails() {
className={styles.randomizerButton} className={styles.randomizerButton}
onClick={handleRandomizerClick} onClick={handleRandomizerClick}
theme="outline" theme="outline"
disabled={isLoadingRandomGame} disabled={!randomGame}
> >
<div style={{ width: 16, height: 16, position: "relative" }}> <div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie <Lottie

View File

@ -3,7 +3,7 @@ import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components"; import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks"; import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types"; import type { Game, GameRepack } from "@types";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -11,9 +11,11 @@ import * as styles from "./hero-panel-actions.css";
export interface HeroPanelActionsProps { export interface HeroPanelActionsProps {
game: Game | null; game: Game | null;
gameDetails: ShopDetails | null; repacks: GameRepack[];
isGamePlaying: boolean; isGamePlaying: boolean;
isGameDownloading: boolean; isGameDownloading: boolean;
objectID: string;
title: string;
openRepacksModal: () => void; openRepacksModal: () => void;
openBinaryNotFoundModal: () => void; openBinaryNotFoundModal: () => void;
getGame: () => void; getGame: () => void;
@ -21,9 +23,11 @@ export interface HeroPanelActionsProps {
export function HeroPanelActions({ export function HeroPanelActions({
game, game,
gameDetails,
isGamePlaying, isGamePlaying,
isGameDownloading, isGameDownloading,
repacks,
objectID,
title,
openRepacksModal, openRepacksModal,
openBinaryNotFoundModal, openBinaryNotFoundModal,
getGame, getGame,
@ -69,12 +73,12 @@ export function HeroPanelActions({
try { try {
if (game) { if (game) {
await removeGameFromLibrary(game.id); await removeGameFromLibrary(game.id);
} else if (gameDetails) { } else {
const gameExecutablePath = await selectGameExecutable(); const gameExecutablePath = await selectGameExecutable();
await window.electron.addGameToLibrary( await window.electron.addGameToLibrary(
gameDetails.objectID, objectID,
gameDetails.name, title,
"steam", "steam",
gameExecutablePath gameExecutablePath
); );
@ -123,7 +127,7 @@ export function HeroPanelActions({
const toggleGameOnLibraryButton = ( const toggleGameOnLibraryButton = (
<Button <Button
theme="outline" theme="outline"
disabled={!gameDetails || toggleLibraryGameDisabled} disabled={toggleLibraryGameDisabled}
onClick={toggleGameOnLibrary} onClick={toggleGameOnLibrary}
className={styles.heroPanelAction} className={styles.heroPanelAction}
> >
@ -239,7 +243,7 @@ export function HeroPanelActions({
); );
} }
if (gameDetails && gameDetails.repacks.length) { if (repacks.length) {
return ( return (
<> <>
{toggleGameOnLibraryButton} {toggleGameOnLibraryButton}

View File

@ -4,13 +4,13 @@ import { SPACING_UNIT, vars } from "../../../theme.css";
export const panel = style({ export const panel = style({
width: "100%", width: "100%",
height: "72px", height: "72px",
minHeight: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`, padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
transition: "all ease 0.2s", transition: "all ease 0.2s",
borderBottom: `solid 1px ${vars.color.border}`, borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px #000000",
}); });
export const content = style({ export const content = style({

View File

@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks"; import { useDownload } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types"; import type { Game, GameRepack } from "@types";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelActions } from "./hero-panel-actions";
@ -15,20 +15,24 @@ import { HeroPanelPlaytime } from "./hero-panel-playtime";
export interface HeroPanelProps { export interface HeroPanelProps {
game: Game | null; game: Game | null;
gameDetails: ShopDetails | null;
color: string; color: string;
isGamePlaying: boolean; isGamePlaying: boolean;
objectID: string;
title: string;
repacks: GameRepack[];
openRepacksModal: () => void; openRepacksModal: () => void;
getGame: () => void; getGame: () => void;
} }
export function HeroPanel({ export function HeroPanel({
game, game,
gameDetails,
color, color,
repacks,
objectID,
title,
isGamePlaying,
openRepacksModal, openRepacksModal,
getGame, getGame,
isGamePlaying,
}: HeroPanelProps) { }: HeroPanelProps) {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
@ -58,8 +62,6 @@ export function HeroPanel({
}, [game, isGameDownloading, gameDownloading]); }, [game, isGameDownloading, gameDownloading]);
const getInfo = () => { const getInfo = () => {
if (!gameDetails) return null;
if (isGameDeleting(game?.id ?? -1)) { if (isGameDeleting(game?.id ?? -1)) {
return <p>{t("deleting")}</p>; return <p>{t("deleting")}</p>;
} }
@ -110,11 +112,11 @@ export function HeroPanel({
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />; return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
} }
const [latestRepack] = gameDetails.repacks; const [latestRepack] = repacks;
if (latestRepack) { if (latestRepack) {
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy"); const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
const repacksCount = gameDetails.repacks.length; const repacksCount = repacks.length;
return ( return (
<> <>
@ -139,7 +141,9 @@ export function HeroPanel({
<div className={styles.actions}> <div className={styles.actions}>
<HeroPanelActions <HeroPanelActions
game={game} game={game}
gameDetails={gameDetails} repacks={repacks}
objectID={objectID}
title={title}
getGame={getGame} getGame={getGame}
openRepacksModal={openRepacksModal} openRepacksModal={openRepacksModal}
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)} openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components"; import { Button, Modal, TextField } from "@renderer/components";
import type { GameRepack, ShopDetails } from "@types"; import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css"; import * as styles from "./repacks-modal.css";
@ -20,14 +20,14 @@ import { getRepackLanguageBasedOnRepacker } from "../../helpers/searcher";
export interface RepacksModalProps { export interface RepacksModalProps {
visible: boolean; visible: boolean;
gameDetails: ShopDetails; repacks: GameRepack[];
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>; startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
onClose: () => void; onClose: () => void;
} }
export function RepacksModal({ export function RepacksModal({
visible, visible,
gameDetails, repacks,
startDownload, startDownload,
onClose, onClose,
}: RepacksModalProps) { }: RepacksModalProps) {
@ -45,8 +45,8 @@ export function RepacksModal({
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
useEffect(() => { useEffect(() => {
setFilteredRepacks(gameDetails.repacks); setFilteredRepacks(repacks);
}, [gameDetails.repacks, visible]); }, [repacks, visible]);
const handleRepackClick = (repack: GameRepack) => { const handleRepackClick = (repack: GameRepack) => {
setRepack(repack); setRepack(repack);
@ -57,7 +57,7 @@ export function RepacksModal({
const term = event.target.value.toLocaleLowerCase(); const term = event.target.value.toLocaleLowerCase();
setFilteredRepacks( setFilteredRepacks(
gameDetails.repacks.filter((repack) => { repacks.filter((repack) => {
const lowerCaseTitle = repack.title.toLowerCase(); const lowerCaseTitle = repack.title.toLowerCase();
const lowerCaseRepacker = repack.repacker.toLowerCase(); const lowerCaseRepacker = repack.repacker.toLowerCase();
@ -73,14 +73,13 @@ export function RepacksModal({
<SelectFolderModal <SelectFolderModal
visible={showSelectFolderModal} visible={showSelectFolderModal}
onClose={() => setShowSelectFolderModal(false)} onClose={() => setShowSelectFolderModal(false)}
gameDetails={gameDetails}
startDownload={startDownload} startDownload={startDownload}
repack={repack} repack={repack}
/> />
<Modal <Modal
visible={visible} visible={visible}
title={`${gameDetails.name} Repacks`} title={t("download_options")}
description={t("repacks_modal_description")} description={t("repacks_modal_description")}
onClose={onClose} onClose={onClose}
> >

View File

@ -1,5 +1,5 @@
import { Button, Link, Modal, TextField } from "@renderer/components"; import { Button, Link, Modal, TextField } from "@renderer/components";
import { GameRepack, ShopDetails } from "@types"; import type { GameRepack } from "@types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
@ -10,7 +10,6 @@ import { formatBytes } from "@shared";
export interface SelectFolderModalProps { export interface SelectFolderModalProps {
visible: boolean; visible: boolean;
gameDetails: ShopDetails;
onClose: () => void; onClose: () => void;
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>; startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
repack: GameRepack | null; repack: GameRepack | null;
@ -18,7 +17,6 @@ export interface SelectFolderModalProps {
export function SelectFolderModal({ export function SelectFolderModal({
visible, visible,
gameDetails,
onClose, onClose,
startDownload, startDownload,
repack, repack,
@ -74,7 +72,7 @@ export function SelectFolderModal({
return ( return (
<Modal <Modal
visible={visible} visible={visible}
title={t("installation_folder", { name: gameDetails.name })} title={t("download_path")}
description={t("space_left_on_disk", { description={t("space_left_on_disk", {
space: formatBytes(diskFreeSpace?.free ?? 0), space: formatBytes(diskFreeSpace?.free ?? 0),
})} })}
@ -82,12 +80,7 @@ export function SelectFolderModal({
> >
<div className={styles.container}> <div className={styles.container}>
<div className={styles.downloadsPathField}> <div className={styles.downloadsPathField}>
<TextField <TextField value={selectedPath} readOnly disabled />
label={t("downloads_path")}
value={selectedPath}
readOnly
disabled
/>
<Button <Button
style={{ alignSelf: "flex-end" }} style={{ alignSelf: "flex-end" }}

View File

@ -1,8 +1,8 @@
import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { HowLongToBeatCategory } from "@types"; import type { HowLongToBeatCategory } from "@types";
import { vars } from "../../theme.css"; import { vars } from "../../../theme.css";
import * as styles from "./game-details.css"; import * as styles from "./sidebar.css";
const durationTranslation: Record<string, string> = { const durationTranslation: Record<string, string> = {
Hours: "hours", Hours: "hours",

View File

@ -0,0 +1,92 @@
import { globalStyle, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`,
width: "100%",
height: "100%",
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "200px",
},
"(min-width: 1024px)": {
maxWidth: "300px",
width: "100%",
},
"(min-width: 1280px)": {
width: "100%",
maxWidth: "400px",
},
},
});
export const contentSidebarTitle = style({
height: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
backgroundColor: vars.color.background,
});
export const requirementButtonContainer = style({
width: "100%",
display: "flex",
});
export const requirementButton = style({
border: `solid 1px ${vars.color.border};`,
borderLeft: "none",
borderRight: "none",
borderRadius: "0",
width: "100%",
});
export const requirementsDetails = style({
padding: `${SPACING_UNIT * 2}px`,
lineHeight: "22px",
fontFamily: "'Fira Sans', sans-serif",
fontSize: "16px",
});
export const requirementsDetailsSkeleton = style({
display: "flex",
flexDirection: "column",
gap: "8px",
padding: `${SPACING_UNIT * 2}px`,
fontSize: "16px",
});
export const howLongToBeatCategoriesList = style({
margin: "0",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
});
export const howLongToBeatCategory = style({
display: "flex",
flexDirection: "column",
gap: "4px",
backgroundColor: vars.color.background,
borderRadius: "8px",
padding: `8px 16px`,
border: `solid 1px ${vars.color.border}`,
});
export const howLongToBeatCategoryLabel = style({
color: vars.color.muted,
});
export const howLongToBeatCategorySkeleton = style({
border: `solid 1px ${vars.color.border}`,
borderRadius: "8px",
height: "76px",
});
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.bodyText,
});

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import type {
HowLongToBeatCategory,
ShopDetails,
SteamAppDetails,
} from "@types";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
export interface SidebarProps {
objectID: string;
title: string;
gameDetails: ShopDetails | null;
}
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { t } = useTranslation("game_details");
useEffect(() => {
setHowLongToBeat({ isLoading: true, data: null });
window.electron
.getHowLongToBeat(objectID, "steam", title)
.then((howLongToBeat) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
});
}, [objectID, title]);
return (
<aside className={styles.contentSidebar}>
<HowLongToBeatSection
howLongToBeatData={howLongToBeat.data}
isLoading={howLongToBeat.isLoading}
/>
<div className={styles.contentSidebarTitle} style={{ border: "none" }}>
<h3>{t("requirements")}</h3>
</div>
<div className={styles.requirementButtonContainer}>
<Button
className={styles.requirementButton}
onClick={() => setActiveRequirement("minimum")}
theme={activeRequirement === "minimum" ? "primary" : "outline"}
>
{t("minimum")}
</Button>
<Button
className={styles.requirementButton}
onClick={() => setActiveRequirement("recommended")}
theme={activeRequirement === "recommended" ? "primary" : "outline"}
>
{t("recommended")}
</Button>
</div>
<div
className={styles.requirementsDetails}
dangerouslySetInnerHTML={{
__html:
gameDetails?.pc_requirements?.[activeRequirement] ??
t(`no_${activeRequirement}_requirements`, {
title,
}),
}}
/>
</aside>
);
}

View File

@ -1,17 +1,22 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components"; import { Button, GameCard, Hero } from "@renderer/components";
import type { CatalogueCategory, CatalogueEntry } from "@types"; import {
Steam250Game,
type CatalogueCategory,
type CatalogueEntry,
} from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json"; import starsAnimation from "@renderer/assets/lottie/stars.json";
import * as styles from "./home.css"; import * as styles from "./home.css";
import { vars } from "../../theme.css"; import { vars } from "../../theme.css";
import Lottie from "lottie-react"; import Lottie from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers";
const categories: CatalogueCategory[] = ["trending", "recently_added"]; const categories: CatalogueCategory[] = ["trending", "recently_added"];
@ -20,8 +25,7 @@ export function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false); const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const randomGameObjectID = useRef<string | null>(null);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -56,24 +60,22 @@ export function Home() {
}; };
const getRandomGame = useCallback(() => { const getRandomGame = useCallback(() => {
setIsLoadingRandomGame(true); window.electron.getRandomGame().then((game) => {
if (game) setRandomGame(game);
window.electron.getRandomGame().then((objectID) => {
if (objectID) {
randomGameObjectID.current = objectID;
setIsLoadingRandomGame(false);
}
}); });
}, []); }, []);
const handleRandomizerClick = () => { const handleRandomizerClick = () => {
const searchParams = new URLSearchParams({ if (randomGame) {
fromRandomizer: "1",
});
navigate( navigate(
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}` buildGameDetailsPath(
{ ...randomGame, shop: "steam" },
{
fromRandomizer: "1",
}
)
); );
}
}; };
useEffect(() => { useEffect(() => {
@ -105,7 +107,7 @@ export function Home() {
<Button <Button
onClick={handleRandomizerClick} onClick={handleRandomizerClick}
theme="outline" theme="outline"
disabled={isLoadingRandomGame} disabled={!randomGame}
> >
<div style={{ width: 16, height: 16, position: "relative" }}> <div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie <Lottie
@ -129,9 +131,7 @@ export function Home() {
<GameCard <GameCard
key={result.objectID} key={result.objectID}
game={result} game={result}
onClick={() => onClick={() => navigate(buildGameDetailsPath(result))}
navigate(`/game/${result.shop}/${result.objectID}`)
}
/> />
))} ))}
</section> </section>

View File

@ -14,6 +14,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "./home.css"; import * as styles from "./home.css";
import { buildGameDetailsPath } from "@renderer/helpers";
export function SearchResults() { export function SearchResults() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -30,7 +31,7 @@ export function SearchResults() {
const handleGameClick = (game: CatalogueEntry) => { const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch()); dispatch(clearSearch());
navigate(`/game/${game.shop}/${game.objectID}`); navigate(buildGameDetailsPath(game));
}; };
useEffect(() => { useEffect(() => {

View File

@ -57,7 +57,7 @@ export function SettingsRealDebrid({
{form.useRealDebrid && ( {form.useRealDebrid && (
<TextField <TextField
label={t("real_debrid_api_token_description")} label={t("real_debrid_api_token_label")}
value={form.realDebridApiToken ?? ""} value={form.realDebridApiToken ?? ""}
type="password" type="password"
onChange={(event) => onChange={(event) =>

View File

@ -69,7 +69,6 @@ export interface GameRepack {
export type ShopDetails = SteamAppDetails & { export type ShopDetails = SteamAppDetails & {
objectID: string; objectID: string;
repacks: GameRepack[];
}; };
export interface TorrentFile { export interface TorrentFile {
@ -134,3 +133,8 @@ export interface HowLongToBeatCategory {
duration: string; duration: string;
accuracy: string; accuracy: string;
} }
export interface Steam250Game {
title: string;
objectID: string;
}