Compare commits

...

186 Commits

Author SHA1 Message Date
Zamitto
20c0d3174b test 2025-11-12 14:37:44 -03:00
Zamitto
cd3fa10bf7 chore: fix version code
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-11 18:27:53 -03:00
Zamitto
a57cc83076 Merge branch 'release/v3.7.5' 2025-11-11 18:17:53 -03:00
Zamitto
c75a6ad439 fix: using achievement count data from api 2025-11-11 18:15:26 -03:00
Moyase
05d68fa23b Merge pull request #1856 from hydralauncher/fix/library-game-card
fix: custom assets not being showed in library page
2025-11-11 22:10:24 +02:00
Moyasee
527a65e9bc feat: remembering the view user left the library and restoring it on opening library again 2025-11-11 22:07:42 +02:00
Moyasee
fe6bb5763d fix: deleting game from context menu doesnt work in library 2025-11-11 22:03:33 +02:00
Moyasee
002dff098c fix: custom assets not being showed in library page 2025-11-11 21:50:48 +02:00
Chubby Granny Chaser
436d1b74be ci: fixing format
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-11 18:35:18 +00:00
Chubby Granny Chaser
b89de065fe ci: fixing format 2025-11-11 18:33:28 +00:00
Chubby Granny Chaser
7fcdab07cb Merge pull request #1842 from hydralauncher/feat/displaying-new-game-update
Feat: displaying new game update
2025-11-11 18:23:42 +00:00
Chubby Granny Chaser
aebf6d1cae feat: adding translations for new label 2025-11-11 18:22:39 +00:00
Moyase
a2148dd1ef Merge branch 'main' into feat/displaying-new-game-update 2025-11-11 20:02:32 +02:00
Moyasee
8dc5be1bdf reverting changes 2025-11-11 20:01:28 +02:00
Moyasee
133168c6c7 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-11 16:32:37 +02:00
Moyasee
d59b96f446 fix: typescript error 2025-11-11 16:31:53 +02:00
Moyasee
a1eef4eab6 feat: check updates for installed games 2025-11-11 16:30:23 +02:00
Chubby Granny Chaser
25103e5eb7 ci: updating ci
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-11 09:09:03 +00:00
Moyase
9cf0ef4b62 Merge branch 'main' into feat/displaying-new-game-update 2025-11-11 01:32:50 +02:00
Moyasee
1521d7c058 reverting changes 2025-11-11 01:29:04 +02:00
Moyasee
14eb0f8172 reverting changes 2025-11-11 01:27:24 +02:00
Moyasee
860030a510 fix: merging conflict 2025-11-11 01:23:37 +02:00
Moyasee
f0e4d241f9 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-11 01:15:13 +02:00
Moyasee
44b24ab63d feat: checking updates only for games with executables 2025-11-11 01:14:27 +02:00
Chubby Granny Chaser
7c1adb70ea fix: fixing lint 2025-11-10 23:07:52 +00:00
Chubby Granny Chaser
9854ed2f53 Merge pull request #1852 from hydralauncher/feat/custom-achievement-sound
Feat: custom achievement sound and volume changing
2025-11-10 22:59:34 +00:00
Chubby Granny Chaser
b8647a3300 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/custom-achievement-sound 2025-11-10 22:57:59 +00:00
Chubby Granny Chaser
95894484f1 feat: adding slider to achievement sound 2025-11-10 22:57:35 +00:00
Chubby Granny Chaser
6fc5a70722 feat: adding slider to achievement sound 2025-11-10 22:55:49 +00:00
Chubby Granny Chaser
399669a94c feat: adding slider to achievement sound 2025-11-10 22:55:17 +00:00
Chubby Granny Chaser
77b2fc3946 Merge pull request #1848 from hydralauncher/fix/library-ui
feat: library ui changes and searchbar removal
2025-11-10 22:55:02 +00:00
Chubby Granny Chaser
3472e90858 Merge branch 'main' into fix/library-ui 2025-11-10 22:53:52 +00:00
Chubby Granny Chaser
d80daa59d0 Merge branch 'main' into feat/custom-achievement-sound 2025-11-10 22:21:51 +00:00
Chubby Granny Chaser
46df34e8a5 feat: improving library 2025-11-10 22:20:44 +00:00
Chubby Granny Chaser
272b047ded Merge pull request #1844 from expload233/main
Add new translation for Chinese
2025-11-10 21:51:44 +00:00
Chubby Granny Chaser
dfba38aeed Merge branch 'main' into main 2025-11-10 21:51:20 +00:00
Moyasee
d54ff9a949 fix: eslint issues 2025-11-09 15:34:24 +02:00
Moyasee
e272470a7b feat: using theme name for folder instead themeid 2025-11-09 15:28:52 +02:00
Moyasee
53bc3551e1 Merge branch 'feat/custom-achievement-sound' of https://github.com/hydralauncher/hydra into feat/custom-achievement-sound 2025-11-09 04:20:48 +02:00
Moyasee
3daf28c882 fix: handling exception and ESLint issues 2025-11-09 04:19:52 +02:00
Moyase
e128dad4dd Merge branch 'main' into feat/custom-achievement-sound 2025-11-09 04:14:31 +02:00
Moyasee
65e2bb38a0 ci: formatting 2025-11-08 19:26:13 +02:00
Moyasee
5f09321728 Merge branch 'fix/library-ui' of https://github.com/hydralauncher/hydra into fix/library-ui 2025-11-08 19:25:29 +02:00
Moyasee
011559b499 fix: removed VirtualList component from large view 2025-11-08 19:24:43 +02:00
Moyasee
482d9b2f96 fix: ensure consistent custom sound detection across main and renderer processes 2025-11-08 15:14:12 +02:00
Moyase
c890b0fd56 Merge branch 'main' into fix/library-ui 2025-11-08 14:52:35 +02:00
Moyasee
cf48627a8d fix: extracter ternary operation 2025-11-08 14:51:10 +02:00
Moyasee
196413ee28 fix: duplicated lines 2025-11-08 14:48:42 +02:00
Moyasee
c3a4990a50 ci: performance optimizing in library 2025-11-08 14:28:54 +02:00
Chubby Granny Chaser
fa4c11e458 Merge pull request #1849 from hydralauncher/fix/surprise-me-button
Fix: surprise me button functionality
2025-11-08 08:18:13 +00:00
Chubby Granny Chaser
50b0a82204 feat: improving styles on randomizer button 2025-11-08 08:17:19 +00:00
Moyasee
b6bbf05da6 fix: theme editor layout positioning 2025-11-07 20:12:50 +02:00
Moyasee
154b6271a1 fix: removed unused function 2025-11-07 17:50:47 +02:00
Moyasee
a6cbaf6dc1 feat: custom achievement sound and volume changing) 2025-11-07 17:48:56 +02:00
Moyasee
6e6e0f7bb7 fix: duplicate next suggestion styling removal 2025-11-07 13:35:50 +02:00
Moyasee
893802be55 fix: next suggestion and title not being showed 2025-11-07 13:27:24 +02:00
Moyasee
3bef0c9269 feat: library ui changes and searchbar removal 2025-11-06 18:26:56 +02:00
Moyase
754e9c14b8 Merge pull request #1821 from iam-sahil/feat/library
Feat/library
2025-11-06 17:22:58 +02:00
ctrlcat0x
5e653be4c3 fix: add error logging in handleActionClick for better debugging 2025-11-06 19:11:20 +05:30
ctrlcat0x
cedf7e6e37 style: improve color contrast in various components and update prop types to readonly 2025-11-06 19:03:23 +05:30
Moyase
518a0e1cf4 Merge branch 'main' into feat/library 2025-11-06 14:57:22 +02:00
expload233
5f56a3d517 add translation 2025-11-04 15:47:44 +08:00
expload
2359c4cc5e Merge branch 'hydralauncher:main' into main 2025-11-04 15:31:03 +08:00
expload233
66bb5221c1 fix lint 2025-11-04 15:24:42 +08:00
Chubby Granny Chaser
7fa50dc5a7 feat: adding ws client back 2025-11-03 12:02:52 +00:00
expload233
088feaffc2 add missing translation for zh-CN 2025-11-03 19:02:11 +08:00
expload233
aa6b595b18 Fill in the missing entries 2025-11-03 18:53:02 +08:00
Chubby Granny Chaser
f49fea3032 ci: bump version
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-02 21:17:34 +00:00
Chubby Granny Chaser
595d39986d Merge pull request #1843 from hydralauncher/fix/fixing-datanodes
fix: fixing datanodes
2025-11-02 21:15:53 +00:00
Chubby Granny Chaser
ac01930d68 Merge branch 'main' into fix/fixing-datanodes 2025-11-02 21:14:49 +00:00
Chubby Granny Chaser
37caeb8047 fix: fixing datanodes 2025-11-02 21:13:44 +00:00
Chubby Granny Chaser
7d6eddb17e Merge pull request #1826 from hydralauncher/feat/reviews-in-profile
Feat: Showing User Reviews in profile
2025-11-02 20:46:47 +00:00
Chubby Granny Chaser
20338fa20b Merge branch 'main' into feat/displaying-new-game-update 2025-11-02 20:45:56 +00:00
Chubby Granny Chaser
48775e57fc feat: adding reviews to profile 2025-11-02 20:43:59 +00:00
Chubby Granny Chaser
fdc3fecd6f feat: adding reviews to profile 2025-11-02 20:42:42 +00:00
Chubby Granny Chaser
f0dc7478cf feat: adding reviews to profile 2025-11-02 20:29:16 +00:00
Chubby Granny Chaser
e7a437e839 Merge branch 'main' into feat/library 2025-11-02 20:23:44 +00:00
Chubby Granny Chaser
2e8da53d1a feat: adding infinite scroll 2025-11-02 20:23:12 +00:00
Chubby Granny Chaser
8794fbc742 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/reviews-in-profile 2025-11-02 17:31:06 +00:00
Chubby Granny Chaser
bf387aef3f feat: improving animations 2025-11-02 17:30:45 +00:00
Chubby Granny Chaser
c2a26b9750 Merge pull request #1832 from hydralauncher/feat/playtime-in-reviews
Feat: Playtime showing in review message
2025-11-02 17:29:28 +00:00
Moyase
3dc2a29114 Merge branch 'main' into feat/playtime-in-reviews 2025-11-02 19:25:31 +02:00
Chubby Granny Chaser
6ebf7766aa Merge branch 'main' into feat/reviews-in-profile 2025-11-02 17:04:58 +00:00
Moyasee
b578af4612 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-02 18:48:13 +02:00
Moyasee
6f6b7d49ac fix: removed void and converted conditional to boolean 2025-11-02 18:47:26 +02:00
Moyase
5c445f8a90 Merge branch 'main' into feat/displaying-new-game-update 2025-11-02 18:41:01 +02:00
Moyasee
87d35da9fc fix: deleted comments 2025-11-02 18:24:10 +02:00
Moyasee
5067cf163e feat: added new badge to repacks-modal, set up badge clearing 2025-11-02 18:22:37 +02:00
Moyasee
efab242c74 ci: showing new badge in repack-modal 2025-10-31 23:17:06 +02:00
Zamitto
19bf99ff11 chore: add sleep to aur script 2025-10-31 16:16:03 -03:00
Zamitto
9c00a17193 Merge branch 'release/v3.7.2' 2025-10-31 13:58:14 -03:00
Zamitto
d167628ed4 fix: prevent crash when detectedLanguage is null
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-31 13:57:15 -03:00
Zamitto
59cfce86ae Merge pull request #1841 from JarEXE/fix/achievement-notification-position
Fix: [Linux] achievement notification positioning on multi-monitor setups
2025-10-31 13:23:41 -03:00
Zamitto
51c4e4f5b3 chore: bump version 2025-10-31 13:07:06 -03:00
jarexe
138120460c fix: correct achievement notification positioning on multi-monitor setups 2025-10-31 10:57:44 -03:00
Zamitto
c71f5947ba feat: use new ep to track game playtime 2025-10-31 10:20:11 -03:00
Chubby Granny Chaser
ff8a61ff7a fix: fixing review partial 2025-10-31 12:05:24 +00:00
Chubby Granny Chaser
d1d46971b6 fix: fixing review partial 2025-10-31 12:03:35 +00:00
Chubby Granny Chaser
b8af69b0fb fix: fixing review partial 2025-10-31 12:01:42 +00:00
Zamitto
1af69465c1 Merge pull request #1839 from hydralauncher/fix/custom-games-requests
fix: requests for custom games
2025-10-31 07:08:51 -03:00
Chubby Granny Chaser
f6c12c22b5 Merge branch 'main' into feat/reviews-in-profile 2025-10-31 07:29:22 +00:00
Chubby Granny Chaser
539010d817 Merge branch 'main' into feat/playtime-in-reviews 2025-10-31 07:29:13 +00:00
Zamitto
aa148c0b70 fix: trim
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-30 20:01:47 -03:00
Moyase
a83a96f214 Merge pull request #1840 from hydralauncher/feat/catalogue-manual-pagination
fix: removed ability to enter non-number symbols to pagination
2025-10-31 00:46:39 +02:00
Moyasee
aadbda770b fix: linting issue, marked props as read-only 2025-10-31 00:19:49 +02:00
Moyasee
bd059cc7fa feat: update cursorrules 2025-10-30 23:45:29 +02:00
Moyasee
bbbf861594 fix: deleted comments 2025-10-30 23:36:41 +02:00
Moyasee
80e0adcd49 fix: removed ability to enter non-number symbols to pagination 2025-10-30 23:33:07 +02:00
Moyasee
4dd3c9de76 fix: formatting 2025-10-30 23:26:22 +02:00
Moyasee
101bc35460 feat: sidebar badge on new game download option 2025-10-30 23:21:31 +02:00
Zamitto
2aa31c0db0 feat: limit game text search to 255 chars 2025-10-30 15:34:49 -03:00
Zamitto
4bfe6d7f86 feat: limit game text search to 255 chars 2025-10-30 15:32:08 -03:00
Zamitto
aadf648a2b chore: unnecessary casting 2025-10-30 07:58:43 -03:00
Zamitto
87dbd548d0 Merge branch 'release/v3.7.2' into fix/custom-games-requests 2025-10-30 07:58:31 -03:00
Zamitto
459bf73121 fix: request download-sources on custom game
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-30 07:36:23 -03:00
Zamitto
a2ef0f304d fix: playtime count and custom games request on process watcher 2025-10-30 07:35:49 -03:00
Zamitto
b04561986e Merge pull request #1838 from hydralauncher/main
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
sync release 3.7.2
2025-10-29 23:07:16 -03:00
Zamitto
1bd88e6c6e Merge pull request #1837 from Stormm232/main
Updating Hungarian Translation
2025-10-29 23:06:22 -03:00
Kiwo.2
4ff8dc4fa7 Fix with Prettier 2025-10-30 02:32:18 +01:00
Kiwo.2
dcc671f999 Mistake Correction 2025-10-30 02:15:35 +01:00
Kiwo.2
6e76111e23 Missing Comma Fix 2025-10-30 02:10:02 +01:00
Kiwo.2
3fce26f1f7 Update to 3.7.2 2025-10-30 01:55:15 +01:00
Kiwo.2
90c5ccb796 Update to 3.7.2 2025-10-30 01:40:36 +01:00
Kiwo.2
41092c2dd4 Update to 3.7.2 2025-10-30 01:35:54 +01:00
Kiwo.2
6383b728bc Fix to the translation 2025-10-30 01:26:51 +01:00
Kiwo.2
4dd28bbbf1 Hungarian Translation 3.7.2 2025-10-30 01:12:29 +01:00
Zamitto
21074322fa Merge pull request #1836 from Wkeynhk/patch-6
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
Update Russian Translation
2025-10-29 20:32:30 -03:00
Wkeynhk
0e7e53478a Update translation.json 2025-10-30 00:47:30 +03:00
Zamitto
65e49550ad chore: fix aur package 2025-10-29 18:10:27 -03:00
Zamitto
0990951183 chore: fix aur package 2025-10-29 18:06:46 -03:00
Zamitto
53c162f0e4 feat: add i18n 2025-10-29 17:55:55 -03:00
Zamitto
2fb44a6c0e chore: remove build renderer trigger on main 2025-10-29 15:49:43 -03:00
Zamitto
49c2bc34d1 Merge branch 'release/v3.7.2' 2025-10-29 15:48:44 -03:00
Zamitto
ef52d710ed Merge branch 'main' into feat/reviews-in-profile 2025-10-29 15:28:39 -03:00
Zamitto
31d57a784e Merge branch 'main' into feat/playtime-in-reviews 2025-10-29 15:28:37 -03:00
Zamitto
49df40650c chore: prettier 2025-10-29 15:27:36 -03:00
Chubby Granny Chaser
499a830e3e chore: sync with main 2025-10-29 18:23:06 +00:00
Chubby Granny Chaser
437b0a3b19 Merge branch 'main' of https://github.com/hydralauncher/hydra into release/v3.7.2 2025-10-29 18:21:39 +00:00
Chubby Granny Chaser
d59ff5c484 Merge pull request #1814 from whintersnow0/refactor/remove-unnecessary-usememo
refactor: remove unnecessary useMemo hooks
2025-10-29 18:20:21 +00:00
Chubby Granny Chaser
dcf13a5920 Merge branch 'main' into refactor/remove-unnecessary-usememo 2025-10-29 18:19:39 +00:00
Chubby Granny Chaser
51861752a1 Merge pull request #1816 from hydralauncher/feat/improving-sources
feat: moving sources to worker
2025-10-29 18:19:35 +00:00
Chubby Granny Chaser
2fce12eba7 Merge branch 'main' into feat/reviews-in-profile 2025-10-29 16:55:36 +00:00
Chubby Granny Chaser
1427775c98 Merge branch 'main' into feat/playtime-in-reviews 2025-10-29 16:55:26 +00:00
Chubby Granny Chaser
e143fadf38 fix: fixing import
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-10-29 02:55:37 +00:00
Chubby Granny Chaser
f99f8d9554 feat: forcing dev tools 2025-10-29 02:32:45 +00:00
Chubby Granny Chaser
274080069f feat: forcing dev tools 2025-10-29 02:12:17 +00:00
Chubby Granny Chaser
b1069426e4 chore: sync with main 2025-10-29 01:47:35 +00:00
Chubby Granny Chaser
dc6d578462 chore: sync with main 2025-10-28 23:49:09 +00:00
Chubby Granny Chaser
8a12c6e088 chore: sync with main 2025-10-28 23:26:28 +00:00
Chubby Granny Chaser
b795cea599 Merge branch 'main' of https://github.com/hydralauncher/hydra into release/v3.7.2 2025-10-28 23:20:44 +00:00
Chubby Granny Chaser
dc8a19e845 ci: adding ci vars 2025-10-28 23:02:40 +00:00
Chubby Granny Chaser
ce0619bbe3 ci: adding releases 2025-10-28 22:40:06 +00:00
Moyasee
5c770bc7e7 fix: unnecessary assertion 2025-10-27 20:12:24 +02:00
Moyasee
b431ed479c fix: converted conditional to boolean 2025-10-27 20:07:08 +02:00
Moyasee
9e09a5decb fix: translation key fix and formatting 2025-10-27 19:28:29 +02:00
Moyasee
1e1a1c61c9 feat: showing playtime in review and changed positions of elements in review 2025-10-27 19:22:59 +02:00
Moyasee
8de6c92d28 ci: formatting 2025-10-24 08:19:55 +03:00
Moyasee
29e1713824 fix: upvote/downvote button arent being disabled after click 2025-10-23 20:06:37 +03:00
Moyasee
81a77411cc ci: fix gap between game image and game name in reviews 2025-10-23 16:54:18 +03:00
Moyasee
cc95deb709 fix: proreply reseting user reviews on profile changing 2025-10-23 14:40:02 +03:00
Moyasee
daf9751cf6 ci: import formatting 2025-10-23 14:27:03 +03:00
Moyasee
d21ec52814 ci: deleted comments 2025-10-23 12:06:23 +03:00
Moyasee
f539977431 fix: refactoring functions to prevent nesting more than 4 lvls 2025-10-23 11:53:35 +03:00
Moyasee
3ff20417d5 fix: extracted ternary operation 2025-10-23 11:37:50 +03:00
Moyasee
65f83399f5 ci: merge 2025-10-23 11:29:27 +03:00
Moyasee
eb34f051e1 Merge branch 'feat/reviews-in-profile' of https://github.com/hydralauncher/hydra into feat/reviews-in-profile 2025-10-23 11:28:40 +03:00
Moyasee
ab27f3295e fix: duplicate selectors and if statements should not be the only statement 2025-10-23 11:26:29 +03:00
Chubby Granny Chaser
3782f79100 Merge branch 'main' into feat/reviews-in-profile 2025-10-23 09:15:57 +01:00
Moyasee
86ab5b107b ci: formatting 2025-10-23 10:34:15 +03:00
Moyasee
acf8f340dd ci: review message ui change and fix loading reviews positioning 2025-10-23 10:33:29 +03:00
ctrlcat0x
f5470b29c0 style: adjust hover effects and dimensions for game cards; refine context menu actions 2025-10-23 10:58:31 +05:30
Moyasee
035f6e8d24 ci: formatting 2025-10-22 22:05:05 +03:00
Moyasee
362d6b634e feat: added reviews in profile and tabs 2025-10-22 21:13:05 +03:00
ctrlcat0x
a0a967aacd style: update compact view styles for game cards; adjust grid layout and add button order 2025-10-22 18:28:24 +05:30
ctrlcat0x
e19102ea66 style: update active state styles for filter and view options; adjust achievement progress bar styles 2025-10-22 16:12:12 +05:30
ctrlcat0x
107b61f663 style: update active state colors for filter and view options 2025-10-22 14:46:25 +05:30
ctrlcat0x
811a6ad955 refactor: remove unused imports and download logic from LibraryGameCard 2025-10-22 14:42:47 +05:30
ctrlcat0x
6fb8bbf744 he commit 2025-10-22 14:29:55 +05:30
ctrlcat0x
459017a4a6 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/game-library 2025-10-22 14:28:00 +05:30
Sahil Rana
d6ff8f670e Merge branch 'main' into feat/game-library 2025-10-22 14:26:09 +05:30
ctrlcat0x
33e0d50966 feat: add achievements tracking to game library
- Updated `get-library.ts` to include unlocked and total achievement counts for each game.
- Removed `library-game-card-detailed.tsx` and its associated styles as part of the refactor.
- Enhanced `library-game-card-large.tsx` to display achievements with progress bars.
- Modified `library-game-card.scss` and `library-game-card-large.scss` to style the achievements section.
- Introduced a new `search-bar` component for filtering the game library.
- Implemented fuzzy search functionality in the library view.
- Updated `view-options` to improve UI consistency.
- Added achievement-related properties to the `LibraryGame` type in `index.ts`.
- Created a new `copilot-instructions.md` for project guidelines.
2025-10-22 14:24:04 +05:30
Chubby Granny Chaser
73e378e26a Merge branch 'main' into refactor/remove-unnecessary-usememo 2025-10-21 21:11:37 +01:00
Sahil Rana
361073d3f8 Merge branch 'main' into feat/game-library 2025-10-20 23:51:13 +05:30
ctrlcat0x
d168e20385 feat(library): implement large game card and enhance library UI
- Added `LibraryGameCardLarge` component for displaying games in a larger format with improved styling and animations.
- Introduced SCSS styles for the large game card, including hover effects and gradient overlays.
- Updated `LibraryGameCard` component to support mouse enter and leave events for better interaction.
- Enhanced the library view options with new styles and functionality for switching between grid, compact, and large views.
- Improved overall layout and responsiveness of the library page, ensuring a better user experience across different screen sizes.
- Added tooltips for playtime information and context menus for game actions.
2025-10-20 23:43:47 +05:30
whintersnow0
3dc71a8d1f refactor: remove unnecessary useMemo hooks 2025-10-15 19:19:08 +02:00
Kiwo.2
8203399eda Matching to Latest Update 2025-10-12 23:27:20 +02:00
103 changed files with 5008 additions and 492 deletions

View File

@@ -27,3 +27,11 @@
- Follow TypeScript strict mode conventions
- Use async/await instead of promises when possible
- Prefer named exports over default exports for utilities and services
## Comments
- Keep comments concise and purposeful; avoid verbose explanations.
- Focus on the "why" or non-obvious context, not restating the code.
- Prefer self-explanatory naming and structure over excessive comments.
- Do not comment every line or obvious behavior; remove stale comments.
- Use docblocks only where they add value (public APIs, complex logic).

View File

@@ -3,3 +3,4 @@ MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=
MAIN_VITE_LAUNCHER_SUBDOMAIN=

View File

@@ -6,23 +6,37 @@ concurrency:
on:
push:
branches: [main]
branches:
- release/**
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
env:
NODE_OPTIONS: --max-old-space-size=4096
BRANCH_NAME: ${{ github.ref_name }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Node.js
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.21.0
cache: "yarn"
- name: Enable Corepack (Yarn)
run: corepack enable
- name: Install dependencies
run: yarn --frozen-lockfile --ignore-scripts
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build Renderer
run: yarn build
@@ -36,5 +50,5 @@ jobs:
run: |
npx --yes wrangler@3 pages deploy out/renderer \
--project-name="hydra" \
--commit-dirty=true \
--branch="main"
--branch "$BRANCH_NAME" \
--commit-dirty

View File

@@ -2,6 +2,9 @@ name: Build
on:
pull_request:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -6,7 +6,8 @@ concurrency:
on:
push:
branches: [main]
branches:
- release/**
jobs:
build:
@@ -61,7 +62,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 }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Build Windows
if: matrix.os == 'windows-2022'
@@ -78,7 +79,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 }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Create artifact
uses: actions/upload-artifact@v4

View File

@@ -95,9 +95,12 @@ jobs:
- name: Update PKGBUILD and .SRCINFO
if: steps.check-update.outputs.update_needed == 'true'
run: |
# sleeps for 1 minute to be sure GH updated the release info
sleep 60
# Update pkgver in PKGBUILD
cd hydra-launcher-bin
NEW_VERSION="${{ steps.get-version.outputs.version }}"
NEW_VERSION="${NEW_VERSION#v}"
echo "Updating PKGBUILD pkgver to $NEW_VERSION"
@@ -137,6 +140,9 @@ jobs:
COMMIT_MSG="v${{ steps.get-version.outputs.version }}"
git commit -m "$COMMIT_MSG"
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts"
git push origin master
echo "Successfully updated AUR package to version ${{ steps.get-version.outputs.version }}"
fi

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.1",
"version": "3.7.4",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -75,6 +75,7 @@
"react-dnd-html5-backend": "^16.0.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0",
"react-infinite-scroll-component": "^6.1.0",
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads",
"settings": "Settings",
"my_library": "My library",
@@ -92,8 +93,10 @@
},
"header": {
"search": "Search games",
"search_library": "Search library",
"home": "Home",
"catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads",
"search_results": "Search results",
"settings": "Settings",
@@ -194,6 +197,7 @@
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option",
"new_download_option": "New",
"create_steam_shortcut": "Create Steam shortcut",
"create_shortcut_success": "Shortcut created successfully",
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
@@ -223,6 +227,7 @@
"show_more": "Show more",
"show_less": "Show less",
"reviews": "Reviews",
"review_played_for": "Played for",
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
@@ -361,7 +366,10 @@
"show_original": "Show original",
"show_translation": "Show translation",
"show_original_translated_from": "Show original (translated from {{language}})",
"hide_original": "Hide original"
"hide_original": "Hide original",
"review_from_blocked_user": "Review from blocked user",
"show": "Show",
"hide": "Hide"
},
"activation": {
"title": "Activate Hydra",
@@ -551,6 +559,15 @@
"platinum": "Platinum",
"hidden": "Hidden",
"test_notification": "Test notification",
"achievement_sound_volume": "Achievement sound volume",
"select_achievement_sound": "Select achievement sound",
"change_achievement_sound": "Change achievement sound",
"remove_achievement_sound": "Remove achievement sound",
"preview_sound": "Preview sound",
"select": "Select",
"preview": "Preview",
"remove": "Remove",
"no_sound_file_selected": "No sound file selected",
"notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
@@ -689,7 +706,31 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews"
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews..."
},
"library": {
"library": "Library",
"play": "Play",
"download": "Download",
"downloading": "Downloading",
"game": "game",
"games": "games",
"grid_view": "Grid view",
"compact_view": "Compact view",
"large_view": "Large view",
"no_games_title": "Your library is empty",
"no_games_description": "Add games from the catalogue or download them to get started",
"amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"manual_playtime_tooltip": "This playtime has been manually updated",
"all_games": "All Games",
"recently_played": "Recently Played",
"favorites": "Favorites"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"settings": "Ajustes",
"my_library": "Mi Librería",
@@ -192,6 +193,7 @@
"download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción de descarga",
"new_download_option": "Nuevo",
"create_steam_shortcut": "Crear atajo de Steam",
"create_shortcut_success": "Atajo creado con éxito",
"you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
@@ -325,6 +327,7 @@
"maybe_later": "Tal vez después",
"no_repacks_found": "Sin fuentes encontradas para este juego",
"no_reviews_yet": "Sin reseñas aún",
"review_played_for": "Jugado por",
"properties": "Propiedades",
"rating": "Calificación",
"rating_count": "Calificación",
@@ -361,7 +364,10 @@
"you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego",
"language": "Idioma",
"caption": "Subtítulo",
"audio": "Audio"
"audio": "Audio",
"review_from_blocked_user": "Reseña de usuario bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
},
"activation": {
"title": "Activar Hydra",
@@ -538,10 +544,18 @@
"platinum": "Platino",
"hidden": "Oculto",
"test_notification": "Probar notificación",
"achievement_sound_volume": "Volumen del sonido de logro",
"select_achievement_sound": "Seleccionar sonido de logro",
"select": "Seleccionar",
"preview": "Vista previa",
"remove": "Remover",
"no_sound_file_selected": "No se seleccionó ningún archivo de sonido",
"notification_preview": "Probar notificación de logro",
"debrid": "Debrid",
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego"
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
},
"notifications": {
"download_complete": "Descarga completada",
@@ -676,7 +690,11 @@
"karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados"
"game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...",
"no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña"
},
"achievement": {
"achievement_unlocked": "Logro desbloqueado",
@@ -706,5 +724,26 @@
"hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!",
"learn_more": "Descubrir más",
"debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus"
},
"library": {
"library": "Librería",
"play": "Jugar",
"download": "Descargar",
"downloading": "Descargando",
"game": "juego",
"games": "juegos",
"grid_view": "Vista de cuadrícula",
"compact_view": "Vista compacta",
"large_view": "Vista grande",
"no_games_title": "Tu librería está vacía",
"no_games_description": "Agregá juegos del catálogo o descargalos para comenzar",
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente",
"all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente",
"favorites": "Favoritos"
}
}

View File

@@ -8,7 +8,7 @@
"no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott",
"weekly": "📅 A hét felkapott játékai",
"weekly": "📅 A hét felkapottjai",
"achievements": "🏆 Achievement támogatott"
},
"sidebar": {
@@ -26,7 +26,7 @@
"sign_in": "Bejelentkezés",
"friends": "Barátok",
"need_help": "Elakadtál?",
"favorites": "Kedvenc játékok",
"favorites": "Kedvenc Játékaim",
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
"add_custom_game_tooltip": "Saját játék hozzáadása",
"show_playable_only_tooltip": "Csak játszható játék mutatása",
@@ -224,7 +224,7 @@
"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...",
"write_review_placeholder": "Oszd meg gondolatod 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!",
@@ -252,7 +252,7 @@
"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",
"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",
@@ -356,13 +356,18 @@
"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"
"delete_review_modal_cancel_button": "Mégse",
"vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.",
"show_original": "Eredeti megjelenítése",
"show_translation": "Fordítás megjelenítése",
"show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})",
"hide_original": "Eredeti elrejtése"
},
"activation": {
"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 hol kérdezz efelől, akkor nem kéne ilyened legyen.",
"message": "Ha nem tudod merre kérdezz efelől, akkor nem kéne ilyened legyen.",
"activate": "Aktiválás",
"loading": "Töltés…"
},
@@ -386,7 +391,7 @@
"download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett",
"queued": "Várakozási sorban",
"queued": "Várakozásban",
"no_downloads_title": "Oly üres..",
"no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.",
"checking_files": "Fájlok ellenőrzése…",
@@ -419,20 +424,30 @@
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
"save_changes": "Változtatások mentése",
"changes_saved": "Változtatások sikeresen mentve",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
"validate_download_source": "Érvényesítés",
"remove_download_source": "Eltávolítás",
"add_download_source": "Forrás hozáadása",
"adding": "Hozzáadás…",
"failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.",
"download_source_already_exists": "Ez a letöltési forrás URL már létezik.",
"download_count_zero": "Nincs letöltési opció",
"download_count_one": "{{countFormatted}} letöltési opció",
"download_count_other": "{{countFormatted}} letöltési opció",
"download_source_url": "URL forrás:",
"download_source_url": "URL Forrás:",
"add_download_source_description": "Helyezd be a .json fájl URL-jét",
"download_source_up_to_date": "Naprakész",
"download_source_errored": "Hiba történt",
"download_source_pending_matching": "Frissítés hamarosan",
"download_source_matched": "Naprakész",
"download_source_matching": "Frissítés..",
"download_source_failed": "Hiba",
"download_source_no_information": "Nincs elérhető információ",
"sync_download_sources": "Források szinkronizálása",
"removed_download_source": "Letöltési forrás eltávolítva",
"removed_download_sources": "Letöltési források eltávolítva",
"removed_all_download_sources": "Összes letöltési forrás eltávolítva",
"download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva",
"cancel_button_confirmation_delete_all_sources": "Nem",
"confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent",
"title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése",
@@ -445,6 +460,7 @@
"found_download_option_one": "{{countFormatted}} Letöltési opció találva",
"found_download_option_other": "{{countFormatted}} Letöltési opciók találva",
"import": "Importálás",
"importing": "Importálás...",
"public": "Publikus",
"private": "Privát",
"friends_only": "Csak barátok",
@@ -462,6 +478,7 @@
"seed_after_download_complete": "Letöltés utáni seedelés",
"show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt",
"account": "Fiók",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "Nincsenek letiltott felhasználóid",
"subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}",
"manage_subscription": "Előfizetés kezelése",
@@ -498,14 +515,14 @@
"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.",
"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, és 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",
"name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
"name_min_length": "A téma neve legalább 3 karakter hosszú kell 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}}",
"error_importing_theme": "Hiba lépett fel a téma importálása közben",
@@ -535,7 +552,9 @@
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"notification_preview": "Achievement Értesítés Előnézete",
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot"
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot",
"autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
"hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára"
},
"notifications": {
"download_complete": "Letöltés befejezve",
@@ -563,10 +582,10 @@
"available_one": "Elérhető",
"available_other": "Elérhető",
"no_downloads": "Nincs elérhető letöltés",
"calculating": "Feldolgozás"
"calculating": "Számítás alatt.."
},
"binary_not_found_modal": {
"title": "A programok nincsenek telepítve",
"title": "Hiányzó programok",
"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ódra, hogy a játék megfelelően fusson"
},
@@ -585,6 +604,7 @@
"activity": "Legutóbbi tevékenység",
"library": "Könyvtár",
"pinned": "Kitűzve",
"sort_by": "Rendezés:",
"achievements_earned": "Elért achievementek",
"played_recently": "Nemrég játszva",
"playtime": "Játszottidő",
@@ -654,7 +674,7 @@
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák",
"achievements": "achievementek",
"achievements": "achievement",
"games": "Játékok",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "A rangsor hetente frissül.",
@@ -669,7 +689,7 @@
"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"
"karma_description": "Pozitív értékelésekkel szerzett pontok"
},
"achievement": {
"achievement_unlocked": "Achievement feloldva",
@@ -678,7 +698,7 @@
"unlocked_at": "Feloldva: {{date}}",
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges",
"new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement",
"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",

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads",
"settings": "Ajustes",
"my_library": "Biblioteca",
@@ -182,6 +183,7 @@
"download_in_progress": "Download em andamento",
"download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada",
"new_download_option": "Novo",
"create_steam_shortcut": "Criar atalho na Steam",
"create_shortcut_success": "Atalho criado com sucesso",
"you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações",
@@ -317,6 +319,7 @@
"sort_lowest_score": "Menor Nota",
"sort_most_voted": "Mais Votadas",
"no_reviews_yet": "Ainda não há avaliações",
"review_played_for": "Jogado por",
"be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
"rating": "Avaliação",
"rating_stats": "Avaliação",
@@ -349,7 +352,10 @@
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
"rating_count": "Avaliação"
"rating_count": "Avaliação",
"review_from_blocked_user": "Avaliação de usuário bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
},
"activation": {
"title": "Ativação",
@@ -537,8 +543,16 @@
"platinum": "Platina",
"hidden": "Oculta",
"test_notification": "Testar notificação",
"achievement_sound_volume": "Volume do som de conquista",
"select_achievement_sound": "Selecionar som de conquista",
"select": "Selecionar",
"preview": "Reproduzir",
"remove": "Remover",
"no_sound_file_selected": "Nenhum arquivo de som selecionado",
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
"hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo"
},
"notifications": {
"download_complete": "Download concluído",
@@ -691,7 +705,11 @@
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente"
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",
@@ -721,5 +739,26 @@
"hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!",
"learn_more": "Saiba mais",
"debrid_description": "Baixe até 4x mais rápido com Nimbus"
},
"library": {
"library": "Biblioteca",
"play": "Jogar",
"download": "Baixar",
"downloading": "Baixando",
"game": "jogo",
"games": "jogos",
"grid_view": "Visualização em grade",
"compact_view": "Visualização compacta",
"large_view": "Visualização grande",
"no_games_title": "Sua biblioteca está vazia",
"no_games_description": "Adicione jogos do catálogo ou baixe-os para começar",
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente",
"favorites": "Favoritos"
}
}

View File

@@ -180,7 +180,11 @@
"download_error_not_cached_on_torbox": "Este download não está disponível no TorBox e a verificação do status do download não está disponível.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos",
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar"
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"review_from_blocked_user": "Avaliação de utilizador bloqueado",
"show": "Mostrar",
"hide": "Ocultar",
"review_played_for": "Jogado por"
},
"activation": {
"title": "Ativação",
@@ -466,7 +470,11 @@
"achievements_unlocked": "Conquistas desbloqueadas",
"earned_points": "Pontos ganhos",
"show_achievements_on_profile": "Mostre as suas conquistas no perfil",
"show_points_on_profile": "Mostre os seus pontos ganhos no perfil"
"show_points_on_profile": "Mostre os seus pontos ganhos no perfil",
"user_reviews": "Avaliações",
"loading_reviews": "A carregar avaliações...",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Eliminar avaliação"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"my_library": "Библиотека",
@@ -194,6 +195,7 @@
"download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки",
"new_download_option": "Новый",
"create_steam_shortcut": "Создать ярлык Steam",
"create_shortcut_success": "Ярлык создан",
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
@@ -212,6 +214,7 @@
"stats": "Статистика",
"download_count": "Загрузки",
"player_count": "Активные игроки",
"rating_count": "Оценка",
"download_error": "Этот вариант загрузки недоступен",
"download": "Скачать",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
@@ -226,6 +229,7 @@
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл",
@@ -252,17 +256,6 @@
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
"yes": "Да",
"maybe_later": "Возможно позже",
"rating_count": "Оценка",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -360,7 +353,21 @@
"caption": "Субтитры",
"audio": "Аудио",
"filter_by_source": "Фильтр по источнику",
"no_repacks_found": "Источники для этой игры не найдены"
"no_repacks_found": "Источники для этой игры не найдены",
"show": "Показать",
"hide": "Скрыть",
"delete_review": "Удалить отзыв",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"vote_failed": "Не удалось зарегистрировать ваш голос. Пожалуйста, попробуйте снова.",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"review_from_blocked_user": "Отзыв от заблокированного пользователя"
},
"activation": {
"title": "Активировать Hydra",
@@ -427,6 +434,9 @@
"validate_download_source": "Проверить",
"remove_download_source": "Удалить",
"add_download_source": "Добавить источник",
"adding": "Добавление…",
"failed_add_download_source": "Не удалось добавить источник. Пожалуйста, попробуйте снова.",
"download_source_already_exists": "Этот URL источника уже существует.",
"download_count_zero": "В списке нет загрузок",
"download_count_one": "{{countFormatted}} загрузка в списке",
"download_count_other": "{{countFormatted}} загрузок в списке",
@@ -434,9 +444,16 @@
"add_download_source_description": "Вставьте ссылку на .json-файл",
"download_source_up_to_date": "Обновлён",
"download_source_errored": "Ошибка",
"download_source_pending_matching": "Скоро обновится",
"download_source_matched": "Обновлен",
"download_source_matching": "Обновление",
"download_source_failed": "Ошибка",
"download_source_no_information": "Информация отсутствует",
"sync_download_sources": "Обновить источники",
"removed_download_source": "Источник удален",
"removed_download_sources": "Источники удалены",
"removed_all_download_sources": "Все источники удалены",
"download_sources_synced_successfully": "Все источники синхронизированы",
"cancel_button_confirmation_delete_all_sources": "Нет",
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
"title_confirmation_delete_all_sources": "Удалить все источники",
@@ -467,6 +484,7 @@
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
"account": "Аккаунт",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "У вас нет заблокированных пользователей",
"subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}",
"manage_subscription": "Управлять подпиской",
@@ -539,8 +557,16 @@
"platinum": "Платиновый",
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"achievement_sound_volume": "Громкость звука достижения",
"select_achievement_sound": "Выбрать звук достижения",
"select": "Выбрать",
"preview": "Предпросмотр",
"remove": "Удалить",
"no_sound_file_selected": "Файл звука не выбран",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
"hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -590,6 +616,7 @@
"activity": "Недавняя активность",
"library": "Библиотека",
"pinned": "Закрепленные",
"sort_by": "Сортировать по:",
"achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные",
"playtime": "Время игры",
@@ -674,7 +701,11 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов"
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",
@@ -704,5 +735,26 @@
"hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!",
"learn_more": "Подробнее",
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
},
"library": {
"library": "Библиотека",
"play": "Играть",
"download": "Скачать",
"downloading": "Скачивание",
"game": "игра",
"games": "игры",
"grid_view": "Вид сетки",
"compact_view": "Компактный вид",
"large_view": "Большой вид",
"no_games_title": "Ваша библиотека пуста",
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"amount_hours_short": "{{amount}}ч",
"amount_minutes_short": "{{amount}}м",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"favorites": "Избранное"
}
}

View File

@@ -27,7 +27,68 @@
"friends": "好友",
"favorites": "收藏",
"need_help": "需要帮助?",
"playable_button_title": "仅显示现在可以游玩的游戏"
"playable_button_title": "仅显示现在可以游玩的游戏",
"add_custom_game_tooltip": "添加自定义游戏",
"cancel": "取消",
"confirm": "确认",
"custom_game_modal": "添加自定义游戏",
"custom_game_modal_add": "添加游戏",
"custom_game_modal_adding": "正在添加游戏...",
"custom_game_modal_browse": "浏览",
"custom_game_modal_cancel": "取消",
"custom_game_modal_description": "通过选择可执行文件将自定义游戏添加到您的库中",
"custom_game_modal_enter_title": "输入标题",
"custom_game_modal_executable": "可执行文件",
"custom_game_modal_executable_path": "可执行文件路径",
"custom_game_modal_failed": "添加自定义游戏失败",
"custom_game_modal_select_executable": "选择可执行文件",
"custom_game_modal_success": "自定义游戏添加成功",
"custom_game_modal_title": "标题",
"decky_plugin_installation_error": "安装 Decky 插件出错: {{error}}",
"decky_plugin_installation_failed": "Decky 插件安装失败: {{error}}",
"decky_plugin_installed": "Decky 插件 v{{version}} 安装成功",
"decky_plugin_installed_version": "Decky 插件 (v{{version}})",
"edit_game_modal": "自定义资源",
"edit_game_modal_assets": "资源",
"edit_game_modal_browse": "浏览",
"edit_game_modal_cancel": "取消",
"edit_game_modal_description": "自定义游戏资源和详情",
"edit_game_modal_drop_hero_image_here": "拖放主图像到此处",
"edit_game_modal_drop_icon_image_here": "拖放图标到此处",
"edit_game_modal_drop_logo_image_here": "拖放Logo到此处",
"edit_game_modal_drop_to_replace_hero": "拖放以替换主图像",
"edit_game_modal_drop_to_replace_icon": "拖放以替换图标",
"edit_game_modal_drop_to_replace_logo": "拖放以替换Logo",
"edit_game_modal_enter_title": "输入标题",
"edit_game_modal_failed": "资源更新失败",
"edit_game_modal_fill_required": "请填写所有必填项",
"edit_game_modal_hero": "库主图",
"edit_game_modal_hero_preview": "库主图预览",
"edit_game_modal_hero_resolution": "推荐分辨率: 1920x620px",
"edit_game_modal_icon": "图标",
"edit_game_modal_icon_preview": "图标预览",
"edit_game_modal_icon_resolution": "推荐分辨率: 256x256px",
"edit_game_modal_image": "图片",
"edit_game_modal_image_filter": "图片",
"edit_game_modal_image_preview": "图片预览",
"edit_game_modal_logo": "Logo",
"edit_game_modal_logo_preview": "Logo预览",
"edit_game_modal_logo_resolution": "推荐分辨率: 640x360px",
"edit_game_modal_select_hero": "选择库主图",
"edit_game_modal_select_icon": "选择图标",
"edit_game_modal_select_image": "选择图片",
"edit_game_modal_select_logo": "选择Logo",
"edit_game_modal_success": "资源更新成功",
"edit_game_modal_title": "标题",
"edit_game_modal_update": "更新",
"edit_game_modal_updating": "正在更新...",
"install_decky_plugin": "安装 Decky 插件",
"install_decky_plugin_message": "这将下载并安装 Hydra 的 Decky Loader 插件。可能需要提升权限。继续吗?",
"install_decky_plugin_title": "安装 Hydra Decky 插件",
"show_playable_only_tooltip": "仅显示可游玩",
"update_decky_plugin": "更新 Decky 插件",
"update_decky_plugin_message": "有新版本的 Hydra Decky 插件可用。现在要更新吗?",
"update_decky_plugin_title": "更新 Hydra Decky 插件"
},
"header": {
"search": "搜索游戏",
@@ -218,7 +279,93 @@
"reset_achievements_title": "您确定吗?",
"save_changes": "保存更改",
"unfreeze_backup": "取消固定",
"you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改"
"you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改",
"add_to_favorites": "添加到收藏",
"already_in_library": "已在游戏库中",
"audio": "音频",
"backup_failed": "备份失败",
"be_first_to_review": "成为第一个分享游戏感受的人!",
"caption": "标题",
"create_shortcut_simple": "创建快捷方式",
"currency_country": "zh",
"currency_symbol": "¥",
"delete_review": "删除评价",
"delete_review_modal_cancel_button": "取消",
"delete_review_modal_delete_button": "删除",
"delete_review_modal_description": "此操作无法撤销。",
"delete_review_modal_title": "确定要删除您的评价吗?",
"edit_game_modal_button": "自定义游戏资源",
"failed_remove_files": "文件删除失败",
"failed_remove_from_library": "移出游戏库失败",
"failed_update_favorites": "收藏更新失败",
"files_removed_success": "文件已成功删除",
"filter_by_source": "按来源筛选",
"game_added_to_pinned": "游戏已添加到置顶",
"game_details": "游戏详情",
"game_removed_from_library": "游戏已从库中移除",
"game_removed_from_pinned": "游戏已从置顶移除",
"hide": "隐藏",
"hide_original": "隐藏原文",
"historical_keyshop": "历史密钥商店",
"historical_retail": "历史零售",
"keyshop_price": "密钥商店价格",
"language": "语言",
"leave_a_review": "留下评价",
"load_more_reviews": "加载更多评价",
"loading_more_reviews": "正在加载更多评价...",
"loading_reviews": "正在加载评价...",
"manual_playtime_tooltip": "该游戏时长已手动更新",
"manual_playtime_warning": "您的游戏时长将被标记为手动更新,且无法撤销。",
"maybe_later": "以后再说",
"no_prices_found": "未找到价格信息",
"no_repacks_found": "未找到该游戏的下载来源",
"no_reviews_yet": "暂无评价",
"prices": "价格",
"properties": "属性",
"rating": "评分",
"rating_count": "评分数",
"rating_negative": "差评",
"rating_neutral": "中性",
"rating_positive": "好评",
"rating_stats": "评分统计",
"rating_very_negative": "极差",
"rating_very_positive": "极好",
"remove_from_favorites": "移出收藏",
"remove_review": "移除评价",
"retail_price": "零售价格",
"review_cannot_be_empty": "评价内容不能为空。",
"review_deleted_successfully": "评价已成功删除。",
"review_deletion_failed": "评价删除失败,请重试。",
"review_from_blocked_user": "来自被屏蔽用户的评价",
"review_played_for": "已游玩",
"review_submission_failed": "评价提交失败,请重试。",
"review_submitted_successfully": "评价提交成功!",
"reviews": "评价",
"show": "显示",
"show_less": "收起",
"show_more": "展开",
"show_original": "显示原文",
"show_original_translated_from": "显示原文(由{{language}}翻译)",
"show_translation": "显示翻译",
"sort_highest_score": "最高分",
"sort_lowest_score": "最低分",
"sort_most_voted": "最多投票",
"sort_newest": "最新",
"sort_oldest": "最旧",
"submit_review": "提交",
"submitting": "正在提交...",
"update_game_playtime": "更新游戏时长",
"update_playtime": "更新时长",
"update_playtime_description": "手动更新 {{game}} 的游玩时长",
"update_playtime_error": "游戏时长更新失败",
"update_playtime_success": "游戏时长已成功更新",
"update_playtime_title": "更新游戏时长",
"view_all_prices": "点击查看所有价格",
"vote_failed": "投票失败,请重试。",
"would_you_recommend_this_game": "您想为此游戏留下评价吗?",
"write_review_placeholder": "分享您对本游戏的看法...",
"yes": "是",
"you_seemed_to_enjoy_this_game": "您似乎很喜欢这款游戏"
},
"activation": {
"title": "激活 Hydra",
@@ -394,7 +541,24 @@
"update_email": "更新邮箱",
"update_password": "更新密码",
"variation": "变体",
"web_store": "网络商店"
"web_store": "网络商店",
"adding": "添加中…",
"autoplay_trailers_on_game_page": "在游戏页面自动播放预告片",
"debrid": "Debrid下载服务",
"debrid_description": "Debrid服务是一种高级不限速下载器可让您以最快的网速下载托管在各类网盘上的文件仅受您的网络速度限制。",
"download_source_already_exists": "该下载源URL已存在。",
"download_source_failed": "出错",
"download_source_matched": "已更新",
"download_source_matching": "正在更新",
"download_source_no_information": "暂无信息",
"download_source_pending_matching": "即将更新",
"download_sources_synced_successfully": "所有下载源已同步",
"enable_steam_achievements": "启用Steam成就搜索",
"failed_add_download_source": "添加下载源失败,请重试。",
"hide_to_tray_on_game_start": "启动游戏时隐藏到托盘",
"hydra_cloud": "Hydra Cloud",
"importing": "导入中…",
"removed_all_download_sources": "已移除所有下载源"
},
"notifications": {
"download_complete": "下载完成",
@@ -421,7 +585,8 @@
"game_card": {
"no_downloads": "无可用下载选项",
"available_one": "可用",
"available_other": "可用"
"available_other": "可用",
"calculating": "正在计算"
},
"binary_not_found_modal": {
"title": "程序未安装",
@@ -515,7 +680,23 @@
"show_achievements_on_profile": "在您的个人资料上显示成就",
"show_points_on_profile": "在您的个人资料上显示获得的积分",
"stats": "统计",
"top_percentile": "前 {{percentile}}%"
"top_percentile": "前 {{percentile}}%",
"achievements_earned": "已获得成就",
"amount_hours_short": "{{amount}}小时",
"amount_minutes_short": "{{amount}}分钟",
"delete_review": "删除评价",
"game_added_to_pinned": "游戏已添加到置顶",
"game_removed_from_pinned": "游戏已从置顶移除",
"karma": "业力",
"karma_count": "业力值",
"karma_description": "通过评论获得的点赞",
"loading_reviews": "正在加载评价...",
"manual_playtime_tooltip": "该游戏时长已手动更新",
"pinned": "已置顶",
"played_recently": "最近游玩",
"playtime": "游戏时长",
"sort_by": "排序方式:",
"user_reviews": "用户评价"
},
"achievement": {
"achievement_unlocked": "成就已解锁",

View File

@@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes");
export const MAIN_LOOP_INTERVAL = 2000;
export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15;
export const DECKY_PLUGINS_LOCATION = path.join(
SystemPath.getPath("home"),
"homebrew",

View File

@@ -0,0 +1,13 @@
import { getDownloadSourcesCheckBaseline } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesCheckBaselineHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesCheckBaseline();
};
registerEvent(
"getDownloadSourcesCheckBaseline",
getDownloadSourcesCheckBaselineHandler
);

View File

@@ -0,0 +1,13 @@
import { getDownloadSourcesSinceValue } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesSinceValueHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesSinceValue();
};
registerEvent(
"getDownloadSourcesSinceValue",
getDownloadSourcesSinceValueHandler
);

View File

@@ -18,7 +18,9 @@ import "./library/close-game";
import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/refresh-library-assets";
import "./library/extract-game-download";
import "./library/clear-new-download-options";
import "./library/open-game";
import "./library/open-game-executable-path";
import "./library/open-game-installer";
@@ -64,6 +66,8 @@ import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
import "./download-sources/sync-download-sources";
import "./download-sources/get-download-sources-check-baseline";
import "./download-sources/get-download-sources-since-value";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
@@ -91,6 +95,11 @@ import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import "./themes/copy-theme-achievement-sound";
import "./themes/remove-theme-achievement-sound";
import "./themes/get-theme-sound-path";
import "./themes/get-theme-sound-data-url";
import "./themes/import-theme-sound-from-store";
import "./download-sources/remove-download-source";
import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers";

View File

@@ -0,0 +1,27 @@
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { logger } from "@main/services";
import type { GameShop } from "@types";
const clearNewDownloadOptions = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (!game) return;
try {
await gamesSublevel.put(gameKey, {
...game,
newDownloadOptionsCount: undefined,
});
logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`);
} catch (error) {
logger.error(`Failed to clear newDownloadOptionsCount: ${error}`);
}
};
registerEvent("clearNewDownloadOptions", clearNewDownloadOptions);

View File

@@ -22,11 +22,15 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
id: key,
...game,
download: download ?? null,
unlockedAchievementCount: game.unlockedAchievementCount ?? 0,
achievementCount: game.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Ensure compatibility with LibraryGame type
libraryHeroImageUrl:
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
} as LibraryGame;
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
};
})
);
});

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { mergeWithRemoteGames } from "@main/services";
const refreshLibraryAssets = async () => {
await mergeWithRemoteGames();
};
registerEvent("refreshLibraryAssets", refreshLibraryAssets);

View File

@@ -0,0 +1,40 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import { getThemePath } from "@main/helpers";
import { themesSublevel } from "@main/level";
const copyThemeAchievementSound = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
sourcePath: string
): Promise<void> => {
if (!sourcePath || !fs.existsSync(sourcePath)) {
throw new Error("Source file does not exist");
}
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
const themeDir = getThemePath(themeId, theme.name);
if (!fs.existsSync(themeDir)) {
fs.mkdirSync(themeDir, { recursive: true });
}
const fileExtension = path.extname(sourcePath);
const destinationPath = path.join(themeDir, `achievement${fileExtension}`);
await fs.promises.copyFile(sourcePath, destinationPath);
await themesSublevel.put(themeId, {
...theme,
hasCustomSound: true,
originalSoundPath: sourcePath,
updatedAt: new Date(),
});
};
registerEvent("copyThemeAchievementSound", copyThemeAchievementSound);

View File

@@ -0,0 +1,40 @@
import { registerEvent } from "../register-event";
import { getThemeSoundPath } from "@main/helpers";
import { themesSublevel } from "@main/level";
import fs from "node:fs";
import path from "node:path";
import { logger } from "@main/services";
const getThemeSoundDataUrl = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
): Promise<string | null> => {
try {
const theme = await themesSublevel.get(themeId);
const soundPath = getThemeSoundPath(themeId, theme?.name);
if (!soundPath || !fs.existsSync(soundPath)) {
return null;
}
const buffer = await fs.promises.readFile(soundPath);
const ext = path.extname(soundPath).toLowerCase().slice(1);
const mimeTypes: Record<string, string> = {
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
m4a: "audio/mp4",
};
const mimeType = mimeTypes[ext] || "audio/mpeg";
const base64 = buffer.toString("base64");
return `data:${mimeType};base64,${base64}`;
} catch (error) {
logger.error("Failed to get theme sound data URL", error);
return null;
}
};
registerEvent("getThemeSoundDataUrl", getThemeSoundDataUrl);

View File

@@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { getThemeSoundPath } from "@main/helpers";
import { themesSublevel } from "@main/level";
const getThemeSoundPathEvent = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
): Promise<string | null> => {
const theme = await themesSublevel.get(themeId);
return getThemeSoundPath(themeId, theme?.name);
};
registerEvent("getThemeSoundPath", getThemeSoundPathEvent);

View File

@@ -0,0 +1,60 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { getThemePath } from "@main/helpers";
import { themesSublevel } from "@main/level";
import { logger } from "@main/services";
const importThemeSoundFromStore = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
themeName: string,
storeUrl: string
): Promise<void> => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
const formats = ["wav", "mp3", "ogg", "m4a"];
for (const format of formats) {
try {
const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`;
const response = await axios.get(soundUrl, {
responseType: "arraybuffer",
timeout: 10000,
});
const themeDir = getThemePath(themeId, theme.name);
if (!fs.existsSync(themeDir)) {
fs.mkdirSync(themeDir, { recursive: true });
}
const destinationPath = path.join(themeDir, `achievement.${format}`);
await fs.promises.writeFile(destinationPath, response.data);
await themesSublevel.put(themeId, {
...theme,
hasCustomSound: true,
updatedAt: new Date(),
});
logger.log(`Successfully imported sound for theme ${themeName}`);
return;
} catch (error) {
logger.error(
`Failed to import ${format} sound for theme ${themeName}`,
error
);
continue;
}
}
logger.log(`No sound file found for theme ${themeName} in store`);
};
registerEvent("importThemeSoundFromStore", importThemeSoundFromStore);

View File

@@ -0,0 +1,48 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import { getThemePath } from "@main/helpers";
import { themesSublevel } from "@main/level";
import { THEMES_PATH } from "@main/constants";
import path from "node:path";
const removeThemeAchievementSound = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
): Promise<void> => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
const themeDir = getThemePath(themeId, theme.name);
const legacyThemeDir = path.join(THEMES_PATH, themeId);
const removeFromDir = async (dir: string) => {
if (!fs.existsSync(dir)) {
return;
}
const formats = ["wav", "mp3", "ogg", "m4a"];
for (const format of formats) {
const soundPath = path.join(dir, `achievement.${format}`);
if (fs.existsSync(soundPath)) {
await fs.promises.unlink(soundPath);
}
}
};
await removeFromDir(themeDir);
if (themeDir !== legacyThemeDir) {
await removeFromDir(legacyThemeDir);
}
await themesSublevel.put(themeId, {
...theme,
hasCustomSound: false,
originalSoundPath: undefined,
updatedAt: new Date(),
});
};
registerEvent("removeThemeAchievementSound", removeThemeAchievementSound);

View File

@@ -2,6 +2,8 @@ import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents";
import path from "node:path";
import fs from "node:fs";
import { THEMES_PATH } from "@main/constants";
export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
@@ -31,9 +33,64 @@ export const isPortableVersion = () => {
};
export const normalizePath = (str: string) =>
path.posix.normalize(str).replace(/\\/g, "/");
path.posix.normalize(str).replaceAll("\\", "/");
export const addTrailingSlash = (str: string) =>
str.endsWith("/") ? str : `${str}/`;
const sanitizeFolderName = (name: string): string => {
return name
.toLowerCase()
.replaceAll(/[^a-z0-9-_\s]/g, "")
.replaceAll(/\s+/g, "-")
.replaceAll(/-+/g, "-")
.replaceAll(/(^-|-$)/g, "");
};
export const getThemePath = (themeId: string, themeName?: string): string => {
if (themeName) {
const sanitizedName = sanitizeFolderName(themeName);
if (sanitizedName) {
return path.join(THEMES_PATH, sanitizedName);
}
}
return path.join(THEMES_PATH, themeId);
};
export const getThemeSoundPath = (
themeId: string,
themeName?: string
): string | null => {
const themeDir = getThemePath(themeId, themeName);
const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null;
const checkDir = (dir: string): string | null => {
if (!fs.existsSync(dir)) {
return null;
}
const formats = ["wav", "mp3", "ogg", "m4a"];
for (const format of formats) {
const soundPath = path.join(dir, `achievement.${format}`);
if (fs.existsSync(soundPath)) {
return soundPath;
}
}
return null;
};
const soundPath = checkDir(themeDir);
if (soundPath) {
return soundPath;
}
if (legacyThemeDir) {
return checkDir(legacyThemeDir);
}
return null;
};
export * from "./reg-parser";

View File

@@ -0,0 +1,59 @@
import { levelKeys } from "./keys";
import { db } from "../level";
import { logger } from "@main/services";
// Gets when we last started the app (for next API call's 'since')
export const getDownloadSourcesCheckBaseline = async (): Promise<
string | null
> => {
try {
const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline);
return timestamp;
} catch (error) {
if (error instanceof Error && error.name === "NotFoundError") {
logger.debug("Download sources check baseline not found, returning null");
} else {
logger.error(
"Unexpected error while getting download sources check baseline",
error
);
}
return null;
}
};
// Updates to current time (when app starts)
export const updateDownloadSourcesCheckBaseline = async (
timestamp: string
): Promise<void> => {
const utcTimestamp = new Date(timestamp).toISOString();
await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp);
};
// Gets the 'since' value the API used in the last check (for modal comparison)
export const getDownloadSourcesSinceValue = async (): Promise<
string | null
> => {
try {
const timestamp = await db.get(levelKeys.downloadSourcesSinceValue);
return timestamp;
} catch (error) {
if (error instanceof Error && error.name === "NotFoundError") {
logger.debug("Download sources since value not found, returning null");
} else {
logger.error(
"Unexpected error while getting download sources since value",
error
);
}
return null;
}
};
// Saves the 'since' value we used in the API call (for modal to compare against)
export const updateDownloadSourcesSinceValue = async (
timestamp: string
): Promise<void> => {
const utcTimestamp = new Date(timestamp).toISOString();
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp);
};

View File

@@ -7,3 +7,4 @@ export * from "./game-achievements";
export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";

View File

@@ -18,4 +18,6 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
};

View File

@@ -16,6 +16,8 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
DownloadSourcesChecker,
WSClient,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -56,7 +58,10 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// WSClient.connect();
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
WSClient.connect();
});
const downloads = await downloadsSublevel

View File

@@ -167,6 +167,8 @@ export class AchievementWatcherManager {
shop: GameShop,
objectId: string
) {
if (shop === "custom") return;
const gameKey = levelKeys.game(shop, objectId);
if (this.alreadySyncedGames.get(gameKey)) return;

View File

@@ -0,0 +1,188 @@
import { HydraApi } from "./hydra-api";
import {
gamesSublevel,
getDownloadSourcesCheckBaseline,
updateDownloadSourcesCheckBaseline,
updateDownloadSourcesSinceValue,
downloadSourcesSublevel,
} from "@main/level";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
import type { Game } from "@types";
interface DownloadSourcesChangeResponse {
shop: string;
objectId: string;
newDownloadOptionsCount: number;
downloadSourceIds: string[];
}
export class DownloadSourcesChecker {
private static async clearStaleBadges(
nonCustomGames: Game[]
): Promise<{ gameId: string; count: number }[]> {
const previouslyFlaggedGames = nonCustomGames.filter(
(game: Game) =>
game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0
);
const clearedPayload: { gameId: string; count: number }[] = [];
if (previouslyFlaggedGames.length > 0) {
logger.info(
`Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games`
);
for (const game of previouslyFlaggedGames) {
await gamesSublevel.put(`${game.shop}:${game.objectId}`, {
...game,
newDownloadOptionsCount: undefined,
});
clearedPayload.push({
gameId: `${game.shop}:${game.objectId}`,
count: 0,
});
}
}
return clearedPayload;
}
private static async processApiResponse(
response: unknown,
nonCustomGames: Game[]
): Promise<{ gameId: string; count: number }[]> {
if (!response || !Array.isArray(response)) {
return [];
}
const gamesWithNewOptions: { gameId: string; count: number }[] = [];
for (const gameUpdate of response as DownloadSourcesChangeResponse[]) {
if (gameUpdate.newDownloadOptionsCount > 0) {
const game = nonCustomGames.find(
(g) =>
g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId
);
if (game) {
await gamesSublevel.put(`${game.shop}:${game.objectId}`, {
...game,
newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount,
});
gamesWithNewOptions.push({
gameId: `${game.shop}:${game.objectId}`,
count: gameUpdate.newDownloadOptionsCount,
});
}
}
}
return gamesWithNewOptions;
}
private static sendNewDownloadOptionsEvent(
clearedPayload: { gameId: string; count: number }[],
gamesWithNewOptions: { gameId: string; count: number }[]
): void {
const eventPayload = [...clearedPayload, ...gamesWithNewOptions];
if (eventPayload.length > 0 && WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send(
"on-new-download-options",
eventPayload
);
}
logger.info(
`Found new download options for ${gamesWithNewOptions.length} games`
);
}
static async checkForChanges(): Promise<void> {
logger.info("DownloadSourcesChecker.checkForChanges() called");
try {
// Get all installed games (excluding custom games)
const installedGames = await gamesSublevel.values().all();
const nonCustomGames = installedGames.filter(
(game: Game) => game.shop !== "custom"
);
logger.info(
`Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games`
);
if (nonCustomGames.length === 0) {
logger.info(
"No non-custom games found, skipping download sources check"
);
return;
}
const downloadSources = await downloadSourcesSublevel.values().all();
const downloadSourceIds = downloadSources.map((source) => source.id);
logger.info(
`Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(", ")}`
);
if (downloadSourceIds.length === 0) {
logger.info(
"No download sources found, skipping download sources check"
);
return;
}
const previousBaseline = await getDownloadSourcesCheckBaseline();
const since =
previousBaseline ||
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
logger.info(`Using since: ${since} (from last app start)`);
const clearedPayload = await this.clearStaleBadges(nonCustomGames);
const games = nonCustomGames.map((game: Game) => ({
shop: game.shop,
objectId: game.objectId,
}));
logger.info(
`Checking download sources changes for ${games.length} non-custom games since ${since}`
);
logger.info(
`Making API call to HydraApi.checkDownloadSourcesChanges with:`,
{
downloadSourceIds,
gamesCount: games.length,
since,
}
);
const response = await HydraApi.checkDownloadSourcesChanges(
downloadSourceIds,
games,
since
);
logger.info("API call completed, response:", response);
await updateDownloadSourcesSinceValue(since);
logger.info(`Saved 'since' value: ${since} (for modal comparison)`);
const now = new Date().toISOString();
await updateDownloadSourcesCheckBaseline(now);
logger.info(
`Updated baseline to: ${now} (will be 'since' on next app start)`
);
const gamesWithNewOptions = await this.processApiResponse(
response,
nonCustomGames
);
this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions);
logger.info("Download sources check completed successfully");
} catch (error) {
logger.error("Failed to check download sources changes:", error);
}
}
}

View File

@@ -1,6 +1,7 @@
import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
import { logger } from "@main/services";
export class DatanodesApi {
private static readonly jar = new CookieJar();
@@ -20,51 +21,42 @@ export class DatanodesApi {
await this.jar.setCookie("lang=english;", "https://datanodes.to");
const payload = new URLSearchParams({
op: "download2",
id: fileCode,
method_free: "Free Download >>",
dl: "1",
});
const formData = new FormData();
formData.append("op", "download2");
formData.append("id", fileCode);
formData.append("rand", "");
formData.append("referer", "https://datanodes.to/download");
formData.append("method_free", "Free Download >>");
formData.append("method_premium", "");
formData.append("__dl", "1");
const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download",
payload,
formData,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
priority: "u=1, i",
"sec-ch-ua":
'"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
Referer: "https://datanodes.to/download",
Origin: "https://datanodes.to",
"Content-Type": "application/x-www-form-urlencoded",
},
maxRedirects: 0,
validateStatus: (status: number) => status === 302 || status < 400,
}
);
if (response.status === 302) {
return response.headers["location"];
}
if (typeof response.data === "object" && response.data.url) {
return decodeURIComponent(response.data.url);
}
const htmlContent = String(response.data);
if (!htmlContent) {
throw new Error("Empty response received");
}
const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/;
const downloadLinkMatch = downloadLinkRegex.exec(htmlContent);
if (downloadLinkMatch) {
return downloadLinkMatch[1];
}
throw new Error("Failed to get the download link");
} catch (error) {
console.error("Error fetching download URL:", error);
logger.error("Error fetching download URL:", error);
throw error;
}
}

View File

@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { WSClient } from "./ws";
export interface HydraApiOptions {
needsAuth?: boolean;
@@ -103,8 +104,8 @@ export class HydraApi {
await clearGamesRemoteIds();
uploadGamesBatch();
// WSClient.close();
// WSClient.connect();
WSClient.close();
WSClient.connect();
const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi();
@@ -399,4 +400,45 @@ export class HydraApi {
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
static async checkDownloadSourcesChanges(
downloadSourceIds: string[],
games: Array<{ shop: string; objectId: string }>,
since: string
) {
logger.info("HydraApi.checkDownloadSourcesChanges called with:", {
downloadSourceIds,
gamesCount: games.length,
since,
isLoggedIn: this.isLoggedIn(),
});
try {
const result = await this.post<
Array<{
shop: string;
objectId: string;
newDownloadOptionsCount: number;
downloadSourceIds: string[];
}>
>(
"/download-sources/changes",
{
downloadSourceIds,
games,
since,
},
{ needsAuth: true }
);
logger.info(
"HydraApi.checkDownloadSourcesChanges completed successfully:",
result
);
return result;
} catch (error) {
logger.error("HydraApi.checkDownloadSourcesChanges failed:", error);
throw error;
}
}
}

View File

@@ -19,3 +19,4 @@ export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./user";
export * from "./download-sources-checker";

View File

@@ -3,6 +3,10 @@ import { HydraApi } from "../hydra-api";
import { gamesSublevel, levelKeys } from "@main/level";
export const createGame = async (game: Game) => {
if (game.shop === "custom") {
return;
}
return HydraApi.post(`/profile/games`, {
objectId: game.objectId,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0),

View File

@@ -9,6 +9,8 @@ type ProfileGame = {
hasManuallyUpdatedPlaytime: boolean;
isFavorite?: boolean;
isPinned?: boolean;
achievementCount: number;
unlockedAchievementCount: number;
} & ShopAssets;
export const mergeWithRemoteGames = async () => {
@@ -39,6 +41,8 @@ export const mergeWithRemoteGames = async () => {
playTimeInMilliseconds: updatedPlayTime,
favorite: game.isFavorite ?? localGame.favorite,
isPinned: game.isPinned ?? localGame.isPinned,
achievementCount: game.achievementCount,
unlockedAchievementCount: game.unlockedAchievementCount,
});
} else {
await gamesSublevel.put(gameKey, {
@@ -55,18 +59,27 @@ export const mergeWithRemoteGames = async () => {
isDeleted: false,
favorite: game.isFavorite ?? false,
isPinned: game.isPinned ?? false,
achievementCount: game.achievementCount,
unlockedAchievementCount: game.unlockedAchievementCount,
});
}
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
// Construct coverImageUrl if not provided by backend (Steam games use predictable pattern)
const coverImageUrl =
game.coverImageUrl ||
(game.shop === "steam"
? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg`
: null);
await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(),
...localGameShopAsset,
shop: game.shop,
objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists
coverImageUrl: game.coverImageUrl,
coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl,
logoImageUrl: game.logoImageUrl,

View File

@@ -1,12 +1,16 @@
import type { Game } from "@types";
import { HydraApi } from "../hydra-api";
export const updateGamePlaytime = async (
export const trackGamePlaytime = async (
game: Game,
deltaInMillis: number,
lastTimePlayed: Date
) => {
return HydraApi.put(`/profile/games/${game.remoteId}`, {
if (game.shop === "custom") {
return;
}
return HydraApi.put(`/profile/games/${game.shop}/${game.objectId}`, {
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
lastTimePlayed,
});

View File

@@ -11,9 +11,10 @@ import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, UserPreferences, UserProfile } from "@types";
import { db, levelKeys } from "@main/level";
import { db, levelKeys, themesSublevel } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers";
async function downloadImage(url: string | null) {
if (!url) return undefined;
@@ -40,6 +41,27 @@ async function downloadImage(url: string | null) {
});
}
async function getAchievementSoundPath(): Promise<string> {
try {
const allThemes = await themesSublevel.values().all();
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme?.hasCustomSound) {
const themeSoundPath = getThemeSoundPath(
activeTheme.id,
activeTheme.name
);
if (themeSoundPath) {
return themeSoundPath;
}
}
} catch (error) {
logger.error("Failed to get theme sound path", error);
}
return achievementSoundPath;
}
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
@@ -145,7 +167,8 @@ export const publishCombinedNewAchievementNotification = async (
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath);
const soundPath = await getAchievementSoundPath();
sound.play(soundPath);
}
};
@@ -205,6 +228,7 @@ export const publishNewAchievementNotification = async (info: {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath);
const soundPath = await getAchievementSoundPath();
sound.play(soundPath);
}
};

View File

@@ -1,5 +1,5 @@
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import { createGame, trackGamePlaytime } from "./library-sync";
import type { Game, GameRunning, UserPreferences } from "@types";
import { PythonRPC } from "./python-rpc";
import axios from "axios";
@@ -198,11 +198,6 @@ export const watchProcesses = async () => {
function onOpenGame(game: Game) {
const now = performance.now();
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
lastTick: now,
firstTick: now,
@@ -220,8 +215,15 @@ function onOpenGame(game: Game) {
})
.catch(() => {});
if (game.shop === "custom") return;
AchievementWatcherManager.firstSyncWithRemoteIfNeeded(
game.shop,
game.objectId
);
if (game.remoteId) {
updateGamePlaytime(
trackGamePlaytime(
game,
game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
new Date()
@@ -255,43 +257,46 @@ function onTickGame(game: Game) {
const delta = now - gamePlaytime.lastTick;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
const updatedGame: Game = {
...game,
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
lastTimePlayed: new Date(),
});
};
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastTick: now,
});
if (currentTick % TICKS_TO_UPDATE_API === 0) {
if (currentTick % TICKS_TO_UPDATE_API === 0 && game.shop !== "custom") {
const deltaToSync =
now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
const gamePromise = game.remoteId
? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
: createGame(game);
gamePromise
.then(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
})
.finally(() => {
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
...gamePlaytime,
lastTick: now,
lastSyncTick: now,
});
});
@@ -299,11 +304,24 @@ function onTickGame(game: Game) {
}
const onCloseGame = (game: Game) => {
const now = performance.now();
const gamePlaytime = gamesPlaytime.get(
levelKeys.game(game.shop, game.objectId)
)!;
gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
const delta = now - gamePlaytime.lastTick;
const updatedGame: Game = {
...game,
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
lastTimePlayed: new Date(),
};
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame);
if (game.shop === "custom") return;
if (game.remoteId) {
if (game.automaticCloudSync) {
CloudSync.uploadSaveGame(
@@ -315,20 +333,20 @@ const onCloseGame = (game: Game) => {
}
const deltaToSync =
performance.now() -
now -
gamePlaytime.lastSyncTick +
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
.then(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: 0,
});
})
.catch(() => {
return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
...game,
...updatedGame,
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
});
});

View File

@@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => {
if (!steamGameUrl) return null;
return {
title: $title.textContent,
title: $title.getAttribute("data-title") || "",
objectId: steamGameUrl.split("/").pop(),
} as Steam250Game;
})

View File

@@ -25,6 +25,7 @@ import type {
} from "@types";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import { isStaging } from "@main/constants";
import { logger } from "./logger";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
@@ -54,21 +55,25 @@ export class WindowManager {
show: false,
};
private static formatVersionNumber(version: string) {
return version.replaceAll(".", "-");
}
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"]) {
window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`);
} else if (import.meta.env.MAIN_VITE_RENDERER_URL) {
} else if (import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN) {
// Try to load from remote URL in production
try {
await window.loadURL(
`${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}`
`https://release-v${this.formatVersionNumber(app.getVersion())}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${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:",
logger.error(
"Failed to load from MAIN_VITE_LAUNCHER_SUBDOMAIN, falling back to local file:",
error
);
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
@@ -284,12 +289,6 @@ export class WindowManager {
}
}
private static loadNotificationWindowURL() {
if (this.notificationWindow) {
this.loadWindowURL(this.notificationWindow, "achievement-notification");
}
}
private static readonly NOTIFICATION_WINDOW_WIDTH = 360;
private static readonly NOTIFICATION_WINDOW_HEIGHT = 140;
@@ -297,46 +296,58 @@ export class WindowManager {
position: AchievementCustomNotificationPosition | undefined
) {
const display = screen.getPrimaryDisplay();
const { width, height } = display.workAreaSize;
const {
x: displayX,
y: displayY,
width: displayWidth,
height: displayHeight,
} = display.bounds;
if (position === "bottom-left") {
return {
x: 0,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
x: displayX,
y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "top-left") {
return {
x: displayX,
y: displayY,
};
}
if (position === "top-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: 0,
x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: displayY,
};
}
if (position === "top-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: 0,
x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH,
y: displayY,
};
}
return {
x: 0,
y: 0,
x: displayX,
y: displayY,
};
}
@@ -382,7 +393,7 @@ export class WindowManager {
this.notificationWindow.setIgnoreMouseEvents(true);
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL();
this.loadWindowURL(this.notificationWindow, "achievement-notification");
if (!app.isPackaged || isStaging) {
this.notificationWindow.webContents.openDevTools();

View File

@@ -7,7 +7,7 @@ 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 MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
readonly ELECTRON_RENDERER_URL: string;
}

View File

@@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesCheckBaseline: () =>
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
getDownloadSourcesSinceValue: () =>
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
/* Library */
toggleAutomaticCloudSync: (
@@ -179,6 +183,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: (
@@ -196,6 +202,7 @@ contextBridge.exposeInMainWorld("electron", {
verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) =>
@@ -570,6 +577,25 @@ contextBridge.exposeInMainWorld("electron", {
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
copyThemeAchievementSound: (themeId: string, sourcePath: string) =>
ipcRenderer.invoke("copyThemeAchievementSound", themeId, sourcePath),
removeThemeAchievementSound: (themeId: string) =>
ipcRenderer.invoke("removeThemeAchievementSound", themeId),
getThemeSoundPath: (themeId: string) =>
ipcRenderer.invoke("getThemeSoundPath", themeId),
getThemeSoundDataUrl: (themeId: string) =>
ipcRenderer.invoke("getThemeSoundDataUrl", themeId),
importThemeSoundFromStore: (
themeId: string,
themeName: string,
storeUrl: string
) =>
ipcRenderer.invoke(
"importThemeSoundFromStore",
themeId,
themeName,
storeUrl
),
/* Editor */
openEditorWindow: (themeId: string) =>
@@ -580,6 +606,17 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-custom-theme-updated", listener);
},
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
gamesWithNewOptions: { gameId: string; count: number }[]
) => cb(gamesWithNewOptions);
ipcRenderer.on("on-new-download-options", listener);
return () =>
ipcRenderer.removeListener("on-new-download-options", listener);
},
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
});

View File

@@ -90,6 +90,7 @@ img {
progress[value] {
-webkit-appearance: none;
appearance: none;
}
.container {

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import {
@@ -10,6 +9,7 @@ import {
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
@@ -25,7 +25,12 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { injectCustomCss, removeCustomCss } from "./helpers";
import {
injectCustomCss,
removeCustomCss,
getAchievementSoundUrl,
getAchievementSoundVolume,
} from "./helpers";
import "./app.scss";
export interface AppProps {
@@ -36,6 +41,9 @@ export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary, library } = useLibrary();
// Listen for new download options updates
useDownloadOptionsListener();
const { t } = useTranslation("app");
const { clearDownload, setLastPacket } = useDownload();
@@ -216,9 +224,11 @@ export function App() {
return () => unsubscribe();
}, [loadAndApplyTheme]);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
const playAudio = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play();
}, []);
@@ -279,7 +289,11 @@ export function App() {
<article className="container">
<Header />
<section ref={contentRef} className="container__content">
<section
ref={contentRef}
id="scrollableDiv"
className="container__content"
>
<Outlet />
</section>
</article>

View File

@@ -47,7 +47,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
>
<div className="game-card__backdrop">
<img
src={game.libraryImageUrl}
src={game.libraryImageUrl ?? undefined}
alt={game.title}
className="game-card__cover"
loading="lazy"

View File

@@ -70,8 +70,10 @@ export function GameContextMenu({
onClick: () => {
if (isGameRunning) {
void handleCloseGame();
} else {
} else if (canPlay) {
void handlePlayGame();
} else {
handleOpenDownloadOptions();
}
},
disabled: isDeleting,

View File

@@ -7,12 +7,13 @@ import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import "./header.scss";
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters } from "@renderer/features";
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames";
const pathTitle: Record<string, string> = {
"/": "home",
"/catalogue": "catalogue",
"/library": "library",
"/downloads": "downloads",
"/settings": "settings",
};
@@ -27,10 +28,20 @@ export function Header() {
(state) => state.window
);
const searchValue = useAppSelector(
const catalogueSearchValue = useAppSelector(
(state) => state.catalogueSearch.filters.title
);
const librarySearchValue = useAppSelector(
(state) => state.library.searchQuery
);
const isOnLibraryPage = location.pathname.startsWith("/library");
const searchValue = isOnLibraryPage
? librarySearchValue
: catalogueSearchValue;
const dispatch = useAppDispatch();
const [isFocused, setIsFocused] = useState(false);
@@ -41,6 +52,8 @@ export function Header() {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/library"))
return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);
@@ -60,18 +73,29 @@ export function Header() {
};
const handleSearch = (value: string) => {
dispatch(setFilters({ title: value }));
if (isOnLibraryPage) {
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
} else {
dispatch(setFilters({ title: value.slice(0, 255) }));
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");
}
}
};
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");
const handleClearSearch = () => {
if (isOnLibraryPage) {
dispatch(setLibrarySearchQuery(""));
} else {
dispatch(setFilters({ title: "" }));
}
};
useEffect(() => {
if (!location.pathname.startsWith("/catalogue") && searchValue) {
if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) {
dispatch(setFilters({ title: "" }));
}
}, [location.pathname, searchValue, dispatch]);
}, [location.pathname, catalogueSearchValue, dispatch]);
return (
<>
@@ -120,7 +144,7 @@ export function Header() {
ref={inputRef}
type="text"
name="search"
placeholder={t("search")}
placeholder={isOnLibraryPage ? t("search_library") : t("search")}
value={searchValue}
className="header__search-input"
onChange={(event) => handleSearch(event.target.value)}
@@ -131,7 +155,7 @@ export function Header() {
{searchValue && (
<button
type="button"
onClick={() => dispatch(setFilters({ title: "" }))}
onClick={handleClearSearch}
className="header__action-button"
>
<XIcon />

View File

@@ -50,14 +50,14 @@ export function Hero() {
>
<div className="hero__backdrop">
<img
src={game.libraryHeroImageUrl}
src={game.libraryHeroImageUrl ?? undefined}
alt={game.description ?? ""}
className="hero__media"
/>
<div className="hero__content">
<img
src={game.logoImageUrl}
src={game.logoImageUrl ?? undefined}
width="250px"
alt={game.description ?? ""}
loading="eager"

View File

@@ -3,6 +3,7 @@ import {
DownloadIcon,
GearIcon,
HomeIcon,
BookIcon,
} from "@primer/octicons-react";
export const routes = [
@@ -16,6 +17,11 @@ export const routes = [
nameKey: "catalogue",
render: () => <AppsIcon />,
},
{
path: "/library",
nameKey: "library",
render: () => <BookIcon />,
},
{
path: "/downloads",
nameKey: "downloads",

View File

@@ -80,6 +80,12 @@ export function SidebarGameItem({
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
{(game.newDownloadOptionsCount ?? 0) > 0 && (
<span className="sidebar__game-badge">
+{game.newDownloadOptionsCount}
</span>
)}
</button>
</li>

View File

@@ -115,6 +115,19 @@
background-size: cover;
}
&__game-badge {
background-color: rgba(34, 197, 94, 0.15);
color: rgb(187, 247, 208);
font-size: 10px;
font-weight: 600;
padding: 4px 6px;
border-radius: 6px;
display: flex;
margin-left: auto;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(34, 197, 94, 0.5);
}
&__section-header {
display: flex;
justify-content: space-between;

View File

@@ -1,9 +1,7 @@
import React, { useId, useMemo, useState } from "react";
import React, { useId, useState } from "react";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import "./text-field.scss";
export interface TextFieldProps
@@ -42,44 +40,30 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
) => {
const id = useId();
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password";
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
const hintContent = useMemo(() => {
if (error)
return (
<small className="text-field-container__error-label">{error}</small>
);
if (hint) return <small>{hint}</small>;
return null;
}, [hint, error]);
const inputType =
props.type === "password" && isPasswordVisible
? "text"
: (props.type ?? "text");
const hintContent = error ? (
<small className="text-field-container__error-label">{error}</small>
) : hint ? (
<small>{hint}</small>
) : null;
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(true);
if (props.onFocus) props.onFocus(event);
props.onFocus?.(event);
};
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(false);
if (props.onBlur) props.onBlur(event);
props.onBlur?.(event);
};
const hasError = !!error;
return (
<div className="text-field-container" {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
<div className="text-field-container__text-field-wrapper">
<div
className={cn(
@@ -104,7 +88,6 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
onBlur={handleBlur}
type={inputType}
/>
{showPasswordToggleButton && (
<button
type="button"
@@ -120,14 +103,11 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
</button>
)}
</div>
{rightContent}
</div>
{hintContent}
</div>
);
}
);
TextField.displayName = "TextField";

View File

@@ -293,6 +293,8 @@ export function GameDetailsContextProvider({
}, [objectId, shop, userDetails]);
useEffect(() => {
if (shop === "custom") return;
const fetchDownloadSources = async () => {
try {
const sources = await window.electron.getDownloadSources();

View File

@@ -14,12 +14,15 @@ export interface UserProfileContext {
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>;
getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise<void>;
loadMoreLibraryGames: (sortBy?: string) => Promise<boolean>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
libraryGames: UserGame[];
pinnedGames: UserGame[];
hasMoreLibraryGames: boolean;
isLoadingLibraryGames: boolean;
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -30,12 +33,15 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false,
userStats: null,
getUserProfile: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {},
getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {},
loadMoreLibraryGames: async (_sortBy?: string) => false,
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
libraryGames: [],
pinnedGames: [],
hasMoreLibraryGames: false,
isLoadingLibraryGames: false,
});
const { Provider } = userProfileContext;
@@ -62,6 +68,9 @@ export function UserProfileContextProvider({
DEFAULT_USER_PROFILE_BACKGROUND
);
const [selectedBackgroundImage, setSelectedBackgroundImage] = useState("");
const [libraryPage, setLibraryPage] = useState(0);
const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true);
const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
@@ -93,7 +102,13 @@ export function UserProfileContextProvider({
}, [userId]);
const getUserLibraryGames = useCallback(
async (sortBy?: string) => {
async (sortBy?: string, reset = true) => {
if (reset) {
setLibraryPage(0);
setHasMoreLibraryGames(true);
setIsLoadingLibraryGames(true);
}
try {
const params = new URLSearchParams();
params.append("take", "12");
@@ -115,18 +130,74 @@ export function UserProfileContextProvider({
if (response) {
setLibraryGames(response.library);
setPinnedGames(response.pinnedGames);
setHasMoreLibraryGames(response.library.length === 12);
} else {
setLibraryGames([]);
setPinnedGames([]);
setHasMoreLibraryGames(false);
}
} catch (error) {
setLibraryGames([]);
setPinnedGames([]);
setHasMoreLibraryGames(false);
} finally {
setIsLoadingLibraryGames(false);
}
},
[userId]
);
const loadMoreLibraryGames = useCallback(
async (sortBy?: string): Promise<boolean> => {
if (isLoadingLibraryGames || !hasMoreLibraryGames) {
return false;
}
setIsLoadingLibraryGames(true);
try {
const nextPage = libraryPage + 1;
const params = new URLSearchParams();
params.append("take", "12");
params.append("skip", String(nextPage * 12));
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 && response.library.length > 0) {
setLibraryGames((prev) => {
const existingIds = new Set(prev.map((game) => game.objectId));
const newGames = response.library.filter(
(game) => !existingIds.has(game.objectId)
);
return [...prev, ...newGames];
});
setLibraryPage(nextPage);
setHasMoreLibraryGames(response.library.length === 12);
return true;
} else {
setHasMoreLibraryGames(false);
return false;
}
} catch (error) {
setHasMoreLibraryGames(false);
return false;
} finally {
setIsLoadingLibraryGames(false);
}
},
[userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames]
);
const getUserProfile = useCallback(async () => {
getUserStats();
getUserLibraryGames();
@@ -164,6 +235,8 @@ export function UserProfileContextProvider({
setLibraryGames([]);
setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
setLibraryPage(0);
setHasMoreLibraryGames(true);
getUserProfile();
getBadges();
@@ -177,12 +250,15 @@ export function UserProfileContextProvider({
isMe,
getUserProfile,
getUserLibraryGames,
loadMoreLibraryGames,
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats,
badges,
libraryGames,
pinnedGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
}}
>
{children}

View File

@@ -142,6 +142,10 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
clearNewDownloadOptions: (
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
@@ -159,6 +163,7 @@ declare global {
) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -214,6 +219,8 @@ declare global {
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
@@ -409,11 +416,28 @@ declare global {
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
copyThemeAchievementSound: (
themeId: string,
sourcePath: string
) => Promise<void>;
removeThemeAchievementSound: (themeId: string) => Promise<void>;
getThemeSoundPath: (themeId: string) => Promise<string | null>;
getThemeSoundDataUrl: (themeId: string) => Promise<string | null>;
importThemeSoundFromStore: (
themeId: string,
themeName: string,
storeUrl: string
) => Promise<void>;
/* Editor */
openEditorWindow: (themeId: string) => Promise<void>;
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>;
/* Download Options */
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => () => Electron.IpcRenderer;
}
interface Window {

View File

@@ -5,10 +5,12 @@ import type { LibraryGame } from "@types";
export interface LibraryState {
value: LibraryGame[];
searchQuery: string;
}
const initialState: LibraryState = {
value: [],
searchQuery: "",
};
export const librarySlice = createSlice({
@@ -18,7 +20,34 @@ export const librarySlice = createSlice({
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
state.value = action.payload;
},
updateGameNewDownloadOptions: (
state,
action: PayloadAction<{ gameId: string; count: number }>
) => {
const game = state.value.find((g) => g.id === action.payload.gameId);
if (game) {
game.newDownloadOptionsCount = action.payload.count;
}
},
clearNewDownloadOptions: (
state,
action: PayloadAction<{ gameId: string }>
) => {
const game = state.value.find((g) => g.id === action.payload.gameId);
if (game) {
game.newDownloadOptionsCount = undefined;
}
},
setLibrarySearchQuery: (state, action: PayloadAction<string>) => {
state.searchQuery = action.payload;
},
},
});
export const { setLibrary } = librarySlice.actions;
export const {
setLibrary,
updateGameNewDownloadOptions,
clearNewDownloadOptions,
setLibrarySearchQuery,
} = librarySlice.actions;

View File

@@ -121,3 +121,35 @@ export const formatNumber = (num: number): string => {
export const generateUUID = (): string => {
return uuidv4();
};
export const getAchievementSoundUrl = async (): Promise<string> => {
const defaultSound = (await import("@renderer/assets/audio/achievement.wav"))
.default;
try {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.hasCustomSound) {
const soundDataUrl = await window.electron.getThemeSoundDataUrl(
activeTheme.id
);
if (soundDataUrl) {
return soundDataUrl;
}
}
} catch (error) {
console.error("Failed to get theme sound", error);
}
return defaultSound;
};
export const getAchievementSoundVolume = async (): Promise<number> => {
try {
const prefs = await window.electron.getUserPreferences();
return prefs?.achievementSoundVolume ?? 0.15;
} catch (error) {
console.error("Failed to get sound volume", error);
return 0.15;
}
};

View File

@@ -6,3 +6,5 @@ export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
export * from "./use-feature";
export * from "./use-download-options-listener";
export * from "./use-game-card";

View File

@@ -0,0 +1,19 @@
import { useEffect } from "react";
import { useAppDispatch } from "./redux";
import { updateGameNewDownloadOptions } from "@renderer/features";
export function useDownloadOptionsListener() {
const dispatch = useAppDispatch();
useEffect(() => {
const unsubscribe = window.electron.onNewDownloadOptions(
(gamesWithNewOptions) => {
for (const { gameId, count } of gamesWithNewOptions) {
dispatch(updateGameNewDownloadOptions({ gameId, count }));
}
}
);
return unsubscribe;
}, [dispatch]);
}

View File

@@ -0,0 +1,66 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useFormat } from "./use-format";
import { useTranslation } from "react-i18next";
import { buildGameDetailsPath } from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { LibraryGame } from "@types";
export function useGameCard(
game: LibraryGame,
onContextMenu: (game: LibraryGame, position: { x: number; y: number }) => void
) {
const { t } = useTranslation("library");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const formatPlayTime = useCallback(
(playTimeInMilliseconds = 0, isShort = false) => {
const minutes = playTimeInMilliseconds / 60000;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t(isShort ? "amount_minutes_short" : "amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
const hoursKey = isShort ? "amount_hours_short" : "amount_hours";
const hoursAmount = isShort
? Math.floor(hours)
: numberFormatter.format(hours);
return t(hoursKey, { amount: hoursAmount });
},
[numberFormatter, t]
);
const handleCardClick = useCallback(() => {
navigate(buildGameDetailsPath(game));
}, [navigate, game]);
const handleContextMenuClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(game, { x: e.clientX, y: e.clientY });
},
[game, onContextMenu]
);
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
onContextMenu(game, { x: rect.right, y: rect.bottom });
},
[game, onContextMenu]
);
return {
formatPlayTime,
handleCardClick,
handleContextMenuClick,
handleMenuButtonClick,
};
}

View File

@@ -7,9 +7,25 @@ export function useLibrary() {
const library = useAppSelector((state) => state.library.value);
const updateLibrary = useCallback(async () => {
return window.electron
.getLibrary()
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary)));
return window.electron.getLibrary().then(async (updatedLibrary) => {
const libraryWithAchievements = await Promise.all(
updatedLibrary.map(async (game) => {
const unlockedAchievements =
await window.electron.getUnlockedAchievements(
game.objectId,
game.shop
);
return {
...game,
unlockedAchievementCount:
game.unlockedAchievementCount || unlockedAchievements.length,
};
})
);
dispatch(setLibrary(libraryWithAchievements));
});
}, [dispatch]);
return { library, updateLibrary };

View File

@@ -3,12 +3,14 @@ import { useState, useCallback } from "react";
interface SectionCollapseState {
pinned: boolean;
library: boolean;
reviews: boolean;
}
export function useSectionCollapse() {
const [collapseState, setCollapseState] = useState<SectionCollapseState>({
pinned: false,
library: false,
reviews: false,
});
const toggleSection = useCallback((section: keyof SectionCollapseState) => {
@@ -23,5 +25,6 @@ export function useSectionCollapse() {
toggleSection,
isPinnedCollapsed: collapseState.pinned,
isLibraryCollapsed: collapseState.library,
isReviewsCollapsed: collapseState.reviews,
};
}

View File

@@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log;
@@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route element={<App />}>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/library" element={<Library />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import {
injectCustomCss,
removeCustomCss,
getAchievementSoundUrl,
getAchievementSoundVolume,
} from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import app from "../../../app.scss?inline";
import styles from "../../../components/achievements/notification/achievement-notification.scss?inline";
@@ -33,9 +37,11 @@ export function AchievementNotification() {
const [shadowRootRef, setShadowRootRef] = useState<HTMLElement | null>(null);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.1;
const playAudio = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play();
}, []);

View File

@@ -35,7 +35,7 @@ export default function Catalogue() {
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
const { steamGenres, steamUserTags, filters, page } = useAppSelector(
(state) => state.catalogueSearch
);
@@ -47,8 +47,6 @@ export default function Catalogue() {
const { formatNumber } = useFormat();
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
const dispatch = useAppDispatch();
const { t, i18n } = useTranslation("catalogue");

View File

@@ -29,9 +29,11 @@ function JumpControl({
return isOpen ? (
<input
ref={inputRef}
type="number"
type="text"
min={1}
max={totalPages}
inputMode="numeric"
pattern="[0-9]*"
className="pagination__page-input"
value={value}
onChange={onChange}
@@ -56,7 +58,7 @@ export function Pagination({
page,
totalPages,
onPageChange,
}: PaginationProps) {
}: Readonly<PaginationProps>) {
const { formatNumber } = useFormat();
const [isJumpOpen, setIsJumpOpen] = useState(false);
@@ -87,13 +89,15 @@ export function Pagination({
}
const onJumpChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
const raw = e.target.value;
const digitsOnly = raw.replaceAll(/\D+/g, "");
if (digitsOnly === "") {
setJumpValue("");
return;
}
const num = Number(val);
const num = Number.parseInt(digitsOnly, 10);
if (Number.isNaN(num)) {
setJumpValue("");
return;
}
if (num < 1) {
@@ -104,19 +108,36 @@ export function Pagination({
setJumpValue(String(totalPages));
return;
}
setJumpValue(val);
setJumpValue(String(num));
};
const onJumpKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const controlKeys = [
"Backspace",
"Delete",
"Tab",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
];
if (controlKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === "Enter") {
if (jumpValue.trim() === "") return;
const parsed = Number(jumpValue);
const sanitized = jumpValue.replaceAll(/\D+/g, "");
if (sanitized.trim() === "") return;
const parsed = Number.parseInt(sanitized, 10);
if (Number.isNaN(parsed)) return;
const target = Math.max(1, Math.min(totalPages, parsed));
onPageChange(target);
setIsJumpOpen(false);
} else if (e.key === "Escape") {
setIsJumpOpen(false);
} else if (!/^\d$/.test(e.key)) {
e.preventDefault();
}
};

View File

@@ -163,7 +163,6 @@ export function GameReviews({
take: "20",
skip: skip.toString(),
sortBy: reviewsSortBy,
language: i18n.language,
});
const response = await window.electron.hydraApi.get(

View File

@@ -231,44 +231,50 @@ $hero-height: 350px;
}
&__randomizer-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
position: fixed;
bottom: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 2);
z-index: 100;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
box-shadow:
0px 0px 10px 0px rgba(0, 0, 0, 0.8),
0px 2px 8px 0px rgba(255, 255, 255, 0.1);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
overflow: visible;
&:active {
opacity: 0.9;
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(255, 255, 255, 0.12);
color: globals.$body-color;
}
}
&__stars-icon-container {
width: 20px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__stars-icon {
width: 26px;
width: 70px;
position: absolute;
top: -3px;
top: -28px;
left: -27px;
}
}

View File

@@ -45,12 +45,26 @@
&__repack-title {
color: globals.$muted-color;
word-break: break-word;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
}
&__repack-info {
font-size: globals.$small-font-size;
}
&__new-badge {
background-color: rgba(34, 197, 94, 0.15);
color: rgb(187, 247, 208);
padding: 2px 8px;
border-radius: 6px;
font-size: 9px;
text-align: center;
flex-shrink: 0;
border: 1px solid rgba(34, 197, 94, 0.5);
}
&__no-results {
width: 100%;
padding: calc(globals.$spacing-unit * 4) 0;

View File

@@ -15,14 +15,14 @@ import {
TextField,
CheckboxField,
} from "@renderer/components";
import type { DownloadSource } from "@types";
import type { GameRepack } from "@types";
import type { DownloadSource, GameRepack } from "@types";
import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
import { useDate, useFeature } from "@renderer/hooks";
import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
import { clearNewDownloadOptions } from "@renderer/features";
import "./repacks-modal.scss";
export interface RepacksModalProps {
@@ -53,6 +53,13 @@ export function RepacksModal({
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
{}
);
const [lastCheckTimestamp, setLastCheckTimestamp] = useState<string | null>(
null
);
const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true);
const [viewedRepackIds, setViewedRepackIds] = useState<Set<string>>(
new Set()
);
const { game, repacks } = useContext(gameDetailsContext);
@@ -60,6 +67,7 @@ export function RepacksModal({
const { formatDate } = useDate();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") {
@@ -97,6 +105,34 @@ export function RepacksModal({
fetchDownloadSources();
}, []);
useEffect(() => {
const fetchLastCheckTimestamp = async () => {
setIsLoadingTimestamp(true);
const timestamp = await window.electron.getDownloadSourcesSinceValue();
setLastCheckTimestamp(timestamp);
setIsLoadingTimestamp(false);
};
if (visible) {
fetchLastCheckTimestamp();
}
}, [visible, repacks]);
useEffect(() => {
if (
visible &&
game?.newDownloadOptionsCount &&
game.newDownloadOptionsCount > 0
) {
globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId);
const gameId = `${game.shop}:${game.objectId}`;
dispatch(clearNewDownloadOptions({ gameId }));
}
}, [visible, game, dispatch]);
const sortedRepacks = useMemo(() => {
return orderBy(
repacks,
@@ -139,6 +175,7 @@ export function RepacksModal({
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
setShowSelectFolderModal(true);
setViewedRepackIds((prev) => new Set(prev).add(repack.id));
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
@@ -158,6 +195,20 @@ export function RepacksModal({
return repack.uris.some((uri) => uri.includes(game.download!.uri));
};
const isNewRepack = (repack: GameRepack): boolean => {
if (isLoadingTimestamp) return false;
if (viewedRepackIds.has(repack.id)) return false;
if (!lastCheckTimestamp || !repack.createdAt) {
return false;
}
const lastCheckUtc = new Date(lastCheckTimestamp).toISOString();
return repack.createdAt > lastCheckUtc;
};
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
useEffect(() => {
@@ -273,7 +324,14 @@ export function RepacksModal({
onClick={() => handleRepackClick(repack)}
className="repacks-modal__repack-button"
>
<p className="repacks-modal__repack-title">{repack.title}</p>
<p className="repacks-modal__repack-title">
{repack.title}
{isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
</p>
{isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge>

View File

@@ -8,11 +8,23 @@
&__review-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1);
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
&__review-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
&__review-header-bottom {
display: flex;
justify-content: flex-start;
align-items: center;
}
&__review-user {
display: flex;
align-items: center;
@@ -22,7 +34,13 @@
&__review-user-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.25);
gap: calc(globals.$spacing-unit * 0.45);
}
&__review-meta-row {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
}
&__review-display-name {
@@ -157,28 +175,28 @@
&__review-score-stars {
display: flex;
align-items: center;
gap: 2px;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px;
font-weight: 500;
}
&__review-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
&__review-star {
color: #666666;
color: rgba(255, 255, 255, 0.7);
transition: color 0.2s ease;
cursor: default;
&--filled {
color: #ffffff;
&.game-details__review-score--red {
color: #fca5a5;
}
&.game-details__review-score--yellow {
color: #fcd34d;
}
&.game-details__review-score--green {
color: #86efac;
}
color: rgba(255, 255, 255, 0.7);
}
&--empty {
@@ -198,6 +216,24 @@
font-size: globals.$small-font-size;
}
&__review-playtime {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 500;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0;
svg {
color: rgba(255, 255, 255, 0.6);
}
}
&__review-content {
color: globals.$body-color;
line-height: 1.5;

View File

@@ -7,9 +7,10 @@ import { useState } from "react";
import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared";
import { useDate } from "@renderer/hooks";
import { useDate, useFormat } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./review-item.scss";
@@ -29,13 +30,6 @@ interface ReviewItemProps {
) => 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:
@@ -68,28 +62,22 @@ export function ReviewItem({
const navigate = useNavigate();
const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate();
const { numberFormatter } = useFormat();
const [showOriginal, setShowOriginal] = useState(false);
// Check if this is the user's own review
const isOwnReview = userDetailsId === review.user.id;
// Helper to get base language code (e.g., "pt" from "pt-BR")
const getBaseLanguage = (lang: string) => lang.split("-")[0];
const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || "";
// Check if the review is in a different language (comparing base language codes)
const isDifferentLanguage =
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
// Check if translation is available and needed (but not for own reviews)
const needsTranslation =
!isOwnReview &&
isDifferentLanguage &&
review.translations &&
review.translations[i18n.language];
!isOwnReview && isDifferentLanguage && review.translations[i18n.language];
// Get the full language name using Intl.DisplayNames
const getLanguageName = (languageCode: string) => {
const getLanguageName = (languageCode: string | null) => {
if (!languageCode) return "";
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
@@ -100,6 +88,20 @@ export function ReviewItem({
}
};
// Format playtime similar to hero panel
const formatPlayTime = (playTimeInSeconds: number) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
// Determine which content to show - always show original for own reviews
const displayContent = needsTranslation
? review.translations[i18n.language]
@@ -109,12 +111,12 @@ export function ReviewItem({
return (
<div className="game-details__review-item">
<div className="game-details__blocked-review-simple">
Review from blocked user {" "}
{t("review_from_blocked_user")}
<button
className="game-details__blocked-review-show-link"
onClick={() => onToggleVisibility(review.id)}
>
Show
{t("show")}
</button>
</div>
</div>
@@ -124,54 +126,61 @@ export function ReviewItem({
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">
<div className="game-details__review-header-top">
<div className="game-details__review-user">
<button
className="game-details__review-display-name game-details__review-display-name--clickable"
onClick={() =>
review.user.id && navigate(`/profile/${review.user.id}`)
}
onClick={() => navigate(`/profile/${review.user.id}`)}
title={review.user.displayName}
>
{review.user.displayName || "Anonymous"}
<Avatar
src={review.user.profileImageUrl}
alt={review.user.displayName || "User"}
size={40}
/>
</button>
<div className="game-details__review-date">
<ClockIcon size={12} />
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
<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>
</div>
<div className="game-details__review-date">
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
</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 className="game-details__review-header-bottom">
<div className="game-details__review-meta-row">
<div
className="game-details__review-score-stars"
title={getRatingText(review.score, t)}
>
<Star
size={12}
className="game-details__review-star game-details__review-star--filled"
/>
<span className="game-details__review-score-text">
{review.score}/5
</span>
</div>
{Boolean(
review.playTimeInSeconds && review.playTimeInSeconds > 0
) && (
<div className="game-details__review-playtime">
<ClockIcon size={12} />
<span>
{t("review_played_for")}{" "}
{formatPlayTime(review.playTimeInSeconds || 0)}
</span>
</div>
)}
</div>
</div>
</div>
<div>
@@ -323,7 +332,7 @@ export function ReviewItem({
className="game-details__blocked-review-hide-link"
onClick={() => onToggleVisibility(review.id)}
>
Hide
{t("hide")}
</button>
)}
</div>

View File

@@ -0,0 +1,55 @@
@use "../../scss/globals.scss";
.library-filter-options {
&__tabs {
display: flex;
gap: calc(globals.$spacing-unit);
position: relative;
}
&__tab-wrapper {
position: relative;
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&--active {
color: white;
}
}
&__tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1;
}
&__tab-underline {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: white;
}
}

View File

@@ -0,0 +1,103 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import "./filter-options.scss";
export type FilterOption = "all" | "recently_played" | "favorites";
interface FilterOptionsProps {
filterBy: FilterOption;
onFilterChange: (filterBy: FilterOption) => void;
allGamesCount: number;
recentlyPlayedCount: number;
favoritesCount: number;
}
export function FilterOptions({
filterBy,
onFilterChange,
allGamesCount,
recentlyPlayedCount,
favoritesCount,
}: Readonly<FilterOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-filter-options__tabs">
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "all" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("all")}
>
{t("all_games")}
{allGamesCount > 0 && (
<span className="library-filter-options__tab-badge">
{allGamesCount}
</span>
)}
</button>
{filterBy === "all" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "recently_played" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("recently_played")}
>
{t("recently_played")}
{recentlyPlayedCount > 0 && (
<span className="library-filter-options__tab-badge">
{recentlyPlayedCount}
</span>
)}
</button>
{filterBy === "recently_played" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "favorites" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("favorites")}
>
{t("favorites")}
{favoritesCount > 0 && (
<span className="library-filter-options__tab-badge">
{favoritesCount}
</span>
)}
</button>
{filterBy === "favorites" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
@use "../../scss/globals.scss";
.library-game-card-large {
width: 100%;
height: 300px;
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
transition: all ease 0.2s;
cursor: pointer;
display: flex;
align-items: center;
text-align: left;
&:before {
content: "";
top: 0;
left: 0;
width: 100%;
height: 172%;
position: absolute;
background: linear-gradient(
35deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.07) 51.5%,
rgba(255, 255, 255, 0.15) 64%,
rgba(255, 255, 255, 0.1) 100%
);
transition: all ease 0.3s;
transform: translateY(-36%);
opacity: 0.5;
z-index: 1;
}
&:hover::before {
opacity: 1;
transform: translateY(-20%);
}
&:hover {
transform: scale(1.01);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.1);
}
&__background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 0;
}
&__gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%);
z-index: 1;
}
&__overlay {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: calc(globals.$spacing-unit * 1.5);
}
&__top-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: calc(globals.$spacing-unit);
}
&__logo-container {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
}
&__logo {
max-height: 120px;
max-width: 400px;
width: auto;
height: auto;
object-fit: contain;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6));
}
&__title {
font-size: 28px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
&__info-bar {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
justify-content: flex-end;
}
&__playtime {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.95);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 4px;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
font-size: 12px;
}
&__playtime-text {
font-weight: 500;
}
&__manual-playtime {
color: globals.$warning-color;
}
&__achievements {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
}
&__achievement-header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__achievements-gap {
display: flex;
align-items: center;
gap: 6px;
}
&__achievement-trophy {
color: #fff;
flex-shrink: 0;
}
&__achievement-progress {
width: 100%;
height: 4px;
transition: all ease 0.2s;
background-color: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: hidden;
&::-webkit-progress-bar {
background-color: transparent;
border-radius: 4px;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
border-radius: 4px;
}
}
&__achievement-bar {
height: 100%;
background-color: globals.$muted-color;
border-radius: 4px;
transition: width 0.3s ease;
}
&__achievement-count {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
}
&__achievement-percentage {
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
}

View File

@@ -0,0 +1,144 @@
import { LibraryGame } from "@types";
import { useGameCard } from "@renderer/hooks";
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
import { memo, useMemo } from "react";
import "./library-game-card-large.scss";
interface LibraryGameCardLargeProps {
game: LibraryGame;
onContextMenu: (
game: LibraryGame,
position: { x: number; y: number }
) => void;
}
const normalizePathForCss = (url: string | null | undefined): string => {
if (!url) return "";
return url.replaceAll("\\", "/");
};
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
originalUrl: string | null | undefined,
fallbackUrl?: string | null | undefined
) => {
const selectedUrl = customUrl || originalUrl || fallbackUrl || "";
return normalizePathForCss(selectedUrl);
};
export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
game,
onContextMenu,
}: Readonly<LibraryGameCardLargeProps>) {
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
useGameCard(game, onContextMenu);
const backgroundImage = useMemo(
() =>
getImageWithCustomPriority(
game.customHeroImageUrl,
game.libraryHeroImageUrl,
game.libraryImageUrl ?? game.iconUrl
),
[
game.customHeroImageUrl,
game.libraryHeroImageUrl,
game.libraryImageUrl,
game.iconUrl,
]
);
const backgroundStyle = useMemo(
() =>
backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {},
[backgroundImage]
);
const achievementBarStyle = useMemo(
() => ({
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}),
[game.unlockedAchievementCount, game.achievementCount]
);
const logoImage = game.customLogoImageUrl ?? game.logoImageUrl;
return (
<button
type="button"
className="library-game-card-large"
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div
className="library-game-card-large__background"
style={backgroundStyle}
/>
<div className="library-game-card-large__gradient" />
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card-large__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card-large__playtime-text">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
</div>
</div>
<div className="library-game-card-large__logo-container">
{logoImage ? (
<img
src={logoImage}
alt={game.title}
className="library-game-card-large__logo"
/>
) : (
<h3 className="library-game-card-large__title">{game.title}</h3>
)}
</div>
<div className="library-game-card-large__info-bar">
{/* Achievements section */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card-large__achievements">
<div className="library-game-card-large__achievement-header">
<div className="library-game-card-large__achievements-gap">
<TrophyIcon
size={14}
className="library-game-card-large__achievement-trophy"
/>
<span className="library-game-card-large__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card-large__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card-large__achievement-progress">
<div
className="library-game-card-large__achievement-bar"
style={achievementBarStyle}
/>
</div>
</div>
)}
</div>
</div>
</button>
);
});

View File

@@ -0,0 +1,241 @@
@use "../../scss/globals.scss";
.library-game-card {
&__wrapper {
cursor: pointer;
transition: all ease 0.2s;
box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5);
width: 100%;
aspect-ratio: 3 / 4;
position: relative;
border: none;
background: none;
padding: 0;
border-radius: 4px;
overflow: hidden;
display: block;
container-type: inline-size;
&:before {
content: "";
top: 0;
left: 0;
width: 100%;
height: 172%;
position: absolute;
background: linear-gradient(
35deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.07) 51.5%,
rgba(255, 255, 255, 0.15) 64%,
rgba(255, 255, 255, 0.1) 100%
);
transition: all ease 0.3s;
transform: translateY(-36%);
opacity: 0.5;
z-index: 1;
}
&:hover {
transform: scale(1.02);
}
&:hover::before {
opacity: 1;
transform: translateY(-20%);
}
}
&__overlay {
position: absolute;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
height: 100%;
width: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%);
padding: 8px;
z-index: 2;
}
&__top-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
}
&__playtime {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.8);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all ease 0.2s;
&-long {
display: inline;
font-size: 12px;
}
&-short {
display: none;
font-size: 12px;
}
// When the card is narrow (less than 140px), show short format
@container (max-width: 140px) {
&-long {
display: none;
}
&-short {
display: inline;
}
}
}
&__manual-playtime {
color: globals.$warning-color;
}
&__achievements {
display: flex;
flex-direction: column;
opacity: 1;
transform: translateY(0);
transition: all ease 0.2s;
pointer-events: auto;
width: 100%;
}
&__achievement-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
color: globals.$muted-color;
overflow: hidden;
height: 18px;
}
&__achievements-gap {
display: flex;
align-items: center;
gap: 8px;
}
&__achievement-trophy {
color: #fff;
flex-shrink: 0;
}
&__achievement-progress {
width: 100%;
height: 4px;
transition: all ease 0.2s;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 4px;
overflow: hidden;
&::-webkit-progress-bar {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
border-radius: 4px;
}
}
&__achievement-bar {
height: 100%;
background-color: globals.$muted-color;
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
&__achievement-count {
white-space: nowrap;
}
&__achievement-percentage {
white-space: nowrap;
}
&__action-button {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: solid 1px rgba(255, 255, 255, 0.2);
border-radius: 4px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all ease 0.2s;
color: rgba(255, 255, 255, 0.9);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: scale(0.9);
&:hover {
background: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.4);
transform: scale(1.1);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
}
&:active {
transform: scale(0.95);
}
}
&__wrapper:hover &__action-button {
opacity: 1;
transform: scale(1);
}
&__game-image {
object-fit: cover;
border-radius: 4px;
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
display: block;
top: 0;
left: 0;
z-index: 0;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Responsive sizing for compact grid cells */
.library__games-grid--compact .library-game-card__wrapper {
width: 100%;
height: auto;
aspect-ratio: 215 / 320;
}

View File

@@ -0,0 +1,109 @@
import { LibraryGame } from "@types";
import { useGameCard } from "@renderer/hooks";
import { memo } from "react";
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
import "./library-game-card.scss";
interface LibraryGameCardProps {
game: LibraryGame;
onMouseEnter: () => void;
onMouseLeave: () => void;
onContextMenu: (
game: LibraryGame,
position: { x: number; y: number }
) => void;
onShowTooltip?: (gameId: string) => void;
onHideTooltip?: () => void;
}
export const LibraryGameCard = memo(function LibraryGameCard({
game,
onMouseEnter,
onMouseLeave,
onContextMenu,
}: Readonly<LibraryGameCardProps>) {
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
useGameCard(game, onContextMenu);
const coverImage = (
game.customIconUrl ??
game.coverImageUrl ??
game.libraryImageUrl ??
game.libraryHeroImageUrl ??
game.iconUrl ??
""
).replaceAll("\\", "/");
return (
<button
type="button"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="library-game-card__wrapper"
title={game.title}
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div className="library-game-card__overlay">
<div className="library-game-card__top-section">
<div className="library-game-card__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card__playtime-long">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
<span className="library-game-card__playtime-short">
{formatPlayTime(game.playTimeInMilliseconds, true)}
</span>
</div>
</div>
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card__achievements">
<div className="library-game-card__achievement-header">
<div className="library-game-card__achievements-gap">
<TrophyIcon
size={13}
className="library-game-card__achievement-trophy"
/>
<span className="library-game-card__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card__achievement-progress">
<div
className="library-game-card__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
</div>
<img
src={coverImage ?? undefined}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
/>
</button>
);
});

View File

@@ -0,0 +1,208 @@
@use "../../scss/globals.scss";
.library {
&__content {
padding: calc(globals.$spacing-unit * 2);
height: 100%;
width: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
align-items: flex-start;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
&__page-header {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
width: 100%;
}
&__page-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
}
&__controls-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
&__controls-left {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
margin-right: calc(globals.$spacing-unit * 2);
}
&__controls-right {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
}
&__header-controls {
display: flex;
flex-direction: column;
align-items: end;
gap: calc(globals.$spacing-unit * 1);
&__left {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1);
}
}
&__header-title {
font-size: 20px;
font-weight: 700;
}
&__filter-label {
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
&__separator {
width: 100%;
height: 1px;
background: rgba(255, 255, 255, 0.1);
border: none;
margin: 0;
}
&__count {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 8px 16px;
}
&__count-label {
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
font-weight: 500;
}
&__count-number {
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
font-weight: 600;
}
&__no-games {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 4);
}
&__telescope-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__games-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: calc(globals.$spacing-unit * 2);
width: 100%;
// Grid view - larger cards
&--grid {
grid-template-columns: repeat(2, 1fr);
@container #{globals.$app-container} (min-width: 900px) {
grid-template-columns: repeat(4, 1fr);
}
@container #{globals.$app-container} (min-width: 1300px) {
grid-template-columns: repeat(5, 1fr);
}
@container #{globals.$app-container} (min-width: 2000px) {
grid-template-columns: repeat(6, 1fr);
}
@container #{globals.$app-container} (min-width: 2600px) {
grid-template-columns: repeat(8, 1fr);
}
@container #{globals.$app-container} (min-width: 3000px) {
grid-template-columns: repeat(12, 1fr);
}
}
// Compact view - smaller cards with responsive design
&--compact {
grid-template-columns: repeat(3, 1fr);
@container #{globals.$app-container} (min-width: 900px) {
grid-template-columns: repeat(5, 1fr);
}
@container #{globals.$app-container} (min-width: 1300px) {
grid-template-columns: repeat(7, 1fr);
}
@container #{globals.$app-container} (min-width: 2000px) {
grid-template-columns: repeat(9, 1fr);
}
@container #{globals.$app-container} (min-width: 2600px) {
grid-template-columns: repeat(12, 1fr);
}
@container #{globals.$app-container} (min-width: 3000px) {
grid-template-columns: repeat(14, 1fr);
}
}
}
&__games-list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
width: 100%;
// Large view - 2 columns grid
&--large {
display: grid;
grid-template-columns: repeat(1, 1fr);
@container #{globals.$app-container} (min-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
}
}
}

View File

@@ -0,0 +1,235 @@
import { useEffect, useMemo, useState, useCallback } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { LibraryGame } from "@types";
import { GameContextMenu } from "@renderer/components";
import { LibraryGameCard } from "./library-game-card";
import { LibraryGameCardLarge } from "./library-game-card-large";
import { ViewOptions, ViewMode } from "./view-options";
import { FilterOptions, FilterOption } from "./filter-options";
import "./library.scss";
export default function Library() {
const { library, updateLibrary } = useLibrary();
type ElectronAPI = {
refreshLibraryAssets?: () => Promise<unknown>;
onLibraryBatchComplete?: (cb: () => void) => () => void;
};
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const savedViewMode = localStorage.getItem("library-view-mode");
return (savedViewMode as ViewMode) || "compact";
});
const [filterBy, setFilterBy] = useState<FilterOption>("all");
const [contextMenu, setContextMenu] = useState<{
game: LibraryGame | null;
visible: boolean;
position: { x: number; y: number };
}>({ game: null, visible: false, position: { x: 0, y: 0 } });
const searchQuery = useAppSelector((state) => state.library.searchQuery);
const dispatch = useAppDispatch();
const { t } = useTranslation("library");
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
localStorage.setItem("library-view-mode", mode);
}, []);
useEffect(() => {
dispatch(setHeaderTitle(t("library")));
const electron = (globalThis as unknown as { electron?: ElectronAPI })
.electron;
let unsubscribe: () => void = () => undefined;
if (electron?.refreshLibraryAssets) {
electron
.refreshLibraryAssets()
.then(() => updateLibrary())
.catch(() => updateLibrary());
if (electron.onLibraryBatchComplete) {
unsubscribe = electron.onLibraryBatchComplete(() => {
updateLibrary();
});
}
} else {
updateLibrary();
}
return () => {
unsubscribe();
};
}, [dispatch, t, updateLibrary]);
const handleOnMouseEnterGameCard = useCallback(() => {
// Optional: pause animations if needed
}, []);
const handleOnMouseLeaveGameCard = useCallback(() => {
// Optional: resume animations if needed
}, []);
const handleOpenContextMenu = useCallback(
(game: LibraryGame, position: { x: number; y: number }) => {
setContextMenu({ game, visible: true, position });
},
[]
);
const handleCloseContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, visible: false }));
}, []);
const filteredLibrary = useMemo(() => {
let filtered;
switch (filterBy) {
case "recently_played":
filtered = library.filter((game) => game.lastTimePlayed !== null);
break;
case "favorites":
filtered = library.filter((game) => game.favorite);
break;
case "all":
default:
filtered = library;
}
if (!searchQuery.trim()) return filtered;
const queryLower = searchQuery.toLowerCase();
return filtered.filter((game) => {
const titleLower = game.title.toLowerCase();
let queryIndex = 0;
for (
let i = 0;
i < titleLower.length && queryIndex < queryLower.length;
i++
) {
if (titleLower[i] === queryLower[queryIndex]) {
queryIndex++;
}
}
return queryIndex === queryLower.length;
});
}, [library, filterBy, searchQuery]);
const sortedLibrary = filteredLibrary;
const filterCounts = useMemo(() => {
const allGamesCount = library.length;
let recentlyPlayedCount = 0;
let favoritesCount = 0;
for (const game of library) {
if (game.lastTimePlayed !== null) recentlyPlayedCount++;
if (game.favorite) favoritesCount++;
}
return {
allGamesCount,
recentlyPlayedCount,
favoritesCount,
};
}, [library]);
const hasGames = library.length > 0;
return (
<section className="library__content">
{hasGames && (
<div className="library__page-header">
<div className="library__controls-row">
<div className="library__controls-left">
<FilterOptions
filterBy={filterBy}
onFilterChange={setFilterBy}
allGamesCount={filterCounts.allGamesCount}
recentlyPlayedCount={filterCounts.recentlyPlayedCount}
favoritesCount={filterCounts.favoritesCount}
/>
</div>
<div className="library__controls-right">
<ViewOptions
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
/>
</div>
</div>
</div>
)}
{!hasGames && (
<div className="library__no-games">
<div className="library__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_games_title")}</h2>
<p>{t("no_games_description")}</p>
</div>
)}
{hasGames && (
<AnimatePresence mode="wait">
{viewMode === "large" && (
<motion.div
key={`${filterBy}-large`}
className="library__games-list library__games-list--large"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
onContextMenu={handleOpenContextMenu}
/>
))}
</motion.div>
)}
{viewMode !== "large" && (
<motion.ul
key={`${filterBy}-${viewMode}`}
className={`library__games-grid library__games-grid--${viewMode}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
<li
key={`${game.shop}-${game.objectId}`}
style={{ listStyle: "none" }}
>
<LibraryGameCard
game={game}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
onContextMenu={handleOpenContextMenu}
/>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
)}
{contextMenu.game && (
<GameContextMenu
game={contextMenu.game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
)}
</section>
);
}

View File

@@ -0,0 +1,55 @@
@use "../../scss/globals.scss";
.library-view-options {
&__container {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
}
&__label {
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
}
&__options {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex-wrap: wrap;
white-space: nowrap;
}
&__option {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
padding: 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: none;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all ease 0.2s;
white-space: nowrap;
&:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.08);
}
&.active {
color: rgba(0, 0, 0, 0.9);
background: #fff;
svg,
svg * {
fill: currentColor;
color: currentColor;
}
}
}
}

View File

@@ -0,0 +1,45 @@
import { AppsIcon, RowsIcon, SquareIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./view-options.scss";
export type ViewMode = "grid" | "compact" | "large";
interface ViewOptionsProps {
viewMode: ViewMode;
onViewModeChange: (viewMode: ViewMode) => void;
}
export function ViewOptions({
viewMode,
onViewModeChange,
}: Readonly<ViewOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-view-options__container">
<div className="library-view-options__options">
<button
className={`library-view-options__option ${viewMode === "compact" ? "active" : ""}`}
onClick={() => onViewModeChange("compact")}
title={t("compact_view")}
>
<SquareIcon size={16} />
</button>
<button
className={`library-view-options__option ${viewMode === "grid" ? "active" : ""}`}
onClick={() => onViewModeChange("grid")}
title={t("grid_view")}
>
<AppsIcon size={16} />
</button>
<button
className={`library-view-options__option ${viewMode === "large" ? "active" : ""}`}
onClick={() => onViewModeChange("large")}
title={t("large_view")}
>
<RowsIcon size={16} />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { TelescopeIcon } from "@primer/octicons-react";
import InfiniteScroll from "react-infinite-scroll-component";
import { useFormat } from "@renderer/hooks";
import type { UserGame } from "@types";
import { SortOptions } from "./sort-options";
import { UserLibraryGameCard } from "./user-library-game-card";
import "./profile-content.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently";
interface LibraryTabProps {
sortBy: SortOption;
onSortChange: (sortBy: SortOption) => void;
pinnedGames: UserGame[];
libraryGames: UserGame[];
hasMoreLibraryGames: boolean;
isLoadingLibraryGames: boolean;
statsIndex: number;
userStats: { libraryCount: number } | null;
animatedGameIdsRef: React.MutableRefObject<Set<string>>;
onLoadMore: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
isMe: boolean;
}
export function LibraryTab({
sortBy,
onSortChange,
pinnedGames,
libraryGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
statsIndex,
userStats,
animatedGameIdsRef,
onLoadMore,
onMouseEnter,
onMouseLeave,
isMe,
}: Readonly<LibraryTabProps>) {
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const hasGames = libraryGames.length > 0;
const hasPinnedGames = pinnedGames.length > 0;
const hasAnyGames = hasGames || hasPinnedGames;
return (
<motion.div
key="library"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={onSortChange} />
)}
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
{hasAnyGames && (
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
</div>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</li>
))}
</ul>
</div>
)}
{hasGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
</div>
<InfiniteScroll
dataLength={libraryGames.length}
next={onLoadMore}
hasMore={hasMoreLibraryGames}
loader={null}
scrollThreshold={0.9}
style={{ overflow: "visible" }}
scrollableTarget="scrollableDiv"
>
<ul className="profile-content__games-grid">
{libraryGames?.map((game, index) => {
const hasAnimated = animatedGameIdsRef.current.has(
game.objectId
);
const isNewGame = !hasAnimated && !isLoadingLibraryGames;
return (
<motion.li
key={`${sortBy}-${game.objectId}`}
style={{ listStyle: "none" }}
initial={
isNewGame
? { opacity: 0.5, y: 15, scale: 0.96 }
: false
}
animate={
isNewGame ? { opacity: 1, y: 0, scale: 1 } : false
}
transition={
isNewGame
? {
duration: 0.15,
ease: "easeOut",
delay: index * 0.01,
}
: undefined
}
onAnimationComplete={() => {
if (isNewGame) {
animatedGameIdsRef.current.add(game.objectId);
}
}}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</motion.li>
);
})}
</ul>
</InfiniteScroll>
</div>
)}
</div>
)}
</motion.div>
);
}

View File

@@ -101,6 +101,11 @@
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
&__tab-wrapper {
position: relative;
}
&__tab {
@@ -111,19 +116,40 @@
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all ease 0.2s;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&--active {
color: white;
border-bottom-color: #c9aa71;
}
}
&__tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 1;
}
&__tab-underline {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: white;
}
&__games-grid {
list-style: none;
margin: 0;
@@ -175,5 +201,245 @@
backdrop-filter: blur(10px);
}
}
&__tab-panels {
display: block;
}
}
}
// Reviews minimal styles
.user-reviews__loading {
padding: calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.8);
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}
.user-reviews__empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.6);
}
.user-reviews__list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 4);
}
.user-reviews__review-item {
border-radius: 8px;
}
.user-reviews__review-header {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1);
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
.user-reviews__review-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.user-reviews__review-header-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-reviews__review-meta-row {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
}
.user-reviews__review-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: calc(globals.$spacing-unit * 1.5);
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
.user-reviews__review-game {
display: flex;
gap: calc(globals.$spacing-unit);
}
.user-reviews__game-icon {
width: 24px;
height: 24px;
object-fit: cover;
}
.user-reviews__game-info {
display: flex;
flex-direction: column;
}
.user-reviews__game-details {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
}
.user-reviews__game-title {
background: none;
border: none;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
cursor: pointer;
text-align: left;
&--clickable:hover {
text-decoration: underline;
}
}
.user-reviews__review-date {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
.user-reviews__review-score-stars {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px;
font-weight: 500;
}
.user-reviews__review-star {
color: rgba(255, 255, 255, 0.7);
transition: color 0.2s ease;
&--filled {
color: rgba(255, 255, 255, 0.7);
svg {
fill: currentColor;
}
}
}
.user-reviews__review-score-text {
font-weight: 500;
}
.user-reviews__review-playtime {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 500;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.user-reviews__review-content {
color: rgba(255, 255, 255, 0.85);
line-height: 1.5;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
.user-reviews__review-translation-toggle {
display: inline-flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
margin-top: calc(globals.$spacing-unit * 1.5);
padding: 0;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 0.875rem;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
color: rgba(255, 255, 255, 0.9);
}
}
.user-reviews__review-actions {
margin-top: calc(globals.$spacing-unit * 2);
padding-top: calc(globals.$spacing-unit);
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.user-reviews__review-votes {
display: flex;
gap: calc(globals.$spacing-unit);
}
.user-reviews__vote-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&--active {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.3);
svg {
fill: white;
}
}
}
.user-reviews__delete-review-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px 10px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: rgba(244, 67, 54, 0.4);
color: #ff7961;
}
}

View File

@@ -1,29 +1,82 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks";
import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import type { GameShop } from "@types";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { UserKarmaBox } from "./user-karma-box";
import { UserLibraryGameCard } from "./user-library-game-card";
import { SortOptions } from "./sort-options";
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
import { motion, AnimatePresence } from "framer-motion";
import {
sectionVariants,
chevronVariants,
GAME_STATS_ANIMATION_DURATION_IN_MS,
} from "./profile-animations";
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { ProfileTabs } from "./profile-tabs";
import { LibraryTab } from "./library-tab";
import { ReviewsTab } from "./reviews-tab";
import { AnimatePresence } from "framer-motion";
import "./profile-content.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently";
interface UserReview {
id: string;
reviewHtml: string;
score: number;
playTimeInSeconds?: number;
upvotes: number;
downvotes: number;
hasUpvoted: boolean;
hasDownvoted: boolean;
createdAt: string;
updatedAt: string;
user: {
id: string;
};
game: {
title: string;
iconUrl: string;
objectId: string;
shop: GameShop;
};
translations: {
[key: string]: string;
};
detectedLanguage: string | null;
}
interface UserReviewsResponse {
totalCount: number;
reviews: UserReview[];
}
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 ProfileContent() {
const {
userProfile,
@@ -32,16 +85,43 @@ export function ProfileContent() {
libraryGames,
pinnedGames,
getUserLibraryGames,
loadMoreLibraryGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
} = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
const [activeTab, setActiveTab] = useState<"library" | "reviews">("library");
// User reviews state
const [reviews, setReviews] = useState<UserReview[]>([]);
const [reviewsTotalCount, setReviewsTotalCount] = useState(0);
const [isLoadingReviews, setIsLoadingReviews] = useState(false);
const [votingReviews, setVotingReviews] = useState<Set<string>>(new Set());
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [reviewToDelete, setReviewToDelete] = useState<string | null>(null);
const dispatch = useAppDispatch();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const formatPlayTime = (playTimeInSeconds: number) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
useEffect(() => {
dispatch(setHeaderTitle(""));
@@ -53,10 +133,201 @@ export function ProfileContent() {
useEffect(() => {
if (userProfile) {
getUserLibraryGames(sortBy);
// When sortBy changes, clear animated games so all games animate in
if (currentSortByRef.current !== sortBy) {
animatedGameIdsRef.current.clear();
currentSortByRef.current = sortBy;
}
getUserLibraryGames(sortBy, true);
}
}, [sortBy, getUserLibraryGames, userProfile]);
const animatedGameIdsRef = useRef<Set<string>>(new Set());
const currentSortByRef = useRef<SortOption>(sortBy);
const handleLoadMore = useCallback(() => {
if (
activeTab === "library" &&
hasMoreLibraryGames &&
!isLoadingLibraryGames
) {
loadMoreLibraryGames(sortBy);
}
}, [
activeTab,
hasMoreLibraryGames,
isLoadingLibraryGames,
loadMoreLibraryGames,
sortBy,
]);
// Clear reviews state and reset tab when switching users
useEffect(() => {
setReviews([]);
setReviewsTotalCount(0);
setIsLoadingReviews(false);
setActiveTab("library");
}, [userProfile?.id]);
useEffect(() => {
if (userProfile?.id) {
fetchUserReviews();
}
}, [userProfile?.id]);
const fetchUserReviews = async () => {
if (!userProfile?.id) return;
setIsLoadingReviews(true);
try {
const response = await window.electron.hydraApi.get<UserReviewsResponse>(
`/users/${userProfile.id}/reviews`,
{ needsAuth: true }
);
setReviews(response.reviews);
setReviewsTotalCount(response.totalCount);
} catch (error) {
// Error handling for fetching reviews
} finally {
setIsLoadingReviews(false);
}
};
const handleDeleteReview = async (reviewId: string) => {
try {
const reviewToDeleteObj = reviews.find(
(review) => review.id === reviewId
);
if (!reviewToDeleteObj) return;
await window.electron.hydraApi.delete(
`/games/${reviewToDeleteObj.game.shop}/${reviewToDeleteObj.game.objectId}/reviews/${reviewId}`
);
// Remove the review from the local state
setReviews((prev) => prev.filter((review) => review.id !== reviewId));
setReviewsTotalCount((prev) => prev - 1);
} catch (error) {
console.error("Failed to delete review:", error);
}
};
const handleDeleteClick = (reviewId: string) => {
setReviewToDelete(reviewId);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = () => {
if (reviewToDelete) {
handleDeleteReview(reviewToDelete);
setReviewToDelete(null);
}
};
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
setReviewToDelete(null);
};
const handleVoteReview = async (reviewId: string, isUpvote: boolean) => {
if (votingReviews.has(reviewId)) return;
setVotingReviews((prev) => new Set(prev).add(reviewId));
const review = reviews.find((r) => r.id === reviewId);
if (!review) {
setVotingReviews((prev) => {
const next = new Set(prev);
next.delete(reviewId);
return next;
});
return;
}
const wasUpvoted = review.hasUpvoted;
const wasDownvoted = review.hasDownvoted;
// Optimistic update
setReviews((prev) =>
prev.map((r) => {
if (r.id !== reviewId) return r;
let newUpvotes = r.upvotes;
let newDownvotes = r.downvotes;
let newHasUpvoted = r.hasUpvoted;
let newHasDownvoted = r.hasDownvoted;
if (isUpvote) {
if (wasUpvoted) {
// Remove upvote
newUpvotes--;
newHasUpvoted = false;
} else {
// Add upvote
newUpvotes++;
newHasUpvoted = true;
if (wasDownvoted) {
// Remove downvote if it was downvoted
newDownvotes--;
newHasDownvoted = false;
}
}
} else if (wasDownvoted) {
// Remove downvote
newDownvotes--;
newHasDownvoted = false;
} else {
// Add downvote
newDownvotes++;
newHasDownvoted = true;
if (wasUpvoted) {
// Remove upvote if it was upvoted
newUpvotes--;
newHasUpvoted = false;
}
}
return {
...r,
upvotes: newUpvotes,
downvotes: newDownvotes,
hasUpvoted: newHasUpvoted,
hasDownvoted: newHasDownvoted,
};
})
);
try {
const endpoint = isUpvote ? "upvote" : "downvote";
await window.electron.hydraApi.put(
`/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}`
);
} catch (error) {
console.error("Failed to vote on review:", error);
// Rollback optimistic update on error
setReviews((prev) =>
prev.map((r) => {
if (r.id !== reviewId) return r;
return {
...r,
upvotes: review.upvotes,
downvotes: review.downvotes,
hasUpvoted: review.hasUpvoted,
hasDownvoted: review.hasDownvoted,
};
})
);
} finally {
setTimeout(() => {
setVotingReviews((prev) => {
const newSet = new Set(prev);
newSet.delete(reviewId);
return newSet;
});
}, 500);
}
};
const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false);
};
@@ -86,8 +357,6 @@ export function ProfileContent() {
};
}, [setStatsIndex, isAnimationRunning]);
const { numberFormatter } = useFormat();
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
@@ -113,112 +382,46 @@ export function ProfileContent() {
return (
<section className="profile-content__section">
<div className="profile-content__main">
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
)}
<ProfileTabs
activeTab={activeTab}
reviewsTotalCount={reviewsTotalCount}
onTabChange={setActiveTab}
/>
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
{hasAnyGames && (
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<button
type="button"
className="profile-content__collapse-button"
onClick={() => toggleSection("pinned")}
aria-label={
isPinnedCollapsed
? "Expand pinned section"
: "Collapse pinned section"
}
>
<motion.div
variants={chevronVariants}
animate={isPinnedCollapsed ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
</div>
<AnimatePresence initial={true} mode="wait">
{!isPinnedCollapsed && (
<motion.div
key="pinned-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li
key={game.objectId}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="profile-content__tab-panels">
<AnimatePresence mode="wait">
{activeTab === "library" && (
<LibraryTab
sortBy={sortBy}
onSortChange={setSortBy}
pinnedGames={pinnedGames}
libraryGames={libraryGames}
hasMoreLibraryGames={hasMoreLibraryGames}
isLoadingLibraryGames={isLoadingLibraryGames}
statsIndex={statsIndex}
userStats={userStats}
animatedGameIdsRef={animatedGameIdsRef}
onLoadMore={handleLoadMore}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
isMe={isMe}
/>
)}
{hasGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
</div>
<ul className="profile-content__games-grid">
{libraryGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</div>
{activeTab === "reviews" && (
<ReviewsTab
reviews={reviews}
isLoadingReviews={isLoadingReviews}
votingReviews={votingReviews}
userDetailsId={userDetails?.id}
formatPlayTime={formatPlayTime}
getRatingText={getRatingText}
onVote={handleVoteReview}
onDelete={handleDeleteClick}
/>
)}
</div>
)}
</AnimatePresence>
</div>
</div>
{shouldShowRightContent && (
@@ -230,6 +433,12 @@ export function ProfileContent() {
<ReportProfile />
</div>
)}
<DeleteReviewModal
visible={deleteModalVisible}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
</section>
);
}, [
@@ -242,9 +451,15 @@ export function ProfileContent() {
statsIndex,
libraryGames,
pinnedGames,
isPinnedCollapsed,
toggleSection,
sortBy,
activeTab,
// ensure reviews UI updates correctly
reviews,
reviewsTotalCount,
isLoadingReviews,
votingReviews,
deleteModalVisible,
]);
return (

View File

@@ -0,0 +1,252 @@
import { motion, AnimatePresence } from "framer-motion";
import { useNavigate } from "react-router-dom";
import { ClockIcon } from "@primer/octicons-react";
import { Star, ThumbsUp, ThumbsDown, TrashIcon, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { GameShop } from "@types";
import { sanitizeHtml } from "@shared";
import { useDate } from "@renderer/hooks";
import { buildGameDetailsPath } from "@renderer/helpers";
import "./profile-content.scss";
interface UserReview {
id: string;
reviewHtml: string;
score: number;
playTimeInSeconds?: number;
upvotes: number;
downvotes: number;
hasUpvoted: boolean;
hasDownvoted: boolean;
createdAt: string;
updatedAt: string;
user: {
id: string;
};
game: {
title: string;
iconUrl: string;
objectId: string;
shop: GameShop;
};
translations: {
[key: string]: string;
};
detectedLanguage: string | null;
}
interface ProfileReviewItemProps {
review: UserReview;
isOwnReview: boolean;
isVoting: boolean;
formatPlayTime: (playTimeInSeconds: number) => string;
getRatingText: (score: number, t: (key: string) => string) => string;
onVote: (reviewId: string, isUpvote: boolean) => void;
onDelete: (reviewId: string) => void;
}
export function ProfileReviewItem({
review,
isOwnReview,
isVoting,
formatPlayTime,
getRatingText,
onVote,
onDelete,
}: Readonly<ProfileReviewItemProps>) {
const navigate = useNavigate();
const { formatDistance } = useDate();
const { t } = useTranslation("user_profile");
const { t: tGameDetails, i18n } = useTranslation("game_details");
const [showOriginal, setShowOriginal] = useState(false);
const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || "";
const isDifferentLanguage =
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
const needsTranslation =
!isOwnReview && isDifferentLanguage && review.translations[i18n.language];
const getLanguageName = (languageCode: string | null) => {
if (!languageCode) return "";
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
});
return displayNames.of(languageCode) || languageCode.toUpperCase();
} catch {
return languageCode.toUpperCase();
}
};
const displayContent = needsTranslation
? review.translations[i18n.language]
: review.reviewHtml;
return (
<motion.div
key={review.id}
className="user-reviews__review-item"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="user-reviews__review-header">
<div className="user-reviews__review-header-top">
<div className="user-reviews__review-game">
<div className="user-reviews__game-info">
<div className="user-reviews__game-details">
<img
src={review.game.iconUrl}
alt={review.game.title}
className="user-reviews__game-icon"
/>
<button
className="user-reviews__game-title user-reviews__game-title--clickable"
onClick={() => navigate(buildGameDetailsPath(review.game))}
>
{review.game.title}
</button>
</div>
</div>
</div>
<div className="user-reviews__review-date">
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
</div>
</div>
<div className="user-reviews__review-header-bottom">
<div className="user-reviews__review-meta-row">
<div
className="user-reviews__review-score-stars"
title={getRatingText(review.score, tGameDetails)}
>
<Star
size={12}
className="user-reviews__review-star user-reviews__review-star--filled"
/>
<span className="user-reviews__review-score-text">
{review.score}/5
</span>
</div>
{Boolean(
review.playTimeInSeconds && review.playTimeInSeconds > 0
) && (
<div className="user-reviews__review-playtime">
<ClockIcon size={12} />
<span>
{tGameDetails("review_played_for")}{" "}
{formatPlayTime(review.playTimeInSeconds || 0)}
</span>
</div>
)}
</div>
</div>
</div>
<div>
<div
className="user-reviews__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(displayContent),
}}
/>
{needsTranslation && (
<>
<button
className="user-reviews__review-translation-toggle"
onClick={() => setShowOriginal(!showOriginal)}
>
<Languages size={13} />
{showOriginal
? tGameDetails("hide_original")
: tGameDetails("show_original_translated_from", {
language: getLanguageName(review.detectedLanguage),
})}
</button>
{showOriginal && (
<div
className="user-reviews__review-content"
style={{
opacity: 0.6,
marginTop: "12px",
}}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
)}
</>
)}
</div>
<div className="user-reviews__review-actions">
<div className="user-reviews__review-votes">
<motion.button
className={`user-reviews__vote-button ${review.hasUpvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() => onVote(review.id, true)}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ThumbsUp size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.upvotes}
</motion.span>
</AnimatePresence>
</motion.button>
<motion.button
className={`user-reviews__vote-button ${review.hasDownvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() => onVote(review.id, false)}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ThumbsDown size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.downvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.downvotes}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
{isOwnReview && (
<button
className="user-reviews__delete-review-button"
onClick={() => onDelete(review.id)}
title={t("delete_review")}
>
<TrashIcon size={14} />
<span>{t("delete_review")}</span>
</button>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,67 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import "./profile-content.scss";
interface ProfileTabsProps {
activeTab: "library" | "reviews";
reviewsTotalCount: number;
onTabChange: (tab: "library" | "reviews") => void;
}
export function ProfileTabs({
activeTab,
reviewsTotalCount,
onTabChange,
}: Readonly<ProfileTabsProps>) {
const { t } = useTranslation("user_profile");
return (
<div className="profile-content__tabs">
<div className="profile-content__tab-wrapper">
<button
type="button"
className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`}
onClick={() => onTabChange("library")}
>
{t("library")}
</button>
{activeTab === "library" && (
<motion.div
className="profile-content__tab-underline"
layoutId="tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="profile-content__tab-wrapper">
<button
type="button"
className={`profile-content__tab ${activeTab === "reviews" ? "profile-content__tab--active" : ""}`}
onClick={() => onTabChange("reviews")}
>
{t("user_reviews")}
{reviewsTotalCount > 0 && (
<span className="profile-content__tab-badge">
{reviewsTotalCount}
</span>
)}
</button>
{activeTab === "reviews" && (
<motion.div
className="profile-content__tab-underline"
layoutId="tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import type { GameShop } from "@types";
import { ProfileReviewItem } from "./profile-review-item";
import "./profile-content.scss";
interface UserReview {
id: string;
reviewHtml: string;
score: number;
playTimeInSeconds?: number;
upvotes: number;
downvotes: number;
hasUpvoted: boolean;
hasDownvoted: boolean;
createdAt: string;
updatedAt: string;
user: {
id: string;
};
game: {
title: string;
iconUrl: string;
objectId: string;
shop: GameShop;
};
translations: {
[key: string]: string;
};
detectedLanguage: string | null;
}
interface ReviewsTabProps {
reviews: UserReview[];
isLoadingReviews: boolean;
votingReviews: Set<string>;
userDetailsId?: string;
formatPlayTime: (playTimeInSeconds: number) => string;
getRatingText: (score: number, t: (key: string) => string) => string;
onVote: (reviewId: string, isUpvote: boolean) => void;
onDelete: (reviewId: string) => void;
}
export function ReviewsTab({
reviews,
isLoadingReviews,
votingReviews,
userDetailsId,
formatPlayTime,
getRatingText,
onVote,
onDelete,
}: Readonly<ReviewsTabProps>) {
const { t } = useTranslation("user_profile");
return (
<motion.div
key="reviews"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{isLoadingReviews && (
<div className="user-reviews__loading">{t("loading_reviews")}</div>
)}
{!isLoadingReviews && reviews.length === 0 && (
<div className="user-reviews__empty">
<p>{t("no_reviews", "No reviews yet")}</p>
</div>
)}
{!isLoadingReviews && reviews.length > 0 && (
<div className="user-reviews__list">
{reviews.map((review) => {
const isOwnReview = userDetailsId === review.user.id;
return (
<ProfileReviewItem
key={review.id}
review={review}
isOwnReview={isOwnReview}
isVoting={votingReviews.has(review.id)}
formatPlayTime={formatPlayTime}
getRatingText={getRatingText}
onVote={onVote}
onDelete={onDelete}
/>
);
})}
</div>
)}
</motion.div>
);
}

View File

@@ -36,6 +36,7 @@
box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5);
width: 100%;
position: relative;
overflow: hidden;
&:before {
content: "";
@@ -193,8 +194,28 @@
border-radius: 4px;
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
display: block;
}
&__cover-placeholder {
position: relative;
width: 100%;
padding-bottom: 150%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.04) 50%,
rgba(255, 255, 255, 0.08) 100%
);
border-radius: 4px;
color: rgba(255, 255, 255, 0.3);
svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
&__achievements-progress {

View File

@@ -2,7 +2,7 @@ import { UserGame } from "@types";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat, useToast } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { useCallback, useContext, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import {
buildGameAchievementPath,
buildGameDetailsPath,
@@ -15,6 +15,7 @@ import {
AlertFillIcon,
PinIcon,
PinSlashIcon,
ImageIcon,
} from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
@@ -44,6 +45,11 @@ export function UserLibraryGameCard({
const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const [isPinning, setIsPinning] = useState(false);
const [imageError, setImageError] = useState(false);
useEffect(() => {
setImageError(false);
}, [game.coverImageUrl]);
const getStatsItemCount = useCallback(() => {
let statsCount = 1;
@@ -233,11 +239,18 @@ export function UserLibraryGameCard({
)}
</div>
<img
src={game.coverImageUrl ?? undefined}
alt={game.title}
className="user-library-game__game-image"
/>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button>
</li>
<Tooltip

View File

@@ -51,6 +51,16 @@ export const ImportThemeModal = ({
if (!currentTheme) return;
try {
await window.electron.importThemeSoundFromStore(
theme.id,
themeName,
THEME_WEB_STORE_URL
);
} catch (soundError) {
logger.error("Failed to import theme sound", soundError);
}
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {

View File

@@ -89,7 +89,7 @@ export function SettingsDownloadSources() {
try {
await window.electron.removeDownloadSource(false, downloadSource.id);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
setDownloadSources(sources);
showSuccessToast(t("removed_download_source"));
} catch (error) {
logger.error("Failed to remove download source:", error);
@@ -104,7 +104,7 @@ export function SettingsDownloadSources() {
try {
await window.electron.removeDownloadSource(true);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
setDownloadSources(sources);
showSuccessToast(t("removed_all_download_sources"));
} catch (error) {
logger.error("Failed to remove all download sources:", error);
@@ -117,7 +117,7 @@ export function SettingsDownloadSources() {
const handleAddDownloadSource = async () => {
try {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
setDownloadSources(sources);
} catch (error) {
logger.error("Failed to refresh download sources:", error);
}
@@ -128,7 +128,7 @@ export function SettingsDownloadSources() {
try {
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources as DownloadSource[]);
setDownloadSources(sources);
showSuccessToast(t("download_sources_synced_successfully"));
} finally {

View File

@@ -17,4 +17,159 @@
&__test-achievement-notification-button {
align-self: flex-start;
}
&__volume-control {
display: flex;
flex-direction: column;
gap: 12px;
label {
font-size: 14px;
color: globals.$muted-color;
}
}
&__volume-slider-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 200px;
position: relative;
--volume-percent: 0%;
}
&__volume-icon {
color: globals.$muted-color;
flex-shrink: 0;
}
&__volume-value {
font-size: 14px;
color: globals.$body-color;
font-weight: 500;
min-width: 40px;
text-align: right;
flex-shrink: 0;
}
&__volume-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: globals.$dark-background-color;
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
transition: background 0.2s;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
margin-top: -6px;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
}
&:active {
transform: scale(1.05);
}
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
margin-top: -6px;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
}
&:active {
transform: scale(1.05);
}
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
globals.$muted-color 0%,
globals.$muted-color var(--volume-percent),
globals.$dark-background-color var(--volume-percent),
globals.$dark-background-color 100%
);
}
&::-moz-range-track {
width: 100%;
height: 6px;
border-radius: 3px;
background: globals.$dark-background-color;
}
&::-moz-range-progress {
height: 6px;
border-radius: 3px;
background: globals.$muted-color;
}
&:focus {
outline: none;
&::-webkit-slider-thumb {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
&::-moz-range-thumb {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
}
&::-ms-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
}
&::-ms-track {
width: 100%;
height: 6px;
background: transparent;
border-color: transparent;
color: transparent;
}
&::-ms-fill-lower {
background: globals.$muted-color;
border-radius: 3px;
}
&::-ms-fill-upper {
background: globals.$dark-background-color;
border-radius: 3px;
}
}
}

View File

@@ -1,4 +1,11 @@
import { useContext, useEffect, useMemo, useState } from "react";
import {
useContext,
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from "react";
import {
TextField,
Button,
@@ -12,7 +19,7 @@ import languageResources from "@locales";
import { orderBy } from "lodash-es";
import { settingsContext } from "@renderer/context";
import "./settings-general.scss";
import { DesktopDownloadIcon } from "@primer/octicons-react";
import { DesktopDownloadIcon, UnmuteIcon } from "@primer/octicons-react";
import { logger } from "@renderer/logger";
import { AchievementCustomNotificationPosition } from "@types";
@@ -43,6 +50,7 @@ export function SettingsGeneral() {
achievementCustomNotificationsEnabled: true,
achievementCustomNotificationPosition:
"top-left" as AchievementCustomNotificationPosition,
achievementSoundVolume: 15,
language: "",
customStyles: window.localStorage.getItem("customStyles") || "",
});
@@ -51,6 +59,8 @@ export function SettingsGeneral() {
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
const volumeUpdateTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
window.electron.getDefaultDownloadsPath().then((path) => {
setDefaultDownloadsPath(path);
@@ -81,6 +91,9 @@ export function SettingsGeneral() {
return () => {
clearInterval(interval);
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
};
}, []);
@@ -110,6 +123,9 @@ export function SettingsGeneral() {
userPreferences.achievementCustomNotificationsEnabled ?? true,
achievementCustomNotificationPosition:
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementSoundVolume: Math.round(
(userPreferences.achievementSoundVolume ?? 0.15) * 100
),
friendRequestNotificationsEnabled:
userPreferences.friendRequestNotificationsEnabled ?? false,
friendStartGameNotificationsEnabled:
@@ -148,6 +164,21 @@ export function SettingsGeneral() {
await updateUserPreferences(values);
};
const handleVolumeChange = useCallback(
(newVolume: number) => {
setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume }));
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
volumeUpdateTimeoutRef.current = setTimeout(() => {
updateUserPreferences({ achievementSoundVolume: newVolume / 100 });
}, 300);
},
[updateUserPreferences]
);
const handleChangeAchievementCustomNotificationPosition = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
@@ -309,6 +340,39 @@ export function SettingsGeneral() {
</>
)}
{form.achievementNotificationsEnabled && (
<div className="settings-general__volume-control">
<label htmlFor="achievement-volume">
{t("achievement_sound_volume")}
</label>
<div className="settings-general__volume-slider-wrapper">
<UnmuteIcon size={16} className="settings-general__volume-icon" />
<input
id="achievement-volume"
type="range"
min="0"
max="100"
value={form.achievementSoundVolume}
onChange={(e) => {
const volumePercent = parseInt(e.target.value, 10);
if (!isNaN(volumePercent)) {
handleVolumeChange(volumePercent);
}
}}
className="settings-general__volume-slider"
style={
{
"--volume-percent": `${form.achievementSoundVolume}%`,
} as React.CSSProperties
}
/>
<span className="settings-general__volume-value">
{form.achievementSoundVolume}%
</span>
</div>
</div>
)}
<h2 className="settings-general__section-title">{t("common_redist")}</h2>
<p className="settings-general__common-redist-description">

View File

@@ -47,6 +47,8 @@
position: relative;
border: 1px solid globals.$muted-color;
border-radius: 2px;
flex: 1;
min-width: 0;
}
&__footer {
@@ -80,7 +82,7 @@
}
&__info {
padding: 16px;
padding: 8px;
p {
font-size: 16px;
@@ -93,12 +95,39 @@
&__notification-preview {
padding-top: 12px;
display: flex;
flex-direction: row;
align-items: center;
flex-direction: column;
gap: 16px;
&__select-variation {
flex: inherit;
}
}
&__notification-preview-controls {
display: flex;
flex-direction: column;
gap: 16px;
flex-shrink: 0;
}
&__notification-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
&__sound-actions {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
&__sound-actions-row {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
}

View File

@@ -3,11 +3,16 @@ import "./theme-editor.scss";
import Editor from "@monaco-editor/react";
import { AchievementCustomNotificationPosition, Theme } from "@types";
import { useSearchParams } from "react-router-dom";
import { Button, SelectField } from "@renderer/components";
import { CheckIcon } from "@primer/octicons-react";
import { Button, SelectField, TextField } from "@renderer/components";
import {
CheckIcon,
UploadIcon,
TrashIcon,
PlayIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import { injectCustomCss } from "@renderer/helpers";
import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import { generateAchievementCustomNotificationTest } from "@shared";
import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu";
@@ -27,6 +32,7 @@ export default function ThemeEditor() {
const [theme, setTheme] = useState<Theme | null>(null);
const [code, setCode] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [soundPath, setSoundPath] = useState<string>("");
const [isClosingNotifications, setIsClosingNotifications] = useState(false);
@@ -62,6 +68,9 @@ export default function ThemeEditor() {
if (loadedTheme) {
setTheme(loadedTheme);
setCode(loadedTheme.code);
if (loadedTheme.originalSoundPath) {
setSoundPath(loadedTheme.originalSoundPath);
}
if (shadowRootRef) {
injectCustomCss(loadedTheme.code, shadowRootRef);
}
@@ -107,6 +116,73 @@ export default function ThemeEditor() {
}
};
const handleSelectSound = useCallback(async () => {
if (!theme) return;
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Audio",
extensions: ["wav", "mp3", "ogg", "m4a"],
},
],
});
if (filePaths && filePaths.length > 0) {
const originalPath = filePaths[0];
await window.electron.copyThemeAchievementSound(theme.id, originalPath);
const updatedTheme = await window.electron.getCustomThemeById(theme.id);
if (updatedTheme) {
setTheme(updatedTheme);
if (updatedTheme.originalSoundPath) {
setSoundPath(updatedTheme.originalSoundPath);
}
}
}
}, [theme]);
const handleRemoveSound = useCallback(async () => {
if (!theme) return;
await window.electron.removeThemeAchievementSound(theme.id);
const updatedTheme = await window.electron.getCustomThemeById(theme.id);
if (updatedTheme) {
setTheme(updatedTheme);
}
setSoundPath("");
}, [theme]);
const handlePreviewSound = useCallback(async () => {
if (!theme) return;
let soundUrl: string;
if (theme.hasCustomSound) {
const themeSoundUrl = await window.electron.getThemeSoundDataUrl(
theme.id
);
if (themeSoundUrl) {
soundUrl = themeSoundUrl;
} else {
const defaultSound = (
await import("@renderer/assets/audio/achievement.wav")
).default;
soundUrl = defaultSound;
}
} else {
const defaultSound = (
await import("@renderer/assets/audio/achievement.wav")
).default;
soundUrl = defaultSound;
}
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play();
}, [theme]);
const achievementCustomNotificationPositionOptions = useMemo(() => {
return [
"top-left",
@@ -164,35 +240,66 @@ export default function ThemeEditor() {
<div className="theme-editor__footer">
<CollapsedMenu title={t("notification_preview")}>
<div className="theme-editor__notification-preview">
<SelectField
className="theme-editor__notification-preview__select-variation"
label={t("variation")}
options={Object.values(notificationVariations).map(
(variation) => {
return {
key: variation,
value: variation,
label: t(variation),
};
}
)}
onChange={(value) =>
setNotificationVariation(
value.target.value as keyof typeof notificationVariations
)
<div className="theme-editor__notification-preview-controls">
<div className="theme-editor__notification-controls">
<SelectField
className="theme-editor__notification-preview__select-variation"
label={t("variation")}
options={Object.values(notificationVariations).map(
(variation) => {
return {
key: variation,
value: variation,
label: t(variation),
};
}
)}
onChange={(value) =>
setNotificationVariation(
value.target.value as keyof typeof notificationVariations
)
}
/>
<SelectField
label={t("alignment")}
value={notificationAlignment}
onChange={(e) =>
setNotificationAlignment(
e.target.value as AchievementCustomNotificationPosition
)
}
options={achievementCustomNotificationPositionOptions}
/>
</div>
</div>
<TextField
label={t("select_achievement_sound")}
value={soundPath || ""}
placeholder={soundPath ? undefined : t("no_sound_file_selected")}
readOnly
disabled
rightContent={
<Button theme="outline" onClick={handleSelectSound}>
<UploadIcon />
{t("select")}
</Button>
}
/>
<SelectField
label={t("alignment")}
value={notificationAlignment}
onChange={(e) =>
setNotificationAlignment(
e.target.value as AchievementCustomNotificationPosition
)
}
options={achievementCustomNotificationPositionOptions}
/>
{theme?.hasCustomSound && (
<div className="theme-editor__sound-actions-row">
<Button theme="outline" onClick={handleRemoveSound}>
<TrashIcon />
{t("remove")}
</Button>
<Button theme="outline" onClick={handlePreviewSound}>
<PlayIcon />
{t("preview")}
</Button>
</div>
)}
<div className="theme-editor__notification-preview-wrapper">
<root.div>

View File

@@ -23,6 +23,7 @@ export interface GameRepack {
uploadDate: string | null;
downloadSourceId: string;
downloadSourceName: string;
createdAt: string;
}
export interface DownloadSource {
@@ -41,9 +42,9 @@ export interface ShopAssets {
shop: GameShop;
title: string;
iconUrl: string | null;
libraryHeroImageUrl: string;
libraryImageUrl: string;
logoImageUrl: string;
libraryHeroImageUrl: string | null;
libraryImageUrl: string | null;
logoImageUrl: string | null;
logoPosition: string | null;
coverImageUrl: string | null;
downloadSources: string[];
@@ -244,6 +245,7 @@ export interface GameReview {
isBlocked: boolean;
hasUpvoted: boolean;
hasDownvoted: boolean;
playTimeInSeconds?: number;
user: {
id: string;
displayName: string;
@@ -252,7 +254,7 @@ export interface GameReview {
translations: {
[key: string]: string;
};
detectedLanguage: string;
detectedLanguage: string | null;
}
export interface TrendingGame extends ShopAssets {
@@ -361,6 +363,8 @@ export type LibraryGame = Game &
Partial<ShopAssets> & {
id: string;
download: Download | null;
unlockedAchievementCount?: number;
achievementCount?: number;
};
export type UserGameDetails = ShopAssets & {

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