Compare commits

..

136 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
Zamitto
8bc1c1c58c fix: handle error on getting steam path from windows registry
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
2025-09-01 21:41:18 -03:00
Zamitto
fbb67af1f6 Merge pull request #1748 from v1ctorsales/quick-add-to-library-button
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
feature: Add button to quickly add games to library
2025-09-01 14:47:56 -03:00
Zamitto
4211f97dd0 Fix condition check for genre index 2025-09-01 14:18:01 -03:00
Zamitto
f569b142f3 Merge branch 'main' into quick-add-to-library-button 2025-09-01 14:14:30 -03:00
Zamitto
18e4baec3d Merge pull request #1779 from hydralauncher/feat/steam-local-cache-achievements
feat: steam local cache achievements
2025-09-01 14:13:41 -03:00
Zamitto
b7199f4d95 chore: bump version 2025-09-01 13:54:21 -03:00
Zamitto
2b8cc506df feat: settings to enable steam achievements search 2025-09-01 12:51:49 -03:00
Zamitto
e0c5f80b68 feat: refactor 2025-08-07 18:07:56 -03:00
Zamitto
63374ccd74 feat: parse achievements from steam local cache 2025-08-07 18:04:15 -03:00
Zamitto
8eeacf478d Merge pull request #1777 from expload233/main
Add complete Chinese translations
2025-07-30 08:40:07 -03:00
Zamitto
87a4c27977 Merge branch 'main' into main 2025-07-30 07:55:23 -03:00
Zamitto
8177c3fd0c chore: add new line to eof 2025-07-30 07:54:16 -03:00
Zamitto
d28a5b828f feat: use await for firstSyncWithRemoteIfNeeded 2025-07-30 07:52:52 -03:00
expload233
68288adef3 fix typo 2025-07-27 16:45:16 +08:00
expload233
51894d9924 feat:complete chinese translation 2025-07-27 16:41:12 +08:00
expload233
2107261f7b Adds missing Chinese translations 2025-07-27 16:34:31 +08:00
Zamitto
737dc37433 chore: bump version 2025-07-23 13:36:36 -03:00
Zamitto
6dcc6bfe56 feat: remove sentry 2025-07-23 13:36:11 -03:00
Zamitto
714b30d6da fix: prevent duplicate achievements request in some cases 2025-07-23 13:32:33 -03:00
Victor Sales
6a94c3c812 fix: create game-item__plus-wrapper--added classname 2025-06-21 14:38:01 -03:00
Chubby Granny Chaser
c7ecd541d5 Merge branch 'main' into quick-add-to-library-button 2025-06-03 11:42:58 +01:00
Victor Sales
2dc700188d remove condition from useffect and rename AddingToLibrary states 2025-05-31 02:34:23 -03:00
Victor Sales
07ac13f4cc prettier changes on game-item.scss 2025-05-31 02:24:35 -03:00
Victor Sales
de605ecd02 rename plusDisabled and add ARIA attributes for accessible plus-button 2025-05-31 01:45:32 -03:00
Victor Sales
81654d7688 translations pt-br and english 2025-05-31 01:16:11 -03:00
Victor Sales
14a8336695 quick add to library button 2025-05-31 01:01:46 -03:00
133 changed files with 5282 additions and 1461 deletions

View File

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

View File

@@ -7,7 +7,6 @@ import {
} from "electron-vite"; } from "electron-vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
import { sentryVitePlugin } from "@sentry/vite-plugin";
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
loadEnv(mode); loadEnv(mode);
@@ -48,15 +47,7 @@ export default defineConfig(({ mode }) => {
"@shared": resolve("src/shared"), "@shared": resolve("src/shared"),
}, },
}, },
plugins: [ plugins: [svgr(), react()],
svgr(),
react(),
sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: "hydra-launcher",
project: "hydra-renderer",
}),
],
}, },
}; };
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.6.2", "version": "3.6.8",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -40,8 +40,6 @@
"@primer/octicons-react": "^19.9.0", "@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@reduxjs/toolkit": "^2.2.3", "@reduxjs/toolkit": "^2.2.3",
"@sentry/react": "^8.47.0",
"@sentry/vite-plugin": "^2.22.7",
"auto-launch": "^5.0.6", "auto-launch": "^5.0.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"axios-cookiejar-support": "^5.0.5", "axios-cookiejar-support": "^5.0.5",
@@ -56,6 +54,8 @@
"diskusage": "^1.2.0", "diskusage": "^1.2.0",
"electron-log": "^5.2.4", "electron-log": "^5.2.4",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0", "file-type": "^20.5.0",
"framer-motion": "^12.15.0", "framer-motion": "^12.15.0",
"i18next": "^23.11.2", "i18next": "^23.11.2",
@@ -65,6 +65,8 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"parse-torrent": "^11.0.18", "parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3", "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-hook-form": "^7.53.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.4.0",

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Has entrat correctament" "successfully_signed_in": "Has entrat correctament"
}, },
"home": { "home": {
"featured": "Destacats",
"surprise_me": "Sorprèn-me", "surprise_me": "Sorprèn-me",
"no_results": "No s'ha trobat res" "no_results": "No s'ha trobat res"
}, },
@@ -25,7 +24,6 @@
}, },
"header": { "header": {
"search": "Cerca jocs", "search": "Cerca jocs",
"home": "Inici", "home": "Inici",
"catalogue": "Catàleg", "catalogue": "Catàleg",
"downloads": "Baixades", "downloads": "Baixades",
@@ -41,10 +39,7 @@
"calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…", "calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…",
"checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)" "checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)"
}, },
"catalogue": { "catalogue": {},
"next_page": "Pàgina següent",
"previous_page": "Pàgina anterior"
},
"game_details": { "game_details": {
"open_download_options": "Obre les opcions de baixada", "open_download_options": "Obre les opcions de baixada",
"download_options_zero": "No hi ha 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" "successfully_signed_in": "Úspěšně přihlášen"
}, },
"home": { "home": {
"featured": "Doporučené",
"surprise_me": "Překvap mě", "surprise_me": "Překvap mě",
"no_results": "Výsledek nenalezen", "no_results": "Výsledek nenalezen",
"start_typing": "Začni psát pro vyhledávání...", "start_typing": "Začni psát pro vyhledávání...",

View File

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

View File

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

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

@@ -4,7 +4,6 @@
"successfully_signed_in": "Successfully signed in" "successfully_signed_in": "Successfully signed in"
}, },
"home": { "home": {
"featured": "Featured",
"surprise_me": "Surprise me", "surprise_me": "Surprise me",
"no_results": "No results found", "no_results": "No results found",
"start_typing": "Starting typing to search...", "start_typing": "Starting typing to search...",
@@ -28,7 +27,50 @@
"friends": "Friends", "friends": "Friends",
"need_help": "Need help?", "need_help": "Need help?",
"favorites": "Favorites", "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": { "header": {
"search": "Search games", "search": "Search games",
@@ -89,6 +131,7 @@
"amount_minutes": "{{amount}} minutes", "amount_minutes": "{{amount}} minutes",
"accuracy": "{{accuracy}}% accuracy", "accuracy": "{{accuracy}}% accuracy",
"add_to_library": "Add to library", "add_to_library": "Add to library",
"already_in_library": "Already in library",
"remove_from_library": "Remove from library", "remove_from_library": "Remove from library",
"no_downloads": "No downloads available", "no_downloads": "No downloads available",
"play_time": "Played for {{amount}}", "play_time": "Played for {{amount}}",
@@ -197,10 +240,20 @@
"download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.", "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_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.", "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_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.", "download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
"game_removed_from_favorites": "Game removed from favorites", "game_removed_from_favorites": "Game removed from favorites",
"game_added_to_favorites": "Game added to 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", "automatically_extract_downloaded_files": "Automatically extract downloaded files",
"create_start_menu_shortcut": "Create Start Menu shortcut", "create_start_menu_shortcut": "Create Start Menu shortcut",
"invalid_wine_prefix_path": "Invalid Wine prefix path", "invalid_wine_prefix_path": "Invalid Wine prefix path",
@@ -219,7 +272,21 @@
"backup_frozen": "Backup pinned", "backup_frozen": "Backup pinned",
"backup_unfrozen": "Backup unpinned", "backup_unfrozen": "Backup unpinned",
"backup_freeze_failed": "Failed to freeze backup", "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": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",
@@ -379,6 +446,7 @@
"installing_common_redist": "Installing…", "installing_common_redist": "Installing…",
"show_download_speed_in_megabytes": "Show download speed in megabytes per second", "show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download", "extract_files_by_default": "Extract files by default after download",
"enable_steam_achievements": "Enable search for Steam achievements",
"achievement_custom_notification_position": "Achievement custom notification position", "achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left", "top-left": "Top left",
"top-center": "Top center", "top-center": "Top center",
@@ -438,10 +506,17 @@
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} hours", "amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes", "amount_minutes": "{{amount}} minutes",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Last played {{period}}", "last_time_played": "Last played {{period}}",
"activity": "Recent Activity", "activity": "Recent Activity",
"library": "Library", "library": "Library",
"pinned": "Pinned",
"achievements_earned": "Achievements earned",
"played_recently": "Played recently",
"playtime": "Playtime",
"total_play_time": "Total 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_title": "Hmmm… nothing here",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that!", "no_recent_activity_description": "You haven't played any games recently. It's time to change that!",
"display_name": "Display name", "display_name": "Display name",
@@ -516,7 +591,9 @@
"show_achievements_on_profile": "Show your achievements on your profile", "show_achievements_on_profile": "Show your achievements on your profile",
"show_points_on_profile": "Show your earned points 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", "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": {
"achievement_unlocked": "Achievement unlocked", "achievement_unlocked": "Achievement unlocked",

View File

@@ -1,34 +1,76 @@
{ {
"language_name": "Español", "language_name": "Español",
"app": { "app": {
"successfully_signed_in": "Sesión iniciada exitosamente" "successfully_signed_in": "Iniciaste sesión exitosamente"
}, },
"home": { "home": {
"featured": "Destacado",
"surprise_me": "¡Sorpréndeme!", "surprise_me": "¡Sorpréndeme!",
"no_results": "Sin resultados encontrados", "no_results": "No se encontraron resultados",
"start_typing": "Empieza a escribir para buscar...", "start_typing": "Empezá a escribir para buscar...",
"hot": "Popular Ahora", "hot": "Tendencias",
"weekly": "📅 Mejores juegos de la semana", "weekly": "📅 Mejores juegos de la semana",
"achievements": "🏆 Juegos para completar" "achievements": "🏆 Juegos para platinar"
}, },
"sidebar": { "sidebar": {
"catalogue": "Catálogo", "catalogue": "Catálogo",
"downloads": "Descargas", "downloads": "Descargas",
"settings": "Ajustes", "settings": "Ajustes",
"my_library": "Mi biblioteca", "my_library": "Mi Librería",
"downloading_metadata": "{{title}} (Descargando metadatos…)", "downloading_metadata": "{{title}} (Descargando metadatos…)",
"paused": "{{title}} (Pausado)", "paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Descargando…)", "downloading": "{{title}} ({{percentage}} - Descargando…)",
"filter": "Buscar en la biblioteca", "filter": "Filtrar Librería",
"home": "Inicio", "home": "Inicio",
"queued": "{{title}} (En cola)", "queued": "{{title}} (En cola)",
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado", "game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar sesión", "sign_in": "Iniciar Sesión",
"friends": "Amigos", "friends": "Amigos",
"need_help": "¿Necesitas ayuda?", "need_help": "¿Necesitás ayuda?",
"favorites": "Favoritos", "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": { "header": {
"search": "Buscar juegos", "search": "Buscar juegos",
@@ -37,348 +79,409 @@
"downloads": "Descargas", "downloads": "Descargas",
"search_results": "Resultados de búsqueda", "search_results": "Resultados de búsqueda",
"settings": "Ajustes", "settings": "Ajustes",
"version_available_install": "Versión {{version}} disponible. Presiona acá para descargar y reinstalar.", "version_available_install": "Versión {{version}} disponible. Presiona acá para reiniciar e instalar.",
"version_available_download": "Versión {{version}} disponible. Presiona aquí para descargar." "version_available_download": "Versión {{version}} disponible. Presiona a para descargar."
}, },
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": "Sin descargas en progreso", "no_downloads_in_progress": "Sin descargas en progreso",
"downloading_metadata": "Descargando metadatos de {{title}}…", "downloading_metadata": "Descargando metadatos de {{title}}…",
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}", "downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}",
"calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…", "calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…",
"installation_complete": "Instalación completada", "checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)",
"installation_complete_message": "Common redistributables instalados exitosamente",
"installing_common_redist": "{{log}}…", "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": { "catalogue": {
"search": "Filtrar…", "search": "Filtrar…",
"developers": "Desarrolladores", "developers": "Desarrolladores",
"genres": "Géneros", "genres": "Géneros",
"tags": "Marcadores", "tags": "Etiquetas",
"publishers": "Editores", "publishers": "Editores",
"download_sources": "Fuentes de descarga", "download_sources": "Descargando fuentes",
"result_count": "{{resultCount}} resultados", "result_count": "{{resultCount}} resultados",
"filter_count": "{{filterCount}} disponibles", "filter_count": "{{filterCount}} disponible",
"clear_filters": "Limpiar {{filterCount}} seleccionados" "clear_filters": "Limpiar {{filterCount}} seleccionados"
}, },
"game_details": { "game_details": {
"open_download_options": "Ver opciones de descargas", "open_download_options": "Abrir opciones de descargas",
"automatically_extract_downloaded_files": "Extraer automáticamente archivos descargados", "download_options_zero": "Sin opciones de descargas",
"download_error_not_cached_on_hydra": "Esta descarga no está disponible en Nimbus.",
"download_options_zero": "No hay opciones de descargas disponibles",
"download_options_one": "{{count}} opción de descarga", "download_options_one": "{{count}} opción de descarga",
"download_options_other": "{{count}} opciones de descargas", "download_options_other": "{{count}} opciones de descargas",
"updated_at": "Actualizado el: {{updated_at}}", "updated_at": "Actualizado el {{updated_at}}",
"install": "Instalar", "install": "Instalar",
"resume": "Continuar", "resume": "Resumir",
"pause": "Pausa", "pause": "Pausar",
"cancel": "Cancelar", "cancel": "Cancelar",
"remove": "Eliminar", "remove": "Remover",
"space_left_on_disk": "{{space}} restantes en el disco", "space_left_on_disk": "{{space}} restante en el disco",
"eta": "Tiempo restante: {{eta}}", "eta": "Conclusión {{eta}}",
"calculating_eta": "Calculando tiempo restante…", "calculating_eta": "Calculando tiempo restante…",
"downloading_metadata": "Descargando metadatos…", "downloading_metadata": "Descargando metadatos…",
"filter": "Buscar repacks", "filter": "Filtrar repacks",
"requirements": "Requisitos del Sistema", "requirements": "Requisitos del Sistema",
"minimum": "Mínimos", "minimum": "Mínimos",
"recommended": "Recomendados", "recommended": "Recomendados",
"paused": "Pausado", "paused": "En Pausa",
"release_date": "Fecha de lanzamiento: {{date}}", "release_date": "Lanzado el {{date}}",
"publisher": "Publicado por: {{publisher}}", "publisher": "Públicado por {{publisher}}",
"hours": "horas", "hours": "horas",
"minutes": "minutos", "minutes": "minutos",
"amount_hours": "{{amount}} horas", "amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos", "amount_minutes": "{{amount}} minutos",
"accuracy": "{{accuracy}}% precisión", "accuracy": "{{accuracy}}% completista",
"add_to_library": "Agregar a la biblioteca", "add_to_library": "Añadir a la librería",
"remove_from_library": "Eliminar de la biblioteca", "already_in_library": "Ya está en la librería",
"no_downloads": "No hay descargas disponibles", "remove_from_library": "Eliminar de la librería",
"play_time": "Has jugado {{amount}}", "no_downloads": "Sin descargas disponibles",
"last_time_played": "Jugado por última vez: {{period}}", "play_time": "Jugado por {{amount}}",
"not_played_yet": "Aún no has jugado a {{title}}", "last_time_played": "Última vez jugado {{period}}",
"not_played_yet": "No has jugado a {{title}} todavía",
"next_suggestion": "Siguiente sugerencia", "next_suggestion": "Siguiente sugerencia",
"play": "Jugar", "play": "Jugar",
"deleting": "Eliminando instalador…", "deleting": "Eliminando instalador…",
"close": "Cerrar", "close": "Cerrar",
"playing_now": "Jugando ahora", "playing_now": "Jugando ahora",
"change": "Cambiar", "change": "Cambiar",
"repacks_modal_description": "Selecciona el repack que quieres descargar", "repacks_modal_description": "Elegí el repack que querés descargar",
"select_folder_hint": "Para cambiar la carpeta predeterminada, ve a <0>Ajustes</0>", "select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes</0>",
"download_now": "Descargar ahora", "download_now": "Descargar ahora",
"no_shop_details": "No se pudieron obtener detalles de la tienda.", "no_shop_details": "No se pudieron obtener detalles de la tienda.",
"download_options": "Opciones de descarga", "download_options": "Opciones de descarga",
"download_path": "Ruta de descarga", "download_path": "Ruta de descarga",
"previous_screenshot": "Anterior captura", "previous_screenshot": "Anterior captura",
"next_screenshot": "Siguiente captura", "next_screenshot": "Siguiente captura",
"screenshot": "Captura {{number}}", "screenshot": "Captura número {{number}}",
"open_screenshot": "Abrir captura {{number}}", "open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Ajustes de descarga", "download_settings": "Descargar ajustes",
"downloader": "Método de descarga", "downloader": "Descargador",
"select_executable": "Seleccionar", "select_executable": "Seleccionar",
"no_executable_selected": "No se seleccionó un ejecutable", "no_executable_selected": "Sin ejecutable seleccionado",
"open_folder": "Abrir carpeta", "open_folder": "Abrir carpeta",
"open_download_location": "Ver archivos descargados", "open_download_location": "Ver archivos descargados",
"create_shortcut": "Crear acceso directo en el escritorio", "create_shortcut": "Crear atajo en el escritorio",
"remove_files": "Eliminar archivos", "clear": "Limpiar",
"remove_files": "Remover archivos",
"remove_from_library_title": "¿Estás seguro?", "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", "options": "Opciones",
"executable_section_title": "Ejecutable", "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_title": "Descargas",
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego", "downloads_section_description": "Revisar actualizaciones u otras versiones del juego",
"danger_zone_section_title": "Opciones Avanzadas", "danger_zone_section_title": "Zona de Peligro",
"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)", "danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra",
"download_in_progress": "Descarga en progreso", "download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada", "download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción de descarga",
"create_steam_shortcut": "Crear atajo de Steam", "create_steam_shortcut": "Crear atajo de Steam",
"last_downloaded_option": "Última opción descargada",
"create_shortcut_success": "Atajo creado con éxito", "create_shortcut_success": "Atajo creado con éxito",
"you_might_need_to_restart_steam": "Es posible que necesites reiniciar Steam para ver los cambios", "you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
"create_shortcut_error": "Error al crear un atajo", "create_shortcut_error": "Error al crear atajo",
"nsfw_content_title": "Este juego contiene contenido inapropiado.", "nsfw_content_title": "Este juego tiene contenido inapropiado",
"nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?", "nsfw_content_description": "{{title}} tiene contenido no apto para todas las edades. ¿Querés continuar igualmente?",
"allow_nsfw_content": "Continuar", "allow_nsfw_content": "Continuar",
"refuse_nsfw_content": "No, gracias", "refuse_nsfw_content": "Regresar",
"stats": "Estadísticas", "stats": "Estadísticas",
"download_count": "Descargas", "download_count": "Descargas",
"player_count": "Jugadores activos", "player_count": "Jugadores activos",
"download_error": "Esta opción de descarga no está disponible.", "download_error": "Esta opción de descarga no está disponible",
"download": "Descargar", "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:", "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": "Logros",
"achievements_count": "Logros {{unlockedCount}}/{{achievementsCount}}", "achievements_count": "Logros {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "Guardado en la nube", "cloud_save": "Guardado en la nube",
"cloud_save_description": "Guarda tu progreso en la nube y continúa jugando en cualquier dispositivo", "cloud_save_description": "Guardá tu progreso en la nube y jugá en cualquier dispositivo",
"backups": "Copias de Seguridad", "backups": "Copia de seguridad",
"install_backup": "Instalar", "install_backup": "Instalar",
"delete_backup": "Eliminar", "delete_backup": "Eliminar",
"create_backup": "Nueva Copia de Seguridad", "create_backup": "Nueva copia de seguridad",
"last_backup_date": "Última copia de seguridad el {{date}}", "last_backup_date": "Última copia de seguridad {{date}}",
"no_backup_preview": "No se encontraron datos de guardados para este juego", "no_backup_preview": "No se han encotrado puntos de guardado para este juego",
"restoring_backup": "Restaurando copia de seguridad ({{progress}} completado)…", "restoring_backup": "Restaurando copia de seguridad ({{progress}} completado)…",
"uploading_backup": "Subiendo copia de seguridad…", "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_uploaded": "Copia de seguridad subida",
"backup_deleted": "Copia de seguridad eliminada", "backup_deleted": "Copia de seguridad eliminada",
"backup_restored": "Copia de seguridad restaurada", "backup_restored": "Copia de seguridad restaurada",
"see_all_achievements": "Ver todos los logros", "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_automatic": "Automático",
"mapping_method_manual": "Manual", "mapping_method_manual": "Manual",
"mapping_method_label": "Método de mapeo", "mapping_method_label": "Método de mapeado",
"files_automatically_mapped": "Archivos mapeados automáticamente", "files_automatically_mapped": "Archivos automáticamente mapeados",
"no_backups_created": "Sin copias de seguridad creadas para este juego", "no_backups_created": "Sin copias de seguridad creadas para este juego",
"manage_files": "Gestionar archivos", "manage_files": "Administrar archivos",
"loading_save_preview": "Buscando datos de guardados de juegos…", "loading_save_preview": "Buscando por guardado de juegos…",
"wine_prefix": "Prefijo de Wine", "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", "no_download_option_info": "Sin información disponible",
"backup_deletion_failed": "La eliminación de la copia de seguridad falló", "backup_deletion_failed": "Error al eliminar copia de seguridad",
"max_number_of_artifacts_reached": "Número máximo de copias de seguridad de este juego alcanzadas", "max_number_of_artifacts_reached": "Máximo de copias de seguridad alcanzadas para este juego",
"achievements_not_sync": "Tus logros no están sincronizados", "achievements_not_sync": "Revisá como sincronizar tus logros'",
"manage_files_description": "Gestiona los archivos que serán respaldados y restaurados", "manage_files_description": "Elegí que archivos se guardarán y restaurarán de la copia de seguridad",
"select_folder": "Seleccionar carpeta", "select_folder": "Seleccionar carpeta",
"backup_from": "Copia de seguridad de {{date}}", "backup_from": "Copia de seguridad de {{date}}",
"automatic_backup_from": "Copia de seguridad automática de {{date}}", "automatic_backup_from": "Copia de seguridad automática de {{date}}",
"enable_automatic_cloud_sync": "Habilitar sincronización automática en la nube", "enable_automatic_cloud_sync": "Habilitar sincronización con la nube",
"custom_backup_location_set": "Se configuró la carpeta de copia de seguridad", "custom_backup_location_set": "Ubicación de copia de seguridad personalizada",
"clear": "Limpiar", "no_directory_selected": "Sin directorio seleccionado",
"no_directory_selected": "No se seleccionó un directorio", "no_write_permission": "No se puede descargar en este directorio. Presioná acá para más información.",
"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.",
"reset_achievements": "Reiniciar logros", "reset_achievements": "Reiniciar logros",
"reset_achievements_description": "Esto reiniciará todos los logros de {{game}}", "reset_achievements_description": "Esto va a reiniciar todos los logros para {{game}}",
"reset_achievements_title": "¿Estás seguro?", "reset_achievements_title": "¿Querés continuar?",
"reset_achievements_success": "Logros reiniciados exitosamente", "reset_achievements_success": "Logros reiniciados éxitosamente",
"reset_achievements_error": "Se produjo un error al reiniciar los logros", "reset_achievements_error": "Error al reiniciar logros",
"download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.", "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 nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.", "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 el estado de descarga del sondeo de Real-Debrid aún no está disponible.", "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.",
"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.", "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_added_to_favorites": "Juego añadido a favoritos",
"game_removed_from_favorites": "Juego removido de favoritos", "game_removed_from_pinned": "Juego removido de fijados",
"invalid_wine_prefix_path": "Ruta de prefijo de Wine inválida", "game_added_to_pinned": "Juego añadido a fijados",
"invalid_wine_prefix_path_description": "La ruta del prefijo Wine es inválida. Por favor, checa la ruta y vuelve a intentarlo.", "automatically_extract_downloaded_files": "Extraer automáticamente archivos descargados",
"missing_wine_prefix": "Se requiere el prefijo Wine para crear una copia de seguridad en Linux" "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": { "activation": {
"title": "Activar Hydra", "title": "Activar Hydra",
"installation_id": "ID de la Instalación:", "installation_id": "ID de Instalación:",
"enter_activation_code": "Introduce tu código de activación", "enter_activation_code": "Introducí tu código de activación",
"message": "Si no sabes donde obtener el código, no deberías de tener esto.", "message": "Si no sabes donde preguntar por esto, entonces no tenés que tener esto.",
"activate": "Activar", "activate": "Activar",
"loading": "Cargando…" "loading": "Cargando…"
}, },
"downloads": { "downloads": {
"resume": "Resumir", "resume": "Resumir",
"pause": "Pausa", "pause": "Pausar",
"eta": "Finalizando en {{eta}}", "eta": "Tiempo de finalizción {{eta}}",
"paused": "En Pausa", "paused": "Pausado",
"verifying": "Verificando…", "verifying": "Verificando…",
"completed": "Completado", "completed": "Completado",
"removed": "No descargado", "removed": "No descargado",
"cancel": "Cancelar", "cancel": "Cancelar",
"filter": "Buscar juegos descargados", "filter": "Filtrar juegos descargados",
"remove": "Eliminar", "remove": "Remover",
"downloading_metadata": "Descargando metadatos…", "downloading_metadata": "Descargando metadatos…",
"deleting": "Eliminando instalador…", "deleting": "Eliminado instalador…",
"delete": "Eliminar instalador", "delete": "Remover instalador",
"delete_modal_title": "¿Estás seguro?", "delete_modal_title": "¿Querés continuar?",
"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)", "delete_modal_description": "Esto eliminará todos los archivos del instalador de tu computadora",
"install": "Instalar", "install": "Instalar",
"download_in_progress": "En progreso", "download_in_progress": "En progreso",
"queued_downloads": "Descargas en cola", "queued_downloads": "Descargas en cola",
"downloads_completed": "Completado", "downloads_completed": "Completado",
"queued": "En cola", "queued": "En cola",
"no_downloads_title": "Esto está tan... vacío", "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!.", "no_downloads_description": "No has descargado nada con Hydra, pero nunca es tarde para comenzar.",
"checking_files": "Verificando archivos…", "checking_files": "Revisando archivos…",
"seeding": "Seeding", "seeding": "Sembrando",
"stop_seeding": "Detener seeding", "stop_seeding": "Dejar de sembrar",
"resume_seeding": "Continuar seeding", "resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos", "extract": "Extraer archivos",
"extracting": "Extrayendo archivos…", "extracting": "Extrayendo archivos…"
"options": "Gestionar"
}, },
"settings": { "settings": {
"downloads_path": "Ruta de descarga", "downloads_path": "Ruta de descarga",
"common_redist": "Common redistributables", "change": "Actualizar",
"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",
"notifications": "Notificaciones", "notifications": "Notificaciones",
"enable_download_notifications": "Cuando se completa una descarga", "enable_download_notifications": "Cuando una descarga se completa",
"enable_repack_list_notifications": "Cuando se añade un repack nuevo", "enable_repack_list_notifications": "Cuando un nuevo repack se añade",
"real_debrid_api_token_label": "Token API de Real-Debrid", "real_debrid_api_token_label": "Real-Debrid API token",
"quit_app_instead_hiding": "Salir de Hydra en vez de minimizar en la bandeja del sistema", "quit_app_instead_hiding": "No ocultar Hydra cuando se cierra",
"launch_with_system": "Iniciar Hydra al inicio del sistema", "launch_with_system": "Iniciar Hydra con el sistema",
"general": "General", "general": "General",
"behavior": "Otros", "behavior": "Comportamiento",
"download_sources": "Fuentes de descarga", "download_sources": "Fuentes de descargas",
"language": "Idioma", "language": "Idioma",
"api_token": "Token API", "api_token": "API Token",
"enable_real_debrid": "Activar Real-Debrid", "enable_real_debrid": "Habilitar 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.", "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 de API inválido", "debrid_invalid_token": "Token API inválido",
"debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí</0>", "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 gratuita. Por favor, suscríbete a Real-Debrid", "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", "debrid_linked_message": "Cuenta \"{{username}}\" vinculada",
"save_changes": "Guardar cambios", "save_changes": "Guardar cambios",
"changes_saved": "Ajustes guardados exitosamente", "changes_saved": "Cambios guardados éxitosamente",
"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", "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", "validate_download_source": "Validar",
"remove_download_source": "Eliminar", "remove_download_source": "Remover",
"add_download_source": "Añadir fuente de descarga", "add_download_source": "Añadir fuente",
"download_count_zero": "No hay descargas en la lista", "download_count_zero": "Sin opciones de descarga",
"download_count_one": "{{countFormatted}} descarga en la lista", "download_count_one": "{{countFormatted}} opción de descarga",
"download_count_other": "{{countFormatted}} descargas en la lista", "download_count_other": "{{countFormatted}} opciones de descarga",
"download_source_url": "Descargar URL de origen", "download_source_url": "Descargar fuente URL",
"add_download_source_description": "Introduce la URL con el archivo .json", "add_download_source_description": "Introducí la URL del archivo .json",
"download_source_up_to_date": "Al día", "download_source_up_to_date": "Actualizado",
"download_source_errored": "Error", "download_source_errored": "Error",
"sync_download_sources": "Sincronizar fuentes", "sync_download_sources": "Sincronizar fuentes",
"removed_download_source": "Fuente de descarga eliminada", "removed_download_source": "Fuente de descarga eliminada",
"removed_download_sources": "Fuente de descarga eliminadas",
"cancel_button_confirmation_delete_all_sources": "No", "cancel_button_confirmation_delete_all_sources": "No",
"confirm_button_confirmation_delete_all_sources": "Sí, eliminar todo", "confirm_button_confirmation_delete_all_sources": "Si, eliminar todo",
"description_confirmation_delete_all_sources": "Eliminarás todas las fuentes de descarga",
"title_confirmation_delete_all_sources": "Eliminar todas las fuentes de descarga", "title_confirmation_delete_all_sources": "Eliminar todas las fuentes de descarga",
"removed_download_sources": "Fuentes de descarga eliminadas", "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
"button_delete_all_sources": "Eliminar todas las fuentes de descarga", "button_delete_all_sources": "Eliminar todo",
"added_download_source": "Fuente de descarga añadida", "added_download_source": "Añadir fuente de descarga",
"download_sources_synced": "Todas las fuentes de descargas están actualizadas.", "download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
"insert_valid_json_url": "Introduce una URL JSON válida", "insert_valid_json_url": "Introducí una URL de json válida",
"found_download_option_zero": "No se encontró una opción de descarga", "found_download_option_zero": "Sin opciones de descargas encontrada",
"found_download_option_one": "Se encontró {{countFormatted}} opción de descarga", "found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga",
"found_download_option_other": "Se encontraron {{countFormatted}} opciones de descarga", "found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas",
"import": "Importar", "import": "Importar",
"public": "Público", "public": "Público",
"private": "Privado", "private": "Privado",
"friends_only": "Solo amigos", "friends_only": "Sólo amigos",
"privacy": "Privacidad", "privacy": "Privacidad",
"profile_visibility": "Visibilidad del perfil", "profile_visibility": "Visibilidad del perfil",
"profile_visibility_description": "Elige quién puede ver tu perfil y biblioteca", "profile_visibility_description": "Elegí quién puede ver tú perfil y biblioteca",
"required_field": "Este campo es obligatorio", "required_field": "Este campo es requerido",
"source_already_exists": "Esta fuente ya ha sido agregada.", "source_already_exists": "Esta fuente ya está añadida",
"must_be_valid_url": "La fuente debe ser una URL válida.", "must_be_valid_url": "La fuente debe ser una URL válida",
"blocked_users": "Usuarios bloqueados", "blocked_users": "Usuarios bloqueados",
"user_unblocked": "El usuario ha sido desbloqueado", "user_unblocked": "Has desbloqueado a este usuario",
"enable_achievement_notifications": "Cuando un logro se desbloquea", "enable_achievement_notifications": "Cuando desbloqueás un logro",
"launch_minimized": "Iniciar Hydra minimizado", "launch_minimized": "Iniciar Hydra minimizado",
"disable_nsfw_alert": "Desactivar alerta NSFW", "disable_nsfw_alert": "Deshabilitar alerta de NSFW",
"seed_after_download_complete": "Realizar seeding después de que se completa la descarga", "seed_after_download_complete": "Sembrar después de completar una descarga",
"show_hidden_achievement_description": "Ocultar descripción de logros ocultos antes de desbloquearlos", "show_hidden_achievement_description": "Mostrar logros ocultos antes de desbloquearlos",
"account": "Cuenta", "account": "Cuenta",
"account_data_updated_successfully": "Datos de la cuenta actualizados", "no_users_blocked": "No has bloqueado a ningún usuario",
"bill_sent_until": "Tú próxima factura se enviará el {{date}}", "subscription_active_until": "Tu Hydra Cloud está activo hasta {{date}}",
"current_email": "Correo actual:", "manage_subscription": "Administrar suscripción",
"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}}",
"update_email": "Actualizar correo", "update_email": "Actualizar correo",
"update_password": "Actualizar contraseña", "update_password": "Cambiar contraseña",
"appearance": "Apariencia", "current_email": "Correo actual:",
"become_subscriber": "Sé Hydra Cloud", "no_email_account": "No tenés ningún correo vinculado aún",
"cancel": "Cancelar", "account_data_updated_successfully": "Datos de la cuenta actualizados correctamente",
"clear_themes": "Limpiar", "renew_subscription": "Renovar Hydra Cloud",
"create_theme": "Crear", "subscription_expired_at": "Tu suscripción expiró el {{date}}",
"create_theme_modal_description": "Crea un nuevo tema para personalizar la apariencia de Hydra", "no_subscription": "Disfrutá Hydra de la mejor forma",
"create_theme_modal_title": "Crear tema personalizado", "become_subscriber": "Sé parte de Hydra Cloud",
"delete_all_themes": "Eliminar todos los temas", "subscription_renew_cancelled": "Renovación automática desactivada",
"delete_all_themes_description": "Esto eliminará todos tus temas personalizados", "subscription_renews_on": "Tu suscripción se renueva el {{date}}",
"delete_theme": "Eliminar tema", "bill_sent_until": "Tu próxima factura se enviará este día",
"delete_theme_description": "Esto eliminará el tema {{theme}}", "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.",
"edit_theme": "Editar tema",
"editor_tab_code": "Código", "editor_tab_code": "Código",
"editor_tab_info": "Info", "editor_tab_info": "Info",
"editor_tab_save": "Guardar", "editor_tab_save": "Guardar",
"enable_torbox": "Habilitar TorBox", "web_store": "Tienda Web",
"error_importing_theme": "Error al importar el tema", "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": "Importar tema",
"import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas", "import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas",
"insert_theme_name": "Introducí el nombre del tema", "error_importing_theme": "Error al importar el tema",
"name_min_length": "El tema tiene que tener 3 carácteres de largo mínimo", "theme_imported": "Tema importado correctamente",
"no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para crear tu primer tema.", "enable_friend_request_notifications": "Cuando recibís una solicitud de amistad",
"real_debrid_account_linked": "Cuenta de Real-Debrid vinculada", "enable_auto_install": "Descargar actualizaciones automáticamente",
"set_theme": "Establecer tema", "common_redist": "Common redistributables",
"theme_imported": "Tema importado exitosamente", "common_redist_description": "Common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.",
"theme_name": "Nombre", "install_common_redist": "Instalar",
"torbox_account_linked": "Cuenta de TorBox vinculada", "installing_common_redist": "Instalando…",
"torbox_description": "TorBox es tu servicio premium de seedbox que rivaliza incluso a los mejores servidores del mercado.", "show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo",
"unset_theme": "Desactivar tema", "extract_files_by_default": "Extraer archivos por defecto después de descargar",
"web_store": "Tienda Web", "enable_steam_achievements": "Habilitar búsqueda de logros de Steam",
"enable_friend_request_notifications": "Cuando se recibe una solicitud de amistad", "achievement_custom_notification_position": "Posición de notificación de logros",
"enable_auto_install": "Descargar actualizaciones automáticamente" "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": { "notifications": {
"download_complete": "Descarga completada", "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", "extraction_complete": "Extracción completada",
"game_extracted": "{{title}} extraído exitosamente", "game_extracted": "{{title}} extraído exitosamente",
"game_ready_to_install": "{{title}} está listo para instalarse", "friend_started_playing_game": "{{displayName}} empezó a jugar un juego",
"repack_list_updated": "Lista de repacks actualizadas", "test_achievement_notification_title": "Esto es una notificación de prueba",
"repack_count_one": "{{count}} repack ha sido añadido", "test_achievement_notification_description": "Piola, ¿verdad?"
"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"
}, },
"system_tray": { "system_tray": {
"open": "Abrir Hydra", "open": "Abrir Hydra",
@@ -387,15 +490,15 @@
"game_card": { "game_card": {
"available_one": "Disponible", "available_one": "Disponible",
"available_other": "Disponibles", "available_other": "Disponibles",
"no_downloads": "No hay descargas disponibles" "no_downloads": "Sin descargas disponibles"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "Programas no instalados", "title": "Programas no instalados",
"description": "Los ejecutables de Wine o Lutris no se encontraron en tu sistema", "description": "Ejecutables de Wine o Lutris executables no encontrados 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" "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": { "modal": {
"close": "Botón de cierre" "close": "Botón de cerrar"
}, },
"forms": { "forms": {
"toggle_password_visibility": "Cambiar visibilidad de contraseña" "toggle_password_visibility": "Cambiar visibilidad de contraseña"
@@ -403,111 +506,120 @@
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} horas", "amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos", "amount_minutes": "{{amount}} minutos",
"last_time_played": "Última vez jugado: {{period}}", "last_time_played": "Jugado por última vez el {{period}}",
"activity": "Actividad reciente", "activity": "Actividad reciente",
"library": "Biblioteca", "library": "Librería",
"total_play_time": "Has jugado", "pinned": "Fijado",
"no_recent_activity_title": "Que raro, no hay nada por acá...", "total_play_time": "Total de tiempo de juego",
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!", "achievements_earned": "Logros conseguidos",
"display_name": "Nombre en pantalla", "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", "saving": "Guardando",
"save": "Guardar", "save": "Guardado",
"edit_profile": "Editar perfil", "edit_profile": "Editar perfil",
"saved_successfully": "Guardado exitosamente", "saved_successfully": "Guardado exitosamente",
"try_again": "Por favor, intenta de nuevo", "try_again": "Por favor, intentá de nuevo",
"sign_out_modal_title": "¿Estás seguro?", "sign_out_modal_title": "¿Querés continuar?",
"cancel": "Cancelar", "cancel": "Cancelar",
"successfully_signed_out": "Sesión cerrada exitosamente", "successfully_signed_out": "Cerraste sesión exitosamente",
"sign_out": "Cerrar sesión", "sign_out": "Cerrar sesión",
"playing_for": "Llevas jugando {{amount}}", "playing_for": "Jugando por {{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?", "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 amigos", "add_friends": "Añadir amistades",
"add": "Añadir", "add": "Añadir",
"friend_code": "Código de amigo", "friend_code": "Código de amistad",
"see_profile": "Ver perfil", "see_profile": "Ver perfil",
"sending": "Enviando", "sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada", "friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amigos", "friends": "Amistades",
"friends_list": "Lista de amigos", "friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado", "user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario", "block_user": "Bloquear usuario",
"add_friend": "Añadir amigo", "add_friend": "Añadir amistad",
"request_sent": "Solicitud enviada", "request_sent": "Solicitud enviada",
"request_received": "Solicitud recibida", "request_received": "Solicitud recibida",
"accept_request": "Aceptar solicitud", "accept_request": "Aceptar solicitud",
"ignore_request": "Ignorar solicitud", "ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud", "cancel_request": "Cancelar solicitud",
"undo_friendship": "Eliminar amistad", "undo_friendship": "Deshacer amistad",
"request_accepted": "Solicitud aceptada", "request_accepted": "Solicitud aceptada",
"user_blocked_successfully": "Usuario bloqueado exitosamente", "user_blocked_successfully": "Usuario bloqueado exitosamente",
"user_block_modal_text": "Esto va a bloquear a {{displayName}}", "user_block_modal_text": "Esto va a bloquear a {{displayName}}",
"blocked_users": "Usuarios bloqueados", "blocked_users": "Usuarios bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "Todavía no tienes amigos añadidos", "no_friends_added": "No tenés amistades añadidas",
"pending": "Pendiente", "pending": "Pendiente",
"no_pending_invites": "No tienes invitaciones pendientes", "no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a ningún usuario", "no_blocked_users": "No has bloqueado a nadie",
"friend_code_copied": "Código de amigo copiado", "friend_code_copied": "Código de amistad copiado",
"undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}", "undo_friendship_modal_text": "Esto va a deshacer tu amistad con {{displayName}}",
"privacy_hint": "Para ajustar quién puede ver esto, ve a <0>Configuración</0>.", "privacy_hint": "Para cambiar quién puede ver esto, andá a <0>Ajustes</0>",
"locked_profile": "Este perfil es privado", "locked_profile": "Este perfil es privado",
"image_process_failure": "Error al procesar la imagen", "image_process_failure": "Errpr al procesar la imagen",
"required_field": "Este campo es obligatorio", "required_field": "Este campo es requerido",
"displayname_min_length": "El nombre a mostrar debe tener al menos 3 caracteres", "displayname_min_length": "El nombre en pantalla debe tener mínimo 3 caracteres",
"displayname_max_length": "El nombre a mostrar debe tener como máximo 50 caracteres", "displayname_max_length": "El nombre en pantalla debe tener máximo 50 caracteres",
"report_profile": "Reportar este perfil", "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": "Información adicional",
"report_description_placeholder": "Información adicional", "report_description_placeholder": "Información adicional",
"report": "Reportar", "report": "Reportar",
"report_reason_hate": "Discursos de odio", "report_reason_hate": "Discursos de odio",
"report_reason_sexual_content": "Contenido sexual", "report_reason_sexual_content": "Contenido sexual",
"report_reason_violence": "Violencia", "report_reason_violence": "Violencia",
"report_reason_spam": "Spam / Contenido no deseado", "report_reason_spam": "Spam",
"report_reason_other": "Otro", "report_reason_other": "Otros",
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amigo:", "your_friend_code": "Tu código de amistad:",
"upload_banner": "Subir un banner", "upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…", "uploading_banner": "Subiendo banner…",
"background_image_updated": "Imagen de fondo actualizada", "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", "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": {
"achievement_unlocked": "Logro desbloqueado", "achievement_unlocked": "Logro desbloqueado",
"user_achievements": "Logros de {{displayName}}", "user_achievements": "Logros de {{displayName}}",
"your_achievements": "Tus Logros", "your_achievements": "Tus logros",
"unlocked_at": "Desbloqueado el: {{date}}", "unlocked_at": "Desbloqueado el: {{date}}",
"subscription_needed": "Se necesita una suscripción a Hydra Cloud necesita para ver este contenido", "subscription_needed": "Se requiere una suscripción a Hydra Cloud para ver esto",
"new_achievements_unlocked": "Desbloqueados {{achievementCount}} nuevos logros de {{gameCount}} juegos", "new_achievements_unlocked": "Desbloqueaste {{achievementCount}} nuevos logros de {{gameCount}} juegos",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} logros", "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", "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:", "earned_points": "Puntos obtenidos:",
"available_points": "Puntos disponibles:", "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": { "hydra_cloud": {
"subscription_tour_title": "Suscripción Hydra Cloud", "subscription_tour_title": "Suscripción Hydra Cloud",
"debrid_description": "Descargas hasta x4 más rápidas con Nimbus", "subscribe_now": "suscribíte ahora",
"subscribe_now": "Suscribirse ahora",
"cloud_saving": "Guardado en la nube", "cloud_saving": "Guardado en la nube",
"cloud_achievements": "Guarda tus logros en la nube", "cloud_achievements": "Guardá tus logros en la nube",
"animated_profile_picture": "Fotos de perfil animadas", "animated_profile_picture": "Foto de perfil animada",
"premium_support": "Soporte Premium", "premium_support": "Soporte Premium",
"show_and_compare_achievements": "Muestra y compara tus logros con otros usuarios", "show_and_compare_achievements": "Mostrá y compará tus logros con otros usuarios",
"animated_profile_banner": "Fondo de perfil animado", "animated_profile_banner": "Banner de perfil animado",
"hydra_cloud": "Hydra Cloud", "hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": Has descubierto una característica de Hydra Cloud!", "hydra_cloud_feature_found": Acabas de descubrir una característica de Hydra Cloud!",
"learn_more": "Aprender más" "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" "successfully_signed_in": "Edukalt sisse logitud"
}, },
"home": { "home": {
"featured": "Esile toodud",
"surprise_me": "Üllata mind", "surprise_me": "Üllata mind",
"no_results": "Tulemusi ei leitud", "no_results": "Tulemusi ei leitud",
"start_typing": "Alusta otsimiseks kirjutamist...", "start_typing": "Alusta otsimiseks kirjutamist...",
@@ -45,10 +44,7 @@
"calculating_eta": "{{title}} allalaadimine… ({{percentage}} valmis) - Järelejäänud aja arvutamine…", "calculating_eta": "{{title}} allalaadimine… ({{percentage}} valmis) - Järelejäänud aja arvutamine…",
"checking_files": "{{title}} failide kontrollimine… ({{percentage}} valmis)" "checking_files": "{{title}} failide kontrollimine… ({{percentage}} valmis)"
}, },
"catalogue": { "catalogue": {},
"next_page": "Järgmine leht",
"previous_page": "Eelmine leht"
},
"game_details": { "game_details": {
"open_download_options": "Ava allalaadimise valikud", "open_download_options": "Ava allalaadimise valikud",
"download_options_zero": "Allalaadimise valikuid pole", "download_options_zero": "Allalaadimise valikuid pole",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -4,7 +4,6 @@
"successfully_signed_in": "Autenticado com sucesso" "successfully_signed_in": "Autenticado com sucesso"
}, },
"home": { "home": {
"featured": "Destaques",
"hot": "Populares", "hot": "Populares",
"weekly": "📅 Mais baixados da semana", "weekly": "📅 Mais baixados da semana",
"achievements": "🏆 Pra platinar", "achievements": "🏆 Pra platinar",
@@ -27,7 +26,22 @@
"sign_in": "Login", "sign_in": "Login",
"friends": "Amigos", "friends": "Amigos",
"need_help": "Precisa de ajuda?", "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": { "header": {
"search": "Buscar jogos", "search": "Buscar jogos",
@@ -76,6 +90,7 @@
"amount_minutes": "{{amount}} minutos", "amount_minutes": "{{amount}} minutos",
"accuracy": "{{accuracy}}% de precisão", "accuracy": "{{accuracy}}% de precisão",
"add_to_library": "Adicionar à biblioteca", "add_to_library": "Adicionar à biblioteca",
"already_in_library": "Já está na biblioteca",
"remove_from_library": "Remover da biblioteca", "remove_from_library": "Remover da biblioteca",
"no_downloads": "Nenhum download disponível", "no_downloads": "Nenhum download disponível",
"play_time": "Jogou por {{amount}}", "play_time": "Jogou por {{amount}}",
@@ -206,7 +221,31 @@
"backup_frozen": "Backup fixado", "backup_frozen": "Backup fixado",
"backup_unfrozen": "Backup removido dos fixados", "backup_unfrozen": "Backup removido dos fixados",
"backup_freeze_failed": "Falha ao fixar backup", "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": { "activation": {
"title": "Ativação", "title": "Ativação",
@@ -364,6 +403,7 @@
"installing_common_redist": "Instalando…", "installing_common_redist": "Instalando…",
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo", "show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
"extract_files_by_default": "Extrair arquivos automaticamente após o download", "extract_files_by_default": "Extrair arquivos automaticamente após o download",
"enable_steam_achievements": "Habilitar busca por conquistas da Steam",
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas", "enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
"top-left": "Superior esquerdo", "top-left": "Superior esquerdo",
"top-center": "Superior central", "top-center": "Superior central",
@@ -380,7 +420,8 @@
"hidden": "Oculta", "hidden": "Oculta",
"test_notification": "Testar notificação", "test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas", "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": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@@ -509,7 +550,15 @@
"show_achievements_on_profile": "Exiba suas conquistas no perfil", "show_achievements_on_profile": "Exiba suas conquistas no perfil",
"show_points_on_profile": "Exiba seus pontos ganhos 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", "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": {
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",

View File

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

View File

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

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Успешный вход" "successfully_signed_in": "Успешный вход"
}, },
"home": { "home": {
"featured": "Рекомендации",
"surprise_me": "Удиви меня", "surprise_me": "Удиви меня",
"no_results": "Ничего не найдено", "no_results": "Ничего не найдено",
"hot": "Сейчас популярно", "hot": "Сейчас популярно",
@@ -28,7 +27,47 @@
"friends": "Друзья", "friends": "Друзья",
"need_help": "Нужна помощь?", "need_help": "Нужна помощь?",
"favorites": "Избранное", "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": { "header": {
"search": "Поиск", "search": "Поиск",
@@ -195,6 +234,10 @@
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.", "download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.", "download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
"download_error_not_cached_on_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с 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 пока невозможно.", "download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.",
"game_added_to_favorites": "Игра добавлена в избранное", "game_added_to_favorites": "Игра добавлена в избранное",
"game_removed_from_favorites": "Игра удалена из избранного", "game_removed_from_favorites": "Игра удалена из избранного",
@@ -205,7 +248,23 @@
"invalid_wine_prefix_path": "Недопустимый путь префикса Wine", "invalid_wine_prefix_path": "Недопустимый путь префикса Wine",
"invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.", "invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.",
"missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux", "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": { "activation": {
"title": "Активировать Hydra", "title": "Активировать Hydra",
@@ -381,7 +440,8 @@
"hidden": "Скрытый", "hidden": "Скрытый",
"test_notification": "Тестовое уведомление", "test_notification": "Тестовое уведомление",
"notification_preview": "Предварительный просмотр уведомления о достижении", "notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру" "enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"enable_steam_achievements": "Включить поиск достижений Steam"
}, },
"notifications": { "notifications": {
"download_complete": "Загрузка завершена", "download_complete": "Загрузка завершена",
@@ -394,12 +454,12 @@
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}", "notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}", "notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
"new_friend_request_title": "Новый запрос на добавление в друзья", "new_friend_request_title": "Новый запрос на добавление в друзья",
"new_friend_request_description": "Вы получили новый запрос на добавление в друзья",
"extraction_complete": "Распаковка завершена", "extraction_complete": "Распаковка завершена",
"game_extracted": "{{title}} успешно распакован", "game_extracted": "{{title}} успешно распакован",
"friend_started_playing_game": "{{displayName}} начал играть в игру", "friend_started_playing_game": "{{displayName}} начал играть в игру",
"test_achievement_notification_title": "Это тестовое уведомление", "test_achievement_notification_title": "Это тестовое уведомление",
"test_achievement_notification_description": "Довольно круто, да?" "test_achievement_notification_description": "Довольно круто, да?",
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья"
}, },
"system_tray": { "system_tray": {
"open": "Открыть Hydra", "open": "Открыть Hydra",
@@ -424,10 +484,13 @@
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} часов", "amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут", "amount_minutes": "{{amount}} минут",
"amount_hours_short": "{{amount}}ч",
"amount_minutes_short": "{{amount}}м",
"last_time_played": "Последняя игра {{period}}", "last_time_played": "Последняя игра {{period}}",
"activity": "Недавняя активность", "activity": "Недавняя активность",
"library": "Библиотека", "library": "Библиотека",
"total_play_time": "Всего сыграно", "total_play_time": "Всего сыграно",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"no_recent_activity_title": "Хммм... Тут ничего нет", "no_recent_activity_title": "Хммм... Тут ничего нет",
"no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!", "no_recent_activity_description": "Вы давно ни во что не играли. Пора это изменить!",
"display_name": "Отображаемое имя", "display_name": "Отображаемое имя",
@@ -500,7 +563,9 @@
"achievements_unlocked": "Достижения разблокированы", "achievements_unlocked": "Достижения разблокированы",
"earned_points": "Заработано очков:", "earned_points": "Заработано очков:",
"show_achievements_on_profile": "Покажите свои достижения в профиле", "show_achievements_on_profile": "Покажите свои достижения в профиле",
"show_points_on_profile": "Показывать заработанные очки в своем профиле" "show_points_on_profile": "Показывать заработанные очки в своем профиле",
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
"friend_code_length_error": "Код друга должен содержать 8 символов"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Достижение разблокировано", "achievement_unlocked": "Достижение разблокировано",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Inloggningen lyckades" "successfully_signed_in": "Inloggningen lyckades"
}, },
"home": { "home": {
"featured": "Utvalt",
"surprise_me": "Överraska mig", "surprise_me": "Överraska mig",
"no_results": "Inga resultat hittades", "no_results": "Inga resultat hittades",
"start_typing": "Börja skriva för att söka...", "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ı" "successfully_signed_in": "Başarıyla giriş yapıldı"
}, },
"home": { "home": {
"featured": "Öne Çıkanlar",
"surprise_me": "Beni Şaşırt", "surprise_me": "Beni Şaşırt",
"no_results": "Sonuç bulunamadı", "no_results": "Sonuç bulunamadı",
"start_typing": "Aramak için yazmaya başlayın...", "start_typing": "Aramak için yazmaya başlayın...",

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
{ {
"language_name": "中文", "language_name": "简体中文",
"app": { "app": {
"successfully_signed_in": "已成功登录" "successfully_signed_in": "已成功登录"
}, },
"home": { "home": {
"featured": "特色推荐",
"surprise_me": "向我推荐", "surprise_me": "向我推荐",
"no_results": "没有找到结果", "no_results": "没有找到结果",
"start_typing": "键入以开始搜素...", "start_typing": "键入以开始搜素...",
@@ -26,7 +25,9 @@
"game_has_no_executable": "未选择游戏的可执行文件", "game_has_no_executable": "未选择游戏的可执行文件",
"sign_in": "登入", "sign_in": "登入",
"friends": "好友", "friends": "好友",
"favorites": "收藏" "favorites": "收藏",
"need_help": "需要帮助?",
"playable_button_title": "仅显示现在可以游玩的游戏"
}, },
"header": { "header": {
"search": "搜索游戏", "search": "搜索游戏",
@@ -43,11 +44,21 @@
"downloading_metadata": "正在下载{{title}}的元数据…", "downloading_metadata": "正在下载{{title}}的元数据…",
"downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}", "downloading": "正在下载{{title}}… ({{percentage}}完成) - 剩余时间{{eta}} - 速度{{speed}}",
"calculating_eta": "正在下载 {{title}}… (已完成{{percentage}}.) - 正在计算剩余时间...", "calculating_eta": "正在下载 {{title}}… (已完成{{percentage}}.) - 正在计算剩余时间...",
"checking_files": "正在校验 {{title}} 的文件... ({{percentage}} 已完成)" "checking_files": "正在校验 {{title}} 的文件... ({{percentage}} 已完成)",
"installation_complete": "安装完成",
"installation_complete_message": "通用可再发行组件安装成功",
"installing_common_redist": "{{log}}…"
}, },
"catalogue": { "catalogue": {
"next_page": "下一页", "clear_filters": "清除已选的 {{filterCount}} 项",
"previous_page": "上一页" "developers": "开发商",
"download_sources": "下载源",
"filter_count": "{{filterCount}} 项可用",
"genres": "类型",
"publishers": "发行商",
"result_count": "{{resultCount}} 个结果",
"search": "筛选…",
"tags": "标签"
}, },
"game_details": { "game_details": {
"open_download_options": "打开下载菜单", "open_download_options": "打开下载菜单",
@@ -166,7 +177,48 @@
"manage_files_description": "管理哪些文件要备份和恢复", "manage_files_description": "管理哪些文件要备份和恢复",
"select_folder": "选择文件夹", "select_folder": "选择文件夹",
"backup_from": "{{date}} 时备份", "backup_from": "{{date}} 时备份",
"custom_backup_location_set": "自定义备份文件位置" "custom_backup_location_set": "自定义备份文件位置",
"artifact_name_label": "备份名称",
"artifact_name_placeholder": "为备份输入名称",
"artifact_renamed": "备份重命名成功",
"automatic_backup_from": "{{date}} 的自动备份",
"automatically_extract_downloaded_files": "自动解压下载的文件",
"backup_freeze_failed": "固定备份失败",
"backup_freeze_failed_description": "您必须至少保留一个空位用于自动备份",
"backup_frozen": "备份已固定",
"backup_unfrozen": "备份已取消固定",
"clear": "清除",
"create_start_menu_shortcut": "创建开始菜单快捷方式",
"create_steam_shortcut": "创建Steam快捷方式",
"download_error_gofile_quota_exceeded": "您已超出Gofile的月度配额。请等待配额重置。",
"download_error_not_cached_on_hydra": "此下载在Nimbus上不可用。",
"download_error_not_cached_on_real_debrid": "此下载在Real-Debrid上不可用且暂不支持从Real-Debrid轮询下载状态。",
"download_error_not_cached_on_torbox": "此下载在TorBox上不可用且暂不支持从TorBox轮询下载状态。",
"download_error_real_debrid_account_not_authorized": "您的Real-Debrid账户未被授权进行新下载。请检查您的账户设置并重试。",
"enable_automatic_cloud_sync": "启用自动云同步",
"freeze_backup": "固定以免被自动备份覆盖",
"game_added_to_favorites": "游戏已添加到收藏",
"game_removed_from_favorites": "游戏已从收藏中移除",
"invalid_wine_prefix_path": "无效的Wine前置路径",
"invalid_wine_prefix_path_description": "Wine前置的路径无效。请检查路径并重试。",
"launch_options": "启动选项",
"launch_options_description": "高级用户可以选择修改启动选项(实验性功能)",
"launch_options_placeholder": "未指定参数",
"max_length_field": "此字段必须少于 {{length}} 个字符",
"missing_wine_prefix": "在Linux上创建备份需要Wine前置",
"no_directory_selected": "未选择目录",
"no_write_permission": "无法下载到此目录。点击此处了解更多。",
"rename_artifact": "重命名备份",
"rename_artifact_description": "将备份重命名为更具描述性的名称",
"required_field": "此字段为必填项",
"reset_achievements": "重置成就",
"reset_achievements_description": "这将重置 {{game}} 的所有成就",
"reset_achievements_error": "重置成就失败",
"reset_achievements_success": "成就重置成功",
"reset_achievements_title": "您确定吗?",
"save_changes": "保存更改",
"unfreeze_backup": "取消固定",
"you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改"
}, },
"activation": { "activation": {
"title": "激活 Hydra", "title": "激活 Hydra",
@@ -199,7 +251,13 @@
"queued": "下载列表", "queued": "下载列表",
"no_downloads_title": "空空如也", "no_downloads_title": "空空如也",
"no_downloads_description": "你还未使用Hydra下载任何游戏,但什么时候开始,都为时不晚。", "no_downloads_description": "你还未使用Hydra下载任何游戏,但什么时候开始,都为时不晚。",
"checking_files": "正在校验文件…" "checking_files": "正在校验文件…",
"extract": "解压文件",
"extracting": "正在解压文件…",
"options": "管理",
"resume_seeding": "恢复做种",
"seeding": "做种中",
"stop_seeding": "停止做种"
}, },
"settings": { "settings": {
"downloads_path": "下载路径", "downloads_path": "下载路径",
@@ -260,7 +318,83 @@
"must_be_valid_url": "来源必须是有效的 URL", "must_be_valid_url": "来源必须是有效的 URL",
"blocked_users": "已屏蔽用户", "blocked_users": "已屏蔽用户",
"user_unblocked": "用户已经被屏蔽", "user_unblocked": "用户已经被屏蔽",
"enable_achievement_notifications": "当成就解锁时" "enable_achievement_notifications": "当成就解锁时",
"account": "账户",
"account_data_updated_successfully": "账户数据更新成功",
"achievement_custom_notification_position": "成就自定义通知位置",
"alignment": "对齐",
"appearance": "外观",
"become_subscriber": "成为Hydra Cloud用户",
"bill_sent_until": "您的下一张账单将在此日期前发送",
"bottom-center": "底部中央",
"bottom-left": "底部左侧",
"bottom-right": "底部右侧",
"cancel": "取消",
"clear_themes": "清除",
"common_redist": "通用可再发行组件",
"common_redist_description": "运行某些游戏需要通用可再发行组件。建议安装以避免问题。",
"create_real_debrid_account": "如果您还没有Real-Debrid账户请点击此处",
"create_theme": "创建",
"create_theme_modal_description": "创建新主题以自定义Hydra的外观",
"create_theme_modal_title": "创建自定义主题",
"create_torbox_account": "如果您还没有TorBox账户请点击此处",
"current_email": "当前邮箱:",
"default": "默认",
"delete_all_themes": "删除所有主题",
"delete_all_themes_description": "这将删除所有您的自定义主题",
"delete_theme": "删除主题",
"delete_theme_description": "这将删除主题 {{theme}}",
"disable_nsfw_alert": "禁用NSFW警告",
"edit_theme": "编辑主题",
"editor_tab_code": "代码",
"editor_tab_info": "信息",
"editor_tab_save": "保存",
"enable_achievement_custom_notifications": "启用成就自定义通知",
"enable_auto_install": "自动下载更新",
"enable_friend_request_notifications": "当收到好友请求时",
"enable_friend_start_game_notifications": "当好友开始游戏时",
"enable_torbox": "启用TorBox",
"error_importing_theme": "导入主题时出错",
"extract_files_by_default": "下载后默认解压文件",
"hidden": "隐藏",
"import_theme": "导入主题",
"import_theme_description": "您将从主题商店导入 {{theme}}",
"insert_theme_name": "输入主题名称",
"install_common_redist": "安装",
"installing_common_redist": "正在安装…",
"launch_minimized": "最小化启动Hydra",
"manage_subscription": "管理订阅",
"name_min_length": "主题名称必须至少3个字符长",
"no_email_account": "您尚未设置邮箱",
"no_subscription": "以最佳方式享受Hydra",
"no_themes": "看起来您还没有任何主题,但别担心,点击这里创建您的第一个杰作。",
"no_users_blocked": "您没有屏蔽任何用户",
"notification_preview": "成就通知预览",
"platinum": "白金",
"rare": "稀有",
"real_debrid_account_linked": "Real-Debrid账户已连接",
"renew_subscription": "续费Hydra Cloud",
"seed_after_download_complete": "下载完成后做种",
"set_theme": "设置主题",
"show_download_speed_in_megabytes": "以兆字节每秒显示下载速度",
"show_hidden_achievement_description": "在解锁前显示隐藏成就描述",
"subscription_active_until": "您的Hydra Cloud活跃至 {{date}}",
"subscription_expired_at": "您的订阅已于 {{date}} 到期",
"subscription_renew_cancelled": "自动续费已禁用",
"subscription_renews_on": "您的订阅将于 {{date}} 续费",
"test_notification": "测试通知",
"theme_imported": "主题导入成功",
"theme_name": "名称",
"top-center": "顶部中央",
"top-left": "顶部左侧",
"top-right": "顶部右侧",
"torbox_account_linked": "TorBox账户已连接",
"torbox_description": "TorBox是您的高级种子盒服务甚至可与市场上最好的服务器相媲美。",
"unset_theme": "取消设置主题",
"update_email": "更新邮箱",
"update_password": "更新密码",
"variation": "变体",
"web_store": "网络商店"
}, },
"notifications": { "notifications": {
"download_complete": "下载完成", "download_complete": "下载完成",
@@ -271,14 +405,23 @@
"new_update_available": "版本 {{version}} 可用", "new_update_available": "版本 {{version}} 可用",
"restart_to_install_update": "重启 Hydra 以安装更新", "restart_to_install_update": "重启 Hydra 以安装更新",
"notification_achievement_unlocked_title": "{{game}} 的成绩已解锁", "notification_achievement_unlocked_title": "{{game}} 的成绩已解锁",
"notification_achievement_unlocked_body": "{{achievement}} 和其他 {{count}} 已解锁" "notification_achievement_unlocked_body": "{{achievement}} 和其他 {{count}} 已解锁",
"extraction_complete": "解压完成",
"friend_started_playing_game": "{{displayName}} 开始玩游戏",
"game_extracted": "{{title}} 解压成功",
"new_friend_request_description": "{{displayName}} 向您发送了好友请求",
"new_friend_request_title": "新好友请求",
"test_achievement_notification_description": "非常酷,对吧?",
"test_achievement_notification_title": "这是一个测试通知"
}, },
"system_tray": { "system_tray": {
"open": "打开 Hydra", "open": "打开 Hydra",
"quit": "退出" "quit": "退出"
}, },
"game_card": { "game_card": {
"no_downloads": "无可用下载选项" "no_downloads": "无可用下载选项",
"available_one": "可用",
"available_other": "可用"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "程序未安装", "title": "程序未安装",
@@ -351,7 +494,7 @@
"report_description": "额外信息", "report_description": "额外信息",
"report_description_placeholder": "额外信息", "report_description_placeholder": "额外信息",
"report": "举报", "report": "举报",
"report_reason_hate": "Hate speech", "report_reason_hate": "仇恨言论",
"report_reason_sexual_content": "色情内容", "report_reason_sexual_content": "色情内容",
"report_reason_violence": "暴力", "report_reason_violence": "暴力",
"report_reason_spam": "骚扰", "report_reason_spam": "骚扰",
@@ -360,7 +503,19 @@
"your_friend_code": "你的好友代码:", "your_friend_code": "你的好友代码:",
"upload_banner": "上传横幅", "upload_banner": "上传横幅",
"uploading_banner": "上传横幅中…", "uploading_banner": "上传横幅中…",
"background_image_updated": "背景图片已更新" "background_image_updated": "背景图片已更新",
"achievements": "成就",
"achievements_unlocked": "成就已解锁",
"earned_points": "获得积分",
"error_adding_friend": "无法发送好友请求。请检查好友代码",
"friend_code_length_error": "好友代码必须为8个字符",
"games": "游戏",
"playing": "正在玩 {{game}}",
"ranking_updated_weekly": "排名每周更新",
"show_achievements_on_profile": "在您的个人资料上显示成就",
"show_points_on_profile": "在您的个人资料上显示获得的积分",
"stats": "统计",
"top_percentile": "前 {{percentile}}%"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "成就已解锁", "achievement_unlocked": "成就已解锁",
@@ -368,7 +523,14 @@
"your_achievements": "你的成就", "your_achievements": "你的成就",
"unlocked_at": "解锁于: {{date}}", "unlocked_at": "解锁于: {{date}}",
"subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容", "subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容",
"new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就" "new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就",
"achievement_earn_points": "通过此成就获得 {{points}} 积分",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} 成就",
"achievements_unlocked_for_game": "为 {{gameTitle}} 解锁了 {{achievementCount}} 个新成就",
"available_points": "可用积分:",
"earned_points": "获得积分:",
"hidden_achievement_tooltip": "这是一个隐藏成就",
"how_to_earn_achievements_points": "如何获得成就积分?"
}, },
"hydra_cloud": { "hydra_cloud": {
"subscription_tour_title": "Hydra 云订阅", "subscription_tour_title": "Hydra 云订阅",
@@ -378,6 +540,10 @@
"animated_profile_picture": "动画头像", "animated_profile_picture": "动画头像",
"premium_support": "高级技术支持", "premium_support": "高级技术支持",
"show_and_compare_achievements": "展示并与其他用户比较您的成就", "show_and_compare_achievements": "展示并与其他用户比较您的成就",
"animated_profile_banner": "动态个人简介横幅" "animated_profile_banner": "动态个人简介横幅",
"debrid_description": "使用Nimbus下载速度提升4倍",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "您刚刚发现了一个Hydra Cloud功能",
"learn_more": "了解更多"
} }
} }

View File

@@ -41,4 +41,4 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
export const MAIN_LOOP_INTERVAL = 1500; export const MAIN_LOOP_INTERVAL = 2000;

View File

@@ -10,7 +10,16 @@ const saveGameShopAssets = async (
): Promise<void> => { ): Promise<void> => {
const key = levelKeys.game(shop, objectId); const key = levelKeys.game(shop, objectId);
const existingAssets = await gamesShopAssetsSublevel.get(key); 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); registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -14,8 +14,12 @@ import "./catalogue/get-developers";
import "./hardware/get-disk-free-space"; import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission"; import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library"; 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/add-game-to-favorites";
import "./library/remove-game-from-favorites"; import "./library/remove-game-from-favorites";
import "./library/toggle-game-pin";
import "./library/create-game-shortcut"; import "./library/create-game-shortcut";
import "./library/close-game"; import "./library/close-game";
import "./library/delete-game-folder"; import "./library/delete-game-folder";
@@ -33,9 +37,12 @@ import "./library/remove-game";
import "./library/remove-game-from-library"; import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix"; import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements"; import "./library/reset-game-achievements";
import "./library/change-game-playtime";
import "./library/toggle-automatic-cloud-sync"; import "./library/toggle-automatic-cloud-sync";
import "./library/get-default-wine-prefix-selection-path"; import "./library/get-default-wine-prefix-selection-path";
import "./library/cleanup-unused-assets";
import "./library/create-steam-shortcut"; import "./library/create-steam-shortcut";
import "./library/copy-custom-game-asset";
import "./misc/open-checkout"; import "./misc/open-checkout";
import "./misc/open-external"; import "./misc/open-external";
import "./misc/show-open-dialog"; import "./misc/show-open-dialog";
@@ -44,6 +51,8 @@ import "./misc/show-item-in-folder";
import "./misc/get-badges"; import "./misc/get-badges";
import "./misc/install-common-redist"; import "./misc/install-common-redist";
import "./misc/can-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/cancel-game-download";
import "./torrenting/pause-game-download"; import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download"; import "./torrenting/resume-game-download";
@@ -63,6 +72,7 @@ import "./auth/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
import "./user/get-user"; import "./user/get-user";
import "./user/get-user-library";
import "./user/get-blocked-users"; import "./user/get-blocked-users";
import "./user/block-user"; import "./user/block-user";
import "./user/unblock-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 = { game = {
title, title,
iconUrl: gameAssets?.iconUrl ?? null, iconUrl: gameAssets?.iconUrl ?? null,
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
logoImageUrl: gameAssets?.logoImageUrl ?? null,
objectId, objectId,
shop, shop,
remoteId: null, remoteId: null,
@@ -41,12 +43,14 @@ const addGameToLibrary = async (
await gamesSublevel.put(gameKey, game); await gamesSublevel.put(gameKey, game);
} }
await createGame(game).catch(() => {}); if (game) {
await createGame(game).catch(() => {});
AchievementWatcherManager.firstSyncWithRemoteIfNeeded( AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop, game.shop,
game.objectId game.objectId
); );
}
}; };
registerEvent("addGameToLibrary", addGameToLibrary); 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

@@ -94,7 +94,7 @@ const createSteamShortcut = async (
if (!steamUserIds.length) { if (!steamUserIds.length) {
logger.error("No Steam user ID found"); logger.error("No Steam user ID found");
return; throw new Error("No Steam user ID found");
} }
const [iconImage, heroImage, logoImage, coverImage, libraryImage] = const [iconImage, heroImage, logoImage, coverImage, libraryImage] =

View File

@@ -23,7 +23,10 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
...game, ...game,
download: download ?? null, download: download ?? null,
...gameAssets, ...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 { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi, logger } from "@main/services";
import { gamesSublevel, levelKeys } from "@main/level"; import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types"; 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 ( const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -11,17 +74,21 @@ const removeGameFromLibrary = async (
const gameKey = levelKeys.game(shop, objectId); const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey); const game = await gamesSublevel.get(gameKey);
if (game) { if (!game) return;
await gamesSublevel.put(gameKey, {
...game,
isDeleted: true,
executablePath: null,
});
if (game?.remoteId) { const assetPathsToDelete = collectAssetPathsToDelete(game);
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
} 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); 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, { await gamesSublevel.put(gameKey, {
title, title,
iconUrl: gameAssets?.iconUrl ?? null, iconUrl: gameAssets?.iconUrl ?? null,
libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null,
logoImageUrl: gameAssets?.logoImageUrl ?? null,
objectId, objectId,
shop, shop,
remoteId: null, remoteId: null,

View File

@@ -80,7 +80,7 @@ const getUnlockedAchievementsEvent = async (
objectId: string, objectId: string,
shop: GameShop shop: GameShop
): Promise<UserAchievement[]> => { ): Promise<UserAchievement[]> => {
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(shop, objectId); await AchievementWatcherManager.firstSyncWithRemoteIfNeeded(shop, objectId);
return getUnlockedAchievements(objectId, shop, false); return getUnlockedAchievements(objectId, shop, false);
}; };

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()); 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(); await loadState();
const language = await db const language = await db

View File

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

View File

@@ -3,6 +3,7 @@ import { mergeAchievements } from "./merge-achievements";
import fs, { readdirSync } from "node:fs"; import fs, { readdirSync } from "node:fs";
import { import {
findAchievementFileInExecutableDirectory, findAchievementFileInExecutableDirectory,
findAchievementFileInSteamPath,
findAchievementFiles, findAchievementFiles,
findAllAchievementFiles, findAllAchievementFiles,
getAlternativeObjectIds, getAlternativeObjectIds,
@@ -43,10 +44,14 @@ const watchAchievementsWindows = async () => {
gameAchievementFiles.push( gameAchievementFiles.push(
...findAchievementFileInExecutableDirectory(game) ...findAchievementFileInExecutableDirectory(game)
); );
gameAchievementFiles.push(
...(await findAchievementFileInSteamPath(game))
);
} }
for (const file of gameAchievementFiles) { for (const file of gameAchievementFiles) {
compareFile(game, file); await compareFile(game, file);
} }
} }
}; };
@@ -61,13 +66,11 @@ const watchAchievementsWithWine = async () => {
for (const game of games) { for (const game of games) {
const gameAchievementFiles = findAchievementFiles(game); const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory); gameAchievementFiles.push(...(await findAchievementFileInSteamPath(game)));
for (const file of gameAchievementFiles) { for (const file of gameAchievementFiles) {
compareFile(game, file); await compareFile(game, file);
} }
} }
}; };
@@ -167,15 +170,14 @@ export class AchievementWatcherManager {
const gameKey = levelKeys.game(shop, objectId); const gameKey = levelKeys.game(shop, objectId);
if (this.alreadySyncedGames.get(gameKey)) return; if (this.alreadySyncedGames.get(gameKey)) return;
this.alreadySyncedGames.set(gameKey, true);
const game = await gamesSublevel.get(gameKey).catch(() => null); const game = await gamesSublevel.get(gameKey).catch(() => null);
if (!game || game.isDeleted) return; if (!game || game.isDeleted) return;
const gameAchievementFiles = findAchievementFiles(game); const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory = gameAchievementFiles.push(...(await findAchievementFileInSteamPath(game)));
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
const unlockedAchievements: UnlockedAchievement[] = []; const unlockedAchievements: UnlockedAchievement[] = [];
@@ -190,8 +192,6 @@ export class AchievementWatcherManager {
} }
} }
this.alreadySyncedGames.set(gameKey, true);
const newAchievements = await mergeAchievements( const newAchievements = await mergeAchievements(
game, game,
unlockedAchievements, unlockedAchievements,
@@ -259,7 +259,7 @@ export class AchievementWatcherManager {
const gameAchievementFilesMap = findAllAchievementFiles(); const gameAchievementFilesMap = findAllAchievementFiles();
return Promise.all( return Promise.all(
games.map((game) => { games.map(async (game) => {
const achievementFiles: AchievementFile[] = []; const achievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectId)) { for (const objectId of getAlternativeObjectIds(game.objectId)) {
@@ -270,6 +270,10 @@ export class AchievementWatcherManager {
achievementFiles.push( achievementFiles.push(
...findAchievementFileInExecutableDirectory(game) ...findAchievementFileInExecutableDirectory(game)
); );
achievementFiles.push(
...(await findAchievementFileInSteamPath(game))
);
} }
return { game, achievementFiles }; return { game, achievementFiles };
@@ -284,12 +288,10 @@ export class AchievementWatcherManager {
.then((games) => games.filter((game) => !game.isDeleted)); .then((games) => games.filter((game) => !game.isDeleted));
return Promise.all( return Promise.all(
games.map((game) => { games.map(async (game) => {
const achievementFiles = findAchievementFiles(game); const achievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
achievementFiles.push(...achievementFileInsideDirectory); achievementFiles.push(...(await findAchievementFileInSteamPath(game)));
return { game, achievementFiles }; return { game, achievementFiles };
}) })

View File

@@ -1,9 +1,11 @@
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import type { Game, AchievementFile } from "@types"; import type { Game, AchievementFile, UserPreferences } from "@types";
import { Cracker } from "@shared"; import { Cracker } from "@shared";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { SystemPath } from "../system-path"; import { SystemPath } from "../system-path";
import { getSteamLocation, getSteamUsersIds } from "../steam";
import { db, levelKeys } from "@main/level";
const getAppDataPath = () => { const getAppDataPath = () => {
if (process.platform === "win32") { if (process.platform === "win32") {
@@ -270,6 +272,55 @@ export const findAchievementFiles = (game: Game) => {
} }
} }
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
return achievementFiles.concat(achievementFileInsideDirectory);
};
const steamUserIds = await getSteamUsersIds();
const steamPath = await getSteamLocation().catch(() => null);
export const findAchievementFileInSteamPath = async (game: Game) => {
if (!steamUserIds.length) {
return [];
}
if (!steamPath) {
return [];
}
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (!userPreferences?.enableSteamAchievements) {
return [];
}
const achievementFiles: AchievementFile[] = [];
for (const steamUserId of steamUserIds) {
const gameAchievementPath = path.join(
steamPath,
"userdata",
steamUserId.toString(),
"config",
"librarycache",
`${game.objectId}.json`
);
if (fs.existsSync(gameAchievementPath)) {
achievementFiles.push({
type: Cracker.Steam,
filePath: gameAchievementPath,
});
}
}
return achievementFiles; return achievementFiles;
}; };

View File

@@ -75,6 +75,11 @@ export const parseAchievementFile = (
return processRazor1911(filePath); return processRazor1911(filePath);
} }
if (type === Cracker.Steam) {
const parsed = jsonParse(filePath);
return processSteamCacheAchievement(parsed);
}
achievementsLogger.log( achievementsLogger.log(
`Unprocessed ${type} achievements found on ${filePath}` `Unprocessed ${type} achievements found on ${filePath}`
); );
@@ -234,6 +239,35 @@ const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => {
return newUnlockedAchievements; return newUnlockedAchievements;
}; };
const processSteamCacheAchievement = (
unlockedAchievements: any[]
): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
const achievementIndex = unlockedAchievements.findIndex(
(element) => element[0] === "achievements"
);
if (achievementIndex === -1) {
achievementsLogger.info("No achievements found in Steam cache file");
return [];
}
const unlockedAchievementsData =
unlockedAchievements[achievementIndex][1]["data"]["vecHighlight"];
for (const achievement of unlockedAchievementsData) {
if (achievement.bAchieved) {
newUnlockedAchievements.push({
name: achievement.strID,
unlockTime: achievement.rtUnlocked * 1000,
});
}
}
return newUnlockedAchievements;
};
const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => { const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = []; const newUnlockedAchievements: UnlockedAchievement[] = [];

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ export const uploadGamesBatch = async () => {
.all() .all()
.then((results) => { .then((results) => {
return results.filter( 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, shop: game.shop,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
isFavorite: game.favorite, isFavorite: game.favorite,
isPinned: game.isPinned ?? false,
}; };
}) })
).catch(() => {}); ).catch(() => {});

View File

@@ -9,6 +9,7 @@ import { CloudSync } from "./cloud-sync";
import { logger } from "./logger"; import { logger } from "./logger";
import path from "path"; import path from "path";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
import { MAIN_LOOP_INTERVAL } from "@main/constants";
export const gamesPlaytime = new Map< export const gamesPlaytime = new Map<
string, string,
@@ -25,7 +26,7 @@ interface GameExecutables {
[key: string]: ExecutableInfo[]; [key: string]: ExecutableInfo[];
} }
const TICKS_TO_UPDATE_API = 120; const TICKS_TO_UPDATE_API = (3 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 3 minutes
let currentTick = 1; let currentTick = 1;
const platform = process.platform; const platform = process.platform;

View File

@@ -42,6 +42,10 @@ export const getSteamLocation = async () => {
reject(err); reject(err);
} }
if (!value) {
reject(new Error("SteamPath not found in registry"));
}
resolve(value.value); resolve(value.value);
}); });
}); });
@@ -78,9 +82,19 @@ export const getSteamAppDetails = async (
}; };
export const getSteamUsersIds = async () => { export const getSteamUsersIds = async () => {
const userDataPath = await getSteamLocation(); const steamLocation = await getSteamLocation().catch(() => null);
const userIds = fs.readdirSync(path.join(userDataPath, "userdata"), { if (!steamLocation) {
return [];
}
const userDataPath = path.join(steamLocation, "userdata");
if (!fs.existsSync(userDataPath)) {
return [];
}
const userIds = fs.readdirSync(userDataPath, {
withFileTypes: true, withFileTypes: true,
}); });

View File

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

View File

@@ -128,6 +128,52 @@ contextBridge.exposeInMainWorld("electron", {
), ),
addGameToLibrary: (shop: GameShop, objectId: string, title: string) => addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), 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: ( createGameShortcut: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -143,6 +189,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId), ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) => removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: ( updateLaunchOptions: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -189,6 +237,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameByObjectId", shop, objectId), ipcRenderer.invoke("getGameByObjectId", shop, objectId),
resetGameAchievements: (shop: GameShop, objectId: string) => resetGameAchievements: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resetGameAchievements", shop, objectId), ipcRenderer.invoke("resetGameAchievements", shop, objectId),
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
extractGameDownload: (shop: GameShop, objectId: string) => extractGameDownload: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("extractGameDownload", shop, objectId), ipcRenderer.invoke("extractGameDownload", shop, objectId),
getDefaultWinePrefixSelectionPath: () => getDefaultWinePrefixSelectionPath: () =>
@@ -364,6 +414,12 @@ contextBridge.exposeInMainWorld("electron", {
/* User */ /* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), 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), blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) => getUserFriends: (userId: string, take: number, skip: number) =>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydra</title> <title>Hydra Launcher</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' * data: local:;" content="default-src 'self' 'unsafe-inline' * data: local:;"

View File

@@ -10,16 +10,16 @@
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.03); background-color: rgba(0, 0, 0, 0.2);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.08); background-color: rgba(255, 255, 255, 0.15);
border-radius: 24px; border-radius: 24px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.16); background-color: rgba(255, 255, 255, 0.25);
} }
html, 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; color: globals.$muted-color;
font-size: 10px; font-size: 10px;
padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit; padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit;
border: solid 1px globals.$muted-color; border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px; border-radius: 6px;
display: flex; display: flex;
gap: 4px; gap: 4px;
align-items: center; 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 { .hero {
width: 100%; width: 100%;
height: 280px; height: 180px;
min-height: 280px; min-height: 150px;
max-height: 280px; border-radius: 0;
border-radius: 4px;
color: #dadbe1; color: #dadbe1;
overflow: hidden; overflow: hidden;
box-shadow: 0px 0px 15px 0px #000000; box-shadow: 0px 0px 15px 0px #000000;
cursor: pointer; cursor: pointer;
border: solid 1px globals.$border-color; border: solid 1px globals.$border-color;
z-index: 1; 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 { &__media {
object-fit: cover; object-fit: cover;
@@ -47,10 +67,42 @@
&__content { &__content {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3); padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 2); gap: calc(globals.$spacing-unit);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; 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" width="250px"
alt={game.description ?? ""} alt={game.description ?? ""}
loading="eager" loading="eager"
className="hero__logo"
/> />
<p className="hero__description">{game.description}</p> <p className="hero__description">{game.description}</p>
</div> </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 SteamLogo from "@renderer/assets/steam-logo.svg?react";
import PlayLogo from "@renderer/assets/play-logo.svg?react";
import { LibraryGame } from "@types"; import { LibraryGame } from "@types";
import cn from "classnames"; import cn from "classnames";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
@@ -16,6 +17,19 @@ export function SidebarGameItem({
}: Readonly<SidebarGameItemProps>) { }: Readonly<SidebarGameItemProps>) {
const location = useLocation(); 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 ( return (
<li <li
key={game.id} key={game.id}
@@ -30,15 +44,15 @@ export function SidebarGameItem({
className="sidebar__menu-item-button" className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)} onClick={(event) => handleSidebarGameClick(event, game)}
> >
{game.iconUrl ? ( {sidebarIcon ? (
<img <img
className="sidebar__game-icon" className="sidebar__game-icon"
src={game.iconUrl} src={sidebarIcon}
alt={game.title} alt={game.title}
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<SteamLogo className="sidebar__game-icon" /> getFallbackIcon()
)} )}
<span className="sidebar__menu-item-button-label"> <span className="sidebar__menu-item-button-label">

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ import type {
ShopDetailsWithAssets, ShopDetailsWithAssets,
AchievementCustomNotificationPosition, AchievementCustomNotificationPosition,
AchievementNotificationInfo, AchievementNotificationInfo,
UserLibraryResponse,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage"; import type disk from "diskusage";
@@ -111,6 +112,43 @@ declare global {
objectId: string, objectId: string,
title: string title: string
) => Promise<void>; ) => 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: ( createGameShortcut: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -126,6 +164,11 @@ declare global {
shop: GameShop, shop: GameShop,
objectId: string objectId: string
) => Promise<void>; ) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
pinned: boolean
) => Promise<void>;
updateLaunchOptions: ( updateLaunchOptions: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -162,6 +205,11 @@ declare global {
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer; onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>; resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
changeGamePlayTime: (
shop: GameShop,
objectId: string,
playtimeInSeconds: number
) => Promise<void>;
/* User preferences */ /* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>; authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>; authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
@@ -262,6 +310,8 @@ declare global {
onCommonRedistProgress: ( onCommonRedistProgress: (
cb: (value: { log: string; complete: boolean }) => void cb: (value: { log: string; complete: boolean }) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
saveTempFile: (fileName: string, fileData: Uint8Array) => Promise<string>;
deleteTempFile: (filePath: string) => Promise<void>;
platform: NodeJS.Platform; platform: NodeJS.Platform;
/* Auto update */ /* Auto update */
@@ -282,6 +332,12 @@ declare global {
/* User */ /* User */
getUser: (userId: string) => Promise<UserProfile | null>; getUser: (userId: string) => Promise<UserProfile | null>;
getUserLibrary: (
userId: string,
take?: number,
skip?: number,
sortBy?: string
) => Promise<UserLibraryResponse>;
blockUser: (userId: string) => Promise<void>; blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>; unblockUser: (userId: string) => Promise<void>;
getUserFriends: ( getUserFriends: (

View File

@@ -84,3 +84,23 @@ export const injectCustomCss = (
export const removeCustomCss = (target: HTMLElement = document.head) => { export const removeCustomCss = (target: HTMLElement = document.head) => {
target.querySelector("#custom-css")?.remove(); 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

@@ -12,7 +12,6 @@ import type {
UpdateProfileRequest, UpdateProfileRequest,
UserDetails, UserDetails,
} from "@types"; } from "@types";
import * as Sentry from "@sentry/react";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() { export function useUserDetails() {
@@ -29,8 +28,6 @@ export function useUserDetails() {
} = useAppSelector((state) => state.userDetails); } = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => { const clearUserDetails = useCallback(async () => {
Sentry.setUser(null);
dispatch(setUserDetails(null)); dispatch(setUserDetails(null));
dispatch(setProfileBackground(null)); dispatch(setProfileBackground(null));
@@ -45,12 +42,6 @@ export function useUserDetails() {
const updateUserDetails = useCallback( const updateUserDetails = useCallback(
async (userDetails: UserDetails) => { async (userDetails: UserDetails) => {
Sentry.setUser({
id: userDetails.id,
username: userDetails.username,
email: userDetails.email ?? undefined,
});
dispatch(setUserDetails(userDetails)); dispatch(setUserDetails(userDetails));
window.localStorage.setItem("userDetails", JSON.stringify(userDetails)); window.localStorage.setItem("userDetails", JSON.stringify(userDetails));
}, },

View File

@@ -21,7 +21,6 @@ import resources from "@locales";
import { logger } from "./logger"; import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies"; import { addCookieInterceptor } from "./cookies";
import * as Sentry from "@sentry/react";
import Catalogue from "./pages/catalogue/catalogue"; import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home"; import Home from "./pages/home/home";
import Downloads from "./pages/downloads/downloads"; import Downloads from "./pages/downloads/downloads";
@@ -32,18 +31,6 @@ import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor"; import ThemeEditor from "./pages/theme-editor/theme-editor";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
Sentry.init({
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
release: await window.electron.getVersion(),
});
console.log = logger.log; console.log = logger.log;
const isStaging = await window.electron.isStaging(); const isStaging = await window.electron.isStaging();

View File

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

View File

@@ -16,6 +16,25 @@
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
.game-item__plus-wrapper {
opacity: 1;
pointer-events: auto;
}
}
&__plus-wrapper {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out;
cursor: pointer;
}
&__plus-wrapper--added {
opacity: 0.5;
} }
&__cover { &__cover {

View File

@@ -1,13 +1,14 @@
import { Badge } from "@renderer/components"; import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import { useAppSelector, useRepacks } from "@renderer/hooks"; import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks";
import { useMemo } from "react"; import { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./game-item.scss"; import "./game-item.scss";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CatalogueSearchResult } from "@types"; import { CatalogueSearchResult } from "@types";
import { QuestionIcon } from "@primer/octicons-react"; import { QuestionIcon, PlusIcon, CheckIcon } from "@primer/octicons-react";
import cn from "classnames";
export interface GameItemProps { export interface GameItemProps {
game: CatalogueSearchResult; game: CatalogueSearchResult;
@@ -16,7 +17,9 @@ export interface GameItemProps {
export function GameItem({ game }: GameItemProps) { export function GameItem({ game }: GameItemProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { i18n } = useTranslation(); const { i18n, t } = useTranslation("game_details");
const language = i18n.language.split("-")[0];
const { steamGenres } = useAppSelector((state) => state.catalogueSearch); const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
@@ -24,7 +27,41 @@ export function GameItem({ game }: GameItemProps) {
const repacks = getRepacksForObjectId(game.objectId); const repacks = getRepacksForObjectId(game.objectId);
const language = i18n.language.split("-")[0]; const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
const [added, setAdded] = useState(false);
const { library, updateLibrary } = useLibrary();
useEffect(() => {
const exists = library.some(
(libItem) =>
libItem.shop === game.shop && libItem.objectId === game.objectId
);
setAdded(exists);
}, [library, game.shop, game.objectId]);
const addGameToLibrary = async (
event: React.MouseEvent | React.KeyboardEvent
) => {
event.stopPropagation();
if (added || isAddingToLibrary) return;
setIsAddingToLibrary(true);
try {
await window.electron.addGameToLibrary(
game.shop,
game.objectId,
game.title
);
updateLibrary();
} catch (error) {
console.error(error);
} finally {
setIsAddingToLibrary(false);
}
};
const uniqueRepackers = useMemo(() => { const uniqueRepackers = useMemo(() => {
return Array.from(new Set(repacks.map((repack) => repack.repacker))); return Array.from(new Set(repacks.map((repack) => repack.repacker)));
@@ -36,7 +73,11 @@ export function GameItem({ game }: GameItemProps) {
(steamGenre) => steamGenre === genre (steamGenre) => steamGenre === genre
); );
if (index && steamGenres[language] && steamGenres[language][index]) { if (
index !== undefined &&
steamGenres[language] &&
steamGenres[language][index]
) {
return steamGenres[language][index]; return steamGenres[language][index];
} }
@@ -81,6 +122,23 @@ export function GameItem({ game }: GameItemProps) {
))} ))}
</div> </div>
</div> </div>
<div
className={cn("game-item__plus-wrapper", {
"game-item__plus-wrapper--added": added,
})}
role="button"
tabIndex={0}
onClick={addGameToLibrary}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
addGameToLibrary(e);
}
}}
title={added ? t("already_in_library") : t("add_to_library")}
>
{added ? <CheckIcon size={16} /> : <PlusIcon size={16} />}
</div>
</button> </button>
); );
} }

View File

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

View File

@@ -1,13 +1,17 @@
@use "../../../scss/globals.scss"; @use "../../../scss/globals.scss";
.description-header { .description-header {
width: 100%; width: calc(100% - calc(globals.$spacing-unit * 2));
padding: 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: globals.$background-color; background-color: globals.$background-color;
height: 72px; 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 { &__info {
display: flex; display: flex;

View File

@@ -2,34 +2,64 @@
.gallery-slider { .gallery-slider {
&__container { &__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%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; 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 { &__media {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
flex-shrink: 0;
flex-grow: 0;
transition: translate 0.3s ease-in-out;
border-radius: 4px; border-radius: 4px;
align-self: center; object-fit: cover;
}
&__animation-container {
width: 100%;
height: 100%;
display: flex;
position: relative;
overflow: hidden;
@media (min-width: 1280px) {
width: 60%;
}
} }
&__preview { &__preview {
@@ -42,10 +72,18 @@
overflow-y: hidden; overflow-y: hidden;
gap: calc(globals.$spacing-unit / 2); gap: calc(globals.$spacing-unit / 2);
@media (min-width: 1024px) {
width: 80%;
}
@media (min-width: 1280px) { @media (min-width: 1280px) {
width: 60%; width: 60%;
} }
@media (min-width: 1536px) {
width: 50%;
}
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
width: 20%; width: 20%;
} }
@@ -68,6 +106,20 @@
border-radius: 4px; border-radius: 4px;
border: solid 1px globals.$border-color; border: solid 1px globals.$border-color;
overflow: hidden; 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 { &:hover {
opacity: 0.8; opacity: 0.8;
@@ -83,49 +135,73 @@
display: flex; 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 { &__button {
position: absolute; position: absolute;
align-self: center; top: 50%;
transform: translateY(-50%);
cursor: pointer; cursor: pointer;
background-color: rgba(0, 0, 0, 0.4); 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%; border-radius: 50%;
color: globals.$muted-color; color: globals.$muted-color;
width: 48px; width: 48px;
height: 48px; height: 48px;
border: none;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
} }
&:active { &:active {
transform: scale(0.95); transform: translateY(-50%) scale(0.95);
} }
&--left { &--left {
left: 0; left: globals.$spacing-unit;
margin-left: globals.$spacing-unit; transform: translateY(-50%) translateX(-100px);
transform: translateX(calc(-1 * (48px + globals.$spacing-unit))); opacity: 0;
&.gallery-slider__button--visible { .gallery-slider__viewport:hover & {
transform: translateX(0); transform: translateY(-50%) translateX(0);
opacity: 1; opacity: 1;
} }
} }
&--right { &--right {
right: 0; right: globals.$spacing-unit;
margin-right: globals.$spacing-unit; transform: translateY(-50%) translateX(100px);
transform: translateX(calc(48px + globals.$spacing-unit)); opacity: 0;
&.gallery-slider__button--visible { .gallery-slider__viewport:hover & {
transform: translateX(0); transform: translateY(-50%) translateX(0);
opacity: 1; 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 { 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 { gameDetailsContext } from "@renderer/context";
import "./gallery-slider.scss"; import "./gallery-slider.scss";
export function GallerySlider() { export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext); const { shopDetails } = useContext(gameDetailsContext);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const mediaContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const hasScreenshots = shopDetails && shopDetails.screenshots?.length; const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
const hasMovies = shopDetails && shopDetails.movies?.length;
const mediaCount = useMemo(() => { const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
if (!shopDetails) return 0; const [selectedIndex, setSelectedIndex] = useState(0);
if (shopDetails.screenshots && shopDetails.movies) { const scrollPrev = useCallback(() => {
return shopDetails.screenshots.length + shopDetails.movies.length; if (emblaApi) emblaApi.scrollPrev();
} else if (shopDetails.movies) { }, [emblaApi]);
return shopDetails.movies.length;
} else if (shopDetails.screenshots) {
return shopDetails.screenshots.length;
}
return 0; const scrollNext = useCallback(() => {
}, [shopDetails]); if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const [mediaIndex, setMediaIndex] = useState(0); const scrollTo = useCallback(
const [showArrows, setShowArrows] = useState(false); (index: number) => {
if (emblaApi) emblaApi.scrollTo(index);
},
[emblaApi]
);
const showNextImage = () => { const scrollToPreview = useCallback(
setMediaIndex((index: number) => { (index: number, event: React.MouseEvent<HTMLButtonElement>) => {
if (index === mediaCount - 1) return 0; scrollTo(index);
return index + 1; const button = event.currentTarget;
}); const previewContainer = button.parentElement;
};
const showPrevImage = () => { if (previewContainer) {
setMediaIndex((index: number) => { const containerRect = previewContainer.getBoundingClientRect();
if (index === 0) return mediaCount - 1; const buttonRect = button.getBoundingClientRect();
return index - 1; const isOffScreenLeft = buttonRect.left < containerRect.left;
}); const isOffScreenRight = buttonRect.right > containerRect.right;
};
useEffect(() => { if (isOffScreenLeft || isOffScreenRight) {
setMediaIndex(0); button.scrollIntoView({
}, [shopDetails]); behavior: "smooth",
block: "nearest",
useEffect(() => { inline: "center",
if (hasMovies && mediaContainerRef.current) { });
mediaContainerRef.current.childNodes.forEach((node, index) => {
if (node instanceof HTMLVideoElement) {
if (index !== mediaIndex) {
node.pause();
}
} }
}
},
[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 (shopDetails?.screenshots) {
if (scrollContainerRef.current) { shopDetails.screenshots.forEach((image, index) => {
const container = scrollContainerRef.current; items.push({
const totalWidth = container.scrollWidth - container.clientWidth; id: String(image.id),
const itemWidth = totalWidth / (mediaCount - 1); type: "image",
const scrollLeft = mediaIndex * itemWidth; src: image.path_full,
container.scrollLeft = scrollLeft; alt: t("screenshot", { number: String(index + 1) }),
});
});
} }
}, [shopDetails, mediaIndex, mediaCount]);
return items;
}, [shopDetails, t]);
const previews = useMemo(() => { const previews = useMemo(() => {
const screenshotPreviews = const screenshotPreviews =
shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({ shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({
id, id,
thumbnail: path_thumbnail, thumbnail: path_thumbnail,
type: "image" as const,
})) ?? []; })) ?? [];
if (shopDetails?.movies) { if (shopDetails?.movies) {
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({ const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
id, id,
thumbnail, thumbnail,
type: "video" as const,
})); }));
return [...moviePreviews, ...screenshotPreviews]; return [...moviePreviews, ...screenshotPreviews];
@@ -93,96 +147,87 @@ export function GallerySlider() {
return screenshotPreviews; return screenshotPreviews;
}, [shopDetails]); }, [shopDetails]);
if (!hasScreenshots) {
return null;
}
return ( return (
<> <div className="gallery-slider__container">
{hasScreenshots && ( <div className="gallery-slider__viewport" ref={emblaRef}>
<div className="gallery-slider__container"> <div className="gallery-slider__container-inner">
<div {mediaItems.map((item) => (
onMouseEnter={() => setShowArrows(true)} <div key={item.id} className="gallery-slider__slide">
onMouseLeave={() => setShowArrows(false)} {item.type === "video" ? (
className="gallery-slider__animation-container"
ref={mediaContainerRef}
>
{shopDetails.movies &&
shopDetails.movies.map((video) => (
<video <video
key={video.id}
controls controls
className="gallery-slider__media" className="gallery-slider__media"
poster={video.thumbnail} poster={item.poster}
style={{ translate: `${-100 * mediaIndex}%` }}
loop loop
muted muted
autoPlay
tabIndex={-1} tabIndex={-1}
> >
<source src={video.mp4.max.replace("http", "https")} /> <source src={item.videoSrc} />
</video> </video>
))} ) : (
{hasScreenshots &&
shopDetails.screenshots?.map((image, i) => (
<img <img
key={image.id}
className="gallery-slider__media" className="gallery-slider__media"
src={image.path_full} src={item.src}
style={{ translate: `${-100 * mediaIndex}%` }} alt={item.alt}
alt={t("screenshot", { number: i + 1 })}
loading="lazy" loading="lazy"
/> />
))} )}
</div>
<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 { useContext, useEffect, useMemo, useRef, useState } from "react";
import { average } from "color.js"; import { PencilIcon } from "@primer/octicons-react";
import Color from "color";
import { HeroPanel } from "./hero"; import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header"; import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider"; import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar"; import { Sidebar } from "./sidebar/sidebar";
import { EditGameModal } from "./modals";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage } from "@shared"; import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; 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 { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss"; import "./game-details.scss";
@@ -21,18 +21,13 @@ export function GameDetailsContent() {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
objectId, useContext(gameDetailsContext);
shopDetails,
game,
gameColor,
setGameColor,
hasNSFWContentBlocked,
} = useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails(); const { userDetails, hasActiveSubscription } = useUserDetails();
const { updateLibrary } = useLibrary();
const { setShowCloudSyncModal, getGameArtifacts } = const { setShowCloudSyncModal, getGameArtifacts } =
useContext(cloudSyncContext); useContext(cloudSyncContext);
@@ -48,31 +43,43 @@ export function GameDetailsContent() {
const $images = Array.from(document.querySelectorAll("img")); const $images = Array.from(document.querySelectorAll("img"));
$images.forEach(($image) => { $images.forEach(($image) => {
$image.loading = "lazy"; $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; return document.body.outerHTML;
} }
if (game?.shop === "custom") {
return "";
}
return t("no_shop_details"); return t("no_shop_details");
}, [shopDetails, t]); }, [shopDetails, t, game?.shop]);
const [backdropOpacity, setBackdropOpacity] = useState(1); const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditGameModal, setShowEditGameModal] = useState(false);
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);
};
useEffect(() => { useEffect(() => {
setBackdropOpacity(1); setBackdropOpacity(1);
@@ -92,10 +99,72 @@ export function GameDetailsContent() {
setShowCloudSyncModal(true); setShowCloudSyncModal(true);
}; };
const handleEditGameClick = () => {
setShowEditGameModal(true);
};
const handleGameUpdated = (_updatedGame: any) => {
updateGame();
updateLibrary();
};
useEffect(() => { useEffect(() => {
getGameArtifacts(); getGameArtifacts();
}, [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 ( return (
<div <div
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`} className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
@@ -103,15 +172,13 @@ export function GameDetailsContent() {
<section className="game-details__container"> <section className="game-details__container">
<div ref={heroRef} className="game-details__hero"> <div ref={heroRef} className="game-details__hero">
<img <img
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""} src={heroImage}
className="game-details__hero-image" className="game-details__hero-image"
alt={game?.title} alt={game?.title}
onLoad={handleHeroLoad}
/> />
<div <div
className="game-details__hero-backdrop" className="game-details__hero-backdrop"
style={{ style={{
backgroundColor: gameColor,
flex: 1, flex: 1,
}} }}
/> />
@@ -121,26 +188,35 @@ export function GameDetailsContent() {
style={{ opacity: backdropOpacity }} style={{ opacity: backdropOpacity }}
> >
<div className="game-details__hero-content"> <div className="game-details__hero-content">
<img {renderGameLogo()}
src={shopDetails?.assets?.logoImageUrl ?? ""}
className="game-details__game-logo"
alt={game?.title}
/>
<button <div className="game-details__hero-buttons game-details__hero-buttons--right">
type="button" <button
className="game-details__cloud-sync-button" type="button"
onClick={handleCloudSaveButtonClick} className="game-details__edit-custom-game-button"
> onClick={handleEditGameClick}
<div className="game-details__cloud-icon-container"> title={t("edit_game_modal_button")}
<img >
src={cloudIconAnimated} <PencilIcon size={16} />
alt="Cloud icon" </button>
className="game-details__cloud-icon"
/> {game?.shop !== "custom" && (
</div> <button
{t("cloud_save")} type="button"
</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> </div>
</div> </div>
@@ -160,9 +236,17 @@ export function GameDetailsContent() {
/> />
</div> </div>
<Sidebar /> {game?.shop !== "custom" && <Sidebar />}
</div> </div>
</section> </section>
<EditGameModal
visible={showEditGameModal}
onClose={() => setShowEditGameModal(false)}
game={game}
shopDetails={shopDetails}
onGameUpdated={handleGameUpdated}
/>
</div> </div>
); );
} }

View File

@@ -18,7 +18,6 @@ $hero-height: 300px;
&__wrapper { &__wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
width: 100%; width: 100%;
height: 100%; height: 100%;
transition: all ease 0.3s; transition: all ease 0.3s;
@@ -44,12 +43,53 @@ $hero-height: 300px;
} }
&__hero-content { &__hero-content {
padding: calc(globals.$spacing-unit * 2); padding: calc(globals.$spacing-unit * 1.5);
height: 100%; height: 100%;
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-end; 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 { &__hero-logo-backdrop {
@@ -64,8 +104,8 @@ $hero-height: 300px;
&__hero-image { &__hero-image {
width: 100%; width: 100%;
height: $hero-height; height: calc($hero-height + 72px);
min-height: $hero-height; min-height: calc($hero-height + 72px);
object-fit: cover; object-fit: cover;
object-position: top; object-position: top;
transition: all ease 0.2s; transition: all ease 0.2s;
@@ -74,14 +114,46 @@ $hero-height: 300px;
@media (min-width: 1250px) { @media (min-width: 1250px) {
object-position: center; object-position: center;
height: 350px; height: calc(350px + 72px);
min-height: 350px; min-height: calc(350px + 72px);
} }
} }
&__game-logo { &__game-logo {
width: 300px; width: 200px;
align-self: flex-end; 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 { &__hero-image-skeleton {
@@ -97,7 +169,6 @@ $hero-height: 300px;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto;
z-index: 1; z-index: 1;
} }
@@ -105,6 +176,7 @@ $hero-height: 300px;
display: flex; display: flex;
width: 100%; width: 100%;
flex: 1; flex: 1;
min-width: 0;
background: linear-gradient( background: linear-gradient(
0deg, 0deg,
globals.$background-color 50%, globals.$background-color 50%,
@@ -114,30 +186,53 @@ $hero-height: 300px;
&__description-content { &__description-content {
width: 100%; width: 100%;
height: 100%; min-height: 100%;
min-width: 0;
flex: 1;
overflow-x: hidden;
} }
&__description { &__description {
user-select: text; user-select: text;
line-height: 22px; line-height: 22px;
font-size: globals.$body-font-size; 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%; width: 100%;
margin-left: auto; margin-left: auto;
margin-right: 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) { @media (min-width: 1280px) {
width: 60%; width: 60%;
} }
img { @media (min-width: 1536px) {
width: 50%;
}
img,
video {
border-radius: 5px; border-radius: 5px;
margin-top: globals.$spacing-unit; margin-top: globals.$spacing-unit;
margin-bottom: calc(globals.$spacing-unit * 3); margin-bottom: calc(globals.$spacing-unit * 3);
display: block; display: block !important;
width: 100%; max-width: 100% !important;
height: auto; width: auto !important;
object-fit: cover; height: auto !important;
object-fit: contain !important;
box-sizing: border-box !important;
word-wrap: break-word;
overflow-wrap: break-word;
} }
a { a {
@@ -154,15 +249,28 @@ $hero-height: 300px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: globals.$spacing-unit; 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%; width: 100%;
margin-left: auto; margin-left: auto;
margin-right: 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) { @media (min-width: 1280px) {
width: 60%; width: 60%;
line-height: 22px; line-height: 22px;
} }
@media (min-width: 1536px) {
width: 50%;
}
} }
&__randomizer-button { &__randomizer-button {

View File

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

View File

@@ -3,11 +3,18 @@ import {
GearIcon, GearIcon,
HeartFillIcon, HeartFillIcon,
HeartIcon, HeartIcon,
PinIcon,
PinSlashIcon,
PlayIcon, PlayIcon,
PlusCircleIcon, PlusCircleIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { Button } from "@renderer/components"; 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 { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
@@ -19,6 +26,7 @@ export function HeroPanelActions() {
useState(false); useState(false);
const { isGameDeleting } = useDownload(); const { isGameDeleting } = useDownload();
const { userDetails } = useUserDetails();
const { const {
game, 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 () => { const openGame = async () => {
if (game) { if (game) {
if (game.executablePath) { if (game.executablePath) {
@@ -198,6 +229,17 @@ export function HeroPanelActions() {
{game.favorite ? <HeartFillIcon /> : <HeartIcon />} {game.favorite ? <HeartFillIcon /> : <HeartIcon />}
</Button> </Button>
{userDetails && game.shop !== "custom" && (
<Button
onClick={toggleGamePinned}
theme="outline"
disabled={deleting}
className="hero-panel-actions__action"
>
{game.isPinned ? <PinSlashIcon /> : <PinIcon />}
</Button>
)}
<Button <Button
onClick={() => setShowGameOptionsModal(true)} onClick={() => setShowGameOptionsModal(true)}
theme="outline" theme="outline"

View File

@@ -12,4 +12,16 @@
color: globals.$body-color; color: globals.$body-color;
text-decoration: underline; 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 { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; 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"; import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() { export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState(""); const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext); const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
@@ -85,7 +88,26 @@ export function HeroPanelPlaytime() {
return ( 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", { {t("play_time", {
amount: formattedPlayTime, amount: formattedPlayTime,
})} })}
@@ -100,6 +122,16 @@ export function HeroPanelPlaytime() {
})} })}
</p> </p>
)} )}
{game.hasManuallyUpdatedPlaytime && (
<Tooltip
id="manual-playtime-warning"
style={{
zIndex: 9999,
}}
openOnClick={false}
/>
)}
</> </>
); );
} }

View File

@@ -5,18 +5,24 @@
height: 72px; height: 72px;
min-height: 72px; min-height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3); 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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
transition: all ease 0.2s; transition: all ease 0.2s;
border-bottom: solid 1px globals.$border-color; border-bottom: solid 1px globals.$border-color;
position: sticky;
overflow: hidden; overflow: hidden;
top: 0; top: 0;
z-index: 2; z-index: 2;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
&--stuck { &--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); 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 { formatDate } = useDate();
const { game, repacks, gameColor } = useContext(gameDetailsContext); const { game, repacks } = useContext(gameDetailsContext);
const { lastPacket } = useDownload(); const { lastPacket } = useDownload();
@@ -50,7 +50,7 @@ export function HeroPanel() {
game?.download?.status === "paused"; game?.download?.status === "paused";
return ( return (
<div style={{ backgroundColor: gameColor }} className="hero-panel"> <div className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div> <div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions"> <div className="hero-panel__actions">
<HeroPanelActions /> <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;
}

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