Compare commits

..

110 Commits

Author SHA1 Message Date
Chubby Granny Chaser
0df5486fec fix: fixing how long to beat broken logic 2025-09-30 03:59:08 +01:00
Chubby Granny Chaser
26dfb6db8e feat: new style for sidebar on game page 2025-09-30 01:13:49 +01:00
Chubby Granny Chaser
ae47498139 feat: sync with main 2025-09-30 01:13:48 +01:00
Chubby Granny Chaser
bc5c5da703 Merge pull request #1796 from hydralauncher/feat/revert-title-and-cleanup-fix
Fix: deleted favorite icon from profile game card and fixed the short format appearing
2025-09-30 01:07:08 +01:00
Moyase
5f1026fcd2 Merge branch 'main' into feat/revert-title-and-cleanup-fix 2025-09-30 02:49:54 +03:00
Moyasee
de4b039d10 Fix: deleted favorite icon from profile game card and fixed the media for showing short format of hours 2025-09-30 02:46:58 +03:00
Chubby Granny Chaser
fd4d293e13 Merge pull request #1795 from hydralauncher/feat/revert-title-and-cleanup-fix
Fix: Playtime font size and original path in edit game modal fix
2025-09-30 00:22:56 +01:00
Moyasee
e5646240ab fix: async error function had too many arguments 2025-09-30 02:18:22 +03:00
Moyasee
655de1361b Merge branch 'feat/revert-title-and-cleanup-fix' of https://github.com/hydralauncher/hydra into feat/revert-title-and-cleanup-fix 2025-09-30 02:18:08 +03:00
Moyasee
ceb236c40c fix: async error function had too many arguments 2025-09-30 02:17:17 +03:00
Moyase
570b881c2b Merge branch 'main' into feat/revert-title-and-cleanup-fix 2025-09-30 02:11:43 +03:00
Moyasee
959bed746b fix: original path to image not showing in modal after updating game asset 2025-09-30 02:09:19 +03:00
Moyasee
f0cb2f9579 fix: playtime font-size increased 2025-09-30 01:43:13 +03:00
Moyasee
0f3d6ef76f fix: state fix and remake checking for overlap 2025-09-30 01:18:44 +03:00
Moyase
3301f845f5 Merge pull request #1794 from hydralauncher/feat/revert-title-and-cleanup-fix
Feat: revert title in edit modal and proper cleanup of unused assets
2025-09-30 01:12:00 +03:00
Moyasee
9689c19863 fix: state fix and remake checking for overlap 2025-09-30 00:52:46 +03:00
Moyasee
bd053a1635 fix: favorite and pin button overlapping playtime 2025-09-29 22:04:01 +03:00
Moyasee
a39f9ebb70 fix: multiple imports 2025-09-29 20:39:58 +03:00
Moyasee
7e22344f77 Fix: fixed fs import and started using logger.warn 2025-09-29 20:36:05 +03:00
Moyasee
96d6b90356 Fix: Refactoring functions to reduce complexity 2025-09-29 20:20:58 +03:00
Moyasee
a87e04a366 Fix: using node:fs instead of fs 2025-09-29 20:14:36 +03:00
Moyasee
bd86321d02 fix: using for...of instead of forEach 2025-09-29 20:09:04 +03:00
Moyase
95fca086ba Merge branch 'main' into feat/revert-title-and-cleanup-fix 2025-09-29 19:55:41 +03:00
Moyasee
3e93a14deb Fix: display actual image path in edit game modal 2025-09-29 19:53:52 +03:00
Chubby Granny Chaser
d4b4b25ec8 Merge pull request #1793 from Lianela/main
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
translation: spanish update
2025-09-29 17:40:47 +01:00
Moyasee
2bed7c0b37 fix: cleaned comments and simplified function 2025-09-29 14:55:23 +03:00
Moyasee
f3b4898e9c feat: proper cleanup of unused assets 2025-09-29 14:51:08 +03:00
Lianela | Kyatto
046f6d388a updated spanish translation
i guess being offline for a while was kinda refreshing lol
2025-09-29 02:33:33 -07:00
Moyasee
dfd640ebda feat: add revert title for non-custom games 2025-09-29 00:14:47 +03:00
Chubby Granny Chaser
88ed335fd4 Merge pull request #1786 from hydralauncher/feat/custom-games-support
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Feat: Custom Games Support + Edit game info support
2025-09-28 18:22:24 +01:00
Moyasee
d48f28b3c7 Merge branch 'feat/custom-games-support' of https://github.com/hydralauncher/hydra into feat/custom-games-support 2025-09-28 20:18:03 +03:00
Moyasee
8f30f8a4ad fix: fixed edit game assets button not using translation 2025-09-28 20:17:02 +03:00
Chubby Granny Chaser
7bbca91d9f Merge branch 'feat/custom-games-support' of https://github.com/hydralauncher/hydra into feat/custom-games-support 2025-09-28 18:14:32 +01:00
Chubby Granny Chaser
dceb3a7509 feat: improving visuals on assets 2025-09-28 18:13:08 +01:00
Moyasee
d78631a7f4 Fix: hide pin button if game is custom 2025-09-28 20:08:03 +03:00
Moyasee
889f3bb773 conflicts fix 2025-09-28 17:40:18 +03:00
Moyasee
b62f8c13fc Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/custom-games-support 2025-09-28 17:39:07 +03:00
Chubby Granny Chaser
f552b759cb fix: fixing carousel 2025-09-28 15:17:37 +01:00
Chubby Granny Chaser
83e7f676bf fix: fixing carousel 2025-09-28 15:14:25 +01:00
Chubby Granny Chaser
cb25a55118 Merge pull request #1780 from saraiva1989/feature-add-low-price-and-game-language
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Feature add low price and game language
2025-09-28 01:02:18 +01:00
Chubby Granny Chaser
5d4409525c Merge branch 'main' into feature-add-low-price-and-game-language 2025-09-28 01:01:51 +01:00
Chubby Granny Chaser
92af5c2b23 feat: adding profile sorting 2025-09-28 01:01:06 +01:00
Chubby Granny Chaser
2cebc73789 feat: adding profile sorting 2025-09-28 00:50:51 +01:00
Chubby Granny Chaser
e96cb3228e feat: adding profile sorting 2025-09-28 00:37:22 +01:00
Chubby Granny Chaser
a71293148d Merge pull request #1789 from hydralauncher/feat/pinning-games
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
fix: single pinned game not visible in profile
2025-09-27 16:43:36 +01:00
Chubby Granny Chaser
aa152385b1 Merge branch 'main' into feat/pinning-games 2025-09-27 16:36:49 +01:00
Moyasee
b6be03cea3 fix: formatting issues 2025-09-26 17:00:50 +03:00
Moyasee
f027f05e02 feat: added functionality to collapse/expand pinned list in user profile 2025-09-26 16:54:10 +03:00
Chubby Granny Chaser
56391837cd Merge pull request #1784 from Wkeynhk/patch-2
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Update Russian Translation
2025-09-26 13:34:13 +01:00
Chubby Granny Chaser
31bd523038 Merge branch 'main' into patch-2 2025-09-26 13:18:46 +01:00
Moyasee
fd1f13225b feat: added pin/unpin to the game card in profile 2025-09-25 20:26:49 +03:00
Moyasee
dba4b2c4be Merge branch 'feat/pinning-games' of https://github.com/hydralauncher/hydra into feat/pinning-games 2025-09-25 20:24:25 +03:00
Moyasee
05f10fd80b feat: added pin/unpin to the game card in profile 2025-09-25 20:23:33 +03:00
Moyasee
d59315e322 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/pinning-games 2025-09-25 20:22:10 +03:00
Moyasee
a29f2ba741 feat: added pin/unpin to the game card in profile 2025-09-25 20:20:49 +03:00
Moyase
d33b0099a1 Merge branch 'main' into feat/pinning-games 2025-09-25 18:09:15 +03:00
Moyasee
cad50649aa fix: single pinned game not visible in profile 2025-09-25 18:07:38 +03:00
Moyase
b3148855bc Merge pull request #1788 from hydralauncher/feat/pinning-games
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Feat: Pinning and favoriting games in profile
2025-09-25 16:18:13 +03:00
Moyasee
f56a3ea126 deleted console log from user-profile-context 2025-09-25 15:55:39 +03:00
Moyasee
eea701f046 Fix: updated pinnedDate logic, set default values for pagination, deleted comments, deleted example in user-profile-context 2025-09-25 15:44:11 +03:00
Moyasee
355d38c0a2 fix: lint failing fix because of custom type 2025-09-25 14:53:34 +03:00
Moyasee
366da7839f feat: hide pin button if game is custom 2025-09-25 14:41:30 +03:00
Moyasee
a869902cfb fix: medium issues fix 2025-09-25 14:41:05 +03:00
Moyasee
f9d51ed33d fix: linting error. Feat: hide pin button if user is not logged in 2025-09-25 14:38:14 +03:00
Moyasee
092af7e421 feat:profile endpoint change and complete pinning functionality 2025-09-25 14:38:14 +03:00
Moyasee
33c15baf0e feat: pinning and showing featuring games in profile 2025-09-25 14:35:39 +03:00
Moyasee
eeed34adcb lint failing fix 2025-09-20 09:21:28 +03:00
Moyasee
393a04d7c3 Complete fix for non-native interactive elements 2025-09-19 22:39:08 +03:00
Moyasee
de4119988c avoiding non-native interactive elements + extracted ternary operation 2025-09-19 22:30:16 +03:00
Moyasee
e0496b6449 added drag and drop functionality and recommended resolution to each image 2025-09-19 22:23:36 +03:00
Moyasee
607bc6407c fixed assertions + use ?? operators 2025-09-19 21:16:46 +03:00
Moyasee
de70beb01e Preload images for non-custom games. Added ability to restore images to default if game is non-custom 2025-09-19 21:06:15 +03:00
Moyasee
9f4fd0ce61 added proper image saving for custom games + edited game settings to hide buttons if game is custom 2025-09-19 20:46:53 +03:00
Moyasee
bf9e3de0b5 fixed background color in hero arent being properly used if game is custom 2025-09-19 20:04:09 +03:00
Moyasee
9ce1c40b21 redirect to home page when removing custom game from library 2025-09-19 19:48:05 +03:00
Daniel Saraiva
78358d212b Merge branch 'main' into feature-add-low-price-and-game-language 2025-09-19 13:20:18 -03:00
Moyasee
ea792d0409 deleted duplicate file, fixed assertions 2025-09-19 17:56:44 +03:00
Moyasee
04be5c754e Changed props to be read-only, started using coalescing operator 2025-09-19 17:43:21 +03:00
Moyasee
ab163ffa39 reductant assignments fix + extracted operation into independent statement 2025-09-19 17:35:11 +03:00
Moyasee
501ca7190e Redundant assignments fix 2025-09-19 17:28:39 +03:00
Moyasee
14fc764328 refactoring functions 2025-09-19 17:24:39 +03:00
Moyasee
672ddff9f8 security fix 2025-09-19 17:15:59 +03:00
Moyasee
f4e84e46cc feat: custom game support/game info changing 2025-09-19 16:58:58 +03:00
Moyasee
3409b53268 Feat: Custom Games 2025-09-19 16:22:12 +03:00
Moyasee
7e59e02d03 Feat: Custom Games 2025-09-19 16:18:49 +03:00
Wkeynhk
a7e4e21167 Update translation.json 2025-09-19 08:21:41 +03:00
Wkeynhk
b22e082781 Update translation.json 2025-09-18 22:56:30 +03:00
Wkeynhk
c056feb26f Update translation.json 2025-09-18 22:24:32 +03:00
Wkeynhk
240b0705d5 Update translation.json 2025-09-18 22:06:19 +03:00
Chubby Granny Chaser
2604dfea22 Merge pull request #1783 from hydralauncher/feat/playtime-changing
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Feat: added warning to the hero-panel-playtime
2025-09-17 12:27:39 +01:00
Moyase
0adea20565 Merge branch 'main' into feat/playtime-changing 2025-09-17 13:57:10 +03:00
Moyasee
f182c7c8e9 Feat: added warning to the hero-panel-playtime 2025-09-17 13:49:05 +03:00
Chubby Granny Chaser
d9379fbcb9 Merge pull request #1782 from hydralauncher/feat/playtime-changing
Feature: Playtime Changing
2025-09-17 10:53:38 +01:00
Moyasee
adc4af731e Fix: Fixed import errors in change-game-playtime.ts 2025-09-17 12:41:41 +03:00
Moyasee
af1b3d4535 Fix: Updated EN and RU translations to use correct keywords 2025-09-17 12:35:46 +03:00
Moyasee
291935a1bc Fix: Changed ChangeGamePlaytimeModalProps to Readonly 2025-09-17 12:20:52 +03:00
Moyasee
665365abdc Deleted logs, comments. Fixed change-game-playtime event. 2025-09-17 12:14:51 +03:00
Moyasee
86da92aa3f Feat: Added changing game playtime functionality 2025-09-17 11:24:24 +03:00
Moyasee
6ff694c078 Feat: Added changing game playtime functionality 2025-09-17 11:17:55 +03:00
Daniel Saraiva
a546b906e9 feat: adjustment in hard code text to use translation constant 2025-09-10 09:04:29 -03:00
Daniel Saraiva
85b40b9a7e added example environment variable 2025-09-07 12:41:52 -03:00
Daniel Saraiva
302ed92018 feat: Added the game's supported languages. Added the game's lowest historical price and a link to see the price in various stores (keyshop and official store) 2025-09-07 12:37:49 -03:00
Daniel Saraiva
7e5cef6e44 feat: Added the game's supported languages. Added the game's lowest historical price and a link to see the price in various stores (keyshop and official store) 2025-09-07 12:36:50 -03:00
Zamitto
01ac5239dc fix: steam user path not found
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-06 12:34:07 -03:00
Zamitto
1dc2176813 chore: bump version
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-03 06:52:44 -03:00
Zamitto
a7ec632a21 fix: window not opening sometimes when clicking on tray 2025-09-03 06:49:26 -03:00
Zamitto
2b6d8eba78 fix: window not opening when clicking on tray icon on linux 2025-09-03 06:05:02 -03:00
Zamitto
6bc6a10d66 chore: testing aur package pipeline
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-02 06:26:28 -03:00
Zamitto
51f8b12e13 chore: bump version 2025-09-02 05:59:34 -03:00
Zamitto
957a6b512e fix: handle case where steam is not installed 2025-09-02 05:59:15 -03:00
117 changed files with 4861 additions and 981 deletions

View File

@@ -1,5 +1,5 @@
MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_API_URL=
MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.6.5",
"version": "3.6.8",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -54,6 +54,8 @@
"diskusage": "^1.2.0",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0",
"framer-motion": "^12.15.0",
"i18next": "^23.11.2",
@@ -63,6 +65,8 @@
"lodash-es": "^4.17.21",
"parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "تم تسجيل الدخول بنجاح"
},
"home": {
"featured": "مميز",
"surprise_me": "مفاجئني",
"no_results": "لم يتم العثور على نتائج",
"start_typing": "ابدأ بالكتابة للبحث...",

View File

@@ -1,7 +1,6 @@
{
"language_name": "беларуская мова",
"home": {
"featured": "Рэкамэндаванае",
"surprise_me": "Здзіві мяне",
"no_results": "Няма вынікаў"
},
@@ -17,7 +16,6 @@
"home": "Галоўная",
"favorites": "Улюбленыя"
},
"header": {
"search": "Пошук",
"home": "Галоўная",
@@ -31,10 +29,7 @@
"downloading_metadata": "Сцягванне мэтаданых {{title}}…",
"downloading": "Сцягванне {{title}}… ({{percentage}} скончана) - Канчатак {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Наступная старонка",
"previous_page": "Папярэдняя старонка"
},
"catalogue": {},
"game_details": {
"open_download_options": "Адкрыць варыянты сцягвання",
"download_options_zero": "Няма варыянтаў сцягвання",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Успешно влизане"
},
"home": {
"featured": "Препоръчани",
"surprise_me": "Изненадай ме",
"no_results": "Няма намерени резултати",
"start_typing": "Започнете да пишете за търсене...",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Has entrat correctament"
},
"home": {
"featured": "Destacats",
"surprise_me": "Sorprèn-me",
"no_results": "No s'ha trobat res"
},
@@ -25,7 +24,6 @@
},
"header": {
"search": "Cerca jocs",
"home": "Inici",
"catalogue": "Catàleg",
"downloads": "Baixades",
@@ -41,10 +39,7 @@
"calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…",
"checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)"
},
"catalogue": {
"next_page": "Pàgina següent",
"previous_page": "Pàgina anterior"
},
"catalogue": {},
"game_details": {
"open_download_options": "Obre les opcions de baixada",
"download_options_zero": "No hi ha opcions de baixada",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Úspěšně přihlášen"
},
"home": {
"featured": "Doporučené",
"surprise_me": "Překvap mě",
"no_results": "Výsledek nenalezen",
"start_typing": "Začni psát pro vyhledávání...",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Loggede ind successfuldt"
},
"home": {
"featured": "Anbefalet",
"surprise_me": "Overrask mig",
"no_results": "Ingen resultater fundet",
"start_typing": "Begynd at skrive for at søge...",
@@ -29,7 +28,6 @@
},
"header": {
"search": "Søg efter spil",
"home": "Hjem",
"catalogue": "Katalog",
"downloads": "Downloads",
@@ -45,10 +43,7 @@
"calculating_eta": "Downloader {{title}}… ({{percentage}} færdig) - Udregner resterende tid…",
"checking_files": "Checker {{title}} filer… ({{percentage}} færdig)"
},
"catalogue": {
"next_page": "Næste side",
"previous_page": "Forrige side"
},
"catalogue": {},
"game_details": {
"open_download_options": "Åben download muligheder",
"download_options_zero": "Ingen download mulighed",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Erfolgreich angemeldet"
},
"home": {
"featured": "Empfohlen",
"surprise_me": "Überrasche mich",
"no_results": "Keine Ergebnisse gefunden",
"start_typing": "Tippe, um zu suchen...",
@@ -59,9 +58,7 @@
"download_sources": "Download-Quellen",
"result_count": "{{resultCount}} Ergebnisse",
"filter_count": "{{filterCount}} verfügbar",
"clear_filters": "{{filterCount}} ausgewählte löschen",
"next_page": "Nächste Seite",
"previous_page": "Vorherige Seite"
"clear_filters": "{{filterCount}} ausgewählte löschen"
},
"game_details": {
"open_download_options": "Download-Optionen öffnen",

83
src/locales/en/translation.json Normal file → Executable file
View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Successfully signed in"
},
"home": {
"featured": "Featured",
"surprise_me": "Surprise me",
"no_results": "No results found",
"start_typing": "Starting typing to search...",
@@ -28,7 +27,50 @@
"friends": "Friends",
"need_help": "Need help?",
"favorites": "Favorites",
"playable_button_title": "Show only games you can play now"
"playable_button_title": "Show only games you can play now",
"add_custom_game_tooltip": "Add Custom Game",
"show_playable_only_tooltip": "Show Playable Only",
"custom_game_modal": "Add Custom Game",
"custom_game_modal_description": "Add a custom game to your library by selecting an executable file",
"custom_game_modal_executable_path": "Executable Path",
"custom_game_modal_select_executable": "Select executable file",
"custom_game_modal_title": "Title",
"custom_game_modal_enter_title": "Enter title",
"custom_game_modal_browse": "Browse",
"custom_game_modal_cancel": "Cancel",
"custom_game_modal_add": "Add Game",
"custom_game_modal_adding": "Adding Game...",
"custom_game_modal_success": "Custom game added successfully",
"custom_game_modal_failed": "Failed to add custom game",
"custom_game_modal_executable": "Executable",
"edit_game_modal": "Customize Assets",
"edit_game_modal_description": "Customize game assets and details",
"edit_game_modal_title": "Title",
"edit_game_modal_enter_title": "Enter title",
"edit_game_modal_image": "Image",
"edit_game_modal_select_image": "Select image",
"edit_game_modal_browse": "Browse",
"edit_game_modal_image_preview": "Image preview",
"edit_game_modal_icon": "Icon",
"edit_game_modal_select_icon": "Select icon",
"edit_game_modal_icon_preview": "Icon preview",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Select logo",
"edit_game_modal_logo_preview": "Logo preview",
"edit_game_modal_hero": "Library Hero",
"edit_game_modal_select_hero": "Select library hero image",
"edit_game_modal_hero_preview": "Library hero image preview",
"edit_game_modal_cancel": "Cancel",
"edit_game_modal_update": "Update",
"edit_game_modal_updating": "Updating...",
"edit_game_modal_fill_required": "Please fill in all required fields",
"edit_game_modal_success": "Assets updated successfully",
"edit_game_modal_failed": "Failed to update assets",
"edit_game_modal_image_filter": "Image",
"edit_game_modal_icon_resolution": "Recommended resolution: 256x256px",
"edit_game_modal_logo_resolution": "Recommended resolution: 640x360px",
"edit_game_modal_hero_resolution": "Recommended resolution: 1920x620px",
"edit_game_modal_assets": "Assets"
},
"header": {
"search": "Search games",
@@ -198,10 +240,20 @@
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.",
"download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.",
"download_error_not_cached_on_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.",
"update_playtime_title": "Update playtime",
"update_playtime_description": "Manually update the playtime for {{game}}",
"update_playtime": "Update playtime",
"update_playtime_success": "Playtime updated successfully",
"update_playtime_error": "Failed to update playtime",
"update_game_playtime": "Update game playtime",
"manual_playtime_warning": "Your hours will be marked as manually updated, and this cannot be undone.",
"manual_playtime_tooltip": "This playtime has been manually updated",
"download_error_not_cached_on_torbox": "This download is not available on TorBox and polling download status from TorBox is not yet available.",
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
"game_removed_from_favorites": "Game removed from favorites",
"game_added_to_favorites": "Game added to favorites",
"game_removed_from_pinned": "Game removed from pinned",
"game_added_to_pinned": "Game added to pinned",
"automatically_extract_downloaded_files": "Automatically extract downloaded files",
"create_start_menu_shortcut": "Create Start Menu shortcut",
"invalid_wine_prefix_path": "Invalid Wine prefix path",
@@ -220,7 +272,21 @@
"backup_frozen": "Backup pinned",
"backup_unfrozen": "Backup unpinned",
"backup_freeze_failed": "Failed to freeze backup",
"backup_freeze_failed_description": "You must leave at least one free slot for automatic backups"
"backup_freeze_failed_description": "You must leave at least one free slot for automatic backups",
"edit_game_modal_button": "Customize game assets",
"game_details": "Game Details",
"currency_symbol": "$",
"currency_country": "us",
"prices": "Prices",
"no_prices_found": "No prices found",
"view_all_prices": "Click to view all prices",
"retail_price": "Retail price",
"keyshop_price": "Keyshop price",
"historical_retail": "Historical retail",
"historical_keyshop": "Historical keyshop",
"language": "Language",
"caption": "Caption",
"audio": "Audio"
},
"activation": {
"title": "Activate Hydra",
@@ -440,10 +506,17 @@
"user_profile": {
"amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Last played {{period}}",
"activity": "Recent Activity",
"library": "Library",
"pinned": "Pinned",
"achievements_earned": "Achievements earned",
"played_recently": "Played recently",
"playtime": "Playtime",
"total_play_time": "Total playtime",
"manual_playtime_tooltip": "This playtime has been manually updated",
"no_recent_activity_title": "Hmmm… nothing here",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that!",
"display_name": "Display name",
@@ -518,7 +591,9 @@
"show_achievements_on_profile": "Show your achievements on your profile",
"show_points_on_profile": "Show your earned points on your profile",
"error_adding_friend": "Could not send friend request. Please check friend code",
"friend_code_length_error": "Friend code must have 8 characters"
"friend_code_length_error": "Friend code must have 8 characters",
"game_removed_from_pinned": "Game removed from pinned",
"game_added_to_pinned": "Game added to pinned"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -1,34 +1,76 @@
{
"language_name": "Español",
"app": {
"successfully_signed_in": "Sesión iniciada exitosamente"
"successfully_signed_in": "Iniciaste sesión exitosamente"
},
"home": {
"featured": "Destacado",
"surprise_me": "¡Sorpréndeme!",
"no_results": "Sin resultados encontrados",
"start_typing": "Empieza a escribir para buscar...",
"hot": "Popular Ahora",
"no_results": "No se encontraron resultados",
"start_typing": "Empezá a escribir para buscar...",
"hot": "Tendencias",
"weekly": "📅 Mejores juegos de la semana",
"achievements": "🏆 Juegos para completar"
"achievements": "🏆 Juegos para platinar"
},
"sidebar": {
"catalogue": "Catálogo",
"downloads": "Descargas",
"settings": "Ajustes",
"my_library": "Mi biblioteca",
"my_library": "Mi Librería",
"downloading_metadata": "{{title}} (Descargando metadatos…)",
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Descargando…)",
"filter": "Buscar en la biblioteca",
"filter": "Filtrar Librería",
"home": "Inicio",
"queued": "{{title}} (En cola)",
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar sesión",
"sign_in": "Iniciar Sesión",
"friends": "Amigos",
"need_help": "¿Necesitas ayuda?",
"need_help": "¿Necesitás ayuda?",
"favorites": "Favoritos",
"playable_button_title": "Mostrar solo juegos que puedes jugar ahora"
"playable_button_title": "Solo mostrar juegos que podés jugar en este momento",
"add_custom_game_tooltip": "Añadir juego personalizado",
"show_playable_only_tooltip": "Mostrar Solo Jugable",
"custom_game_modal": "Añadir juego personalizado",
"custom_game_modal_description": "Añadí un juego personalizado a tu librería seleccionando el ejecutable",
"custom_game_modal_executable_path": "Ruta del Ejecutable",
"custom_game_modal_select_executable": "Seleccionar archivo ejecutable",
"custom_game_modal_title": "Título",
"custom_game_modal_enter_title": "Ingresá el título",
"custom_game_modal_browse": "Buscar",
"custom_game_modal_cancel": "Cancelar",
"custom_game_modal_add": "Añadir juego",
"custom_game_modal_adding": "Añadiendo juego...",
"custom_game_modal_success": "Juego personalizado añadido exitosamente",
"custom_game_modal_failed": "Error al añadir juego personalizado",
"custom_game_modal_executable": "Ejecutable",
"edit_game_modal": "Personalizar recursos",
"edit_game_modal_description": "Personaliza los recursos y detalles del juego",
"edit_game_modal_title": "Título",
"edit_game_modal_enter_title": "Ingresá el título",
"edit_game_modal_image": "Imagen",
"edit_game_modal_select_image": "Seleccionar imagen",
"edit_game_modal_browse": "Navegar",
"edit_game_modal_image_preview": "Vista previa de imagen",
"edit_game_modal_icon": "Ícono",
"edit_game_modal_select_icon": "Seleccionar ícono",
"edit_game_modal_icon_preview": "Vista previa de ícono",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Seleccionar logo",
"edit_game_modal_logo_preview": "Vista previa del logo",
"edit_game_modal_hero": "Library Hero",
"edit_game_modal_select_hero": "Seleccionar una imagen de Library Hero",
"edit_game_modal_hero_preview": "Vista previa de library hero",
"edit_game_modal_cancel": "Cancelar",
"edit_game_modal_update": "Actualizar",
"edit_game_modal_updating": "Actualizando...",
"edit_game_modal_fill_required": "Por favor rellená todos los espacios requeridos",
"edit_game_modal_success": "Recursos actualizados exitosamente",
"edit_game_modal_failed": "Error al actualizar los recursos",
"edit_game_modal_image_filter": "Imagen",
"edit_game_modal_icon_resolution": "Resolución recomendada: 256x256px",
"edit_game_modal_logo_resolution": "Resolución recomendada: 640x360px",
"edit_game_modal_hero_resolution": "Resolución recomendada: 1920x620px",
"edit_game_modal_assets": "Recursos"
},
"header": {
"search": "Buscar juegos",
@@ -37,348 +79,409 @@
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",
"version_available_install": "Versión {{version}} disponible. Presiona acá para descargar y reinstalar.",
"version_available_download": "Versión {{version}} disponible. Presiona aquí para descargar."
"version_available_install": "Versión {{version}} disponible. Presiona acá para reiniciar e instalar.",
"version_available_download": "Versión {{version}} disponible. Presiona a para descargar."
},
"bottom_panel": {
"no_downloads_in_progress": "Sin descargas en progreso",
"downloading_metadata": "Descargando metadatos de {{title}}…",
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…",
"installation_complete": "Instalación completada",
"installation_complete_message": "Common redistributables instalados exitosamente",
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
"checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
"installing_common_redist": "{{log}}…",
"checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)"
"installation_complete": "Instalación completada",
"installation_complete_message": "Common redistributables instalados correctamente"
},
"catalogue": {
"search": "Filtrar…",
"developers": "Desarrolladores",
"genres": "Géneros",
"tags": "Marcadores",
"tags": "Etiquetas",
"publishers": "Editores",
"download_sources": "Fuentes de descarga",
"download_sources": "Descargando fuentes",
"result_count": "{{resultCount}} resultados",
"filter_count": "{{filterCount}} disponibles",
"filter_count": "{{filterCount}} disponible",
"clear_filters": "Limpiar {{filterCount}} seleccionados"
},
"game_details": {
"open_download_options": "Ver opciones de descargas",
"automatically_extract_downloaded_files": "Extraer automáticamente archivos descargados",
"download_error_not_cached_on_hydra": "Esta descarga no está disponible en Nimbus.",
"download_options_zero": "No hay opciones de descargas disponibles",
"open_download_options": "Abrir opciones de descargas",
"download_options_zero": "Sin opciones de descargas",
"download_options_one": "{{count}} opción de descarga",
"download_options_other": "{{count}} opciones de descargas",
"updated_at": "Actualizado el: {{updated_at}}",
"updated_at": "Actualizado el {{updated_at}}",
"install": "Instalar",
"resume": "Continuar",
"pause": "Pausa",
"resume": "Resumir",
"pause": "Pausar",
"cancel": "Cancelar",
"remove": "Eliminar",
"space_left_on_disk": "{{space}} restantes en el disco",
"eta": "Tiempo restante: {{eta}}",
"remove": "Remover",
"space_left_on_disk": "{{space}} restante en el disco",
"eta": "Conclusión {{eta}}",
"calculating_eta": "Calculando tiempo restante…",
"downloading_metadata": "Descargando metadatos…",
"filter": "Buscar repacks",
"filter": "Filtrar repacks",
"requirements": "Requisitos del Sistema",
"minimum": "Mínimos",
"recommended": "Recomendados",
"paused": "Pausado",
"release_date": "Fecha de lanzamiento: {{date}}",
"publisher": "Publicado por: {{publisher}}",
"paused": "En Pausa",
"release_date": "Lanzado el {{date}}",
"publisher": "Públicado por {{publisher}}",
"hours": "horas",
"minutes": "minutos",
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"accuracy": "{{accuracy}}% precisión",
"add_to_library": "Agregar a la biblioteca",
"remove_from_library": "Eliminar de la biblioteca",
"no_downloads": "No hay descargas disponibles",
"play_time": "Has jugado {{amount}}",
"last_time_played": "Jugado por última vez: {{period}}",
"not_played_yet": "Aún no has jugado a {{title}}",
"accuracy": "{{accuracy}}% completista",
"add_to_library": "Añadir a la librería",
"already_in_library": "Ya está en la librería",
"remove_from_library": "Eliminar de la librería",
"no_downloads": "Sin descargas disponibles",
"play_time": "Jugado por {{amount}}",
"last_time_played": "Última vez jugado {{period}}",
"not_played_yet": "No has jugado a {{title}} todavía",
"next_suggestion": "Siguiente sugerencia",
"play": "Jugar",
"deleting": "Eliminando instalador…",
"close": "Cerrar",
"playing_now": "Jugando ahora",
"change": "Cambiar",
"repacks_modal_description": "Selecciona el repack que quieres descargar",
"select_folder_hint": "Para cambiar la carpeta predeterminada, ve a <0>Ajustes</0>",
"repacks_modal_description": "Elegí el repack que querés descargar",
"select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>",
"download_now": "Descargar ahora",
"no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga",
"download_path": "Ruta de descarga",
"previous_screenshot": "Anterior captura",
"next_screenshot": "Siguiente captura",
"screenshot": "Captura {{number}}",
"open_screenshot": "Abrir captura {{number}}",
"download_settings": "Ajustes de descarga",
"downloader": "Método de descarga",
"screenshot": "Captura número {{number}}",
"open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Descargar ajustes",
"downloader": "Descargador",
"select_executable": "Seleccionar",
"no_executable_selected": "No se seleccionó un ejecutable",
"no_executable_selected": "Sin ejecutable seleccionado",
"open_folder": "Abrir carpeta",
"open_download_location": "Ver archivos descargados",
"create_shortcut": "Crear acceso directo en el escritorio",
"remove_files": "Eliminar archivos",
"create_shortcut": "Crear atajo en el escritorio",
"clear": "Limpiar",
"remove_files": "Remover archivos",
"remove_from_library_title": "¿Estás seguro?",
"remove_from_library_description": "Esto eliminará {{game}} de tu biblioteca",
"remove_from_library_description": "Esto va eliminará {{game}} de tu librería",
"options": "Opciones",
"executable_section_title": "Ejecutable",
"executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"",
"executable_section_description": "Ruta del archivo que se ejecutará cuando presiones \"Jugar\"",
"downloads_section_title": "Descargas",
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego",
"danger_zone_section_title": "Opciones Avanzadas",
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)",
"downloads_section_description": "Revisar actualizaciones u otras versiones del juego",
"danger_zone_section_title": "Zona de Peligro",
"danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra",
"download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción de descarga",
"create_steam_shortcut": "Crear atajo de Steam",
"last_downloaded_option": "Última opción descargada",
"create_shortcut_success": "Atajo creado con éxito",
"you_might_need_to_restart_steam": "Es posible que necesites reiniciar Steam para ver los cambios",
"create_shortcut_error": "Error al crear un atajo",
"nsfw_content_title": "Este juego contiene contenido inapropiado.",
"nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?",
"you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
"create_shortcut_error": "Error al crear atajo",
"nsfw_content_title": "Este juego tiene contenido inapropiado",
"nsfw_content_description": "{{title}} tiene contenido no apto para todas las edades. ¿Querés continuar igualmente?",
"allow_nsfw_content": "Continuar",
"refuse_nsfw_content": "No, gracias",
"refuse_nsfw_content": "Regresar",
"stats": "Estadísticas",
"download_count": "Descargas",
"player_count": "Jugadores activos",
"download_error": "Esta opción de descarga no está disponible.",
"player_count": "Jugadores activos",
"download_error": "Esta opción de descarga no está disponible",
"download": "Descargar",
"executable_path_in_use": "El ejecutable se encuentra en uso por \"{{game}}\"",
"executable_path_in_use": "El ejecutable ya se está usando por \"{{game}}\"",
"warning": "Advertencia:",
"hydra_needs_to_remain_open": "Para esta descarga, Hydra necesita mantenerse abierta hasta que concluya. En caso de que Hydra se cierre antes de que concluya, podrías perder todo el progreso.",
"hydra_needs_to_remain_open": "para esta descarga, Hydra necesita estar abierta hasta que termine. Si se cierra antes de completar, perderás todo el progreso.",
"achievements": "Logros",
"achievements_count": "Logros {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "Guardado en la nube",
"cloud_save_description": "Guarda tu progreso en la nube y continúa jugando en cualquier dispositivo",
"backups": "Copias de Seguridad",
"cloud_save_description": "Guardá tu progreso en la nube y jugá en cualquier dispositivo",
"backups": "Copia de seguridad",
"install_backup": "Instalar",
"delete_backup": "Eliminar",
"create_backup": "Nueva Copia de Seguridad",
"last_backup_date": "Última copia de seguridad el {{date}}",
"no_backup_preview": "No se encontraron datos de guardados para este juego",
"create_backup": "Nueva copia de seguridad",
"last_backup_date": "Última copia de seguridad {{date}}",
"no_backup_preview": "No se han encotrado puntos de guardado para este juego",
"restoring_backup": "Restaurando copia de seguridad ({{progress}} completado)…",
"uploading_backup": "Subiendo copia de seguridad…",
"no_backups": "No has creado ninguna copia de seguridad para este juego aún",
"no_backups": "No has creado ninguna copia de seguridad para este juego todavía",
"backup_uploaded": "Copia de seguridad subida",
"backup_deleted": "Copia de seguridad eliminada",
"backup_restored": "Copia de seguridad restaurada",
"see_all_achievements": "Ver todos los logros",
"sign_in_to_see_achievements": "Inicia sesión para ver los logros",
"sign_in_to_see_achievements": "Iniciá sesión para ver los logros",
"mapping_method_automatic": "Automático",
"mapping_method_manual": "Manual",
"mapping_method_label": "Método de mapeo",
"files_automatically_mapped": "Archivos mapeados automáticamente",
"mapping_method_label": "Método de mapeado",
"files_automatically_mapped": "Archivos automáticamente mapeados",
"no_backups_created": "Sin copias de seguridad creadas para este juego",
"manage_files": "Gestionar archivos",
"loading_save_preview": "Buscando datos de guardados de juegos…",
"manage_files": "Administrar archivos",
"loading_save_preview": "Buscando por guardado de juegos…",
"wine_prefix": "Prefijo de Wine",
"wine_prefix_description": "El prefijo de Wine usado para ejecutar este juego",
"wine_prefix_description": "El prefijo de Wine usado para este juego",
"launch_options": "Opciones para iniciar",
"launch_options_description": "Los usuarios avanzados pueden ingresar sus modificaciones para el inicio de sus juegos (característica experimental)",
"launch_options_placeholder": "Sin parámetro específicado",
"no_download_option_info": "Sin información disponible",
"backup_deletion_failed": "La eliminación de la copia de seguridad falló",
"max_number_of_artifacts_reached": "Número máximo de copias de seguridad de este juego alcanzadas",
"achievements_not_sync": "Tus logros no están sincronizados",
"manage_files_description": "Gestiona los archivos que serán respaldados y restaurados",
"backup_deletion_failed": "Error al eliminar copia de seguridad",
"max_number_of_artifacts_reached": "Máximo de copias de seguridad alcanzadas para este juego",
"achievements_not_sync": "Revisá como sincronizar tus logros'",
"manage_files_description": "Elegí que archivos se guardarán y restaurarán de la copia de seguridad",
"select_folder": "Seleccionar carpeta",
"backup_from": "Copia de seguridad de {{date}}",
"automatic_backup_from": "Copia de seguridad automática de {{date}}",
"enable_automatic_cloud_sync": "Habilitar sincronización automática en la nube",
"custom_backup_location_set": "Se configuró la carpeta de copia de seguridad",
"clear": "Limpiar",
"no_directory_selected": "No se seleccionó un directorio",
"launch_options": "Opciones de Inicio",
"launch_options_description": "Los usuarios avanzados pueden introducir sus propias modificaciones de opciones de inicio (característica experimental)",
"launch_options_placeholder": "Sin parámetro específicado",
"no_write_permission": "No se puede descargar en este directorio. Presiona aquí para aprender más.",
"enable_automatic_cloud_sync": "Habilitar sincronización con la nube",
"custom_backup_location_set": "Ubicación de copia de seguridad personalizada",
"no_directory_selected": "Sin directorio seleccionado",
"no_write_permission": "No se puede descargar en este directorio. Presioná acá para más información.",
"reset_achievements": "Reiniciar logros",
"reset_achievements_description": "Esto reiniciará todos los logros de {{game}}",
"reset_achievements_title": "¿Estás seguro?",
"reset_achievements_success": "Logros reiniciados exitosamente",
"reset_achievements_error": "Se produjo un error al reiniciar los logros",
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.",
"download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.",
"download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.",
"download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y aún no se puede verificar el estado de la descarga.",
"reset_achievements_description": "Esto va a reiniciar todos los logros para {{game}}",
"reset_achievements_title": "¿Querés continuar?",
"reset_achievements_success": "Logros reiniciados éxitosamente",
"reset_achievements_error": "Error al reiniciar logros",
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de GoFile. Esperá a que se reinice.",
"download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nuevas descargas. Revisá los ajustes de tu cuenta y probá de nuevo.",
"download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y no está disponible el estado de descarga de sondeo todavía.",
"update_playtime_title": "Actualizar tiempo de juego",
"update_playtime_description": "Actualizar manualmente el tiempo de juego para {{game}}",
"update_playtime": "Actualizar tiempo de juego",
"update_playtime_success": "Tiempo de juego actualizado éxitosamente",
"update_playtime_error": "Error al actualizar el tiempo de juego",
"update_game_playtime": "Actualizar tu tiempo de juego",
"manual_playtime_warning": "Tus horas de juego se marcarán como actualizadas manualmente, y esto no se puede deshacer.",
"manual_playtime_tooltip": "Este tiempo de juego se ha actualizad manualmente",
"download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y no está disponible el estado de descarga de sondeo todavía.",
"download_error_not_cached_on_hydra": "Esta descarga no está disponible en Nimbus.",
"game_removed_from_favorites": "Juego eliminado de favoritos",
"game_added_to_favorites": "Juego añadido a favoritos",
"game_removed_from_favorites": "Juego removido de favoritos",
"invalid_wine_prefix_path": "Ruta de prefijo de Wine inválida",
"invalid_wine_prefix_path_description": "La ruta del prefijo Wine es inválida. Por favor, checa la ruta y vuelve a intentarlo.",
"missing_wine_prefix": "Se requiere el prefijo Wine para crear una copia de seguridad en Linux"
"game_removed_from_pinned": "Juego removido de fijados",
"game_added_to_pinned": "Juego añadido a fijados",
"automatically_extract_downloaded_files": "Extraer automáticamente archivos descargados",
"create_start_menu_shortcut": "Crear un atajo en el Menú de Inicio",
"invalid_wine_prefix_path": "Ruta inválida del prefijo de Wine",
"invalid_wine_prefix_path_description": "La ruta al prefijo de Wine es inválida. Por favor revisá la ruta y probá de nuevo.",
"missing_wine_prefix": "EL prefijo de Wine es requerido para hacer una copia en Linux",
"artifact_renamed": "Copia de seguridad renombrada éxitosamente",
"rename_artifact": "Renombrar copia de seguridad",
"rename_artifact_description": "Renombrar copia de seguridad con un nombre más descriptivo",
"artifact_name_label": "Nombre de la copia de seguridad",
"artifact_name_placeholder": "Introducí un nombre para la copia de seguridad",
"save_changes": "Guardar cambios",
"required_field": "Este campo es requerido",
"max_length_field": "Este campo debe tener menos de {{length}} carácteres",
"freeze_backup": "Fíjalo así no se re-escríbira por copias de seguridad automáticas",
"unfreeze_backup": "Dejar de fijar",
"backup_frozen": "Copia de seguridad fijada",
"backup_unfrozen": "Copia de seguridad desfijada",
"backup_freeze_failed": "Error al congelar tu copia de seguridad",
"backup_freeze_failed_description": "Tenés que tener mínimo un espacio para copias de seguridad automáticas",
"edit_game_modal_button": "Personalizar recursos de juego",
"game_details": "Detalles del juego",
"currency_symbol": "$",
"currency_country": "us",
"prices": "Precios",
"no_prices_found": "No se encontraron precios",
"view_all_prices": "Presioná acá para ver todos los precios",
"retail_price": "Precio recomendado",
"keyshop_price": "Precio de tiendas de terceros",
"historical_retail": "Precio de tiendas",
"historical_keyshop": "Precio de tiendas de terceros",
"language": "Idioma",
"caption": "Subtítulo",
"audio": "Audio"
},
"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.",
"installation_id": "ID de Instalación:",
"enter_activation_code": "Introducí tu código de activación",
"message": "Si no sabes donde preguntar por esto, entonces no tenés que tener esto.",
"activate": "Activar",
"loading": "Cargando…"
},
"downloads": {
"resume": "Resumir",
"pause": "Pausa",
"eta": "Finalizando en {{eta}}",
"paused": "En Pausa",
"pause": "Pausar",
"eta": "Tiempo de finalizción {{eta}}",
"paused": "Pausado",
"verifying": "Verificando…",
"completed": "Completado",
"removed": "No descargado",
"cancel": "Cancelar",
"filter": "Buscar juegos descargados",
"remove": "Eliminar",
"filter": "Filtrar juegos descargados",
"remove": "Remover",
"downloading_metadata": "Descargando metadatos…",
"deleting": "Eliminando instalador…",
"delete": "Eliminar instalador",
"delete_modal_title": "¿Estás seguro?",
"delete_modal_description": "Esto eliminará todos los archivos de la instalación del repack del juego de tu computadora. (Si ya instalaste el juego, puedes eliminar esto, no afectará al juego)",
"deleting": "Eliminado instalador…",
"delete": "Remover instalador",
"delete_modal_title": "¿Querés continuar?",
"delete_modal_description": "Esto eliminará todos los archivos del instalador de tu computadora",
"install": "Instalar",
"download_in_progress": "En progreso",
"queued_downloads": "Descargas en cola",
"downloads_completed": "Completado",
"queued": "En cola",
"no_downloads_title": "Esto está tan... vacío",
"no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!.",
"checking_files": "Verificando archivos…",
"seeding": "Seeding",
"stop_seeding": "Detener seeding",
"resume_seeding": "Continuar seeding",
"no_downloads_title": "Esto está... tan, ¿vacío?",
"no_downloads_description": "No has descargado nada con Hydra, pero nunca es tarde para comenzar.",
"checking_files": "Revisando archivos…",
"seeding": "Sembrando",
"stop_seeding": "Dejar de sembrar",
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…",
"options": "Gestionar"
"extracting": "Extrayendo archivos…"
},
"settings": {
"downloads_path": "Ruta de descarga",
"common_redist": "Common redistributables",
"common_redist_description": "Las Common redistributables son requeridos para ejecutar algunos juegos. Es recomendado instalarlos para evitar problemas.",
"create_real_debrid_account": "Presiona acá si no tienes una cuenta de Real-Debrid aún",
"create_torbox_account": "Presiona acá si no tienes una cuenta de TorBox aún",
"install_common_redist": "Instalar",
"installing_common_redist": "Instalando…",
"show_download_speed_in_megabytes": "Mostrar velocidad de descargar en megabytes por segundo",
"change": "Cambiar",
"change": "Actualizar",
"notifications": "Notificaciones",
"enable_download_notifications": "Cuando se completa una descarga",
"enable_repack_list_notifications": "Cuando se añade un repack nuevo",
"real_debrid_api_token_label": "Token API de Real-Debrid",
"quit_app_instead_hiding": "Salir de Hydra en vez de minimizar en la bandeja del sistema",
"launch_with_system": "Iniciar Hydra al inicio del sistema",
"enable_download_notifications": "Cuando una descarga se completa",
"enable_repack_list_notifications": "Cuando un nuevo repack se añade",
"real_debrid_api_token_label": "Real-Debrid API token",
"quit_app_instead_hiding": "No ocultar Hydra cuando se cierra",
"launch_with_system": "Iniciar Hydra con el sistema",
"general": "General",
"behavior": "Otros",
"download_sources": "Fuentes de descarga",
"behavior": "Comportamiento",
"download_sources": "Fuentes de descargas",
"language": "Idioma",
"api_token": "Token API",
"enable_real_debrid": "Activar Real-Debrid",
"real_debrid_description": "Real-Debrid es una forma de descargar sin restricciones archivos instantáneamente con la máxima velocidad de tu internet.",
"debrid_invalid_token": "Token de API inválido",
"debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>",
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid",
"api_token": "API Token",
"enable_real_debrid": "Habilitar Real-Debrid",
"real_debrid_description": "Real-Debrid es un descargador que te permite descargar archivos más rápidos, solo límitado por la velocidad de tu internet.",
"debrid_invalid_token": "Token API inválido",
"debrid_api_token_hint": "Podés obtener la el token de tu API <0>a</0>",
"real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratis. Por favor suscribíte a Real-Debrid",
"debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
"save_changes": "Guardar cambios",
"changes_saved": "Ajustes guardados exitosamente",
"download_sources_description": "Hydra buscará los enlaces de descarga de estas fuentes. La URL de origen debe ser un enlace directo a un archivo .json que contenga los enlaces de descarga",
"changes_saved": "Cambios guardados éxitosamente",
"download_sources_description": "Hydra va a recoger los links de descarga de cada fuente. La URL de origen debe ser un enlace .json que contenga los enlaces de descarga.",
"validate_download_source": "Validar",
"remove_download_source": "Eliminar",
"add_download_source": "Añadir fuente de descarga",
"download_count_zero": "No hay descargas en la lista",
"download_count_one": "{{countFormatted}} descarga en la lista",
"download_count_other": "{{countFormatted}} descargas en la lista",
"download_source_url": "Descargar URL de origen",
"add_download_source_description": "Introduce la URL con el archivo .json",
"download_source_up_to_date": "Al día",
"remove_download_source": "Remover",
"add_download_source": "Añadir fuente",
"download_count_zero": "Sin opciones de descarga",
"download_count_one": "{{countFormatted}} opción de descarga",
"download_count_other": "{{countFormatted}} opciones de descarga",
"download_source_url": "Descargar fuente URL",
"add_download_source_description": "Introducí la URL del archivo .json",
"download_source_up_to_date": "Actualizado",
"download_source_errored": "Error",
"sync_download_sources": "Sincronizar fuentes",
"removed_download_source": "Fuente de descarga eliminada",
"removed_download_sources": "Fuente de descarga eliminadas",
"cancel_button_confirmation_delete_all_sources": "No",
"confirm_button_confirmation_delete_all_sources": "Sí, eliminar todo",
"description_confirmation_delete_all_sources": "Eliminarás todas las fuentes de descarga",
"confirm_button_confirmation_delete_all_sources": "Si, eliminar todo",
"title_confirmation_delete_all_sources": "Eliminar todas las fuentes de descarga",
"removed_download_sources": "Fuentes de descarga eliminadas",
"button_delete_all_sources": "Eliminar todas las fuentes de descarga",
"added_download_source": "Fuente de descarga añadida",
"download_sources_synced": "Todas las fuentes de descargas están actualizadas.",
"insert_valid_json_url": "Introduce una URL JSON válida",
"found_download_option_zero": "No se encontró una opción de descarga",
"found_download_option_one": "Se encontró {{countFormatted}} opción de descarga",
"found_download_option_other": "Se encontraron {{countFormatted}} opciones de descarga",
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
"button_delete_all_sources": "Eliminar todo",
"added_download_source": "Añadir fuente de descarga",
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
"insert_valid_json_url": "Introducí una URL de json válida",
"found_download_option_zero": "Sin opciones de descargas encontrada",
"found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga",
"found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas",
"import": "Importar",
"public": "Público",
"private": "Privado",
"friends_only": "Solo amigos",
"friends_only": "Sólo amigos",
"privacy": "Privacidad",
"profile_visibility": "Visibilidad del perfil",
"profile_visibility_description": "Elige quién puede ver tu perfil y biblioteca",
"required_field": "Este campo es obligatorio",
"source_already_exists": "Esta fuente ya ha sido agregada.",
"must_be_valid_url": "La fuente debe ser una URL válida.",
"profile_visibility_description": "Elegí quién puede ver tú perfil y biblioteca",
"required_field": "Este campo es requerido",
"source_already_exists": "Esta fuente ya está añadida",
"must_be_valid_url": "La fuente debe ser una URL válida",
"blocked_users": "Usuarios bloqueados",
"user_unblocked": "El usuario ha sido desbloqueado",
"enable_achievement_notifications": "Cuando un logro se desbloquea",
"user_unblocked": "Has desbloqueado a este usuario",
"enable_achievement_notifications": "Cuando desbloqueás un logro",
"launch_minimized": "Iniciar Hydra minimizado",
"disable_nsfw_alert": "Desactivar alerta NSFW",
"seed_after_download_complete": "Realizar seeding después de que se completa la descarga",
"show_hidden_achievement_description": "Ocultar descripción de logros ocultos antes de desbloquearlos",
"disable_nsfw_alert": "Deshabilitar alerta de NSFW",
"seed_after_download_complete": "Sembrar después de completar una descarga",
"show_hidden_achievement_description": "Mostrar logros ocultos antes de desbloquearlos",
"account": "Cuenta",
"account_data_updated_successfully": "Datos de la cuenta actualizados",
"bill_sent_until": "Tú próxima factura se enviará el {{date}}",
"current_email": "Correo actual:",
"manage_subscription": "Gestionar suscripción",
"no_email_account": "No has configurado un correo aún",
"no_subscription": "Disfruta Hydra de la mejor manera",
"no_users_blocked": "No tienes usuarios bloqueados",
"renew_subscription": "Renovar Hydra Cloud",
"subscription_active_until": "Tu Hydra Cloud está activa hasta {{date}}",
"subscription_expired_at": "Tú suscripción expiró el {{date}}",
"subscription_renew_cancelled": "Está desactivada la renovación automática",
"subscription_renews_on": "Tú suscripción se renueva el {{date}}",
"no_users_blocked": "No has bloqueado a ningún usuario",
"subscription_active_until": "Tu Hydra Cloud está activo hasta {{date}}",
"manage_subscription": "Administrar suscripción",
"update_email": "Actualizar correo",
"update_password": "Actualizar contraseña",
"appearance": "Apariencia",
"become_subscriber": "Sé Hydra Cloud",
"cancel": "Cancelar",
"clear_themes": "Limpiar",
"create_theme": "Crear",
"create_theme_modal_description": "Crea un nuevo tema para personalizar la apariencia de Hydra",
"create_theme_modal_title": "Crear tema personalizado",
"delete_all_themes": "Eliminar todos los temas",
"delete_all_themes_description": "Esto eliminará todos tus temas personalizados",
"delete_theme": "Eliminar tema",
"delete_theme_description": "Esto eliminará el tema {{theme}}",
"edit_theme": "Editar tema",
"update_password": "Cambiar contraseña",
"current_email": "Correo actual:",
"no_email_account": "No tenés ningún correo vinculado aún",
"account_data_updated_successfully": "Datos de la cuenta actualizados correctamente",
"renew_subscription": "Renovar Hydra Cloud",
"subscription_expired_at": "Tu suscripción expiró el {{date}}",
"no_subscription": "Disfrutá Hydra de la mejor forma",
"become_subscriber": "Sé parte de Hydra Cloud",
"subscription_renew_cancelled": "Renovación automática desactivada",
"subscription_renews_on": "Tu suscripción se renueva el {{date}}",
"bill_sent_until": "Tu próxima factura se enviará este día",
"no_themes": "Parece que no tenés ningún tema aún, pero no te preocupés, presiona acá para hacer tu primera obra maestra.",
"editor_tab_code": "Código",
"editor_tab_info": "Info",
"editor_tab_save": "Guardar",
"enable_torbox": "Habilitar TorBox",
"error_importing_theme": "Error al importar el tema",
"web_store": "Tienda Web",
"clear_themes": "Limpiar",
"create_theme": "Crear",
"create_theme_modal_title": "Crear tema personalizado",
"create_theme_modal_description": "Crear un nuevo tema para personalizar el estilo de Hydra",
"theme_name": "Nombre",
"insert_theme_name": "Introducí un nombre del tema",
"set_theme": "Usar tema",
"unset_theme": "Dejar de usar tema",
"delete_theme": "Eliminar tema",
"edit_theme": "Editar tema",
"delete_all_themes": "Eliminar todos los temas",
"delete_all_themes_description": "Esto va a eliminar todos los temas personalizados",
"delete_theme_description": "Esto va a eliminar el tema {{theme}}",
"cancel": "Cancelar",
"appearance": "Apariencia",
"enable_torbox": "Activar TorBox",
"torbox_description": "TorBox es un servicio premium de seedbox que incluso rivaliza los mejores servidores.",
"torbox_account_linked": "Cuenta de TorBox vinculada",
"create_real_debrid_account": "Presioná acá si todavía no tenés una cuenta de Real-Debrid",
"create_torbox_account": "Presioná acá si todavía no tenés una cuenta de TorBox",
"real_debrid_account_linked": "Cuenta de Real-Debrid vinculada",
"name_min_length": "El nombre del tema debe tener mínimo 3 carácteres",
"import_theme": "Importar tema",
"import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas",
"insert_theme_name": "Introducí el nombre del tema",
"name_min_length": "El tema tiene que tener 3 carácteres de largo mínimo",
"no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para crear tu primer tema.",
"real_debrid_account_linked": "Cuenta de Real-Debrid vinculada",
"set_theme": "Establecer tema",
"theme_imported": "Tema importado exitosamente",
"theme_name": "Nombre",
"torbox_account_linked": "Cuenta de TorBox vinculada",
"torbox_description": "TorBox es tu servicio premium de seedbox que rivaliza incluso a los mejores servidores del mercado.",
"unset_theme": "Desactivar tema",
"web_store": "Tienda Web",
"enable_friend_request_notifications": "Cuando se recibe una solicitud de amistad",
"enable_auto_install": "Descargar actualizaciones automáticamente"
"error_importing_theme": "Error al importar el tema",
"theme_imported": "Tema importado correctamente",
"enable_friend_request_notifications": "Cuando recibís una solicitud de amistad",
"enable_auto_install": "Descargar actualizaciones automáticamente",
"common_redist": "Common redistributables",
"common_redist_description": "Common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.",
"install_common_redist": "Instalar",
"installing_common_redist": "Instalando…",
"show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
"extract_files_by_default": "Extraer archivos por defecto después de descargar",
"enable_steam_achievements": "Habilitar búsqueda de logros de Steam",
"achievement_custom_notification_position": "Posición de notificación de logros",
"top-left": "Superior Izquierda",
"top-center": "Superior Centro",
"top-right": "Superior Derecha",
"bottom-left": "Inferior Izquierda",
"bottom-center": "Inferior Centro",
"bottom-right": "Inferior Derecha",
"enable_achievement_custom_notifications": "Habilitar notificación personalizada de logros",
"alignment": "Centrado",
"variation": "Variación",
"default": "Defecto",
"rare": "Raro",
"platinum": "Platino",
"hidden": "Oculto",
"test_notification": "Probar notificación",
"notification_preview": "Probar notificación de logro",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego"
},
"notifications": {
"download_complete": "Descarga completada",
"game_ready_to_install": "{{title}} está listo para instalar",
"repack_list_updated": "Lista de repacks actualizadas",
"repack_count_one": "{{count}} repack añadido",
"repack_count_other": "{{count}} repacks añadidos",
"new_update_available": "Versión {{version}} disponible",
"restart_to_install_update": "Reiniciá Hydra para instalar la actualización",
"notification_achievement_unlocked_title": "Logro desbloqueado para {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados",
"new_friend_request_description": "{{displayName}} te envió una solicitud de amistad",
"new_friend_request_title": "Nueva solicitud de amistad",
"extraction_complete": "Extracción completada",
"game_extracted": "{{title}} extraído exitosamente",
"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",
"new_update_available": "Version {{version}} disponible",
"restart_to_install_update": "Reinicia Hydra para instalar la actualización",
"notification_achievement_unlocked_title": "Logro desbloqueado de {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados",
"new_friend_request_title": "Nueva solicitud de amistad",
"new_friend_request_description": "{{displayName}} te envió una solicitud de amistad",
"friend_started_playing_game": "{{displayName}} está jugando"
"friend_started_playing_game": "{{displayName}} empezó a jugar un juego",
"test_achievement_notification_title": "Esto es una notificación de prueba",
"test_achievement_notification_description": "Piola, ¿verdad?"
},
"system_tray": {
"open": "Abrir Hydra",
@@ -387,15 +490,15 @@
"game_card": {
"available_one": "Disponible",
"available_other": "Disponibles",
"no_downloads": "No hay descargas disponibles"
"no_downloads": "Sin descargas disponibles"
},
"binary_not_found_modal": {
"title": "Programas no instalados",
"description": "Los ejecutables de Wine o Lutris no se encontraron en tu sistema",
"instructions": "Comprueba como instalar de forma correcta uno de los dos en tu distro de Linux para ejecutar el juego con normalidad"
"description": "Ejecutables de Wine o Lutris executables no encontrados en tu sistema",
"instructions": "Comprobá la forma correcta de instalar cualquiera de ellos en tu distribución de Linux para que el juego pueda ejecutarse con normalidad"
},
"modal": {
"close": "Botón de cierre"
"close": "Botón de cerrar"
},
"forms": {
"toggle_password_visibility": "Cambiar visibilidad de contraseña"
@@ -403,111 +506,120 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"last_time_played": "Última vez jugado: {{period}}",
"last_time_played": "Jugado por última vez el {{period}}",
"activity": "Actividad reciente",
"library": "Biblioteca",
"total_play_time": "Has jugado",
"no_recent_activity_title": "Que raro, no hay nada por acá...",
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!",
"display_name": "Nombre en pantalla",
"library": "Librería",
"pinned": "Fijado",
"total_play_time": "Total de tiempo de juego",
"achievements_earned": "Logros conseguidos",
"played_recently": "Jugado recientemente",
"playtime": "Tiempo de juego",
"manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente",
"no_recent_activity_title": "Hmmm… nada por acá",
"no_recent_activity_description": "No has jugado nada recientemente. ¡Te toca cambiar eso!",
"display_name": "Nombre a mostar",
"saving": "Guardando",
"save": "Guardar",
"save": "Guardado",
"edit_profile": "Editar perfil",
"saved_successfully": "Guardado exitosamente",
"try_again": "Por favor, intenta de nuevo",
"sign_out_modal_title": "¿Estás seguro?",
"try_again": "Por favor, intentá de nuevo",
"sign_out_modal_title": "¿Querés continuar?",
"cancel": "Cancelar",
"successfully_signed_out": "Sesión cerrada exitosamente",
"successfully_signed_out": "Cerraste sesión exitosamente",
"sign_out": "Cerrar sesión",
"playing_for": "Llevas jugando {{amount}}",
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?",
"add_friends": "Añadir amigos",
"playing_for": "Jugando por {{amount}}",
"sign_out_modal_text": "Tu librería está vinculada con esta cuenta. Cuando cerrés sesión, tu librería ya no será visible, y cualquier progreso no se guardará. ¿Querés continuar con el cierre de sesión?",
"add_friends": "Añadir amistades",
"add": "Añadir",
"friend_code": "Código de amigo",
"friend_code": "Código de amistad",
"see_profile": "Ver perfil",
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amigos",
"friends_list": "Lista de amigos",
"friends": "Amistades",
"friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario",
"add_friend": "Añadir amigo",
"add_friend": "Añadir amistad",
"request_sent": "Solicitud enviada",
"request_received": "Solicitud recibida",
"accept_request": "Aceptar solicitud",
"ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud",
"undo_friendship": "Eliminar amistad",
"undo_friendship": "Deshacer amistad",
"request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Todavía no tienes amigos añadidos",
"no_friends_added": "No tenés amistades añadidas",
"pending": "Pendiente",
"no_pending_invites": "No tienes invitaciones pendientes",
"no_blocked_users": "No has bloqueado a ningún usuario",
"friend_code_copied": "Código de amigo copiado",
"undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}",
"privacy_hint": "Para ajustar quién puede ver esto, ve a <0>Configuración</0>.",
"no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a nadie",
"friend_code_copied": "Código de amistad copiado",
"undo_friendship_modal_text": "Esto va a deshacer tu amistad con {{displayName}}",
"privacy_hint": "Para cambiar quién puede ver esto, andá a <0>Ajustes</0>",
"locked_profile": "Este perfil es privado",
"image_process_failure": "Error al procesar la imagen",
"required_field": "Este campo es obligatorio",
"displayname_min_length": "El nombre a mostrar debe tener al menos 3 caracteres",
"displayname_max_length": "El nombre a mostrar debe tener como máximo 50 caracteres",
"image_process_failure": "Errpr al procesar la imagen",
"required_field": "Este campo es requerido",
"displayname_min_length": "El nombre en pantalla debe tener mínimo 3 caracteres",
"displayname_max_length": "El nombre en pantalla debe tener máximo 50 caracteres",
"report_profile": "Reportar este perfil",
"report_reason": "¿Cual es el motivo del reporte?",
"report_reason": "¿Porque estás reportando este perfil?",
"report_description": "Información adicional",
"report_description_placeholder": "Información adicional",
"report": "Reportar",
"report_reason_hate": "Discursos de odio",
"report_reason_sexual_content": "Contenido sexual",
"report_reason_violence": "Violencia",
"report_reason_spam": "Spam / Contenido no deseado",
"report_reason_other": "Otro",
"report_reason_spam": "Spam",
"report_reason_other": "Otros",
"profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amigo:",
"upload_banner": "Subir un banner",
"your_friend_code": "Tu código de amistad:",
"upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…",
"background_image_updated": "Imagen de fondo actualizada",
"playing": "Jugando {{game}}",
"achievements": "logros",
"achievements_unlocked": "Logros desbloqueados",
"earned_points": "Puntos Obtenidos",
"show_achievements_on_profile": "Mostrar tus logros en tu perfil",
"show_points_on_profile": "Mostrar tus puntos obtenidos en tu perfil",
"games": "Juegos",
"ranking_updated_weekly": "El Ranking se actualiza semanalmente",
"stats": "Estadísticas",
"top_percentile": "Top {{percentile}}%"
"achievements": "logros",
"games": "Juegos",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "El ranking se actualiza semanalmente",
"playing": "Jugando {{game}}",
"achievements_unlocked": "Logros desbloqueados",
"earned_points": "Puntos obtenidos",
"show_achievements_on_profile": "Mostrá tus logros en tu perfil",
"show_points_on_profile": "Mostrá los puntos obtenidos en tu perfil",
"error_adding_friend": "No se pudo enviar la solicitud de amistad. Por favor revisá el código",
"friend_code_length_error": "El código de amistad debe tener mínimo 8 caracteres",
"game_removed_from_pinned": "Juego removido de fijados",
"game_added_to_pinned": "Juego añadido a fijados"
},
"achievement": {
"achievement_unlocked": "Logro desbloqueado",
"user_achievements": "Logros de {{displayName}}",
"your_achievements": "Tus Logros",
"your_achievements": "Tus logros",
"unlocked_at": "Desbloqueado el: {{date}}",
"subscription_needed": "Se necesita una suscripción a Hydra Cloud necesita para ver este contenido",
"new_achievements_unlocked": "Desbloqueados {{achievementCount}} nuevos logros de {{gameCount}} juegos",
"subscription_needed": "Se requiere una suscripción a Hydra Cloud para ver esto",
"new_achievements_unlocked": "Desbloqueaste {{achievementCount}} nuevos logros de {{gameCount}} juegos",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} logros",
"achievements_unlocked_for_game": "Se han desbloqueado {{achievementCount}} nuevos logros de {{gameTitle}}",
"achievements_unlocked_for_game": "Desbloqueaste {{achievementCount}} nuevos logros para {{gameTitle}}",
"hidden_achievement_tooltip": "Este es un logro oculto",
"achievement_earn_points": "Obtén {{points}} puntos con este logro",
"achievement_earn_points": "Conseguí {{points}} puntos por este logro",
"earned_points": "Puntos obtenidos:",
"available_points": "Puntos disponibles:",
"how_to_earn_achievements_points": "¿Cómo obtener puntos de logros?"
"how_to_earn_achievements_points": "¿Como conseguir puntos por logros?"
},
"hydra_cloud": {
"subscription_tour_title": "Suscripción Hydra Cloud",
"debrid_description": "Descargas hasta x4 más rápidas con Nimbus",
"subscribe_now": "Suscribirse ahora",
"subscribe_now": "suscribíte ahora",
"cloud_saving": "Guardado en la nube",
"cloud_achievements": "Guarda tus logros en la nube",
"animated_profile_picture": "Fotos de perfil animadas",
"cloud_achievements": "Guardá tus logros en la nube",
"animated_profile_picture": "Foto de perfil animada",
"premium_support": "Soporte Premium",
"show_and_compare_achievements": "Muestra y compara tus logros con otros usuarios",
"animated_profile_banner": "Fondo de perfil animado",
"show_and_compare_achievements": "Mostrá y compará tus logros con otros usuarios",
"animated_profile_banner": "Banner de perfil animado",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": Has descubierto una característica de Hydra Cloud!",
"learn_more": "Aprender más"
"hydra_cloud_feature_found": Acabas de descubrir una característica de Hydra Cloud!",
"learn_more": "Descubrir más",
"debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus"
}
}

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Edukalt sisse logitud"
},
"home": {
"featured": "Esile toodud",
"surprise_me": "Üllata mind",
"no_results": "Tulemusi ei leitud",
"start_typing": "Alusta otsimiseks kirjutamist...",
@@ -45,10 +44,7 @@
"calculating_eta": "{{title}} allalaadimine… ({{percentage}} valmis) - Järelejäänud aja arvutamine…",
"checking_files": "{{title}} failide kontrollimine… ({{percentage}} valmis)"
},
"catalogue": {
"next_page": "Järgmine leht",
"previous_page": "Eelmine leht"
},
"catalogue": {},
"game_details": {
"open_download_options": "Ava allalaadimise valikud",
"download_options_zero": "Allalaadimise valikuid pole",

View File

@@ -1,7 +1,6 @@
{
"language_name": "فارسی",
"home": {
"featured": "پیشنهادی",
"surprise_me": "سوپرایزم کن",
"no_results": "اتمام‌ای پیدا نشد"
},
@@ -17,7 +16,6 @@
"home": "خانه",
"favorites": "علاقه‌مندی‌ها"
},
"header": {
"search": "جستجوی بازی‌ها",
"home": "خانه",
@@ -31,10 +29,7 @@
"downloading_metadata": "درحال دانلود متادیتاهای {{title}}…",
"downloading": "در حال دانلود {{title}}… ({{percentage}} تکمیل شده) - اتمام {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "صفحه‌ی بعدی",
"previous_page": "صفحه‌ی قبلی"
},
"catalogue": {},
"game_details": {
"open_download_options": "بازکردن آپشن‌های دانلود",
"download_options_zero": "هیچ آپشن دانلودی وجود ندارد",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Connecté avec succès"
},
"home": {
"featured": "En vedette",
"surprise_me": "Surprenez-moi",
"no_results": "Aucun résultat trouvé",
"start_typing": "Commencez à taper pour rechercher...",

View File

@@ -1,7 +1,6 @@
{
"language_name": "Magyar",
"home": {
"featured": "Featured",
"surprise_me": "Lepj meg",
"no_results": "Nem található"
},
@@ -19,7 +18,6 @@
},
"header": {
"search": "Keresés",
"home": "Főoldal",
"catalogue": "Katalógus",
"downloads": "Letöltések",
@@ -31,10 +29,7 @@
"downloading_metadata": "{{title}} metaadatainak letöltése…",
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Következő olda",
"previous_page": "Előző olda"
},
"catalogue": {},
"game_details": {
"open_download_options": "Letöltési lehetőségek",
"download_options_zero": "Nincs letöltési lehetőség",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Berhasil masuk"
},
"home": {
"featured": "Unggulan",
"surprise_me": "Kejutkan saya",
"no_results": "Tidak ada hasil ditemukan"
},
@@ -25,7 +24,6 @@
},
"header": {
"search": "Cari game",
"home": "Beranda",
"catalogue": "Katalog",
"downloads": "Unduhan",
@@ -41,10 +39,7 @@
"calculating_eta": "Mengunduh {{title}}… ({{percentage}} selesai) - Menghitung waktu yang tersisa…",
"checking_files": "Memeriksa file {{title}}… ({{percentage}} selesai)"
},
"catalogue": {
"next_page": "Halaman Berikutnya",
"previous_page": "Halaman Sebelumnya"
},
"catalogue": {},
"game_details": {
"open_download_options": "Buka opsi unduhan",
"download_options_zero": "Tidak ada opsi unduhan",

View File

@@ -1,7 +1,6 @@
{
"language_name": "Italiano",
"home": {
"featured": "In primo piano",
"surprise_me": "Sorprendimi",
"no_results": "Nessun risultato trovato"
},
@@ -20,7 +19,6 @@
},
"header": {
"search": "Cerca",
"home": "Home",
"catalogue": "Catalogo",
"downloads": "Download",
@@ -32,10 +30,7 @@
"downloading_metadata": "Scaricamento metadati di {{title}}…",
"downloading": "Download di {{title}}… ({{percentage}} completato) - Conclusione {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Pagina successiva",
"previous_page": "Pagina precedente"
},
"catalogue": {},
"game_details": {
"open_download_options": "Apri opzioni di download",
"download_options_zero": "Nessuna opzione di download",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Сәтті кіру"
},
"home": {
"featured": "Ұсынылған",
"surprise_me": "Таңқалдыр",
"no_results": "Ештеңе табылмады"
},
@@ -23,7 +22,6 @@
"sign_in": "Кіру",
"favorites": "Таңдаулылар"
},
"header": {
"search": "Іздеу",
"home": "Басты бет",
@@ -40,10 +38,7 @@
"downloading": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Аяқтау {{eta}} - {{speed}}",
"calculating_eta": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Қалған уақытты есептеу…"
},
"catalogue": {
"next_page": "Келесі бет",
"previous_page": "Алдыңғы бет"
},
"catalogue": {},
"game_details": {
"open_download_options": "Жүктеу нұсқаларын ашу",
"download_options_zero": "Жүктеу нұсқалары жоқ",

View File

@@ -1,7 +1,6 @@
{
"language_name": "한국어",
"home": {
"featured": "추천",
"surprise_me": "무작위 추천",
"no_results": "결과 없음"
},
@@ -17,7 +16,6 @@
"home": "홈",
"favorites": "즐겨찾기"
},
"header": {
"search": "게임 검색하기",
"home": "홈",
@@ -31,10 +29,7 @@
"downloading_metadata": "{{title}}의 메타데이터를 다운로드 중…",
"downloading": "{{title}}의 파일들을 다운로드 중… ({{percentage}} 완료) - 완료까지 {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "다음 페이지",
"previous_page": "이전 페이지"
},
"catalogue": {},
"game_details": {
"open_download_options": "다운로드 선택지 열기",
"download_options_zero": "다운로드 선택지 없음",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Logget inn vellykket"
},
"home": {
"featured": "Anbefalinger",
"surprise_me": "Overrask meg",
"no_results": "Ingen resultater fundet",
"start_typing": "Begynn å skrive for å søke...",
@@ -29,7 +28,6 @@
},
"header": {
"search": "Søk efter spill",
"home": "Hjem",
"catalogue": "Katalog",
"downloads": "Nedlastinger",
@@ -45,10 +43,7 @@
"calculating_eta": "Laster ned {{title}}… ({{percentage}} ferdig) - Regner ut resterende tid…",
"checking_files": "Sjekker {{title}} filer… ({{percentage}} ferdig)"
},
"catalogue": {
"next_page": "Neste side",
"previous_page": "Forrige side"
},
"catalogue": {},
"game_details": {
"open_download_options": "Åpne nedlastingsmuligheter",
"download_options_zero": "Ingen nedlastingsmulighet",

View File

@@ -1,7 +1,6 @@
{
"language_name": "Nederlands",
"home": {
"featured": "Uitgelicht",
"surprise_me": "Verrasing",
"no_results": "Geen resultaten gevonden"
},
@@ -19,7 +18,6 @@
},
"header": {
"search": "Zoek spellen",
"home": "Home",
"catalogue": "Bibliotheek",
"downloads": "Downloads",
@@ -31,10 +29,7 @@
"downloading_metadata": "Downloading {{title}} metadata…",
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Volgende Pagina",
"previous_page": "Vorige Pagina"
},
"catalogue": {},
"game_details": {
"open_download_options": "Open download Instellingen",
"download_options_zero": "Geen download Instellingen",

View File

@@ -1,7 +1,6 @@
{
"language_name": "Polski",
"home": {
"featured": "Wyróżnione",
"surprise_me": "Zaskocz mnie",
"no_results": "Nie znaleziono wyników"
},
@@ -20,7 +19,6 @@
},
"header": {
"search": "Szukaj",
"home": "Główna",
"catalogue": "Katalog",
"downloads": "Pobrane",
@@ -32,10 +30,7 @@
"downloading_metadata": "Pobieranie {{title}} metadata…",
"downloading": "Pobieranie {{title}}… (ukończone w {{percentage}}) - Podsumowanie {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Następna strona",
"previous_page": "Poprzednia strona"
},
"catalogue": {},
"game_details": {
"open_download_options": "Otwórz opcje pobierania",
"download_options_zero": "Brak opcji pobierania",

57
src/locales/pt-BR/translation.json Normal file → Executable file
View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Autenticado com sucesso"
},
"home": {
"featured": "Destaques",
"hot": "Populares",
"weekly": "📅 Mais baixados da semana",
"achievements": "🏆 Pra platinar",
@@ -27,7 +26,22 @@
"sign_in": "Login",
"friends": "Amigos",
"need_help": "Precisa de ajuda?",
"favorites": "Favoritos"
"favorites": "Favoritos",
"add_custom_game_tooltip": "Adicionar jogo personalizado",
"custom_game_modal": "Adicionar jogo personalizado",
"edit_game_modal_title": "Título",
"playable_button_title": "",
"custom_game_modal_add": "Adicionar Jogo",
"custom_game_modal_adding": "Adicionando...",
"custom_game_modal_browse": "Buscar",
"custom_game_modal_cancel": "Cancelar",
"edit_game_modal_assets": "Imagens",
"edit_game_modal_icon": "Ícone",
"edit_game_modal_browse": "Buscar",
"edit_game_modal_cancel": "Cancelar",
"edit_game_modal_enter_title": "Insira o título",
"edit_game_modal_logo": "Logo",
"edit_game_modal": "Personalizar detalhes"
},
"header": {
"search": "Buscar jogos",
@@ -207,7 +221,31 @@
"backup_frozen": "Backup fixado",
"backup_unfrozen": "Backup removido dos fixados",
"backup_freeze_failed": "Falha ao fixar backup",
"backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos"
"backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos",
"game_details": "Detalhes do Jogo",
"currency_symbol": "R$",
"currency_country": "br",
"prices": "Preços",
"no_prices_found": "Nenhum preço encontrado",
"view_all_prices": "Clique para ver todos os preços",
"retail_price": "Preço de lojas oficiais",
"keyshop_price": "Preço em keyshops",
"historical_retail": "Preço histórico de lojas oficiais",
"historical_keyshop": "Preço histórico em keyshops",
"language": "Idioma",
"caption": "Legenda",
"audio": "Áudio",
"edit_game_modal_button": "Alterar detalhes do jogo",
"game_added_to_pinned": "Jogo adicionado aos fixados",
"game_removed_from_pinned": "Jogo removido dos fixados",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"manual_playtime_warning": "As suas horas de jogo serão marcadas como atualizadas manualmente. Esta ação não pode ser desfeita.",
"missing_wine_prefix": "Um prefixo Wine é necessário para criar um backup no Linux",
"update_game_playtime": "Modificar tempo de jogo",
"update_playtime": "Modificar tempo de jogo",
"update_playtime_description": "Atualizar manualmente o tempo de jogo de {{game}}",
"update_playtime_error": "Falha ao atualizar tempo de jogo",
"update_playtime_title": "Atualizar tempo de jogo"
},
"activation": {
"title": "Ativação",
@@ -382,7 +420,8 @@
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"editor_tab_code": "Código"
},
"notifications": {
"download_complete": "Download concluído",
@@ -511,7 +550,15 @@
"show_achievements_on_profile": "Exiba suas conquistas no perfil",
"show_points_on_profile": "Exiba seus pontos ganhos no perfil",
"error_adding_friend": "Não foi possível enviar o pedido de amizade. Verifique o código de amizade inserido",
"friend_code_length_error": "Código de amigo deve ter 8 caracteres"
"friend_code_length_error": "Código de amigo deve ter 8 caracteres",
"top_percentile": "Top {{percentile}}%",
"playtime": "Tempo de jogo",
"played_recently": "Jogado recentemente",
"pinned": "Fixado",
"amount_minutes_short": "{{amount}}h",
"amount_hours_short": "{{amount}}h",
"game_added_to_pinned": "Jogo adicionado aos fixados",
"achievements_earned": "Conquistas recebidas"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Sessão iniciada com sucesso"
},
"home": {
"featured": "Destaques",
"hot": "Populares",
"weekly": "📅 Mais descarregados esta semana",
"achievements": "🏆 Para completar",
@@ -26,7 +25,8 @@
"game_has_no_executable": "O jogo não tem um executável selecionado",
"sign_in": "Iniciar sessão",
"friends": "Amigos",
"favorites": "Favoritos"
"favorites": "Favoritos",
"edit_game_modal_cancel": "Cancelar"
},
"header": {
"search": "Procurar jogos",
@@ -247,9 +247,6 @@
"download_count_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista",
"download_options_zero": "Sem downloads disponíveis",
"download_options_one": "{{countFormatted}} download disponível",
"download_options_other": "{{countFormatted}} downloads disponíveis",
"download_source_url": "URL da fonte",
"add_download_source_description": "Insere o URL que contém o ficheiro .json",
"download_source_up_to_date": "Sincronizada",
@@ -359,8 +356,6 @@
"instructions": "Verifica a forma correta de instalar algum deles na tua distribuição Linux, para garantir a execução normal do jogo"
},
"catalogue": {
"next_page": "Página seguinte",
"previous_page": "Página anterior",
"search": "Filtrar…",
"developers": "Desenvolvedores",
"genres": "Géneros",
@@ -427,7 +422,6 @@
"friend_code_copied": "Código de amigo copiado",
"undo_friendship_modal_text": "Isto vai remover a tua amizade com {{displayName}}",
"privacy_hint": "Para controlar quem pode ver o teu perfil, acede às <0>Definições</0>",
"profile_locked": "Este perfil é privado",
"image_process_failure": "Falha ao processar a imagem",
"required_field": "Este campo é obrigatório",
"displayname_min_length": "O nome de apresentação deve ter pelo menos 3 caracteres",

View File

@@ -1,7 +1,6 @@
{
"language_name": "Română",
"home": {
"featured": "Recomandate",
"surprise_me": "Surprinde-mă",
"no_results": "Niciun rezultat găsit"
},
@@ -19,7 +18,6 @@
},
"header": {
"search": "Caută jocuri",
"home": "Acasă",
"catalogue": "Catalog",
"downloads": "Descărcări",
@@ -32,10 +30,7 @@
"downloading": "Se descarcă {{title}}... ({{percentage}} complet) - Concluzie {{eta}} - {{speed}}",
"calculating_eta": "Se descarcă {{title}}... ({{percentage}} complet) - Calculare timp rămas..."
},
"catalogue": {
"next_page": "Pagina următoare",
"previous_page": "Pagina anterioară"
},
"catalogue": {},
"game_details": {
"open_download_options": "Deschide opțiunile de descărcare",
"download_options_zero": "Nicio opțiune de descărcare",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Успешный вход"
},
"home": {
"featured": "Рекомендации",
"surprise_me": "Удиви меня",
"no_results": "Ничего не найдено",
"hot": "Сейчас популярно",
@@ -28,7 +27,47 @@
"friends": "Друзья",
"need_help": "Нужна помощь?",
"favorites": "Избранное",
"playable_button_title": "Показать только игры, в которые можно играть сейчас"
"playable_button_title": "Показать только установленные игры.",
"custom_game_modal": "Добавить пользовательскую игру",
"custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл",
"custom_game_modal_executable_path": "Путь к исполняемому файлу",
"custom_game_modal_select_executable": "Выберите исполняемый файл",
"custom_game_modal_title": "Название игры",
"custom_game_modal_enter_title": "Введите название игры",
"custom_game_modal_browse": "Обзор",
"custom_game_modal_cancel": "Отмена",
"custom_game_modal_add": "Добавить игру",
"custom_game_modal_adding": "Добавление игры...",
"custom_game_modal_success": "Пользовательская игра успешно добавлена",
"custom_game_modal_failed": "Не удалось добавить пользовательскую игру",
"custom_game_modal_executable": "Исполняемый файл",
"edit_game_modal": "Настроить ресурсы",
"edit_game_modal_description": "Настройте ресурсы и детали игры",
"edit_game_modal_title": "Название",
"edit_game_modal_enter_title": "Введите название",
"edit_game_modal_image": "Изображение",
"edit_game_modal_select_image": "Выберите изображение",
"edit_game_modal_browse": "Обзор",
"edit_game_modal_image_preview": "Предпросмотр изображения",
"edit_game_modal_icon": "Иконка",
"edit_game_modal_select_icon": "Выберите иконку",
"edit_game_modal_icon_preview": "Предпросмотр иконки",
"edit_game_modal_logo": "Логотип",
"edit_game_modal_select_logo": "Выберите логотип",
"edit_game_modal_logo_preview": "Предпросмотр логотипа",
"edit_game_modal_hero": "Изображение обложку игры",
"edit_game_modal_select_hero": "Выберите обложку игры",
"edit_game_modal_hero_preview": "Предпросмотр обложки игры",
"edit_game_modal_cancel": "Отмена",
"edit_game_modal_update": "Обновить",
"edit_game_modal_updating": "Обновление...",
"edit_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля",
"edit_game_modal_success": "Ресурсы успешно обновлены",
"edit_game_modal_failed": "Не удалось обновить ресурсы",
"edit_game_modal_image_filter": "Изображение",
"edit_game_modal_icon_resolution": "Рекомендуемое разрешение: 256x256px",
"edit_game_modal_logo_resolution": "Рекомендуемое разрешение: 640x360px",
"edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px"
},
"header": {
"search": "Поиск",
@@ -195,6 +234,10 @@
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
"download_error_not_cached_on_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
"update_playtime_title": "Обновить время игры",
"update_playtime_description": "Вручную обновите время игры для {{game}}",
"update_playtime": "Обновить время игры",
"update_game_playtime": "Обновить время игры",
"download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.",
"game_added_to_favorites": "Игра добавлена в избранное",
"game_removed_from_favorites": "Игра удалена из избранного",
@@ -205,7 +248,23 @@
"invalid_wine_prefix_path": "Недопустимый путь префикса Wine",
"invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.",
"missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux",
"download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus."
"download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.",
"update_playtime_success": "Время игры успешно обновлено",
"update_playtime_error": "Не удалось обновить время игры",
"manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.",
"artifact_renamed": "Резервная копия успешно переименована",
"rename_artifact": "Переименовать резервную копию",
"rename_artifact_description": "Переименуйте резервную копию, присвоив ей более описательное имя.",
"artifact_name_label": "Название резервной копии",
"artifact_name_placeholder": "Введите название для резервной копии",
"max_length_field": "Это поле должно содержать менее {{length}} символов",
"freeze_backup": "Закрепить, чтобы она не была перезаписана автоматическими резервными копиями",
"unfreeze_backup": "Открепить",
"backup_frozen": "Резервная копия закреплена",
"backup_unfrozen": "Резервная копия откреплена",
"backup_freeze_failed": "Не удалось закрепить резервную копию",
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
"manual_playtime_tooltip": "Это время игры было обновлено вручную"
},
"activation": {
"title": "Активировать Hydra",
@@ -381,7 +440,8 @@
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"enable_steam_achievements": "Включить поиск достижений Steam"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -394,12 +454,12 @@
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
"new_friend_request_title": "Новый запрос на добавление в друзья",
"new_friend_request_description": "Вы получили новый запрос на добавление в друзья",
"extraction_complete": "Распаковка завершена",
"game_extracted": "{{title}} успешно распакован",
"friend_started_playing_game": "{{displayName}} начал играть в игру",
"test_achievement_notification_title": "Это тестовое уведомление",
"test_achievement_notification_description": "Довольно круто, да?"
"test_achievement_notification_description": "Довольно круто, да?",
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья"
},
"system_tray": {
"open": "Открыть Hydra",
@@ -424,10 +484,13 @@
"user_profile": {
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"amount_hours_short": "{{amount}}ч",
"amount_minutes_short": "{{amount}}м",
"last_time_played": "Последняя игра {{period}}",
"activity": "Недавняя активность",
"library": "Библиотека",
"total_play_time": "Всего сыграно",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"no_recent_activity_title": "Хммм... Тут ничего нет",
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
"display_name": "Отображаемое имя",
@@ -500,7 +563,9 @@
"achievements_unlocked": "Достижения разблокированы",
"earned_points": "Заработано очков:",
"show_achievements_on_profile": "Покажите свои достижения в профиле",
"show_points_on_profile": "Показывать заработанные очки в своем профиле"
"show_points_on_profile": "Показывать заработанные очки в своем профиле",
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
"friend_code_length_error": "Код друга должен содержать 8 символов"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Inloggningen lyckades"
},
"home": {
"featured": "Utvalt",
"surprise_me": "Överraska mig",
"no_results": "Inga resultat hittades",
"start_typing": "Börja skriva för att söka...",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Başarıyla giriş yapıldı"
},
"home": {
"featured": "Öne Çıkanlar",
"surprise_me": "Beni Şaşırt",
"no_results": "Sonuç bulunamadı",
"start_typing": "Aramak için yazmaya başlayın...",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Успішний вхід в систему"
},
"home": {
"featured": "Рекомендоване",
"surprise_me": "Здивуй мене",
"no_results": "Результатів не знайдено",
"start_typing": "Почніть набирати текст для пошуку...",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Kirish muvaffaqiyatli amalga oshirildi"
},
"home": {
"featured": "Tavsiya etilgan",
"surprise_me": "Hayratda qoldir",
"no_results": "Natijalar topilmadi",
"hot": "Eng mashhur",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "已成功登录"
},
"home": {
"featured": "特色推荐",
"surprise_me": "向我推荐",
"no_results": "没有找到结果",
"start_typing": "键入以开始搜素...",
@@ -51,8 +50,6 @@
"installing_common_redist": "{{log}}…"
},
"catalogue": {
"next_page": "下一页",
"previous_page": "上一页",
"clear_filters": "清除已选的 {{filterCount}} 项",
"developers": "开发商",
"download_sources": "下载源",

View File

@@ -10,7 +10,16 @@ const saveGameShopAssets = async (
): Promise<void> => {
const key = levelKeys.game(shop, objectId);
const existingAssets = await gamesShopAssetsSublevel.get(key);
return gamesShopAssetsSublevel.put(key, { ...existingAssets, ...assets });
// Preserve existing title if it differs from the incoming title (indicating it was customized)
const shouldPreserveTitle =
existingAssets?.title && existingAssets.title !== assets.title;
return gamesShopAssetsSublevel.put(key, {
...existingAssets,
...assets,
title: shouldPreserveTitle ? existingAssets.title : assets.title,
});
};
registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -14,8 +14,12 @@ import "./catalogue/get-developers";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
import "./library/add-custom-game-to-library";
import "./library/update-custom-game";
import "./library/update-game-custom-assets";
import "./library/add-game-to-favorites";
import "./library/remove-game-from-favorites";
import "./library/toggle-game-pin";
import "./library/create-game-shortcut";
import "./library/close-game";
import "./library/delete-game-folder";
@@ -33,9 +37,12 @@ import "./library/remove-game";
import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements";
import "./library/change-game-playtime";
import "./library/toggle-automatic-cloud-sync";
import "./library/get-default-wine-prefix-selection-path";
import "./library/cleanup-unused-assets";
import "./library/create-steam-shortcut";
import "./library/copy-custom-game-asset";
import "./misc/open-checkout";
import "./misc/open-external";
import "./misc/show-open-dialog";
@@ -44,6 +51,8 @@ import "./misc/show-item-in-folder";
import "./misc/get-badges";
import "./misc/install-common-redist";
import "./misc/can-install-common-redist";
import "./misc/save-temp-file";
import "./misc/delete-temp-file";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
@@ -63,6 +72,7 @@ import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./user/get-user-library";
import "./user/get-blocked-users";
import "./user/block-user";
import "./user/unblock-user";

View File

@@ -0,0 +1,65 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { randomUUID } from "crypto";
import type { GameShop } from "@types";
const addCustomGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
title: string,
executablePath: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => {
const objectId = randomUUID();
const shop: GameShop = "custom";
const gameKey = levelKeys.game(shop, objectId);
const existingGames = await gamesSublevel.iterator().all();
const existingGame = existingGames.find(
([_key, game]) => game.executablePath === executablePath && !game.isDeleted
);
if (existingGame) {
throw new Error(
"A game with this executable path already exists in your library"
);
}
const assets = {
objectId,
shop,
title,
iconUrl: iconUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || "",
libraryImageUrl: iconUrl || "",
logoImageUrl: logoImageUrl || "",
logoPosition: null,
coverImageUrl: iconUrl || "",
};
await gamesShopAssetsSublevel.put(gameKey, assets);
const game = {
title,
iconUrl: iconUrl || null,
logoImageUrl: logoImageUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || null,
objectId,
shop,
remoteId: null,
isDeleted: false,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
executablePath,
launchOptions: null,
favorite: false,
automaticCloudSync: false,
hasManuallyUpdatedPlaytime: false,
};
await gamesSublevel.put(gameKey, game);
return game;
};
registerEvent("addCustomGameToLibrary", addCustomGameToLibrary);

View File

@@ -30,6 +30,8 @@ const addGameToLibrary = async (
game = {
title,
iconUrl: gameAssets?.iconUrl ?? null,
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
logoImageUrl: gameAssets?.logoImageUrl ?? null,
objectId,
shop,
remoteId: null,
@@ -41,12 +43,14 @@ const addGameToLibrary = async (
await gamesSublevel.put(gameKey, game);
}
await createGame(game).catch(() => {});
if (game) {
await createGame(game).catch(() => {});
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
}
};
registerEvent("addGameToLibrary", addGameToLibrary);

View File

@@ -0,0 +1,33 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
const changeGamePlaytime = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
playTimeInSeconds: number
) => {
try {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
if (game.remoteId) {
await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, {
playTimeInSeconds,
});
}
await gamesSublevel.put(gameKey, {
...game,
playTimeInMilliseconds: playTimeInSeconds * 1000,
hasManuallyUpdatedPlaytime: true,
});
} catch (error) {
throw new Error(`Failed to update game playtime: ${error}`);
}
};
registerEvent("changeGamePlayTime", changeGamePlaytime);

View File

@@ -0,0 +1,76 @@
import { ipcMain } from "electron";
import fs from "fs";
import path from "path";
import { ASSETS_PATH } from "@main/constants";
const getCustomGamesAssetsPath = () => {
return path.join(ASSETS_PATH, "custom-games");
};
const getAllCustomGameAssets = async (): Promise<string[]> => {
const assetsPath = getCustomGamesAssetsPath();
if (!fs.existsSync(assetsPath)) {
return [];
}
const files = await fs.promises.readdir(assetsPath);
return files.map((file) => path.join(assetsPath, file));
};
const getUsedAssetPaths = async (): Promise<Set<string>> => {
// Get all custom games from the level database
const { gamesSublevel } = await import("@main/level");
const allGames = await gamesSublevel.iterator().all();
const customGames = allGames
.map(([_key, game]) => game)
.filter((game) => game.shop === "custom" && !game.isDeleted);
const usedPaths = new Set<string>();
customGames.forEach((game) => {
// Extract file paths from local URLs
if (game.iconUrl?.startsWith("local:")) {
usedPaths.add(game.iconUrl.replace("local:", ""));
}
if (game.logoImageUrl?.startsWith("local:")) {
usedPaths.add(game.logoImageUrl.replace("local:", ""));
}
if (game.libraryHeroImageUrl?.startsWith("local:")) {
usedPaths.add(game.libraryHeroImageUrl.replace("local:", ""));
}
});
return usedPaths;
};
export const cleanupUnusedAssets = async (): Promise<{
deletedCount: number;
errors: string[];
}> => {
try {
const allAssets = await getAllCustomGameAssets();
const usedAssets = await getUsedAssetPaths();
const errors: string[] = [];
let deletedCount = 0;
for (const assetPath of allAssets) {
if (!usedAssets.has(assetPath)) {
try {
await fs.promises.unlink(assetPath);
deletedCount++;
} catch (error) {
errors.push(`Failed to delete ${assetPath}: ${error}`);
}
}
}
return { deletedCount, errors };
} catch (error) {
throw new Error(`Failed to cleanup unused assets: ${error}`);
}
};
ipcMain.handle("cleanupUnusedAssets", cleanupUnusedAssets);

View File

@@ -0,0 +1,42 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "crypto";
import { ASSETS_PATH } from "@main/constants";
const copyCustomGameAsset = async (
_event: Electron.IpcMainInvokeEvent,
sourcePath: string,
assetType: "icon" | "logo" | "hero"
): Promise<string> => {
if (!sourcePath || !fs.existsSync(sourcePath)) {
throw new Error("Source file does not exist");
}
// Ensure assets directory exists
if (!fs.existsSync(ASSETS_PATH)) {
fs.mkdirSync(ASSETS_PATH, { recursive: true });
}
// Create custom games assets subdirectory
const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games");
if (!fs.existsSync(customGamesAssetsPath)) {
fs.mkdirSync(customGamesAssetsPath, { recursive: true });
}
// Get file extension
const fileExtension = path.extname(sourcePath);
// Generate unique filename
const uniqueId = randomUUID();
const fileName = `${assetType}-${uniqueId}${fileExtension}`;
const destinationPath = path.join(customGamesAssetsPath, fileName);
// Copy the file
await fs.promises.copyFile(sourcePath, destinationPath);
// Return the local URL format
return `local:${destinationPath}`;
};
registerEvent("copyCustomGameAsset", copyCustomGameAsset);

View File

@@ -23,7 +23,10 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
...game,
download: download ?? null,
...gameAssets,
};
// Ensure compatibility with LibraryGame type
libraryHeroImageUrl:
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
} as LibraryGame;
})
);
});

View File

@@ -1,7 +1,70 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { gamesSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
import { HydraApi, logger } from "@main/services";
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import type { GameShop, Game } from "@types";
import fs from "node:fs";
const collectAssetPathsToDelete = (game: Game): string[] => {
const assetPathsToDelete: string[] = [];
const assetUrls =
game.shop === "custom"
? [game.iconUrl, game.logoImageUrl, game.libraryHeroImageUrl]
: [game.customIconUrl, game.customLogoImageUrl, game.customHeroImageUrl];
for (const url of assetUrls) {
if (url?.startsWith("local:")) {
assetPathsToDelete.push(url.replace("local:", ""));
}
}
return assetPathsToDelete;
};
const updateGameAsDeleted = async (
game: Game,
gameKey: string
): Promise<void> => {
const updatedGame = {
...game,
isDeleted: true,
executablePath: null,
...(game.shop !== "custom" && {
customIconUrl: null,
customLogoImageUrl: null,
customHeroImageUrl: null,
}),
};
await gamesSublevel.put(gameKey, updatedGame);
};
const resetShopAssets = async (gameKey: string): Promise<void> => {
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
if (existingAssets) {
const resetAssets = {
...existingAssets,
title: existingAssets.title,
};
await gamesShopAssetsSublevel.put(gameKey, resetAssets);
}
};
const deleteAssetFiles = async (
assetPathsToDelete: string[]
): Promise<void> => {
if (assetPathsToDelete.length === 0) return;
for (const assetPath of assetPathsToDelete) {
try {
if (fs.existsSync(assetPath)) {
await fs.promises.unlink(assetPath);
}
} catch (error) {
logger.warn(`Failed to delete asset ${assetPath}:`, error);
}
}
};
const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent,
@@ -11,17 +74,21 @@ const removeGameFromLibrary = async (
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (game) {
await gamesSublevel.put(gameKey, {
...game,
isDeleted: true,
executablePath: null,
});
if (!game) return;
if (game?.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
}
const assetPathsToDelete = collectAssetPathsToDelete(game);
await updateGameAsDeleted(game, gameKey);
if (game.shop !== "custom") {
await resetShopAssets(gameKey);
}
if (game?.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
}
await deleteAssetFiles(assetPathsToDelete);
};
registerEvent("removeGameFromLibrary", removeGameFromLibrary);

View File

@@ -0,0 +1,43 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { HydraApi, logger } from "@main/services";
import type { GameShop, UserGame } from "@types";
const toggleGamePin = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
pin: boolean
) => {
try {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
if (pin) {
const response = await HydraApi.put<UserGame>(
`/profile/games/${shop}/${objectId}/pin`
);
await gamesSublevel.put(gameKey, {
...game,
isPinned: pin,
pinnedDate: new Date(response.pinnedDate!),
});
} else {
await HydraApi.put(`/profile/games/${shop}/${objectId}/unpin`);
await gamesSublevel.put(gameKey, {
...game,
isPinned: pin,
pinnedDate: null,
});
}
} catch (error) {
logger.error("Failed to update game pinned status", error);
throw new Error(`Failed to update game pinned status: ${error}`);
}
};
registerEvent("toggleGamePin", toggleGamePin);

View File

@@ -0,0 +1,98 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
import fs from "node:fs";
import { logger } from "@main/services";
interface UpdateCustomGameParams {
shop: GameShop;
objectId: string;
title: string;
iconUrl?: string;
logoImageUrl?: string;
libraryHeroImageUrl?: string;
originalIconPath?: string;
originalLogoPath?: string;
originalHeroPath?: string;
}
const updateCustomGame = async (
_event: Electron.IpcMainInvokeEvent,
params: UpdateCustomGameParams
) => {
const {
shop,
objectId,
title,
iconUrl,
logoImageUrl,
libraryHeroImageUrl,
originalIconPath,
originalLogoPath,
originalHeroPath,
} = params;
const gameKey = levelKeys.game(shop, objectId);
const existingGame = await gamesSublevel.get(gameKey);
if (!existingGame) {
throw new Error("Game not found");
}
const oldAssetPaths: string[] = [];
const assetPairs = [
{ existing: existingGame.iconUrl, new: iconUrl },
{ existing: existingGame.logoImageUrl, new: logoImageUrl },
{ existing: existingGame.libraryHeroImageUrl, new: libraryHeroImageUrl },
];
for (const { existing, new: newUrl } of assetPairs) {
if (existing?.startsWith("local:") && (!newUrl || existing !== newUrl)) {
oldAssetPaths.push(existing.replace("local:", ""));
}
}
const updatedGame = {
...existingGame,
title,
iconUrl: iconUrl || null,
logoImageUrl: logoImageUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || null,
originalIconPath: originalIconPath || existingGame.originalIconPath || null,
originalLogoPath: originalLogoPath || existingGame.originalLogoPath || null,
originalHeroPath: originalHeroPath || existingGame.originalHeroPath || null,
};
await gamesSublevel.put(gameKey, updatedGame);
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
if (existingAssets) {
const updatedAssets = {
...existingAssets,
title,
iconUrl: iconUrl || null,
libraryHeroImageUrl: libraryHeroImageUrl || "",
libraryImageUrl: iconUrl || "",
logoImageUrl: logoImageUrl || "",
coverImageUrl: iconUrl || "",
};
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
}
if (oldAssetPaths.length > 0) {
for (const assetPath of oldAssetPaths) {
try {
if (fs.existsSync(assetPath)) {
await fs.promises.unlink(assetPath);
}
} catch (error) {
logger.warn(`Failed to delete old asset ${assetPath}:`, error);
}
}
}
return updatedGame;
};
registerEvent("updateCustomGame", updateCustomGame);

View File

@@ -0,0 +1,162 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import type { GameShop, Game } from "@types";
import fs from "node:fs";
import { logger } from "@main/services";
const collectOldAssetPaths = (
existingGame: Game,
customIconUrl?: string | null,
customLogoImageUrl?: string | null,
customHeroImageUrl?: string | null
): string[] => {
const oldAssetPaths: string[] = [];
const assetPairs = [
{ existing: existingGame.customIconUrl, new: customIconUrl },
{ existing: existingGame.customLogoImageUrl, new: customLogoImageUrl },
{ existing: existingGame.customHeroImageUrl, new: customHeroImageUrl },
];
for (const { existing, new: newUrl } of assetPairs) {
if (
existing &&
newUrl !== undefined &&
existing !== newUrl &&
existing.startsWith("local:")
) {
oldAssetPaths.push(existing.replace("local:", ""));
}
}
return oldAssetPaths;
};
interface UpdateGameDataParams {
gameKey: string;
existingGame: Game;
title: string;
customIconUrl?: string | null;
customLogoImageUrl?: string | null;
customHeroImageUrl?: string | null;
customOriginalIconPath?: string | null;
customOriginalLogoPath?: string | null;
customOriginalHeroPath?: string | null;
}
const updateGameData = async (params: UpdateGameDataParams): Promise<Game> => {
const {
gameKey,
existingGame,
title,
customIconUrl,
customLogoImageUrl,
customHeroImageUrl,
customOriginalIconPath,
customOriginalLogoPath,
customOriginalHeroPath,
} = params;
const updatedGame = {
...existingGame,
title,
...(customIconUrl !== undefined && { customIconUrl }),
...(customLogoImageUrl !== undefined && { customLogoImageUrl }),
...(customHeroImageUrl !== undefined && { customHeroImageUrl }),
...(customOriginalIconPath !== undefined && { customOriginalIconPath }),
...(customOriginalLogoPath !== undefined && { customOriginalLogoPath }),
...(customOriginalHeroPath !== undefined && { customOriginalHeroPath }),
};
await gamesSublevel.put(gameKey, updatedGame);
return updatedGame;
};
const updateShopAssets = async (
gameKey: string,
title: string
): Promise<void> => {
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
if (existingAssets) {
const updatedAssets = {
...existingAssets,
title,
};
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
}
};
const deleteOldAssetFiles = async (oldAssetPaths: string[]): Promise<void> => {
if (oldAssetPaths.length === 0) return;
for (const assetPath of oldAssetPaths) {
try {
if (fs.existsSync(assetPath)) {
await fs.promises.unlink(assetPath);
}
} catch (error) {
logger.warn(`Failed to delete old custom asset ${assetPath}:`, error);
}
}
};
interface UpdateGameCustomAssetsParams {
shop: GameShop;
objectId: string;
title: string;
customIconUrl?: string | null;
customLogoImageUrl?: string | null;
customHeroImageUrl?: string | null;
customOriginalIconPath?: string | null;
customOriginalLogoPath?: string | null;
customOriginalHeroPath?: string | null;
}
const updateGameCustomAssets = async (
_event: Electron.IpcMainInvokeEvent,
params: UpdateGameCustomAssetsParams
) => {
const {
shop,
objectId,
title,
customIconUrl,
customLogoImageUrl,
customHeroImageUrl,
customOriginalIconPath,
customOriginalLogoPath,
customOriginalHeroPath,
} = params;
const gameKey = levelKeys.game(shop, objectId);
const existingGame = await gamesSublevel.get(gameKey);
if (!existingGame) {
throw new Error("Game not found");
}
const oldAssetPaths = collectOldAssetPaths(
existingGame,
customIconUrl,
customLogoImageUrl,
customHeroImageUrl
);
const updatedGame = await updateGameData({
gameKey,
existingGame,
title,
customIconUrl,
customLogoImageUrl,
customHeroImageUrl,
customOriginalIconPath,
customOriginalLogoPath,
customOriginalHeroPath,
});
await updateShopAssets(gameKey, title);
await deleteOldAssetFiles(oldAssetPaths);
return updatedGame;
};
registerEvent("updateGameCustomAssets", updateGameCustomAssets);

View File

@@ -0,0 +1,18 @@
import fs from "node:fs";
import { registerEvent } from "../register-event";
const deleteTempFile = async (
_event: Electron.IpcMainInvokeEvent,
filePath: string
): Promise<void> => {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (error) {
// Silently fail - temp files will be cleaned up by OS eventually
console.warn(`Failed to delete temp file: ${error}`);
}
};
registerEvent("deleteTempFile", deleteTempFile);

View File

@@ -0,0 +1,27 @@
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";
import { registerEvent } from "../register-event";
const saveTempFile = async (
_event: Electron.IpcMainInvokeEvent,
fileName: string,
fileData: Uint8Array
): Promise<string> => {
try {
const tempDir = app.getPath("temp");
const tempFilePath = path.join(
tempDir,
`hydra-temp-${Date.now()}-${fileName}`
);
// Write the file data to temp directory
fs.writeFileSync(tempFilePath, fileData);
return tempFilePath;
} catch (error) {
throw new Error(`Failed to save temp file: ${error}`);
}
};
registerEvent("saveTempFile", saveTempFile);

View File

@@ -53,6 +53,8 @@ const startGameDownload = async (
await gamesSublevel.put(gameKey, {
title,
iconUrl: gameAssets?.iconUrl ?? null,
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
logoImageUrl: gameAssets?.logoImageUrl ?? null,
objectId,
shop,
remoteId: null,

View File

@@ -0,0 +1,28 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserLibraryResponse } from "@types";
const getUserLibrary = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
take: number = 12,
skip: number = 0,
sortBy?: string
): Promise<UserLibraryResponse | null> => {
const params = new URLSearchParams();
params.append("take", take.toString());
params.append("skip", skip.toString());
if (sortBy) {
params.append("sortBy", sortBy);
}
const queryString = params.toString();
const baseUrl = `/users/${userId}/library`;
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
return HydraApi.get<UserLibraryResponse>(url).catch(() => null);
};
registerEvent("getUserLibrary", getUserLibrary);

View File

@@ -64,6 +64,71 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
protocol.handle("gradient", (request) => {
const gradientCss = decodeURIComponent(
request.url.slice("gradient:".length)
);
// Parse gradient CSS safely without regex to prevent ReDoS
let direction = "45deg";
let color1 = "#4a90e2";
let color2 = "#7b68ee";
// Simple string parsing approach - more secure than regex
if (
gradientCss.startsWith("linear-gradient(") &&
gradientCss.endsWith(")")
) {
const content = gradientCss.slice(16, -1); // Remove "linear-gradient(" and ")"
const parts = content.split(",").map((part) => part.trim());
if (parts.length >= 3) {
direction = parts[0];
color1 = parts[1];
color2 = parts[2];
}
}
let x1 = "0%",
y1 = "0%",
x2 = "100%",
y2 = "100%";
if (direction === "to right") {
y2 = "0%";
} else if (direction === "to bottom") {
x2 = "0%";
} else if (direction === "45deg") {
y1 = "100%";
y2 = "0%";
} else if (direction === "225deg") {
x1 = "100%";
x2 = "0%";
} else if (direction === "315deg") {
x1 = "100%";
y1 = "100%";
x2 = "0%";
y2 = "0%";
}
// Note: "135deg" case removed as it uses all default values
const svgContent = `
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#grad)" />
</svg>
`;
return new Response(svgContent, {
headers: { "Content-Type": "image/svg+xml" },
});
});
await loadState();
const language = await db

View File

@@ -4,7 +4,6 @@ import { Downloader } from "@shared";
import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types";
import {
WSClient,
SystemPath,
CommonRedistManager,
TorBoxClient,
@@ -47,7 +46,7 @@ export const loadState = async () => {
await HydraApi.setupApi().then(() => {
uploadGamesBatch();
WSClient.connect();
// WSClient.connect();
});
const downloads = await downloadsSublevel

View File

@@ -11,7 +11,6 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { WSClient } from "./ws/ws-client";
interface HydraApiOptions {
needsAuth?: boolean;
@@ -103,8 +102,8 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds();
uploadGamesBatch();
WSClient.close();
WSClient.connect();
// WSClient.close();
// WSClient.connect();
}
}

View File

@@ -6,7 +6,9 @@ type ProfileGame = {
id: string;
lastTimePlayed: Date | null;
playTimeInMilliseconds: number;
hasManuallyUpdatedPlaytime: boolean;
isFavorite?: boolean;
isPinned?: boolean;
} & ShopAssets;
export const mergeWithRemoteGames = async () => {
@@ -35,6 +37,7 @@ export const mergeWithRemoteGames = async () => {
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
favorite: game.isFavorite ?? localGame.favorite,
isPinned: game.isPinned ?? localGame.isPinned,
});
} else {
await gamesSublevel.put(gameKey, {
@@ -43,17 +46,21 @@ export const mergeWithRemoteGames = async () => {
remoteId: game.id,
shop: game.shop,
iconUrl: game.iconUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
logoImageUrl: game.logoImageUrl,
lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds,
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
isDeleted: false,
favorite: game.isFavorite ?? false,
isPinned: game.isPinned ?? false,
});
}
await gamesShopAssetsSublevel.put(gameKey, {
shop: game.shop,
objectId: game.objectId,
title: game.title,
title: localGame?.title || game.title, // Preserve local title if it exists
coverImageUrl: game.coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl,

View File

@@ -11,7 +11,8 @@ export const uploadGamesBatch = async () => {
.all()
.then((results) => {
return results.filter(
(game) => !game.isDeleted && game.remoteId === null
(game) =>
!game.isDeleted && game.remoteId === null && game.shop !== "custom"
);
});
@@ -27,6 +28,7 @@ export const uploadGamesBatch = async () => {
shop: game.shop,
lastTimePlayed: game.lastTimePlayed,
isFavorite: game.favorite,
isPinned: game.isPinned ?? false,
};
})
).catch(() => {});

View File

@@ -82,13 +82,19 @@ export const getSteamAppDetails = async (
};
export const getSteamUsersIds = async () => {
const userDataPath = await getSteamLocation().catch(() => null);
const steamLocation = await getSteamLocation().catch(() => null);
if (!userDataPath) {
if (!steamLocation) {
return [];
}
const userIds = fs.readdirSync(path.join(userDataPath, "userdata"), {
const userDataPath = path.join(steamLocation, "userdata");
if (!fs.existsSync(userDataPath)) {
return [];
}
const userIds = fs.readdirSync(userDataPath, {
withFileTypes: true,
});

View File

@@ -181,6 +181,9 @@ export class WindowManager {
});
this.mainWindow.on("close", async () => {
const mainWindow = this.mainWindow;
this.mainWindow = null;
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
@@ -188,9 +191,11 @@ export class WindowManager {
}
);
if (this.mainWindow) {
const lastBounds = this.mainWindow.getBounds();
const isMaximized = this.mainWindow.isMaximized() ?? false;
if (mainWindow) {
mainWindow.setProgressBar(-1);
const lastBounds = mainWindow.getBounds();
const isMaximized = mainWindow.isMaximized() ?? false;
const screenConfig = isMaximized
? {
x: undefined,
@@ -207,9 +212,6 @@ export class WindowManager {
if (userPreferences?.preferQuitInsteadOfHiding) {
app.quit();
}
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow = null;
});
this.mainWindow.webContents.setWindowOpenHandler((handler) => {
@@ -584,7 +586,7 @@ export class WindowManager {
tray.setToolTip("Hydra Launcher");
if (process.platform !== "darwin") {
if (process.platform === "win32") {
await updateSystemTray();
tray.addListener("double-click", () => {
@@ -595,6 +597,18 @@ export class WindowManager {
}
});
tray.addListener("right-click", showContextMenu);
} else if (process.platform === "linux") {
await updateSystemTray();
tray.addListener("click", () => {
if (this.mainWindow) {
this.mainWindow.show();
} else {
this.createMainWindow();
}
});
tray.addListener("right-click", showContextMenu);
} else {
tray.addListener("click", showContextMenu);

View File

@@ -128,6 +128,52 @@ contextBridge.exposeInMainWorld("electron", {
),
addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title),
addCustomGameToLibrary: (
title: string,
executablePath: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) =>
ipcRenderer.invoke(
"addCustomGameToLibrary",
title,
executablePath,
iconUrl,
logoImageUrl,
libraryHeroImageUrl
),
copyCustomGameAsset: (
sourcePath: string,
assetType: "icon" | "logo" | "hero"
) => ipcRenderer.invoke("copyCustomGameAsset", sourcePath, assetType),
saveTempFile: (fileName: string, fileData: Uint8Array) =>
ipcRenderer.invoke("saveTempFile", fileName, fileData),
deleteTempFile: (filePath: string) =>
ipcRenderer.invoke("deleteTempFile", filePath),
cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"),
updateCustomGame: (params: {
shop: GameShop;
objectId: string;
title: string;
iconUrl?: string;
logoImageUrl?: string;
libraryHeroImageUrl?: string;
originalIconPath?: string;
originalLogoPath?: string;
originalHeroPath?: string;
}) => ipcRenderer.invoke("updateCustomGame", params),
updateGameCustomAssets: (params: {
shop: GameShop;
objectId: string;
title: string;
customIconUrl?: string | null;
customLogoImageUrl?: string | null;
customHeroImageUrl?: string | null;
customOriginalIconPath?: string | null;
customOriginalLogoPath?: string | null;
customOriginalHeroPath?: string | null;
}) => ipcRenderer.invoke("updateGameCustomAssets", params),
createGameShortcut: (
shop: GameShop,
objectId: string,
@@ -143,6 +189,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -189,6 +237,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId),
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
extractGameDownload: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("extractGameDownload", shop, objectId),
getDefaultWinePrefixSelectionPath: () =>
@@ -364,6 +414,12 @@ contextBridge.exposeInMainWorld("electron", {
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
getUserLibrary: (
userId: string,
take?: number,
skip?: number,
sortBy?: string
) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy),
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) =>

View File

@@ -10,16 +10,16 @@
}
::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.03);
background-color: rgba(0, 0, 0, 0.2);
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.08);
background-color: rgba(255, 255, 255, 0.15);
border-radius: 24px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.16);
background-color: rgba(255, 255, 255, 0.25);
}
html,

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
</svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@@ -4,9 +4,14 @@
color: globals.$muted-color;
font-size: 10px;
padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit;
border: solid 1px globals.$muted-color;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
display: flex;
gap: 4px;
align-items: center;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all ease 0.2s;
}

View File

@@ -2,16 +2,36 @@
.hero {
width: 100%;
height: 280px;
min-height: 280px;
max-height: 280px;
border-radius: 4px;
height: 180px;
min-height: 150px;
border-radius: 0;
color: #dadbe1;
overflow: hidden;
box-shadow: 0px 0px 15px 0px #000000;
cursor: pointer;
border: solid 1px globals.$border-color;
z-index: 1;
flex-shrink: 0;
@media (min-width: 480px) {
height: 220px;
min-height: 200px;
}
@media (min-width: 768px) {
height: 300px;
min-height: 300px;
}
@media (min-width: 1024px) and (min-height: 800px) {
height: 400px;
min-height: 400px;
}
@media (min-width: 1024px) and (max-height: 799px) {
height: 300px;
min-height: 250px;
}
&__media {
object-fit: cover;
@@ -47,10 +67,42 @@
&__content {
width: 100%;
height: 100%;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit);
display: flex;
flex-direction: column;
justify-content: flex-end;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 1.5);
}
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
}
}
&__logo {
max-width: 100%;
height: auto;
width: 120px;
@media (min-width: 480px) {
width: 150px;
}
@media (min-width: 768px) {
width: 200px;
}
@media (min-width: 1024px) and (min-height: 800px) {
width: 250px;
}
@media (min-width: 1024px) and (max-height: 799px) {
width: 200px;
}
}
}

View File

@@ -53,6 +53,7 @@ export function Hero() {
width="250px"
alt={game.description ?? ""}
loading="eager"
className="hero__logo"
/>
<p className="hero__description">{game.description}</p>
</div>

View File

@@ -0,0 +1,52 @@
@use "../../scss/globals.scss";
.sidebar-adding-custom-game-modal {
&__container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 500px;
margin: 0 auto;
text-align: center;
}
&__form {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
text-align: left;
}
&__image-section {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
text-align: left;
}
&__image-preview {
display: flex;
justify-content: center;
align-items: center;
padding: globals.$spacing-unit;
border: 1px solid globals.$border-color;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
}
&__preview-image {
max-width: 120px;
max-height: 80px;
width: auto;
height: auto;
border-radius: 4px;
object-fit: contain;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -0,0 +1,178 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { FileDirectoryIcon } from "@primer/octicons-react";
import { Modal, TextField, Button } from "@renderer/components";
import { useLibrary, useToast } from "@renderer/hooks";
import {
buildGameDetailsPath,
generateRandomGradient,
} from "@renderer/helpers";
import "./sidebar-adding-custom-game-modal.scss";
export interface SidebarAddingCustomGameModalProps {
visible: boolean;
onClose: () => void;
}
export function SidebarAddingCustomGameModal({
visible,
onClose,
}: Readonly<SidebarAddingCustomGameModalProps>) {
const { t } = useTranslation("sidebar");
const { updateLibrary } = useLibrary();
const { showSuccessToast, showErrorToast } = useToast();
const navigate = useNavigate();
const [gameName, setGameName] = useState("");
const [executablePath, setExecutablePath] = useState("");
const [isAdding, setIsAdding] = useState(false);
const handleSelectExecutable = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: t("custom_game_modal_executable"),
extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"],
},
],
});
if (filePaths && filePaths.length > 0) {
const selectedPath = filePaths[0];
setExecutablePath(selectedPath);
if (!gameName.trim()) {
const fileName = selectedPath.split(/[\\/]/).pop() || "";
const gameNameFromFile = fileName.replace(/\.[^/.]+$/, "");
setGameName(gameNameFromFile);
}
}
};
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setGameName(event.target.value);
};
const handleAddGame = async () => {
if (!gameName.trim() || !executablePath.trim()) {
showErrorToast(t("custom_game_modal_fill_required"));
return;
}
setIsAdding(true);
try {
// Generate gradient URL only for hero image
const gameNameForSeed = gameName.trim();
const iconUrl = ""; // Don't use gradient for icon
const logoImageUrl = ""; // Don't use gradient for logo
const libraryHeroImageUrl = generateRandomGradient(); // Only use gradient for hero
const newGame = await window.electron.addCustomGameToLibrary(
gameNameForSeed,
executablePath,
iconUrl,
logoImageUrl,
libraryHeroImageUrl
);
showSuccessToast(t("custom_game_modal_success"));
updateLibrary();
const gameDetailsPath = buildGameDetailsPath({
shop: "custom",
objectId: newGame.objectId,
title: newGame.title,
});
navigate(gameDetailsPath);
setGameName("");
setExecutablePath("");
onClose();
} catch (error) {
console.error("Failed to add custom game:", error);
showErrorToast(
error instanceof Error ? error.message : t("custom_game_modal_failed")
);
} finally {
setIsAdding(false);
}
};
const handleClose = () => {
if (!isAdding) {
setGameName("");
setExecutablePath("");
onClose();
}
};
const isFormValid = gameName.trim() && executablePath.trim();
return (
<Modal
visible={visible}
title={t("custom_game_modal")}
description={t("custom_game_modal_description")}
onClose={handleClose}
>
<div className="sidebar-adding-custom-game-modal__container">
<div className="sidebar-adding-custom-game-modal__form">
<TextField
label={t("custom_game_modal_executable_path")}
placeholder={t("custom_game_modal_select_executable")}
value={executablePath}
readOnly
theme="dark"
rightContent={
<Button
type="button"
theme="outline"
onClick={handleSelectExecutable}
disabled={isAdding}
>
<FileDirectoryIcon />
{t("custom_game_modal_browse")}
</Button>
}
/>
<TextField
label={t("custom_game_modal_title")}
placeholder={t("custom_game_modal_enter_title")}
value={gameName}
onChange={handleGameNameChange}
theme="dark"
disabled={isAdding}
/>
</div>
<div className="sidebar-adding-custom-game-modal__actions">
<Button
type="button"
theme="outline"
onClick={handleClose}
disabled={isAdding}
>
{t("custom_game_modal_cancel")}
</Button>
<Button
type="button"
theme="primary"
onClick={handleAddGame}
disabled={!isFormValid || isAdding}
>
{isAdding
? t("custom_game_modal_adding")
: t("custom_game_modal_add")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -1,4 +1,5 @@
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import PlayLogo from "@renderer/assets/play-logo.svg?react";
import { LibraryGame } from "@types";
import cn from "classnames";
import { useLocation } from "react-router-dom";
@@ -16,6 +17,19 @@ export function SidebarGameItem({
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
const isCustomGame = game.shop === "custom";
const sidebarIcon = isCustomGame
? game.libraryImageUrl || game.iconUrl
: game.customIconUrl || game.iconUrl;
// Determine fallback icon based on game type
const getFallbackIcon = () => {
if (isCustomGame) {
return <PlayLogo className="sidebar__game-icon" />;
}
return <SteamLogo className="sidebar__game-icon" />;
};
return (
<li
key={game.id}
@@ -30,15 +44,15 @@ export function SidebarGameItem({
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
{sidebarIcon ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
src={sidebarIcon}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className="sidebar__game-icon" />
getFallbackIcon()
)}
<span className="sidebar__menu-item-button-label">

View File

@@ -172,4 +172,24 @@
display: block;
}
}
&__add-button {
background: none;
border: none;
color: globals.$muted-color;
cursor: pointer;
padding: 0;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&:active {
color: rgba(255, 255, 255, 0.5);
}
svg {
display: block;
}
}
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Tooltip } from "react-tooltip";
import type { LibraryGame } from "@types";
@@ -21,8 +22,13 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { CommentDiscussionIcon, PlayIcon } from "@primer/octicons-react";
import {
CommentDiscussionIcon,
PlayIcon,
PlusIcon,
} from "@primer/octicons-react";
import { SidebarGameItem } from "./sidebar-game-item";
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
import { useDispatch } from "react-redux";
@@ -63,11 +69,20 @@ export function Sidebar() {
const { showWarningToast } = useToast();
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
const [showAddGameModal, setShowAddGameModal] = useState(false);
const handlePlayButtonClick = () => {
setShowPlayableOnly(!showPlayableOnly);
};
const handleAddGameButtonClick = () => {
setShowAddGameModal(true);
};
const handleCloseAddGameModal = () => {
setShowAddGameModal(false);
};
useEffect(() => {
updateLibrary();
}, [lastPacket?.gameId, updateLibrary]);
@@ -254,15 +269,32 @@ export function Sidebar() {
<small className="sidebar__section-title">
{t("my_library")}
</small>
<button
type="button"
className={cn("sidebar__play-button", {
"sidebar__play-button--active": showPlayableOnly,
})}
onClick={handlePlayButtonClick}
<div
style={{ display: "flex", gap: "8px", alignItems: "center" }}
>
<PlayIcon size={16} />
</button>
<button
type="button"
className="sidebar__add-button"
onClick={handleAddGameButtonClick}
data-tooltip-id="add-custom-game-tooltip"
data-tooltip-content={t("add_custom_game_tooltip")}
data-tooltip-place="top"
>
<PlusIcon size={16} />
</button>
<button
type="button"
className={cn("sidebar__play-button", {
"sidebar__play-button--active": showPlayableOnly,
})}
onClick={handlePlayButtonClick}
data-tooltip-id="show-playable-only-tooltip"
data-tooltip-content={t("show_playable_only_tooltip")}
data-tooltip-place="top"
>
<PlayIcon size={16} />
</button>
</div>
</div>
<TextField
@@ -307,6 +339,14 @@ export function Sidebar() {
className="sidebar__handle"
onMouseDown={handleMouseDown}
/>
<SidebarAddingCustomGameModal
visible={showAddGameModal}
onClose={handleCloseAddGameModal}
/>
<Tooltip id="add-custom-game-tooltip" />
<Tooltip id="show-playable-only-tooltip" />
</aside>
);
}

View File

@@ -38,14 +38,12 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
isGameRunning: false,
isLoading: false,
objectId: undefined,
gameColor: "",
showRepacksModal: false,
showGameOptionsModal: false,
stats: null,
achievements: null,
hasNSFWContentBlocked: false,
lastDownloadedOption: null,
setGameColor: () => {},
selectGameExecutable: async () => null,
updateGame: async () => {},
setShowGameOptionsModal: () => {},
@@ -82,7 +80,6 @@ export function GameDetailsContextProvider({
const [stats, setStats] = useState<GameStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [gameColor, setGameColor] = useState("");
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
@@ -201,6 +198,12 @@ export function GameDetailsContextProvider({
dispatch(setHeaderTitle(gameTitle));
}, [objectId, gameTitle, dispatch]);
useEffect(() => {
if (game?.title) {
dispatch(setHeaderTitle(game.title));
}
}, [game?.title, dispatch]);
useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
const updatedIsGameRunning =
@@ -286,7 +289,6 @@ export function GameDetailsContextProvider({
isGameRunning,
isLoading,
objectId,
gameColor,
showGameOptionsModal,
showRepacksModal,
stats,
@@ -294,7 +296,6 @@ export function GameDetailsContextProvider({
hasNSFWContentBlocked,
lastDownloadedOption,
setHasNSFWContentBlocked,
setGameColor,
selectGameExecutable,
updateGame,
setShowRepacksModal,

View File

@@ -16,14 +16,12 @@ export interface GameDetailsContext {
isGameRunning: boolean;
isLoading: boolean;
objectId: string | undefined;
gameColor: string;
showRepacksModal: boolean;
showGameOptionsModal: boolean;
stats: GameStats | null;
achievements: UserAchievement[] | null;
hasNSFWContentBlocked: boolean;
lastDownloadedOption: GameRepack | null;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>;
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;

View File

@@ -1,6 +1,6 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { Badge, UserProfile, UserStats } from "@types";
import type { Badge, UserProfile, UserStats, UserGame } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@@ -14,9 +14,12 @@ export interface UserProfileContext {
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
libraryGames: UserGame[];
pinnedGames: UserGame[];
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -27,9 +30,12 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false,
userStats: null,
getUserProfile: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {},
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
libraryGames: [],
pinnedGames: [],
});
const { Provider } = userProfileContext;
@@ -49,6 +55,8 @@ export function UserProfileContextProvider({
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [libraryGames, setLibraryGames] = useState<UserGame[]>([]);
const [pinnedGames, setPinnedGames] = useState<UserGame[]>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
@@ -85,8 +93,34 @@ export function UserProfileContextProvider({
});
}, [userId]);
const getUserLibraryGames = useCallback(
async (sortBy?: string) => {
try {
const response = await window.electron.getUserLibrary(
userId,
12,
0,
sortBy
);
if (response) {
setLibraryGames(response.library);
setPinnedGames(response.pinnedGames);
} else {
setLibraryGames([]);
setPinnedGames([]);
}
} catch (error) {
setLibraryGames([]);
setPinnedGames([]);
}
},
[userId]
);
const getUserProfile = useCallback(async () => {
getUserStats();
getUserLibraryGames();
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
@@ -102,7 +136,7 @@ export function UserProfileContextProvider({
navigate(-1);
}
});
}, [navigate, getUserStats, showErrorToast, userId, t]);
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const badges = await window.electron.getBadges();
@@ -111,6 +145,8 @@ export function UserProfileContextProvider({
useEffect(() => {
setUserProfile(null);
setLibraryGames([]);
setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
getUserProfile();
@@ -124,10 +160,13 @@ export function UserProfileContextProvider({
heroBackground,
isMe,
getUserProfile,
getUserLibraryGames,
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats,
badges,
libraryGames,
pinnedGames,
}}
>
{children}

View File

@@ -37,6 +37,7 @@ import type {
ShopDetailsWithAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
UserLibraryResponse,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -111,6 +112,43 @@ declare global {
objectId: string,
title: string
) => Promise<void>;
addCustomGameToLibrary: (
title: string,
executablePath: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => Promise<Game>;
updateCustomGame: (params: {
shop: GameShop;
objectId: string;
title: string;
iconUrl?: string;
logoImageUrl?: string;
libraryHeroImageUrl?: string;
originalIconPath?: string;
originalLogoPath?: string;
originalHeroPath?: string;
}) => Promise<Game>;
copyCustomGameAsset: (
sourcePath: string,
assetType: "icon" | "logo" | "hero"
) => Promise<string>;
cleanupUnusedAssets: () => Promise<{
deletedCount: number;
errors: string[];
}>;
updateGameCustomAssets: (params: {
shop: GameShop;
objectId: string;
title: string;
customIconUrl?: string | null;
customLogoImageUrl?: string | null;
customHeroImageUrl?: string | null;
customOriginalIconPath?: string | null;
customOriginalLogoPath?: string | null;
customOriginalHeroPath?: string | null;
}) => Promise<Game>;
createGameShortcut: (
shop: GameShop,
objectId: string,
@@ -126,6 +164,11 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
pinned: boolean
) => Promise<void>;
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -162,6 +205,11 @@ declare global {
) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
changeGamePlayTime: (
shop: GameShop,
objectId: string,
playtimeInSeconds: number
) => Promise<void>;
/* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
@@ -262,6 +310,8 @@ declare global {
onCommonRedistProgress: (
cb: (value: { log: string; complete: boolean }) => void
) => () => Electron.IpcRenderer;
saveTempFile: (fileName: string, fileData: Uint8Array) => Promise<string>;
deleteTempFile: (filePath: string) => Promise<void>;
platform: NodeJS.Platform;
/* Auto update */
@@ -282,6 +332,12 @@ declare global {
/* User */
getUser: (userId: string) => Promise<UserProfile | null>;
getUserLibrary: (
userId: string,
take?: number,
skip?: number,
sortBy?: string
) => Promise<UserLibraryResponse>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
getUserFriends: (

View File

@@ -84,3 +84,23 @@ export const injectCustomCss = (
export const removeCustomCss = (target: HTMLElement = document.head) => {
target.querySelector("#custom-css")?.remove();
};
export const generateRandomGradient = (): string => {
// Use a single consistent gradient with softer colors for custom games as placeholder
const color1 = "#2c3e50"; // Dark blue-gray
const color2 = "#34495e"; // Darker slate
// Create SVG data URL that works in img tags
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#grad)" />
</svg>`;
// Return as data URL that works in img tags
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};

View File

@@ -0,0 +1,27 @@
import { useState, useCallback } from "react";
interface SectionCollapseState {
pinned: boolean;
library: boolean;
}
export function useSectionCollapse() {
const [collapseState, setCollapseState] = useState<SectionCollapseState>({
pinned: false,
library: false,
});
const toggleSection = useCallback((section: keyof SectionCollapseState) => {
setCollapseState((prevState) => ({
...prevState,
[section]: !prevState[section],
}));
}, []);
return {
collapseState,
toggleSection,
isPinnedCollapsed: collapseState.pinned,
isLibraryCollapsed: collapseState.library,
};
}

View File

@@ -9,8 +9,6 @@ import {
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
import { gameDetailsContext } from "@renderer/context";
import type { ComparedAchievements } from "@types";
import { average } from "color.js";
import Color from "color";
import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list";
import { AchievementList } from "./achievement-list";
@@ -119,15 +117,8 @@ export function AchievementsContent({
const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const {
gameTitle,
objectId,
shop,
shopDetails,
achievements,
gameColor,
setGameColor,
} = useContext(gameDetailsContext);
const { gameTitle, objectId, shop, shopDetails, achievements } =
useContext(gameDetailsContext);
const dispatch = useAppDispatch();
@@ -136,22 +127,6 @@ export function AchievementsContent({
dispatch(setHeaderTitle(gameTitle));
}, [dispatch, gameTitle]);
const handleHeroLoad = async () => {
const output = await average(
shopDetails?.assets?.libraryHeroImageUrl ?? "",
{
amount: 1,
format: "hex",
}
);
const backgroundColor = output
? (new Color(output).darken(0.7).toString() as string)
: "";
setGameColor(backgroundColor);
};
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? 150;
@@ -191,7 +166,6 @@ export function AchievementsContent({
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="achievements-content__achievements-list__image"
alt={gameTitle}
onLoad={handleHeroLoad}
/>
<section
@@ -199,12 +173,7 @@ export function AchievementsContent({
onScroll={onScroll}
className="achievements-content__achievements-list__section"
>
<div
className="achievements-content__achievements-list__section__container"
style={{
background: `linear-gradient(0deg, #151515 0%, ${gameColor} 100%)`,
}}
>
<div className="achievements-content__achievements-list__section__container">
<div
ref={heroRef}
className="achievements-content__achievements-list__section__container__hero"

View File

@@ -28,12 +28,12 @@ export function DeleteGameModal({
onClose={onClose}
>
<div className="delete-game-modal__actions">
<Button onClick={handleDeleteGame} theme="outline">
{t("delete")}
<Button onClick={onClose} theme="outline">
{t("cancel")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
<Button onClick={handleDeleteGame} theme="primary">
{t("delete")}
</Button>
</div>
</Modal>

View File

@@ -1,13 +1,17 @@
@use "../../../scss/globals.scss";
.description-header {
width: 100%;
padding: calc(globals.$spacing-unit * 2);
width: calc(100% - calc(globals.$spacing-unit * 2));
margin: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 1);
padding: calc(globals.$spacing-unit * 1.5);
display: flex;
justify-content: space-between;
align-items: center;
background-color: globals.$background-color;
height: 72px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
&__info {
display: flex;

View File

@@ -2,34 +2,64 @@
.gallery-slider {
&__container {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1);
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
max-height: 80vh;
@media (min-width: 1024px) {
max-height: 70vh;
}
@media (min-width: 1280px) {
max-height: 60vh;
}
}
&__viewport {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
border-radius: 8px;
@media (min-width: 1024px) {
width: 80%;
max-height: 400px;
}
@media (min-width: 1280px) {
width: 60%;
max-height: 500px;
}
@media (min-width: 1536px) {
width: 50%;
max-height: 600px;
}
}
&__container-inner {
display: flex;
height: 100%;
}
&__slide {
flex: 0 0 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
&__media {
width: 100%;
height: 100%;
display: block;
flex-shrink: 0;
flex-grow: 0;
transition: translate 0.3s ease-in-out;
border-radius: 4px;
align-self: center;
}
&__animation-container {
width: 100%;
height: 100%;
display: flex;
position: relative;
overflow: hidden;
@media (min-width: 1280px) {
width: 60%;
}
object-fit: cover;
}
&__preview {
@@ -42,10 +72,18 @@
overflow-y: hidden;
gap: calc(globals.$spacing-unit / 2);
@media (min-width: 1024px) {
width: 80%;
}
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
&::-webkit-scrollbar-thumb {
width: 20%;
}
@@ -68,6 +106,20 @@
border-radius: 4px;
border: solid 1px globals.$border-color;
overflow: hidden;
position: relative;
aspect-ratio: 16/9;
@media (min-width: 1024px) {
width: 15%;
}
@media (min-width: 1280px) {
width: 12%;
}
@media (min-width: 1536px) {
width: 10%;
}
&:hover {
opacity: 0.8;
@@ -83,49 +135,73 @@
display: flex;
}
&__play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.4);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: all 0.2s ease;
pointer-events: none;
.gallery-slider__preview-button:hover & {
background-color: rgba(0, 0, 0, 0.6);
transform: translate(-50%, -50%) scale(1.1);
}
}
&__button {
position: absolute;
align-self: center;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
background-color: rgba(0, 0, 0, 0.4);
transition: all 0.2s ease-in-out;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
color: globals.$muted-color;
width: 48px;
height: 48px;
border: none;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
&:hover {
background-color: rgba(0, 0, 0, 0.6);
}
&:active {
transform: scale(0.95);
transform: translateY(-50%) scale(0.95);
}
&--left {
left: 0;
margin-left: globals.$spacing-unit;
transform: translateX(calc(-1 * (48px + globals.$spacing-unit)));
left: globals.$spacing-unit;
transform: translateY(-50%) translateX(-100px);
opacity: 0;
&.gallery-slider__button--visible {
transform: translateX(0);
.gallery-slider__viewport:hover & {
transform: translateY(-50%) translateX(0);
opacity: 1;
}
}
&--right {
right: 0;
margin-right: globals.$spacing-unit;
transform: translateX(calc(48px + globals.$spacing-unit));
right: globals.$spacing-unit;
transform: translateY(-50%) translateX(100px);
opacity: 0;
&.gallery-slider__button--visible {
transform: translateX(0);
.gallery-slider__viewport:hover & {
transform: translateY(-50%) translateX(0);
opacity: 1;
}
}
&--hidden {
opacity: 0;
}
}
}

View File

@@ -1,90 +1,144 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useContext, useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import {
ChevronRightIcon,
ChevronLeftIcon,
PlayIcon,
} from "@primer/octicons-react";
import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context";
import "./gallery-slider.scss";
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const mediaContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation("game_details");
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
const hasMovies = shopDetails && shopDetails.movies?.length;
const mediaCount = useMemo(() => {
if (!shopDetails) return 0;
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
const [selectedIndex, setSelectedIndex] = useState(0);
if (shopDetails.screenshots && shopDetails.movies) {
return shopDetails.screenshots.length + shopDetails.movies.length;
} else if (shopDetails.movies) {
return shopDetails.movies.length;
} else if (shopDetails.screenshots) {
return shopDetails.screenshots.length;
}
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
}, [emblaApi]);
return 0;
}, [shopDetails]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const [mediaIndex, setMediaIndex] = useState(0);
const [showArrows, setShowArrows] = useState(false);
const scrollTo = useCallback(
(index: number) => {
if (emblaApi) emblaApi.scrollTo(index);
},
[emblaApi]
);
const showNextImage = () => {
setMediaIndex((index: number) => {
if (index === mediaCount - 1) return 0;
const scrollToPreview = useCallback(
(index: number, event: React.MouseEvent<HTMLButtonElement>) => {
scrollTo(index);
return index + 1;
});
};
const button = event.currentTarget;
const previewContainer = button.parentElement;
const showPrevImage = () => {
setMediaIndex((index: number) => {
if (index === 0) return mediaCount - 1;
if (previewContainer) {
const containerRect = previewContainer.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
return index - 1;
});
};
const isOffScreenLeft = buttonRect.left < containerRect.left;
const isOffScreenRight = buttonRect.right > containerRect.right;
useEffect(() => {
setMediaIndex(0);
}, [shopDetails]);
useEffect(() => {
if (hasMovies && mediaContainerRef.current) {
mediaContainerRef.current.childNodes.forEach((node, index) => {
if (node instanceof HTMLVideoElement) {
if (index !== mediaIndex) {
node.pause();
}
if (isOffScreenLeft || isOffScreenRight) {
button.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}
},
[scrollTo]
);
useEffect(() => {
if (!emblaApi) return;
let isInitialLoad = true;
const onSelect = () => {
const newIndex = emblaApi.selectedScrollSnap();
setSelectedIndex(newIndex);
if (!isInitialLoad) {
const videos = document.querySelectorAll(".gallery-slider__media");
videos.forEach((video) => {
if (video instanceof HTMLVideoElement) {
video.pause();
}
});
}
isInitialLoad = false;
};
emblaApi.on("select", onSelect);
onSelect();
return () => {
emblaApi.off("select", onSelect);
};
}, [emblaApi]);
const mediaItems = useMemo(() => {
const items: Array<{
id: string;
type: "video" | "image";
src?: string;
poster?: string;
videoSrc?: string;
alt: string;
}> = [];
if (shopDetails?.movies) {
shopDetails.movies.forEach((video, index) => {
items.push({
id: String(video.id),
type: "video",
poster: video.thumbnail,
videoSrc: video.mp4.max.startsWith("http://")
? video.mp4.max.replace("http://", "https://")
: video.mp4.max,
alt: t("video", { number: String(index + 1) }),
});
});
}
}, [hasMovies, mediaContainerRef, mediaIndex]);
useEffect(() => {
if (scrollContainerRef.current) {
const container = scrollContainerRef.current;
const totalWidth = container.scrollWidth - container.clientWidth;
const itemWidth = totalWidth / (mediaCount - 1);
const scrollLeft = mediaIndex * itemWidth;
container.scrollLeft = scrollLeft;
if (shopDetails?.screenshots) {
shopDetails.screenshots.forEach((image, index) => {
items.push({
id: String(image.id),
type: "image",
src: image.path_full,
alt: t("screenshot", { number: String(index + 1) }),
});
});
}
}, [shopDetails, mediaIndex, mediaCount]);
return items;
}, [shopDetails, t]);
const previews = useMemo(() => {
const screenshotPreviews =
shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({
id,
thumbnail: path_thumbnail,
type: "image" as const,
})) ?? [];
if (shopDetails?.movies) {
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
id,
thumbnail,
type: "video" as const,
}));
return [...moviePreviews, ...screenshotPreviews];
@@ -93,96 +147,87 @@ export function GallerySlider() {
return screenshotPreviews;
}, [shopDetails]);
if (!hasScreenshots) {
return null;
}
return (
<>
{hasScreenshots && (
<div className="gallery-slider__container">
<div
onMouseEnter={() => setShowArrows(true)}
onMouseLeave={() => setShowArrows(false)}
className="gallery-slider__animation-container"
ref={mediaContainerRef}
>
{shopDetails.movies &&
shopDetails.movies.map((video) => (
<div className="gallery-slider__container">
<div className="gallery-slider__viewport" ref={emblaRef}>
<div className="gallery-slider__container-inner">
{mediaItems.map((item) => (
<div key={item.id} className="gallery-slider__slide">
{item.type === "video" ? (
<video
key={video.id}
controls
className="gallery-slider__media"
poster={video.thumbnail}
style={{ translate: `${-100 * mediaIndex}%` }}
poster={item.poster}
loop
muted
autoPlay
tabIndex={-1}
>
<source src={video.mp4.max.replace("http", "https")} />
<source src={item.videoSrc} />
</video>
))}
{hasScreenshots &&
shopDetails.screenshots?.map((image, i) => (
) : (
<img
key={image.id}
className="gallery-slider__media"
src={image.path_full}
style={{ translate: `${-100 * mediaIndex}%` }}
alt={t("screenshot", { number: i + 1 })}
src={item.src}
alt={item.alt}
loading="lazy"
/>
))}
<button
onClick={showPrevImage}
type="button"
className={`gallery-slider__button gallery-slider__button--left ${
showArrows
? "gallery-slider__button--visible"
: "gallery-slider__button--hidden"
}`}
aria-label={t("previous_screenshot")}
tabIndex={0}
>
<ChevronLeftIcon size={36} />
</button>
<button
onClick={showNextImage}
type="button"
className={`gallery-slider__button gallery-slider__button--right ${
showArrows
? "gallery-slider__button--visible"
: "gallery-slider__button--hidden"
}`}
aria-label={t("next_screenshot")}
tabIndex={0}
>
<ChevronRightIcon size={36} />
</button>
</div>
<div className="gallery-slider__preview" ref={scrollContainerRef}>
{previews.map((media, i) => (
<button
key={media.id}
type="button"
className={`gallery-slider__preview-button ${
mediaIndex === i
? "gallery-slider__preview-button--active"
: ""
}`}
onClick={() => setMediaIndex(i)}
aria-label={t("open_screenshot", { number: i + 1 })}
>
<img
src={media.thumbnail}
className="gallery-slider__preview-image"
alt={t("screenshot", { number: i + 1 })}
/>
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</>
<button
onClick={scrollPrev}
type="button"
className="gallery-slider__button gallery-slider__button--left"
aria-label={t("previous_screenshot")}
tabIndex={0}
>
<ChevronLeftIcon size={36} />
</button>
<button
onClick={scrollNext}
type="button"
className="gallery-slider__button gallery-slider__button--right"
aria-label={t("next_screenshot")}
tabIndex={0}
>
<ChevronRightIcon size={36} />
</button>
</div>
<div className="gallery-slider__preview">
{previews.map((media, i) => (
<button
key={media.id}
type="button"
className={`gallery-slider__preview-button ${
selectedIndex === i
? "gallery-slider__preview-button--active"
: ""
}`}
onClick={(e) => scrollToPreview(i, e)}
aria-label={t("open_screenshot", { number: String(i + 1) })}
>
<img
src={media.thumbnail}
className="gallery-slider__preview-image"
alt={t("screenshot", { number: String(i + 1) })}
/>
{media.type === "video" && (
<div className="gallery-slider__play-overlay">
<PlayIcon size={20} />
</div>
)}
</button>
))}
</div>
</div>
);
}

View File

@@ -1,18 +1,18 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { average } from "color.js";
import Color from "color";
import { PencilIcon } from "@primer/octicons-react";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import { EditGameModal } from "./modals";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails } from "@renderer/hooks";
import { useUserDetails, useLibrary } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
@@ -21,18 +21,13 @@ export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const {
objectId,
shopDetails,
game,
gameColor,
setGameColor,
hasNSFWContentBlocked,
} = useContext(gameDetailsContext);
const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails();
const { updateLibrary } = useLibrary();
const { setShowCloudSyncModal, getGameArtifacts } =
useContext(cloudSyncContext);
@@ -48,31 +43,43 @@ export function GameDetailsContent() {
const $images = Array.from(document.querySelectorAll("img"));
$images.forEach(($image) => {
$image.loading = "lazy";
// Remove any inline width/height styles that might cause overflow
$image.removeAttribute("width");
$image.removeAttribute("height");
$image.removeAttribute("style");
// Set max-width to prevent overflow
$image.style.maxWidth = "100%";
$image.style.width = "auto";
$image.style.height = "auto";
$image.style.boxSizing = "border-box";
});
// Handle videos the same way
const $videos = Array.from(document.querySelectorAll("video"));
$videos.forEach(($video) => {
// Remove any inline width/height styles that might cause overflow
$video.removeAttribute("width");
$video.removeAttribute("height");
$video.removeAttribute("style");
// Set max-width to prevent overflow
$video.style.maxWidth = "100%";
$video.style.width = "auto";
$video.style.height = "auto";
$video.style.boxSizing = "border-box";
});
return document.body.outerHTML;
}
if (game?.shop === "custom") {
return "";
}
return t("no_shop_details");
}, [shopDetails, t]);
}, [shopDetails, t, game?.shop]);
const [backdropOpacity, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => {
const output = await average(
shopDetails?.assets?.libraryHeroImageUrl ?? "",
{
amount: 1,
format: "hex",
}
);
const backgroundColor = output
? new Color(output).darken(0.7).toString()
: "";
setGameColor(backgroundColor);
};
const [showEditGameModal, setShowEditGameModal] = useState(false);
useEffect(() => {
setBackdropOpacity(1);
@@ -92,10 +99,72 @@ export function GameDetailsContent() {
setShowCloudSyncModal(true);
};
const handleEditGameClick = () => {
setShowEditGameModal(true);
};
const handleGameUpdated = (_updatedGame: any) => {
updateGame();
updateLibrary();
};
useEffect(() => {
getGameArtifacts();
}, [getGameArtifacts]);
const isCustomGame = game?.shop === "custom";
// Helper function to get image with custom asset priority
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
originalUrl: string | null | undefined,
fallbackUrl?: string | null | undefined
) => {
return customUrl || originalUrl || fallbackUrl || "";
};
const heroImage = isCustomGame
? game?.libraryHeroImageUrl || game?.iconUrl || ""
: getImageWithCustomPriority(
game?.customHeroImageUrl,
shopDetails?.assets?.libraryHeroImageUrl
);
const logoImage = isCustomGame
? game?.logoImageUrl || ""
: getImageWithCustomPriority(
game?.customLogoImageUrl,
shopDetails?.assets?.logoImageUrl
);
const renderGameLogo = () => {
if (isCustomGame) {
// For custom games, show logo image if available, otherwise show game title as text
if (logoImage) {
return (
<img
src={logoImage}
className="game-details__game-logo"
alt={game?.title}
/>
);
} else {
return (
<div className="game-details__game-logo-text">{game?.title}</div>
);
}
} else {
// For non-custom games, show logo image if available
return logoImage ? (
<img
src={logoImage}
className="game-details__game-logo"
alt={game?.title}
/>
) : null;
}
};
return (
<div
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
@@ -103,15 +172,13 @@ export function GameDetailsContent() {
<section className="game-details__container">
<div ref={heroRef} className="game-details__hero">
<img
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
src={heroImage}
className="game-details__hero-image"
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div
className="game-details__hero-backdrop"
style={{
backgroundColor: gameColor,
flex: 1,
}}
/>
@@ -121,26 +188,35 @@ export function GameDetailsContent() {
style={{ opacity: backdropOpacity }}
>
<div className="game-details__hero-content">
<img
src={shopDetails?.assets?.logoImageUrl ?? ""}
className="game-details__game-logo"
alt={game?.title}
/>
{renderGameLogo()}
<button
type="button"
className="game-details__cloud-sync-button"
onClick={handleCloudSaveButtonClick}
>
<div className="game-details__cloud-icon-container">
<img
src={cloudIconAnimated}
alt="Cloud icon"
className="game-details__cloud-icon"
/>
</div>
{t("cloud_save")}
</button>
<div className="game-details__hero-buttons game-details__hero-buttons--right">
<button
type="button"
className="game-details__edit-custom-game-button"
onClick={handleEditGameClick}
title={t("edit_game_modal_button")}
>
<PencilIcon size={16} />
</button>
{game?.shop !== "custom" && (
<button
type="button"
className="game-details__cloud-sync-button"
onClick={handleCloudSaveButtonClick}
>
<div className="game-details__cloud-icon-container">
<img
src={cloudIconAnimated}
alt="Cloud icon"
className="game-details__cloud-icon"
/>
</div>
{t("cloud_save")}
</button>
)}
</div>
</div>
</div>
</div>
@@ -160,9 +236,17 @@ export function GameDetailsContent() {
/>
</div>
<Sidebar />
{game?.shop !== "custom" && <Sidebar />}
</div>
</section>
<EditGameModal
visible={showEditGameModal}
onClose={() => setShowEditGameModal(false)}
game={game}
shopDetails={shopDetails}
onGameUpdated={handleGameUpdated}
/>
</div>
);
}

View File

@@ -18,7 +18,6 @@ $hero-height: 300px;
&__wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
transition: all ease 0.3s;
@@ -44,12 +43,53 @@ $hero-height: 300px;
}
&__hero-content {
padding: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2);
}
}
&__hero-buttons {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
&--right {
margin-left: auto;
}
}
&__edit-custom-game-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__hero-logo-backdrop {
@@ -64,8 +104,8 @@ $hero-height: 300px;
&__hero-image {
width: 100%;
height: $hero-height;
min-height: $hero-height;
height: calc($hero-height + 72px);
min-height: calc($hero-height + 72px);
object-fit: cover;
object-position: top;
transition: all ease 0.2s;
@@ -74,14 +114,46 @@ $hero-height: 300px;
@media (min-width: 1250px) {
object-position: center;
height: 350px;
min-height: 350px;
height: calc(350px + 72px);
min-height: calc(350px + 72px);
}
}
&__game-logo {
width: 300px;
width: 200px;
align-self: flex-end;
@media (min-width: 768px) {
width: 250px;
}
@media (min-width: 1024px) {
width: 300px;
}
}
&__game-logo-text {
width: 200px;
align-self: flex-end;
font-size: 1.8rem;
font-weight: bold;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
text-align: left;
line-height: 1.2;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
@media (min-width: 768px) {
width: 250px;
font-size: 2.2rem;
}
@media (min-width: 1024px) {
width: 300px;
font-size: 2.5rem;
}
}
&__hero-image-skeleton {
@@ -97,7 +169,6 @@ $hero-height: 300px;
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
z-index: 1;
}
@@ -105,6 +176,7 @@ $hero-height: 300px;
display: flex;
width: 100%;
flex: 1;
min-width: 0;
background: linear-gradient(
0deg,
globals.$background-color 50%,
@@ -114,30 +186,53 @@ $hero-height: 300px;
&__description-content {
width: 100%;
height: 100%;
min-height: 100%;
min-width: 0;
flex: 1;
overflow-x: hidden;
}
&__description {
user-select: text;
line-height: 22px;
font-size: globals.$body-font-size;
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
width: 100%;
margin-left: auto;
margin-right: auto;
overflow-x: auto;
min-height: auto;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
}
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 80%;
}
@media (min-width: 1280px) {
width: 60%;
}
img {
@media (min-width: 1536px) {
width: 50%;
}
img,
video {
border-radius: 5px;
margin-top: globals.$spacing-unit;
margin-bottom: calc(globals.$spacing-unit * 3);
display: block;
width: 100%;
height: auto;
object-fit: cover;
display: block !important;
max-width: 100% !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
box-sizing: border-box !important;
word-wrap: break-word;
overflow-wrap: break-word;
}
a {
@@ -154,15 +249,28 @@ $hero-height: 300px;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
}
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 80%;
}
@media (min-width: 1280px) {
width: 60%;
line-height: 22px;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__randomizer-button {

View File

@@ -178,6 +178,7 @@ export default function GameDetails() {
onClose={() => {
setShowGameOptionsModal(false);
}}
onNavigateHome={() => navigate("/")}
/>
)}

View File

@@ -3,11 +3,18 @@ import {
GearIcon,
HeartFillIcon,
HeartIcon,
PinIcon,
PinSlashIcon,
PlayIcon,
PlusCircleIcon,
} from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import {
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
@@ -19,6 +26,7 @@ export function HeroPanelActions() {
useState(false);
const { isGameDeleting } = useDownload();
const { userDetails } = useUserDetails();
const {
game,
@@ -82,6 +90,29 @@ export function HeroPanelActions() {
}
};
const toggleGamePinned = async () => {
setToggleLibraryGameDisabled(true);
try {
if (game?.isPinned && objectId) {
await window.electron.toggleGamePin(shop, objectId, false).then(() => {
showSuccessToast(t("game_removed_from_pinned"));
});
} else {
if (!objectId) return;
await window.electron.toggleGamePin(shop, objectId, true).then(() => {
showSuccessToast(t("game_added_to_pinned"));
});
}
updateLibrary();
updateGame();
} finally {
setToggleLibraryGameDisabled(false);
}
};
const openGame = async () => {
if (game) {
if (game.executablePath) {
@@ -198,6 +229,17 @@ export function HeroPanelActions() {
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
</Button>
{userDetails && game.shop !== "custom" && (
<Button
onClick={toggleGamePinned}
theme="outline"
disabled={deleting}
className="hero-panel-actions__action"
>
{game.isPinned ? <PinSlashIcon /> : <PinIcon />}
</Button>
)}
<Button
onClick={() => setShowGameOptionsModal(true)}
theme="outline"

View File

@@ -12,4 +12,16 @@
color: globals.$body-color;
text-decoration: underline;
}
&__play-time {
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
}
&__manual-warning {
color: #f59e0b; // Warning amber color
flex-shrink: 0;
}
}

View File

@@ -5,10 +5,13 @@ import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { AlertFillIcon } from "@primer/octicons-react";
import { Tooltip } from "react-tooltip";
import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat();
@@ -85,7 +88,26 @@ export function HeroPanelPlaytime() {
return (
<>
<p>
<p
className="hero-panel-playtime__play-time"
data-tooltip-place="right"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={
game.hasManuallyUpdatedPlaytime
? "manual-playtime-warning"
: undefined
}
>
{game.hasManuallyUpdatedPlaytime && (
<AlertFillIcon
size={16}
className="hero-panel-playtime__manual-warning"
/>
)}
{t("play_time", {
amount: formattedPlayTime,
})}
@@ -100,6 +122,16 @@ export function HeroPanelPlaytime() {
})}
</p>
)}
{game.hasManuallyUpdatedPlaytime && (
<Tooltip
id="manual-playtime-warning"
style={{
zIndex: 9999,
}}
openOnClick={false}
/>
)}
</>
);
}

View File

@@ -5,18 +5,24 @@
height: 72px;
min-height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
background-color: globals.$dark-background-color;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: solid 1px rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: space-between;
transition: all ease 0.2s;
border-bottom: solid 1px globals.$border-color;
position: sticky;
overflow: hidden;
top: 0;
z-index: 2;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
&--stuck {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}

View File

@@ -14,7 +14,7 @@ export function HeroPanel() {
const { formatDate } = useDate();
const { game, repacks, gameColor } = useContext(gameDetailsContext);
const { game, repacks } = useContext(gameDetailsContext);
const { lastPacket } = useDownload();
@@ -50,7 +50,7 @@ export function HeroPanel() {
game?.download?.status === "paused";
return (
<div style={{ backgroundColor: gameColor }} className="hero-panel">
<div className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />

View File

@@ -0,0 +1,39 @@
@use "../../../scss/globals.scss";
.change-game-playtime-modal {
&__content {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit * 2;
}
&__warning {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
padding: globals.$spacing-unit;
background-color: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
color: #ffc107;
font-size: 14px;
svg {
flex-shrink: 0;
}
}
&__inputs {
display: flex;
gap: globals.$spacing-unit;
align-items: flex-end;
}
&__actions {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,167 @@
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Game } from "@types";
import { useState, useEffect } from "react";
import { AlertIcon } from "@primer/octicons-react";
import "./change-game-playtime-modal.scss";
export interface ChangeGamePlaytimeModalProps {
visible: boolean;
game: Game;
onClose: () => void;
changePlaytime: (playTimeInSeconds: number) => Promise<void>;
onSuccess?: (message: string) => void;
onError?: (message: string) => void;
}
export function ChangeGamePlaytimeModal({
onClose,
game,
visible,
changePlaytime,
onSuccess,
onError,
}: Readonly<ChangeGamePlaytimeModalProps>) {
const { t } = useTranslation("game_details");
const [hours, setHours] = useState("");
const [minutes, setMinutes] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (visible && game.playTimeInMilliseconds) {
const totalMinutes = Math.floor(
game.playTimeInMilliseconds / (1000 * 60)
);
const currentHours = Math.floor(totalMinutes / 60);
const currentMinutes = totalMinutes % 60;
setHours(currentHours.toString());
setMinutes(currentMinutes.toString());
} else if (visible) {
setHours("");
setMinutes("");
}
}, [visible, game.playTimeInMilliseconds]);
const MAX_TOTAL_HOURS = 10000;
const currentHours = parseInt(hours) || 0;
const currentMinutes = parseInt(minutes) || 0;
const maxAllowedHours = Math.min(
MAX_TOTAL_HOURS,
Math.floor(MAX_TOTAL_HOURS - currentMinutes / 60)
);
const maxAllowedMinutes =
currentHours >= MAX_TOTAL_HOURS
? 0
: Math.min(59, Math.floor((MAX_TOTAL_HOURS - currentHours) * 60));
const handleChangePlaytime = async () => {
const hoursNum = parseInt(hours) || 0;
const minutesNum = parseInt(minutes) || 0;
const totalSeconds = hoursNum * 3600 + minutesNum * 60;
if (totalSeconds < 0) return;
if (hoursNum + minutesNum / 60 > MAX_TOTAL_HOURS) return;
setIsSubmitting(true);
try {
await changePlaytime(totalSeconds);
onSuccess?.(t("update_playtime_success"));
onClose();
} catch (error) {
onError?.(t("update_playtime_error"));
} finally {
setIsSubmitting(false);
}
};
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, "") || "0";
}
const numValue = parseInt(value) || 0;
if (numValue <= maxAllowedHours) {
setHours(value);
}
};
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, "") || "0";
}
const numValue = parseInt(value) || 0;
if (numValue <= maxAllowedMinutes) {
setMinutes(value);
}
};
const isValid = hours !== "" || minutes !== "";
return (
<Modal
visible={visible}
onClose={onClose}
title={t("update_playtime_title")}
description={t("update_playtime_description", {
game: game.title,
})}
>
<div className="change-game-playtime-modal__content">
{!game.hasManuallyUpdatedPlaytime && (
<div className="change-game-playtime-modal__warning">
<AlertIcon size={16} />
<span>{t("manual_playtime_warning")}</span>
</div>
)}
<div className="change-game-playtime-modal__inputs">
<TextField
label={t("hours")}
type="number"
min="0"
max={maxAllowedHours.toString()}
value={hours}
onChange={handleHoursChange}
placeholder="0"
theme="dark"
/>
<TextField
label={t("minutes")}
type="number"
min="0"
max={maxAllowedMinutes.toString()}
value={minutes}
onChange={handleMinutesChange}
placeholder="0"
theme="dark"
/>
</div>
<div className="change-game-playtime-modal__actions">
<Button onClick={onClose} theme="outline">
{t("cancel")}
</Button>
<Button
onClick={handleChangePlaytime}
theme="primary"
disabled={!isValid || isSubmitting}
>
{t("update_playtime")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,181 @@
.edit-game-modal__container {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
max-width: 500px;
}
.edit-game-modal__form {
display: flex;
flex-direction: column;
gap: 16px;
}
.edit-game-modal__asset-selector {
margin-bottom: 8px;
}
.edit-game-modal__asset-tabs {
display: flex;
gap: 8px;
margin-bottom: 4px;
button {
flex: 1;
min-width: 0;
}
}
.edit-game-modal__asset-label {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
margin-bottom: 4px;
}
.edit-game-modal__image-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-game-modal__resolution-info {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: -4px;
margin-bottom: 4px;
}
.edit-game-modal__image-preview {
display: flex;
justify-content: center;
align-items: center;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 8px;
background-color: var(--color-background-secondary);
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.1) 25%,
transparent 25%
),
linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0px;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
position: relative;
/* Reset button styles when used as button element */
&[type="button"] {
font: inherit;
color: inherit;
text-align: inherit;
text-decoration: none;
outline: none;
cursor: pointer;
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&:hover {
border-color: var(--color-primary);
}
}
.edit-game-modal__drop-zone {
min-height: 120px;
cursor: pointer;
border-style: dashed !important;
&:hover {
border-color: var(--color-primary);
background-color: rgba(var(--color-primary-rgb), 0.05);
}
&--active {
border-color: var(--color-primary) !important;
background-color: rgba(var(--color-primary-rgb), 0.1) !important;
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3);
}
}
.edit-game-modal__drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(var(--color-primary-rgb), 0.9);
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: white;
font-weight: 600;
font-size: 14px;
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.edit-game-modal__drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--color-text-secondary);
font-size: 14px;
svg {
width: 24px;
height: 24px;
opacity: 0.6;
}
}
.edit-game-modal__icon-preview {
max-width: 200px;
margin: 0 auto;
}
.edit-game-modal__preview-image {
max-width: 100%;
max-height: 120px;
object-fit: contain;
border-radius: 4px;
}
.edit-game-modal__actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 8px;
}
.edit-game-modal__actions button {
min-width: 100px;
}

View File

@@ -0,0 +1,642 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ImageIcon, XIcon } from "@primer/octicons-react";
import { Modal, TextField, Button } from "@renderer/components";
import { useToast } from "@renderer/hooks";
import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types";
import "./edit-game-modal.scss";
export interface EditGameModalProps {
visible: boolean;
onClose: () => void;
game: LibraryGame | Game | null;
shopDetails?: ShopDetailsWithAssets | null;
onGameUpdated: (updatedGame: LibraryGame | Game) => void;
}
type AssetType = "icon" | "logo" | "hero";
export function EditGameModal({
visible,
onClose,
game,
shopDetails,
onGameUpdated,
}: Readonly<EditGameModalProps>) {
const { t } = useTranslation("sidebar");
const { showSuccessToast, showErrorToast } = useToast();
const [gameName, setGameName] = useState("");
const [assetPaths, setAssetPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [assetDisplayPaths, setAssetDisplayPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [originalAssetPaths, setOriginalAssetPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [defaultUrls, setDefaultUrls] = useState({
icon: null as string | null,
logo: null as string | null,
hero: null as string | null,
});
const [isUpdating, setIsUpdating] = useState(false);
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
const isCustomGame = (game: LibraryGame | Game): boolean => {
return game.shop === "custom";
};
const extractLocalPath = (url: string | null | undefined): string => {
return url?.startsWith("local:") ? url.replace("local:", "") : "";
};
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
setAssetPaths({
icon: extractLocalPath(game.iconUrl),
logo: extractLocalPath(game.logoImageUrl),
hero: extractLocalPath(game.libraryHeroImageUrl),
});
setAssetDisplayPaths({
icon: extractLocalPath(game.iconUrl),
logo: extractLocalPath(game.logoImageUrl),
hero: extractLocalPath(game.libraryHeroImageUrl),
});
setOriginalAssetPaths({
icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl),
logo:
(game as any).originalLogoPath || extractLocalPath(game.logoImageUrl),
hero:
(game as any).originalHeroPath ||
extractLocalPath(game.libraryHeroImageUrl),
});
}, []);
const setNonCustomGameAssets = useCallback(
(game: LibraryGame) => {
setAssetPaths({
icon: extractLocalPath(game.customIconUrl),
logo: extractLocalPath(game.customLogoImageUrl),
hero: extractLocalPath(game.customHeroImageUrl),
});
setAssetDisplayPaths({
icon: extractLocalPath(game.customIconUrl),
logo: extractLocalPath(game.customLogoImageUrl),
hero: extractLocalPath(game.customHeroImageUrl),
});
setOriginalAssetPaths({
icon:
(game as any).customOriginalIconPath ||
extractLocalPath(game.customIconUrl),
logo:
(game as any).customOriginalLogoPath ||
extractLocalPath(game.customLogoImageUrl),
hero:
(game as any).customOriginalHeroPath ||
extractLocalPath(game.customHeroImageUrl),
});
setDefaultUrls({
icon: shopDetails?.assets?.iconUrl || game.iconUrl || null,
logo: shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null,
hero:
shopDetails?.assets?.libraryHeroImageUrl ||
game.libraryHeroImageUrl ||
null,
});
},
[shopDetails]
);
useEffect(() => {
if (game && visible) {
setGameName(game.title || "");
if (isCustomGame(game)) {
setCustomGameAssets(game);
} else {
setNonCustomGameAssets(game as LibraryGame);
}
}
}, [game, visible, shopDetails, setCustomGameAssets, setNonCustomGameAssets]);
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setGameName(event.target.value);
};
const handleAssetTypeChange = (assetType: AssetType) => {
setSelectedAssetType(assetType);
};
const getAssetPath = (assetType: AssetType): string => {
return assetPaths[assetType];
};
const getAssetDisplayPath = (assetType: AssetType): string => {
// Use original path if available, otherwise fall back to display path
return originalAssetPaths[assetType] || assetDisplayPaths[assetType];
};
const setAssetPath = (assetType: AssetType, path: string): void => {
setAssetPaths((prev) => ({ ...prev, [assetType]: path }));
};
const setAssetDisplayPath = (assetType: AssetType, path: string): void => {
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path }));
};
const getDefaultUrl = (assetType: AssetType): string | null => {
return defaultUrls[assetType];
};
const handleSelectAsset = async (assetType: AssetType) => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: t("edit_game_modal_image_filter"),
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const originalPath = filePaths[0];
try {
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
originalPath,
assetType
);
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
setAssetDisplayPath(assetType, originalPath);
// Store the original path for display purposes
setOriginalAssetPaths((prev) => ({
...prev,
[assetType]: originalPath,
}));
} catch (error) {
console.error(`Failed to copy ${assetType} asset:`, error);
setAssetPath(assetType, originalPath);
setAssetDisplayPath(assetType, originalPath);
setOriginalAssetPaths((prev) => ({
...prev,
[assetType]: originalPath,
}));
}
}
};
const handleRestoreDefault = (assetType: AssetType) => {
setAssetPath(assetType, "");
setAssetDisplayPath(assetType, "");
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
};
const getOriginalTitle = (): string => {
if (!game) return "";
// For non-custom games, the original title is from shopDetails assets
return shopDetails?.assets?.title || game.title || "";
};
const handleRestoreDefaultTitle = () => {
const originalTitle = getOriginalTitle();
setGameName(originalTitle);
};
const isTitleChanged = useMemo((): boolean => {
if (!game || isCustomGame(game)) return false;
const originalTitle = getOriginalTitle();
return gameName.trim() !== originalTitle.trim();
}, [game, gameName, shopDetails]);
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragEnter = (e: React.DragEvent, target: string) => {
e.preventDefault();
e.stopPropagation();
setDragOverTarget(target);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragOverTarget(null);
}
};
const validateImageFile = (file: File): boolean => {
const validTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
return validTypes.includes(file.type);
};
const processDroppedFile = async (file: File, assetType: AssetType) => {
setDragOverTarget(null);
if (!validateImageFile(file)) {
showErrorToast("Invalid file type. Please select an image file.");
return;
}
try {
let filePath: string;
interface ElectronFile extends File {
path?: string;
}
if ("path" in file && typeof (file as ElectronFile).path === "string") {
filePath = (file as ElectronFile).path!;
} else {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const tempFileName = `temp_${Date.now()}_${file.name}`;
const tempPath = await window.electron.saveTempFile?.(
tempFileName,
uint8Array
);
if (!tempPath) {
throw new Error(
"Unable to process file. Drag and drop may not be fully supported."
);
}
filePath = tempPath;
}
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
filePath,
assetType
);
const assetPath = copiedAssetUrl.replace("local:", "");
setAssetPath(assetType, assetPath);
setAssetDisplayPath(assetType, filePath);
showSuccessToast(
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
);
if (!("path" in file) && filePath) {
try {
await window.electron.deleteTempFile?.(filePath);
} catch (cleanupError) {
console.warn("Failed to clean up temporary file:", cleanupError);
}
}
} catch (error) {
console.error(`Failed to process dropped ${assetType}:`, error);
showErrorToast(
`Failed to process dropped ${assetType}. Please try again.`
);
}
};
const handleAssetDrop = async (e: React.DragEvent, assetType: AssetType) => {
e.preventDefault();
e.stopPropagation();
setDragOverTarget(null);
if (isUpdating) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await processDroppedFile(files[0], assetType);
}
};
// Helper function to prepare custom game assets
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
const iconUrl = assetPaths.icon ? `local:${assetPaths.icon}` : game.iconUrl;
const logoImageUrl = assetPaths.logo
? `local:${assetPaths.logo}`
: game.logoImageUrl;
const libraryHeroImageUrl = assetPaths.hero
? `local:${assetPaths.hero}`
: game.libraryHeroImageUrl;
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
};
// Helper function to prepare non-custom game assets
const prepareNonCustomGameAssets = () => {
return {
customIconUrl: assetPaths.icon ? `local:${assetPaths.icon}` : null,
customLogoImageUrl: assetPaths.logo ? `local:${assetPaths.logo}` : null,
customHeroImageUrl: assetPaths.hero ? `local:${assetPaths.hero}` : null,
};
};
// Helper function to update custom game
const updateCustomGame = async (game: LibraryGame | Game) => {
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
prepareCustomGameAssets(game);
return window.electron.updateCustomGame({
shop: game.shop,
objectId: game.objectId,
title: gameName.trim(),
iconUrl: iconUrl || undefined,
logoImageUrl: logoImageUrl || undefined,
libraryHeroImageUrl: libraryHeroImageUrl || undefined,
originalIconPath: originalAssetPaths.icon || undefined,
originalLogoPath: originalAssetPaths.logo || undefined,
originalHeroPath: originalAssetPaths.hero || undefined,
});
};
// Helper function to update non-custom game
const updateNonCustomGame = async (game: LibraryGame) => {
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
prepareNonCustomGameAssets();
return window.electron.updateGameCustomAssets({
shop: game.shop,
objectId: game.objectId,
title: gameName.trim(),
customIconUrl,
customLogoImageUrl,
customHeroImageUrl,
customOriginalIconPath: originalAssetPaths.icon || undefined,
customOriginalLogoPath: originalAssetPaths.logo || undefined,
customOriginalHeroPath: originalAssetPaths.hero || undefined,
});
};
const handleUpdateGame = async () => {
if (!game || !gameName.trim()) {
showErrorToast(t("edit_game_modal_fill_required"));
return;
}
setIsUpdating(true);
try {
const updatedGame =
game && isCustomGame(game)
? await updateCustomGame(game)
: await updateNonCustomGame(game as LibraryGame);
showSuccessToast(t("edit_game_modal_success"));
onGameUpdated(updatedGame);
onClose();
} catch (error) {
console.error("Failed to update game:", error);
showErrorToast(
error instanceof Error ? error.message : t("edit_game_modal_failed")
);
} finally {
setIsUpdating(false);
}
};
// Helper function to reset form to initial state
const resetFormToInitialState = useCallback(
(game: LibraryGame | Game) => {
setGameName(game.title || "");
if (isCustomGame(game)) {
setCustomGameAssets(game);
// Clear default URLs for custom games
setDefaultUrls({
icon: null,
logo: null,
hero: null,
});
} else {
setNonCustomGameAssets(game as LibraryGame);
}
},
[setCustomGameAssets, setNonCustomGameAssets]
);
const handleClose = () => {
if (!isUpdating && game) {
resetFormToInitialState(game);
onClose();
}
};
const isFormValid = gameName.trim();
const getPreviewUrl = (assetType: AssetType): string | undefined => {
const assetPath = getAssetPath(assetType);
const defaultUrl = getDefaultUrl(assetType);
if (game && !isCustomGame(game)) {
return assetPath ? `local:${assetPath}` : defaultUrl || undefined;
}
return assetPath ? `local:${assetPath}` : undefined;
};
const renderImageSection = (assetType: AssetType) => {
const assetPath = getAssetPath(assetType);
const assetDisplayPath = getAssetDisplayPath(assetType);
const defaultUrl = getDefaultUrl(assetType);
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
const isDragOver = dragOverTarget === assetType;
const getTranslationKey = (suffix: string) =>
`edit_game_modal_${assetType}${suffix}`;
const getResolutionKey = () => `edit_game_modal_${assetType}_resolution`;
return (
<div className="edit-game-modal__image-section">
<TextField
placeholder={t(`edit_game_modal_select_${assetType}`)}
value={assetDisplayPath}
readOnly
theme="dark"
rightContent={
<div style={{ display: "flex", gap: "8px" }}>
<Button
type="button"
theme="outline"
onClick={() => handleSelectAsset(assetType)}
disabled={isUpdating}
>
<ImageIcon />
{t("edit_game_modal_browse")}
</Button>
{game && !isCustomGame(game) && assetPath && (
<Button
type="button"
theme="outline"
onClick={() => handleRestoreDefault(assetType)}
disabled={isUpdating}
title={`Remove ${assetType}`}
>
<XIcon />
</Button>
)}
</div>
}
/>
<div className="edit-game-modal__resolution-info">
{t(getResolutionKey())}
</div>
{hasImage && (
<button
type="button"
aria-label={t(getTranslationKey("_drop_zone"))}
className={`edit-game-modal__image-preview ${
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
} ${isDragOver ? "edit-game-modal__drop-zone--active" : ""}`}
onDragOver={handleDragOver}
onDragEnter={(e) => handleDragEnter(e, assetType)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleAssetDrop(e, assetType)}
onClick={() => handleSelectAsset(assetType)}
>
<img
src={getPreviewUrl(assetType)}
alt={t(getTranslationKey("_preview"))}
className="edit-game-modal__preview-image"
/>
{isDragOver && (
<div className="edit-game-modal__drop-overlay">
<span>Drop to replace {assetType}</span>
</div>
)}
</button>
)}
{!hasImage && (
<button
type="button"
aria-label={t(getTranslationKey("_drop_zone_empty"))}
className={`edit-game-modal__image-preview ${
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
} edit-game-modal__drop-zone ${
isDragOver ? "edit-game-modal__drop-zone--active" : ""
}`}
onDragOver={handleDragOver}
onDragEnter={(e) => handleDragEnter(e, assetType)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleAssetDrop(e, assetType)}
onClick={() => handleSelectAsset(assetType)}
>
<div className="edit-game-modal__drop-zone-content">
<ImageIcon />
<span>Drop {assetType} image here</span>
</div>
</button>
)}
</div>
);
};
return (
<Modal
visible={visible}
title={t("edit_game_modal")}
description={t("edit_game_modal_description")}
onClose={handleClose}
>
<div className="edit-game-modal__container">
<div className="edit-game-modal__form">
<TextField
label={t("edit_game_modal_title")}
placeholder={t("edit_game_modal_enter_title")}
value={gameName}
onChange={handleGameNameChange}
theme="dark"
disabled={isUpdating}
rightContent={
isTitleChanged && (
<Button
type="button"
theme="outline"
onClick={handleRestoreDefaultTitle}
disabled={isUpdating}
title="Restore default title"
>
<XIcon />
</Button>
)
}
/>
<div className="edit-game-modal__asset-selector">
<div className="edit-game-modal__asset-label">
{t("edit_game_modal_assets")}
</div>
<div className="edit-game-modal__asset-tabs">
<Button
type="button"
theme={selectedAssetType === "icon" ? "primary" : "outline"}
onClick={() => handleAssetTypeChange("icon")}
disabled={isUpdating}
>
{t("edit_game_modal_icon")}
</Button>
<Button
type="button"
theme={selectedAssetType === "logo" ? "primary" : "outline"}
onClick={() => handleAssetTypeChange("logo")}
disabled={isUpdating}
>
{t("edit_game_modal_logo")}
</Button>
<Button
type="button"
theme={selectedAssetType === "hero" ? "primary" : "outline"}
onClick={() => handleAssetTypeChange("hero")}
disabled={isUpdating}
>
{t("edit_game_modal_hero")}
</Button>
</div>
</div>
{renderImageSection(selectedAssetType)}
</div>
<div className="edit-game-modal__actions">
<Button
type="button"
theme="outline"
onClick={handleClose}
disabled={isUpdating}
>
{t("edit_game_modal_cancel")}
</Button>
<Button
type="button"
theme="primary"
onClick={handleUpdateGame}
disabled={!isFormValid || isUpdating}
>
{isUpdating
? t("edit_game_modal_updating")
: t("edit_game_modal_update")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -7,6 +7,7 @@ import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast, useUserDetails } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { ResetAchievementsModal } from "./reset-achievements-modal";
import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal";
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { debounce } from "lodash-es";
@@ -17,12 +18,14 @@ export interface GameOptionsModalProps {
visible: boolean;
game: LibraryGame;
onClose: () => void;
onNavigateHome?: () => void;
}
export function GameOptionsModal({
visible,
game,
onClose,
onNavigateHome,
}: Readonly<GameOptionsModalProps>) {
const { t } = useTranslation("game_details");
@@ -43,6 +46,7 @@ export function GameOptionsModal({
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
const [showResetAchievementsModal, setShowResetAchievementsModal] =
useState(false);
const [showChangePlaytimeModal, setShowChangePlaytimeModal] = useState(false);
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
const [automaticCloudSync, setAutomaticCloudSync] = useState(
game.automaticCloudSync ?? false
@@ -88,6 +92,11 @@ export function GameOptionsModal({
await removeGameFromLibrary(game.shop, game.objectId);
updateGame();
onClose();
// Redirect to home page if it's a custom game
if (game.shop === "custom" && onNavigateHome) {
onNavigateHome();
}
};
const handleChangeExecutableLocation = async () => {
@@ -228,6 +237,20 @@ export function GameOptionsModal({
}
};
const handleChangePlaytime = async (playtimeInSeconds: number) => {
try {
await window.electron.changeGamePlayTime(
game.shop,
game.objectId,
playtimeInSeconds
);
await updateGame();
showSuccessToast(t("update_playtime_success"));
} catch (error) {
showErrorToast(t("update_playtime_error"));
}
};
const handleToggleAutomaticCloudSync = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
@@ -264,6 +287,13 @@ export function GameOptionsModal({
game={game}
/>
<ChangeGamePlaytimeModal
visible={showChangePlaytimeModal}
onClose={() => setShowChangePlaytimeModal(false)}
changePlaytime={handleChangePlaytime}
game={game}
/>
<Modal
visible={visible}
title={game.title}
@@ -323,14 +353,16 @@ export function GameOptionsModal({
>
{t("create_shortcut")}
</Button>
<Button
onClick={handleCreateSteamShortcut}
theme="outline"
disabled={creatingSteamShortcut}
>
<SteamLogo />
{t("create_steam_shortcut")}
</Button>
{game.shop !== "custom" && (
<Button
onClick={handleCreateSteamShortcut}
theme="outline"
disabled={creatingSteamShortcut}
>
<SteamLogo />
{t("create_steam_shortcut")}
</Button>
)}
{shouldShowCreateStartMenuShortcut && (
<Button
onClick={() => handleCreateShortcut("start_menu")}
@@ -344,19 +376,21 @@ export function GameOptionsModal({
</div>
</div>
<CheckboxField
label={
<div className="game-options-modal__cloud-sync-label">
{t("enable_automatic_cloud_sync")}
<span className="game-options-modal__cloud-sync-hydra-cloud">
Hydra Cloud
</span>
</div>
}
checked={automaticCloudSync}
disabled={!hasActiveSubscription || !game.executablePath}
onChange={handleToggleAutomaticCloudSync}
/>
{game.shop !== "custom" && (
<CheckboxField
label={
<div className="game-options-modal__cloud-sync-label">
{t("enable_automatic_cloud_sync")}
<span className="game-options-modal__cloud-sync-hydra-cloud">
Hydra Cloud
</span>
</div>
}
checked={automaticCloudSync}
disabled={!hasActiveSubscription || !game.executablePath}
onChange={handleToggleAutomaticCloudSync}
/>
)}
{shouldShowWinePrefixConfiguration && (
<div className="game-options-modal__wine-prefix">
@@ -418,33 +452,35 @@ export function GameOptionsModal({
/>
</div>
<div className="game-options-modal__downloads">
<div className="game-options-modal__header">
<h2>{t("downloads_section_title")}</h2>
<h4 className="game-options-modal__header-description">
{t("downloads_section_description")}
</h4>
</div>
{game.shop !== "custom" && (
<div className="game-options-modal__downloads">
<div className="game-options-modal__header">
<h2>{t("downloads_section_title")}</h2>
<h4 className="game-options-modal__header-description">
{t("downloads_section_description")}
</h4>
</div>
<div className="game-options-modal__row">
<Button
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting || isGameDownloading || !repacks.length}
>
{t("open_download_options")}
</Button>
{game.download?.downloadPath && (
<div className="game-options-modal__row">
<Button
onClick={handleOpenDownloadFolder}
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting}
disabled={deleting || isGameDownloading || !repacks.length}
>
{t("open_download_location")}
{t("open_download_options")}
</Button>
)}
{game.download?.downloadPath && (
<Button
onClick={handleOpenDownloadFolder}
theme="outline"
disabled={deleting}
>
{t("open_download_location")}
</Button>
)}
</div>
</div>
</div>
)}
<div className="game-options-modal__danger-zone">
<div className="game-options-modal__header">
@@ -463,30 +499,43 @@ export function GameOptionsModal({
{t("remove_from_library")}
</Button>
<Button
onClick={() => setShowResetAchievementsModal(true)}
theme="danger"
disabled={
deleting ||
isDeletingAchievements ||
!hasAchievements ||
!userDetails
}
>
{t("reset_achievements")}
</Button>
{game.shop !== "custom" && (
<Button
onClick={() => setShowResetAchievementsModal(true)}
theme="danger"
disabled={
deleting ||
isDeletingAchievements ||
!hasAchievements ||
!userDetails
}
>
{t("reset_achievements")}
</Button>
)}
<Button
onClick={() => {
setShowDeleteModal(true);
}}
onClick={() => setShowChangePlaytimeModal(true)}
theme="danger"
disabled={
isGameDownloading || deleting || !game.download?.downloadPath
}
>
{t("remove_files")}
{t("update_game_playtime")}
</Button>
{game.shop !== "custom" && (
<Button
onClick={() => {
setShowDeleteModal(true);
}}
theme="danger"
disabled={
isGameDownloading ||
deleting ||
!game.download?.downloadPath
}
>
{t("remove_files")}
</Button>
)}
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
export * from "./repacks-modal";
export * from "./download-settings-modal";
export * from "./game-options-modal";
export * from "./edit-game-modal";

View File

@@ -31,12 +31,12 @@ export function RemoveGameFromLibraryModal({
onClose={onClose}
>
<div className="remove-from-library-modal__actions">
<Button onClick={handleRemoveGame} theme="outline">
{t("remove")}
<Button onClick={onClose} theme="outline">
{t("cancel")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
<Button onClick={handleRemoveGame} theme="primary">
{t("remove")}
</Button>
</div>
</Modal>

View File

@@ -36,12 +36,12 @@ export function ResetAchievementsModal({
})}
>
<div className="reset-achievements-modal__actions">
<Button onClick={handleResetAchievements} theme="outline">
{t("reset_achievements")}
<Button onClick={onClose} theme="outline">
{t("cancel")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
<Button onClick={handleResetAchievements} theme="primary">
{t("reset_achievements")}
</Button>
</div>
</Modal>

View File

@@ -1,9 +1,14 @@
@use "../../../scss/globals.scss";
.sidebar-section {
background-color: globals.$dark-background-color;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
&__button {
height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;

View File

@@ -0,0 +1,85 @@
@use "../../../scss/globals.scss";
.game-language-section {
background-color: rgba(255, 255, 255, 0.02);
overflow: hidden;
&__header {
display: flex;
background-color: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid globals.$border-color;
}
&__header-item {
display: flex;
align-items: center;
color: globals.$muted-color;
font-size: globals.$small-font-size;
font-weight: 600;
flex: 1;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5);
&--center {
justify-content: flex-start;
flex: 0 0 60px;
}
}
&__content {
display: flex;
flex-direction: column;
}
&__row {
display: flex;
transition: background-color 0.2s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
&:hover {
background-color: rgba(255, 255, 255, 0.03);
}
&:last-child {
border-bottom: none;
}
}
&__cell {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5);
font-size: globals.$body-font-size;
color: globals.$body-color;
display: flex;
align-items: center;
flex: 1;
&--language {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--center {
justify-content: flex-start;
flex: 0 0 60px;
}
}
&__check {
color: globals.$body-color;
opacity: 0.8;
}
&__cross {
color: globals.$body-color;
opacity: 0.8;
}
@media (max-width: 320px) {
&__header,
&__cell {
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 0.5);
font-size: calc(globals.$small-font-size * 0.9);
}
}
}

View File

@@ -0,0 +1,72 @@
import { useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CheckIcon, XIcon } from "@primer/octicons-react";
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import "./game-language-section.scss";
export function GameLanguageSection() {
const { t } = useTranslation("game_details");
const { shopDetails } = useContext(gameDetailsContext);
const languages = useMemo(() => {
const supportedLanguages = shopDetails?.supported_languages;
if (!supportedLanguages) return [];
const languagesString = supportedLanguages.split("<br>")[0];
const languageArray = languagesString?.split(",") || [];
return languageArray.map((lang) => ({
language: lang.replace("<strong>*</strong>", "").trim(),
hasAudio: lang.includes("*"),
}));
}, [shopDetails?.supported_languages]);
if (languages.length === 0) {
return null;
}
return (
<SidebarSection title={t("language")}>
<div className="game-language-section">
<div className="game-language-section__header">
<div className="game-language-section__header-item">
<span>{t("language")}</span>
</div>
<div className="game-language-section__header-item game-language-section__header-item--center">
<span>{t("caption")}</span>
</div>
<div className="game-language-section__header-item game-language-section__header-item--center">
<span>{t("audio")}</span>
</div>
</div>
<div className="game-language-section__content">
{languages.map((lang) => (
<div key={lang.language} className="game-language-section__row">
<div
className="game-language-section__cell game-language-section__cell--language"
title={lang.language}
>
{lang.language}
</div>
<div className="game-language-section__cell game-language-section__cell--center">
<CheckIcon size={14} className="game-language-section__check" />
</div>
<div className="game-language-section__cell game-language-section__cell--center">
{lang.hasAudio ? (
<CheckIcon
size={14}
className="game-language-section__check"
/>
) : (
<XIcon size={14} className="game-language-section__cross" />
)}
</div>
</div>
))}
</div>
</div>
</SidebarSection>
);
}

View File

@@ -25,7 +25,7 @@ export function HowLongToBeatSection({
return `${value} ${t(durationTranslation[unit])}`;
};
if (!howLongToBeatData && !isLoading) return null;
if (!howLongToBeatData || !isLoading) return null;
return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">

View File

@@ -3,17 +3,30 @@
.content-sidebar {
border-left: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
width: 100%;
height: 100%;
flex-shrink: 0;
width: 280px;
@media (min-width: 1024px) {
max-width: 300px;
width: 100%;
width: 320px;
}
@media (min-width: 1280px) {
width: 100%;
max-width: 400px;
width: 380px;
}
@media (min-width: 1440px) {
width: 420px;
}
@media (max-width: 768px) {
width: 35%;
min-width: 220px;
}
@media (max-width: 480px) {
width: 40%;
min-width: 200px;
}
}

View File

@@ -21,6 +21,7 @@ import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./sidebar.scss";
import { GameLanguageSection } from "./game-language-section";
const achievementsPlaceholder: UserAchievement[] = [
{
@@ -263,6 +264,8 @@ export function Sidebar() {
}}
/>
</SidebarSection>
<GameLanguageSection />
</aside>
);
}

View File

@@ -6,8 +6,8 @@
height: 100%;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
padding: calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
padding: 0;
flex: 1;
overflow-y: auto;
}
@@ -17,6 +17,7 @@
gap: globals.$spacing-unit;
justify-content: space-between;
align-items: center;
padding: calc(globals.$spacing-unit * 3);
}
&__buttons-list {
@@ -27,25 +28,6 @@
gap: globals.$spacing-unit;
}
&__cards {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: calc(globals.$spacing-unit * 2);
transition: all ease 0.2s;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1250px) {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1600px) {
grid-template-columns: repeat(4, 1fr);
}
}
&__card-skeleton {
width: 100%;
height: 180px;
@@ -94,10 +76,40 @@
width: 24px;
height: 24px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
&__title-flame-icon {
width: 32px;
height: 32px;
object-fit: contain;
}
&__title {
display: flex;
gap: globals.$spacing-unit;
padding: 0 calc(globals.$spacing-unit * 3);
}
&__cards {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: calc(globals.$spacing-unit * 2);
transition: all ease 0.2s;
padding: 0 calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3);
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1250px) {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1600px) {
grid-template-columns: repeat(4, 1fr);
}
}
}

View File

@@ -97,8 +97,6 @@ export default function Home() {
return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
<section className="home__content">
<h2>{t("featured")}</h2>
<Hero />
<section className="home__header">
@@ -160,7 +158,7 @@ export default function Home() {
<img
src={flameIconAnimated}
alt="Flame animation"
className="home__flame-icon"
className="home__title-flame-icon"
/>
</div>
)}

Some files were not shown because too many files have changed in this diff Show More