mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-09 03:37:45 +03:00
Merge branch 'main' of https://github.com/hydralauncher/hydra
This commit is contained in:
commit
fce0fbd151
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@ -6,11 +6,23 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest]
|
||||
os:
|
||||
[
|
||||
{
|
||||
name: windows-latest,
|
||||
build_path: out/Hydra-win32-x64,
|
||||
artifact: Hydra-win32-x64,
|
||||
},
|
||||
{
|
||||
name: ubuntu-latest,
|
||||
build_path: out/Hydra-linux-x64,
|
||||
artifact: Hydra-linux-x64,
|
||||
},
|
||||
]
|
||||
|
||||
runs-on: ${{ matrix.os.name }}
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
@ -42,10 +54,10 @@ jobs:
|
||||
run: yarn run publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STEAMGRIDDB_API_KEY: ${{ env.STEAMGRIDDB_API_KEY }}
|
||||
STEAMGRIDDB_API_KEY: ${{ secrets.STEAMGRIDDB_API_KEY }}
|
||||
|
||||
- name: Create artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build
|
||||
path: out/Hydra-win32-x64
|
||||
name: ${{ matrix.os.artifact }}
|
||||
path: ${{ matrix.os.build_path }}
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -93,6 +93,8 @@ out/
|
||||
|
||||
.vscode/
|
||||
|
||||
.venv
|
||||
|
||||
dev.db
|
||||
|
||||
__pycache__
|
||||
|
6
.prettierrc.js
Normal file
6
.prettierrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "es5",
|
||||
singleQuote: false,
|
||||
tabWidth: 2,
|
||||
};
|
@ -1,6 +1,8 @@
|
||||
# Hydra
|
||||
|
||||
<a href="https://discord.gg/hydralauncher" target="_blank">![Discord](https://img.shields.io/discord/1220692017311645737?style=flat&logo=discord&label=Hydra&labelColor=%231c1c1c)</a>
|
||||
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)
|
||||
![GitHub package.json version](https://img.shields.io/github/package-json/v/hydralauncher/hydra)
|
||||
|
||||
Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.
|
||||
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using [libtorrent](https://www.libtorrent.org/).
|
||||
|
@ -17,6 +17,7 @@ const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
icon: "./images/icon.png",
|
||||
executableName: "Hydra",
|
||||
extraResource: [
|
||||
"./resources/hydra.db",
|
||||
"./resources/icon_tray.png",
|
||||
@ -34,11 +35,17 @@ const config: ForgeConfig = {
|
||||
new MakerSquirrel({
|
||||
setupIcon: "./images/icon.ico",
|
||||
}),
|
||||
new MakerZIP({}, ["darwin"]),
|
||||
new MakerRpm({}),
|
||||
new MakerZIP({}, ["darwin", "linux"]),
|
||||
new MakerRpm({
|
||||
options: {
|
||||
mimeType: ["x-scheme-handler/hydralauncher"],
|
||||
bin: "./Hydra",
|
||||
},
|
||||
}),
|
||||
new MakerDeb({
|
||||
options: {
|
||||
mimeType: ["x-scheme-handler/hydralauncher"],
|
||||
bin: "./Hydra",
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
19174
package-lock.json
generated
19174
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "hydra",
|
||||
"productName": "Hydra",
|
||||
"version": "1.0.0+steamdb-rotation",
|
||||
"version": "1.0.1",
|
||||
"description": "No bullshit. Just play.",
|
||||
"main": ".webpack/main",
|
||||
"repository": {
|
||||
@ -16,7 +16,8 @@
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"format": "prettier . --write"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.3.0",
|
||||
@ -43,6 +44,7 @@
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"@vanilla-extract/webpack-plugin": "^2.3.7",
|
||||
"@vercel/webpack-asset-relocator-loader": "1.7.3",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"css-loader": "^6.0.0",
|
||||
"dotenv-webpack": "^8.1.0",
|
||||
"electron": "29.1.4",
|
||||
@ -52,25 +54,26 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"fork-ts-checker-webpack-plugin": "^7.2.13",
|
||||
"node-loader": "^2.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"style-loader": "^3.0.0",
|
||||
"ts-loader": "^9.2.2",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"typescript": "^5.4.3"
|
||||
"typescript": "^5.4.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource/fira-mono": "^5.0.12",
|
||||
"@fontsource/fira-sans": "^5.0.19",
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"@primer/octicons-react": "^19.8.0",
|
||||
"@reduxjs/toolkit": "^2.2.2",
|
||||
"@vanilla-extract/css": "^1.14.1",
|
||||
"@vanilla-extract/recipes": "^0.5.2",
|
||||
"@vanilla-extract/vite-plugin": "^4.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"axios": "^1.6.8",
|
||||
"check-disk-space": "^3.4.0",
|
||||
"classnames": "^2.5.1",
|
||||
@ -92,12 +95,10 @@
|
||||
"react-redux": "^9.1.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"systeminformation": "^5.22.3",
|
||||
"typeorm": "^0.3.20",
|
||||
"update-electron-app": "^3.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"winston": "^3.12.0"
|
||||
"winston": "^3.12.0",
|
||||
"yaml": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
@ -58,7 +58,10 @@
|
||||
"release_date": "Released in {{date}}",
|
||||
"publisher": "Published by {{publisher}}",
|
||||
"copy_link_to_clipboard": "Copy link",
|
||||
"copied_link_to_clipboard": "Link copied"
|
||||
"copied_link_to_clipboard": "Link copied",
|
||||
"hours": "hours",
|
||||
"minutes": "minutes",
|
||||
"accuracy": "{{accuracy}}% accuracy"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activate Hydra",
|
||||
@ -108,5 +111,10 @@
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "No downloads available"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programs not installed",
|
||||
"description": "Wine or Lutris executables were not found on your system",
|
||||
"instructions": "Check the correct way to install any of them on your Linux distro so that the game can run normally"
|
||||
}
|
||||
}
|
||||
|
@ -1,112 +1,120 @@
|
||||
{
|
||||
"catalogue": {
|
||||
"featured": "Destacado",
|
||||
"recently_added": "Recién Añadidos",
|
||||
"trending": "Tendencias",
|
||||
"surprise_me": "✨ ¡Sorpréndeme!",
|
||||
"no_results": "No se encontraron resultados"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catálogo",
|
||||
"downloads": "Descargas",
|
||||
"settings": "Ajustes",
|
||||
"my_library": "Mi biblioteca",
|
||||
"downloading_metadata": "{{title}} (Descargando metadatos…)",
|
||||
"checking_files": "{{title}} ({{percentage}} - Analizando archivos…)",
|
||||
"paused": "{{title}} (Pausado)",
|
||||
"downloading": "{{title}} ({{percentage}} - Descargando…)",
|
||||
"filter": "Filtrar biblioteca"
|
||||
},
|
||||
"header": {
|
||||
"search": "Buscar",
|
||||
"catalogue": "Catálogo",
|
||||
"downloads": "Descargas",
|
||||
"search_results": "Resultados de búsqueda",
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Sin descargas en progreso",
|
||||
"downloading_metadata": "Descargando metadatos de {{title}}…",
|
||||
"checking_files": "Analizando archivos de {{title}} - ({{percentage}} completado)",
|
||||
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
|
||||
"deleting": "Eliminando archivos…"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Ver opciones de descargas",
|
||||
"download_options_zero": "No hay opciones de descargas disponibles",
|
||||
"download_options_one": "{{count}} opción de descarga",
|
||||
"download_options_other": "{{count}} opciones de descargas",
|
||||
"updated_at": "Actualizado el {{updated_at}}",
|
||||
"launch": "Iniciar",
|
||||
"resume": "Continuar",
|
||||
"pause": "Pausa",
|
||||
"cancel": "Cancelar",
|
||||
"remove": "Eliminar",
|
||||
"space_left_on_disk": "{{space}} restantes en el disco",
|
||||
"eta": "Finalizando {{eta}}",
|
||||
"downloading_metadata": "Descargando metadatos…",
|
||||
"checking_files": "Analizando archivos…",
|
||||
"filter": "Filtrar repacks",
|
||||
"requirements": "Requisitos del Sistema",
|
||||
"minimum": "Mínimos",
|
||||
"recommended": "Recomendados",
|
||||
"no_minimum_requirements": "Sin requisitos mínimos para {{title}}",
|
||||
"no_recommended_requirements": "{{title}} no tiene requisitos recomendados",
|
||||
"paused_progress": "{{progress}} (Pausado)",
|
||||
"deleting": "Eliminando archivos…",
|
||||
"delete": "Eliminar todos los archivos",
|
||||
"release_date": "Fecha de lanzamiento {{date}}",
|
||||
"publisher": "Publicado por {{publisher}}",
|
||||
"copy_link_to_clipboard": "Copiar enlace",
|
||||
"copied_link_to_clipboard": "Enlace copiado"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activar Hydra",
|
||||
"installation_id": "ID de la Instalación:",
|
||||
"enter_activation_code": "Introduce tu código de activación",
|
||||
"message": "Si no sabes donde obtener el código, no deberías de tener esto.",
|
||||
"activate": "Activar",
|
||||
"loading": "Cargando…"
|
||||
},
|
||||
"downloads": {
|
||||
"launch": "Iniciar",
|
||||
"resume": "Resumir",
|
||||
"pause": "Pausa",
|
||||
"eta": "Finalizando {{eta}}",
|
||||
"paused": "En Pausa",
|
||||
"verifying": "Verificando…",
|
||||
"completed_at": "Completado el {{date}}",
|
||||
"completed": "Completado",
|
||||
"cancelled": "Cancelado",
|
||||
"download_again": "Descargar de nuevo",
|
||||
"cancel": "Cancelar",
|
||||
"filter": "Buscar juegos descargados",
|
||||
"remove": "Eliminar",
|
||||
"downloading_metadata": "Descargando metadatos…",
|
||||
"checking_files": "Verificando archivos…",
|
||||
"starting_download": "Iniciando descarga…",
|
||||
"deleting": "Eliminando archivos…",
|
||||
"delete": "Eliminar todos los archivos"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Ruta de descarga",
|
||||
"change": "Cambiar",
|
||||
"notifications": "Notificaciones",
|
||||
"enable_download_notifications": "Cuando se completa una descarga",
|
||||
"enable_repack_list_notifications": "Cuando se añade un repack nuevo"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descarga completada",
|
||||
"game_ready_to_install": "{{title}} está listo para instalarse",
|
||||
"repack_list_updated": "Lista de repacks actualizadas",
|
||||
"repack_count_one": "{{count}} repack ha sido añadido",
|
||||
"repack_count_other": "{{count}} repacks añadidos"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
"quit": "Salir"
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "No hay descargas disponibles"
|
||||
}
|
||||
}
|
||||
{
|
||||
"catalogue": {
|
||||
"featured": "Destacado",
|
||||
"recently_added": "Recién Añadidos",
|
||||
"trending": "Tendencias",
|
||||
"surprise_me": "✨ ¡Sorpréndeme!",
|
||||
"no_results": "No se encontraron resultados"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catálogo",
|
||||
"downloads": "Descargas",
|
||||
"settings": "Ajustes",
|
||||
"my_library": "Mi biblioteca",
|
||||
"downloading_metadata": "{{title}} (Descargando metadatos…)",
|
||||
"checking_files": "{{title}} ({{percentage}} - Analizando archivos…)",
|
||||
"paused": "{{title}} (Pausado)",
|
||||
"downloading": "{{title}} ({{percentage}} - Descargando…)",
|
||||
"filter": "Filtrar biblioteca"
|
||||
},
|
||||
"header": {
|
||||
"search": "Buscar",
|
||||
"catalogue": "Catálogo",
|
||||
"downloads": "Descargas",
|
||||
"search_results": "Resultados de búsqueda",
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Sin descargas en progreso",
|
||||
"downloading_metadata": "Descargando metadatos de {{title}}…",
|
||||
"checking_files": "Analizando archivos de {{title}} - ({{percentage}} completado)",
|
||||
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
|
||||
"deleting": "Eliminando archivos…"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Ver opciones de descargas",
|
||||
"download_options_zero": "No hay opciones de descargas disponibles",
|
||||
"download_options_one": "{{count}} opción de descarga",
|
||||
"download_options_other": "{{count}} opciones de descargas",
|
||||
"updated_at": "Actualizado el {{updated_at}}",
|
||||
"launch": "Iniciar",
|
||||
"resume": "Continuar",
|
||||
"pause": "Pausa",
|
||||
"cancel": "Cancelar",
|
||||
"remove": "Eliminar",
|
||||
"space_left_on_disk": "{{space}} restantes en el disco",
|
||||
"eta": "Finalizando {{eta}}",
|
||||
"downloading_metadata": "Descargando metadatos…",
|
||||
"checking_files": "Analizando archivos…",
|
||||
"filter": "Filtrar repacks",
|
||||
"requirements": "Requisitos del Sistema",
|
||||
"minimum": "Mínimos",
|
||||
"recommended": "Recomendados",
|
||||
"no_minimum_requirements": "Sin requisitos mínimos para {{title}}",
|
||||
"no_recommended_requirements": "{{title}} no tiene requisitos recomendados",
|
||||
"paused_progress": "{{progress}} (Pausado)",
|
||||
"deleting": "Eliminando archivos…",
|
||||
"delete": "Eliminar todos los archivos",
|
||||
"release_date": "Fecha de lanzamiento {{date}}",
|
||||
"publisher": "Publicado por {{publisher}}",
|
||||
"copy_link_to_clipboard": "Copiar enlace",
|
||||
"copied_link_to_clipboard": "Enlace copiado",
|
||||
"hours": "horas",
|
||||
"minutes": "minutos",
|
||||
"accuracy": "{{accuracy}}% precisión"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activar Hydra",
|
||||
"installation_id": "ID de la Instalación:",
|
||||
"enter_activation_code": "Introduce tu código de activación",
|
||||
"message": "Si no sabes donde obtener el código, no deberías de tener esto.",
|
||||
"activate": "Activar",
|
||||
"loading": "Cargando…"
|
||||
},
|
||||
"downloads": {
|
||||
"launch": "Iniciar",
|
||||
"resume": "Resumir",
|
||||
"pause": "Pausa",
|
||||
"eta": "Finalizando {{eta}}",
|
||||
"paused": "En Pausa",
|
||||
"verifying": "Verificando…",
|
||||
"completed_at": "Completado el {{date}}",
|
||||
"completed": "Completado",
|
||||
"cancelled": "Cancelado",
|
||||
"download_again": "Descargar de nuevo",
|
||||
"cancel": "Cancelar",
|
||||
"filter": "Buscar juegos descargados",
|
||||
"remove": "Eliminar",
|
||||
"downloading_metadata": "Descargando metadatos…",
|
||||
"checking_files": "Verificando archivos…",
|
||||
"starting_download": "Iniciando descarga…",
|
||||
"deleting": "Eliminando archivos…",
|
||||
"delete": "Eliminar todos los archivos"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Ruta de descarga",
|
||||
"change": "Cambiar",
|
||||
"notifications": "Notificaciones",
|
||||
"enable_download_notifications": "Cuando se completa una descarga",
|
||||
"enable_repack_list_notifications": "Cuando se añade un repack nuevo"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descarga completada",
|
||||
"game_ready_to_install": "{{title}} está listo para instalarse",
|
||||
"repack_list_updated": "Lista de repacks actualizadas",
|
||||
"repack_count_one": "{{count}} repack ha sido añadido",
|
||||
"repack_count_other": "{{count}} repacks añadidos"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
"quit": "Salir"
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "No hay descargas disponibles"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
111
src/locales/fr/translation.json
Normal file
111
src/locales/fr/translation.json
Normal file
@ -0,0 +1,111 @@
|
||||
{
|
||||
"catalogue": {
|
||||
"featured": "En vedette",
|
||||
"recently_added": "Récemment ajouté",
|
||||
"trending": "Tendance",
|
||||
"surprise_me": "✨ Surprenez-moi"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Catalogue",
|
||||
"downloads": "Téléchargements",
|
||||
"settings": "Paramètres",
|
||||
"my_library": "Ma bibliothèque",
|
||||
"downloading_metadata": "{{title}} (Téléchargement des métadonnées…)",
|
||||
"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"
|
||||
},
|
||||
"header": {
|
||||
"search": "Recherche",
|
||||
"catalogue": "Catalogue",
|
||||
"downloads": "Téléchargements",
|
||||
"search_results": "Résultats de la recherche",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Aucun téléchargement en cours",
|
||||
"downloading_metadata": "Téléchargement des métadonnées de {{title}}…",
|
||||
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} complet)",
|
||||
"downloading": "Téléchargement de {{title}}… ({{percentage}} complet) - Conclusion dans {{eta}} - {{speed}}",
|
||||
"deleting": "Suppression des fichiers…"
|
||||
},
|
||||
"game_details": {
|
||||
"open_download_options": "Ouvrir les options de téléchargement",
|
||||
"download_options_zero": "Aucune option de téléchargement",
|
||||
"download_options_one": "{{count}} option de téléchargement",
|
||||
"download_options_other": "{{count}} options de téléchargement",
|
||||
"updated_at": "Mis à jour le {{updated_at}}",
|
||||
"launch": "Lancer",
|
||||
"resume": "Reprendre",
|
||||
"pause": "Pause",
|
||||
"cancel": "Annuler",
|
||||
"remove": "Supprimer",
|
||||
"space_left_on_disk": "{{space}} restant sur le disque",
|
||||
"eta": "Conclusion dans {{eta}}",
|
||||
"downloading_metadata": "Téléchargement des métadonnées en cours…",
|
||||
"checking_files": "Vérification des fichiers…",
|
||||
"filter": "Filtrer les réductions",
|
||||
"requirements": "Configuration requise",
|
||||
"minimum": "Minimum",
|
||||
"recommended": "Recommandée",
|
||||
"no_minimum_requirements": "{{title}} ne fournit pas d'informations sur les exigences minimales",
|
||||
"no_recommended_requirements": "{{title}} ne fournit pas d'informations sur les exigences recommandées",
|
||||
"paused_progress": "{{progress}} (En pause)",
|
||||
"deleting": "Suppression des fichiers…",
|
||||
"delete": "Supprimer tous les fichiers",
|
||||
"release_date": "Sorti le {{date}}",
|
||||
"publisher": "Édité par {{publisher}}",
|
||||
"copy_link_to_clipboard": "Copier le lien",
|
||||
"copied_link_to_clipboard": "Lien copié"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activer Hydra",
|
||||
"installation_id": "ID d'installation :",
|
||||
"enter_activation_code": "Entrez votre code d'activation",
|
||||
"message": "Si vous ne savez pas où demander cela, vous ne devriez pas l'avoir.",
|
||||
"activate": "Activer",
|
||||
"loading": "Chargement en cours…"
|
||||
},
|
||||
"downloads": {
|
||||
"launch": "Lancer",
|
||||
"resume": "Reprendre",
|
||||
"pause": "Pause",
|
||||
"eta": "Conclusion dans {{eta}}",
|
||||
"paused": "En pause",
|
||||
"verifying": "Vérification en cours…",
|
||||
"completed_at": "Terminé en {{date}}",
|
||||
"completed": "Terminé",
|
||||
"cancelled": "Annulé",
|
||||
"download_again": "Télécharger à nouveau",
|
||||
"cancel": "Annuler",
|
||||
"filter": "Filtrer les jeux téléchargés",
|
||||
"remove": "Supprimer",
|
||||
"downloading_metadata": "Téléchargement des métadonnées en cours…",
|
||||
"checking_files": "Vérification des fichiers…",
|
||||
"starting_download": "Démarrage du téléchargement…",
|
||||
"deleting": "Suppression des fichiers…",
|
||||
"delete": "Supprimer tous les fichiers"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Chemin des téléchargements",
|
||||
"change": "Mettre à jour",
|
||||
"notifications": "Notifications",
|
||||
"enable_download_notifications": "Quand un téléchargement est terminé",
|
||||
"enable_repack_list_notifications": "Quand une nouvelle réduction est ajoutée"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Téléchargement terminé",
|
||||
"game_ready_to_install": "{{title}} est prêt à être installé",
|
||||
"repack_list_updated": "Liste de réductions mise à jour",
|
||||
"repack_count_one": "{{count}} réduction ajoutée",
|
||||
"repack_count_other": "{{count}} réductions ajoutées"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Ouvrir Hydra",
|
||||
"quit": "Quitter"
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "Aucun téléchargement disponible"
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
export { default as en } from "./en/translation.json";
|
||||
export { default as pt } from "./pt/translation.json";
|
||||
export { default as es } from "./es/translation.json";
|
||||
export { default as fr } from "./fr/translation.json";
|
||||
|
@ -58,7 +58,10 @@
|
||||
"release_date": "Lançado em {{date}}",
|
||||
"publisher": "Publicado por {{publisher}}",
|
||||
"copy_link_to_clipboard": "Copiar link",
|
||||
"copied_link_to_clipboard": "Link copiado"
|
||||
"copied_link_to_clipboard": "Link copiado",
|
||||
"hours": "horas",
|
||||
"minutes": "minutos",
|
||||
"accuracy": "{{accuracy}}% de precisão"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@ -108,5 +111,10 @@
|
||||
},
|
||||
"game_card": {
|
||||
"no_downloads": "Sem downloads disponíveis"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -1,54 +1,54 @@
|
||||
import { app } from "electron";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const repackersOn1337x = [
|
||||
"DODI",
|
||||
"FitGirl",
|
||||
"0xEMPRESS",
|
||||
"KaOsKrew",
|
||||
"TinyRepacks",
|
||||
] as const;
|
||||
|
||||
export const repackers = [
|
||||
...repackersOn1337x,
|
||||
"Xatab",
|
||||
"CPG",
|
||||
"TinyRepacks",
|
||||
"GOG",
|
||||
] as const;
|
||||
|
||||
export const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
export enum GameStatus {
|
||||
Seeding = "seeding",
|
||||
Downloading = "downloading",
|
||||
Paused = "paused",
|
||||
CheckingFiles = "checking_files",
|
||||
DownloadingMetadata = "downloading_metadata",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
export const defaultDownloadsPath = path.join(os.homedir(), "downloads");
|
||||
|
||||
export const databasePath = path.join(
|
||||
app.getPath("appData"),
|
||||
app.getName(),
|
||||
"hydra.db"
|
||||
);
|
||||
|
||||
export const INSTALLATION_ID_LENGTH = 6;
|
||||
export const ACTIVATION_KEY_MULTIPLIER = 7;
|
||||
import { app } from "electron";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const repackersOn1337x = [
|
||||
"DODI",
|
||||
"FitGirl",
|
||||
"0xEMPRESS",
|
||||
"KaOsKrew",
|
||||
"TinyRepacks",
|
||||
] as const;
|
||||
|
||||
export const repackers = [
|
||||
...repackersOn1337x,
|
||||
"Xatab",
|
||||
"CPG",
|
||||
"TinyRepacks",
|
||||
"GOG",
|
||||
] as const;
|
||||
|
||||
export const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
export enum GameStatus {
|
||||
Seeding = "seeding",
|
||||
Downloading = "downloading",
|
||||
Paused = "paused",
|
||||
CheckingFiles = "checking_files",
|
||||
DownloadingMetadata = "downloading_metadata",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
|
||||
export const defaultDownloadsPath = path.join(os.homedir(), "downloads");
|
||||
|
||||
export const databasePath = path.join(
|
||||
app.getPath("appData"),
|
||||
app.getName(),
|
||||
"hydra.db"
|
||||
);
|
||||
|
||||
export const INSTALLATION_ID_LENGTH = 6;
|
||||
export const ACTIVATION_KEY_MULTIPLIER = 7;
|
||||
|
25
src/main/events/catalogue/get-how-long-to-beat.ts
Normal file
25
src/main/events/catalogue/get-how-long-to-beat.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { GameShop, HowLongToBeatCategory } from "@types";
|
||||
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getHowLongToBeat = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
_shop: GameShop,
|
||||
title: string
|
||||
): Promise<HowLongToBeatCategory[] | null> => {
|
||||
const response = await searchHowLongToBeat(title);
|
||||
const game = response.data.find(
|
||||
(game) => game.profile_steam === Number(objectID)
|
||||
);
|
||||
|
||||
if (!game) return null;
|
||||
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
|
||||
return howLongToBeat;
|
||||
};
|
||||
|
||||
registerEvent(getHowLongToBeat, {
|
||||
name: "getHowLongToBeat",
|
||||
memoize: true,
|
||||
});
|
@ -20,6 +20,7 @@ import "./misc/show-open-dialog";
|
||||
import "./library/remove-game";
|
||||
import "./library/delete-game-folder";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/get-how-long-to-beat";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
ipcMain.handle("getVersion", () => app.getVersion());
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { generateYML } from "../misc/generate-lutris-yaml";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { spawnSync, exec } from "node:child_process";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { shell } from "electron";
|
||||
@ -12,25 +15,42 @@ const openGame = async (
|
||||
) => {
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
|
||||
if (!game) return;
|
||||
if (!game) return true;
|
||||
|
||||
const gamePath = path.join(
|
||||
game.downloadPath ?? (await getDownloadsPath()),
|
||||
game.folderName
|
||||
);
|
||||
|
||||
if (fs.existsSync(gamePath)) {
|
||||
const setupPath = path.join(gamePath, "setup.exe");
|
||||
if (fs.existsSync(setupPath)) {
|
||||
shell.openExternal(setupPath);
|
||||
} else {
|
||||
shell.openPath(gamePath);
|
||||
}
|
||||
} else {
|
||||
await gameRepository.delete({
|
||||
id: gameId,
|
||||
});
|
||||
if (!fs.existsSync(gamePath)) {
|
||||
await gameRepository.delete({ id: gameId });
|
||||
return true;
|
||||
}
|
||||
|
||||
const setupPath = path.join(gamePath, "setup.exe");
|
||||
if (!fs.existsSync(setupPath)) {
|
||||
shell.openPath(gamePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
shell.openExternal(setupPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (spawnSync("which", ["lutris"]).status === 0) {
|
||||
const ymlPath = path.join(gamePath, "setup.yml");
|
||||
await writeFile(ymlPath, generateYML(game));
|
||||
exec(`lutris --install "${ymlPath}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (spawnSync("which", ["wine"]).status === 0) {
|
||||
exec(`wine "${setupPath}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
registerEvent(openGame, {
|
||||
|
44
src/main/events/misc/generate-lutris-yaml.ts
Normal file
44
src/main/events/misc/generate-lutris-yaml.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Document as YMLDocument } from "yaml";
|
||||
import { Game } from "@main/entity";
|
||||
import path from "node:path";
|
||||
|
||||
export const generateYML = (game: Game) => {
|
||||
const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase();
|
||||
|
||||
const doc = new YMLDocument({
|
||||
name: game.title,
|
||||
game_slug: slugifiedGameTitle,
|
||||
slug: `${slugifiedGameTitle}-installer`,
|
||||
version: "Installer",
|
||||
runner: "wine",
|
||||
script: {
|
||||
game: {
|
||||
prefix: "$GAMEDIR",
|
||||
arch: "win64",
|
||||
working_dir: "$GAMEDIR",
|
||||
},
|
||||
installer: [
|
||||
{
|
||||
task: {
|
||||
name: "create_prefix",
|
||||
arch: "win64",
|
||||
prefix: "$GAMEDIR",
|
||||
},
|
||||
},
|
||||
{
|
||||
task: {
|
||||
executable: path.join(
|
||||
game.downloadPath,
|
||||
game.folderName,
|
||||
"setup.exe"
|
||||
),
|
||||
name: "wineexec",
|
||||
prefix: "$GAMEDIR",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return doc.toString();
|
||||
};
|
@ -1,12 +1,14 @@
|
||||
/* String formatting */
|
||||
|
||||
export const removeReleaseYearFromName = (name: string) => name;
|
||||
export const removeReleaseYearFromName = (name: string) =>
|
||||
name.replace(/\([0-9]{4}\)/g, "");
|
||||
|
||||
export const removeSymbolsFromName = (name: string) => name;
|
||||
export const removeSymbolsFromName = (name: string) =>
|
||||
name.replace(/[^A-Za-z 0-9]/g, "");
|
||||
|
||||
export const removeSpecialEditionFromName = (name: string) =>
|
||||
name.replace(
|
||||
/(The |Digital )?(Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited) Edition/g,
|
||||
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
|
||||
""
|
||||
);
|
||||
|
||||
|
@ -14,17 +14,24 @@ export interface AlgoliaSearchParams {
|
||||
}
|
||||
|
||||
export const getSteamDBAlgoliaCredentials = async () => {
|
||||
const searchParams = new URLSearchParams({
|
||||
t: new Date().getTime().toString(),
|
||||
});
|
||||
|
||||
const js = await requestWebPage(
|
||||
"https://steamdb.info/static/js/instantsearch.js"
|
||||
`https://steamdb.info/static/js/instantsearch.js?${searchParams.toString()}`
|
||||
);
|
||||
|
||||
const algoliaCredentialsRegExp = new RegExp(
|
||||
/algoliasearch\("(.*?)","(.*?)"\);/
|
||||
/algoliasearch\("(.*?)",atob\("(.*?)"\)\);/
|
||||
);
|
||||
|
||||
const [, applicationId, apiKey] = algoliaCredentialsRegExp.exec(js);
|
||||
const [, applicationId, encodedApiKey] = algoliaCredentialsRegExp.exec(js);
|
||||
|
||||
return { applicationId, apiKey };
|
||||
return {
|
||||
applicationId,
|
||||
apiKey: Buffer.from(encodedApiKey, "base64").toString("utf-8"),
|
||||
};
|
||||
};
|
||||
|
||||
export const searchAlgolia = async <T>(
|
||||
|
60
src/main/services/how-long-to-beat.ts
Normal file
60
src/main/services/how-long-to-beat.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { formatName } from "@main/helpers";
|
||||
import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { requestWebPage } from "./repack-tracker/helpers";
|
||||
import { HowLongToBeatCategory } from "@types";
|
||||
|
||||
export interface HowLongToBeatResult {
|
||||
game_id: number;
|
||||
profile_steam: number;
|
||||
}
|
||||
|
||||
export interface HowLongToBeatSearchResponse {
|
||||
data: HowLongToBeatResult[];
|
||||
}
|
||||
|
||||
export const searchHowLongToBeat = async (gameName: string) => {
|
||||
const response = await axios.post(
|
||||
"https://howlongtobeat.com/api/search",
|
||||
{
|
||||
searchType: "games",
|
||||
searchTerms: formatName(gameName).split(" "),
|
||||
searchPage: 1,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
Referer: "https://howlongtobeat.com/",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data as HowLongToBeatSearchResponse;
|
||||
};
|
||||
|
||||
export const getHowLongToBeatGame = async (
|
||||
id: string
|
||||
): Promise<HowLongToBeatCategory[]> => {
|
||||
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
|
||||
|
||||
const { window } = new JSDOM(response);
|
||||
const { document } = window;
|
||||
|
||||
const $ul = document.querySelector(".shadow_shadow ul");
|
||||
const $lis = Array.from($ul.children);
|
||||
|
||||
return $lis.map(($li) => {
|
||||
const title = $li.querySelector("h4").textContent;
|
||||
const [, accuracyClassName] = Array.from(($li as HTMLElement).classList);
|
||||
|
||||
const accuracy = accuracyClassName.split("time_").at(1);
|
||||
|
||||
return {
|
||||
title,
|
||||
duration: $li.querySelector("h5").textContent,
|
||||
accuracy,
|
||||
};
|
||||
});
|
||||
};
|
@ -8,3 +8,4 @@ export * from "./update-resolver";
|
||||
export * from "./window-manager";
|
||||
export * from "./fifo";
|
||||
export * from "./torrent-client";
|
||||
export * from "./how-long-to-beat";
|
||||
|
@ -35,12 +35,14 @@ export interface TorrentUpdate {
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
|
||||
export const BITTORRENT_PORT = "5881";
|
||||
|
||||
export class TorrentClient {
|
||||
public static startTorrentClient(
|
||||
writePipePath: string,
|
||||
readPipePath: string
|
||||
) {
|
||||
const commonArgs = ["6881", writePipePath, readPipePath];
|
||||
const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
|
||||
|
||||
if (app.isPackaged) {
|
||||
const binaryName = binaryNameByPlatform[process.platform];
|
||||
|
@ -16,6 +16,8 @@ export class WindowManager {
|
||||
this.mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 720,
|
||||
minWidth: 1024,
|
||||
minHeight: 540,
|
||||
titleBarStyle: "hidden",
|
||||
icon: path.join(__dirname, "..", "..", "images", "icon.png"),
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
|
@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
||||
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
|
||||
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||
|
@ -12,15 +12,12 @@ import {
|
||||
import * as styles from "./app.css";
|
||||
import { themeClass } from "./theme.css";
|
||||
|
||||
import debounce from "lodash/debounce";
|
||||
import type { DebouncedFunc } from "lodash";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
setSearch,
|
||||
clearSearch,
|
||||
setUserPreferences,
|
||||
setRepackersFriendlyNames,
|
||||
setSearchResults,
|
||||
} from "@renderer/features";
|
||||
|
||||
document.body.classList.add(themeClass);
|
||||
@ -36,8 +33,6 @@ export function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);
|
||||
|
||||
const search = useAppSelector((state) => state.search.value);
|
||||
|
||||
useEffect(() => {
|
||||
@ -72,24 +67,15 @@ export function App() {
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
dispatch(setSearch(query));
|
||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||
|
||||
if (query === "") {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (location.pathname !== "/search") {
|
||||
navigate("/search");
|
||||
}
|
||||
|
||||
debouncedFunc.current = debounce(() => {
|
||||
window.electron.searchGames(query).then((results) => {
|
||||
dispatch(setSearchResults(results));
|
||||
});
|
||||
}, 300);
|
||||
|
||||
debouncedFunc.current();
|
||||
navigate(`/search/${query}`, {
|
||||
replace: location.pathname.startsWith("/search"),
|
||||
});
|
||||
},
|
||||
[dispatch, location.pathname, navigate]
|
||||
);
|
||||
|
@ -1,7 +1,24 @@
|
||||
import type { ComplexStyleRule } from "@vanilla-extract/css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { keyframes, 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 slideIn = keyframes({
|
||||
"0%": { transform: "translateX(20px)", opacity: "0" },
|
||||
"100%": {
|
||||
transform: "translateX(0)",
|
||||
opacity: "1",
|
||||
},
|
||||
});
|
||||
|
||||
export const slideOut = keyframes({
|
||||
"0%": { transform: "translateX(0px)", opacity: "1" },
|
||||
"100%": {
|
||||
transform: "translateX(20px)",
|
||||
opacity: "0",
|
||||
},
|
||||
});
|
||||
|
||||
export const header = recipe({
|
||||
base: {
|
||||
@ -83,9 +100,49 @@ export const actionButton = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const leftContent = style({
|
||||
export const section = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
export const backButton = recipe({
|
||||
base: {
|
||||
color: vars.color.bodyText,
|
||||
cursor: "pointer",
|
||||
WebkitAppRegion: "no-drag",
|
||||
position: "absolute",
|
||||
transition: "transform ease 0.2s",
|
||||
animationDuration: "0.2s",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
} as ComplexStyleRule,
|
||||
variants: {
|
||||
enabled: {
|
||||
true: {
|
||||
animationName: slideIn,
|
||||
},
|
||||
false: {
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
animationName: slideOut,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const title = recipe({
|
||||
base: {
|
||||
transition: "all ease 0.2s",
|
||||
},
|
||||
variants: {
|
||||
hasBackButton: {
|
||||
true: {
|
||||
transform: "translateX(28px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
|
||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||
|
||||
@ -17,32 +17,33 @@ export interface HeaderProps {
|
||||
const pathTitle: Record<string, string> = {
|
||||
"/": "catalogue",
|
||||
"/downloads": "downloads",
|
||||
"/search": "search_results",
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||
(state) => state.window
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||
|
||||
return t(pathTitle[location.pathname]);
|
||||
}, [location.pathname, headerTitle, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search && location.pathname !== "/search") {
|
||||
if (search && !location.pathname.startsWith("/search")) {
|
||||
dispatch(clearSearch());
|
||||
}
|
||||
}, [location.pathname, search, dispatch]);
|
||||
@ -56,6 +57,10 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={styles.header({
|
||||
@ -63,9 +68,26 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
isWindows: window.electron.platform === "win32",
|
||||
})}
|
||||
>
|
||||
<h3>{title}</h3>
|
||||
<div className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton({ enabled: location.key !== "default" })}
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={location.key === "default"}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
|
||||
<section className={styles.leftContent}>
|
||||
<h3
|
||||
className={styles.title({
|
||||
hasBackButton: location.key !== "default",
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.search({ focused: isFocused })}>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -6,7 +6,7 @@ import { ShopDetails } from "@types";
|
||||
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const FEATURED_GAME_ID = "1144200";
|
||||
const FEATURED_GAME_ID = "1245620";
|
||||
|
||||
export function Hero() {
|
||||
const [featuredGameDetails, setFeaturedGameDetails] =
|
||||
|
@ -115,6 +115,12 @@ export function Sidebar() {
|
||||
return game.title;
|
||||
};
|
||||
|
||||
const handleSidebarItemClick = (path: string) => {
|
||||
if (path !== location.pathname) {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
@ -146,7 +152,7 @@ export function Sidebar() {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={() => navigate(path)}
|
||||
onClick={() => handleSidebarItemClick(path)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(nameKey)}</span>
|
||||
@ -179,7 +185,9 @@ export function Sidebar() {
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={() =>
|
||||
navigate(`/game/${game.shop}/${game.objectID}`)
|
||||
handleSidebarItemClick(
|
||||
`/game/${game.shop}/${game.objectID}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<AsyncImage className={styles.gameIcon} src={game.iconUrl} />
|
||||
|
8
src/renderer/declaration.d.ts
vendored
8
src/renderer/declaration.d.ts
vendored
@ -6,6 +6,7 @@ import type {
|
||||
TorrentProgress,
|
||||
ShopDetails,
|
||||
UserPreferences,
|
||||
HowLongToBeatCategory,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
@ -39,11 +40,16 @@ declare global {
|
||||
language: string
|
||||
) => Promise<ShopDetails | null>;
|
||||
getRandomGame: () => Promise<string>;
|
||||
getHowLongToBeat: (
|
||||
objectID: string,
|
||||
shop: GameShop,
|
||||
title: string
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
|
||||
/* Library */
|
||||
getLibrary: () => Promise<Game[]>;
|
||||
getRepackersFriendlyNames: () => Promise<Record<string, string>>;
|
||||
openGame: (gameId: number) => Promise<void>;
|
||||
openGame: (gameId: number) => Promise<boolean>;
|
||||
removeGame: (gameId: number) => Promise<void>;
|
||||
deleteGameFolder: (gameId: number) => Promise<unknown>;
|
||||
getGameByObjectID: (objectID: string) => Promise<Game | null>;
|
||||
|
@ -1,18 +1,12 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
interface SearchState {
|
||||
value: string;
|
||||
results: CatalogueEntry[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const initialState: SearchState = {
|
||||
value: "",
|
||||
results: [],
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export const searchSlice = createSlice({
|
||||
@ -20,19 +14,12 @@ export const searchSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.isLoading = true;
|
||||
state.value = action.payload;
|
||||
},
|
||||
clearSearch: (state) => {
|
||||
state.value = "";
|
||||
state.results = [];
|
||||
state.isLoading = false;
|
||||
},
|
||||
setSearchResults: (state, action: PayloadAction<CatalogueEntry[]>) => {
|
||||
state.isLoading = false;
|
||||
state.results = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setSearch, clearSearch, setSearchResults } = searchSlice.actions;
|
||||
export const { setSearch, clearSearch } = searchSlice.actions;
|
||||
|
@ -20,5 +20,6 @@ export const formatDownloadProgress = (progress?: number) => {
|
||||
export const getSteamLanguage = (language: string) => {
|
||||
if (language.startsWith("pt")) return "brazilian";
|
||||
if (language.startsWith("es")) return "spanish";
|
||||
if (language.startsWith("fr")) return "french";
|
||||
return "english";
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { addMilliseconds, formatDistance } from "date-fns";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
|
||||
import { ptBR, enUS, es } from "date-fns/locale";
|
||||
import { ptBR, enUS, es, fr } from "date-fns/locale";
|
||||
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -71,6 +71,7 @@ export function useDownload() {
|
||||
const getDateLocale = (language: string) => {
|
||||
if (language.startsWith("pt")) return ptBR;
|
||||
if (language.startsWith("es")) return es;
|
||||
if (language.startsWith("fr")) return fr;
|
||||
return enUS;
|
||||
};
|
||||
|
||||
|
@ -45,7 +45,7 @@ const router = createHashRouter([
|
||||
Component: GameDetails,
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
path: "/search/:query",
|
||||
Component: SearchResults,
|
||||
},
|
||||
{
|
||||
|
@ -3,61 +3,69 @@ import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
import { InboxIcon } from "@primer/octicons-react";
|
||||
import type { DebouncedFunc } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import * as styles from "./catalogue.css";
|
||||
|
||||
export function SearchResults() {
|
||||
const { t } = useTranslation("catalogue");
|
||||
|
||||
const { results, isLoading } = useAppSelector((state) => state.search);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { query } = useParams();
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(`/game/${game.shop}/${game.objectID}`, { replace: true });
|
||||
navigate(`/game/${game.shop}/${game.objectID}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||
|
||||
debouncedFunc.current = debounce(() => {
|
||||
window.electron
|
||||
.searchGames(query)
|
||||
.then((results) => {
|
||||
setSearchResults(results);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
debouncedFunc.current();
|
||||
}, [query, dispatch]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<main className={styles.content}>
|
||||
<section className={styles.cards({ searching: false })}>
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={styles.cardSkeleton}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
<>
|
||||
{results.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectID}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
disabled={!game.repacks.length}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!isLoading && results.length === 0 && (
|
||||
<div className={styles.noResults}>
|
||||
<InboxIcon size={56} />
|
||||
|
||||
<p>{t('no_results')}</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<section className={styles.content}>
|
||||
<section className={styles.cards({ searching: false })}>
|
||||
{isLoading
|
||||
? Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))
|
||||
: searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectID}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
disabled={!game.repacks.length}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import type { Game } from "@types";
|
||||
|
||||
import * as styles from "./downloads.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
|
||||
export function Downloads() {
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
@ -18,6 +19,7 @@ export function Downloads() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
|
||||
const {
|
||||
game: gameDownloading,
|
||||
@ -37,7 +39,8 @@ export function Downloads() {
|
||||
}, [library]);
|
||||
|
||||
const openGame = (gameId: number) =>
|
||||
window.electron.openGame(gameId).then(() => {
|
||||
window.electron.openGame(gameId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
@ -202,6 +205,10 @@ export function Downloads() {
|
||||
|
||||
return (
|
||||
<section className={styles.downloadsContainer}>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
|
||||
<ul className={styles.downloads}>
|
||||
|
@ -65,7 +65,7 @@ export const descriptionContent = style({
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
export const requirements = style({
|
||||
export const contentSidebar = style({
|
||||
borderLeft: `solid 1px ${vars.color.borderColor};`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@ -83,12 +83,13 @@ export const requirements = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const requirementsHeader = style({
|
||||
height: "71px",
|
||||
export const contentSidebarTitle = style({
|
||||
height: "72px",
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: vars.color.background,
|
||||
borderBottom: `solid 1px ${vars.color.borderColor}`,
|
||||
});
|
||||
|
||||
export const requirementButtonContainer = style({
|
||||
@ -105,7 +106,7 @@ export const requirementButton = style({
|
||||
});
|
||||
|
||||
export const requirementsDetails = style({
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
lineHeight: "22px",
|
||||
fontFamily: "'Fira Sans', sans-serif",
|
||||
fontSize: "16px",
|
||||
@ -137,13 +138,42 @@ export const descriptionHeaderInfo = style({
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
});
|
||||
|
||||
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.borderColor}`,
|
||||
});
|
||||
|
||||
export const howLongToBeatCategoryLabel = style({
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
color: "#DADBE1",
|
||||
});
|
||||
|
||||
export const howLongToBeatCategorySkeleton = style({
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
borderRadius: "8px",
|
||||
height: "76px",
|
||||
});
|
||||
|
||||
globalStyle(".bb_tag", {
|
||||
marginTop: `${SPACING_UNIT * 2}px`,
|
||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
globalStyle(`${description} img`, {
|
||||
borderRadius: 5,
|
||||
borderRadius: "5px",
|
||||
marginTop: `${SPACING_UNIT}px`,
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
marginLeft: "auto",
|
||||
|
@ -3,7 +3,13 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import Color from "color";
|
||||
import { average } from "color.js";
|
||||
|
||||
import type { Game, GameShop, ShopDetails, SteamAppDetails } from "@types";
|
||||
import type {
|
||||
Game,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
SteamAppDetails,
|
||||
} from "@types";
|
||||
|
||||
import { AsyncImage, Button } from "@renderer/components";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
@ -15,6 +21,7 @@ import { RepacksModal } from "./repacks-modal";
|
||||
import { HeroPanel } from "./hero-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShareAndroidIcon } from "@primer/octicons-react";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
|
||||
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
|
||||
|
||||
@ -24,6 +31,11 @@ export function GameDetails() {
|
||||
const [color, setColor] = useState("");
|
||||
const [clipboardLock, setClipboardLock] = useState(false);
|
||||
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||
isLoading: boolean;
|
||||
data: HowLongToBeatCategory[] | null;
|
||||
}>({ isLoading: true, data: null });
|
||||
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
@ -67,11 +79,19 @@ export function GameDetails() {
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.getHowLongToBeat(objectID, "steam", result.name)
|
||||
.then((data) => {
|
||||
setHowLongToBeat({ isLoading: false, data });
|
||||
});
|
||||
|
||||
setGameDetails(result);
|
||||
dispatch(setHeaderTitle(result.name));
|
||||
});
|
||||
|
||||
getGame();
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
setClipboardLock(false);
|
||||
}, [getGame, dispatch, navigate, objectID, i18n.language]);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
@ -196,8 +216,17 @@ export function GameDetails() {
|
||||
className={styles.description}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.requirements}>
|
||||
<div className={styles.requirementsHeader}>
|
||||
|
||||
<div className={styles.contentSidebar}>
|
||||
<HowLongToBeatSection
|
||||
howLongToBeatData={howLongToBeat.data}
|
||||
isLoading={howLongToBeat.isLoading}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.contentSidebarTitle}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<h3>{t("requirements")}</h3>
|
||||
</div>
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { format } from "date-fns";
|
||||
@ -9,6 +9,7 @@ import type { Game, ShopDetails } from "@types";
|
||||
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
|
||||
export interface HeroPanelProps {
|
||||
game: Game | null;
|
||||
@ -27,6 +28,8 @@ export function HeroPanel({
|
||||
}: HeroPanelProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
|
||||
const {
|
||||
game: gameDownloading,
|
||||
isDownloading,
|
||||
@ -46,7 +49,8 @@ export function HeroPanel({
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
|
||||
const openGame = (gameId: number) =>
|
||||
window.electron.openGame(gameId).then(() => {
|
||||
window.electron.openGame(gameId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
@ -202,6 +206,10 @@ export function HeroPanel({
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: color }} className={styles.panel}>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
<div className={styles.content}>{getInfo()}</div>
|
||||
<div className={styles.actions}>{getActions()}</div>
|
||||
</div>
|
||||
|
69
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal file
69
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
import type { HowLongToBeatCategory } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import * as styles from "./game-details.css";
|
||||
|
||||
const durationTranslation: Record<string, string> = {
|
||||
Hours: "hours",
|
||||
Mins: "minutes",
|
||||
};
|
||||
|
||||
export interface HowLongToBeatSectionProps {
|
||||
howLongToBeatData: HowLongToBeatCategory[] | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function HowLongToBeatSection({
|
||||
howLongToBeatData,
|
||||
isLoading,
|
||||
}: HowLongToBeatSectionProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const getDuration = (duration: string) => {
|
||||
const [value, unit] = duration.split(" ");
|
||||
return `${value} ${t(durationTranslation[unit])}`;
|
||||
};
|
||||
|
||||
if (!howLongToBeatData && !isLoading) return null;
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<div className={styles.contentSidebarTitle}>
|
||||
<h3>HowLongToBeat</h3>
|
||||
</div>
|
||||
|
||||
<ul className={styles.howLongToBeatCategoriesList}>
|
||||
{howLongToBeatData
|
||||
? howLongToBeatData.map((category) => (
|
||||
<li key={category.title} className={styles.howLongToBeatCategory}>
|
||||
<p
|
||||
className={styles.howLongToBeatCategoryLabel}
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{category.title}
|
||||
</p>
|
||||
|
||||
<p className={styles.howLongToBeatCategoryLabel}>
|
||||
{getDuration(category.duration)}
|
||||
</p>
|
||||
|
||||
{category.accuracy !== "00" && (
|
||||
<small>
|
||||
{t("accuracy", { accuracy: category.accuracy })}
|
||||
</small>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
: Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={styles.howLongToBeatCategorySkeleton}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
25
src/renderer/pages/shared-modals/binary-not-found-modal.tsx
Normal file
25
src/renderer/pages/shared-modals/binary-not-found-modal.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Modal } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BinaryNotFoundModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BinaryNotFoundModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
}: BinaryNotFoundModalProps) => {
|
||||
const { t } = useTranslation("binary_not_found_modal");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
{t("instructions")}
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -102,3 +102,9 @@ export interface UserPreferences {
|
||||
downloadNotificationsEnabled: boolean;
|
||||
repackUpdatesNotificationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface HowLongToBeatCategory {
|
||||
title: string;
|
||||
duration: string;
|
||||
accuracy: string;
|
||||
}
|
||||
|
@ -10,5 +10,7 @@ export const plugins = [
|
||||
}),
|
||||
new Dotenv({
|
||||
path: "./.env",
|
||||
safe: true,
|
||||
systemvars: true,
|
||||
}),
|
||||
];
|
||||
|
@ -1,20 +1,20 @@
|
||||
import type { ModuleOptions } from 'webpack';
|
||||
import type { ModuleOptions } from "webpack";
|
||||
|
||||
export const rules: Required<ModuleOptions>['rules'] = [
|
||||
export const rules: Required<ModuleOptions>["rules"] = [
|
||||
// Add support for native node modules
|
||||
{
|
||||
// We're specifying native_modules in the test because the asset relocator loader generates a
|
||||
// "fake" .node file which is really a cjs file.
|
||||
test: /native_modules[/\\].+\.node$/,
|
||||
use: 'node-loader',
|
||||
use: "node-loader",
|
||||
},
|
||||
{
|
||||
test: /[/\\]node_modules[/\\].+\.(m?js|node)$/,
|
||||
parser: { amd: false },
|
||||
use: {
|
||||
loader: '@vercel/webpack-asset-relocator-loader',
|
||||
loader: "@vercel/webpack-asset-relocator-loader",
|
||||
options: {
|
||||
outputAssetBase: 'native_modules',
|
||||
outputAssetBase: "native_modules",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -22,7 +22,7 @@ export const rules: Required<ModuleOptions>['rules'] = [
|
||||
test: /\.tsx?$/,
|
||||
exclude: /(node_modules|\.webpack)/,
|
||||
use: {
|
||||
loader: 'ts-loader',
|
||||
loader: "ts-loader",
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user