mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
48 Commits
feat/separ
...
fix/game-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9c7f992dc | ||
|
|
97dc7653b0 | ||
|
|
330f38776f | ||
|
|
b874138641 | ||
|
|
a439095260 | ||
|
|
5ff263e8c8 | ||
|
|
864c47ee46 | ||
|
|
523e19d97a | ||
|
|
03b2c8d07f | ||
|
|
d296830533 | ||
|
|
7fc4e17547 | ||
|
|
636cf287c9 | ||
|
|
84be238988 | ||
|
|
494a2a0da5 | ||
|
|
4e912b3b8d | ||
|
|
e71211f1aa | ||
|
|
a946f3bd5a | ||
|
|
374b62983b | ||
|
|
0cd4c3ccf6 | ||
|
|
7b97663b3a | ||
|
|
68e2e2a772 | ||
|
|
39979292e2 | ||
|
|
60ae7d40fa | ||
|
|
63b6b0b44e | ||
|
|
6b6dfc7adc | ||
|
|
7c33c43d9c | ||
|
|
82c0dc0d97 | ||
|
|
1cba3f350c | ||
|
|
5d0f036de2 | ||
|
|
c2e5bc0e91 | ||
|
|
030b3b8f7c | ||
|
|
38b04ee991 | ||
|
|
5f643ecd5f | ||
|
|
366ce953d4 | ||
|
|
34aea2b0c4 | ||
|
|
dcec33ada1 | ||
|
|
5877c8c798 | ||
|
|
14204f1fbe | ||
|
|
602b2fef91 | ||
|
|
2240a8c9fb | ||
|
|
6c34a1fcc0 | ||
|
|
741f9de85c | ||
|
|
5510bb9c9e | ||
|
|
1e779a32c1 | ||
|
|
7b8f7fc070 | ||
|
|
aa4def327a | ||
|
|
b0d9d18c6c | ||
|
|
e6d5a2e871 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.18.0
|
||||
node-version: 20.18.3
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.18.0
|
||||
node-version: 20.18.3
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -6,7 +6,7 @@ concurrency:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.18.0
|
||||
node-version: 20.18.3
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.6.8",
|
||||
"version": "3.7.0",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -32,13 +32,13 @@
|
||||
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@fontsource/noto-sans": "^5.1.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource/noto-sans": "^5.2.10",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@reduxjs/toolkit": "^2.2.3",
|
||||
"@tiptap/extension-bold": "^3.6.2",
|
||||
"@tiptap/extension-italic": "^3.6.2",
|
||||
@@ -47,8 +47,9 @@
|
||||
"@tiptap/react": "^3.6.2",
|
||||
"@tiptap/starter-kit": "^3.6.2",
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.12.2",
|
||||
"axios-cookiejar-support": "^5.0.5",
|
||||
"check-disk-space": "^3.4.0",
|
||||
"classic-level": "^2.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
"color": "^4.2.3",
|
||||
@@ -57,8 +58,7 @@
|
||||
"create-desktop-shortcuts": "^1.11.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dexie": "^4.0.10",
|
||||
"diskusage": "^1.2.0",
|
||||
"electron-log": "^5.2.4",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
@@ -116,7 +116,7 @@
|
||||
"@types/winreg": "^1.2.36",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^32.3.3",
|
||||
"electron": "^33.4.11",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^3.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
@@ -130,8 +130,8 @@
|
||||
"sass-embedded": "^1.80.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
"vite": "5.4.20",
|
||||
"vite-plugin-svgr": "^4.5.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -76,7 +76,19 @@
|
||||
"edit_game_modal_drop_hero_image_here": "Drop hero image here",
|
||||
"edit_game_modal_drop_to_replace_icon": "Drop to replace icon",
|
||||
"edit_game_modal_drop_to_replace_logo": "Drop to replace logo",
|
||||
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero"
|
||||
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero",
|
||||
"install_decky_plugin": "Install Decky Plugin",
|
||||
"update_decky_plugin": "Update Decky Plugin",
|
||||
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
|
||||
"install_decky_plugin_title": "Install Hydra Decky Plugin",
|
||||
"install_decky_plugin_message": "This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?",
|
||||
"update_decky_plugin_title": "Update Hydra Decky Plugin",
|
||||
"update_decky_plugin_message": "A new version of the Hydra Decky plugin is available. Would you like to update it now?",
|
||||
"decky_plugin_installed": "Decky plugin v{{version}} installed successfully",
|
||||
"decky_plugin_installation_failed": "Failed to install Decky plugin: {{error}}",
|
||||
"decky_plugin_installation_error": "Error installing Decky plugin: {{error}}",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"header": {
|
||||
"search": "Search games",
|
||||
@@ -227,7 +239,7 @@
|
||||
"rating_neutral": "Neutral",
|
||||
"rating_positive": "Positive",
|
||||
"rating_very_positive": "Very Positive",
|
||||
"submit_review": "Submit Review",
|
||||
"submit_review": "Submit",
|
||||
"submitting": "Submitting...",
|
||||
"review_submitted_successfully": "Review submitted successfully!",
|
||||
"review_submission_failed": "Failed to submit review. Please try again.",
|
||||
@@ -486,6 +498,8 @@
|
||||
"delete_theme_description": "This will delete the theme {{theme}}",
|
||||
"cancel": "Cancel",
|
||||
"appearance": "Appearance",
|
||||
"debrid": "Debrid",
|
||||
"debrid_description": "Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.",
|
||||
"enable_torbox": "Enable TorBox",
|
||||
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
|
||||
"torbox_account_linked": "TorBox account linked",
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"show_playable_only_tooltip": "Csak játszható játék mutatása",
|
||||
"custom_game_modal": "Saját játék hozzáadása:",
|
||||
"custom_game_modal_description": "Adj meg egy futtatható fájlt",
|
||||
"custom_game_modal_executable_path": "A fájl útvonala",
|
||||
"custom_game_modal_select_executable": "Az útvonal",
|
||||
"custom_game_modal_executable_path": "Futtatható fájl",
|
||||
"custom_game_modal_select_executable": "Fájl útvonala",
|
||||
"custom_game_modal_title": "Játékcím",
|
||||
"custom_game_modal_enter_title": "Játék elnevezése",
|
||||
"custom_game_modal_browse": "Tallózás",
|
||||
@@ -43,7 +43,7 @@
|
||||
"custom_game_modal_success": "Saját játék sikeresen hozzáadva",
|
||||
"custom_game_modal_failed": "Saját játék hozzáadása sikertelen",
|
||||
"custom_game_modal_executable": "Futtatható fájl",
|
||||
"edit_game_modal": "Játékmegjelenés",
|
||||
"edit_game_modal": "Játékmegjelenítése:",
|
||||
"edit_game_modal_description": "Játékcím és vizuális elemek módosítása",
|
||||
"edit_game_modal_title": "Játékcím",
|
||||
"edit_game_modal_enter_title": "Játék elnevezése",
|
||||
@@ -70,7 +70,25 @@
|
||||
"edit_game_modal_icon_resolution": "Ajánlott felbontás: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Ajánlott felbontás: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Ajánlott felbontás: 1920x620px",
|
||||
"edit_game_modal_assets": "Vizuális elemek:"
|
||||
"edit_game_modal_assets": "Vizuális elemek:",
|
||||
"edit_game_modal_drop_icon_image_here": "Húzd ide az ikon képét",
|
||||
"edit_game_modal_drop_logo_image_here": "Húzd ide a logó képét",
|
||||
"edit_game_modal_drop_hero_image_here": "Húzd ide a borítókép képét",
|
||||
"edit_game_modal_drop_to_replace_icon": "Ikon kicserélése ráhúzással",
|
||||
"edit_game_modal_drop_to_replace_logo": "Logó kicserélése ráhúzással",
|
||||
"edit_game_modal_drop_to_replace_hero": "Borítókép kicserélése ráhúzással",
|
||||
"install_decky_plugin": "Decky Plugin Telepítése",
|
||||
"update_decky_plugin": "Decky Plugin Frissítése",
|
||||
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
|
||||
"install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint",
|
||||
"install_decky_plugin_message": "Ez letölti és telepíteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?",
|
||||
"update_decky_plugin_title": "Hydra Decky Plugin Frissítése",
|
||||
"update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?",
|
||||
"decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
|
||||
"decky_plugin_installation_failed": "Decky plugin telepítése sikertelen: {{error}}",
|
||||
"decky_plugin_installation_error": "Decky plugin telepítése hibával járt el: {{error}}",
|
||||
"confirm": "Megerősít",
|
||||
"cancel": "Mégse"
|
||||
},
|
||||
"header": {
|
||||
"search": "Keresés",
|
||||
@@ -120,7 +138,7 @@
|
||||
"downloading_metadata": "Metaadat letöltése",
|
||||
"filter": "Repackek szűrése",
|
||||
"requirements": "Rendszerkövetelmények",
|
||||
"minimum": "Minimum",
|
||||
"minimum": "Minimális",
|
||||
"recommended": "Ajánlott",
|
||||
"paused": "Szüneteltetve",
|
||||
"release_date": "Megjelenés: {{date}}",
|
||||
@@ -144,7 +162,7 @@
|
||||
"playing_now": "Játékban: ",
|
||||
"change": "Változtatás",
|
||||
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
||||
"select_folder_hint": "Hogy megváltoztasd a letöltési mappát, menj a <0>Beállítások</0> menüjébe",
|
||||
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
|
||||
"download_now": "Letöltés",
|
||||
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
||||
"download_options": "Letöltési opciók",
|
||||
@@ -160,17 +178,19 @@
|
||||
"open_folder": "Mappa megnyitása",
|
||||
"open_download_location": "Letöltött fájlok megtekintése",
|
||||
"create_shortcut": "Asztali parancsikon létrehozása",
|
||||
"create_shortcut_simple": "Parancsikon létrehozása",
|
||||
"clear": "Visszavon",
|
||||
"remove_files": "Fájlok eltávolítása",
|
||||
"remove_from_library_title": "Biztos vagy ebben?",
|
||||
"remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból",
|
||||
"options": "Beállítások",
|
||||
"properties": "További beállítások",
|
||||
"executable_section_title": "Futtatható fájl",
|
||||
"executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül",
|
||||
"downloads_section_title": "Letöltések",
|
||||
"downloads_section_description": "Csekkold le a játék frissítéseit vagy más verzióit",
|
||||
"danger_zone_section_title": "Veszélyzóna",
|
||||
"danger_zone_section_description": "Távolítsd el a játékot könyvtáradból, vagy a fájlokat amit a Hydra töltött le",
|
||||
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
|
||||
"download_in_progress": "Letöltés folyamatban",
|
||||
"download_paused": "Letöltés szüneteltetve",
|
||||
"last_downloaded_option": "Utoljára letöltött",
|
||||
@@ -178,13 +198,21 @@
|
||||
"create_shortcut_success": "A parancsikon létrehozása sikeres",
|
||||
"you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.",
|
||||
"create_shortcut_error": "Hiba lépett fel létrehozás közben",
|
||||
"nsfw_content_title": "Ez a játék nem megfelelő tartalmat tartalmaz.",
|
||||
"nsfw_content_description": "{{title}} tartalmaz tartalmat amely nem megfelelő minden korosztálynak. Biztosan folytatni szeretnéd?",
|
||||
"add_to_favorites": "Kedvencekhez adás",
|
||||
"remove_from_favorites": "Eltávolítás a kedvencek közül",
|
||||
"failed_update_favorites": "Kedvencek frissítése sikertelen",
|
||||
"game_removed_from_library": "Játék eltávolítva a könyvtárból",
|
||||
"failed_remove_from_library": "Játék eltávolítása a könyvtárból sikertelen",
|
||||
"files_removed_success": "Fájlok eltávolítása sikeres",
|
||||
"failed_remove_files": "Fájlok eltávolítása sikertelen",
|
||||
"nsfw_content_title": "Ez a játék tartalmaz nem megfelelő tartalmat",
|
||||
"nsfw_content_description": "A(z) {{title}} tartalma lehetséges hogy nem megfelelő minden korosztály számára. Biztosan folytatni szeretnéd?",
|
||||
"allow_nsfw_content": "Folytatás",
|
||||
"refuse_nsfw_content": "Vissza",
|
||||
"stats": "Statisztikák",
|
||||
"download_count": "Letöltések",
|
||||
"player_count": "Aktív játékosok",
|
||||
"rating_count": "Értékelés",
|
||||
"download_error": "Ez a letöltési opció nem elérhető",
|
||||
"download": "Letöltés",
|
||||
"executable_path_in_use": "Ez a futtatható fájl már használatban van a(z) \"{{game}}\" által",
|
||||
@@ -192,6 +220,39 @@
|
||||
"hydra_needs_to_remain_open": "ehhez a letöltéshez, a Hydrának muszáj nyitva maradnia hogy letöltődjön. Ha a Hydra bezáródik letöltés előtt, a letöltés elveszik.",
|
||||
"achievements": "Achievementek",
|
||||
"achievements_count": "Achievementek {{unlockedCount}}/{{achievementsCount}}",
|
||||
"show_more": "Mutass többet",
|
||||
"show_less": "Mutass kevesebbet",
|
||||
"reviews": "Vélemények",
|
||||
"leave_a_review": "Hagyd itt a véleményed",
|
||||
"write_review_placeholder": "Oszd meg a gondolataid a játékról...",
|
||||
"sort_newest": "Legújabb",
|
||||
"no_reviews_yet": "Még nem lett vélemény megosztva",
|
||||
"be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!",
|
||||
"sort_oldest": "Legrégibb",
|
||||
"sort_highest_score": "Legmagasabb Pontszám",
|
||||
"sort_lowest_score": "Legalacsonyabb Pontszám",
|
||||
"sort_most_voted": "Legszavazottabb",
|
||||
"rating": "Értékelés",
|
||||
"rating_stats": "Értékelés",
|
||||
"rating_very_negative": "Nagyon Negatív",
|
||||
"rating_negative": "Negatív",
|
||||
"rating_neutral": "Átlagos",
|
||||
"rating_positive": "Pozitív",
|
||||
"rating_very_positive": "Nagyon Pozitív",
|
||||
"submit_review": "Küldés",
|
||||
"submitting": "Küldés alatt...",
|
||||
"review_submitted_successfully": "Vélemény beküldve sikeresen!",
|
||||
"review_submission_failed": "Vélemény beküldése sikertelen. Kérlek próbáld újra.",
|
||||
"review_cannot_be_empty": "A vélemény mező nem lehet üres.",
|
||||
"review_deleted_successfully": "Vélemény sikeresen törölve.",
|
||||
"review_deletion_failed": "Vélemény törlése sikertelen. Kérlek próbáld újra.",
|
||||
"loading_reviews": "Vélemények betöltése...",
|
||||
"loading_more_reviews": "Több vélemény betöltése...",
|
||||
"load_more_reviews": "Több vélemény betöltése",
|
||||
"you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot",
|
||||
"would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?",
|
||||
"yes": "Igen",
|
||||
"maybe_later": "Talán Később",
|
||||
"cloud_save": "Mentés felhőben",
|
||||
"cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön",
|
||||
"backups": "Biztonsági másolatok",
|
||||
@@ -204,6 +265,7 @@
|
||||
"uploading_backup": "Biztonsági mentés feltöltése…",
|
||||
"no_backups": "Még nem hoztál létre biztonsági másolatot ehhez a játékhoz",
|
||||
"backup_uploaded": "Biztonsági mentés feltöltve",
|
||||
"backup_failed": "Biztonsági mentés sikertelen",
|
||||
"backup_deleted": "Biztonsági mentés törölve",
|
||||
"backup_restored": "Biztonsági mentés helyreállítva",
|
||||
"see_all_achievements": "Achievementlista megtekintése",
|
||||
@@ -239,17 +301,17 @@
|
||||
"reset_achievements_error": "Achievementek nullázása sikertelen",
|
||||
"download_error_gofile_quota_exceeded": "Túllépted a Gofile havi kvótáját. Kérlek, várd meg amíg a kvóta lejár.",
|
||||
"download_error_real_debrid_account_not_authorized": "A Real-Debrid fiókod nem jogosult új letöltésekre. Kérlek, ellenőrízd a fiókbeállításaidat, majd próbáld újra.",
|
||||
"download_error_not_cached_on_real_debrid": "Ez a letöltés nem érhető el a Real-Debridnél, és lekérdezni letöltési állapotot még nem lehet vele.",
|
||||
"download_error_not_cached_on_real_debrid": "Ez a letöltés nem elérhető a Real-Debriden, és lekérdezni letöltési állapotot még nem lehet.",
|
||||
"update_playtime_title": "Játékidő frissítése",
|
||||
"update_playtime_description": "Manuálisan frissíteni a Játékidőt a {{game}} játékhoz",
|
||||
"update_playtime_description": "A(z) {{game}} játékidejének frissítése manuálisan",
|
||||
"update_playtime": "Játékidő frissítése",
|
||||
"update_playtime_success": "Játékidő sikeresen frissítve",
|
||||
"update_playtime_error": "A Játékidőnek nem sikerült frissülnie",
|
||||
"update_game_playtime": "Játékidő frissítése",
|
||||
"manual_playtime_warning": "Az óráid 'manuálisan frissítve' lesznek megjelölve, és ez nem visszavonható.",
|
||||
"manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
|
||||
"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.",
|
||||
"manual_playtime_warning": "A Játékidő „Manuálisan frissített”-ként lesz megjelölve, és ez nem visszavonható.",
|
||||
"manual_playtime_tooltip": "Ez a Játékidő manuálisan lett frissítve",
|
||||
"download_error_not_cached_on_torbox": "Ez a letöltés nem elérhető a TorBoxon, és lekérdezni letöltési állapotot még nem lehet.",
|
||||
"download_error_not_cached_on_hydra": "Ez a letöltés nem elérhető a Nimbuson.",
|
||||
"game_removed_from_favorites": "Játék eltávolítva a kedvencek közül",
|
||||
"game_added_to_favorites": "Játék hozzáadva a kedvencekhez",
|
||||
"game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül",
|
||||
@@ -272,7 +334,7 @@
|
||||
"backup_frozen": "Biztonsági mentés rögzítve",
|
||||
"backup_unfrozen": "Biztonsági mentés leválasztva",
|
||||
"backup_freeze_failed": "Biztonsági mentés rögzítése sikertelen",
|
||||
"backup_freeze_failed_description": "Legalább egy szabad helyet kell hagyni az automatikus biztonsági mentéseknek.",
|
||||
"backup_freeze_failed_description": "Legalább egy szabad helyet kell hagyni az automatikus biztonsági mentéseknek",
|
||||
"edit_game_modal_button": "Játékadatok testreszabása",
|
||||
"game_details": "Játék leírása",
|
||||
"currency_symbol": "Ft",
|
||||
@@ -286,13 +348,21 @@
|
||||
"historical_keyshop": "Korábbi nem hivatalos ár",
|
||||
"language": "Nyelv",
|
||||
"caption": "Felirat",
|
||||
"audio": "Hang"
|
||||
"audio": "Hang",
|
||||
"filter_by_source": "Szűrés forrás szerint",
|
||||
"no_repacks_found": "Nem található forrás ehhez a játékhoz",
|
||||
"delete_review": "Vélemény törlése",
|
||||
"remove_review": "Vélemény eltávolítása",
|
||||
"delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?",
|
||||
"delete_review_modal_description": "Ez a lépés nem vonható vissza.",
|
||||
"delete_review_modal_delete_button": "Törlés",
|
||||
"delete_review_modal_cancel_button": "Mégse"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Hydra aktiválása",
|
||||
"installation_id": "Telepítési azonosító:",
|
||||
"title": "Hydra Aktiválása",
|
||||
"installation_id": "Telepítési Azonosító:",
|
||||
"enter_activation_code": "Írd be az aktiválási kódod",
|
||||
"message": "Ha nem tudod kit kérdezz efelől, akkor nem kéne nálad legyen.",
|
||||
"message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.",
|
||||
"activate": "Aktiválás",
|
||||
"loading": "Töltés…"
|
||||
},
|
||||
@@ -324,6 +394,7 @@
|
||||
"stop_seeding": "Seedelés leállítása",
|
||||
"resume_seeding": "Seedelés folytatása",
|
||||
"options": "Kezelés",
|
||||
"alldebrid_size_not_supported": "Letöltési információ az AllDebrid-hez még nem támogatott",
|
||||
"extract": "Fájlok kibontása",
|
||||
"extracting": "Fájlok kibontása…"
|
||||
},
|
||||
@@ -342,10 +413,10 @@
|
||||
"language": "Nyelv",
|
||||
"api_token": "API Token",
|
||||
"enable_real_debrid": "Real-Debrid Bekapcsolása",
|
||||
"real_debrid_description": "A Real-Debrid egy korlátozásmentes letöltőprogram, lehetővé teszi a fájlok gyors letöltését, és csak az internetkapcsolat sebessége szab határt.",
|
||||
"real_debrid_description": "A Real-Debrid egy korlátozásmentes letöltőprogram, ami lehetővé teszi a fájlok gyors letöltését, és csak az internetkapcsolat sebessége szab határt.",
|
||||
"debrid_invalid_token": "Érvénytelen API token",
|
||||
"debrid_api_token_hint": "Az API tokened <0>itt</0> található",
|
||||
"real_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel a Real-Debrid-re",
|
||||
"real_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel a Real-Debridre",
|
||||
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"changes_saved": "Változtatások sikeresen mentve",
|
||||
@@ -407,7 +478,7 @@
|
||||
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva",
|
||||
"subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}",
|
||||
"bill_sent_until": "A következő számlát ezen napon küldjük",
|
||||
"no_themes": "Úgy látom nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
|
||||
"no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
|
||||
"editor_tab_code": "Code",
|
||||
"editor_tab_info": "Info",
|
||||
"editor_tab_save": "Mentés",
|
||||
@@ -427,12 +498,25 @@
|
||||
"delete_theme_description": "Ez törölni fogja a(z) {{theme}} témát",
|
||||
"cancel": "Mégsem",
|
||||
"appearance": "Megjelenés",
|
||||
"debrid": "Debrid",
|
||||
"debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.",
|
||||
"enable_torbox": "TorBox bekapcsolása",
|
||||
"torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.",
|
||||
"torbox_account_linked": "TorBox fiók összekapcsolva",
|
||||
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod",
|
||||
"create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
|
||||
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva",
|
||||
"enable_all_debrid": "All-Debrid bekapcsolása",
|
||||
"all_debrid_description": "Az All-Debrid egy korlátozásmentes letöltőprogram, ami lehetővé teszi a fájlok gyors letöltését különböző forrásokból.",
|
||||
"all_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel az All-Debridre",
|
||||
"all_debrid_account_linked": "All-Debrid fiók összekapcsolva",
|
||||
"alldebrid_missing_key": "Kérlek adj meg egy API key-t",
|
||||
"alldebrid_invalid_key": "Érvénytelen API key",
|
||||
"alldebrid_blocked": "Az API key-ed Földrajzilag vagy IP-alapján van blokkolva",
|
||||
"alldebrid_banned": "Ez a fiók kitiltásra került",
|
||||
"alldebrid_unknown_error": "Egy ismeretlen hiba történt",
|
||||
"alldebrid_invalid_response": "Érvénytelen válasz az All-Debrid felől",
|
||||
"alldebrid_network_error": "Hálózati hiba. Ellenőrízd az internetkapcsolatod",
|
||||
"name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
|
||||
"import_theme": "Téma importálása",
|
||||
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}",
|
||||
@@ -459,7 +543,7 @@
|
||||
"variation": "Variáció",
|
||||
"default": "Alapértelmezett",
|
||||
"rare": "Ritka",
|
||||
"platinum": "Platinum",
|
||||
"platinum": "Platina",
|
||||
"hidden": "Rejtett",
|
||||
"test_notification": "Értesítés tesztelése",
|
||||
"notification_preview": "Achievement Értesítés Előnézete",
|
||||
@@ -490,12 +574,13 @@
|
||||
"game_card": {
|
||||
"available_one": "Elérhető",
|
||||
"available_other": "Elérhető",
|
||||
"no_downloads": "Nincs elérhető letöltés"
|
||||
"no_downloads": "Nincs elérhető letöltés",
|
||||
"calculating": "Feldolgozás"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "A programok nincsenek telepítve",
|
||||
"description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden",
|
||||
"instructions": "Ellenőrízd, hogy melyiket kell helyesen telepíteni a Linux disztribúcióra, hogy a játék normálisan fusson"
|
||||
"instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Bezárás gomb"
|
||||
@@ -575,7 +660,7 @@
|
||||
"report_reason_violence": "Fenyegető",
|
||||
"report_reason_spam": "Spam",
|
||||
"report_reason_other": "Egyéb",
|
||||
"profile_reported": "Profil jelentve",
|
||||
"profile_reported": "Profil bejelentve",
|
||||
"your_friend_code": "A barát kódod:",
|
||||
"upload_banner": "Borítókép feltöltés",
|
||||
"uploading_banner": "Borítókép feltöltése…",
|
||||
@@ -593,17 +678,20 @@
|
||||
"error_adding_friend": "Hiba, barátfelkérés sikertelen. Kérlek ellenőrízd a barát kódot",
|
||||
"friend_code_length_error": "A barát kódnak 8 karakterből kell állnia",
|
||||
"game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül",
|
||||
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez"
|
||||
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Pozitív értékelésekre kapott pontok alapján"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement feloldva",
|
||||
"user_achievements": "{{displayName}} Achievementjei",
|
||||
"your_achievements": "A te Achievementjeid",
|
||||
"unlocked_at": "Feloldva ekkor: {{date}}",
|
||||
"user_achievements": "{{displayName}} achievementjei",
|
||||
"your_achievements": "A te achievementjeid",
|
||||
"unlocked_at": "Feloldva: {{date}}",
|
||||
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges",
|
||||
"new_achievements_unlocked": "{{achievementCount}} új achievementet oldottál fel {{gameCount}} játékban",
|
||||
"new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban",
|
||||
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
|
||||
"achievements_unlocked_for_game": "{{achievementCount}} új achievementet oldottál fel a(z) {{gameTitle}} játékban",
|
||||
"achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}",
|
||||
"hidden_achievement_tooltip": "Ez egy rejtett achievement",
|
||||
"achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el",
|
||||
"earned_points": "Megszerzett pontok:",
|
||||
|
||||
@@ -27,21 +27,68 @@
|
||||
"friends": "Amigos",
|
||||
"need_help": "Precisa de ajuda?",
|
||||
"favorites": "Favoritos",
|
||||
"playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
|
||||
"add_custom_game_tooltip": "Adicionar jogo personalizado",
|
||||
"show_playable_only_tooltip": "Mostrar Apenas Jogáveis",
|
||||
"custom_game_modal": "Adicionar jogo personalizado",
|
||||
"custom_game_modal_description": "Adicione um jogo personalizado à sua biblioteca selecionando um arquivo executável",
|
||||
"custom_game_modal_executable_path": "Caminho do Executável",
|
||||
"custom_game_modal_select_executable": "Selecionar arquivo executável",
|
||||
"custom_game_modal_title": "Título",
|
||||
"custom_game_modal_enter_title": "Insira o título",
|
||||
"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",
|
||||
"custom_game_modal_add": "Adicionar Jogo",
|
||||
"custom_game_modal_adding": "Adicionando...",
|
||||
"custom_game_modal_success": "Jogo personalizado adicionado com sucesso",
|
||||
"custom_game_modal_failed": "Falha ao adicionar jogo personalizado",
|
||||
"custom_game_modal_executable": "Executável",
|
||||
"edit_game_modal": "Personalizar detalhes",
|
||||
"edit_game_modal_description": "Personalize os recursos e detalhes do jogo",
|
||||
"edit_game_modal_enter_title": "Insira o título",
|
||||
"edit_game_modal_image": "Imagem",
|
||||
"edit_game_modal_select_image": "Selecionar imagem",
|
||||
"edit_game_modal_browse": "Buscar",
|
||||
"edit_game_modal_image_preview": "Visualização da imagem",
|
||||
"edit_game_modal_icon": "Ícone",
|
||||
"edit_game_modal_select_icon": "Selecionar ícone",
|
||||
"edit_game_modal_icon_preview": "Visualização do ícone",
|
||||
"edit_game_modal_logo": "Logo",
|
||||
"edit_game_modal": "Personalizar detalhes"
|
||||
"edit_game_modal_select_logo": "Selecionar logo",
|
||||
"edit_game_modal_logo_preview": "Visualização do logo",
|
||||
"edit_game_modal_hero": "Hero da Biblioteca",
|
||||
"edit_game_modal_select_hero": "Selecionar imagem hero da biblioteca",
|
||||
"edit_game_modal_hero_preview": "Visualização da imagem hero da biblioteca",
|
||||
"edit_game_modal_cancel": "Cancelar",
|
||||
"edit_game_modal_update": "Atualizar",
|
||||
"edit_game_modal_updating": "Atualizando...",
|
||||
"edit_game_modal_fill_required": "Por favor, preencha todos os campos obrigatórios",
|
||||
"edit_game_modal_success": "Recursos atualizados com sucesso",
|
||||
"edit_game_modal_failed": "Falha ao atualizar recursos",
|
||||
"edit_game_modal_image_filter": "Imagem",
|
||||
"edit_game_modal_icon_resolution": "Resolução recomendada: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Resolução recomendada: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Resolução recomendada: 1920x620px",
|
||||
"edit_game_modal_assets": "Imagens",
|
||||
"edit_game_modal_drop_icon_image_here": "Solte a imagem do ícone aqui",
|
||||
"edit_game_modal_drop_logo_image_here": "Solte a imagem do logo aqui",
|
||||
"edit_game_modal_drop_hero_image_here": "Solte a imagem hero aqui",
|
||||
"edit_game_modal_drop_to_replace_icon": "Solte para substituir o ícone",
|
||||
"edit_game_modal_drop_to_replace_logo": "Solte para substituir o logo",
|
||||
"edit_game_modal_drop_to_replace_hero": "Solte para substituir o hero",
|
||||
"install_decky_plugin": "Instalar Plugin Decky",
|
||||
"update_decky_plugin": "Atualizar Plugin Decky",
|
||||
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
|
||||
"install_decky_plugin_title": "Instalar Plugin Hydra Decky",
|
||||
"install_decky_plugin_message": "Isso irá baixar e instalar o plugin Hydra para Decky Loader. Pode ser necessário permissões elevadas. Continuar?",
|
||||
"update_decky_plugin_title": "Atualizar Plugin Hydra Decky",
|
||||
"update_decky_plugin_message": "Uma nova versão do plugin Hydra Decky está disponível. Gostaria de atualizar agora?",
|
||||
"decky_plugin_installed": "Plugin Decky v{{version}} instalado com sucesso",
|
||||
"decky_plugin_installation_failed": "Falha ao instalar plugin Decky: {{error}}",
|
||||
"decky_plugin_installation_error": "Erro ao instalar plugin Decky: {{error}}",
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"header": {
|
||||
"search": "Buscar jogos",
|
||||
@@ -165,6 +212,7 @@
|
||||
"uploading_backup": "Criando backup…",
|
||||
"no_backups": "Você ainda não fez nenhum backup deste jogo",
|
||||
"backup_uploaded": "Backup criado",
|
||||
"backup_failed": "Falha no backup",
|
||||
"backup_deleted": "Backup apagado",
|
||||
"backup_restored": "Backup restaurado",
|
||||
"see_all_achievements": "Ver todas as conquistas",
|
||||
@@ -256,7 +304,48 @@
|
||||
"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"
|
||||
"update_playtime_title": "Atualizar tempo de jogo",
|
||||
"update_playtime_success": "Tempo de jogo atualizado com sucesso",
|
||||
"show_more": "Mostrar mais",
|
||||
"show_less": "Mostrar menos",
|
||||
"reviews": "Avaliações",
|
||||
"leave_a_review": "Deixar uma Avaliação",
|
||||
"write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...",
|
||||
"sort_newest": "Mais Recentes",
|
||||
"sort_oldest": "Mais Antigas",
|
||||
"sort_highest_score": "Maior Nota",
|
||||
"sort_lowest_score": "Menor Nota",
|
||||
"sort_most_voted": "Mais Votadas",
|
||||
"no_reviews_yet": "Ainda não há avaliações",
|
||||
"be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
|
||||
"rating": "Avaliação",
|
||||
"rating_stats": "Avaliação",
|
||||
"rating_very_negative": "Muito Negativo",
|
||||
"rating_negative": "Negativo",
|
||||
"rating_neutral": "Neutro",
|
||||
"rating_positive": "Positivo",
|
||||
"rating_very_positive": "Muito Positivo",
|
||||
"submit_review": "Enviar",
|
||||
"submitting": "Enviando...",
|
||||
"review_submitted_successfully": "Avaliação enviada com sucesso!",
|
||||
"review_submission_failed": "Falha ao enviar avaliação. Por favor, tente novamente.",
|
||||
"review_cannot_be_empty": "O campo de texto da avaliação não pode estar vazio.",
|
||||
"review_deleted_successfully": "Avaliação excluída com sucesso.",
|
||||
"review_deletion_failed": "Falha ao excluir avaliação. Por favor, tente novamente.",
|
||||
"loading_reviews": "Carregando avaliações...",
|
||||
"loading_more_reviews": "Carregando mais avaliações...",
|
||||
"load_more_reviews": "Carregar Mais Avaliações",
|
||||
"you_seemed_to_enjoy_this_game": "Parece que você gostou deste jogo",
|
||||
"would_you_recommend_this_game": "Gostaria de deixar uma avaliação para este jogo?",
|
||||
"yes": "Sim",
|
||||
"maybe_later": "Talvez Mais Tarde",
|
||||
"delete_review": "Excluir avaliação",
|
||||
"remove_review": "Remover Avaliação",
|
||||
"delete_review_modal_title": "Tem certeza de que deseja excluir sua avaliação?",
|
||||
"delete_review_modal_description": "Esta ação não pode ser desfeita.",
|
||||
"delete_review_modal_delete_button": "Excluir",
|
||||
"delete_review_modal_cancel_button": "Cancelar",
|
||||
"rating_count": "Avaliação"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -294,6 +383,7 @@
|
||||
"stop_seeding": "Parar de semear",
|
||||
"resume_seeding": "Semear",
|
||||
"options": "Gerenciar",
|
||||
"alldebrid_size_not_supported": "Informações de download para AllDebrid ainda não são suportadas",
|
||||
"extract": "Extrair arquivos",
|
||||
"extracting": "Extraindo arquivos…"
|
||||
},
|
||||
@@ -378,6 +468,8 @@
|
||||
"subscription_renews_on": "Sua assinatura renova dia {{date}}",
|
||||
"bill_sent_until": "Sua próxima cobrança será enviada até esse dia",
|
||||
"no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.",
|
||||
"editor_tab_code": "Código",
|
||||
"editor_tab_info": "Info",
|
||||
"editor_tab_save": "Salvar",
|
||||
"web_store": "Loja de temas",
|
||||
"clear_themes": "Limpar",
|
||||
@@ -395,12 +487,25 @@
|
||||
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
|
||||
"cancel": "Cancelar",
|
||||
"appearance": "Aparência",
|
||||
"debrid": "Debrid",
|
||||
"debrid_description": "Serviços Debrid são downloaders premium sem restrições que permitem baixar rapidamente arquivos hospedados em vários serviços de hospedagem de arquivos, limitados apenas pela sua velocidade de internet.",
|
||||
"enable_torbox": "Habilitar TorBox",
|
||||
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
|
||||
"torbox_account_linked": "Conta do TorBox vinculada",
|
||||
"create_real_debrid_account": "Clique aqui se você ainda não tem uma conta do Real-Debrid",
|
||||
"create_torbox_account": "Clique aqui se você ainda não tem uma conta do TorBox",
|
||||
"real_debrid_account_linked": "Conta Real-Debrid associada",
|
||||
"enable_all_debrid": "Habilitar All-Debrid",
|
||||
"all_debrid_description": "All-Debrid é um downloader sem restrições que permite baixar rapidamente arquivos de várias fontes.",
|
||||
"all_debrid_free_account_error": "A conta \"{{username}}\" é uma conta gratuita. Por favor, assine o All-Debrid",
|
||||
"all_debrid_account_linked": "Conta All-Debrid vinculada com sucesso",
|
||||
"alldebrid_missing_key": "Por favor, forneça uma chave de API",
|
||||
"alldebrid_invalid_key": "Chave de API inválida",
|
||||
"alldebrid_blocked": "Sua chave de API está bloqueada por geolocalização ou IP",
|
||||
"alldebrid_banned": "Esta conta foi banida",
|
||||
"alldebrid_unknown_error": "Ocorreu um erro desconhecido",
|
||||
"alldebrid_invalid_response": "Resposta inválida do All-Debrid",
|
||||
"alldebrid_network_error": "Erro de rede. Por favor, verifique sua conexão",
|
||||
"name_min_length": "O nome do tema deve ter pelo menos 3 caracteres",
|
||||
"import_theme": "Importar tema",
|
||||
"import_theme_description": "Você irá importar {{theme}} da loja de temas",
|
||||
@@ -431,8 +536,7 @@
|
||||
"hidden": "Oculta",
|
||||
"test_notification": "Testar notificação",
|
||||
"notification_preview": "Prévia da Notificação de Conquistas",
|
||||
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
|
||||
"editor_tab_code": "Código"
|
||||
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Download concluído",
|
||||
@@ -448,7 +552,9 @@
|
||||
"game_extracted": "{{title}} extraído com sucesso",
|
||||
"friend_started_playing_game": "{{displayName}} começou a jogar",
|
||||
"test_achievement_notification_title": "Esta é uma notificação de teste",
|
||||
"test_achievement_notification_description": "Bem legal, né?"
|
||||
"test_achievement_notification_description": "Bem legal, né?",
|
||||
"notification_achievement_unlocked_title": "Conquista desbloqueada para {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} e outras {{count}} foram desbloqueadas"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Abrir Hydra",
|
||||
@@ -457,7 +563,8 @@
|
||||
"game_card": {
|
||||
"available_one": "Disponível",
|
||||
"available_other": "Disponíveis",
|
||||
"no_downloads": "Sem downloads disponíveis"
|
||||
"no_downloads": "Sem downloads disponíveis",
|
||||
"calculating": "Calculando"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programas não instalados",
|
||||
@@ -569,7 +676,12 @@
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"game_added_to_pinned": "Jogo adicionado aos fixados",
|
||||
"achievements_earned": "Conquistas recebidas"
|
||||
"game_removed_from_pinned": "Jogo removido dos fixados",
|
||||
"achievements_earned": "Conquistas recebidas",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
|
||||
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Conquista desbloqueada",
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"home": {
|
||||
"surprise_me": "Удиви меня",
|
||||
"no_results": "Ничего не найдено",
|
||||
"hot": "Сейчас популярно",
|
||||
"start_typing": "Начинаю вводить текст...",
|
||||
"hot": "Сейчас популярно",
|
||||
"weekly": "📅 Лучшие игры недели",
|
||||
"achievements": "🏆 Игры с достижениями"
|
||||
},
|
||||
@@ -28,6 +28,8 @@
|
||||
"need_help": "Нужна помощь?",
|
||||
"favorites": "Избранное",
|
||||
"playable_button_title": "Показать только установленные игры.",
|
||||
"add_custom_game_tooltip": "Добавить пользовательскую игру",
|
||||
"show_playable_only_tooltip": "Показать только доступные для игры",
|
||||
"custom_game_modal": "Добавить пользовательскую игру",
|
||||
"custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл",
|
||||
"custom_game_modal_executable_path": "Путь к исполняемому файлу",
|
||||
@@ -74,7 +76,19 @@
|
||||
"edit_game_modal_drop_hero_image_here": "Перетащите изображение обложки сюда",
|
||||
"edit_game_modal_drop_to_replace_icon": "Перетащите для замены иконки",
|
||||
"edit_game_modal_drop_to_replace_logo": "Перетащите для замены логотипа",
|
||||
"edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки"
|
||||
"edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки",
|
||||
"install_decky_plugin": "Установить плагин Decky",
|
||||
"update_decky_plugin": "Обновить плагин Decky",
|
||||
"decky_plugin_installed_version": "Плагин Decky (v{{version}})",
|
||||
"install_decky_plugin_title": "Установить плагин Hydra Decky",
|
||||
"install_decky_plugin_message": "Это загрузит и установит плагин Hydra для Decky Loader. Может потребоваться повышенные разрешения. Продолжить?",
|
||||
"update_decky_plugin_title": "Обновить плагин Hydra Decky",
|
||||
"update_decky_plugin_message": "Доступна новая версия плагина Hydra Decky. Хотите обновить его сейчас?",
|
||||
"decky_plugin_installed": "Плагин Decky v{{version}} успешно установлен",
|
||||
"decky_plugin_installation_failed": "Не удалось установить плагин Decky: {{error}}",
|
||||
"decky_plugin_installation_error": "Ошибка установки плагина Decky: {{error}}",
|
||||
"confirm": "Подтвердить",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"header": {
|
||||
"search": "Поиск",
|
||||
@@ -135,6 +149,7 @@
|
||||
"amount_minutes": "{{amount}} минут",
|
||||
"accuracy": "точность {{accuracy}}%",
|
||||
"add_to_library": "Добавить в библиотеку",
|
||||
"already_in_library": "Уже в библиотеке",
|
||||
"remove_from_library": "Удалить из библиотеки",
|
||||
"no_downloads": "Нет доступных источников",
|
||||
"play_time": "Сыграно {{amount}}",
|
||||
@@ -163,11 +178,13 @@
|
||||
"open_folder": "Открыть папку",
|
||||
"open_download_location": "Просмотреть папку загрузок",
|
||||
"create_shortcut": "Создать ярлык на рабочем столе",
|
||||
"create_shortcut_simple": "Создать ярлык",
|
||||
"clear": "Очистить",
|
||||
"remove_files": "Удалить файлы",
|
||||
"remove_from_library_title": "Вы уверены?",
|
||||
"remove_from_library_description": "{{game}} будет удалена из вашей библиотеки.",
|
||||
"options": "Настройки",
|
||||
"properties": "Свойства",
|
||||
"executable_section_title": "Файл",
|
||||
"executable_section_description": "Путь к файлу, который будет запущен при нажатии на \"Play\"",
|
||||
"downloads_section_title": "Загрузки",
|
||||
@@ -177,26 +194,71 @@
|
||||
"download_in_progress": "Идёт загрузка",
|
||||
"download_paused": "Загрузка приостановлена",
|
||||
"last_downloaded_option": "Последний вариант загрузки",
|
||||
"create_steam_shortcut": "Создать ярлык Steam",
|
||||
"create_shortcut_success": "Ярлык создан",
|
||||
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
|
||||
"create_shortcut_error": "Не удалось создать ярлык",
|
||||
"allow_nsfw_content": "Продолжить",
|
||||
"download": "Скачать",
|
||||
"download_count": "Загрузки",
|
||||
"download_error": "Этот вариант загрузки недоступен",
|
||||
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
|
||||
"nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?",
|
||||
"add_to_favorites": "Добавить в избранное",
|
||||
"remove_from_favorites": "Удалить из избранного",
|
||||
"failed_update_favorites": "Не удалось обновить избранное",
|
||||
"game_removed_from_library": "Игра удалена из библиотеки",
|
||||
"failed_remove_from_library": "Не удалось удалить из библиотеки",
|
||||
"files_removed_success": "Файлы успешно удалены",
|
||||
"failed_remove_files": "Не удалось удалить файлы",
|
||||
"nsfw_content_title": "Эта игра содержит неприемлемый контент",
|
||||
"nsfw_content_description": "{{title}} содержит контент, который может не подходить для всех возрастов. \nВы уверены, что хотите продолжить?",
|
||||
"allow_nsfw_content": "Продолжить",
|
||||
"refuse_nsfw_content": "Назад",
|
||||
"stats": "Статистика",
|
||||
"download_count": "Загрузки",
|
||||
"player_count": "Активные игроки",
|
||||
"rating_count": "Рейтинг",
|
||||
"download_error": "Этот вариант загрузки недоступен",
|
||||
"download": "Скачать",
|
||||
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
|
||||
"warning": "Внимание:",
|
||||
"hydra_needs_to_remain_open": "Для этой загрузки Hydra должна оставаться открытой до завершения. Если Hydra закроется до завершения, вы потеряете прогресс.",
|
||||
"achievements": "Достижения",
|
||||
"achievements_count": "Достижения {{unlockedCount}}/{{achievementsCount}}",
|
||||
"show_more": "Показать больше",
|
||||
"show_less": "Показать меньше",
|
||||
"reviews": "Отзывы",
|
||||
"leave_a_review": "Оставить отзыв",
|
||||
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
|
||||
"sort_newest": "Сначала новые",
|
||||
"no_reviews_yet": "Пока нет отзывов",
|
||||
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
|
||||
"sort_oldest": "Сначала старые",
|
||||
"sort_highest_score": "Высший балл",
|
||||
"sort_lowest_score": "Низший балл",
|
||||
"sort_most_voted": "Самые популярные",
|
||||
"rating": "Оценка",
|
||||
"rating_stats": "Оценка",
|
||||
"rating_very_negative": "Очень негативно",
|
||||
"rating_negative": "Негативно",
|
||||
"rating_neutral": "Нейтрально",
|
||||
"rating_positive": "Позитивно",
|
||||
"rating_very_positive": "Очень позитивно",
|
||||
"submit_review": "Отправить",
|
||||
"submitting": "Отправка...",
|
||||
"review_submitted_successfully": "Отзыв успешно отправлен!",
|
||||
"review_submission_failed": "Не удалось отправить отзыв. Пожалуйста, попробуйте снова.",
|
||||
"review_cannot_be_empty": "Текстовое поле отзыва не может быть пустым.",
|
||||
"review_deleted_successfully": "Отзыв успешно удален.",
|
||||
"review_deletion_failed": "Не удалось удалить отзыв. Пожалуйста, попробуйте снова.",
|
||||
"loading_reviews": "Загрузка отзывов...",
|
||||
"loading_more_reviews": "Загрузка дополнительных отзывов...",
|
||||
"load_more_reviews": "Загрузить больше отзывов",
|
||||
"you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра",
|
||||
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
|
||||
"yes": "Да",
|
||||
"maybe_later": "Возможно позже",
|
||||
"rating_count": "Оценка",
|
||||
"delete_review": "Удалить отзыв",
|
||||
"remove_review": "Удалить отзыв",
|
||||
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
|
||||
"delete_review_modal_description": "Это действие нельзя отменить.",
|
||||
"delete_review_modal_delete_button": "Удалить",
|
||||
"delete_review_modal_cancel_button": "Отмена",
|
||||
"cloud_save": "Облачное сохранение",
|
||||
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
|
||||
"backups": "Резервные копии",
|
||||
@@ -209,6 +271,7 @@
|
||||
"uploading_backup": "Загрузка резервной копии…",
|
||||
"no_backups": "Вы еще не создали резервных копий для этой игры",
|
||||
"backup_uploaded": "Резервная копия загружена",
|
||||
"backup_failed": "Ошибка резервного копирования",
|
||||
"backup_deleted": "Резервная копия удалена",
|
||||
"backup_restored": "Резервная копия восстановлена",
|
||||
"see_all_achievements": "Просмотреть все достижения",
|
||||
@@ -248,26 +311,29 @@
|
||||
"update_playtime_title": "Обновить время игры",
|
||||
"update_playtime_description": "Вручную обновите время игры для {{game}}",
|
||||
"update_playtime": "Обновить время игры",
|
||||
"update_playtime_success": "Время игры успешно обновлено",
|
||||
"update_playtime_error": "Не удалось обновить время игры",
|
||||
"update_game_playtime": "Обновить время игры",
|
||||
"manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.",
|
||||
"manual_playtime_tooltip": "Это время игры было обновлено вручную",
|
||||
"download_error_not_cached_on_torbox": "Эта загрузка недоступна на TorBox, и получить статус загрузки с TorBox пока невозможно.",
|
||||
"game_added_to_favorites": "Игра добавлена в избранное",
|
||||
"download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.",
|
||||
"game_removed_from_favorites": "Игра удалена из избранного",
|
||||
"game_added_to_favorites": "Игра добавлена в избранное",
|
||||
"game_removed_from_pinned": "Игра удалена из закрепленных",
|
||||
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
||||
"automatically_extract_downloaded_files": "Автоматическая распаковка загруженных файлов",
|
||||
"create_steam_shortcut": "Создать ярлык Steam",
|
||||
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
|
||||
"create_start_menu_shortcut": "Создать ярлык в меню «Пуск»",
|
||||
"invalid_wine_prefix_path": "Недопустимый путь префикса Wine",
|
||||
"invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.",
|
||||
"missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux",
|
||||
"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": "Введите название для резервной копии",
|
||||
"save_changes": "Сохранить изменения",
|
||||
"required_field": "Это поле обязательно к заполнению",
|
||||
"max_length_field": "Это поле должно содержать менее {{length}} символов",
|
||||
"freeze_backup": "Закрепить, чтобы она не была перезаписана автоматическими резервными копиями",
|
||||
"unfreeze_backup": "Открепить",
|
||||
@@ -275,41 +341,22 @@
|
||||
"backup_unfrozen": "Резервная копия откреплена",
|
||||
"backup_freeze_failed": "Не удалось закрепить резервную копию",
|
||||
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
|
||||
"manual_playtime_tooltip": "Это время игры было обновлено вручную",
|
||||
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
|
||||
"sort_newest": "Новые",
|
||||
"no_reviews_yet": "Пока нет отзывов",
|
||||
"be_first_to_review": "Будьте первым, кто поделится своими мыслями об этой игре!",
|
||||
"sort_oldest": "Старые",
|
||||
"sort_highest_score": "Высший балл",
|
||||
"sort_lowest_score": "Низший балл",
|
||||
"sort_most_voted": "Самые популярные",
|
||||
"rating": "Рейтинг",
|
||||
"rating_stats": "Рейтинг",
|
||||
"rating_very_negative": "Очень негативный",
|
||||
"rating_negative": "Негативный",
|
||||
"rating_neutral": "Нейтральный",
|
||||
"rating_positive": "Позитивный",
|
||||
"rating_very_positive": "Очень позитивный",
|
||||
"submit_review": "Отправить отзыв",
|
||||
"submitting": "Отправка...",
|
||||
"remove_review": "Удалить отзыв",
|
||||
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
|
||||
"delete_review_modal_description": "Это действие нельзя отменить.",
|
||||
"delete_review_modal_delete_button": "Удалить",
|
||||
"delete_review_modal_cancel_button": "Отмена",
|
||||
"review_submitted_successfully": "Отзыв успешно отправлен!",
|
||||
"review_submission_failed": "Не удалось отправить отзыв. Попробуйте еще раз.",
|
||||
"review_cannot_be_empty": "Поле отзыва не может быть пустым.",
|
||||
"review_deleted_successfully": "Отзыв успешно удален.",
|
||||
"review_deletion_failed": "Не удалось удалить отзыв. Попробуйте еще раз.",
|
||||
"loading_reviews": "Загрузка отзывов...",
|
||||
"loading_more_reviews": "Загрузка дополнительных отзывов...",
|
||||
"load_more_reviews": "Загрузить больше отзывов",
|
||||
"you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра",
|
||||
"would_you_recommend_this_game": "Хотели бы вы оставить отзыв об этой игре?",
|
||||
"yes": "Да",
|
||||
"maybe_later": "Может быть позже"
|
||||
"edit_game_modal_button": "Изменить детали игры",
|
||||
"game_details": "Детали игры",
|
||||
"currency_symbol": "₽",
|
||||
"currency_country": "ru",
|
||||
"prices": "Цены",
|
||||
"no_prices_found": "Цены не найдены",
|
||||
"view_all_prices": "Нажмите, чтобы посмотреть все цены",
|
||||
"retail_price": "Розничная цена",
|
||||
"keyshop_price": "Цена в магазине ключей",
|
||||
"historical_retail": "Исторические розничные цены",
|
||||
"historical_keyshop": "Исторические цены в магазинах ключей",
|
||||
"language": "Язык",
|
||||
"caption": "Субтитры",
|
||||
"audio": "Аудио",
|
||||
"filter_by_source": "Фильтр по источнику",
|
||||
"no_repacks_found": "Источники для этой игры не найдены"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Активировать Hydra",
|
||||
@@ -347,6 +394,7 @@
|
||||
"stop_seeding": "Остановить раздачу",
|
||||
"resume_seeding": "Продолжить раздачу",
|
||||
"options": "Управлять",
|
||||
"alldebrid_size_not_supported": "Информация о загрузке для AllDebrid пока не поддерживается",
|
||||
"extract": "Распаковать файлы",
|
||||
"extracting": "Распаковка файлов…"
|
||||
},
|
||||
@@ -355,13 +403,10 @@
|
||||
"change": "Изменить",
|
||||
"notifications": "Уведомления",
|
||||
"enable_download_notifications": "По завершении загрузки",
|
||||
"enable_achievement_notifications": "Когда достижение разблокировано",
|
||||
"enable_repack_list_notifications": "При добавлении нового репака",
|
||||
"real_debrid_api_token_label": "Real-Debrid API-токен",
|
||||
"quit_app_instead_hiding": "Закрывать приложение вместо сворачивания в трей",
|
||||
"launch_with_system": "Запускать Hydra вместе с системой",
|
||||
"launch_minimized": "Запустить Hydra в свернутом виде",
|
||||
"disable_nsfw_alert": "Отключить предупреждение о непристойном контенте",
|
||||
"general": "Основные",
|
||||
"behavior": "Поведение",
|
||||
"download_sources": "Источники загрузки",
|
||||
@@ -388,11 +433,11 @@
|
||||
"download_source_errored": "Ошибка",
|
||||
"sync_download_sources": "Обновить источники",
|
||||
"removed_download_source": "Источник удален",
|
||||
"removed_download_sources": "Источники удалены",
|
||||
"cancel_button_confirmation_delete_all_sources": "Нет",
|
||||
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
|
||||
"description_confirmation_delete_all_sources": "Вы удалите все источники",
|
||||
"title_confirmation_delete_all_sources": "Удалить все источники",
|
||||
"removed_download_sources": "Источники удалены",
|
||||
"description_confirmation_delete_all_sources": "Вы удалите все источники",
|
||||
"button_delete_all_sources": "Удалить все источники",
|
||||
"added_download_source": "Источник добавлен",
|
||||
"download_sources_synced": "Все источники обновлены",
|
||||
@@ -401,17 +446,20 @@
|
||||
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
|
||||
"found_download_option_other": "Найдено {{countFormatted}} вариантов загрузки",
|
||||
"import": "Импортировать",
|
||||
"blocked_users": "Заблокированные пользователи",
|
||||
"friends_only": "Только для друзей",
|
||||
"must_be_valid_url": "У источника должен быть правильный URL",
|
||||
"privacy": "Конфиденциальность",
|
||||
"public": "Публичный",
|
||||
"private": "Частный",
|
||||
"friends_only": "Только для друзей",
|
||||
"privacy": "Конфиденциальность",
|
||||
"profile_visibility": "Видимость профиля",
|
||||
"profile_visibility_description": "Выберите, кто может видеть ваш профиль и библиотеку",
|
||||
"public": "Публичный",
|
||||
"required_field": "Это поле обязательно к заполнению",
|
||||
"source_already_exists": "Этот источник уже добавлен",
|
||||
"must_be_valid_url": "У источника должен быть правильный URL",
|
||||
"blocked_users": "Заблокированные пользователи",
|
||||
"user_unblocked": "Пользователь разблокирован",
|
||||
"enable_achievement_notifications": "Когда достижение разблокировано",
|
||||
"launch_minimized": "Запускать Hydra в свернутом виде",
|
||||
"disable_nsfw_alert": "Отключить предупреждение о непристойном контенте",
|
||||
"seed_after_download_complete": "Раздавать после завершения загрузки",
|
||||
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
|
||||
"account": "Аккаунт",
|
||||
@@ -450,12 +498,25 @@
|
||||
"delete_theme_description": "Это приведет к удалению темы {{theme}}",
|
||||
"cancel": "Отменить",
|
||||
"appearance": "Внешний вид",
|
||||
"debrid": "Debrid",
|
||||
"debrid_description": "Сервисы Debrid - это премиум-загрузчики без ограничений, которые позволяют быстро скачивать файлы с различных файлообменников, ограничиваясь только скоростью вашего интернета.",
|
||||
"enable_torbox": "Включить TorBox",
|
||||
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
|
||||
"torbox_account_linked": "Аккаунт TorBox привязан",
|
||||
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
|
||||
"create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid",
|
||||
"create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox",
|
||||
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
|
||||
"enable_all_debrid": "Включить All-Debrid",
|
||||
"all_debrid_description": "All-Debrid - это неограниченный загрузчик, который позволяет быстро скачивать файлы из различных источников.",
|
||||
"all_debrid_free_account_error": "Аккаунт \"{{username}}\" является бесплатным. Пожалуйста, оформите подписку на All-Debrid",
|
||||
"all_debrid_account_linked": "Аккаунт All-Debrid успешно привязан",
|
||||
"alldebrid_missing_key": "Пожалуйста, предоставьте API ключ",
|
||||
"alldebrid_invalid_key": "Неверный API ключ",
|
||||
"alldebrid_blocked": "Ваш API ключ заблокирован по геолокации или IP",
|
||||
"alldebrid_banned": "Этот аккаунт был заблокирован",
|
||||
"alldebrid_unknown_error": "Произошла неизвестная ошибка",
|
||||
"alldebrid_invalid_response": "Неверный ответ от All-Debrid",
|
||||
"alldebrid_network_error": "Ошибка сети. Пожалуйста, проверьте соединение",
|
||||
"name_min_length": "Название темы должно содержать не менее 3 символов",
|
||||
"import_theme": "Импортировать тему",
|
||||
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
|
||||
@@ -469,6 +530,7 @@
|
||||
"installing_common_redist": "Установка…",
|
||||
"show_download_speed_in_megabytes": "Показать скорость загрузки в мегабайтах в секунду",
|
||||
"extract_files_by_default": "Извлекать файлы по умолчанию после загрузки",
|
||||
"enable_steam_achievements": "Включить поиск достижений Steam",
|
||||
"achievement_custom_notification_position": "Позиция уведомлений достижений",
|
||||
"top-left": "Верхний левый угол",
|
||||
"top-center": "Верхний центр",
|
||||
@@ -485,8 +547,7 @@
|
||||
"hidden": "Скрытый",
|
||||
"test_notification": "Тестовое уведомление",
|
||||
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
||||
"enable_steam_achievements": "Включить поиск достижений Steam"
|
||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Загрузка завершена",
|
||||
@@ -498,13 +559,13 @@
|
||||
"restart_to_install_update": "Перезапустите Hydra для установки обновления",
|
||||
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
|
||||
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
|
||||
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья",
|
||||
"new_friend_request_title": "Новый запрос на добавление в друзья",
|
||||
"extraction_complete": "Распаковка завершена",
|
||||
"game_extracted": "{{title}} успешно распакован",
|
||||
"friend_started_playing_game": "{{displayName}} начал играть в игру",
|
||||
"test_achievement_notification_title": "Это тестовое уведомление",
|
||||
"test_achievement_notification_description": "Довольно круто, да?",
|
||||
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья"
|
||||
"test_achievement_notification_description": "Довольно круто, да?"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Открыть Hydra",
|
||||
@@ -535,6 +596,10 @@
|
||||
"last_time_played": "Последняя игра {{period}}",
|
||||
"activity": "Недавняя активность",
|
||||
"library": "Библиотека",
|
||||
"pinned": "Закрепленные",
|
||||
"achievements_earned": "Заработанные достижения",
|
||||
"played_recently": "Недавно сыгранные",
|
||||
"playtime": "Время игры",
|
||||
"total_play_time": "Всего сыграно",
|
||||
"manual_playtime_tooltip": "Время игры было обновлено вручную",
|
||||
"no_recent_activity_title": "Хммм... Тут ничего нет",
|
||||
@@ -578,24 +643,24 @@
|
||||
"no_pending_invites": "У вас нет запросов ожидающих ответа",
|
||||
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
|
||||
"friend_code_copied": "Код друга скопирован",
|
||||
"displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.",
|
||||
"displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.",
|
||||
"image_process_failure": "Сбой при обработке изображения",
|
||||
"locked_profile": "Этот профиль является частным",
|
||||
"undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}.",
|
||||
"privacy_hint": "Чтобы указать, кто может это видеть, перейдите в <0>Настройки</0>.",
|
||||
"profile_reported": "Профиль сообщил",
|
||||
"report": "Отчет",
|
||||
"report_description": "Дополнительная информация",
|
||||
"report_description_placeholder": "Дополнительная информация",
|
||||
"locked_profile": "Этот профиль является частным",
|
||||
"image_process_failure": "Сбой при обработке изображения",
|
||||
"required_field": "Это поле обязательно к заполнению",
|
||||
"displayname_min_length": "Отображаемое имя должно содержать не менее 3 символов.",
|
||||
"displayname_max_length": "Отображаемое имя должно содержать не более 50 символов.",
|
||||
"report_profile": "Пожаловаться на этот профиль",
|
||||
"report_reason": "Почему вы жалуетесь на этот профиль?",
|
||||
"report_description": "Дополнительная информация",
|
||||
"report_description_placeholder": "Дополнительная информация",
|
||||
"report": "Пожаловаться",
|
||||
"report_reason_hate": "Разжигание ненависти",
|
||||
"report_reason_other": "Другой",
|
||||
"report_reason_sexual_content": "Сексуальный контент",
|
||||
"report_reason_spam": "Спам",
|
||||
"report_reason_violence": "Насилие",
|
||||
"required_field": "Это поле обязательно к заполнению",
|
||||
"undo_friendship_modal_text": "Это отменит вашу дружбу с {{displayName}}.",
|
||||
"report_reason_spam": "Спам",
|
||||
"report_reason_other": "Другое",
|
||||
"profile_reported": "Жалоба на профиль отправлена",
|
||||
"your_friend_code": "Код вашего друга:",
|
||||
"upload_banner": "Загрузить баннер",
|
||||
"uploading_banner": "Загрузка баннера...",
|
||||
@@ -616,7 +681,7 @@
|
||||
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
||||
"karma": "Карма",
|
||||
"karma_count": "карма",
|
||||
"karma_description": "Заработано от положительных лайков на отзывах"
|
||||
"karma_description": "Заработана положительными оценками отзывов"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Достижение разблокировано",
|
||||
|
||||
@@ -42,3 +42,14 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
|
||||
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
|
||||
|
||||
export const MAIN_LOOP_INTERVAL = 2000;
|
||||
|
||||
export const DECKY_PLUGINS_LOCATION = path.join(
|
||||
SystemPath.getPath("home"),
|
||||
"homebrew",
|
||||
"plugins"
|
||||
);
|
||||
|
||||
export const HYDRA_DECKY_PLUGIN_LOCATION = path.join(
|
||||
DECKY_PLUGINS_LOCATION,
|
||||
"Hydra"
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import disk from "diskusage";
|
||||
|
||||
import { DiskUsage } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import checkDiskSpace from "check-disk-space";
|
||||
|
||||
const getDiskFreeSpace = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
path: string
|
||||
) => disk.check(path);
|
||||
): Promise<DiskUsage> => {
|
||||
const result = await checkDiskSpace(path);
|
||||
return { free: result.free, total: result.size };
|
||||
};
|
||||
|
||||
registerEvent("getDiskFreeSpace", getDiskFreeSpace);
|
||||
|
||||
@@ -57,6 +57,9 @@ import "./misc/install-common-redist";
|
||||
import "./misc/can-install-common-redist";
|
||||
import "./misc/save-temp-file";
|
||||
import "./misc/delete-temp-file";
|
||||
import "./misc/install-hydra-decky-plugin";
|
||||
import "./misc/get-hydra-decky-plugin-info";
|
||||
import "./misc/check-homebrew-folder-exists";
|
||||
import "./torrenting/cancel-game-download";
|
||||
import "./torrenting/pause-game-download";
|
||||
import "./torrenting/resume-game-download";
|
||||
|
||||
13
src/main/events/misc/check-homebrew-folder-exists.ts
Normal file
13
src/main/events/misc/check-homebrew-folder-exists.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { DECKY_PLUGINS_LOCATION } from "@main/constants";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const checkHomebrewFolderExists = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<boolean> => {
|
||||
const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION);
|
||||
return fs.existsSync(homebrewPath);
|
||||
};
|
||||
|
||||
registerEvent("checkHomebrewFolderExists", checkHomebrewFolderExists);
|
||||
94
src/main/events/misc/get-hydra-decky-plugin-info.ts
Normal file
94
src/main/events/misc/get-hydra-decky-plugin-info.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { logger, HydraApi } from "@main/services";
|
||||
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
interface DeckyReleaseInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
const getHydraDeckyPluginInfo = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<{
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
path: string;
|
||||
outdated: boolean;
|
||||
expectedVersion: string | null;
|
||||
}> => {
|
||||
try {
|
||||
// Fetch the expected version from API
|
||||
let expectedVersion: string | null = null;
|
||||
try {
|
||||
const releaseInfo = await HydraApi.get<DeckyReleaseInfo>(
|
||||
"/decky/release",
|
||||
{},
|
||||
{ needsAuth: false }
|
||||
);
|
||||
expectedVersion = releaseInfo.version;
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch Decky release info:", error);
|
||||
}
|
||||
|
||||
// Check if plugin folder exists
|
||||
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
logger.log("Hydra Decky plugin not installed");
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
outdated: true,
|
||||
expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if package.json exists
|
||||
const packageJsonPath = path.join(
|
||||
HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
"package.json"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
logger.log("Hydra Decky plugin package.json not found");
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
outdated: true,
|
||||
expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
// Read and parse package.json
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
const version = packageJson.version;
|
||||
|
||||
const outdated = expectedVersion ? version !== expectedVersion : false;
|
||||
|
||||
logger.log(
|
||||
`Hydra Decky plugin installed, version: ${version}, expected: ${expectedVersion}, outdated: ${outdated}`
|
||||
);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
outdated,
|
||||
expectedVersion,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to get plugin info:", error);
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
outdated: true,
|
||||
expectedVersion: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("getHydraDeckyPluginInfo", getHydraDeckyPluginInfo);
|
||||
50
src/main/events/misc/install-hydra-decky-plugin.ts
Normal file
50
src/main/events/misc/install-hydra-decky-plugin.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { logger, DeckyPlugin } from "@main/services";
|
||||
import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
|
||||
|
||||
const installHydraDeckyPlugin = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
path: string;
|
||||
currentVersion: string | null;
|
||||
expectedVersion: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
logger.log("Installing/updating Hydra Decky plugin...");
|
||||
|
||||
const result = await DeckyPlugin.checkPluginVersion();
|
||||
|
||||
if (result.exists && !result.outdated) {
|
||||
logger.log("Plugin installed successfully");
|
||||
return {
|
||||
success: true,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
currentVersion: result.currentVersion,
|
||||
expectedVersion: result.expectedVersion,
|
||||
};
|
||||
} else {
|
||||
logger.error("Failed to install plugin");
|
||||
return {
|
||||
success: false,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
currentVersion: result.currentVersion,
|
||||
expectedVersion: result.expectedVersion,
|
||||
error: "Plugin installation failed",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error("Failed to install plugin:", error);
|
||||
return {
|
||||
success: false,
|
||||
path: HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
currentVersion: null,
|
||||
expectedVersion: "unknown",
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("installHydraDeckyPlugin", installHydraDeckyPlugin);
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
clearGamesPlaytime,
|
||||
WindowManager,
|
||||
Lock,
|
||||
Aria2,
|
||||
} from "@main/services";
|
||||
import resources from "@locales";
|
||||
import { PythonRPC } from "./services/python-rpc";
|
||||
@@ -222,6 +223,7 @@ app.on("before-quit", async (e) => {
|
||||
e.preventDefault();
|
||||
/* Disconnects libtorrent */
|
||||
PythonRPC.kill();
|
||||
Aria2.kill();
|
||||
await clearGamesPlaytime();
|
||||
canAppBeClosed = true;
|
||||
app.quit();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
startMainLoop,
|
||||
Ludusavi,
|
||||
Lock,
|
||||
DeckyPlugin,
|
||||
} from "@main/services";
|
||||
|
||||
export const loadState = async () => {
|
||||
@@ -49,6 +50,10 @@ export const loadState = async () => {
|
||||
Ludusavi.copyConfigFileToUserData();
|
||||
Ludusavi.copyBinaryToUserData();
|
||||
|
||||
if (process.platform === "linux") {
|
||||
DeckyPlugin.checkAndUpdateIfOutdated();
|
||||
}
|
||||
|
||||
await HydraApi.setupApi().then(() => {
|
||||
uploadGamesBatch();
|
||||
// WSClient.connect();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import cp from "node:child_process";
|
||||
import { app } from "electron";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export class Aria2 {
|
||||
private static process: cp.ChildProcess | null = null;
|
||||
@@ -23,6 +24,9 @@ export class Aria2 {
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
this.process?.kill();
|
||||
if (this.process) {
|
||||
logger.log("Killing aria2 process");
|
||||
this.process.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
400
src/main/services/decky-plugin.ts
Normal file
400
src/main/services/decky-plugin.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import axios from "axios";
|
||||
import sudo from "sudo-prompt";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
HYDRA_DECKY_PLUGIN_LOCATION,
|
||||
DECKY_PLUGINS_LOCATION,
|
||||
} from "@main/constants";
|
||||
import { logger } from "./logger";
|
||||
import { SevenZip } from "./7zip";
|
||||
import { SystemPath } from "./system-path";
|
||||
import { HydraApi } from "./hydra-api";
|
||||
|
||||
interface DeckyReleaseInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export class DeckyPlugin {
|
||||
private static releaseInfo: DeckyReleaseInfo | null = null;
|
||||
|
||||
private static async getDeckyReleaseInfo(): Promise<DeckyReleaseInfo> {
|
||||
if (this.releaseInfo) {
|
||||
return this.releaseInfo;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await HydraApi.get<DeckyReleaseInfo>(
|
||||
"/decky/release",
|
||||
{},
|
||||
{ needsAuth: false }
|
||||
);
|
||||
|
||||
this.releaseInfo = response;
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch Decky release info:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static getPackageJsonPath(): string {
|
||||
return path.join(HYDRA_DECKY_PLUGIN_LOCATION, "package.json");
|
||||
}
|
||||
|
||||
private static async downloadPlugin(): Promise<string> {
|
||||
logger.log("Downloading Hydra Decky plugin...");
|
||||
|
||||
const releaseInfo = await this.getDeckyReleaseInfo();
|
||||
const tempDir = SystemPath.getPath("temp");
|
||||
const zipPath = path.join(tempDir, "Hydra.zip");
|
||||
|
||||
const response = await axios.get(releaseInfo.downloadUrl, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
await fs.promises.writeFile(zipPath, response.data);
|
||||
logger.log(`Plugin downloaded to: ${zipPath}`);
|
||||
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
private static async extractPlugin(zipPath: string): Promise<string> {
|
||||
logger.log("Extracting Hydra Decky plugin...");
|
||||
|
||||
const tempDir = SystemPath.getPath("temp");
|
||||
const extractPath = path.join(tempDir, "hydra-decky-plugin");
|
||||
|
||||
if (fs.existsSync(extractPath)) {
|
||||
await fs.promises.rm(extractPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(extractPath, { recursive: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
SevenZip.extractFile(
|
||||
{
|
||||
filePath: zipPath,
|
||||
outputPath: extractPath,
|
||||
},
|
||||
() => {
|
||||
logger.log(`Plugin extracted to: ${extractPath}`);
|
||||
resolve(extractPath);
|
||||
},
|
||||
() => {
|
||||
reject(new Error("Failed to extract plugin"));
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static needsSudo(): boolean {
|
||||
try {
|
||||
if (fs.existsSync(DECKY_PLUGINS_LOCATION)) {
|
||||
fs.accessSync(DECKY_PLUGINS_LOCATION, fs.constants.W_OK);
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(DECKY_PLUGINS_LOCATION);
|
||||
if (fs.existsSync(parentDir)) {
|
||||
fs.accessSync(parentDir, fs.constants.W_OK);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error.code === "EACCES" || error.code === "EPERM")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async installPluginWithSudo(
|
||||
extractPath: string
|
||||
): Promise<void> {
|
||||
logger.log("Installing plugin with sudo...");
|
||||
|
||||
const username = os.userInfo().username;
|
||||
const sourcePath = path.join(extractPath, "Hydra");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const command = `mkdir -p "${DECKY_PLUGINS_LOCATION}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION}"`;
|
||||
|
||||
sudo.exec(
|
||||
command,
|
||||
{ name: app.getName() },
|
||||
(sudoError, _stdout, stderr) => {
|
||||
if (sudoError) {
|
||||
logger.error("Failed to install plugin with sudo:", sudoError);
|
||||
reject(sudoError);
|
||||
} else {
|
||||
logger.log("Plugin installed successfully with sudo");
|
||||
if (stderr) {
|
||||
logger.log("Sudo stderr:", stderr);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static async installPluginWithoutSudo(
|
||||
extractPath: string
|
||||
): Promise<void> {
|
||||
logger.log("Installing plugin without sudo...");
|
||||
|
||||
const sourcePath = path.join(extractPath, "Hydra");
|
||||
|
||||
if (!fs.existsSync(DECKY_PLUGINS_LOCATION)) {
|
||||
await fs.promises.mkdir(DECKY_PLUGINS_LOCATION, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
logger.log("Plugin installed successfully");
|
||||
}
|
||||
|
||||
private static async installPlugin(extractPath: string): Promise<void> {
|
||||
if (this.needsSudo()) {
|
||||
await this.installPluginWithSudo(extractPath);
|
||||
} else {
|
||||
await this.installPluginWithoutSudo(extractPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async updatePlugin(): Promise<void> {
|
||||
let zipPath: string | null = null;
|
||||
let extractPath: string | null = null;
|
||||
|
||||
try {
|
||||
zipPath = await this.downloadPlugin();
|
||||
extractPath = await this.extractPlugin(zipPath);
|
||||
await this.installPlugin(extractPath);
|
||||
|
||||
logger.log("Plugin update completed successfully");
|
||||
} catch (error) {
|
||||
logger.error("Failed to update plugin:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (zipPath) {
|
||||
try {
|
||||
await fs.promises.rm(zipPath, { force: true });
|
||||
logger.log("Cleaned up downloaded zip file");
|
||||
} catch (cleanupError) {
|
||||
logger.error("Failed to clean up zip file:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
if (extractPath) {
|
||||
try {
|
||||
await fs.promises.rm(extractPath, { recursive: true, force: true });
|
||||
logger.log("Cleaned up extraction directory");
|
||||
} catch (cleanupError) {
|
||||
logger.error(
|
||||
"Failed to clean up extraction directory:",
|
||||
cleanupError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async checkAndUpdateIfOutdated(): Promise<void> {
|
||||
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
logger.log("Hydra Decky plugin not installed, skipping update check");
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJsonPath = this.getPackageJsonPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
logger.log(
|
||||
"Hydra Decky plugin package.json not found, skipping update"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseInfo = await this.getDeckyReleaseInfo();
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
const currentVersion = packageJson.version;
|
||||
const isOutdated = currentVersion !== releaseInfo.version;
|
||||
|
||||
if (isOutdated) {
|
||||
logger.log(
|
||||
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}. Updating...`
|
||||
);
|
||||
|
||||
await this.updatePlugin();
|
||||
logger.log("Hydra Decky plugin updated successfully");
|
||||
} else {
|
||||
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error checking/updating Hydra Decky plugin: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async checkPluginVersion(): Promise<{
|
||||
exists: boolean;
|
||||
outdated: boolean;
|
||||
currentVersion: string | null;
|
||||
expectedVersion: string;
|
||||
}> {
|
||||
try {
|
||||
const releaseInfo = await this.getDeckyReleaseInfo();
|
||||
|
||||
if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
|
||||
logger.log("Hydra Decky plugin folder not found, installing...");
|
||||
|
||||
try {
|
||||
await this.updatePlugin();
|
||||
|
||||
// Read the actual installed version from package.json
|
||||
const packageJsonPath = this.getPackageJsonPath();
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJsonContent = fs.readFileSync(
|
||||
packageJsonPath,
|
||||
"utf-8"
|
||||
);
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: packageJson.version,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: releaseInfo.version,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to install plugin:", error);
|
||||
return {
|
||||
exists: false,
|
||||
outdated: true,
|
||||
currentVersion: null,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const packageJsonPath = this.getPackageJsonPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
logger.log(
|
||||
"Hydra Decky plugin package.json not found, installing..."
|
||||
);
|
||||
|
||||
await this.updatePlugin();
|
||||
|
||||
// Read the actual installed version from package.json
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJsonContent = fs.readFileSync(
|
||||
packageJsonPath,
|
||||
"utf-8"
|
||||
);
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: packageJson.version,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: releaseInfo.version,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
}
|
||||
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
const currentVersion = packageJson.version;
|
||||
const isOutdated = currentVersion !== releaseInfo.version;
|
||||
|
||||
if (isOutdated) {
|
||||
logger.log(
|
||||
`Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${releaseInfo.version}`
|
||||
);
|
||||
|
||||
await this.updatePlugin();
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const updatedPackageJsonContent = fs.readFileSync(
|
||||
packageJsonPath,
|
||||
"utf-8"
|
||||
);
|
||||
const updatedPackageJson = JSON.parse(updatedPackageJsonContent);
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: updatedPackageJson.version,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
outdated: false,
|
||||
currentVersion: releaseInfo.version,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
} else {
|
||||
logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
outdated: isOutdated,
|
||||
currentVersion,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error checking Hydra Decky plugin version: ${error}`);
|
||||
return {
|
||||
exists: false,
|
||||
outdated: true,
|
||||
currentVersion: null,
|
||||
expectedVersion: releaseInfo.version,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching release info: ${error}`);
|
||||
return {
|
||||
exists: false,
|
||||
outdated: true,
|
||||
currentVersion: null,
|
||||
expectedVersion: "unknown",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from "./system-path";
|
||||
export * from "./library-sync";
|
||||
export * from "./wine";
|
||||
export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
|
||||
@@ -79,11 +79,18 @@ const findGamePathByProcess = async (
|
||||
const executables = gameExecutables[gameId];
|
||||
|
||||
for (const executable of executables) {
|
||||
const pathSet = processMap.get(executable.exe);
|
||||
const executablewithoutExtension = executable.exe.replace(/\.exe$/i, "");
|
||||
|
||||
const pathSet =
|
||||
processMap.get(executable.exe) ??
|
||||
processMap.get(executablewithoutExtension);
|
||||
|
||||
if (pathSet) {
|
||||
for (const path of pathSet) {
|
||||
if (path.toLowerCase().endsWith(executable.name)) {
|
||||
if (
|
||||
path.toLowerCase().endsWith(executable.name) ||
|
||||
path.toLowerCase().endsWith(executablewithoutExtension)
|
||||
) {
|
||||
const gameKey = levelKeys.game("steam", gameId);
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
|
||||
@@ -124,7 +131,6 @@ const getSystemProcessMap = async () => {
|
||||
if (!key || !value) return;
|
||||
|
||||
const STEAM_COMPAT_DATA_PATH = process.environ?.STEAM_COMPAT_DATA_PATH;
|
||||
|
||||
if (STEAM_COMPAT_DATA_PATH) {
|
||||
winePrefixMap.set(value, STEAM_COMPAT_DATA_PATH);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ export interface SteamAppDetailsResponse {
|
||||
|
||||
export const getSteamLocation = async () => {
|
||||
if (process.platform === "linux") {
|
||||
return path.join(SystemPath.getPath("home"), ".local", "share", "Steam");
|
||||
const possiblePaths = [
|
||||
path.join(SystemPath.getPath("home"), ".steam", "steam"),
|
||||
path.join(SystemPath.getPath("home"), ".local", "share", "Steam"),
|
||||
];
|
||||
|
||||
return possiblePaths.find((p) => fs.existsSync(p)) || possiblePaths[0];
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
|
||||
@@ -385,6 +385,10 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getBadges: () => ipcRenderer.invoke("getBadges"),
|
||||
canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"),
|
||||
installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"),
|
||||
installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"),
|
||||
getHydraDeckyPluginInfo: () => ipcRenderer.invoke("getHydraDeckyPluginInfo"),
|
||||
checkHomebrewFolderExists: () =>
|
||||
ipcRenderer.invoke("checkHomebrewFolderExists"),
|
||||
platform: process.platform,
|
||||
|
||||
/* Auto update */
|
||||
|
||||
BIN
src/renderer/src/assets/icons/decky.png
Normal file
BIN
src/renderer/src/assets/icons/decky.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
@@ -1,11 +0,0 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.confirm-modal {
|
||||
&__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import "./confirm-modal.scss";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmTheme?: "primary" | "outline" | "danger";
|
||||
confirmDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
confirmTheme = "outline",
|
||||
confirmDisabled = false,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="confirm-modal__actions">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
theme={confirmTheme}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmLabel || t("confirm")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{cancelLabel || t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
&__description {
|
||||
font-size: 16px;
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ConfirmationModal({
|
||||
{cancelButtonLabel}
|
||||
</Button>
|
||||
<Button
|
||||
theme="danger"
|
||||
theme="primary"
|
||||
disabled={buttonsIsDisabled}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
&__item-container {
|
||||
position: relative;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
@@ -97,7 +96,7 @@
|
||||
|
||||
&__submenu {
|
||||
position: absolute;
|
||||
left: calc(100% - 2px);
|
||||
left: 100%;
|
||||
top: 0;
|
||||
background-color: globals.$background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
|
||||
@@ -144,9 +144,9 @@ export function ContextMenu({
|
||||
|
||||
if (parentRect.right + submenuWidth > viewportWidth - 8) {
|
||||
styles.left = "auto";
|
||||
styles.right = "calc(100% - 2px)";
|
||||
styles.right = "100%";
|
||||
} else {
|
||||
styles.left = "calc(100% - 2px)";
|
||||
styles.left = "100%";
|
||||
styles.right = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,13 @@ import {
|
||||
} from "@primer/octicons-react";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import { ContextMenu, ContextMenuItemData, ContextMenuProps } from "..";
|
||||
import { ConfirmModal } from "@renderer/components/confirm-modal/confirm-modal";
|
||||
import { useGameActions } from "..";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItemData,
|
||||
ContextMenuProps,
|
||||
ConfirmationModal,
|
||||
useGameActions,
|
||||
} from "..";
|
||||
|
||||
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
|
||||
game: LibraryGame;
|
||||
@@ -36,9 +40,11 @@ export function GameContextMenu({
|
||||
canPlay,
|
||||
isDeleting,
|
||||
isGameDownloading,
|
||||
isGameRunning,
|
||||
hasRepacks,
|
||||
shouldShowCreateStartMenuShortcut,
|
||||
handlePlayGame,
|
||||
handleCloseGame,
|
||||
handleToggleFavorite,
|
||||
handleCreateShortcut,
|
||||
handleCreateSteamShortcut,
|
||||
@@ -53,10 +59,20 @@ export function GameContextMenu({
|
||||
const items: ContextMenuItemData[] = [
|
||||
{
|
||||
id: "play",
|
||||
label: canPlay ? t("play") : t("download"),
|
||||
icon: canPlay ? <PlayIcon size={16} /> : <DownloadIcon size={16} />,
|
||||
label: isGameRunning ? t("close") : canPlay ? t("play") : t("download"),
|
||||
icon: isGameRunning ? (
|
||||
<XIcon size={16} />
|
||||
) : canPlay ? (
|
||||
<PlayIcon size={16} />
|
||||
) : (
|
||||
<DownloadIcon size={16} />
|
||||
),
|
||||
onClick: () => {
|
||||
void handlePlayGame();
|
||||
if (isGameRunning) {
|
||||
void handleCloseGame();
|
||||
} else {
|
||||
void handlePlayGame();
|
||||
}
|
||||
},
|
||||
disabled: isDeleting,
|
||||
},
|
||||
@@ -195,36 +211,40 @@ export function GameContextMenu({
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmationModal
|
||||
visible={showConfirmRemoveLibrary}
|
||||
title={t("remove_from_library_title")}
|
||||
description={t("remove_from_library_description", { game: game.title })}
|
||||
descriptionText={t("remove_from_library_description", {
|
||||
game: game.title,
|
||||
})}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveLibrary(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
setShowConfirmRemoveLibrary(false);
|
||||
onClose();
|
||||
await handleRemoveFromLibrary();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
cancelButtonLabel={t("cancel")}
|
||||
confirmButtonLabel={t("remove")}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmationModal
|
||||
visible={showConfirmRemoveFiles}
|
||||
title={t("remove_files")}
|
||||
description={t("delete_modal_description", { ns: "downloads" })}
|
||||
descriptionText={t("delete_modal_description", { ns: "downloads" })}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveFiles(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
setShowConfirmRemoveFiles(false);
|
||||
onClose();
|
||||
await handleRemoveFiles();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
cancelButtonLabel={t("cancel")}
|
||||
confirmButtonLabel={t("remove")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LibraryGame, ShortcutLocation } from "@types";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
@@ -21,6 +21,7 @@ export function useGameActions(game: LibraryGame) {
|
||||
} = useDownload();
|
||||
|
||||
const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false);
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
|
||||
const canPlay = Boolean(game.executablePath);
|
||||
const isDeleting = isGameDeleting(game.id);
|
||||
@@ -30,6 +31,20 @@ export function useGameActions(game: LibraryGame) {
|
||||
const shouldShowCreateStartMenuShortcut =
|
||||
window.electron.platform === "win32";
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||
const updatedIsGameRunning =
|
||||
!!game?.id &&
|
||||
!!gamesIds.find((gameRunning) => gameRunning.id == game.id);
|
||||
|
||||
setIsGameRunning(updatedIsGameRunning);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [game?.id]);
|
||||
|
||||
const handlePlayGame = async () => {
|
||||
if (!canPlay) {
|
||||
const path = buildGameDetailsPath({
|
||||
@@ -75,6 +90,15 @@ export function useGameActions(game: LibraryGame) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseGame = async () => {
|
||||
try {
|
||||
await window.electron.closeGame(game.shop, game.objectId);
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to close game");
|
||||
logger.error("Failed to close game", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
try {
|
||||
if (game.favorite) {
|
||||
@@ -239,10 +263,12 @@ export function useGameActions(game: LibraryGame) {
|
||||
canPlay,
|
||||
isDeleting,
|
||||
isGameDownloading,
|
||||
isGameRunning,
|
||||
hasRepacks,
|
||||
shouldShowCreateStartMenuShortcut,
|
||||
creatingSteamShortcut,
|
||||
handlePlayGame,
|
||||
handleCloseGame,
|
||||
handleToggleFavorite,
|
||||
handleCreateShortcut,
|
||||
handleCreateSteamShortcut,
|
||||
|
||||
@@ -71,6 +71,23 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--decky {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(22, 177, 149, 0.2) 0%,
|
||||
rgba(62, 98, 192, 0.1) 100%
|
||||
);
|
||||
color: globals.$muted-color;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(22, 177, 149, 0.3) 0%,
|
||||
rgba(62, 98, 192, 0.15) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-item-button {
|
||||
@@ -123,6 +140,11 @@
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__bottom-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__help-button {
|
||||
color: globals.$muted-color;
|
||||
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from "react-tooltip";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
|
||||
import { TextField } from "@renderer/components";
|
||||
import { TextField, ConfirmationModal } from "@renderer/components";
|
||||
import {
|
||||
useDownload,
|
||||
useLibrary,
|
||||
@@ -31,6 +31,7 @@ import { SidebarGameItem } from "./sidebar-game-item";
|
||||
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
|
||||
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
|
||||
import { useDispatch } from "react-redux";
|
||||
import deckyIcon from "@renderer/assets/icons/decky.png";
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||
@@ -47,6 +48,13 @@ export function Sidebar() {
|
||||
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
const [deckyPluginInfo, setDeckyPluginInfo] = useState<{
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
outdated: boolean;
|
||||
}>({ installed: false, version: null, outdated: false });
|
||||
const [homebrewFolderExists, setHomebrewFolderExists] = useState(false);
|
||||
const [showDeckyConfirmModal, setShowDeckyConfirmModal] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<LibraryGame[]>([]);
|
||||
@@ -66,7 +74,7 @@ export function Sidebar() {
|
||||
|
||||
const { lastPacket, progress } = useDownload();
|
||||
|
||||
const { showWarningToast } = useToast();
|
||||
const { showWarningToast, showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
|
||||
const [showAddGameModal, setShowAddGameModal] = useState(false);
|
||||
@@ -83,10 +91,68 @@ export function Sidebar() {
|
||||
setShowAddGameModal(false);
|
||||
};
|
||||
|
||||
const loadDeckyPluginInfo = async () => {
|
||||
if (window.electron.platform !== "linux") return;
|
||||
|
||||
try {
|
||||
const [info, folderExists] = await Promise.all([
|
||||
window.electron.getHydraDeckyPluginInfo(),
|
||||
window.electron.checkHomebrewFolderExists(),
|
||||
]);
|
||||
|
||||
setDeckyPluginInfo({
|
||||
installed: info.installed,
|
||||
version: info.version,
|
||||
outdated: info.outdated,
|
||||
});
|
||||
setHomebrewFolderExists(folderExists);
|
||||
} catch (error) {
|
||||
console.error("Failed to load Decky plugin info:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallHydraDeckyPlugin = () => {
|
||||
if (deckyPluginInfo.installed && !deckyPluginInfo.outdated) {
|
||||
return;
|
||||
}
|
||||
setShowDeckyConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDeckyInstallation = async () => {
|
||||
setShowDeckyConfirmModal(false);
|
||||
|
||||
try {
|
||||
const result = await window.electron.installHydraDeckyPlugin();
|
||||
|
||||
if (result.success) {
|
||||
showSuccessToast(
|
||||
t("decky_plugin_installed", {
|
||||
version: result.currentVersion,
|
||||
})
|
||||
);
|
||||
await loadDeckyPluginInfo();
|
||||
} else {
|
||||
showErrorToast(
|
||||
t("decky_plugin_installation_failed", {
|
||||
error: result.error || "Unknown error",
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
t("decky_plugin_installation_error", { error: String(error) })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.gameId, updateLibrary]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDeckyPluginInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
|
||||
dispatch(setFriendRequestCount(result.friendRequestCount));
|
||||
@@ -244,6 +310,30 @@ export function Sidebar() {
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{window.electron.platform === "linux" && homebrewFolderExists && (
|
||||
<li className="sidebar__menu-item sidebar__menu-item--decky">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={handleInstallHydraDeckyPlugin}
|
||||
>
|
||||
<img
|
||||
src={deckyIcon}
|
||||
alt="Decky"
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
<span>
|
||||
{deckyPluginInfo.installed && !deckyPluginInfo.outdated
|
||||
? t("decky_plugin_installed_version", {
|
||||
version: deckyPluginInfo.version,
|
||||
})
|
||||
: deckyPluginInfo.installed && deckyPluginInfo.outdated
|
||||
? t("update_decky_plugin")
|
||||
: t("install_decky_plugin")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -321,18 +411,20 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasActiveSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__help-button"
|
||||
data-open-support-chat
|
||||
>
|
||||
<div className="sidebar__help-button-icon">
|
||||
<CommentDiscussionIcon size={14} />
|
||||
</div>
|
||||
<span>{t("need_help")}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar__bottom-buttons">
|
||||
{hasActiveSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__help-button"
|
||||
data-open-support-chat
|
||||
>
|
||||
<div className="sidebar__help-button-icon">
|
||||
<CommentDiscussionIcon size={14} />
|
||||
</div>
|
||||
<span>{t("need_help")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -345,6 +437,24 @@ export function Sidebar() {
|
||||
onClose={handleCloseAddGameModal}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={showDeckyConfirmModal}
|
||||
title={
|
||||
deckyPluginInfo.installed && deckyPluginInfo.outdated
|
||||
? t("update_decky_plugin_title")
|
||||
: t("install_decky_plugin_title")
|
||||
}
|
||||
descriptionText={
|
||||
deckyPluginInfo.installed && deckyPluginInfo.outdated
|
||||
? t("update_decky_plugin_message")
|
||||
: t("install_decky_plugin_message")
|
||||
}
|
||||
onClose={() => setShowDeckyConfirmModal(false)}
|
||||
onConfirm={handleConfirmDeckyInstallation}
|
||||
cancelButtonLabel={t("cancel")}
|
||||
confirmButtonLabel={t("confirm")}
|
||||
/>
|
||||
|
||||
<Tooltip id="add-custom-game-tooltip" />
|
||||
<Tooltip id="show-playable-only-tooltip" />
|
||||
</aside>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Downloader } from "@shared";
|
||||
|
||||
export const VERSION_CODENAME = "Lumen";
|
||||
export const VERSION_CODENAME = "Supernova";
|
||||
|
||||
export const DOWNLOADER_NAME = {
|
||||
[Downloader.RealDebrid]: "Real-Debrid",
|
||||
|
||||
@@ -152,6 +152,7 @@ export function GameDetailsContextProvider({
|
||||
Promise.all([shopDetailsPromise, assetsPromise])
|
||||
.then(([_, assets]) => {
|
||||
if (assets) {
|
||||
if (abortController.signal.aborted) return;
|
||||
setShopDetails((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
|
||||
19
src/renderer/src/declaration.d.ts
vendored
19
src/renderer/src/declaration.d.ts
vendored
@@ -40,9 +40,9 @@ import type {
|
||||
AchievementNotificationInfo,
|
||||
UserLibraryResponse,
|
||||
Game,
|
||||
DiskUsage,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
|
||||
declare global {
|
||||
declare module "*.svg" {
|
||||
@@ -271,7 +271,7 @@ declare global {
|
||||
>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<disk.DiskUsage>;
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||
checkFolderWritePermission: (path: string) => Promise<boolean>;
|
||||
|
||||
/* Cloud save */
|
||||
@@ -339,6 +339,21 @@ declare global {
|
||||
getBadges: () => Promise<Badge[]>;
|
||||
canInstallCommonRedist: () => Promise<boolean>;
|
||||
installCommonRedist: () => Promise<void>;
|
||||
installHydraDeckyPlugin: () => Promise<{
|
||||
success: boolean;
|
||||
path: string;
|
||||
currentVersion: string | null;
|
||||
expectedVersion: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getHydraDeckyPluginInfo: () => Promise<{
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
path: string;
|
||||
outdated: boolean;
|
||||
expectedVersion: string | null;
|
||||
}>;
|
||||
checkHomebrewFolderExists: () => Promise<boolean>;
|
||||
onCommonRedistProgress: (
|
||||
cb: (value: { log: string; complete: boolean }) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
@@ -104,3 +104,11 @@ export const generateRandomGradient = (): string => {
|
||||
// Return as data URL that works in img tags
|
||||
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
};
|
||||
|
||||
export const formatNumber = (num: number): string => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
@@ -2,20 +2,30 @@
|
||||
|
||||
.description-header {
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: globals.$background-color;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1.5);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
gap: calc(globals.$spacing-unit * 0.5);
|
||||
flex-direction: column;
|
||||
|
||||
p {
|
||||
font-size: globals.$small-font-size;
|
||||
color: globals.$muted-color;
|
||||
font-weight: 400;
|
||||
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
@@ -9,7 +16,7 @@ import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { GameReview } from "@types";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
@@ -27,6 +34,8 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails, useLibrary, useDate, useToast } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { formatNumber } from "@renderer/helpers";
|
||||
import { Button } from "@renderer/components";
|
||||
import "./game-details.scss";
|
||||
|
||||
const getScoreColorClass = (score: number): string => {
|
||||
@@ -147,6 +156,9 @@ export function GameDetailsContent() {
|
||||
const [reviewCharCount, setReviewCharCount] = useState(0);
|
||||
const MAX_REVIEW_CHARS = 1000;
|
||||
const [reviewsSortBy, setReviewsSortBy] = useState("newest");
|
||||
const previousVotesRef = useRef<
|
||||
Map<string, { upvotes: number; downvotes: number }>
|
||||
>(new Map());
|
||||
const [reviewsPage, setReviewsPage] = useState(0);
|
||||
const [hasMoreReviews, setHasMoreReviews] = useState(true);
|
||||
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
|
||||
@@ -159,6 +171,8 @@ export function GameDetailsContent() {
|
||||
const [hasUserReviewed, setHasUserReviewed] = useState(false);
|
||||
const [reviewCheckLoading, setReviewCheckLoading] = useState(false);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Check if the current game is in the user's library
|
||||
const isGameInLibrary = useMemo(() => {
|
||||
if (!library || !shop || !objectId) return false;
|
||||
@@ -220,6 +234,14 @@ export function GameDetailsContent() {
|
||||
|
||||
useEffect(() => {
|
||||
setBackdropOpacity(1);
|
||||
|
||||
// Cleanup: abort any pending review requests when objectId changes
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [objectId]);
|
||||
|
||||
const handleCloudSaveButtonClick = () => {
|
||||
@@ -251,7 +273,7 @@ export function GameDetailsContent() {
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
|
||||
const checkUserReview = async () => {
|
||||
const checkUserReview = useCallback(async () => {
|
||||
if (!objectId || !userDetails) return;
|
||||
|
||||
setReviewCheckLoading(true);
|
||||
@@ -260,51 +282,77 @@ export function GameDetailsContent() {
|
||||
const hasReviewed = (response as any)?.hasReviewed || false;
|
||||
setHasUserReviewed(hasReviewed);
|
||||
|
||||
const twoHoursInMilliseconds = 2 * 60 * 60 * 1000;
|
||||
const hasEnoughPlaytime =
|
||||
game &&
|
||||
game.playTimeInMilliseconds >= twoHoursInMilliseconds &&
|
||||
!game.hasManuallyUpdatedPlaytime;
|
||||
|
||||
if (
|
||||
!hasReviewed &&
|
||||
hasEnoughPlaytime &&
|
||||
!sessionStorage.getItem(`reviewPromptDismissed_${objectId}`)
|
||||
) {
|
||||
setShowReviewPrompt(true);
|
||||
setShowReviewForm(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check user review:", error);
|
||||
} finally {
|
||||
setReviewCheckLoading(false);
|
||||
}
|
||||
};
|
||||
}, [objectId, userDetails, shop, game]);
|
||||
|
||||
const loadReviews = async (reset = false) => {
|
||||
if (!objectId) return;
|
||||
const loadReviews = useCallback(
|
||||
async (reset = false) => {
|
||||
if (!objectId) return;
|
||||
|
||||
setReviewsLoading(true);
|
||||
try {
|
||||
const skip = reset ? 0 : reviewsPage * 20;
|
||||
const response = await window.electron.getGameReviews(
|
||||
shop,
|
||||
objectId,
|
||||
20,
|
||||
skip,
|
||||
reviewsSortBy
|
||||
);
|
||||
|
||||
const reviewsData = (response as any)?.reviews || [];
|
||||
const reviewCount = (response as any)?.totalCount || 0;
|
||||
|
||||
if (reset) {
|
||||
setReviews(reviewsData);
|
||||
setReviewsPage(0);
|
||||
setTotalReviewCount(reviewCount);
|
||||
} else {
|
||||
setReviews((prev) => [...prev, ...reviewsData]);
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
setHasMoreReviews(reviewsData.length === 20);
|
||||
} catch (error) {
|
||||
console.error("Failed to load reviews:", error);
|
||||
} finally {
|
||||
setReviewsLoading(false);
|
||||
}
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setReviewsLoading(true);
|
||||
try {
|
||||
const skip = reset ? 0 : reviewsPage * 20;
|
||||
const response = await window.electron.getGameReviews(
|
||||
shop,
|
||||
objectId,
|
||||
20,
|
||||
skip,
|
||||
reviewsSortBy
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewsData = (response as any)?.reviews || [];
|
||||
const reviewCount = (response as any)?.totalCount || 0;
|
||||
|
||||
if (reset) {
|
||||
setReviews(reviewsData);
|
||||
setReviewsPage(0);
|
||||
setTotalReviewCount(reviewCount);
|
||||
} else {
|
||||
setReviews((prev) => [...prev, ...reviewsData]);
|
||||
}
|
||||
|
||||
setHasMoreReviews(reviewsData.length === 20);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error("Failed to load reviews:", error);
|
||||
}
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setReviewsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[objectId, shop, reviewsPage, reviewsSortBy]
|
||||
);
|
||||
|
||||
const handleVoteReview = async (
|
||||
reviewId: string,
|
||||
@@ -333,6 +381,8 @@ export function GameDetailsContent() {
|
||||
loadReviews(true);
|
||||
setShowDeleteReviewModal(false);
|
||||
setReviewToDelete(null);
|
||||
setHasUserReviewed(false);
|
||||
setShowReviewForm(true);
|
||||
showSuccessToast(t("review_deleted_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete review:", error);
|
||||
@@ -389,7 +439,6 @@ export function GameDetailsContent() {
|
||||
|
||||
const handleReviewPromptYes = () => {
|
||||
setShowReviewPrompt(false);
|
||||
setShowReviewForm(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const reviewFormElement = document.querySelector(
|
||||
@@ -406,6 +455,7 @@ export function GameDetailsContent() {
|
||||
|
||||
const handleReviewPromptLater = () => {
|
||||
setShowReviewPrompt(false);
|
||||
setShowReviewForm(false);
|
||||
if (objectId) {
|
||||
sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true");
|
||||
}
|
||||
@@ -444,13 +494,25 @@ export function GameDetailsContent() {
|
||||
loadReviews(true);
|
||||
checkUserReview();
|
||||
}
|
||||
}, [game, shop, objectId, reviewsSortBy, userDetails]);
|
||||
}, [game, shop, objectId, loadReviews, checkUserReview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewsPage > 0) {
|
||||
loadReviews(false);
|
||||
}
|
||||
}, [reviewsPage]);
|
||||
}, [reviewsPage, loadReviews]);
|
||||
|
||||
// Initialize previousVotesRef for new reviews
|
||||
useEffect(() => {
|
||||
reviews.forEach((review) => {
|
||||
if (!previousVotesRef.current.has(review.id)) {
|
||||
previousVotesRef.current.set(review.id, {
|
||||
upvotes: review.upvotes || 0,
|
||||
downvotes: review.downvotes || 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [reviews]);
|
||||
|
||||
const getImageWithCustomPriority = (
|
||||
customUrl: string | null | undefined,
|
||||
@@ -683,7 +745,7 @@ export function GameDetailsContent() {
|
||||
title={getRatingText(starValue, t)}
|
||||
>
|
||||
<Star
|
||||
size={24}
|
||||
size={18}
|
||||
fill={
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "currentColor"
|
||||
@@ -695,8 +757,8 @@ export function GameDetailsContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="game-details__review-submit-button"
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={handleSubmitReview}
|
||||
disabled={
|
||||
!editor?.getHTML().trim() ||
|
||||
@@ -708,7 +770,7 @@ export function GameDetailsContent() {
|
||||
{submittingReview
|
||||
? t("submitting")
|
||||
: t("submit_review")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -754,159 +816,234 @@ export function GameDetailsContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reviews.map((review) => (
|
||||
<div key={review.id} className="game-details__review-item">
|
||||
{review.isBlocked &&
|
||||
!visibleBlockedReviews.has(review.id) ? (
|
||||
<div className="game-details__blocked-review-simple">
|
||||
Review from blocked user —{" "}
|
||||
<button
|
||||
className="game-details__blocked-review-show-link"
|
||||
onClick={() => toggleBlockedReview(review.id)}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="game-details__review-header">
|
||||
<div className="game-details__review-user">
|
||||
{review.user?.profileImageUrl && (
|
||||
<img
|
||||
src={review.user.profileImageUrl}
|
||||
alt={review.user.displayName || "User"}
|
||||
className="game-details__review-avatar"
|
||||
/>
|
||||
)}
|
||||
<div className="game-details__review-user-info">
|
||||
<button
|
||||
className="game-details__review-display-name game-details__review-display-name--clickable"
|
||||
onClick={() =>
|
||||
review.user?.id &&
|
||||
navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
>
|
||||
{review.user?.displayName || "Anonymous"}
|
||||
</button>
|
||||
<div className="game-details__review-date">
|
||||
<ClockIcon size={12} />
|
||||
{formatDistance(
|
||||
new Date(review.createdAt),
|
||||
new Date(),
|
||||
{ addSuffix: true }
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="game-details__review-item"
|
||||
>
|
||||
{review.isBlocked &&
|
||||
!visibleBlockedReviews.has(review.id) ? (
|
||||
<div className="game-details__blocked-review-simple">
|
||||
Review from blocked user —{" "}
|
||||
<button
|
||||
className="game-details__blocked-review-show-link"
|
||||
onClick={() => toggleBlockedReview(review.id)}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="game-details__review-header">
|
||||
<div className="game-details__review-user">
|
||||
{review.user?.profileImageUrl && (
|
||||
<button
|
||||
className="game-details__review-avatar-button"
|
||||
onClick={() =>
|
||||
review.user?.id &&
|
||||
navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
title={review.user.displayName || "User"}
|
||||
>
|
||||
<img
|
||||
src={review.user.profileImageUrl}
|
||||
alt={review.user.displayName || "User"}
|
||||
className="game-details__review-avatar"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="game-details__review-user-info">
|
||||
<button
|
||||
className="game-details__review-display-name game-details__review-display-name--clickable"
|
||||
onClick={() =>
|
||||
review.user?.id &&
|
||||
navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
>
|
||||
{review.user?.displayName || "Anonymous"}
|
||||
</button>
|
||||
<div className="game-details__review-date">
|
||||
<ClockIcon size={12} />
|
||||
{formatDistance(
|
||||
new Date(review.createdAt),
|
||||
new Date(),
|
||||
{ addSuffix: true }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-score-stars"
|
||||
title={getRatingText(review.score, t)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<Star
|
||||
key={starValue}
|
||||
size={20}
|
||||
fill={
|
||||
starValue <= review.score
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
className={`game-details__review-star ${
|
||||
starValue <= review.score
|
||||
? "game-details__review-star--filled"
|
||||
: "game-details__review-star--empty"
|
||||
} ${
|
||||
starValue <= review.score
|
||||
? getScoreColorClass(review.score)
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-score-stars"
|
||||
title={getRatingText(review.score, t)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<Star
|
||||
key={starValue}
|
||||
size={20}
|
||||
fill={
|
||||
starValue <= review.score
|
||||
? "currentColor"
|
||||
: "none"
|
||||
className="game-details__review-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(review.reviewHtml),
|
||||
}}
|
||||
/>
|
||||
<div className="game-details__review-actions">
|
||||
<div className="game-details__review-votes">
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "upvote")
|
||||
}
|
||||
animate={
|
||||
review.hasUpvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className={`game-details__review-star ${
|
||||
starValue <= review.score
|
||||
? "game-details__review-star--filled"
|
||||
: "game-details__review-star--empty"
|
||||
} ${
|
||||
starValue <= review.score
|
||||
? getScoreColorClass(review.score)
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(review.reviewHtml),
|
||||
}}
|
||||
/>
|
||||
<div className="game-details__review-actions">
|
||||
<div className="game-details__review-votes">
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "upvote")
|
||||
}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
animate={
|
||||
review.hasUpvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<span>{review.upvotes || 0}</span>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "downvote")
|
||||
}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
animate={
|
||||
review.hasDownvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
<span>{review.downvotes || 0}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
{userDetails?.id === review.user?.id && (
|
||||
<button
|
||||
className="game-details__delete-review-button"
|
||||
onClick={() => handleDeleteReview(review.id)}
|
||||
title={t("delete_review")}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span>{t("remove_review")}</span>
|
||||
</button>
|
||||
)}
|
||||
{review.isBlocked &&
|
||||
visibleBlockedReviews.has(review.id) && (
|
||||
<button
|
||||
className="game-details__blocked-review-hide-link"
|
||||
onClick={() => toggleBlockedReview(review.id)}
|
||||
>
|
||||
Hide
|
||||
<ThumbsUp size={16} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.upvotes || 0}
|
||||
custom={
|
||||
(review.upvotes || 0) >
|
||||
(previousVotesRef.current.get(review.id)
|
||||
?.upvotes || 0)
|
||||
}
|
||||
variants={{
|
||||
enter: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? 10 : -10,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: { y: 0, opacity: 1 },
|
||||
exit: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? -10 : 10,
|
||||
opacity: 0,
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
onAnimationComplete={() => {
|
||||
previousVotesRef.current.set(
|
||||
review.id,
|
||||
{
|
||||
upvotes: review.upvotes || 0,
|
||||
downvotes: review.downvotes || 0,
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{formatNumber(review.upvotes || 0)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "downvote")
|
||||
}
|
||||
animate={
|
||||
review.hasDownvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.downvotes || 0}
|
||||
custom={
|
||||
(review.downvotes || 0) >
|
||||
(previousVotesRef.current.get(review.id)
|
||||
?.downvotes || 0)
|
||||
}
|
||||
variants={{
|
||||
enter: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? 10 : -10,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: { y: 0, opacity: 1 },
|
||||
exit: (isIncreasing: boolean) => ({
|
||||
y: isIncreasing ? -10 : 10,
|
||||
opacity: 0,
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
onAnimationComplete={() => {
|
||||
previousVotesRef.current.set(
|
||||
review.id,
|
||||
{
|
||||
upvotes: review.upvotes || 0,
|
||||
downvotes: review.downvotes || 0,
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{formatNumber(review.downvotes || 0)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
{userDetails?.id === review.user?.id && (
|
||||
<button
|
||||
className="game-details__delete-review-button"
|
||||
onClick={() => handleDeleteReview(review.id)}
|
||||
title={t("delete_review")}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span>{t("remove_review")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{review.isBlocked &&
|
||||
visibleBlockedReviews.has(review.id) && (
|
||||
<button
|
||||
className="game-details__blocked-review-hide-link"
|
||||
onClick={() =>
|
||||
toggleBlockedReview(review.id)
|
||||
}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMoreReviews && !reviewsLoading && (
|
||||
<button
|
||||
|
||||
@@ -128,7 +128,7 @@ $hero-height: 300px;
|
||||
&__star-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__star {
|
||||
@@ -136,7 +136,7 @@ $hero-height: 300px;
|
||||
border: none;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -220,30 +220,6 @@ $hero-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-submit-button {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
cursor: not-allowed;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&__reviews-list {
|
||||
margin-top: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
@@ -288,7 +264,12 @@ $hero-height: 300px;
|
||||
}
|
||||
|
||||
&__review-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
globals.$dark-background-color 0%,
|
||||
globals.$dark-background-color 30%,
|
||||
globals.$background-color 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
@@ -310,12 +291,29 @@ $hero-height: 300px;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
}
|
||||
|
||||
&__review-avatar-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__review-user-info {
|
||||
@@ -370,16 +368,7 @@ $hero-height: 300px;
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&--upvote:hover {
|
||||
color: #4caf50;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
&--downvote:hover {
|
||||
color: #f44336;
|
||||
border-color: #f44336;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
@@ -398,6 +387,9 @@ $hero-height: 300px;
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
min-width: 1ch;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,9 +1007,9 @@ $hero-height: 300px;
|
||||
&__review-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #3a3a3a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background-color: #1e1e1e;
|
||||
background-color: globals.$dark-background-color;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1026,8 +1018,8 @@ $hero-height: 300px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #2a2a2a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: globals.$background-color;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&__review-editor-toolbar {
|
||||
@@ -1037,7 +1029,7 @@ $hero-height: 300px;
|
||||
|
||||
&__editor-button {
|
||||
background: none;
|
||||
border: 1px solid #4a4a4a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
@@ -1046,13 +1038,13 @@ $hero-height: 300px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #3a3a3a;
|
||||
border-color: #5a5a5a;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: #0078d4;
|
||||
border-color: #0078d4;
|
||||
background-color: globals.$brand-blue;
|
||||
border-color: globals.$brand-blue;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -67,6 +67,12 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
|
||||
// Check if assets were removed (URLs are null but original paths exist)
|
||||
const iconRemoved = !game.iconUrl && (game as any).originalIconPath;
|
||||
const logoRemoved = !game.logoImageUrl && (game as any).originalLogoPath;
|
||||
const heroRemoved =
|
||||
!game.libraryHeroImageUrl && (game as any).originalHeroPath;
|
||||
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.iconUrl),
|
||||
logo: extractLocalPath(game.logoImageUrl),
|
||||
@@ -85,10 +91,25 @@ export function EditGameModal({
|
||||
(game as any).originalHeroPath ||
|
||||
extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
|
||||
// Set removed assets state based on whether assets were explicitly removed
|
||||
setRemovedAssets({
|
||||
icon: iconRemoved,
|
||||
logo: logoRemoved,
|
||||
hero: heroRemoved,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setNonCustomGameAssets = useCallback(
|
||||
(game: LibraryGame) => {
|
||||
// Check if assets were removed (custom URLs are null but original paths exist)
|
||||
const iconRemoved =
|
||||
!game.customIconUrl && (game as any).customOriginalIconPath;
|
||||
const logoRemoved =
|
||||
!game.customLogoImageUrl && (game as any).customOriginalLogoPath;
|
||||
const heroRemoved =
|
||||
!game.customHeroImageUrl && (game as any).customOriginalHeroPath;
|
||||
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.customIconUrl),
|
||||
logo: extractLocalPath(game.customLogoImageUrl),
|
||||
@@ -111,6 +132,13 @@ export function EditGameModal({
|
||||
extractLocalPath(game.customHeroImageUrl),
|
||||
});
|
||||
|
||||
// Set removed assets state based on whether assets were explicitly removed
|
||||
setRemovedAssets({
|
||||
icon: iconRemoved,
|
||||
logo: logoRemoved,
|
||||
hero: heroRemoved,
|
||||
});
|
||||
|
||||
setDefaultUrls({
|
||||
icon: shopDetails?.assets?.iconUrl || game.iconUrl || null,
|
||||
logo: shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null,
|
||||
@@ -148,8 +176,12 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const getAssetDisplayPath = (assetType: AssetType): string => {
|
||||
// Use original path if available, otherwise fall back to display path
|
||||
return originalAssetPaths[assetType] || assetDisplayPaths[assetType];
|
||||
// If asset was removed, don't show any path
|
||||
if (removedAssets[assetType]) {
|
||||
return "";
|
||||
}
|
||||
// Use display path first, then fall back to original path
|
||||
return assetDisplayPaths[assetType] || originalAssetPaths[assetType];
|
||||
};
|
||||
|
||||
const setAssetPath = (assetType: AssetType, path: string): void => {
|
||||
@@ -221,18 +253,11 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const handleRestoreDefault = (assetType: AssetType) => {
|
||||
if (game && isCustomGame(game)) {
|
||||
// For custom games, mark asset as removed and clear paths
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
} else {
|
||||
// For non-custom games, clear custom assets (restore to shop defaults)
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
}
|
||||
// Mark asset as removed and clear paths (for both custom and non-custom games)
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
// Don't clear originalAssetPaths - keep them for reference but don't use them for display
|
||||
};
|
||||
|
||||
const getOriginalTitle = (): string => {
|
||||
@@ -402,10 +427,28 @@ export function EditGameModal({
|
||||
|
||||
// Helper function to prepare non-custom game assets
|
||||
const prepareNonCustomGameAssets = () => {
|
||||
const hasIconPath = assetPaths.icon;
|
||||
let customIconUrl: string | null = null;
|
||||
if (!removedAssets.icon && hasIconPath) {
|
||||
customIconUrl = `local:${assetPaths.icon}`;
|
||||
}
|
||||
|
||||
const hasLogoPath = assetPaths.logo;
|
||||
let customLogoImageUrl: string | null = null;
|
||||
if (!removedAssets.logo && hasLogoPath) {
|
||||
customLogoImageUrl = `local:${assetPaths.logo}`;
|
||||
}
|
||||
|
||||
const hasHeroPath = assetPaths.hero;
|
||||
let customHeroImageUrl: string | null = null;
|
||||
if (!removedAssets.hero && hasHeroPath) {
|
||||
customHeroImageUrl = `local:${assetPaths.hero}`;
|
||||
}
|
||||
|
||||
return {
|
||||
customIconUrl: assetPaths.icon ? `local:${assetPaths.icon}` : null,
|
||||
customLogoImageUrl: assetPaths.logo ? `local:${assetPaths.logo}` : null,
|
||||
customHeroImageUrl: assetPaths.hero ? `local:${assetPaths.hero}` : null,
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -439,9 +482,15 @@ export function EditGameModal({
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl,
|
||||
customOriginalIconPath: originalAssetPaths.icon || undefined,
|
||||
customOriginalLogoPath: originalAssetPaths.logo || undefined,
|
||||
customOriginalHeroPath: originalAssetPaths.hero || undefined,
|
||||
customOriginalIconPath: removedAssets.icon
|
||||
? undefined
|
||||
: originalAssetPaths.icon || undefined,
|
||||
customOriginalLogoPath: removedAssets.logo
|
||||
? undefined
|
||||
: originalAssetPaths.logo || undefined,
|
||||
customOriginalHeroPath: removedAssets.hero
|
||||
? undefined
|
||||
: originalAssetPaths.hero || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -484,6 +533,23 @@ export function EditGameModal({
|
||||
hero: false,
|
||||
});
|
||||
|
||||
// Clear all asset paths to ensure clean state
|
||||
setAssetPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setAssetDisplayPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
|
||||
@@ -161,6 +161,14 @@ export function RepacksModal({
|
||||
|
||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setFilterTerm("");
|
||||
setSelectedFingerprints([]);
|
||||
setIsFilterDrawerOpen(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DownloadSettingsModal
|
||||
@@ -180,7 +188,11 @@ export function RepacksModal({
|
||||
className={`repacks-modal__filter-container ${isFilterDrawerOpen ? "repacks-modal__filter-container--drawer-open" : ""}`}
|
||||
>
|
||||
<div className="repacks-modal__filter-top">
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
<TextField
|
||||
placeholder={t("filter")}
|
||||
value={filterTerm}
|
||||
onChange={handleFilter}
|
||||
/>
|
||||
{downloadSources.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
71
src/renderer/src/pages/settings/settings-debrid.scss
Normal file
71
src/renderer/src/pages/settings/settings-debrid.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.settings-debrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&__description {
|
||||
margin: 0 0 calc(globals.$spacing-unit * 2) 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&__collapse-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all ease 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__check-icon {
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__beta-badge {
|
||||
background: linear-gradient(135deg, #c9aa71, #d4af37);
|
||||
color: #1a1a1a;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
228
src/renderer/src/pages/settings/settings-debrid.tsx
Normal file
228
src/renderer/src/pages/settings/settings-debrid.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useFeature, useAppSelector } from "@renderer/hooks";
|
||||
import { SettingsTorBox } from "./settings-torbox";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronRightIcon, CheckCircleFillIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./settings-debrid.scss";
|
||||
|
||||
interface CollapseState {
|
||||
torbox: boolean;
|
||||
realDebrid: boolean;
|
||||
allDebrid: boolean;
|
||||
}
|
||||
|
||||
const sectionVariants = {
|
||||
collapsed: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
height: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.1 },
|
||||
y: { duration: 0.1 },
|
||||
height: { duration: 0.2 },
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: "auto",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.2, delay: 0.1 },
|
||||
y: { duration: 0.3 },
|
||||
height: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const chevronVariants = {
|
||||
collapsed: {
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
rotate: 90,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function SettingsDebrid() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isFeatureEnabled, Feature } = useFeature();
|
||||
const isTorBoxEnabled = isFeatureEnabled(Feature.TorBox);
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const initialCollapseState = useMemo<CollapseState>(() => {
|
||||
return {
|
||||
torbox: !userPreferences?.torBoxApiToken,
|
||||
realDebrid: !userPreferences?.realDebridApiToken,
|
||||
allDebrid: !userPreferences?.allDebridApiKey,
|
||||
};
|
||||
}, [userPreferences]);
|
||||
|
||||
const [collapseState, setCollapseState] =
|
||||
useState<CollapseState>(initialCollapseState);
|
||||
|
||||
const toggleSection = useCallback((section: keyof CollapseState) => {
|
||||
setCollapseState((prevState) => ({
|
||||
...prevState,
|
||||
[section]: !prevState[section],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="settings-debrid">
|
||||
<p className="settings-debrid__description">{t("debrid_description")}</p>
|
||||
|
||||
<div className="settings-debrid__section">
|
||||
<div className="settings-debrid__section-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-debrid__collapse-button"
|
||||
onClick={() => toggleSection("realDebrid")}
|
||||
aria-label={
|
||||
collapseState.realDebrid
|
||||
? "Expand Real-Debrid section"
|
||||
: "Collapse Real-Debrid section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={collapseState.realDebrid ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h3 className="settings-debrid__section-title">Real-Debrid</h3>
|
||||
{userPreferences?.realDebridApiToken && (
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="settings-debrid__check-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!collapseState.realDebrid && (
|
||||
<motion.div
|
||||
key="realdebrid-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<SettingsRealDebrid />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{isTorBoxEnabled && (
|
||||
<div className="settings-debrid__section">
|
||||
<div className="settings-debrid__section-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-debrid__collapse-button"
|
||||
onClick={() => toggleSection("torbox")}
|
||||
aria-label={
|
||||
collapseState.torbox
|
||||
? "Expand TorBox section"
|
||||
: "Collapse TorBox section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={collapseState.torbox ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h3 className="settings-debrid__section-title">TorBox</h3>
|
||||
{userPreferences?.torBoxApiToken && (
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="settings-debrid__check-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!collapseState.torbox && (
|
||||
<motion.div
|
||||
key="torbox-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<SettingsTorBox />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-debrid__section">
|
||||
<div className="settings-debrid__section-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-debrid__collapse-button"
|
||||
onClick={() => toggleSection("allDebrid")}
|
||||
aria-label={
|
||||
collapseState.allDebrid
|
||||
? "Expand All-Debrid section"
|
||||
: "Collapse All-Debrid section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={collapseState.allDebrid ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h3 className="settings-debrid__section-title">All-Debrid</h3>
|
||||
<span className="settings-debrid__beta-badge">BETA</span>
|
||||
{userPreferences?.allDebridApiKey && (
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="settings-debrid__check-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!collapseState.allDebrid && (
|
||||
<motion.div
|
||||
key="alldebrid-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<SettingsAllDebrid />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Button } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||
@@ -10,21 +8,17 @@ import {
|
||||
SettingsContextProvider,
|
||||
} from "@renderer/context";
|
||||
import { SettingsAccount } from "./settings-account";
|
||||
import { useFeature, useUserDetails } from "@renderer/hooks";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import "./settings.scss";
|
||||
import { SettingsAppearance } from "./aparence/settings-appearance";
|
||||
import { SettingsTorBox } from "./settings-torbox";
|
||||
import { SettingsDebrid } from "./settings-debrid";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const { isFeatureEnabled, Feature } = useFeature();
|
||||
|
||||
const isTorBoxEnabled = isFeatureEnabled(Feature.TorBox);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categories = [
|
||||
{ tabLabel: t("general"), contentTitle: t("general") },
|
||||
@@ -34,16 +28,7 @@ export default function Settings() {
|
||||
tabLabel: t("appearance"),
|
||||
contentTitle: t("appearance"),
|
||||
},
|
||||
...(isTorBoxEnabled
|
||||
? [
|
||||
{
|
||||
tabLabel: "TorBox",
|
||||
contentTitle: "TorBox",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
|
||||
{ tabLabel: "All-Debrid", contentTitle: "All-Debrid" },
|
||||
{ tabLabel: t("debrid"), contentTitle: t("debrid") },
|
||||
];
|
||||
|
||||
if (userDetails)
|
||||
@@ -52,7 +37,7 @@ export default function Settings() {
|
||||
{ tabLabel: t("account"), contentTitle: t("account") },
|
||||
];
|
||||
return categories;
|
||||
}, [userDetails, t, isTorBoxEnabled]);
|
||||
}, [userDetails, t]);
|
||||
|
||||
return (
|
||||
<SettingsContextProvider>
|
||||
@@ -76,15 +61,7 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 4) {
|
||||
return <SettingsTorBox />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 5) {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 6) {
|
||||
return <SettingsAllDebrid />;
|
||||
return <SettingsDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsAccount />;
|
||||
|
||||
@@ -1,40 +1,18 @@
|
||||
function removeZalgoText(text: string): string {
|
||||
const zalgoRegex =
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
/[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/g;
|
||||
// Match combining characters that are commonly used in Zalgo text
|
||||
// Using a more explicit approach to avoid misleading-character-class warning
|
||||
const combiningMarks = [
|
||||
/\u0300-\u036F/g, // Combining Diacritical Marks
|
||||
/\u1AB0-\u1AFF/g, // Combining Diacritical Marks Extended
|
||||
/\u1DC0-\u1DFF/g, // Combining Diacritical Marks Supplement
|
||||
/\u20D0-\u20FF/g, // Combining Diacritical Marks for Symbols
|
||||
/\uFE20-\uFE2F/g, // Combining Half Marks
|
||||
];
|
||||
|
||||
return text.replaceAll(zalgoRegex, "");
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
const entityMap: { [key: string]: string } = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": '"',
|
||||
"'": "'",
|
||||
" ": " ",
|
||||
};
|
||||
|
||||
return text.replaceAll(/&[#\w]+;/g, (entity) => {
|
||||
return entityMap[entity] || entity;
|
||||
});
|
||||
}
|
||||
|
||||
function removeHtmlTags(html: string): string {
|
||||
let result = "";
|
||||
let inTag = false;
|
||||
|
||||
for (const char of html) {
|
||||
if (char === "<") {
|
||||
inTag = true;
|
||||
} else if (char === ">") {
|
||||
inTag = false;
|
||||
} else if (!inTag) {
|
||||
result += char;
|
||||
}
|
||||
let result = text;
|
||||
for (const regex of combiningMarks) {
|
||||
result = result.replace(regex, "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -43,19 +21,48 @@ export function sanitizeHtml(html: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
let cleanText = removeHtmlTags(html);
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
cleanText = decodeHtmlEntities(cleanText);
|
||||
|
||||
cleanText = removeZalgoText(cleanText);
|
||||
|
||||
cleanText = cleanText.replaceAll(/\s+/g, " ").trim();
|
||||
|
||||
if (!cleanText || cleanText.length === 0) {
|
||||
return "";
|
||||
const disallowedSelectors = [
|
||||
"script",
|
||||
"style",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"link",
|
||||
"meta",
|
||||
];
|
||||
for (const sel of disallowedSelectors) {
|
||||
for (const el of tempDiv.querySelectorAll(sel)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return cleanText;
|
||||
for (const el of tempDiv.querySelectorAll("*")) {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (
|
||||
name.startsWith("on") ||
|
||||
name === "style" ||
|
||||
name === "src" ||
|
||||
name === "href"
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const textNode = node as Text;
|
||||
const value = textNode.nodeValue || "";
|
||||
textNode.nodeValue = removeZalgoText(value);
|
||||
}
|
||||
|
||||
const cleanHtml = tempDiv.innerHTML.trim();
|
||||
return cleanHtml;
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
|
||||
@@ -10,6 +10,11 @@ export type HydraCloudFeature =
|
||||
| "backup"
|
||||
| "achievements-points";
|
||||
|
||||
export interface DiskUsage {
|
||||
free: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GameRepack {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user