Compare commits

..

44 Commits

Author SHA1 Message Date
Chubby Granny Chaser
32b9f88702 feat: adding ota updates 2025-10-13 23:56:16 +01:00
Chubby Granny Chaser
c9fc4dfc02 feat: adding ota updates 2025-10-13 23:55:33 +01:00
Chubby Granny Chaser
0ae3e35cb4 feat: adding ota updates 2025-10-13 23:53:44 +01:00
Chubby Granny Chaser
cd136c07a6 ci: prod deployment wrangler 2025-10-13 23:20:26 +01:00
Chubby Granny Chaser
6e243822ff ci: prod deployment wrangler 2025-10-13 23:16:26 +01:00
Chubby Granny Chaser
12274b8c57 ci: prod deployment wrangler 2025-10-13 23:13:23 +01:00
Chubby Granny Chaser
a8bbb76190 ci: testing build 2025-10-13 22:59:09 +01:00
Chubby Granny Chaser
59d4545476 ci: testing build 2025-10-13 22:54:15 +01:00
Chubby Granny Chaser
621adbb1ab ci: testing build 2025-10-13 22:51:53 +01:00
Zamitto
d530c384c9 Merge pull request #1809 from whintersnow0/fix/get-user-data-db
fix: race condition in getUserData database update
2025-10-13 18:03:01 -03:00
Zamitto
d8e30a3f2f Merge pull request #1808 from hydralauncher/fix/game-not-auto-detecting-exe-on-linux
fix: game not auto detecting exe on linux
2025-10-13 18:02:42 -03:00
Chubby Granny Chaser
5e59e1a7d1 feat: adding uuid library 2025-10-13 18:12:07 +01:00
Chubby Granny Chaser
fc541daaed feat: adding uuid library 2025-10-13 18:08:41 +01:00
Chubby Granny Chaser
53d81018e9 feat: adding uuid library 2025-10-13 18:05:48 +01:00
Chubby Granny Chaser
25758a540f ci: adding dockerfile 2025-10-13 17:45:51 +01:00
Chubby Granny Chaser
e5659543ce ci: adding dockerfile 2025-10-13 17:01:45 +01:00
whintersnow0
612350ac19 fix: race condition in getUserData database update 2025-10-13 16:42:31 +02:00
Zamitto
b9c7f992dc fix: aria2 process not being killed 2025-10-13 09:10:31 -03:00
Zamitto
97dc7653b0 chore: bump deps 2025-10-13 09:10:12 -03:00
Zamitto
330f38776f feat: change disk usage library as old one did not compile with more recent electron 2025-10-13 08:18:54 -03:00
Zamitto
b874138641 fix: also test executable name without extension 2025-10-13 08:17:50 -03:00
Chubby Granny Chaser
a439095260 fix: fixing hu translation
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-12 22:51:41 +01:00
Chubby Granny Chaser
5ff263e8c8 Merge pull request #1807 from Stormm232/main
Hungarian Translation: Aligned to latest update.
2025-10-12 22:46:06 +01:00
Kiwo.2
864c47ee46 Missing "," fix 2025-10-12 23:45:34 +02:00
Kiwo.2
523e19d97a Merge branch 'main' into main 2025-10-12 23:41:08 +02:00
Zamitto
03b2c8d07f fix: missing abortController after assets promise 2025-10-12 18:32:06 -03:00
Kiwo.2
d296830533 Matching *HU* translation to Latest 2025-10-12 23:30:52 +02:00
Kiwo.2
7fc4e17547 Merge branch 'hydralauncher:main' into main 2025-10-12 23:26:16 +02:00
Chubby Granny Chaser
636cf287c9 Merge branch 'main' of github.com:hydralauncher/hydra 2025-10-12 22:06:16 +01:00
Chubby Granny Chaser
84be238988 ci: version bump 2025-10-12 22:05:53 +01:00
Chubby Granny Chaser
494a2a0da5 Merge pull request #1806 from hydralauncher/fix/game_asset_changing_path
Fix: game asset changing path
2025-10-12 20:56:00 +01:00
Moyase
4e912b3b8d Merge branch 'main' into fix/game_asset_changing_path 2025-10-12 22:52:45 +03:00
Moyasee
e71211f1aa Fix: extracted ternary operations 2025-10-12 22:51:35 +03:00
Moyasee
a946f3bd5a Fix: extracted ternary operations 2025-10-12 22:48:33 +03:00
Chubby Granny Chaser
374b62983b feat: adding 2h constraint 2025-10-12 20:48:13 +01:00
Moyasee
0cd4c3ccf6 Fix: extracted ternary operations 2025-10-12 22:45:20 +03:00
Moyasee
7b97663b3a Merge branch 'fix/game_asset_changing_path' of https://github.com/hydralauncher/hydra into fix/game_asset_changing_path 2025-10-12 22:40:07 +03:00
Moyasee
68e2e2a772 Fix: conditional structure 2025-10-12 22:39:26 +03:00
Moyase
39979292e2 Merge branch 'main' into fix/game_asset_changing_path 2025-10-12 22:37:28 +03:00
Moyasee
60ae7d40fa Fix: Image path persists upon clearing image 2025-10-12 22:34:05 +03:00
Chubby Granny Chaser
63b6b0b44e Merge pull request #1805 from hydralauncher/feat/reviews-and-commenting
fix: fixing search on sources modal
2025-10-12 20:17:22 +01:00
Moyasee
82c0dc0d97 Merge branch 'main' of https://github.com/hydralauncher/hydra into fix/game_asset_changing_path 2025-10-12 22:13:09 +03:00
Moyasee
1cba3f350c formatting 2025-10-12 22:12:15 +03:00
Kiwo.2
aa4def327a New lines added for update 2025-10-07 19:39:25 +02:00
76 changed files with 2433 additions and 2244 deletions

40
.github/workflows/build-renderer.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Build Renderer
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.0
- name: Install dependencies
run: yarn --frozen-lockfile --ignore-scripts
- name: Build Renderer
run: yarn build
env:
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
- name: Deploy to Cloudflare Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx --yes wrangler@3 pages deploy out/renderer \
--project-name="hydra" \
--commit-dirty=true \
--branch="main"

View File

@@ -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

View File

@@ -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

View File

@@ -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
@@ -57,6 +57,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
- name: Build Windows
if: matrix.os == 'windows-2022'
@@ -73,6 +74,7 @@ jobs:
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
- name: Create artifact
uses: actions/upload-artifact@v4

View File

@@ -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",
@@ -87,6 +87,7 @@
"tar": "^7.4.3",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"uuid": "^13.0.0",
"winreg": "^1.2.5",
"ws": "^8.18.1",
"yaml": "^2.6.1",
@@ -116,7 +117,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 +131,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"
}

View File

@@ -356,7 +356,8 @@
"delete_review_modal_title": "Are you sure you want to delete your review?",
"delete_review_modal_description": "This action cannot be undone.",
"delete_review_modal_delete_button": "Delete",
"delete_review_modal_cancel_button": "Cancel"
"delete_review_modal_cancel_button": "Cancel",
"vote_failed": "Failed to register your vote. Please try again."
},
"activation": {
"title": "Activate Hydra",

View File

@@ -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:",

View File

@@ -1,15 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const checkGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
return HydraApi.get(`/games/${shop}/${objectId}/reviews/check`, null, {
needsAuth: true,
});
};
registerEvent("checkGameReview", checkGameReview);

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const createGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => {
return HydraApi.post(`/games/${shop}/${objectId}/reviews`, {
reviewHtml,
score,
});
};
registerEvent("createGameReview", createGameReview);

View File

@@ -1,14 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const deleteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string
) => {
return HydraApi.delete(`/games/${shop}/${objectId}/reviews/${reviewId}`);
};
registerEvent("deleteReview", deleteReview);

View File

@@ -1,22 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { CatalogueCategory } from "@shared";
import { ShopAssets } from "@types";
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const params = new URLSearchParams({
take: "12",
skip: "0",
});
return HydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`,
{},
{ needsAuth: false }
);
};
registerEvent("getCatalogue", getCatalogue);

View File

@@ -1,10 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>(`/catalogue/developers`, null, {
needsAuth: false,
});
};
registerEvent("getDevelopers", getDevelopers);

View File

@@ -1,26 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const getGameReviews = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
take: number = 20,
skip: number = 0,
sortBy: string = "newest"
) => {
const params = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
sortBy,
});
return HydraApi.get(
`/games/${shop}/${objectId}/reviews?${params.toString()}`,
null,
{ needsAuth: false }
);
};
registerEvent("getGameReviews", getGameReviews);

View File

@@ -1,16 +0,0 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
): Promise<HowLongToBeatCategory[] | null> => {
return HydraApi.get(`/games/${shop}/${objectId}/how-long-to-beat`, null, {
needsAuth: false,
});
};
registerEvent("getHowLongToBeat", getHowLongToBeat);

View File

@@ -1,10 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>(`/catalogue/publishers`, null, {
needsAuth: false,
});
};
registerEvent("getPublishers", getPublishers);

View File

@@ -1,22 +0,0 @@
import { db, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { TrendingGame } from "@types";
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf8",
})
.then((language) => language || "en");
const trendingGames = await HydraApi.get<TrendingGame[]>(
"/catalogue/featured",
{ language },
{ needsAuth: false }
).catch(() => []);
return trendingGames.slice(0, 1);
};
registerEvent("getTrendingGames", getTrendingGames);

View File

@@ -1,18 +0,0 @@
import type { CatalogueSearchPayload } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const searchGames = async (
_event: Electron.IpcMainInvokeEvent,
payload: CatalogueSearchPayload,
take: number,
skip: number
) => {
return HydraApi.post(
"/catalogue/search",
{ ...payload, take, skip },
{ needsAuth: false }
);
};
registerEvent("searchGames", searchGames);

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const voteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => {
return HydraApi.put(
`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
{}
);
};
registerEvent("voteReview", voteReview);

View File

@@ -1,12 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const deleteGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string
) =>
HydraApi.delete<{ ok: boolean }>(
`/profile/games/artifacts/${gameArtifactId}`
);
registerEvent("deleteGameArtifact", deleteGameArtifact);

View File

@@ -1,33 +0,0 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
import type { GameArtifact, GameShop } from "@types";
import { SubscriptionRequiredError, UserNotLoggedInError } from "@shared";
const getGameArtifacts = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const params = new URLSearchParams({
objectId,
shop,
});
return HydraApi.get<GameArtifact[]>(
`/profile/games/artifacts?${params.toString()}`,
{},
{ needsSubscription: true }
).catch((err) => {
if (err instanceof SubscriptionRequiredError) {
return [];
}
if (err instanceof UserNotLoggedInError) {
return [];
}
throw err;
});
};
registerEvent("getGameArtifacts", getGameArtifacts);

View File

@@ -1,14 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const renameGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string,
label: string
) => {
await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}`, {
label,
});
};
registerEvent("renameGameArtifact", renameGameArtifact);

View File

@@ -1,16 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const toggleArtifactFreeze = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string,
freeze: boolean
) => {
if (freeze) {
await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/freeze`);
} else {
await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/unfreeze`);
}
};
registerEvent("toggleArtifactFreeze", toggleArtifactFreeze);

View File

@@ -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);

View File

@@ -1,20 +1,9 @@
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
import { ipcMain } from "electron";
import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-publishers";
import "./catalogue/get-developers";
import "./catalogue/create-game-review";
import "./catalogue/get-game-reviews";
import "./catalogue/vote-review";
import "./catalogue/delete-review";
import "./catalogue/check-game-review";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
@@ -50,9 +39,7 @@ import "./library/copy-custom-game-asset";
import "./misc/open-checkout";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./misc/get-features";
import "./misc/show-item-in-folder";
import "./misc/get-badges";
import "./misc/install-common-redist";
import "./misc/can-install-common-redist";
import "./misc/save-temp-file";
@@ -60,6 +47,7 @@ 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 "./misc/hydra-api-call";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
@@ -79,33 +67,17 @@ import "./download-sources/put-download-source";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./user/get-user-library";
import "./user/get-blocked-users";
import "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./user/get-auth";
import "./user/get-user-stats";
import "./user/report-user";
import "./user/get-unlocked-achievements";
import "./user/get-compared-unlocked-achievements";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/undo-friendship";
import "./profile/update-friend-request";
import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/send-friend-request";
import "./profile/sync-friend-requests";
import "./cloud-save/download-game-artifact";
import "./cloud-save/get-game-artifacts";
import "./cloud-save/get-game-backup-preview";
import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path";
import "./cloud-save/toggle-artifact-freeze";
import "./cloud-save/rename-game-artifact";
import "./notifications/publish-new-repacks-notification";
import "./notifications/update-achievement-notification-window";
import "./notifications/show-achievement-test-notification";

View File

@@ -1,22 +0,0 @@
import { Badge } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
const getBadges = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db
.get<string, string>(levelKeys.language, {
valueEncoding: "utf8",
})
.then((language) => language || "en");
const params = new URLSearchParams({
locale: language,
});
return HydraApi.get<Badge[]>(`/badges?${params.toString()}`, null, {
needsAuth: false,
});
};
registerEvent("getBadges", getBadges);

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const getFeatures = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>("/features", null, { needsAuth: false });
};
registerEvent("getFeatures", getFeatures);

View File

@@ -0,0 +1,38 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
interface HydraApiCallPayload {
method: "get" | "post" | "put" | "patch" | "delete";
url: string;
data?: unknown;
params?: unknown;
options?: {
needsAuth?: boolean;
needsSubscription?: boolean;
ifModifiedSince?: Date;
};
}
const hydraApiCall = async (
_event: Electron.IpcMainInvokeEvent,
payload: HydraApiCallPayload
) => {
const { method, url, data, params, options } = payload;
switch (method) {
case "get":
return HydraApi.get(url, params, options);
case "post":
return HydraApi.post(url, data, options);
case "put":
return HydraApi.put(url, data, options);
case "patch":
return HydraApi.patch(url, data, options);
case "delete":
return HydraApi.delete(url, options);
default:
throw new Error(`Unsupported HTTP method: ${method}`);
}
};
registerEvent("hydraApiCall", hydraApiCall);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { FriendRequest } from "@types";
const getFriendRequests = async (
_event: Electron.IpcMainInvokeEvent
): Promise<FriendRequest[]> => {
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
};
registerEvent("getFriendRequests", getFriendRequests);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const sendFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
return HydraApi.post("/profile/friend-requests", { friendCode: userId });
};
registerEvent("sendFriendRequest", sendFriendRequest);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const undoFriendship = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.delete(`/profile/friends/${userId}`);
};
registerEvent("undoFriendship", undoFriendship);

View File

@@ -1,19 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { FriendRequestAction } from "@types";
const updateFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
action: FriendRequestAction
) => {
if (action == "CANCEL") {
return HydraApi.delete(`/profile/friend-requests/${userId}`);
}
return HydraApi.patch(`/profile/friend-requests/${userId}`, {
requestState: action,
});
};
registerEvent("updateFriendRequest", updateFriendRequest);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const blockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/users/${userId}/block`);
};
registerEvent("blockUser", blockUser);

View File

@@ -1,19 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import type { UserBlocks } from "@types";
export const getBlockedUsers = async (
_event: Electron.IpcMainInvokeEvent,
take: number,
skip: number
): Promise<UserBlocks> => {
return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { blocks: [] };
}
throw err;
});
};
registerEvent("getBlockedUsers", getBlockedUsers);

View File

@@ -1,32 +0,0 @@
import { db } from "@main/level";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { User, UserFriends } from "@types";
import { levelKeys } from "@main/level/sublevels";
export const getUserFriends = async (
userId: string,
take: number,
skip: number
): Promise<UserFriends> => {
const user = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
if (user?.id === userId) {
return HydraApi.get(`/profile/friends`, { take, skip });
}
return HydraApi.get(`/users/${userId}/friends`, { take, skip });
};
const getUserFriendsEvent = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
take: number,
skip: number
) => {
return getUserFriends(userId, take, skip);
};
registerEvent("getUserFriends", getUserFriendsEvent);

View File

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

View File

@@ -1,12 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserStats } from "@types";
export const getUserStats = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserStats> => {
return HydraApi.get(`/users/${userId}/stats`);
};
registerEvent("getUserStats", getUserStats);

View File

@@ -1,12 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserProfile } from "@types";
const getUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserProfile | null> => {
return HydraApi.get<UserProfile>(`/users/${userId}`).catch(() => null);
};
registerEvent("getUser", getUser);

View File

@@ -1,16 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
export const reportUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
reason: string,
description: string
): Promise<void> => {
return HydraApi.post(`/users/${userId}/report`, {
reason,
description,
});
};
registerEvent("reportUser", reportUser);

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const unblockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/users/${userId}/unblock`);
};
registerEvent("unblockUser", unblockUser);

View File

@@ -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();

View File

@@ -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();
}
}
}

View File

@@ -12,7 +12,7 @@ import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
interface HydraApiOptions {
export interface HydraApiOptions {
needsAuth?: boolean;
needsSubscription?: boolean;
ifModifiedSince?: Date;

View File

@@ -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);
}

View File

@@ -8,58 +8,65 @@ import { levelKeys } from "@main/level/sublevels";
export const getUserData = async () => {
return HydraApi.get<UserDetails>(`/profile/me`)
.then(async (me) => {
db.get<string, User>(levelKeys.user, { valueEncoding: "json" }).then(
(user) => {
return db.put<string, User>(
levelKeys.user,
{
...user,
id: me.id,
displayName: me.displayName,
profileImageUrl: me.profileImageUrl,
backgroundImageUrl: me.backgroundImageUrl,
subscription: me.subscription,
},
{ valueEncoding: "json" }
);
}
);
try {
const user = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
await db.put<string, User>(
levelKeys.user,
{
...user,
id: me.id,
displayName: me.displayName,
profileImageUrl: me.profileImageUrl,
backgroundImageUrl: me.backgroundImageUrl,
subscription: me.subscription,
},
{ valueEncoding: "json" }
);
} catch (error) {
logger.error("Failed to update user in DB", error);
}
return me;
})
.catch(async (err) => {
if (err instanceof UserNotLoggedInError) {
return null;
}
logger.error("Failed to get logged user");
const loggedUser = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
logger.error("Failed to get logged user", err);
if (loggedUser) {
return {
...loggedUser,
username: "",
bio: "",
email: null,
profileVisibility: "PUBLIC" as ProfileVisibility,
quirks: {
backupsPerGameLimit: 0,
},
subscription: loggedUser.subscription
? {
id: loggedUser.subscription.id,
status: loggedUser.subscription.status,
plan: {
id: loggedUser.subscription.plan.id,
name: loggedUser.subscription.plan.name,
},
expiresAt: loggedUser.subscription.expiresAt,
}
: null,
featurebaseJwt: "",
} as UserDetails;
try {
const loggedUser = await db.get<string, User>(levelKeys.user, {
valueEncoding: "json",
});
if (loggedUser) {
return {
...loggedUser,
username: "",
bio: "",
email: null,
profileVisibility: "PUBLIC" as ProfileVisibility,
quirks: {
backupsPerGameLimit: 0,
},
subscription: loggedUser.subscription
? {
id: loggedUser.subscription.id,
status: loggedUser.subscription.status,
plan: {
id: loggedUser.subscription.plan.id,
name: loggedUser.subscription.plan.name,
},
expiresAt: loggedUser.subscription.expiresAt,
}
: null,
featurebaseJwt: "",
} as UserDetails;
}
} catch (dbError) {
logger.error("Failed to read user from DB", dbError);
}
return null;

View File

@@ -54,20 +54,37 @@ export class WindowManager {
show: false,
};
private static loadMainWindowURL(hash = "") {
private static async loadWindowURL(window: BrowserWindow, hash: string = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.mainWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
);
} else {
this.mainWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`);
} else if (process.env["MAIN_VITE_RENDERER_URL"]) {
// Try to load from remote URL in production
try {
await window.loadURL(
`${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}`
);
} catch (error) {
// Fall back to local file if remote URL fails
console.error(
"Failed to load from MAIN_VITE_RENDERER_URL, falling back to local file:",
error
);
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash,
}
);
});
}
} else {
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash,
});
}
}
private static async loadMainWindowURL(hash: string = "") {
if (this.mainWindow) {
await this.loadWindowURL(this.mainWindow, hash);
}
}
@@ -268,17 +285,8 @@ export class WindowManager {
}
private static loadNotificationWindowURL() {
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.notificationWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
);
} else {
this.notificationWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "achievement-notification",
}
);
if (this.notificationWindow) {
this.loadWindowURL(this.notificationWindow, "achievement-notification");
}
}
@@ -450,19 +458,10 @@ export class WindowManager {
editorWindow.removeMenu();
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
editorWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}`
);
} else {
editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), {
hash: `theme-editor?themeId=${themeId}`,
});
}
this.loadWindowURL(editorWindow, `theme-editor?themeId=${themeId}`);
editorWindow.once("ready-to-show", () => {
editorWindow.show();
this.mainWindow?.webContents.openDevTools();
if (!app.isPackaged || isStaging) {
editorWindow.webContents.openDevTools();
}
@@ -470,12 +469,11 @@ export class WindowManager {
editorWindow.webContents.on("before-input-event", (_event, input) => {
if (input.key === "F12") {
this.mainWindow?.webContents.toggleDevTools();
editorWindow.webContents.toggleDevTools();
}
});
editorWindow.on("close", () => {
this.mainWindow?.webContents.closeDevTools();
this.editorWindows.delete(themeId);
});
}

View File

@@ -7,6 +7,8 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string;
readonly MAIN_VITE_RENDERER_URL: string;
readonly ELECTRON_RENDERER_URL: string;
}
interface ImportMeta {

View File

@@ -11,7 +11,6 @@ import type {
GameRunning,
FriendRequestAction,
UpdateProfileRequest,
CatalogueSearchPayload,
SeedingStatus,
GameAchievement,
Theme,
@@ -20,7 +19,7 @@ import type {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AuthPage } from "@shared";
import type { AxiosProgressEvent } from "axios";
contextBridge.exposeInMainWorld("electron", {
@@ -62,44 +61,13 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("checkDebridAvailability", magnets),
/* Catalogue */
searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) =>
ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getGameAssets: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAssets", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
createGameReview: (
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) =>
ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
getGameReviews: (
shop: GameShop,
objectId: string,
take?: number,
skip?: number,
sortBy?: string
) => ipcRenderer.invoke("getGameReviews", shop, objectId, take, skip, sortBy),
voteReview: (
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => ipcRenderer.invoke("voteReview", shop, objectId, reviewId, voteType),
deleteReview: (shop: GameShop, objectId: string, reviewId: string) =>
ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
checkGameReview: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("checkGameReview", shop, objectId),
onUpdateAchievements: (
objectId: string,
shop: GameShop,
@@ -309,10 +277,6 @@ contextBridge.exposeInMainWorld("electron", {
downloadOptionTitle: string | null
) =>
ipcRenderer.invoke("uploadSaveGame", objectId, shop, downloadOptionTitle),
toggleArtifactFreeze: (gameArtifactId: string, freeze: boolean) =>
ipcRenderer.invoke("toggleArtifactFreeze", gameArtifactId, freeze),
renameGameArtifact: (gameArtifactId: string, label: string) =>
ipcRenderer.invoke("renameGameArtifact", gameArtifactId, label),
downloadGameArtifact: (
objectId: string,
shop: GameShop,
@@ -323,8 +287,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) =>
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
selectGameBackupPath: (
shop: GameShop,
objectId: string,
@@ -381,8 +343,93 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("showOpenDialog", options),
showItemInFolder: (path: string) =>
ipcRenderer.invoke("showItemInFolder", path),
getFeatures: () => ipcRenderer.invoke("getFeatures"),
getBadges: () => ipcRenderer.invoke("getBadges"),
hydraApi: {
get: (
url: string,
options?: {
params?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
ifModifiedSince?: Date;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "get",
url,
params: options?.params,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
ifModifiedSince: options?.ifModifiedSince,
},
}),
post: (
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "post",
url,
data: options?.data,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
put: (
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "put",
url,
data: options?.data,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
patch: (
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "patch",
url,
data: options?.data,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
delete: (
url: string,
options?: {
needsAuth?: boolean;
needsSubscription?: boolean;
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "delete",
url,
options: {
needsAuth: options?.needsAuth,
needsSubscription: options?.needsSubscription,
},
}),
},
canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"),
installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"),
installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"),
@@ -419,13 +466,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Profile */
getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (updateProfile: UpdateProfileRequest) =>
ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
@@ -438,26 +482,8 @@ contextBridge.exposeInMainWorld("electron", {
},
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
sendFriendRequest: (userId: string) =>
ipcRenderer.invoke("sendFriendRequest", userId),
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
getUserLibrary: (
userId: string,
take?: number,
skip?: number,
sortBy?: string
) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy),
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) =>
ipcRenderer.invoke("getUserFriends", userId, take, skip),
getBlockedUsers: (take: number, skip: number) =>
ipcRenderer.invoke("getBlockedUsers", take, skip),
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
reportUser: (userId: string, reason: string, description: string) =>
ipcRenderer.invoke("reportUser", userId, reason, description),
getComparedUnlockedAchievements: (
objectId: string,
shop: GameShop,

View File

@@ -27,6 +27,7 @@ import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { generateUUID } from "./helpers";
import { injectCustomCss, removeCustomCss } from "./helpers";
import "./app.scss";
@@ -212,7 +213,7 @@ export function App() {
useEffect(() => {
updateRepacks();
const id = crypto.randomUUID();
const id = generateUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
channel.onmessage = async (event: MessageEvent<number>) => {

View File

@@ -18,10 +18,18 @@ export function Hero() {
useEffect(() => {
setIsLoading(true);
window.electron
.getTrendingGames()
const language = i18n.language.split("-")[0];
window.electron.hydraApi
.get<TrendingGame[]>("/catalogue/featured", {
params: { language },
needsAuth: false,
})
.then((result) => {
setFeaturedGameDetails(result);
setFeaturedGameDetails(result.slice(0, 1));
})
.catch(() => {
setFeaturedGameDetails([]);
})
.finally(() => {
setIsLoading(false);

View File

@@ -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",

View File

@@ -98,7 +98,18 @@ export function CloudSyncContextProvider({
);
const getGameArtifacts = useCallback(async () => {
const results = await window.electron.getGameArtifacts(objectId, shop);
const params = new URLSearchParams({
objectId,
shop,
});
const results = await window.electron.hydraApi
.get<GameArtifact[]>(`/profile/games/artifacts?${params.toString()}`, {
needsSubscription: true,
})
.catch(() => {
return [];
});
setArtifacts(results);
}, [objectId, shop]);
@@ -137,7 +148,10 @@ export function CloudSyncContextProvider({
async (gameArtifactId: string, freeze: boolean) => {
setFreezingArtifact(true);
try {
await window.electron.toggleArtifactFreeze(gameArtifactId, freeze);
const endpoint = freeze ? "freeze" : "unfreeze";
await window.electron.hydraApi.put(
`/profile/games/artifacts/${gameArtifactId}/${endpoint}`
);
getGameArtifacts();
} catch (err) {
logger.error("Failed to toggle artifact freeze", objectId, shop, err);
@@ -185,10 +199,12 @@ export function CloudSyncContextProvider({
const deleteGameArtifact = useCallback(
async (gameArtifactId: string) => {
return window.electron.deleteGameArtifact(gameArtifactId).then(() => {
getGameBackupPreview();
getGameArtifacts();
});
return window.electron.hydraApi
.delete<{ ok: boolean }>(`/profile/games/artifacts/${gameArtifactId}`)
.then(() => {
getGameBackupPreview();
getGameArtifacts();
});
},
[getGameBackupPreview, getGameArtifacts]
);

View File

@@ -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 {

View File

@@ -116,7 +116,13 @@ export function SettingsContextProvider({
}, []);
const fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
const blockedUsers = await window.electron.hydraApi
.get<UserBlocks>("/profile/blocks", {
params: { take: 12, skip: 0 },
})
.catch(() => {
return { blocks: [] };
});
setBlockedUsers(blockedUsers.blocks);
}, []);

View File

@@ -82,26 +82,38 @@ export function UserProfileContextProvider({
return "";
};
const { t } = useTranslation("user_profile");
const { t, i18n } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const navigate = useNavigate();
const getUserStats = useCallback(async () => {
window.electron.getUserStats(userId).then((stats) => {
setUserStats(stats);
});
window.electron.hydraApi
.get<UserStats>(`/users/${userId}/stats`)
.then((stats) => {
setUserStats(stats);
});
}, [userId]);
const getUserLibraryGames = useCallback(
async (sortBy?: string) => {
try {
const response = await window.electron.getUserLibrary(
userId,
12,
0,
sortBy
);
const params = new URLSearchParams();
params.append("take", "12");
params.append("skip", "0");
if (sortBy) {
params.append("sortBy", sortBy);
}
const queryString = params.toString();
const url = queryString
? `/users/${userId}/library?${queryString}`
: `/users/${userId}/library`;
const response = await window.electron.hydraApi.get<{
library: UserGame[];
pinnedGames: UserGame[];
}>(url);
if (response) {
setLibraryGames(response.library);
@@ -122,26 +134,36 @@ export function UserProfileContextProvider({
getUserStats();
getUserLibraryGames();
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
return window.electron.hydraApi
.get<UserProfile | null>(`/users/${userId}`)
.then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
} else {
showErrorToast(t("user_not_found"));
navigate(-1);
}
});
});
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const badges = await window.electron.getBadges();
const language = i18n.language.split("-")[0];
const params = new URLSearchParams({
locale: language,
});
const badges = await window.electron.hydraApi.get<Badge[]>(
`/badges?${params.toString()}`,
{ needsAuth: false }
);
setBadges(badges);
}, []);
}, [i18n]);
useEffect(() => {
setUserProfile(null);

View File

@@ -1,8 +1,7 @@
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AuthPage } from "@shared";
import type {
AppUpdaterEvent,
GameShop,
HowLongToBeatCategory,
Steam250Game,
DownloadProgress,
SeedingStatus,
@@ -11,38 +10,29 @@ import type {
RealDebridUser,
AllDebridUser,
UserProfile,
FriendRequest,
FriendRequestAction,
UserFriends,
UserBlocks,
UpdateProfileRequest,
GameStats,
TrendingGame,
UserStats,
UserDetails,
FriendRequestSync,
GameArtifact,
LudusaviBackup,
UserAchievement,
ComparedAchievements,
CatalogueSearchPayload,
LibraryGame,
GameRunning,
TorBoxUser,
Theme,
Badge,
Auth,
ShortcutLocation,
CatalogueSearchResult,
ShopAssets,
ShopDetailsWithAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
UserLibraryResponse,
Game,
DiskUsage,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
declare global {
declare module "*.svg" {
@@ -72,63 +62,22 @@ declare global {
) => Promise<Record<string, boolean>>;
/* Catalogue */
searchGames: (
payload: CatalogueSearchPayload,
take: number,
skip: number
) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
getGameShopDetails: (
objectId: string,
shop: GameShop,
language: string
) => Promise<ShopDetailsWithAssets | null>;
getRandomGame: () => Promise<Steam250Game>;
getHowLongToBeat: (
objectId: string,
shop: GameShop
) => Promise<HowLongToBeatCategory[] | null>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getGameAssets: (
objectId: string,
shop: GameShop
) => Promise<ShopAssets | null>;
getTrendingGames: () => Promise<TrendingGame[]>;
createGameReview: (
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => Promise<void>;
getGameReviews: (
shop: GameShop,
objectId: string,
take?: number,
skip?: number,
sortBy?: string
) => Promise<any[]>;
voteReview: (
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => Promise<void>;
deleteReview: (
shop: GameShop,
objectId: string,
reviewId: string
) => Promise<void>;
checkGameReview: (
shop: GameShop,
objectId: string
) => Promise<{ hasReviewed: boolean }>;
onUpdateAchievements: (
objectId: string,
shop: GameShop,
cb: (achievements: UserAchievement[]) => void
) => () => Electron.IpcRenderer;
getPublishers: () => Promise<string[]>;
getDevelopers: () => Promise<string[]>;
/* Library */
toggleAutomaticCloudSync: (
@@ -271,7 +220,7 @@ declare global {
>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<disk.DiskUsage>;
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
checkFolderWritePermission: (path: string) => Promise<boolean>;
/* Cloud save */
@@ -280,14 +229,6 @@ declare global {
shop: GameShop,
downloadOptionTitle: string | null
) => Promise<void>;
toggleArtifactFreeze: (
gameArtifactId: string,
freeze: boolean
) => Promise<void>;
renameGameArtifact: (
gameArtifactId: string,
label: string
) => Promise<void>;
downloadGameArtifact: (
objectId: string,
shop: GameShop,
@@ -301,7 +242,6 @@ declare global {
objectId: string,
shop: GameShop
) => Promise<LudusaviBackup | null>;
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
selectGameBackupPath: (
shop: GameShop,
objectId: string,
@@ -335,8 +275,48 @@ declare global {
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;
showItemInFolder: (path: string) => Promise<void>;
getFeatures: () => Promise<string[]>;
getBadges: () => Promise<Badge[]>;
hydraApi: {
get: <T = unknown>(
url: string,
options?: {
params?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
ifModifiedSince?: Date;
}
) => Promise<T>;
post: <T = unknown>(
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) => Promise<T>;
put: <T = unknown>(
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) => Promise<T>;
patch: <T = unknown>(
url: string,
options?: {
data?: unknown;
needsAuth?: boolean;
needsSubscription?: boolean;
}
) => Promise<T>;
delete: <T = unknown>(
url: string,
options?: {
needsAuth?: boolean;
needsSubscription?: boolean;
}
) => Promise<T>;
};
canInstallCommonRedist: () => Promise<boolean>;
installCommonRedist: () => Promise<void>;
installHydraDeckyPlugin: () => Promise<{
@@ -378,27 +358,6 @@ declare global {
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
/* User */
getUser: (userId: string) => Promise<UserProfile | null>;
getUserLibrary: (
userId: string,
take?: number,
skip?: number,
sortBy?: string
) => Promise<UserLibraryResponse>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
getUserFriends: (
userId: string,
take: number,
skip: number
) => Promise<UserFriends>;
getBlockedUsers: (take: number, skip: number) => Promise<UserBlocks>;
getUserStats: (userId: string) => Promise<UserStats>;
reportUser: (
userId: string,
reason: string,
description: string
) => Promise<void>;
getComparedUnlockedAchievements: (
objectId: string,
shop: GameShop,
@@ -411,7 +370,6 @@ declare global {
/* Profile */
getMe: () => Promise<UserDetails | null>;
undoFriendship: (userId: string) => Promise<void>;
updateProfile: (
updateProfile: UpdateProfileRequest
) => Promise<UserProfile>;
@@ -419,7 +377,6 @@ declare global {
processProfileImage: (
path: string
) => Promise<{ imagePath: string; mimeType: string }>;
getFriendRequests: () => Promise<FriendRequest[]>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
@@ -428,7 +385,6 @@ declare global {
userId: string,
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;

View File

@@ -1,6 +1,7 @@
import type { GameShop } from "@types";
import Color from "color";
import { v4 as uuidv4 } from "uuid";
import { THEME_WEB_STORE_URL } from "./constants";
export const formatDownloadProgress = (
@@ -112,3 +113,11 @@ export const formatNumber = (num: number): string => {
maximumFractionDigits: 1,
}).format(num);
};
/**
* Generates a UUID v4
* @returns A random UUID string
*/
export const generateUUID = (): string => {
return uuidv4();
};

View File

@@ -11,10 +11,12 @@ export function useFeature() {
const [features, setFeatures] = useState<string[] | null>(null);
useEffect(() => {
window.electron.getFeatures().then((features) => {
localStorage.setItem("features", JSON.stringify(features || []));
setFeatures(features || []);
});
window.electron.hydraApi
.get<string[]>("/features", { needsAuth: false })
.then((features) => {
localStorage.setItem("features", JSON.stringify(features || []));
setFeatures(features || []);
});
}, []);
const isFeatureEnabled = useCallback(

View File

@@ -11,6 +11,7 @@ import type {
FriendRequestAction,
UpdateProfileRequest,
UserDetails,
FriendRequest,
} from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
@@ -76,12 +77,13 @@ export function useUserDetails() {
userDetails?.username,
userDetails?.subscription,
userDetails?.featurebaseJwt,
userDetails?.karma,
]
);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
return window.electron.hydraApi
.get<FriendRequest[]>("/profile/friend-requests")
.then((friendRequests) => {
window.electron.syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
@@ -103,8 +105,10 @@ export function useUserDetails() {
const sendFriendRequest = useCallback(
async (userId: string) => {
return window.electron
.sendFriendRequest(userId)
return window.electron.hydraApi
.post("/profile/friend-requests", {
data: { friendCode: userId },
})
.then(() => fetchFriendRequests());
},
[fetchFriendRequests]
@@ -112,19 +116,31 @@ export function useUserDetails() {
const updateFriendRequestState = useCallback(
async (userId: string, action: FriendRequestAction) => {
return window.electron
.updateFriendRequest(userId, action)
if (action === "CANCEL") {
return window.electron.hydraApi
.delete(`/profile/friend-requests/${userId}`)
.then(() => fetchFriendRequests());
}
return window.electron.hydraApi
.patch(`/profile/friend-requests/${userId}`, {
data: {
requestState: action,
},
})
.then(() => fetchFriendRequests());
},
[fetchFriendRequests]
);
const undoFriendship = (userId: string) =>
window.electron.undoFriendship(userId);
window.electron.hydraApi.delete(`/profile/friends/${userId}`);
const blockUser = (userId: string) => window.electron.blockUser(userId);
const blockUser = (userId: string) =>
window.electron.hydraApi.post(`/users/${userId}/block`);
const unblockUser = (userId: string) => window.electron.unblockUser(userId);
const unblockUser = (userId: string) =>
window.electron.hydraApi.post(`/users/${userId}/unblock`);
const hasActiveSubscription = useMemo(() => {
const expiresAt = new Date(userDetails?.subscription?.expiresAt ?? 0);

View File

@@ -63,11 +63,13 @@ export default function Catalogue() {
const abortController = new AbortController();
abortControllerRef.current = abortController;
const response = await window.electron.searchGames(
filters,
pageSize,
offset
);
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: { ...filters, take: pageSize, skip: offset },
needsAuth: false,
});
if (abortController.signal.aborted) return;

View File

@@ -58,7 +58,14 @@ export function CloudSyncRenameArtifactModal({
try {
if (!artifact) return;
await window.electron.renameGameArtifact(artifact.id, data.label);
await window.electron.hydraApi.put(
`/profile/games/artifacts/${artifact.id}`,
{
data: {
label: data.label,
},
}
);
await getGameArtifacts();
showSuccessToast(t("artifact_renamed"));

View File

@@ -1,43 +1,23 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import {
PencilIcon,
TrashIcon,
ClockIcon,
NoteIcon,
} from "@primer/octicons-react";
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, AnimatePresence } from "framer-motion";
import type { GameReview } from "@types";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import { EditGameModal, DeleteReviewModal } from "./modals";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
import { EditGameModal } from "./modals";
import { GameReviews } from "./game-reviews";
import { GameLogo } from "./game-logo";
import { sanitizeHtml, AuthPage } from "@shared";
import { useTranslation } from "react-i18next";
import { AuthPage } from "@shared";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails, useLibrary, useDate, useToast } from "@renderer/hooks";
import { useUserDetails, useLibrary } 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 => {
if (score >= 1 && score <= 2) return "game-details__review-score--red";
if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
if (score >= 4 && score <= 5) return "game-details__review-score--green";
return "";
};
const processMediaElements = (document: Document) => {
const $images = Array.from(document.querySelectorAll("img"));
$images.forEach(($image) => {
@@ -64,35 +44,16 @@ const processMediaElements = (document: Document) => {
});
};
const getSelectScoreColorClass = (score: number): string => {
if (score >= 1 && score <= 2) return "game-details__review-score-select--red";
if (score >= 3 && score <= 3)
return "game-details__review-score-select--yellow";
if (score >= 4 && score <= 5)
return "game-details__review-score-select--green";
return "";
};
const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) {
case 1:
return t("rating_very_negative");
case 2:
return t("rating_negative");
case 3:
return t("rating_neutral");
case 4:
return t("rating_positive");
case 5:
return t("rating_very_positive");
default:
return "";
}
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
originalUrl: string | null | undefined,
fallbackUrl?: string | null | undefined
) => {
return customUrl || originalUrl || fallbackUrl || "";
};
export function GameDetailsContent() {
const heroRef = useRef<HTMLDivElement | null>(null);
const navigate = useNavigate();
const { t } = useTranslation("game_details");
@@ -109,8 +70,6 @@ export function GameDetailsContent() {
const { userDetails, hasActiveSubscription } = useUserDetails();
const { updateLibrary, library } = useLibrary();
const { formatDistance } = useDate();
const { showSuccessToast, showErrorToast } = useToast();
const { setShowCloudSyncModal, getGameArtifacts } =
useContext(cloudSyncContext);
@@ -137,32 +96,8 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditGameModal, setShowEditGameModal] = useState(false);
const [showDeleteReviewModal, setShowDeleteReviewModal] = useState(false);
const [reviewToDelete, setReviewToDelete] = useState<string | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
// Reviews state management
const [reviews, setReviews] = useState<GameReview[]>([]);
const [reviewsLoading, setReviewsLoading] = useState(false);
const [reviewScore, setReviewScore] = useState<number | null>(null);
const [submittingReview, setSubmittingReview] = useState(false);
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<
Set<string>
>(new Set());
const [totalReviewCount, setTotalReviewCount] = useState(0);
const [showReviewForm, setShowReviewForm] = useState(false);
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const [hasUserReviewed, setHasUserReviewed] = useState(false);
const [reviewCheckLoading, setReviewCheckLoading] = useState(false);
// Check if the current game is in the user's library
const isGameInLibrary = useMemo(() => {
@@ -172,57 +107,6 @@ export function GameDetailsContent() {
);
}, [library, shop, objectId]);
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
}),
],
content: "",
editorProps: {
attributes: {
class: "game-details__review-editor",
"data-placeholder": t("write_review_placeholder"),
},
handlePaste: (view, event) => {
const htmlContent = event.clipboardData?.getData("text/html") || "";
const plainText = event.clipboardData?.getData("text/plain") || "";
const currentText = view.state.doc.textContent;
const remainingChars = MAX_REVIEW_CHARS - currentText.length;
if ((htmlContent || plainText) && remainingChars > 0) {
event.preventDefault();
if (htmlContent) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
const textLength = tempDiv.textContent?.length || 0;
if (textLength <= remainingChars) {
return false;
}
}
const truncatedText = plainText.slice(0, remainingChars);
view.dispatch(view.state.tr.insertText(truncatedText));
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
const text = editor.getText();
setReviewCharCount(text.length);
if (text.length > MAX_REVIEW_CHARS) {
const truncatedContent = text.slice(0, MAX_REVIEW_CHARS);
editor.commands.setContent(truncatedContent);
setReviewCharCount(MAX_REVIEW_CHARS);
}
},
});
useEffect(() => {
setBackdropOpacity(1);
}, [objectId]);
@@ -245,7 +129,7 @@ export function GameDetailsContent() {
setShowEditGameModal(true);
};
const handleGameUpdated = (_updatedGame: any) => {
const handleGameUpdated = () => {
updateGame();
updateLibrary();
};
@@ -256,229 +140,6 @@ export function GameDetailsContent() {
const isCustomGame = game?.shop === "custom";
const checkUserReview = async () => {
if (!objectId || !userDetails) return;
setReviewCheckLoading(true);
try {
const response = await window.electron.checkGameReview(shop, objectId);
const hasReviewed = (response as any)?.hasReviewed || false;
setHasUserReviewed(hasReviewed);
if (
!hasReviewed &&
!sessionStorage.getItem(`reviewPromptDismissed_${objectId}`)
) {
setShowReviewPrompt(true);
}
} catch (error) {
console.error("Failed to check user review:", error);
} finally {
setReviewCheckLoading(false);
}
};
const loadReviews = 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]);
}
setHasMoreReviews(reviewsData.length === 20);
} catch (error) {
console.error("Failed to load reviews:", error);
} finally {
setReviewsLoading(false);
}
};
const handleVoteReview = async (
reviewId: string,
voteType: "upvote" | "downvote"
) => {
if (!objectId) return;
try {
await window.electron.voteReview(shop, objectId, reviewId, voteType);
loadReviews(true);
} catch (error) {
console.error(`Failed to ${voteType} review:`, error);
}
};
const handleDeleteReview = async (reviewId: string) => {
setReviewToDelete(reviewId);
setShowDeleteReviewModal(true);
};
const confirmDeleteReview = async () => {
if (!objectId || !reviewToDelete) return;
try {
await window.electron.deleteReview(shop, objectId, reviewToDelete);
loadReviews(true);
setShowDeleteReviewModal(false);
setReviewToDelete(null);
setHasUserReviewed(false);
setShowReviewForm(true);
showSuccessToast(t("review_deleted_successfully"));
} catch (error) {
console.error("Failed to delete review:", error);
showErrorToast(t("review_deletion_failed"));
}
};
const handleSubmitReview = async () => {
const reviewHtml = editor?.getHTML() || "";
const reviewText = editor?.getText() || "";
if (!objectId) {
return;
}
if (!reviewText.trim()) {
showErrorToast(t("review_cannot_be_empty"));
return;
}
if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) {
return;
}
if (reviewScore === null) {
return;
}
setSubmittingReview(true);
try {
await window.electron.createGameReview(
shop,
objectId,
reviewHtml,
reviewScore
);
editor?.commands.clearContent();
setReviewScore(null);
showSuccessToast(t("review_submitted_successfully"));
await loadReviews(true);
setShowReviewForm(false);
setShowReviewPrompt(false);
setHasUserReviewed(true);
} catch (error) {
console.error("Failed to submit review:", error);
showErrorToast(t("review_submission_failed"));
} finally {
setSubmittingReview(false);
}
};
const handleReviewPromptYes = () => {
setShowReviewPrompt(false);
setShowReviewForm(true);
setTimeout(() => {
const reviewFormElement = document.querySelector(
".game-details__review-form"
);
if (reviewFormElement) {
reviewFormElement.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 100);
};
const handleReviewPromptLater = () => {
setShowReviewPrompt(false);
if (objectId) {
sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true");
}
};
const handleSortChange = (newSortBy: string) => {
if (newSortBy !== reviewsSortBy) {
setReviewsSortBy(newSortBy);
setReviewsPage(0);
setHasMoreReviews(true);
loadReviews(true);
}
};
const toggleBlockedReview = (reviewId: string) => {
setVisibleBlockedReviews((prev) => {
const newSet = new Set(prev);
if (newSet.has(reviewId)) {
newSet.delete(reviewId);
} else {
newSet.add(reviewId);
}
return newSet;
});
};
const loadMoreReviews = () => {
if (!reviewsLoading && hasMoreReviews) {
setReviewsPage((prev) => prev + 1);
loadReviews(false);
}
};
useEffect(() => {
if (objectId && (game || shop)) {
loadReviews(true);
checkUserReview();
}
}, [game, shop, objectId, reviewsSortBy, userDetails]);
useEffect(() => {
if (reviewsPage > 0) {
loadReviews(false);
}
}, [reviewsPage]);
// 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,
originalUrl: string | null | undefined,
fallbackUrl?: string | null | undefined
) => {
return customUrl || originalUrl || fallbackUrl || "";
};
const heroImage = isCustomGame
? game?.libraryHeroImageUrl || game?.iconUrl || ""
: getImageWithCustomPriority(
@@ -486,41 +147,6 @@ export function GameDetailsContent() {
shopDetails?.assets?.libraryHeroImageUrl
);
const logoImage = isCustomGame
? game?.logoImageUrl || ""
: getImageWithCustomPriority(
game?.customLogoImageUrl,
shopDetails?.assets?.logoImageUrl
);
const renderGameLogo = () => {
if (isCustomGame) {
// For custom games, show logo image if available, otherwise show game title as text
if (logoImage) {
return (
<img
src={logoImage}
className="game-details__game-logo"
alt={game?.title}
/>
);
} else {
return (
<div className="game-details__game-logo-text">{game?.title}</div>
);
}
} else {
// For non-custom games, show logo image if available
return logoImage ? (
<img
src={logoImage}
className="game-details__game-logo"
alt={game?.title}
/>
) : null;
}
};
return (
<div
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
@@ -544,7 +170,7 @@ export function GameDetailsContent() {
style={{ opacity: backdropOpacity }}
>
<div className="game-details__hero-content">
{renderGameLogo()}
<GameLogo game={game} shopDetails={shopDetails} />
<div className="game-details__hero-buttons game-details__hero-buttons--right">
{game && (
@@ -583,19 +209,6 @@ export function GameDetailsContent() {
<div className="game-details__description-container">
<div className="game-details__description-content">
{/* Review Prompt Banner */}
{game?.shop !== "custom" &&
showReviewPrompt &&
userDetails &&
!hasUserReviewed &&
!reviewCheckLoading &&
isGameInLibrary && (
<ReviewPromptBanner
onYesClick={handleReviewPromptYes}
onLaterClick={handleReviewPromptLater}
/>
)}
<DescriptionHeader />
<GallerySlider />
@@ -620,386 +233,16 @@ export function GameDetailsContent() {
</button>
)}
{game?.shop !== "custom" && (
<div className="game-details__reviews-section">
{showReviewForm && (
<>
<div className="game-details__reviews-header">
<h3 className="game-details__reviews-title">
{t("leave_a_review")}
</h3>
</div>
<div className="game-details__review-form">
<div className="game-details__review-input-container">
<div className="game-details__review-input-header">
<div className="game-details__review-editor-toolbar">
<button
type="button"
onClick={() =>
editor?.chain().focus().toggleBold().run()
}
className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
disabled={!editor}
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() =>
editor?.chain().focus().toggleItalic().run()
}
className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
disabled={!editor}
>
<em>I</em>
</button>
<button
type="button"
onClick={() =>
editor?.chain().focus().toggleUnderline().run()
}
className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
disabled={!editor}
>
<u>U</u>
</button>
</div>
<div className="game-details__review-char-counter">
<span
className={
reviewCharCount > MAX_REVIEW_CHARS
? "over-limit"
: ""
}
>
{reviewCharCount}/{MAX_REVIEW_CHARS}
</span>
</div>
</div>
<div className="game-details__review-input">
<EditorContent editor={editor} />
</div>
</div>
<div className="game-details__review-form-bottom">
<div className="game-details__review-score-container">
<div className="game-details__star-rating">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={starValue}
type="button"
className={`game-details__star ${
reviewScore && starValue <= reviewScore
? "game-details__star--filled"
: "game-details__star--empty"
} ${
reviewScore && starValue <= reviewScore
? getSelectScoreColorClass(reviewScore)
: ""
}`}
onClick={() => setReviewScore(starValue)}
title={getRatingText(starValue, t)}
>
<Star
size={18}
fill={
reviewScore && starValue <= reviewScore
? "currentColor"
: "none"
}
/>
</button>
))}
</div>
</div>
<Button
theme="primary"
onClick={handleSubmitReview}
disabled={
!editor?.getHTML().trim() ||
reviewScore === null ||
submittingReview ||
reviewCharCount > MAX_REVIEW_CHARS
}
>
{submittingReview
? t("submitting")
: t("submit_review")}
</Button>
</div>
</div>
</>
)}
{showReviewForm && (
<div className="game-details__reviews-separator"></div>
)}
<div className="game-details__reviews-list">
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">
{t("reviews")}
</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
</div>
<ReviewSortOptions
sortBy={reviewsSortBy as any}
onSortChange={handleSortChange}
/>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">
<NoteIcon size={48} />
</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</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 && (
<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-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 },
}
: {}
}
>
<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>
)}
{review.isBlocked &&
visibleBlockedReviews.has(review.id) && (
<button
className="game-details__blocked-review-hide-link"
onClick={() => toggleBlockedReview(review.id)}
>
Hide
</button>
)}
</div>
</>
)}
</div>
))}
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
</div>
{game?.shop !== "custom" && shop && objectId && (
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
)}
</div>
@@ -1016,15 +259,6 @@ export function GameDetailsContent() {
onGameUpdated={handleGameUpdated}
/>
)}
<DeleteReviewModal
visible={showDeleteReviewModal}
onClose={() => {
setShowDeleteReviewModal(false);
setReviewToDelete(null);
}}
onConfirm={confirmDeleteReview}
/>
</div>
);
}

View File

@@ -291,31 +291,6 @@ $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 {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,49 @@
import type { Game, ShopDetailsWithAssets } from "@types";
interface GameLogoProps {
game: Game | null;
shopDetails: ShopDetailsWithAssets | null;
}
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
originalUrl: string | null | undefined,
fallbackUrl?: string | null | undefined
) => {
return customUrl || originalUrl || fallbackUrl || "";
};
export function GameLogo({ game, shopDetails }: Readonly<GameLogoProps>) {
const isCustomGame = game?.shop === "custom";
const logoImage = isCustomGame
? game?.logoImageUrl || ""
: getImageWithCustomPriority(
game?.customLogoImageUrl,
shopDetails?.assets?.logoImageUrl
);
if (isCustomGame) {
// For custom games, show logo image if available, otherwise show game title as text
if (logoImage) {
return (
<img
src={logoImage}
className="game-details__game-logo"
alt={game?.title}
/>
);
} else {
return <div className="game-details__game-logo-text">{game?.title}</div>;
}
} else {
// For non-custom games, show logo image if available
return logoImage ? (
<img
src={logoImage}
className="game-details__game-logo"
alt={game?.title}
/>
) : null;
}
}

View File

@@ -0,0 +1,548 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { NoteIcon } from "@primer/octicons-react";
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useTranslation } from "react-i18next";
import type { GameReview, Game, GameShop } from "@types";
import { ReviewForm } from "./review-form";
import { ReviewItem } from "./review-item";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
import { useToast } from "@renderer/hooks";
type ReviewSortOption =
| "newest"
| "oldest"
| "score_high"
| "score_low"
| "most_voted";
interface GameReviewsProps {
shop: GameShop;
objectId: string;
game: Game | null;
userDetailsId?: string;
isGameInLibrary: boolean;
hasUserReviewed: boolean;
onUserReviewedChange: (hasReviewed: boolean) => void;
}
const MAX_REVIEW_CHARS = 1000;
export function GameReviews({
shop,
objectId,
game,
userDetailsId,
isGameInLibrary,
hasUserReviewed,
onUserReviewedChange,
}: Readonly<GameReviewsProps>) {
const { t } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast();
const [reviews, setReviews] = useState<GameReview[]>([]);
const [reviewsLoading, setReviewsLoading] = useState(false);
const [reviewScore, setReviewScore] = useState<number | null>(null);
const [submittingReview, setSubmittingReview] = useState(false);
const [reviewCharCount, setReviewCharCount] = useState(0);
const [reviewsSortBy, setReviewsSortBy] =
useState<ReviewSortOption>("newest");
const [reviewsPage, setReviewsPage] = useState(0);
const [hasMoreReviews, setHasMoreReviews] = useState(true);
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
Set<string>
>(new Set());
const [totalReviewCount, setTotalReviewCount] = useState(0);
const [showReviewForm, setShowReviewForm] = useState(false);
const [votingReviews, setVotingReviews] = useState<Set<string>>(new Set());
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const previousVotesRef = useRef<
Map<string, { upvotes: number; downvotes: number }>
>(new Map());
const abortControllerRef = useRef<AbortController | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
}),
],
content: "",
editorProps: {
attributes: {
class: "game-details__review-editor",
"data-placeholder": t("write_review_placeholder"),
},
handlePaste: (view, event) => {
const htmlContent = event.clipboardData?.getData("text/html") || "";
const plainText = event.clipboardData?.getData("text/plain") || "";
const currentText = view.state.doc.textContent;
const remainingChars = MAX_REVIEW_CHARS - currentText.length;
if ((htmlContent || plainText) && remainingChars > 0) {
event.preventDefault();
if (htmlContent) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
const textLength = tempDiv.textContent?.length || 0;
if (textLength <= remainingChars) {
return false;
}
}
const truncatedText = plainText.slice(0, remainingChars);
view.dispatch(view.state.tr.insertText(truncatedText));
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
const text = editor.getText();
setReviewCharCount(text.length);
if (text.length > MAX_REVIEW_CHARS) {
const truncatedContent = text.slice(0, MAX_REVIEW_CHARS);
editor.commands.setContent(truncatedContent);
setReviewCharCount(MAX_REVIEW_CHARS);
}
},
});
const checkUserReview = useCallback(async () => {
if (!objectId || !userDetailsId) return;
try {
const response = await window.electron.hydraApi.get<{
hasReviewed: boolean;
}>(`/games/${shop}/${objectId}/reviews/check`, {
needsAuth: true,
});
const hasReviewed = response?.hasReviewed || false;
onUserReviewedChange(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);
}
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
setReviewsLoading(true);
try {
const skip = reset ? 0 : reviewsPage * 20;
const params = new URLSearchParams({
take: "20",
skip: skip.toString(),
sortBy: reviewsSortBy,
});
const response = await window.electron.hydraApi.get(
`/games/${shop}/${objectId}/reviews?${params.toString()}`,
{ needsAuth: false }
);
if (abortController.signal.aborted) {
return;
}
const typedResponse = response as unknown as
| { reviews: GameReview[]; totalCount: number }
| undefined;
const reviewsData = typedResponse?.reviews || [];
const reviewCount = typedResponse?.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,
voteType: "upvote" | "downvote"
) => {
if (!objectId || votingReviews.has(reviewId)) return;
setVotingReviews((prev) => new Set(prev).add(reviewId));
const reviewIndex = reviews.findIndex((r) => r.id === reviewId);
if (reviewIndex === -1) {
setVotingReviews((prev) => {
const next = new Set(prev);
next.delete(reviewId);
return next;
});
return;
}
const review = reviews[reviewIndex];
const originalReview = { ...review };
const updatedReviews = [...reviews];
const updatedReview = { ...review };
if (voteType === "upvote") {
if (review.hasUpvoted) {
updatedReview.hasUpvoted = false;
updatedReview.upvotes = Math.max(0, (review.upvotes || 0) - 1);
} else {
updatedReview.hasUpvoted = true;
updatedReview.upvotes = (review.upvotes || 0) + 1;
if (review.hasDownvoted) {
updatedReview.hasDownvoted = false;
updatedReview.downvotes = Math.max(0, (review.downvotes || 0) - 1);
}
}
} else {
if (review.hasDownvoted) {
updatedReview.hasDownvoted = false;
updatedReview.downvotes = Math.max(0, (review.downvotes || 0) - 1);
} else {
updatedReview.hasDownvoted = true;
updatedReview.downvotes = (review.downvotes || 0) + 1;
if (review.hasUpvoted) {
updatedReview.hasUpvoted = false;
updatedReview.upvotes = Math.max(0, (review.upvotes || 0) - 1);
}
}
}
updatedReviews[reviewIndex] = updatedReview;
setReviews(updatedReviews);
try {
await window.electron.hydraApi.put(
`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
{ data: {} }
);
} catch (error) {
console.error(`Failed to ${voteType} review:`, error);
const rolledBackReviews = [...reviews];
rolledBackReviews[reviewIndex] = originalReview;
setReviews(rolledBackReviews);
showErrorToast(t("vote_failed"));
} finally {
setTimeout(() => {
setVotingReviews((prev) => {
const next = new Set(prev);
next.delete(reviewId);
return next;
});
}, 500);
}
};
const handleDeleteReview = async (reviewId: string) => {
if (!objectId) return;
try {
await window.electron.hydraApi.delete(
`/games/${shop}/${objectId}/reviews/${reviewId}`
);
loadReviews(true);
onUserReviewedChange(false);
setShowReviewForm(true);
showSuccessToast(t("review_deleted_successfully"));
} catch (error) {
console.error("Failed to delete review:", error);
showErrorToast(t("review_deletion_failed"));
}
};
const handleSubmitReview = async () => {
const reviewHtml = editor?.getHTML() || "";
const reviewText = editor?.getText() || "";
if (!objectId) return;
if (!reviewText.trim()) {
showErrorToast(t("review_cannot_be_empty"));
return;
}
if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) {
return;
}
if (reviewScore === null) {
return;
}
setSubmittingReview(true);
try {
await window.electron.hydraApi.post(
`/games/${shop}/${objectId}/reviews`,
{
data: {
reviewHtml,
score: reviewScore,
},
}
);
editor?.commands.clearContent();
setReviewScore(null);
showSuccessToast(t("review_submitted_successfully"));
await loadReviews(true);
setShowReviewForm(false);
setShowReviewPrompt(false);
onUserReviewedChange(true);
} catch (error) {
console.error("Failed to submit review:", error);
showErrorToast(t("review_submission_failed"));
} finally {
setSubmittingReview(false);
}
};
const handleReviewPromptYes = () => {
setShowReviewPrompt(false);
setTimeout(() => {
const reviewFormElement = document.querySelector(
".game-details__review-form"
);
if (reviewFormElement) {
reviewFormElement.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 100);
};
const handleReviewPromptLater = () => {
setShowReviewPrompt(false);
setShowReviewForm(false);
if (objectId) {
sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true");
}
};
const handleSortChange = (newSortBy: ReviewSortOption) => {
if (newSortBy !== reviewsSortBy) {
setReviewsSortBy(newSortBy);
setReviewsPage(0);
setHasMoreReviews(true);
loadReviews(true);
}
};
const toggleBlockedReview = (reviewId: string) => {
setVisibleBlockedReviews((prev) => {
const newSet = new Set(prev);
if (newSet.has(reviewId)) {
newSet.delete(reviewId);
} else {
newSet.add(reviewId);
}
return newSet;
});
};
const loadMoreReviews = () => {
if (!reviewsLoading && hasMoreReviews) {
setReviewsPage((prev) => prev + 1);
loadReviews(false);
}
};
const handleVoteAnimationComplete = (
reviewId: string,
votes: { upvotes: number; downvotes: number }
) => {
previousVotesRef.current.set(reviewId, votes);
};
useEffect(() => {
if (objectId) {
loadReviews(true);
if (userDetailsId) {
checkUserReview();
}
}
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, [objectId, userDetailsId, checkUserReview, loadReviews]);
useEffect(() => {
if (reviewsPage > 0) {
loadReviews(false);
}
}, [reviewsPage, loadReviews]);
useEffect(() => {
reviews.forEach((review) => {
if (!previousVotesRef.current.has(review.id)) {
previousVotesRef.current.set(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}
});
}, [reviews]);
return (
<div className="game-details__reviews-section">
{showReviewPrompt &&
userDetailsId &&
!hasUserReviewed &&
isGameInLibrary && (
<ReviewPromptBanner
onYesClick={handleReviewPromptYes}
onLaterClick={handleReviewPromptLater}
/>
)}
{showReviewForm && (
<>
<ReviewForm
editor={editor}
reviewScore={reviewScore}
reviewCharCount={reviewCharCount}
maxReviewChars={MAX_REVIEW_CHARS}
submittingReview={submittingReview}
onScoreChange={setReviewScore}
onSubmit={handleSubmitReview}
/>
<div className="game-details__reviews-separator"></div>
</>
)}
<div className="game-details__reviews-list">
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">{t("reviews")}</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
</div>
<ReviewSortOptions
sortBy={reviewsSortBy}
onSortChange={handleSortChange}
/>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">
<NoteIcon size={48} />
</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</div>
)}
<div
style={{
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
transition: "opacity 0.2s ease",
}}
>
{reviews.map((review) => (
<ReviewItem
key={review.id}
review={review}
userDetailsId={userDetailsId}
isBlocked={review.isBlocked}
isVisible={visibleBlockedReviews.has(review.id)}
isVoting={votingReviews.has(review.id)}
previousVotes={
previousVotesRef.current.get(review.id) || {
upvotes: 0,
downvotes: 0,
}
}
onVote={handleVoteReview}
onDelete={handleDeleteReview}
onToggleVisibility={toggleBlockedReview}
onAnimationComplete={handleVoteAnimationComplete}
/>
))}
</div>
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
</div>
);
}

View File

@@ -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

View File

@@ -0,0 +1,149 @@
import { Star } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EditorContent, Editor } from "@tiptap/react";
import { Button } from "@renderer/components";
interface ReviewFormProps {
editor: Editor | null;
reviewScore: number | null;
reviewCharCount: number;
maxReviewChars: number;
submittingReview: boolean;
onScoreChange: (score: number) => void;
onSubmit: () => void;
}
const getSelectScoreColorClass = (score: number): string => {
if (score >= 1 && score <= 2) return "game-details__review-score-select--red";
if (score >= 3 && score <= 3)
return "game-details__review-score-select--yellow";
if (score >= 4 && score <= 5)
return "game-details__review-score-select--green";
return "";
};
const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) {
case 1:
return t("rating_very_negative");
case 2:
return t("rating_negative");
case 3:
return t("rating_neutral");
case 4:
return t("rating_positive");
case 5:
return t("rating_very_positive");
default:
return "";
}
};
export function ReviewForm({
editor,
reviewScore,
reviewCharCount,
maxReviewChars,
submittingReview,
onScoreChange,
onSubmit,
}: Readonly<ReviewFormProps>) {
const { t } = useTranslation("game_details");
return (
<>
<div className="game-details__reviews-header">
<h3 className="game-details__reviews-title">{t("leave_a_review")}</h3>
</div>
<div className="game-details__review-form">
<div className="game-details__review-input-container">
<div className="game-details__review-input-header">
<div className="game-details__review-editor-toolbar">
<button
type="button"
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
disabled={!editor}
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
disabled={!editor}
>
<em>I</em>
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleUnderline().run()}
className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
disabled={!editor}
>
<u>U</u>
</button>
</div>
<div className="game-details__review-char-counter">
<span
className={reviewCharCount > maxReviewChars ? "over-limit" : ""}
>
{reviewCharCount}/{maxReviewChars}
</span>
</div>
</div>
<div className="game-details__review-input">
<EditorContent editor={editor} />
</div>
</div>
<div className="game-details__review-form-bottom">
<div className="game-details__review-score-container">
<div className="game-details__star-rating">
{[1, 2, 3, 4, 5].map((starValue) => (
<button
key={starValue}
type="button"
className={`game-details__star ${
reviewScore && starValue <= reviewScore
? "game-details__star--filled"
: "game-details__star--empty"
} ${
reviewScore && starValue <= reviewScore
? getSelectScoreColorClass(reviewScore)
: ""
}`}
onClick={() => onScoreChange(starValue)}
title={getRatingText(starValue, t)}
>
<Star
size={18}
fill={
reviewScore && starValue <= reviewScore
? "currentColor"
: "none"
}
/>
</button>
))}
</div>
</div>
<Button
theme="primary"
onClick={onSubmit}
disabled={
!editor?.getHTML().trim() ||
reviewScore === null ||
submittingReview ||
reviewCharCount > maxReviewChars
}
>
{submittingReview ? t("submitting") : t("submit_review")}
</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,264 @@
import { TrashIcon, ClockIcon } from "@primer/octicons-react";
import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared";
import { useDate } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
interface ReviewItemProps {
review: GameReview;
userDetailsId?: string;
isBlocked: boolean;
isVisible: boolean;
isVoting: boolean;
previousVotes: { upvotes: number; downvotes: number };
onVote: (reviewId: string, voteType: "upvote" | "downvote") => void;
onDelete: (reviewId: string) => void;
onToggleVisibility: (reviewId: string) => void;
onAnimationComplete: (
reviewId: string,
votes: { upvotes: number; downvotes: number }
) => void;
}
const getScoreColorClass = (score: number): string => {
if (score >= 1 && score <= 2) return "game-details__review-score--red";
if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
if (score >= 4 && score <= 5) return "game-details__review-score--green";
return "";
};
const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) {
case 1:
return t("rating_very_negative");
case 2:
return t("rating_negative");
case 3:
return t("rating_neutral");
case 4:
return t("rating_positive");
case 5:
return t("rating_very_positive");
default:
return "";
}
};
export function ReviewItem({
review,
userDetailsId,
isBlocked,
isVisible,
isVoting,
previousVotes,
onVote,
onDelete,
onToggleVisibility,
onAnimationComplete,
}: Readonly<ReviewItemProps>) {
const navigate = useNavigate();
const { t } = useTranslation("game_details");
const { formatDistance } = useDate();
if (isBlocked && !isVisible) {
return (
<div className="game-details__review-item">
<div className="game-details__blocked-review-simple">
Review from blocked user {" "}
<button
className="game-details__blocked-review-show-link"
onClick={() => onToggleVisibility(review.id)}
>
Show
</button>
</div>
</div>
);
}
return (
<div className="game-details__review-item">
<div className="game-details__review-header">
<div className="game-details__review-user">
<button
onClick={() => navigate(`/profile/${review.user.id}`)}
title={review.user.displayName}
>
<Avatar
src={review.user.profileImageUrl}
alt={review.user.displayName || "User"}
size={40}
/>
</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-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={() => onVote(review.id, "upvote")}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
animate={
review.hasUpvoted
? {
scale: [1, 1.2, 1],
transition: { duration: 0.3 },
}
: {}
}
>
<ThumbsUp size={16} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes || 0}
custom={(review.upvotes || 0) > previousVotes.upvotes}
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={() => {
onAnimationComplete(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={() => onVote(review.id, "downvote")}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
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) > previousVotes.downvotes}
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={() => {
onAnimationComplete(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}}
>
{formatNumber(review.downvotes || 0)}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
{userDetailsId === review.user.id && (
<button
className="game-details__delete-review-button"
onClick={() => onDelete(review.id)}
title={t("delete_review")}
>
<TrashIcon size={16} />
<span>{t("remove_review")}</span>
</button>
)}
{isBlocked && isVisible && (
<button
className="game-details__blocked-review-hide-link"
onClick={() => onToggleVisibility(review.id)}
>
Hide
</button>
)}
</div>
</div>
);
}

View File

@@ -91,10 +91,11 @@ export function Sidebar() {
});
} else {
try {
const howLongToBeat = await window.electron.getHowLongToBeat(
objectId,
shop
);
const howLongToBeat = await window.electron.hydraApi.get<
HowLongToBeatCategory[] | null
>(`/games/${shop}/${objectId}/how-long-to-beat`, {
needsAuth: false,
});
if (howLongToBeat) {
howLongToBeatEntriesTable.add({

View File

@@ -40,7 +40,15 @@ export default function Home() {
setCurrentCatalogueCategory(category);
setIsLoading(true);
const catalogue = await window.electron.getCatalogue(category);
const params = new URLSearchParams({
take: "12",
skip: "0",
});
const catalogue = await window.electron.hydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`,
{ needsAuth: false }
);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
} finally {

View File

@@ -54,8 +54,13 @@ export function ReportProfile() {
const onSubmit = useCallback(
async (values: FormValues) => {
return window.electron
.reportUser(userProfile!.id, values.reason, values.description)
return window.electron.hydraApi
.post(`/users/${userProfile!.id}/report`, {
data: {
reason: values.reason,
description: values.description,
},
})
.then(() => {
showSuccessToast(t("profile_reported"));
setShowReportProfileModal(false);

View File

@@ -9,6 +9,7 @@ import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { useCallback } from "react";
import { generateUUID } from "@renderer/helpers";
import "./modals.scss";
@@ -79,7 +80,7 @@ export function AddThemeModal({
const onSubmit = useCallback(
async (values: FormValues) => {
const theme: Theme = {
id: crypto.randomUUID(),
id: generateUUID(),
name: values.name,
isActive: false,
author: userDetails?.id,

View File

@@ -3,7 +3,11 @@ import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { Theme } from "@types";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import {
injectCustomCss,
removeCustomCss,
generateUUID,
} from "@renderer/helpers";
import { useToast } from "@renderer/hooks";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
import { logger } from "@renderer/logger";
@@ -30,7 +34,7 @@ export const ImportThemeModal = ({
const handleImportTheme = async () => {
const theme: Theme = {
id: crypto.randomUUID(),
id: generateUUID(),
name: themeName,
isActive: false,
author: authorId,

View File

@@ -23,6 +23,7 @@ import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
import { useNavigate } from "react-router-dom";
import { setFilters, clearFilters } from "@renderer/features";
import { generateUUID } from "@renderer/helpers";
import "./settings-download-sources.scss";
export function SettingsDownloadSources() {
@@ -95,7 +96,7 @@ export function SettingsDownloadSources() {
const handleRemoveAllDownloadSources = () => {
setIsRemovingDownloadSource(true);
const id = crypto.randomUUID();
const id = generateUUID();
const channel = new BroadcastChannel(`download_sources:delete_all:${id}`);
downloadSourcesWorker.postMessage(["DELETE_ALL_DOWNLOAD_SOURCES", id]);
@@ -120,7 +121,7 @@ export function SettingsDownloadSources() {
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
const id = crypto.randomUUID();
const id = generateUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);

View File

@@ -34,8 +34,13 @@ export const UserFriendModalList = ({
const loadNextPage = () => {
if (page > maxPage) return;
setIsLoading(true);
window.electron
.getUserFriends(userId, pageSize, page * pageSize)
const url = isMe ? "/profile/friends" : `/users/${userId}/friends`;
window.electron.hydraApi
.get<{ totalFriends: number; friends: UserFriend[] }>(url, {
params: { take: pageSize, skip: page * pageSize },
})
.then((newPage) => {
if (page === 0) {
setMaxPage(newPage.totalFriends / pageSize);

View File

@@ -10,6 +10,11 @@ export type HydraCloudFeature =
| "backup"
| "achievements-points";
export interface DiskUsage {
free: number;
total: number;
}
export interface GameRepack {
id: number;
title: string;
@@ -254,7 +259,7 @@ export interface GameReview {
id: string;
displayName: string;
profileImageUrl: string | null;
} | null;
};
}
export interface TrendingGame extends ShopAssets {

1197
yarn.lock

File diff suppressed because it is too large Load Diff