Compare commits

...

159 Commits

Author SHA1 Message Date
Chubby Granny Chaser
50b0a82204 feat: improving styles on randomizer button 2025-11-08 08:17:19 +00: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
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
Chubby Granny Chaser
7fa50dc5a7 feat: adding ws client back 2025-11-03 12:02:52 +00: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
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
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
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
fb29152a0b Merge branch 'main' into feat/improving-sources 2025-10-29 18:19:07 +00:00
Zamitto
19bbbbfb77 Merge pull request #1835 from hydralauncher/fix/custom-assets-sizing
fix: images with big height breaking layout
2025-10-29 15:13:21 -03:00
Chubby Granny Chaser
952d4d9ee2 Merge branch 'main' into feat/improving-sources 2025-10-29 18:13:05 +00:00
Moyasee
ad588b5600 fix: images with big height breaking layout 2025-10-29 19:51:09 +02: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
Zamitto
c24ad34bc7 fix: hltb and achievements being called for custom games
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 12:42:39 -03:00
Zamitto
4f2c3105ce Merge pull request #1834 from hydralauncher/fix/custom-games-unnecessary-requests
fix: disabling unnecessary api calls when game is custom
2025-10-29 12:07:40 -03:00
Moyasee
feedcb1dc7 feat: disabled assets request for custom games 2025-10-29 16:49:51 +02:00
Moyasee
4b8d64c72b feat: disabled favorite/unfavorite get request for custom games 2025-10-29 16:44:48 +02:00
Moyasee
dff68a3e26 fix: removed comments 2025-10-29 16:22:12 +02:00
Moyasee
58bdbdab71 fix: disabling unnecessary api calls if game is custom 2025-10-29 16:16:11 +02: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
574a012d8c Merge pull request #1833 from hydralauncher/feat/settings-trailer-and-launch
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
Feat: New behavior settings: Trailers Auto-play and Hide on game launch
2025-10-28 23:11:57 +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
Chubby Granny Chaser
4acb7f7001 Merge branch 'feat/improving-sources' of https://github.com/hydralauncher/hydra into feat/improving-sources 2025-10-28 21:38:24 +00:00
Chubby Granny Chaser
a11b3e8877 ci: fixing release pipeline 2025-10-28 21:38:07 +00:00
Chubby Granny Chaser
6b96c99bb1 ci: fixing release pipeline 2025-10-28 21:37:28 +00:00
Zamitto
1123aaa65e chore: remove zod dep 2025-10-28 06:48:42 -03:00
Zamitto
2f8d241ded Merge pull request #1829 from hydralauncher/chore/bump-electron-version
chore: bump electron version
2025-10-27 18:16:35 -03: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
Chubby Granny Chaser
fc6068d603 fix: fixing dlls 2025-10-27 12:33:29 +00:00
Chubby Granny Chaser
3ab1e29578 ci: testing windows 2025-10-27 10:47:19 +00:00
Chubby Granny Chaser
1effa80311 ci: testing windows 2025-10-27 10:35:20 +00:00
Chubby Granny Chaser
549e1270ee ci: testing windows 2025-10-27 09:56:45 +00:00
Chubby Granny Chaser
eb006c5e90 ci: testing windows 2019 2025-10-27 09:51:11 +00:00
Chubby Granny Chaser
54632bd06d ci: testing windows 2019 2025-10-27 09:16:55 +00:00
Zamitto
a073cf7f8c chore: bump electron vite version 2025-10-27 04:50:32 -03:00
Chubby Granny Chaser
6921bfa3ff ci: testing windows 2019 2025-10-27 07:41:58 +00:00
Chubby Granny Chaser
2835207d79 fix: fixing download sources import status 2025-10-27 04:12:19 +00:00
Chubby Granny Chaser
2a90faeb42 fix: persisting is remote 2025-10-27 01:42:02 +00:00
Chubby Granny Chaser
87a57f7a37 feat: adding sources migration 2025-10-26 23:22:20 +00:00
Chubby Granny Chaser
7c272aeed8 Merge branch 'main' into feat/improving-sources 2025-10-26 21:42:58 +00:00
Zamitto
4471bf0f8b chore: bump to electron 37 2025-10-24 21:05:40 -03:00
Zamitto
f239562bb3 Merge branch 'main' into chore/bump-electron-version 2025-10-24 21:05:00 -03:00
Zamitto
11c19f5fe5 chore: downgrade to latest of 34 2025-10-24 20:47:24 -03:00
Moyasee
8de6c92d28 ci: formatting 2025-10-24 08:19:55 +03:00
Zamitto
29e822f2f1 fix: node version on gh actions files 2025-10-23 17:56:45 -03:00
Zamitto
a388acf948 chore: update node version on gh actions 2025-10-23 17:51:15 -03:00
Zamitto
40f7e6e2ad chore: bump electron version to 35 2025-10-23 17:47:54 -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
Chubby Granny Chaser
3782d74ad2 Merge branch 'feat/improving-sources' of github.com:hydralauncher/hydra into feat/improving-sources 2025-10-21 04:22:17 +01:00
Chubby Granny Chaser
8a40c678f7 feat: using api download sources 2025-10-21 04:21:56 +01:00
Chubby Granny Chaser
e1ce5bc6cb feat: using api download sources 2025-10-21 04:20:11 +01:00
Chubby Granny Chaser
48ce9a2476 feat: using api download sources 2025-10-21 04:18:11 +01:00
Chubby Granny Chaser
2909be312a Merge branch 'main' into feat/improving-sources 2025-10-21 03:59:49 +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
Chubby Granny Chaser
d2089ec7d4 Merge branch 'main' into feat/improving-sources 2025-10-18 14:08:31 +01:00
Chubby Granny Chaser
c2273dbf71 feat: moving sources to worker 2025-10-18 14:07:44 +01:00
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
121 changed files with 4485 additions and 1976 deletions

37
.cursorrules Normal file
View File

@@ -0,0 +1,37 @@
# Hydra Project Rules
## Logging
- **Always use `logger` instead of `console` for logging** in both main and renderer processes
- In main process: `import { logger } from "@main/services";`
- In renderer process: `import { logger } from "@renderer/logger";`
- Replace all instances of:
- `console.log()` → `logger.log()`
- `console.error()` → `logger.error()`
- `console.warn()` → `logger.warn()`
- `console.info()` → `logger.info()`
- `console.debug()` → `logger.debug()`
- Do not use `console` for any logging purposes
## Internationalization (i18n)
- All user-facing strings must be translated using i18next
- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");`
- Add new translation keys to `src/locales/en/translation.json`
- Never hardcode English strings in the UI code
- Placeholder text in form fields must also be translated
## Code Style
- Use ESLint and Prettier for code formatting
- 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: 20.18.0
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

@@ -1,11 +1,12 @@
name: Build
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: pull_request
jobs:
build:
strategy:
@@ -22,7 +23,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.3
node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -38,6 +39,12 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |

View File

@@ -17,7 +17,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.3
node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile

View File

@@ -6,7 +6,8 @@ concurrency:
on:
push:
branches: [main]
branches:
- release/**
jobs:
build:
@@ -23,7 +24,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.3
node-version: 22.21.0
- name: Install dependencies
run: yarn --frozen-lockfile
@@ -39,6 +40,12 @@ jobs:
- name: Build with cx_Freeze
run: python python_rpc/setup.py build
- name: Copy OpenSSL DLLs
if: matrix.os == 'windows-2022'
run: |
cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll
cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
@@ -55,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'
@@ -72,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",
@@ -90,8 +91,7 @@
"winreg": "^1.2.5",
"ws": "^8.18.1",
"yaml": "^2.6.1",
"yup": "^1.5.0",
"zod": "^3.24.1"
"yup": "^1.5.0"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.705.0",
@@ -116,9 +116,9 @@
"@types/winreg": "^1.2.36",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^33.4.11",
"electron": "^37.7.1",
"electron-builder": "^26.0.12",
"electron-vite": "^3.0.0",
"electron-vite": "^4.0.1",
"eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.4",
@@ -130,7 +130,7 @@
"sass-embedded": "^1.80.6",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vite": "5.4.20",
"vite": "5.4.21",
"vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads",
"settings": "Settings",
"my_library": "My library",
@@ -94,6 +95,7 @@
"search": "Search games",
"home": "Home",
"catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads",
"search_results": "Search results",
"settings": "Settings",
@@ -223,6 +225,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 +364,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",
@@ -428,6 +434,9 @@
"validate_download_source": "Validate",
"remove_download_source": "Remove",
"add_download_source": "Add source",
"adding": "Adding…",
"failed_add_download_source": "Failed to add download source. Please try again.",
"download_source_already_exists": "This download source URL already exists.",
"download_count_zero": "No download options",
"download_count_one": "{{countFormatted}} download option",
"download_count_other": "{{countFormatted}} download options",
@@ -435,9 +444,16 @@
"add_download_source_description": "Insert the URL of the .json file",
"download_source_up_to_date": "Up-to-date",
"download_source_errored": "Errored",
"download_source_pending_matching": "Updating soon",
"download_source_matched": "Up to date",
"download_source_matching": "Updating",
"download_source_failed": "Error",
"download_source_no_information": "No information available",
"sync_download_sources": "Sync sources",
"removed_download_source": "Download source removed",
"removed_download_sources": "Download sources removed",
"removed_all_download_sources": "All download sources removed",
"download_sources_synced_successfully": "All download sources are synced",
"cancel_button_confirmation_delete_all_sources": "No",
"confirm_button_confirmation_delete_all_sources": "Yes, delete everything",
"title_confirmation_delete_all_sources": "Delete all download sources",
@@ -468,6 +484,7 @@
"seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievements description before unlocking them",
"account": "Account",
"hydra_cloud": "Hydra Cloud",
"no_users_blocked": "You have no blocked users",
"subscription_active_until": "Your Hydra Cloud is active until {{date}}",
"manage_subscription": "Manage subscription",
@@ -678,7 +695,32 @@
"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",
"favourited_games": "Favourited",
"new_games": "New Games",
"top_10": "Top 10"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -325,6 +325,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 +362,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",
@@ -541,7 +545,9 @@
"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 +682,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",

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

@@ -317,6 +317,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 +350,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",
@@ -416,6 +420,9 @@
"validate_download_source": "Validar",
"remove_download_source": "Remover",
"add_download_source": "Adicionar fonte",
"adding": "Adicionando…",
"failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.",
"download_source_already_exists": "Esta URL de fonte de download já existe.",
"download_count_zero": "Sem downloads na lista",
"download_count_one": "{{countFormatted}} download na lista",
"download_count_other": "{{countFormatted}} downloads na lista",
@@ -423,7 +430,13 @@
"add_download_source_description": "Insira a URL contendo o arquivo .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"download_source_pending_matching": "Importando em breve",
"download_source_matched": "Sincronizada",
"download_source_matching": "Sincronizando",
"download_source_failed": "Erro",
"download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"removed_download_sources": "Fontes removidas",
"cancel_button_confirmation_delete_all_sources": "Não",
@@ -529,7 +542,9 @@
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo"
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"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",
@@ -682,7 +697,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",

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",
@@ -252,7 +256,13 @@
"add_download_source_description": "Insere o URL que contém o ficheiro .json",
"download_source_up_to_date": "Sincronizada",
"download_source_errored": "Falhou",
"download_source_pending_matching": "A atualizar em breve",
"download_source_matched": "Atualizado",
"download_source_matching": "A atualizar",
"download_source_failed": "Erro",
"download_source_no_information": "Sem informações",
"sync_download_sources": "Sincronizar",
"download_sources_synced_successfully": "Fontes de download sincronizadas",
"removed_download_source": "Fonte removida",
"cancel_button_confirmation_delete_all_sources": "Não",
"confirm_button_confirmation_delete_all_sources": "Sim, apague tudo",
@@ -460,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

@@ -212,6 +212,7 @@
"stats": "Статистика",
"download_count": "Загрузки",
"player_count": "Активные игроки",
"rating_count": "Оценка",
"download_error": "Этот вариант загрузки недоступен",
"download": "Скачать",
"executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"",
@@ -226,6 +227,7 @@
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл",
@@ -252,17 +254,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 +351,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 +432,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 +442,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 +482,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": "Управлять подпиской",
@@ -540,7 +556,9 @@
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"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 +608,7 @@
"activity": "Недавняя активность",
"library": "Библиотека",
"pinned": "Закрепленные",
"sort_by": "Сортировать по:",
"achievements_earned": "Заработанные достижения",
"played_recently": "Недавно сыгранные",
"playtime": "Время игры",
@@ -674,7 +693,11 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов"
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",

View File

@@ -6,6 +6,10 @@ import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
export const getGameAssets = async (objectId: string, shop: GameShop) => {
if (shop === "custom") {
return null;
}
const cachedAssets = await gamesShopAssetsSublevel.get(
levelKeys.game(shop, objectId)
);

View File

@@ -26,6 +26,8 @@ const getGameShopDetails = async (
shop: GameShop,
language: string
): Promise<ShopDetailsWithAssets | null> => {
if (shop === "custom") return null;
if (shop === "steam") {
const [cachedData, cachedAssets] = await Promise.all([
gamesShopCacheSublevel.get(

View File

@@ -10,6 +10,10 @@ const getGameStats = async (
objectId: string,
shop: GameShop
) => {
if (shop === "custom") {
return null;
}
const cachedStats = await gamesStatsCacheSublevel.get(
levelKeys.game(shop, objectId)
);

View File

@@ -1,76 +1,50 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal } from "./helpers";
import { HydraApi } from "@main/services/hydra-api";
import { downloadSourcesSublevel } from "@main/level";
import type { DownloadSource } from "@types";
import { logger } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const result = await importDownloadSourceToLocal(url, true);
if (!result) {
throw new Error("Failed to import download source");
}
try {
const existingSources = await downloadSourcesSublevel.values().all();
const urlExists = existingSources.some((source) => source.url === url);
// Verify that repacks were actually written to the database (read-after-write)
// This ensures all async operations are complete before proceeding
let repackCount = 0;
for await (const [, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === result.id) {
repackCount++;
if (urlExists) {
throw new Error("Download source with this URL already exists");
}
}
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
const downloadSource = await HydraApi.post<DownloadSource>(
"/download-sources",
{
url,
},
{ needsAuth: false }
);
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: result.objectIds,
},
{ needsAuth: false }
);
if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
try {
await HydraApi.post("/profile/download-sources", {
urls: [url],
});
} catch (error) {
logger.error("Failed to add download source to profile:", error);
}
}
// Update the source with fingerprint
const updatedSource = await downloadSourcesSublevel.get(`${result.id}`);
if (updatedSource) {
await downloadSourcesSublevel.put(`${result.id}`, {
...updatedSource,
fingerprint,
updatedAt: new Date(),
await downloadSourcesSublevel.put(downloadSource.id, {
...downloadSource,
isRemote: true,
createdAt: new Date().toISOString(),
});
}
// Final verification: ensure the source with fingerprint is persisted
const finalSource = await downloadSourcesSublevel.get(`${result.id}`);
if (!finalSource || !finalSource.fingerprint) {
throw new Error("Failed to persist download source with fingerprint");
return downloadSource;
} catch (error) {
logger.error("Failed to add download source:", error);
throw error;
}
// Verify repacks still exist after fingerprint update
let finalRepackCount = 0;
for await (const [, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === result.id) {
finalRepackCount++;
}
}
if (finalRepackCount !== repackCount) {
logger.warn(
`Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}`
);
} else {
logger.info(
`Final verification passed: ${finalRepackCount} repacks confirmed`
);
}
return {
...result,
fingerprint,
};
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@@ -1,17 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
const checkDownloadSourceExists = async (
_event: Electron.IpcMainInvokeEvent,
url: string
): Promise<boolean> => {
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (source.url === url) {
return true;
}
}
return false;
};
registerEvent("checkDownloadSourceExists", checkDownloadSourceExists);

View File

@@ -1,13 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateIdCaches } from "./helpers";
const deleteAllDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
) => {
await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]);
invalidateIdCaches();
};
registerEvent("deleteAllDownloadSources", deleteAllDownloadSources);

View File

@@ -1,28 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { invalidateIdCaches } from "./helpers";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
const repacksToDelete: string[] = [];
for await (const [key, repack] of repacksSublevel.iterator()) {
if (repack.downloadSourceId === id) {
repacksToDelete.push(key);
}
}
const batch = repacksSublevel.batch();
for (const key of repacksToDelete) {
batch.del(key);
}
await batch.write();
await downloadSourcesSublevel.del(`${id}`);
invalidateIdCaches();
};
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@@ -1,19 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel, DownloadSource } from "@main/level";
const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => {
const sources: DownloadSource[] = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
sources.push(source);
}
// Sort by createdAt descending
sources.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return sources;
};
registerEvent("getDownloadSourcesList", getDownloadSourcesList);

View File

@@ -1,8 +1,10 @@
import { HydraApi } from "@main/services";
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
import { orderBy } from "lodash-es";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get("/profile/download-sources");
const allSources = await downloadSourcesSublevel.values().all();
return orderBy(allSources, "createdAt", "desc");
};
registerEvent("getDownloadSources", getDownloadSources);

View File

@@ -1,367 +0,0 @@
import axios from "axios";
import { z } from "zod";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import crypto from "node:crypto";
import { logger, ResourceCache } from "@main/services";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
export type TitleHashMapping = Record<string, number[]>;
let titleHashMappingCache: TitleHashMapping | null = null;
export const getTitleHashMapping = async (): Promise<TitleHashMapping> => {
if (titleHashMappingCache) {
return titleHashMappingCache;
}
try {
const cached =
ResourceCache.getCachedData<TitleHashMapping>("sources-manifest");
if (cached) {
titleHashMappingCache = cached;
return cached;
}
const fetched = await ResourceCache.fetchAndCache<TitleHashMapping>(
"sources-manifest",
"https://cdn.losbroxas.org/sources-manifest.json",
10000
);
titleHashMappingCache = fetched;
return fetched;
} catch (error) {
logger.error("Failed to fetch title hash mapping:", error);
return {} as TitleHashMapping;
}
};
export const hashTitle = (title: string): string => {
return crypto.createHash("sha256").update(title).digest("hex");
};
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
export type FormattedSteamGame = {
id: string;
name: string;
formattedName: string;
};
export type FormattedSteamGamesByLetter = Record<string, FormattedSteamGame[]>;
export const formatName = (name: string) => {
return name
.normalize("NFD")
.replaceAll(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replaceAll(/[^a-z0-9]/g, "");
};
export const formatRepackName = (name: string) => {
return formatName(name.replace("[DL]", ""));
};
interface DownloadSource {
id: number;
url: string;
name: string;
etag: string | null;
status: number;
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}
const getDownloadSourcesMap = async (): Promise<
Map<string, DownloadSource>
> => {
const map = new Map();
for await (const [key, source] of downloadSourcesSublevel.iterator()) {
map.set(key, source);
}
return map;
};
export const checkUrlExists = async (url: string): Promise<boolean> => {
const sources = await getDownloadSourcesMap();
for (const source of sources.values()) {
if (source.url === url) {
return true;
}
}
return false;
};
let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null;
export const getSteamGames = async (): Promise<FormattedSteamGamesByLetter> => {
if (steamGamesFormattedCache) {
return steamGamesFormattedCache;
}
let steamGames: SteamGamesByLetter;
const cached = ResourceCache.getCachedData<SteamGamesByLetter>(
"steam-games-by-letter"
);
if (cached) {
steamGames = cached;
} else {
steamGames = await ResourceCache.fetchAndCache<SteamGamesByLetter>(
"steam-games-by-letter",
`${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
);
}
const formattedData: FormattedSteamGamesByLetter = {};
for (const [letter, games] of Object.entries(steamGames)) {
formattedData[letter] = games.map((game) => ({
...game,
formattedName: formatName(game.name),
}));
}
steamGamesFormattedCache = formattedData;
return formattedData;
};
export type SublevelIterator = AsyncIterable<[string, { id: number }]>;
export interface SublevelWithId {
iterator: () => SublevelIterator;
}
let maxRepackId: number | null = null;
let maxDownloadSourceId: number | null = null;
export const getNextId = async (sublevel: SublevelWithId): Promise<number> => {
const isRepackSublevel = sublevel === repacksSublevel;
const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel;
if (isRepackSublevel && maxRepackId !== null) {
return ++maxRepackId;
}
if (isDownloadSourceSublevel && maxDownloadSourceId !== null) {
return ++maxDownloadSourceId;
}
let maxId = 0;
for await (const [, value] of sublevel.iterator()) {
if (value.id > maxId) {
maxId = value.id;
}
}
if (isRepackSublevel) {
maxRepackId = maxId;
} else if (isDownloadSourceSublevel) {
maxDownloadSourceId = maxId;
}
return maxId + 1;
};
export const invalidateIdCaches = () => {
maxRepackId = null;
maxDownloadSourceId = null;
};
export const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: FormattedSteamGamesByLetter
) => {
const now = new Date();
const objectIdsOnSource = new Set<string>();
let nextRepackId = await getNextId(repacksSublevel);
const batch = repacksSublevel.batch();
const titleHashMapping = await getTitleHashMapping();
let hashMatchCount = 0;
let fuzzyMatchCount = 0;
let noMatchCount = 0;
for (const download of downloads) {
let objectIds: string[] = [];
let usedHashMatch = false;
const titleHash = hashTitle(download.title);
const steamIdsFromHash = titleHashMapping[titleHash];
if (steamIdsFromHash && steamIdsFromHash.length > 0) {
hashMatchCount++;
usedHashMatch = true;
objectIds = steamIdsFromHash.map(String);
}
if (!usedHashMatch) {
let gamesInSteam: FormattedSteamGame[] = [];
const formattedTitle = formatRepackName(download.title);
if (formattedTitle && formattedTitle.length > 0) {
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.formattedName)
);
if (gamesInSteam.length === 0) {
gamesInSteam = games.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
}
if (gamesInSteam.length === 0) {
for (const letter of Object.keys(steamGames)) {
const letterGames = steamGames[letter] || [];
const matches = letterGames.filter(
(game) =>
formattedTitle.includes(game.formattedName) ||
game.formattedName.includes(formattedTitle)
);
if (matches.length > 0) {
gamesInSteam = matches;
break;
}
}
}
if (gamesInSteam.length > 0) {
fuzzyMatchCount++;
objectIds = gamesInSteam.map((game) => String(game.id));
} else {
noMatchCount++;
}
} else {
noMatchCount++;
}
}
for (const id of objectIds) {
objectIdsOnSource.add(id);
}
const repack = {
id: nextRepackId++,
objectIds: objectIds,
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
};
batch.put(`${repack.id}`, repack);
}
await batch.write();
logger.info(
`Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}`
);
const existingSource = await downloadSourcesSublevel.get(
`${downloadSource.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...existingSource,
objectIds: Array.from(objectIdsOnSource),
});
}
return Array.from(objectIdsOnSource);
};
export const importDownloadSourceToLocal = async (
url: string,
throwOnDuplicate = false
) => {
const urlExists = await checkUrlExists(url);
if (urlExists) {
if (throwOnDuplicate) {
throw new Error("Download source with this URL already exists");
}
return null;
}
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const steamGames = await getSteamGames();
const now = new Date();
const nextId = await getNextId(downloadSourcesSublevel);
const downloadSource = {
id: nextId,
url,
name: response.data.name,
etag: response.headers["etag"] || null,
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
objectIds: [],
createdAt: now,
updatedAt: now,
};
await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource);
const objectIds = await addNewDownloads(
downloadSource,
response.data.downloads,
steamGames
);
// Invalidate ID caches after creating new repacks to prevent ID collisions
invalidateIdCaches();
return {
...downloadSource,
objectIds,
};
};
export const updateDownloadSourcePreservingTimestamp = async (
existingSource: DownloadSource,
url: string
) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const updatedSource = {
...existingSource,
name: response.data.name,
etag: response.headers["etag"] || null,
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
updatedAt: new Date(),
// Preserve the original createdAt timestamp
};
await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource);
return updatedSource;
};

View File

@@ -1,18 +1,27 @@
import { HydraApi } from "@main/services";
import { downloadSourcesSublevel } from "@main/level";
import { registerEvent } from "../register-event";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url?: string,
removeAll = false
removeAll = false,
downloadSourceId?: string
) => {
const params = new URLSearchParams({
all: removeAll.toString(),
});
if (url) params.set("url", url);
if (downloadSourceId) params.set("downloadSourceId", downloadSourceId);
return HydraApi.delete(`/profile/download-sources?${params.toString()}`);
if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) {
void HydraApi.delete(`/profile/download-sources?${params.toString()}`);
}
if (removeAll) {
await downloadSourcesSublevel.clear();
} else if (downloadSourceId) {
await downloadSourcesSublevel.del(downloadSourceId);
}
};
registerEvent("removeDownloadSource", removeDownloadSource);

View File

@@ -1,19 +0,0 @@
import { HydraApi, logger } from "@main/services";
import { importDownloadSourceToLocal, checkUrlExists } from "./helpers";
export const syncDownloadSourcesFromApi = async () => {
try {
const apiSources = await HydraApi.get<
{ url: string; createdAt: string; updatedAt: string }[]
>("/profile/download-sources");
for (const apiSource of apiSources) {
const exists = await checkUrlExists(apiSource.url);
if (!exists) {
await importDownloadSourceToLocal(apiSource.url, false);
}
}
} catch (error) {
logger.error("Failed to sync download sources from API:", error);
}
};

View File

@@ -1,114 +1,28 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
import axios, { AxiosError } from "axios";
import { downloadSourcesSublevel, repacksSublevel } from "@main/level";
import { DownloadSourceStatus } from "@shared";
import {
invalidateIdCaches,
downloadSourceSchema,
getSteamGames,
addNewDownloads,
} from "./helpers";
import { downloadSourcesSublevel } from "@main/level";
import type { DownloadSource } from "@types";
const syncDownloadSources = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
let newRepacksCount = 0;
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
const downloadSources = await downloadSourcesSublevel.values().all();
try {
const downloadSources: Array<{
id: number;
url: string;
name: string;
etag: string | null;
status: number;
downloadCount: number;
objectIds: string[];
fingerprint?: string;
createdAt: Date;
updatedAt: Date;
}> = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
downloadSources.push(source);
}
const response = await HydraApi.post<DownloadSource[]>(
"/download-sources/sync",
{
ids: downloadSources.map((downloadSource) => downloadSource.id),
},
{ needsAuth: false }
);
const existingRepacks: Array<{
id: number;
title: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
downloadSourceId: number;
createdAt: Date;
updatedAt: Date;
}> = [];
for await (const [, repack] of repacksSublevel.iterator()) {
existingRepacks.push(repack);
}
// Handle sources with missing fingerprints individually, don't delete all sources
const sourcesWithFingerprints = downloadSources.filter(
(source) => source.fingerprint
);
const sourcesWithoutFingerprints = downloadSources.filter(
(source) => !source.fingerprint
for (const downloadSource of response) {
const existingDownloadSource = downloadSources.find(
(source) => source.id === downloadSource.id
);
// For sources without fingerprints, just continue with normal sync
// They will get fingerprints updated later by updateMissingFingerprints
const allSourcesToSync = [
...sourcesWithFingerprints,
...sourcesWithoutFingerprints,
];
for (const downloadSource of allSourcesToSync) {
const headers: Record<string, string> = {};
if (downloadSource.etag) {
headers["If-None-Match"] = downloadSource.etag;
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
const steamGames = await getSteamGames();
const repacks = source.downloads.filter(
(download) =>
!existingRepacks.some((repack) => repack.title === download.title)
);
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...downloadSource,
etag: response.headers["etag"] || null,
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
await addNewDownloads(downloadSource, repacks, steamGames);
newRepacksCount += repacks.length;
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesSublevel.put(`${downloadSource.id}`, {
...downloadSource,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
invalidateIdCaches();
return newRepacksCount;
} catch (err) {
return -1;
await downloadSourcesSublevel.put(downloadSource.id, {
...existingDownloadSource,
...downloadSource,
});
}
};

View File

@@ -1,67 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi, logger } from "@main/services";
const updateMissingFingerprints = async (
_event: Electron.IpcMainInvokeEvent
): Promise<number> => {
const sourcesNeedingFingerprints: Array<{
id: number;
objectIds: string[];
}> = [];
for await (const [, source] of downloadSourcesSublevel.iterator()) {
if (
!source.fingerprint &&
source.objectIds &&
source.objectIds.length > 0
) {
sourcesNeedingFingerprints.push({
id: source.id,
objectIds: source.objectIds,
});
}
}
if (sourcesNeedingFingerprints.length === 0) {
return 0;
}
logger.info(
`Updating fingerprints for ${sourcesNeedingFingerprints.length} sources`
);
await Promise.all(
sourcesNeedingFingerprints.map(async (source) => {
try {
const { fingerprint } = await HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds: source.objectIds,
},
{ needsAuth: false }
);
const existingSource = await downloadSourcesSublevel.get(
`${source.id}`
);
if (existingSource) {
await downloadSourcesSublevel.put(`${source.id}`, {
...existingSource,
fingerprint,
updatedAt: new Date(),
});
}
} catch (error) {
logger.error(
`Failed to update fingerprint for source ${source.id}:`,
error
);
}
})
);
return sourcesNeedingFingerprints.length;
};
registerEvent("updateMissingFingerprints", updateMissingFingerprints);

View File

@@ -1,32 +0,0 @@
import { registerEvent } from "../register-event";
import axios from "axios";
import { z } from "zod";
const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
const { name } = downloadSourceSchema.parse(response.data);
return {
name,
etag: response.headers["etag"] || null,
downloadCount: response.data.downloads.length,
};
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@@ -18,6 +18,7 @@ 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/open-game";
import "./library/open-game-executable-path";
@@ -63,14 +64,7 @@ import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
import "./download-sources/update-missing-fingerprints";
import "./download-sources/delete-download-source";
import "./download-sources/delete-all-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/sync-download-sources";
import "./download-sources/get-download-sources-list";
import "./download-sources/check-download-source-exists";
import "./repacks/get-all-repacks";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";

View File

@@ -37,6 +37,7 @@ const addCustomGameToLibrary = async (
logoImageUrl: logoImageUrl || "",
logoPosition: null,
coverImageUrl: iconUrl || "",
downloadSources: [],
};
await gamesShopAssetsSublevel.put(gameKey, assets);

View File

@@ -13,7 +13,9 @@ const addGameToFavorites = async (
const game = await gamesSublevel.get(gameKey);
if (!game) return;
HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {});
if (shop !== "custom") {
HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {});
}
try {
await gamesSublevel.put(gameKey, {

View File

@@ -4,6 +4,7 @@ import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
gameAchievementsSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => {
@@ -18,14 +19,32 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
let unlockedAchievementCount = 0;
let achievementCount = 0;
try {
const achievements = await gameAchievementsSublevel.get(key);
if (achievements) {
achievementCount = achievements.achievements.length;
unlockedAchievementCount =
achievements.unlockedAchievements.length;
}
} catch {
// No achievements data for this game
}
return {
id: key,
...game,
download: download ?? null,
unlockedAchievementCount,
achievementCount,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Ensure compatibility with LibraryGame type
libraryHeroImageUrl:
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
} as LibraryGame;
})
);

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

@@ -13,7 +13,11 @@ const removeGameFromFavorites = async (
const game = await gamesSublevel.get(gameKey);
if (!game) return;
HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(() => {});
if (shop !== "custom") {
HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(
() => {}
);
}
try {
await gamesSublevel.put(gameKey, {

View File

@@ -84,7 +84,7 @@ const removeGameFromLibrary = async (
await resetShopAssets(gameKey);
}
if (game?.remoteId) {
if (game.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
}

View File

@@ -1,16 +0,0 @@
import { registerEvent } from "../register-event";
import { repacksSublevel, GameRepack } from "@main/level";
const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => {
const repacks: GameRepack[] = [];
for await (const [, repack] of repacksSublevel.iterator()) {
if (Array.isArray(repack.objectIds)) {
repacks.push(repack);
}
}
return repacks;
};
registerEvent("getAllRepacks", getAllRepacks);

View File

@@ -0,0 +1,27 @@
import { downloadSourcesSublevel } from "@main/level";
import { HydraApi } from "@main/services/hydra-api";
import { DownloadSource } from "@types";
export const migrateDownloadSources = async () => {
const downloadSources = downloadSourcesSublevel.iterator();
for await (const [key, value] of downloadSources) {
if (!value.isRemote) {
const downloadSource = await HydraApi.post<DownloadSource>(
"/download-sources",
{
url: value.url,
},
{ needsAuth: false }
);
await downloadSourcesSublevel.put(downloadSource.id, {
...downloadSource,
isRemote: true,
createdAt: new Date().toISOString(),
});
await downloadSourcesSublevel.del(key);
}
}
};

View File

@@ -1,18 +1,6 @@
import { db } from "../level";
import { levelKeys } from "./keys";
export interface DownloadSource {
id: number;
name: string;
url: string;
status: number;
objectIds: string[];
downloadCount: number;
fingerprint?: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
}
import type { DownloadSource } from "@types";
export const downloadSourcesSublevel = db.sublevel<string, DownloadSource>(
levelKeys.downloadSources,

View File

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

View File

@@ -18,5 +18,4 @@ export const levelKeys = {
screenState: "screenState",
rpcPassword: "rpcPassword",
downloadSources: "downloadSources",
repacks: "repacks",
};

View File

@@ -1,22 +0,0 @@
import { db } from "../level";
import { levelKeys } from "./keys";
export interface GameRepack {
id: number;
title: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
downloadSourceId: number;
createdAt: Date;
updatedAt: Date;
}
export const repacksSublevel = db.sublevel<string, GameRepack>(
levelKeys.repacks,
{
valueEncoding: "json",
}
);

View File

@@ -16,15 +16,13 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
ResourceCache,
WSClient,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
export const loadState = async () => {
await Lock.acquireLock();
ResourceCache.initialize();
await ResourceCache.updateResourcesOnStartup();
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
@@ -53,9 +51,13 @@ export const loadState = async () => {
DeckyPlugin.checkAndUpdateIfOutdated();
}
await HydraApi.setupApi().then(() => {
await HydraApi.setupApi().then(async () => {
uploadGamesBatch();
// WSClient.connect();
void migrateDownloadSources();
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
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

@@ -27,6 +27,10 @@ export const getGameAchievementData = async (
shop: GameShop,
useCachedData: boolean
) => {
if (shop === "custom") {
return [];
}
const gameKey = levelKeys.game(shop, objectId);
const cachedAchievements = await gameAchievementsSublevel.get(gameKey);

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;
@@ -46,7 +47,7 @@ export class HydraApi {
return this.userAuth.authToken !== "";
}
private static hasActiveSubscription() {
public static hasActiveSubscription() {
const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
}
@@ -103,12 +104,10 @@ export class HydraApi {
await clearGamesRemoteIds();
uploadGamesBatch();
// WSClient.close();
// WSClient.connect();
WSClient.close();
WSClient.connect();
const { syncDownloadSourcesFromApi } = await import(
"../events/download-sources/sync-download-sources-from-api"
);
const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi();
}
}

View File

@@ -18,4 +18,4 @@ export * from "./library-sync";
export * from "./wine";
export * from "./lock";
export * from "./decky-plugin";
export * from "./resource-cache";
export * from "./user";

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

@@ -60,18 +60,26 @@ export const mergeWithRemoteGames = async () => {
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,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
downloadSources: game.downloadSources,
});
}
})

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

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

@@ -1,157 +0,0 @@
import { app } from "electron";
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
import { logger } from "./logger";
interface CachedResource<T = unknown> {
data: T;
etag: string | null;
}
export class ResourceCache {
private static cacheDir: string;
static initialize() {
this.cacheDir = path.join(app.getPath("userData"), "resource-cache");
if (!fs.existsSync(this.cacheDir)) {
fs.mkdirSync(this.cacheDir, { recursive: true });
}
}
private static getCacheFilePath(resourceName: string): string {
return path.join(this.cacheDir, `${resourceName}.json`);
}
private static getEtagFilePath(resourceName: string): string {
return path.join(this.cacheDir, `${resourceName}.etag`);
}
private static readCachedResource<T = unknown>(
resourceName: string
): CachedResource<T> | null {
const dataPath = this.getCacheFilePath(resourceName);
const etagPath = this.getEtagFilePath(resourceName);
if (!fs.existsSync(dataPath)) {
return null;
}
try {
const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T;
const etag = fs.existsSync(etagPath)
? fs.readFileSync(etagPath, "utf-8")
: null;
return { data, etag };
} catch (error) {
logger.error(`Failed to read cached resource ${resourceName}:`, error);
return null;
}
}
private static writeCachedResource<T = unknown>(
resourceName: string,
data: T,
etag: string | null
): void {
const dataPath = this.getCacheFilePath(resourceName);
const etagPath = this.getEtagFilePath(resourceName);
try {
fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8");
if (etag) {
fs.writeFileSync(etagPath, etag, "utf-8");
}
logger.info(
`Cached resource ${resourceName} with etag: ${etag || "none"}`
);
} catch (error) {
logger.error(`Failed to write cached resource ${resourceName}:`, error);
}
}
static async fetchAndCache<T = unknown>(
resourceName: string,
url: string,
timeout: number = 10000
): Promise<T> {
const cached = this.readCachedResource<T>(resourceName);
const headers: Record<string, string> = {};
if (cached?.etag) {
headers["If-None-Match"] = cached.etag;
}
try {
const response = await axios.get<T>(url, {
headers,
timeout,
});
const newEtag = response.headers["etag"] || null;
this.writeCachedResource(resourceName, response.data, newEtag);
return response.data;
} catch (error: unknown) {
const axiosError = error as {
response?: { status?: number };
message?: string;
};
if (axiosError.response?.status === 304 && cached) {
logger.info(`Resource ${resourceName} not modified, using cache`);
return cached.data;
}
if (cached) {
logger.warn(
`Failed to fetch ${resourceName}, using cached version:`,
axiosError.message || "Unknown error"
);
return cached.data;
}
logger.error(
`Failed to fetch ${resourceName} and no cache available:`,
error
);
throw error;
}
}
static getCachedData<T = unknown>(resourceName: string): T | null {
const cached = this.readCachedResource<T>(resourceName);
return cached?.data || null;
}
static async updateResourcesOnStartup(): Promise<void> {
logger.info("Starting background resource cache update...");
const resources = [
{
name: "steam-games-by-letter",
url: `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`,
},
{
name: "sources-manifest",
url: "https://cdn.losbroxas.org/sources-manifest.json",
},
];
await Promise.allSettled(
resources.map(async (resource) => {
try {
await this.fetchAndCache(resource.name, resource.url);
} catch (error) {
logger.error(`Failed to update ${resource.name} on startup:`, error);
}
})
);
logger.info("Resource cache update complete");
}
}

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

@@ -0,0 +1,2 @@
export * from "./get-user-data";
export * from "./sync-download-sources";

View File

@@ -0,0 +1,41 @@
import { HydraApi, logger } from "../";
import { downloadSourcesSublevel } from "@main/level";
import type { DownloadSource } from "@types";
export const syncDownloadSourcesFromApi = async () => {
if (!HydraApi.isLoggedIn() || !HydraApi.hasActiveSubscription()) {
return;
}
try {
const profileSources = await HydraApi.get<DownloadSource[]>(
"/profile/download-sources"
);
const existingSources = await downloadSourcesSublevel.values().all();
const existingUrls = new Set(existingSources.map((source) => source.url));
for (const downloadSource of profileSources) {
if (!existingUrls.has(downloadSource.url)) {
try {
await downloadSourcesSublevel.put(downloadSource.id, {
...downloadSource,
isRemote: true,
createdAt: new Date().toISOString(),
});
logger.log(
`Synced download source from profile: ${downloadSource.url}`
);
} catch (error) {
logger.error(
`Failed to sync download source ${downloadSource.url}:`,
error
);
}
}
}
} catch (error) {
logger.error("Failed to sync download sources from API:", error);
}
};

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

@@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
updateMissingFingerprints: () =>
ipcRenderer.invoke("updateMissingFingerprints"),
removeDownloadSource: (url: string, removeAll?: boolean) =>
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
deleteAllDownloadSources: () =>
ipcRenderer.invoke("deleteAllDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"),
checkDownloadSourceExists: (url: string) =>
ipcRenderer.invoke("checkDownloadSourceExists", url),
getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"),
/* Library */
toggleAutomaticCloudSync: (
@@ -208,6 +196,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) =>

View File

@@ -5,7 +5,7 @@
}
::-webkit-scrollbar {
width: 9px;
width: 4px;
background-color: globals.$dark-background-color;
}

View File

@@ -7,7 +7,6 @@ import {
useAppSelector,
useDownload,
useLibrary,
useRepacks,
useToast,
useUserDetails,
} from "@renderer/hooks";
@@ -20,7 +19,6 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -40,8 +38,6 @@ export function App() {
const { t } = useTranslation("app");
const { updateRepacks } = useRepacks();
const { clearDownload, setLastPacket } = useDownload();
const {
@@ -199,36 +195,6 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
useEffect(() => {
(async () => {
dispatch(setIsImportingSources(true));
try {
// Initial repacks load
await updateRepacks();
// Sync all local sources (check for updates)
const newRepacksCount = await window.electron.syncDownloadSources();
if (newRepacksCount > 0) {
window.electron.publishNewRepacksNotification(newRepacksCount);
}
// Update fingerprints for sources that don't have them
await window.electron.updateMissingFingerprints();
// Update repacks AFTER all syncing and fingerprint updates are complete
await updateRepacks();
} catch (error) {
console.error("Error syncing download sources:", error);
// Still update repacks even if sync fails
await updateRepacks();
} finally {
dispatch(setIsImportingSources(false));
}
})();
}, [updateRepacks, dispatch]);
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
@@ -313,7 +279,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

@@ -1,5 +1,5 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { GameStats } from "@types";
import type { GameStats, ShopAssets } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -8,15 +8,15 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating";
import { useCallback, useState, useMemo } from "react";
import { useFormat, useRepacks } from "@renderer/hooks";
import { useCallback, useState } from "react";
import { useFormat } from "@renderer/hooks";
export interface GameCardProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
game: any;
game: ShopAssets;
}
const shopIcon = {
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState<GameStats | null>(null);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = Array.from(
new Set(repacks.map((repack) => repack.repacker))
);
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
@@ -45,15 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { numberFormatter } = useFormat();
const firstThreeRepackers = useMemo(
() => uniqueRepackers.slice(0, 3),
[uniqueRepackers]
);
const remainingCount = useMemo(
() => uniqueRepackers.length - 3,
[uniqueRepackers]
);
return (
<button
{...props}
@@ -75,18 +59,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
<p className="game-card__title">{game.title}</p>
</div>
{uniqueRepackers.length > 0 ? (
{game.downloadSources.length > 0 ? (
<ul className="game-card__download-options">
{firstThreeRepackers.map((repacker) => (
<li key={repacker}>
<Badge>{repacker}</Badge>
{game.downloadSources.slice(0, 3).map((sourceName) => (
<li key={sourceName}>
<Badge>{sourceName}</Badge>
</li>
))}
{remainingCount > 0 && (
{game.downloadSources.length > 3 && (
<li>
<Badge>
+{remainingCount}{" "}
{t("game_card:available", { count: remainingCount })}
+{game.downloadSources.length - 3}{" "}
{t("game_card:available", {
count: game.downloadSources.length - 3,
})}
</Badge>
</li>
)}

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

@@ -24,7 +24,7 @@
background-color: globals.$background-color;
display: inline-flex;
transition: all ease 0.2s;
width: 200px;
width: 300px;
align-items: center;
border-radius: 8px;
border: solid 1px globals.$border-color;
@@ -35,7 +35,7 @@
}
&--focused {
width: 250px;
width: 350px;
border-color: #dadbe1;
}
}

View File

@@ -13,6 +13,7 @@ import cn from "classnames";
const pathTitle: Record<string, string> = {
"/": "home",
"/catalogue": "catalogue",
"/library": "library",
"/downloads": "downloads",
"/settings": "settings",
};
@@ -41,6 +42,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,7 +63,7 @@ export function Header() {
};
const handleSearch = (value: string) => {
dispatch(setFilters({ title: value }));
dispatch(setFilters({ title: value.slice(0, 255) }));
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");

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

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

@@ -98,6 +98,11 @@ export function CloudSyncContextProvider({
);
const getGameArtifacts = useCallback(async () => {
if (shop === "custom") {
setArtifacts([]);
return;
}
const params = new URLSearchParams({
objectId,
shop,

View File

@@ -1,11 +1,4 @@
import {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
@@ -13,11 +6,11 @@ import {
useAppDispatch,
useAppSelector,
useDownload,
useRepacks,
useUserDetails,
} from "@renderer/hooks";
import type {
GameRepack,
GameShop,
GameStats,
LibraryGame,
@@ -84,12 +77,7 @@ export function GameDetailsContextProvider({
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const { getRepacksForObjectId } = useRepacks();
const repacks = useMemo(() => {
return getRepacksForObjectId(objectId);
}, [getRepacksForObjectId, objectId]);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { i18n } = useTranslation("game_details");
const location = useLocation();
@@ -142,10 +130,12 @@ export function GameDetailsContextProvider({
}
});
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
if (shop !== "custom") {
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
}
const assetsPromise = window.electron.getGameAssets(objectId, shop);
@@ -167,7 +157,7 @@ export function GameDetailsContextProvider({
setIsLoading(false);
});
if (userDetails) {
if (userDetails && shop !== "custom") {
window.electron
.getUnlockedAchievements(objectId, shop)
.then((achievements) => {
@@ -287,19 +277,6 @@ export function GameDetailsContextProvider({
}
}, [location]);
const lastDownloadedOption = useMemo(() => {
if (game?.download) {
const repack = repacks.find((repack) =>
repack.uris.some((uri) => uri.includes(game.download!.uri))
);
if (!repack) return null;
return repack;
}
return null;
}, [game?.download, repacks]);
useEffect(() => {
const unsubscribe = window.electron.onUpdateAchievements(
objectId,
@@ -315,6 +292,36 @@ export function GameDetailsContextProvider({
};
}, [objectId, shop, userDetails]);
useEffect(() => {
if (shop === "custom") return;
const fetchDownloadSources = async () => {
try {
const sources = await window.electron.getDownloadSources();
const params = {
take: 100,
skip: 0,
downloadSourceIds: sources.map((source) => source.id),
};
const downloads = await window.electron.hydraApi.get<GameRepack[]>(
`/games/${shop}/${objectId}/download-sources`,
{
params,
needsAuth: false,
}
);
setRepacks(downloads);
} catch (error) {
console.error("Failed to fetch download sources:", error);
}
};
fetchDownloadSources();
}, [shop, objectId]);
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@@ -359,7 +366,7 @@ export function GameDetailsContextProvider({
stats,
achievements,
hasNSFWContentBlocked,
lastDownloadedOption,
lastDownloadedOption: null,
setHasNSFWContentBlocked,
selectGameExecutable,
updateGame,

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

@@ -31,8 +31,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
DownloadSourceValidationResult,
GameRepack,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -161,6 +159,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>;
@@ -210,20 +209,12 @@ declare global {
/* Download sources */
addDownloadSource: (url: string) => Promise<DownloadSource>;
updateMissingFingerprints: () => Promise<number>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
>;
deleteDownloadSource: (id: number) => Promise<void>;
deleteAllDownloadSources: () => Promise<void>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: () => Promise<number>;
getDownloadSourcesList: () => Promise<DownloadSource[]>;
checkDownloadSourceExists: (url: string) => Promise<boolean>;
getAllRepacks: () => Promise<GameRepack[]>;
removeDownloadSource: (
removeAll = false,
downloadSourceId?: string
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -1,21 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
export interface DownloadSourcesState {
isImporting: boolean;
}
const initialState: DownloadSourcesState = {
isImporting: false,
};
export const downloadSourcesSlice = createSlice({
name: "downloadSources",
initialState,
reducers: {
setIsImportingSources: (state, action) => {
state.isImporting = action.payload;
},
},
});
export const { setIsImportingSources } = downloadSourcesSlice.actions;

View File

@@ -6,6 +6,4 @@ export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./game-running.slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./download-sources-slice";
export * from "./catalogue-search";

View File

@@ -5,5 +5,4 @@ export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
export * from "./use-repacks";
export * from "./use-feature";

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features";
import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -12,6 +13,7 @@ export function useCatalogue() {
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
@@ -37,17 +39,25 @@ export function useCatalogue() {
});
}, []);
const getDownloadSources = useCallback(() => {
window.electron.getDownloadSources().then((results) => {
setDownloadSources(results.filter((source) => !!source.fingerprint));
});
}, []);
useEffect(() => {
getSteamUserTags();
getSteamGenres();
getSteamPublishers();
getSteamDevelopers();
getDownloadSources();
}, [
getSteamUserTags,
getSteamGenres,
getSteamPublishers,
getSteamDevelopers,
getDownloadSources,
]);
return { steamPublishers, steamDevelopers };
return { steamPublishers, downloadSources, steamDevelopers };
}

View File

@@ -1,26 +0,0 @@
import { setRepacks } from "@renderer/features";
import { useCallback } from "react";
import { RootState } from "@renderer/store";
import { useSelector } from "react-redux";
import { useAppDispatch } from "./redux";
export function useRepacks() {
const dispatch = useAppDispatch();
const repacks = useSelector((state: RootState) => state.repacks.value);
const getRepacksForObjectId = useCallback(
(objectId: string) => {
return repacks.filter((repack) => repack.objectIds.includes(objectId));
},
[repacks]
);
const updateRepacks = useCallback(async () => {
const repacks = await window.electron.getAllRepacks();
dispatch(
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
);
}, [dispatch]);
return { getRepacksForObjectId, updateRepacks };
}

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,4 +1,8 @@
import type { CatalogueSearchResult, DownloadSource } from "@types";
import type {
CatalogueSearchResult,
CatalogueSearchPayload,
DownloadSource,
} from "@types";
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -29,13 +33,12 @@ export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null);
const cataloguePageRef = useRef<HTMLDivElement>(null);
const { steamDevelopers, steamPublishers } = useCatalogue();
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
const { steamGenres, steamUserTags, filters, page } = useAppSelector(
(state) => state.catalogueSearch
);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
@@ -44,31 +47,46 @@ export default function Catalogue() {
const { formatNumber } = useFormat();
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
const dispatch = useAppDispatch();
const { t, i18n } = useTranslation("catalogue");
const debouncedSearch = useRef(
debounce(async (filters, pageSize, offset) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
debounce(
async (
filters: CatalogueSearchPayload,
downloadSources: DownloadSource[],
pageSize: number,
offset: number
) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: { ...filters, take: pageSize, skip: offset },
needsAuth: false,
});
const requestData = {
...filters,
take: pageSize,
skip: offset,
downloadSourceIds: downloadSources.map(
(downloadSource) => downloadSource.id
),
};
if (abortController.signal.aborted) return;
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: requestData,
needsAuth: false,
});
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
}, 500)
if (abortController.signal.aborted) return;
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
},
500
)
).current;
const decodeHTML = (s: string) =>
@@ -79,18 +97,17 @@ export default function Catalogue() {
setIsLoading(true);
abortControllerRef.current?.abort();
debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE);
debouncedSearch(
filters,
downloadSources,
PAGE_SIZE,
(page - 1) * PAGE_SIZE
);
return () => {
debouncedSearch.cancel();
};
}, [filters, page, debouncedSearch]);
useEffect(() => {
window.electron.getDownloadSourcesList().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint));
});
}, []);
}, [filters, downloadSources, page, debouncedSearch]);
const language = i18n.language.split("-")[0];
@@ -168,7 +185,7 @@ export default function Catalogue() {
value: publisher,
})),
];
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
}, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
const filterSections = useMemo(() => {
return [

View File

@@ -1,6 +1,6 @@
import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks";
import { useAppSelector, useLibrary } from "@renderer/hooks";
import { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
@@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) {
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
const [added, setAdded] = useState(false);
@@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) {
}
};
const uniqueRepackers = useMemo(() => {
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
}, [repacks]);
const genres = useMemo(() => {
return game.genres?.map((genre) => {
const index = steamGenres["en"]?.findIndex(
@@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) {
<span className="game-item__genres">{genres.join(", ")}</span>
<div className="game-item__repackers">
{uniqueRepackers.map((repacker) => (
<Badge key={repacker}>{repacker}</Badge>
{game.downloadSources.map((sourceName) => (
<Badge key={sourceName}>{sourceName}</Badge>
))}
</div>
</div>

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

@@ -228,7 +228,7 @@ export function GameDetailsContent() {
</button>
)}
{game?.shop !== "custom" && shop && objectId && (
{shop !== "custom" && shop && objectId && (
<GameReviews
shop={shop}
objectId={objectId}
@@ -241,7 +241,7 @@ export function GameDetailsContent() {
)}
</div>
{game?.shop !== "custom" && <Sidebar />}
{shop !== "custom" && <Sidebar />}
</div>
</section>

View File

@@ -103,7 +103,6 @@ export default function GameDetails() {
automaticallyExtract: boolean
) => {
const response = await startDownload({
repackId: repack.id,
objectId: objectId!,
title: gameTitle,
downloader,

View File

@@ -117,7 +117,7 @@ export function GameReviews({
});
const checkUserReview = useCallback(async () => {
if (!objectId || !userDetailsId) return;
if (!objectId || !userDetailsId || shop === "custom") return;
try {
const response = await window.electron.hydraApi.get<{
@@ -147,7 +147,7 @@ export function GameReviews({
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
if (!objectId || shop === "custom") return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -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

@@ -146,6 +146,8 @@ $hero-height: 350px;
&__game-logo {
width: 200px;
align-self: flex-end;
object-fit: contain;
object-position: left bottom;
@media (min-width: 768px) {
width: 250px;
@@ -153,6 +155,7 @@ $hero-height: 350px;
@media (min-width: 1024px) {
width: 300px;
max-height: 150px;
}
}
@@ -228,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

@@ -54,7 +54,7 @@ export function RepacksModal({
{}
);
const { repacks, game } = useContext(gameDetailsContext);
const { game, repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -88,6 +88,15 @@ export function RepacksModal({
});
}, [repacks, isFeatureEnabled, Feature]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
};
fetchDownloadSources();
}, []);
const sortedRepacks = useMemo(() => {
return orderBy(
repacks,
@@ -103,23 +112,13 @@ export function RepacksModal({
);
}, [repacks, hashesInDebrid]);
useEffect(() => {
window.electron.getDownloadSourcesList().then((sources) => {
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
const filteredSources = sources.filter(
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
);
setDownloadSources(filteredSources);
});
}, [sortedRepacks]);
useEffect(() => {
const term = filterTerm.trim().toLowerCase();
const byTerm = sortedRepacks.filter((repack) => {
if (!term) return true;
const lowerTitle = repack.title.toLowerCase();
const lowerRepacker = repack.repacker.toLowerCase();
const lowerRepacker = repack.downloadSourceName.toLowerCase();
return lowerTitle.includes(term) || lowerRepacker.includes(term);
});
@@ -130,7 +129,7 @@ export function RepacksModal({
(src) =>
src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) &&
src.name === repack.repacker
src.name === repack.downloadSourceName
);
});
@@ -281,7 +280,7 @@ export function RepacksModal({
)}
<p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "}
{repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p>

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

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

View File

@@ -0,0 +1,63 @@
@use "../../scss/globals.scss";
.library-filter-options {
&__container {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex-wrap: wrap;
}
&__option {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
padding: 8px 16px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all ease 0.2s;
white-space: nowrap; /* prevent label and count from wrapping */
border: 1px solid rgba(0, 0, 0, 0.06);
&:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.08);
}
&.active {
color: #000;
background: #fff;
svg,
svg * {
fill: currentColor;
color: currentColor;
}
.library-filter-options__count {
background: #ebebeb;
color: rgba(0, 0, 0, 0.9);
}
}
}
&__label {
font-weight: 500;
white-space: nowrap;
}
&__count {
background: rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.95);
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
transition: all ease 0.2s;
}
}

View File

@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import "./filter-options.scss";
export type FilterOption = "all" | "favourited" | "new" | "top10";
interface FilterOptionsProps {
filterBy: FilterOption;
onFilterChange: (filterBy: FilterOption) => void;
allGamesCount: number;
favouritedCount: number;
newGamesCount: number;
top10Count: number;
}
export function FilterOptions({
filterBy,
onFilterChange,
allGamesCount,
favouritedCount,
newGamesCount,
top10Count,
}: Readonly<FilterOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-filter-options__container">
<button
className={`library-filter-options__option ${filterBy === "all" ? "active" : ""}`}
onClick={() => onFilterChange("all")}
>
<span className="library-filter-options__label">{t("all_games")}</span>
<span className="library-filter-options__count">{allGamesCount}</span>
</button>
<button
className={`library-filter-options__option ${filterBy === "favourited" ? "active" : ""}`}
onClick={() => onFilterChange("favourited")}
>
<span className="library-filter-options__label">
{t("Favourite Games")}
</span>
<span className="library-filter-options__count">{favouritedCount}</span>
</button>
<button
className={`library-filter-options__option ${filterBy === "new" ? "active" : ""}`}
onClick={() => onFilterChange("new")}
>
<span className="library-filter-options__label">{t("new_games")}</span>
<span className="library-filter-options__count">{newGamesCount}</span>
</button>
<button
className={`library-filter-options__option ${filterBy === "top10" ? "active" : ""}`}
onClick={() => onFilterChange("top10")}
>
<span className="library-filter-options__label">
{t("Most Played")}
</span>
<span className="library-filter-options__count">{top10Count}</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,295 @@
@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;
padding: 0;
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) 74%,
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.1) 0%,
rgba(0, 0, 0, 0.2) 50%,
rgba(0, 0, 0, 0.3) 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 * 2);
}
&__top-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: calc(globals.$spacing-unit);
}
&__menu-button {
align-self: flex-start;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 4px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all ease 0.2s;
color: rgba(255, 255, 255, 0.95);
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
opacity: 0;
transform: scale(0.9);
&:hover {
background: rgba(0, 0, 0, 0.6);
border-color: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
}
&__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: 8px 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
font-size: 14px;
}
&__playtime-text {
font-weight: 500;
}
&__manual-playtime {
color: globals.$warning-color;
}
&__achievements {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 12px;
flex: 1 1 auto;
min-width: 0;
}
&__achievement-header {
display: flex;
align-items: center;
gap: 8px;
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: 12px;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
}
&__action-button {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all ease 0.2s;
flex: 0 0 auto;
&:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
&:active {
transform: scale(0.98);
}
}
&:hover &__menu-button {
opacity: 1;
transform: scale(1);
}
&__action-icon--downloading {
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,279 @@
import { LibraryGame } from "@types";
import { useDownload, useFormat } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath } from "@renderer/helpers";
import {
PlayIcon,
DownloadIcon,
ClockIcon,
AlertFillIcon,
ThreeBarsIcon,
TrophyIcon,
XIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useCallback, useState } from "react";
import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { GameContextMenu } from "@renderer/components";
import "./library-game-card-large.scss";
interface LibraryGameCardLargeProps {
game: LibraryGame;
}
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
originalUrl: string | null | undefined,
fallbackUrl?: string | null | undefined
) => {
return customUrl || originalUrl || fallbackUrl || "";
};
export function LibraryGameCardLarge({
game,
}: Readonly<LibraryGameCardLargeProps>) {
const { t } = useTranslation("library");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const { lastPacket } = useDownload();
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
}>({ visible: false, position: { x: 0, y: 0 } });
const isGameDownloading =
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
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 = () => {
navigate(buildGameDetailsPath(game));
};
const {
handlePlayGame,
handleOpenDownloadOptions,
handleCloseGame,
isGameRunning,
} = useGameActions(game);
const handleActionClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isGameRunning) {
try {
await handleCloseGame();
} catch (e) {
console.error(e);
}
return;
}
try {
await handlePlayGame();
} catch (err) {
console.error(err);
try {
handleOpenDownloadOptions();
} catch (e) {
console.error(e);
}
}
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY },
});
};
const handleMenuButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
setContextMenu({
visible: true,
position: {
x: e.currentTarget.getBoundingClientRect().right,
y: e.currentTarget.getBoundingClientRect().bottom,
},
});
};
const handleCloseContextMenu = () => {
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
};
// Use libraryHeroImageUrl as background, fallback to libraryImageUrl
const backgroundImage = getImageWithCustomPriority(
game.libraryHeroImageUrl,
game.libraryImageUrl,
game.iconUrl
);
// For logo, check if logoImageUrl exists (similar to game details page)
const logoImage = game.logoImageUrl;
return (
<>
<button
type="button"
className="library-game-card-large"
onClick={handleCardClick}
onContextMenu={handleContextMenu}
>
<div
className="library-game-card-large__background"
style={{ backgroundImage: `url(${backgroundImage})` }}
/>
<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>
<button
type="button"
className="library-game-card-large__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
</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={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
<button
type="button"
className="library-game-card-large__action-button"
onClick={handleActionClick}
>
{(() => {
if (isGameDownloading) {
return (
<>
<DownloadIcon
size={16}
className="library-game-card-large__action-icon--downloading"
/>
{t("downloading")}
</>
);
}
if (isGameRunning) {
return (
<>
<XIcon size={16} />
{t("close")}
</>
);
}
if (game.executablePath) {
return (
<>
<PlayIcon size={16} />
{t("play")}
</>
);
}
return (
<>
<DownloadIcon size={16} />
{t("download")}
</>
);
})()}
</button>
</div>
</div>
</button>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
);
}

View File

@@ -0,0 +1,289 @@
@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.5) 5%, 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;
gap: 4px;
padding: 6px 8px;
opacity: 0;
transform: translateY(8px);
transition: all ease 0.2s;
pointer-events: none;
width: 100%;
}
&__achievements-gap {
display: flex;
align-items: center;
gap: 6px;
}
&__achievement-header {
display: flex;
align-items: center;
gap: 6px;
justify-content: space-between;
}
&__achievement-trophy {
color: #fff;
flex-shrink: 0;
}
&__achievement-progress {
margin-top: 8px;
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;
position: relative;
}
&__achievement-count {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
}
&__achievement-percentage {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
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);
}
}
&__menu-button {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all ease 0.2s;
color: rgba(255, 255, 255, 0.8);
padding: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
opacity: 0;
transform: scale(0.9);
&:hover {
background: rgba(0, 0, 0, 0.6);
border-color: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
}
&__wrapper:hover &__action-button,
&__wrapper:hover &__menu-button {
opacity: 1;
transform: scale(1);
}
&__wrapper:hover &__achievements {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
&__action-icon {
&--downloading {
animation: pulse 1.5s ease-in-out infinite;
}
}
&__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;
}
}
/* Force fixed size for compact grid cells so cards render at 220x320 */
.library__games-grid--compact .library-game-card__wrapper {
width: 215px;
height: 320px;
aspect-ratio: unset;
}

View File

@@ -0,0 +1,202 @@
import { LibraryGame } from "@types";
import { useFormat } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { useCallback, useState } from "react";
import { buildGameDetailsPath } from "@renderer/helpers";
import {
ClockIcon,
AlertFillIcon,
ThreeBarsIcon,
TrophyIcon,
} from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
import { useTranslation } from "react-i18next";
import { GameContextMenu } from "@renderer/components";
import "./library-game-card.scss";
interface LibraryGameCardProps {
game: LibraryGame;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export function LibraryGameCard({
game,
onMouseEnter,
onMouseLeave,
}: Readonly<LibraryGameCardProps>) {
const { t } = useTranslation("library");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
}>({ visible: false, position: { x: 0, y: 0 } });
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 = () => {
navigate(buildGameDetailsPath(game));
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY },
});
};
const handleMenuButtonClick = (e: React.MouseEvent) => {
e.stopPropagation();
setContextMenu({
visible: true,
position: {
x: e.currentTarget.getBoundingClientRect().right,
y: e.currentTarget.getBoundingClientRect().bottom,
},
});
};
const handleCloseContextMenu = () => {
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
};
const coverImage =
game.coverImageUrl ??
game.libraryImageUrl ??
game.libraryHeroImageUrl ??
game.iconUrl ??
undefined;
return (
<>
<button
type="button"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="library-game-card__wrapper"
title={isTooltipHovered ? undefined : game.title}
onClick={handleCardClick}
onContextMenu={handleContextMenu}
>
<div className="library-game-card__overlay">
<div className="library-game-card__top-section">
<div
className="library-game-card__playtime"
data-tooltip-place="top"
data-tooltip-content={
game.hasManuallyUpdatedPlaytime
? t("manual_playtime_tooltip")
: undefined
}
data-tooltip-id={game.objectId}
>
{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>
<button
type="button"
className="library-game-card__menu-button"
onClick={handleMenuButtonClick}
title="More options"
>
<ThreeBarsIcon size={16} />
</button>
</div>
{/* Achievements section - shown on hover */}
{(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={14}
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"
/>
</button>
<Tooltip
id={game.objectId}
style={{
zIndex: 9999,
}}
openOnClick={false}
afterShow={() => setIsTooltipHovered(true)}
afterHide={() => setIsTooltipHovered(false)}
/>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
);
}

View File

@@ -0,0 +1,207 @@
@use "../../scss/globals.scss";
.library {
&__content {
padding: calc(globals.$spacing-unit * 3);
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%;
gap: calc(globals.$spacing-unit * 2);
}
&__controls-left {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
}
&__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
&--compact {
grid-template-columns: repeat(auto-fill, 215px);
grid-auto-rows: 320px;
justify-content: start;
@container #{globals.$app-container} (min-width: 900px) {
grid-template-columns: repeat(auto-fill, 215px);
}
@container #{globals.$app-container} (min-width: 1300px) {
grid-template-columns: repeat(auto-fill, 215px);
}
/* keep same pattern for very large screens */
@container #{globals.$app-container} (min-width: 2000px) {
grid-template-columns: repeat(auto-fill, 215px);
}
@container #{globals.$app-container} (min-width: 2600px) {
grid-template-columns: repeat(auto-fill, 215px);
}
@container #{globals.$app-container} (min-width: 3000px) {
grid-template-columns: repeat(auto-fill, 210px);
}
}
}
&__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,182 @@
import { useEffect, useMemo, useState } from "react";
import { useLibrary, useAppDispatch } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { LibraryGameCard } from "./library-game-card";
// detailed view removed — keep file if needed later
import { LibraryGameCardLarge } from "./library-game-card-large";
import { ViewOptions, ViewMode } from "./view-options";
import { FilterOptions, FilterOption } from "./filter-options";
import { SearchBar } from "./search-bar";
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>("compact");
const [filterBy, setFilterBy] = useState<FilterOption>("all");
const [searchQuery, setSearchQuery] = useState<string>("");
const dispatch = useAppDispatch();
const { t } = useTranslation("library");
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 = () => {
// Optional: pause animations if needed
};
const handleOnMouseLeaveGameCard = () => {
// Optional: resume animations if needed
};
const filteredLibrary = useMemo(() => {
let filtered;
switch (filterBy) {
case "favourited":
filtered = library.filter((game) => game.favorite);
break;
case "new":
filtered = library.filter(
(game) => (game.playTimeInMilliseconds || 0) === 0
);
break;
case "top10":
filtered = library
.slice()
.sort(
(a, b) =>
(b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0)
)
.slice(0, 10);
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]);
// No sorting for now — rely on filteredLibrary
const sortedLibrary = filteredLibrary;
// Calculate counts for filters
const allGamesCount = library.length;
const favouritedCount = library.filter((game) => game.favorite).length;
const newGamesCount = library.filter(
(game) => (game.playTimeInMilliseconds || 0) === 0
).length;
const top10Count = Math.min(10, library.length);
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={allGamesCount}
favouritedCount={favouritedCount}
newGamesCount={newGamesCount}
top10Count={top10Count}
/>
</div>
<div className="library__controls-right">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<ViewOptions viewMode={viewMode} onViewModeChange={setViewMode} />
</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 && viewMode === "large" && (
<div className="library__games-list library__games-list--large">
{sortedLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
/>
))}
</div>
)}
{hasGames && viewMode !== "large" && (
<ul className={`library__games-grid library__games-grid--${viewMode}`}>
{sortedLibrary.map((game) => (
<li
key={`${game.shop}-${game.objectId}`}
style={{ listStyle: "none" }}
>
<LibraryGameCard
game={game}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,75 @@
.search-bar {
display: flex;
align-items: center;
&__container {
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
background-color: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
transition: all 0.2s ease;
width: 250px;
&:focus-within {
width: 300px;
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.2);
}
}
&__icon {
color: rgba(255, 255, 255, 0.75);
flex-shrink: 0;
transition: color 0.2s ease;
}
&__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
font-family: inherit;
min-width: 0;
&::placeholder {
color: rgba(255, 255, 255, 0.6);
}
&:focus ~ .search-bar__icon {
color: rgba(255, 255, 255, 0.7);
}
}
&__clear {
flex-shrink: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.65);
font-size: 18px;
font-weight: bold;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
&:active {
background: rgba(255, 255, 255, 0.15);
}
}
}

View File

@@ -0,0 +1,44 @@
import { SearchIcon } from "@primer/octicons-react";
import { FC, useRef } from "react";
import { useTranslation } from "react-i18next";
import "./search-bar.scss";
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
}
export const SearchBar: FC<SearchBarProps> = ({ value, onChange }) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const handleClear = () => {
onChange("");
inputRef.current?.focus();
};
return (
<div className="search-bar">
<div className="search-bar__container">
<SearchIcon size={16} className="search-bar__icon" />
<input
ref={inputRef}
type="text"
className="search-bar__input"
placeholder={t("Search library", { defaultValue: "Search library" })}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{value && (
<button
className="search-bar__clear"
onClick={handleClear}
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
);
};

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: 8px 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.95);
background: rgba(255, 255, 255, 0.06);
}
&.active {
color: rgba(0, 0, 0, 0.9);
background: #fff;
svg,
svg * {
fill: currentColor;
color: currentColor;
}
}
}
}

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