Compare commits

...

142 Commits

Author SHA1 Message Date
Chubby Granny Chaser
d6b38771a8 Merge branch 'main' into fix/use-local-achievement-cache 2025-11-13 09:54:30 +00:00
Chubby Granny Chaser
8400edd000 Merge pull request #1858 from quirxsama/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
[translation] Added Turkish translations for new keys
2025-11-13 09:54:08 +00:00
Chubby Granny Chaser
6e3c5cac7e Merge branch 'main' into fix/use-local-achievement-cache 2025-11-13 09:53:28 +00:00
Chubby Granny Chaser
4f2d6f3302 Merge branch 'main' into main 2025-11-13 09:53:23 +00:00
Chubby Granny Chaser
72c3219fc0 Merge pull request #1859 from Stormm232/main
3.7.4 - Hungarian  Translation
2025-11-13 09:53:09 +00:00
Kiwo.2
048c56d670 Prettified it 2025-11-12 19:35:57 +01:00
Kiwo.2
43505a281f Merge branch 'main' of https://github.com/Stormm232/hydra 2025-11-12 19:11:50 +01:00
Kiwo.2
c380e5f5a0 Fixes & 3.7.4's new lines 2025-11-12 19:11:13 +01:00
Zamitto
5c32e61569 Merge branch 'test' into fix/use-local-achievement-cache 2025-11-12 14:53:24 -03:00
Zamitto
f594cd298a fix: performance 2025-11-12 14:52:38 -03:00
Zamitto
20c0d3174b test 2025-11-12 14:37:44 -03:00
Kaan
bcd6db24c9 Add turkish translates for new keys
added keys:
 - sidebar.library
  - sidebar.playable_button_title
  - sidebar.add_custom_game_tooltip
  - sidebar.show_playable_only_tooltip
  - sidebar.custom_game_modal
  - sidebar.custom_game_modal_description
  - sidebar.custom_game_modal_executable_path
  - sidebar.custom_game_modal_select_executable
  - sidebar.custom_game_modal_title
  - sidebar.custom_game_modal_enter_title
  - sidebar.custom_game_modal_browse
  - sidebar.custom_game_modal_cancel
  - sidebar.custom_game_modal_add
  - sidebar.custom_game_modal_adding
  - sidebar.custom_game_modal_success
  - sidebar.custom_game_modal_failed
  - sidebar.custom_game_modal_executable
  - sidebar.edit_game_modal
  - sidebar.edit_game_modal_description
  - sidebar.edit_game_modal_title
  - sidebar.edit_game_modal_enter_title
  - sidebar.edit_game_modal_image
  - sidebar.edit_game_modal_select_image
  - sidebar.edit_game_modal_browse
  - sidebar.edit_game_modal_image_preview
  - sidebar.edit_game_modal_icon
  - sidebar.edit_game_modal_select_icon
  - sidebar.edit_game_modal_icon_preview
  - sidebar.edit_game_modal_logo
  - sidebar.edit_game_modal_select_logo
  - sidebar.edit_game_modal_logo_preview
  - sidebar.edit_game_modal_hero
  - sidebar.edit_game_modal_select_hero
  - sidebar.edit_game_modal_hero_preview
  - sidebar.edit_game_modal_cancel
  - sidebar.edit_game_modal_update
  - sidebar.edit_game_modal_updating
  - sidebar.edit_game_modal_fill_required
  - sidebar.edit_game_modal_success
  - sidebar.edit_game_modal_failed
  - sidebar.edit_game_modal_image_filter
  - sidebar.edit_game_modal_icon_resolution
  - sidebar.edit_game_modal_logo_resolution
  - sidebar.edit_game_modal_hero_resolution
  - sidebar.edit_game_modal_assets
  - sidebar.edit_game_modal_drop_icon_image_here
  - sidebar.edit_game_modal_drop_logo_image_here
  - sidebar.edit_game_modal_drop_hero_image_here
  - sidebar.edit_game_modal_drop_to_replace_icon
  - sidebar.edit_game_modal_drop_to_replace_logo
  - sidebar.edit_game_modal_drop_to_replace_hero
  - sidebar.install_decky_plugin
  - sidebar.update_decky_plugin
  - sidebar.decky_plugin_installed_version
  - sidebar.install_decky_plugin_title
  - sidebar.install_decky_plugin_message
  - sidebar.update_decky_plugin_title
  - sidebar.update_decky_plugin_message
  - sidebar.decky_plugin_installed
  - sidebar.decky_plugin_installation_failed
  - sidebar.decky_plugin_installation_error
  - sidebar.confirm
  - sidebar.cancel
  - header.search_library
  - header.library
  - game_details.already_in_library
  - game_details.create_shortcut_simple
  - game_details.properties
  - game_details.new_download_option
  - game_details.add_to_favorites
  - game_details.remove_from_favorites
  - game_details.failed_update_favorites
  - game_details.game_removed_from_library
  - game_details.failed_remove_from_library
  - game_details.files_removed_success
  - game_details.failed_remove_files
  - game_details.rating_count
  - game_details.show_more
  - game_details.show_less
  - game_details.reviews
  - game_details.review_played_for
  - game_details.leave_a_review
  - game_details.write_review_placeholder
  - game_details.sort_newest
  - game_details.no_reviews_yet
  - game_details.be_first_to_review
  - game_details.sort_oldest
  - game_details.sort_highest_score
  - game_details.sort_lowest_score
  - game_details.sort_most_voted
  - game_details.rating
  - game_details.rating_stats
  - game_details.rating_very_negative
  - game_details.rating_negative
  - game_details.rating_neutral
  - game_details.rating_positive
  - game_details.rating_very_positive
  - game_details.submit_review
  - game_details.submitting
  - game_details.review_submitted_successfully
  - game_details.review_submission_failed
  - game_details.review_cannot_be_empty
  - game_details.review_deleted_successfully
  - game_details.review_deletion_failed
  - game_details.loading_reviews
  - game_details.loading_more_reviews
  - game_details.load_more_reviews
  - game_details.you_seemed_to_enjoy_this_game
  - game_details.would_you_recommend_this_game
  - game_details.yes
  - game_details.maybe_later
  - game_details.backup_failed
  - game_details.update_playtime_title
  - game_details.update_playtime_description
  - game_details.update_playtime
  - game_details.update_playtime_success
  - game_details.update_playtime_error
  - game_details.update_game_playtime
  - game_details.manual_playtime_warning
  - game_details.manual_playtime_tooltip
  - game_details.game_removed_from_pinned
  - game_details.game_added_to_pinned
  - game_details.artifact_renamed
  - game_details.rename_artifact
  - game_details.rename_artifact_description
  - game_details.artifact_name_label
  - game_details.artifact_name_placeholder
  - game_details.save_changes
  - game_details.required_field
  - game_details.max_length_field
  - game_details.freeze_backup
  - game_details.unfreeze_backup
  - game_details.backup_frozen
  - game_details.backup_unfrozen
  - game_details.backup_freeze_failed
  - game_details.backup_freeze_failed_description
  - game_details.edit_game_modal_button
  - game_details.game_details
  - game_details.currency_symbol
  - game_details.currency_country
  - game_details.prices
  - game_details.no_prices_found
  - game_details.view_all_prices
  - game_details.retail_price
  - game_details.keyshop_price
  - game_details.historical_retail
  - game_details.historical_keyshop
  - game_details.language
  - game_details.caption
  - game_details.audio
  - game_details.filter_by_source
  - game_details.no_repacks_found
  - game_details.delete_review
  - game_details.remove_review
  - game_details.delete_review_modal_title
  - game_details.delete_review_modal_description
  - game_details.delete_review_modal_delete_button
  - game_details.delete_review_modal_cancel_button
  - game_details.vote_failed
  - game_details.show_original
  - game_details.show_translation
  - game_details.show_original_translated_from
  - game_details.hide_original
  - game_details.review_from_blocked_user
  - game_details.show
  - game_details.hide
  - settings.adding
  - settings.failed_add_download_source
  - settings.download_source_already_exists
  - settings.download_source_pending_matching
  - settings.download_source_matched
  - settings.download_source_matching
  - settings.download_source_failed
  - settings.download_source_no_information
  - settings.removed_all_download_sources
  - settings.download_sources_synced_successfully
  - settings.importing
  - settings.hydra_cloud
  - settings.debrid
  - settings.debrid_description
  - settings.enable_steam_achievements
  - settings.achievement_sound_volume
  - settings.select_achievement_sound
  - settings.change_achievement_sound
  - settings.remove_achievement_sound
  - settings.preview_sound
  - settings.select
  - settings.preview
  - settings.remove
  - settings.no_sound_file_selected
  - settings.autoplay_trailers_on_game_page
  - settings.hide_to_tray_on_game_start
  - game_card.calculating
  - user_profile.amount_hours_short
  - user_profile.amount_minutes_short
  - user_profile.pinned
  - user_profile.sort_by
  - user_profile.achievements_earned
  - user_profile.played_recently
  - user_profile.playtime
  - user_profile.manual_playtime_tooltip
  - user_profile.error_adding_friend
  - user_profile.friend_code_length_error
  - user_profile.game_removed_from_pinned
  - user_profile.game_added_to_pinned
  - user_profile.karma
  - user_profile.karma_count
  - user_profile.karma_description
  - user_profile.user_reviews
  - user_profile.delete_review
  - user_profile.loading_reviews
  - library.library
  - library.play
  - library.download
  - library.downloading
  - library.game
  - library.games
  - library.grid_view
  - library.compact_view
  - library.large_view
  - library.no_games_title
  - library.no_games_description
  - library.amount_hours
  - library.amount_minutes
  - library.amount_hours_short
  - library.amount_minutes_short
  - library.manual_playtime_tooltip
  - library.all_games
  - library.recently_played
  - library.favorites
2025-11-12 18:46:18 +03:00
Zamitto
c2216bbf95 feat: use jpg for system notifications 2025-11-12 08:17:53 -03:00
Zamitto
f84917a00b feat: get user static image on notifications 2025-11-12 08:07:51 -03:00
Zamitto
94ebf94abc fix: use local achievement cache for unlocked achievement count 2025-11-12 07:21:28 -03:00
Zamitto
cd3fa10bf7 chore: fix version code
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-11 18:27:53 -03:00
Zamitto
a57cc83076 Merge branch 'release/v3.7.5' 2025-11-11 18:17:53 -03:00
Zamitto
c75a6ad439 fix: using achievement count data from api 2025-11-11 18:15:26 -03:00
Moyase
05d68fa23b Merge pull request #1856 from hydralauncher/fix/library-game-card
fix: custom assets not being showed in library page
2025-11-11 22:10:24 +02:00
Moyasee
527a65e9bc feat: remembering the view user left the library and restoring it on opening library again 2025-11-11 22:07:42 +02:00
Moyasee
fe6bb5763d fix: deleting game from context menu doesnt work in library 2025-11-11 22:03:33 +02:00
Moyasee
002dff098c fix: custom assets not being showed in library page 2025-11-11 21:50:48 +02:00
Chubby Granny Chaser
436d1b74be ci: fixing format
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-11 18:35:18 +00:00
Chubby Granny Chaser
b89de065fe ci: fixing format 2025-11-11 18:33:28 +00:00
Chubby Granny Chaser
7fcdab07cb Merge pull request #1842 from hydralauncher/feat/displaying-new-game-update
Feat: displaying new game update
2025-11-11 18:23:42 +00:00
Chubby Granny Chaser
aebf6d1cae feat: adding translations for new label 2025-11-11 18:22:39 +00:00
Moyase
a2148dd1ef Merge branch 'main' into feat/displaying-new-game-update 2025-11-11 20:02:32 +02:00
Moyasee
8dc5be1bdf reverting changes 2025-11-11 20:01:28 +02:00
Moyasee
133168c6c7 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-11 16:32:37 +02:00
Moyasee
d59b96f446 fix: typescript error 2025-11-11 16:31:53 +02:00
Moyasee
a1eef4eab6 feat: check updates for installed games 2025-11-11 16:30:23 +02:00
Chubby Granny Chaser
25103e5eb7 ci: updating ci
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-11 09:09:03 +00:00
Moyase
9cf0ef4b62 Merge branch 'main' into feat/displaying-new-game-update 2025-11-11 01:32:50 +02:00
Moyasee
1521d7c058 reverting changes 2025-11-11 01:29:04 +02:00
Moyasee
14eb0f8172 reverting changes 2025-11-11 01:27:24 +02:00
Moyasee
860030a510 fix: merging conflict 2025-11-11 01:23:37 +02:00
Moyasee
f0e4d241f9 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-11 01:15:13 +02:00
Moyasee
44b24ab63d feat: checking updates only for games with executables 2025-11-11 01:14:27 +02:00
Chubby Granny Chaser
7c1adb70ea fix: fixing lint 2025-11-10 23:07:52 +00:00
Chubby Granny Chaser
9854ed2f53 Merge pull request #1852 from hydralauncher/feat/custom-achievement-sound
Feat: custom achievement sound and volume changing
2025-11-10 22:59:34 +00:00
Chubby Granny Chaser
b8647a3300 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/custom-achievement-sound 2025-11-10 22:57:59 +00:00
Chubby Granny Chaser
95894484f1 feat: adding slider to achievement sound 2025-11-10 22:57:35 +00:00
Chubby Granny Chaser
6fc5a70722 feat: adding slider to achievement sound 2025-11-10 22:55:49 +00:00
Chubby Granny Chaser
399669a94c feat: adding slider to achievement sound 2025-11-10 22:55:17 +00:00
Chubby Granny Chaser
77b2fc3946 Merge pull request #1848 from hydralauncher/fix/library-ui
feat: library ui changes and searchbar removal
2025-11-10 22:55:02 +00:00
Chubby Granny Chaser
3472e90858 Merge branch 'main' into fix/library-ui 2025-11-10 22:53:52 +00:00
Chubby Granny Chaser
d80daa59d0 Merge branch 'main' into feat/custom-achievement-sound 2025-11-10 22:21:51 +00:00
Chubby Granny Chaser
46df34e8a5 feat: improving library 2025-11-10 22:20:44 +00:00
Chubby Granny Chaser
272b047ded Merge pull request #1844 from expload233/main
Add new translation for Chinese
2025-11-10 21:51:44 +00:00
Chubby Granny Chaser
dfba38aeed Merge branch 'main' into main 2025-11-10 21:51:20 +00:00
Moyasee
d54ff9a949 fix: eslint issues 2025-11-09 15:34:24 +02:00
Moyasee
e272470a7b feat: using theme name for folder instead themeid 2025-11-09 15:28:52 +02:00
Moyasee
53bc3551e1 Merge branch 'feat/custom-achievement-sound' of https://github.com/hydralauncher/hydra into feat/custom-achievement-sound 2025-11-09 04:20:48 +02:00
Moyasee
3daf28c882 fix: handling exception and ESLint issues 2025-11-09 04:19:52 +02:00
Moyase
e128dad4dd Merge branch 'main' into feat/custom-achievement-sound 2025-11-09 04:14:31 +02:00
Moyasee
65e2bb38a0 ci: formatting 2025-11-08 19:26:13 +02:00
Moyasee
5f09321728 Merge branch 'fix/library-ui' of https://github.com/hydralauncher/hydra into fix/library-ui 2025-11-08 19:25:29 +02:00
Moyasee
011559b499 fix: removed VirtualList component from large view 2025-11-08 19:24:43 +02:00
Moyasee
482d9b2f96 fix: ensure consistent custom sound detection across main and renderer processes 2025-11-08 15:14:12 +02:00
Moyase
c890b0fd56 Merge branch 'main' into fix/library-ui 2025-11-08 14:52:35 +02:00
Moyasee
cf48627a8d fix: extracter ternary operation 2025-11-08 14:51:10 +02:00
Moyasee
196413ee28 fix: duplicated lines 2025-11-08 14:48:42 +02:00
Moyasee
c3a4990a50 ci: performance optimizing in library 2025-11-08 14:28:54 +02:00
Chubby Granny Chaser
fa4c11e458 Merge pull request #1849 from hydralauncher/fix/surprise-me-button
Fix: surprise me button functionality
2025-11-08 08:18:13 +00:00
Chubby Granny Chaser
50b0a82204 feat: improving styles on randomizer button 2025-11-08 08:17:19 +00:00
Moyasee
b6bbf05da6 fix: theme editor layout positioning 2025-11-07 20:12:50 +02:00
Moyasee
154b6271a1 fix: removed unused function 2025-11-07 17:50:47 +02:00
Moyasee
a6cbaf6dc1 feat: custom achievement sound and volume changing) 2025-11-07 17:48:56 +02:00
Moyasee
6e6e0f7bb7 fix: duplicate next suggestion styling removal 2025-11-07 13:35:50 +02:00
Moyasee
893802be55 fix: next suggestion and title not being showed 2025-11-07 13:27:24 +02:00
Moyasee
3bef0c9269 feat: library ui changes and searchbar removal 2025-11-06 18:26:56 +02:00
Moyase
754e9c14b8 Merge pull request #1821 from iam-sahil/feat/library
Feat/library
2025-11-06 17:22:58 +02:00
ctrlcat0x
5e653be4c3 fix: add error logging in handleActionClick for better debugging 2025-11-06 19:11:20 +05:30
ctrlcat0x
cedf7e6e37 style: improve color contrast in various components and update prop types to readonly 2025-11-06 19:03:23 +05:30
Moyase
518a0e1cf4 Merge branch 'main' into feat/library 2025-11-06 14:57:22 +02:00
expload233
5f56a3d517 add translation 2025-11-04 15:47:44 +08:00
expload
2359c4cc5e Merge branch 'hydralauncher:main' into main 2025-11-04 15:31:03 +08:00
expload233
66bb5221c1 fix lint 2025-11-04 15:24:42 +08:00
Chubby Granny Chaser
7fa50dc5a7 feat: adding ws client back 2025-11-03 12:02:52 +00:00
expload233
088feaffc2 add missing translation for zh-CN 2025-11-03 19:02:11 +08:00
expload233
aa6b595b18 Fill in the missing entries 2025-11-03 18:53:02 +08:00
Chubby Granny Chaser
f49fea3032 ci: bump version
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-02 21:17:34 +00:00
Chubby Granny Chaser
595d39986d Merge pull request #1843 from hydralauncher/fix/fixing-datanodes
fix: fixing datanodes
2025-11-02 21:15:53 +00:00
Chubby Granny Chaser
ac01930d68 Merge branch 'main' into fix/fixing-datanodes 2025-11-02 21:14:49 +00:00
Chubby Granny Chaser
37caeb8047 fix: fixing datanodes 2025-11-02 21:13:44 +00:00
Chubby Granny Chaser
7d6eddb17e Merge pull request #1826 from hydralauncher/feat/reviews-in-profile
Feat: Showing User Reviews in profile
2025-11-02 20:46:47 +00:00
Chubby Granny Chaser
20338fa20b Merge branch 'main' into feat/displaying-new-game-update 2025-11-02 20:45:56 +00:00
Chubby Granny Chaser
48775e57fc feat: adding reviews to profile 2025-11-02 20:43:59 +00:00
Chubby Granny Chaser
fdc3fecd6f feat: adding reviews to profile 2025-11-02 20:42:42 +00:00
Chubby Granny Chaser
f0dc7478cf feat: adding reviews to profile 2025-11-02 20:29:16 +00:00
Chubby Granny Chaser
e7a437e839 Merge branch 'main' into feat/library 2025-11-02 20:23:44 +00:00
Chubby Granny Chaser
2e8da53d1a feat: adding infinite scroll 2025-11-02 20:23:12 +00:00
Chubby Granny Chaser
8794fbc742 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/reviews-in-profile 2025-11-02 17:31:06 +00:00
Chubby Granny Chaser
bf387aef3f feat: improving animations 2025-11-02 17:30:45 +00:00
Chubby Granny Chaser
c2a26b9750 Merge pull request #1832 from hydralauncher/feat/playtime-in-reviews
Feat: Playtime showing in review message
2025-11-02 17:29:28 +00:00
Moyase
3dc2a29114 Merge branch 'main' into feat/playtime-in-reviews 2025-11-02 19:25:31 +02:00
Chubby Granny Chaser
6ebf7766aa Merge branch 'main' into feat/reviews-in-profile 2025-11-02 17:04:58 +00:00
Moyasee
b578af4612 Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update 2025-11-02 18:48:13 +02:00
Moyasee
6f6b7d49ac fix: removed void and converted conditional to boolean 2025-11-02 18:47:26 +02:00
Moyase
5c445f8a90 Merge branch 'main' into feat/displaying-new-game-update 2025-11-02 18:41:01 +02:00
Moyasee
87d35da9fc fix: deleted comments 2025-11-02 18:24:10 +02:00
Moyasee
5067cf163e feat: added new badge to repacks-modal, set up badge clearing 2025-11-02 18:22:37 +02:00
Moyasee
efab242c74 ci: showing new badge in repack-modal 2025-10-31 23:17:06 +02:00
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
Moyasee
4dd3c9de76 fix: formatting 2025-10-30 23:26:22 +02:00
Moyasee
101bc35460 feat: sidebar badge on new game download option 2025-10-30 23:21:31 +02:00
Zamitto
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
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
Moyasee
5c770bc7e7 fix: unnecessary assertion 2025-10-27 20:12:24 +02:00
Moyasee
b431ed479c fix: converted conditional to boolean 2025-10-27 20:07:08 +02:00
Moyasee
9e09a5decb fix: translation key fix and formatting 2025-10-27 19:28:29 +02:00
Moyasee
1e1a1c61c9 feat: showing playtime in review and changed positions of elements in review 2025-10-27 19:22:59 +02:00
Moyasee
8de6c92d28 ci: formatting 2025-10-24 08:19:55 +03:00
Moyasee
29e1713824 fix: upvote/downvote button arent being disabled after click 2025-10-23 20:06:37 +03:00
Moyasee
81a77411cc ci: fix gap between game image and game name in reviews 2025-10-23 16:54:18 +03:00
Moyasee
cc95deb709 fix: proreply reseting user reviews on profile changing 2025-10-23 14:40:02 +03:00
Moyasee
daf9751cf6 ci: import formatting 2025-10-23 14:27:03 +03:00
Moyasee
d21ec52814 ci: deleted comments 2025-10-23 12:06:23 +03:00
Moyasee
f539977431 fix: refactoring functions to prevent nesting more than 4 lvls 2025-10-23 11:53:35 +03:00
Moyasee
3ff20417d5 fix: extracted ternary operation 2025-10-23 11:37:50 +03:00
Moyasee
65f83399f5 ci: merge 2025-10-23 11:29:27 +03:00
Moyasee
eb34f051e1 Merge branch 'feat/reviews-in-profile' of https://github.com/hydralauncher/hydra into feat/reviews-in-profile 2025-10-23 11:28:40 +03:00
Moyasee
ab27f3295e fix: duplicate selectors and if statements should not be the only statement 2025-10-23 11:26:29 +03:00
Chubby Granny Chaser
3782f79100 Merge branch 'main' into feat/reviews-in-profile 2025-10-23 09:15:57 +01:00
Moyasee
86ab5b107b ci: formatting 2025-10-23 10:34:15 +03:00
Moyasee
acf8f340dd ci: review message ui change and fix loading reviews positioning 2025-10-23 10:33:29 +03:00
ctrlcat0x
f5470b29c0 style: adjust hover effects and dimensions for game cards; refine context menu actions 2025-10-23 10:58:31 +05:30
Moyasee
035f6e8d24 ci: formatting 2025-10-22 22:05:05 +03:00
Moyasee
362d6b634e feat: added reviews in profile and tabs 2025-10-22 21:13:05 +03:00
ctrlcat0x
a0a967aacd style: update compact view styles for game cards; adjust grid layout and add button order 2025-10-22 18:28:24 +05:30
ctrlcat0x
e19102ea66 style: update active state styles for filter and view options; adjust achievement progress bar styles 2025-10-22 16:12:12 +05:30
ctrlcat0x
107b61f663 style: update active state colors for filter and view options 2025-10-22 14:46:25 +05:30
ctrlcat0x
811a6ad955 refactor: remove unused imports and download logic from LibraryGameCard 2025-10-22 14:42:47 +05:30
ctrlcat0x
6fb8bbf744 he commit 2025-10-22 14:29:55 +05:30
ctrlcat0x
459017a4a6 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/game-library 2025-10-22 14:28:00 +05:30
Sahil Rana
d6ff8f670e Merge branch 'main' into feat/game-library 2025-10-22 14:26:09 +05:30
ctrlcat0x
33e0d50966 feat: add achievements tracking to game library
- Updated `get-library.ts` to include unlocked and total achievement counts for each game.
- Removed `library-game-card-detailed.tsx` and its associated styles as part of the refactor.
- Enhanced `library-game-card-large.tsx` to display achievements with progress bars.
- Modified `library-game-card.scss` and `library-game-card-large.scss` to style the achievements section.
- Introduced a new `search-bar` component for filtering the game library.
- Implemented fuzzy search functionality in the library view.
- Updated `view-options` to improve UI consistency.
- Added achievement-related properties to the `LibraryGame` type in `index.ts`.
- Created a new `copilot-instructions.md` for project guidelines.
2025-10-22 14:24:04 +05:30
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
90 changed files with 5054 additions and 368 deletions

View File

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

View File

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

View File

@@ -153,8 +153,11 @@ def profile_image():
data = request.get_json()
image_path = data.get('image_path')
# use webp as default value for target_extension
target_extension = data.get('target_extension') or 'webp'
try:
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path)
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path, target_extension)
return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200
except Exception as e:
return jsonify({"error": str(e)}), 400

View File

@@ -4,7 +4,7 @@ import os, uuid, tempfile
class ProfileImageProcessor:
@staticmethod
def get_parsed_image_data(image_path):
def get_parsed_image_data(image_path, target_extension):
Image.MAX_IMAGE_PIXELS = 933120000
image = Image.open(image_path)
@@ -16,7 +16,7 @@ class ProfileImageProcessor:
return image_path, mime_type
else:
new_uuid = str(uuid.uuid4())
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp"
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension
image.save(new_image_path)
new_image = Image.open(new_image_path)
@@ -26,5 +26,5 @@ class ProfileImageProcessor:
@staticmethod
def process_image(image_path):
return ProfileImageProcessor.get_parsed_image_data(image_path)
def process_image(image_path, target_extension):
return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)

View File

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

View File

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

View File

@@ -8,11 +8,12 @@
"no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott",
"weekly": "📅 A hét felkapottjai",
"weekly": "📅 Heti kiemeltek",
"achievements": "🏆 Achievement támogatott"
},
"sidebar": {
"catalogue": "Katalógus",
"library": "Könyvtár",
"downloads": "Letöltések",
"settings": "Beállítások",
"my_library": "Könyvtáram",
@@ -81,7 +82,7 @@
"update_decky_plugin": "Decky Plugin Frissítése",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint",
"install_decky_plugin_message": "Ez letölti és telepíteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?",
"install_decky_plugin_message": "Ez letölti és telepíti a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?",
"update_decky_plugin_title": "Hydra Decky Plugin Frissítése",
"update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?",
"decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
@@ -92,8 +93,10 @@
},
"header": {
"search": "Keresés",
"search_library": "Könyvtár böngészése",
"home": "Főoldal",
"catalogue": "Katalógus",
"library": "Könyvtár",
"downloads": "Letöltések",
"search_results": "Keresési találatok",
"settings": "Beállítások",
@@ -117,7 +120,7 @@
"tags": "Címkék",
"publishers": "Kiadók",
"download_sources": "Letöltési források",
"result_count": "{{resultCount}} találatok",
"result_count": "{{resultCount}} találat",
"filter_count": "{{filterCount}} elérhető",
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
},
@@ -166,11 +169,11 @@
"download_now": "Letöltés",
"no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók",
"download_path": "Letöltis hely",
"download_path": "Letöltési hely",
"previous_screenshot": "Előző screenshot",
"next_screenshot": "Következő screenshot",
"screenshot": "Screenshot {{number}}",
"open_screenshot": "Screenshot megnyitása {{number}}",
"open_screenshot": "{{number}} Screenshot megnyitása ",
"download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód",
"select_executable": "Tallózás",
@@ -194,6 +197,7 @@
"download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve",
"last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új",
"create_steam_shortcut": "Steam parancsikon létrehozása",
"create_shortcut_success": "A parancsikon létrehozása sikeres",
"you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.",
@@ -223,6 +227,7 @@
"show_more": "Mutass többet",
"show_less": "Mutass kevesebbet",
"reviews": "Vélemények",
"review_played_for": "Játszva",
"leave_a_review": "Hagyd itt a véleményed",
"write_review_placeholder": "Oszd meg gondolatod a játékról...",
"sort_newest": "Legújabb",
@@ -361,7 +366,10 @@
"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"
"hide_original": "Eredeti elrejtése",
"review_from_blocked_user": "Letiltott felhasználó véleménye",
"show": "Megjelenítés",
"hide": "Elrejtés"
},
"activation": {
"title": "Hydra Aktiválása",
@@ -488,11 +496,11 @@
"no_email_account": "Még nincs beállított emailed",
"account_data_updated_successfully": "Fiókadatok változtatása sikeres",
"renew_subscription": "Hydra Cloud Megújítása",
"subscription_expired_at": "Az előfizetésed lejárt, ekkor: {{date}}",
"subscription_expired_at": "Az előfizetésed lejárt: {{date}}",
"no_subscription": "Élvezd a Hydrát a lehető legjobb módon",
"become_subscriber": "Légy Hydra Cloud tag",
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva",
"subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}",
"subscription_renews_on": "Az előfizetésed megújul: {{date}}",
"bill_sent_until": "A következő számlát ezen napon küldjük",
"no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
"editor_tab_code": "Code",
@@ -551,10 +559,19 @@
"platinum": "Platina",
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"achievement_sound_volume": "Achievement hangereje",
"select_achievement_sound": "Achievement hang kiválasztása",
"change_achievement_sound": "Achievement hang megváltoztatása",
"remove_achievement_sound": "Achievement hang eltávolítása",
"preview_sound": "Hang előnézet",
"select": "Kiválaszt",
"preview": "Előnézet",
"remove": "Eltávolít",
"no_sound_file_selected": "Nincs hangfájl kiválasztva",
"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",
"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"
"hide_to_tray_on_game_start": "Hydra elrejtése játék indításakor a tálcára"
},
"notifications": {
"download_complete": "Letöltés befejezve",
@@ -670,7 +687,7 @@
"report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:",
"upload_banner": "Borítókép feltöltés",
"upload_banner": "Borítókép feltöltése",
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák",
@@ -689,7 +706,31 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Pozitív értékelésekkel szerzett pontok"
"karma_description": "Pozitív értékelésekkel szerzett pontok",
"user_reviews": "Vélemények",
"delete_review": "Vélemény Törlése",
"loading_reviews": "Vélemények betöltése..."
},
"library": {
"library": "Könyvtár",
"play": "Játék",
"download": "Letöltés",
"downloading": "Letöltés..",
"game": "játék",
"games": "játékok",
"grid_view": "Rács nézet",
"compact_view": "Kompakt nézet",
"large_view": "Nagy nézet",
"no_games_title": "A könyvtárad üres",
"no_games_description": "Adj játékokat a katalógusból hozzá vagy töltsd le őket hogy bele vágj",
"amount_hours": "{{amount}} óra",
"amount_minutes": "{{amount}} perc",
"amount_hours_short": "{{amount}}ó",
"amount_minutes_short": "{{amount}}p",
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
"all_games": "Összes Játék",
"recently_played": "Nemrég Játszva",
"favorites": "Kedvencek"
},
"achievement": {
"achievement_unlocked": "Achievement feloldva",

View File

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

View File

@@ -183,7 +183,8 @@
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"review_from_blocked_user": "Avaliação de utilizador bloqueado",
"show": "Mostrar",
"hide": "Ocultar"
"hide": "Ocultar",
"review_played_for": "Jogado por"
},
"activation": {
"title": "Ativação",
@@ -469,7 +470,11 @@
"achievements_unlocked": "Conquistas desbloqueadas",
"earned_points": "Pontos ganhos",
"show_achievements_on_profile": "Mostre as suas conquistas no perfil",
"show_points_on_profile": "Mostre os seus pontos ganhos no perfil"
"show_points_on_profile": "Mostre os seus pontos ganhos no perfil",
"user_reviews": "Avaliações",
"loading_reviews": "A carregar avaliações...",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Eliminar avaliação"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"my_library": "Библиотека",
@@ -194,6 +195,7 @@
"download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки",
"new_download_option": "Новый",
"create_steam_shortcut": "Создать ярлык Steam",
"create_shortcut_success": "Ярлык создан",
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
@@ -227,6 +229,7 @@
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл",
@@ -554,6 +557,12 @@
"platinum": "Платиновый",
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"achievement_sound_volume": "Громкость звука достижения",
"select_achievement_sound": "Выбрать звук достижения",
"select": "Выбрать",
"preview": "Предпросмотр",
"remove": "Удалить",
"no_sound_file_selected": "Файл звука не выбран",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
@@ -692,7 +701,11 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов"
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",
@@ -722,5 +735,26 @@
"hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!",
"learn_more": "Подробнее",
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
},
"library": {
"library": "Библиотека",
"play": "Играть",
"download": "Скачать",
"downloading": "Скачивание",
"game": "игра",
"games": "игры",
"grid_view": "Вид сетки",
"compact_view": "Компактный вид",
"large_view": "Большой вид",
"no_games_title": "Ваша библиотека пуста",
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"amount_hours_short": "{{amount}}ч",
"amount_minutes_short": "{{amount}}м",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"favorites": "Избранное"
}
}

View File

@@ -16,6 +16,7 @@
"downloads": "İndirilenler",
"settings": "Ayarlar",
"my_library": "Kütüphanem",
"library": "Kütüphane",
"downloading_metadata": "{{title}} (Meta verileri indiriliyor…)",
"paused": "{{title}} (Duraklatıldı)",
"downloading": "{{title}} (%{{percentage}} - İndiriliyor…)",
@@ -26,7 +27,69 @@
"sign_in": "Giriş Yap",
"friends": "Arkadaşlar",
"need_help": "Yardıma mı ihtiyacınız var?",
"favorites": "Favoriler"
"favorites": "Favoriler",
"playable_button_title": "Şu anda oynayabileceğin oyunları göster",
"add_custom_game_tooltip": "Özel Oyun Ekle",
"show_playable_only_tooltip": "Sadece Oynanabilirleri Göster",
"custom_game_modal": "Özel Oyun Ekle",
"custom_game_modal_description": "Çalıştırılabilir bir dosya seçerek kütüphanene özel oyun ekle",
"custom_game_modal_executable_path": "Çalıştırılabilir Dosya Yolu",
"custom_game_modal_select_executable": "Çalıştırılabilir dosya seç",
"custom_game_modal_title": "Başlık",
"custom_game_modal_enter_title": "Başlık gir",
"custom_game_modal_browse": "Gözat",
"custom_game_modal_cancel": "İptal",
"custom_game_modal_add": "Oyun Ekle",
"custom_game_modal_adding": "Oyun Ekleniyor...",
"custom_game_modal_success": "Özel oyun başarıyla eklendi",
"custom_game_modal_failed": "Özel oyun eklenemedi",
"custom_game_modal_executable": "Çalıştırılabilir",
"edit_game_modal": "Varlıkları Özelleştir",
"edit_game_modal_description": "Oyun varlıklarını ve detaylarını özelleştir",
"edit_game_modal_title": "Başlık",
"edit_game_modal_enter_title": "Başlık gir",
"edit_game_modal_image": "Görsel",
"edit_game_modal_select_image": "Görsel seç",
"edit_game_modal_browse": "Gözat",
"edit_game_modal_image_preview": "Görsel önizleme",
"edit_game_modal_icon": "İkon",
"edit_game_modal_select_icon": "İkon seç",
"edit_game_modal_icon_preview": "İkon önizleme",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Logo seç",
"edit_game_modal_logo_preview": "Logo önizleme",
"edit_game_modal_hero": "Kütüphane Hero",
"edit_game_modal_select_hero": "Kütüphane hero görseli seç",
"edit_game_modal_hero_preview": "Kütüphane hero görseli önizleme",
"edit_game_modal_cancel": "İptal et",
"edit_game_modal_update": "Güncelle",
"edit_game_modal_updating": "Güncelleniyor...",
"edit_game_modal_fill_required": "Lütfen tüm gerekli alanları doldur",
"edit_game_modal_success": "Varlıklar başarıyla güncellendi",
"edit_game_modal_failed": "Varlıklar güncellenemedi",
"edit_game_modal_image_filter": "Görsel",
"edit_game_modal_icon_resolution": "Önerilen çözünürlük: 256x256px",
"edit_game_modal_logo_resolution": "Önerilen çözünürlük: 640x360px",
"edit_game_modal_hero_resolution": "Önerilen çözünürlük: 1920x620px",
"edit_game_modal_assets": "Varlıklar",
"edit_game_modal_drop_icon_image_here": "İkon görselini buraya bırak",
"edit_game_modal_drop_logo_image_here": "Logo görselini buraya bırak",
"edit_game_modal_drop_hero_image_here": "Hero görselini buraya bırak",
"edit_game_modal_drop_to_replace_icon": "İkonu değiştirmek için buraya bırak",
"edit_game_modal_drop_to_replace_logo": "Logoyu değiştirmek için buraya bırak",
"edit_game_modal_drop_to_replace_hero": "Hero'yu değiştirmek için buraya bırak",
"install_decky_plugin": "Decky Plugin Kur",
"update_decky_plugin": "Decky Plugin Güncelle",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Hydra Decky Plugin Kur",
"install_decky_plugin_message": "Bu işlem Decky Loader için Hydra plugin'ini indirecek ve kuracak. Bu işlem yükseltilmiş izinler gerektirebilir. Devam et?",
"update_decky_plugin_title": "Hydra Decky Plugin Güncelle",
"update_decky_plugin_message": "Hydra Decky plugin'inin yeni bir sürümü mevcut. Şimdi güncellemek ister misin?",
"decky_plugin_installed": "Decky plugin v{{version}} başarıyla kuruldu",
"decky_plugin_installation_failed": "Decky plugin kurulamadı: {{error}}",
"decky_plugin_installation_error": "Decky plugin kurulumu hatası: {{error}}",
"confirm": "Onayla",
"cancel": "İptal"
},
"header": {
"search": "Oyunlarda Ara",
@@ -35,6 +98,8 @@
"downloads": "İndirilenler",
"search_results": "Arama Sonuçları",
"settings": "Ayarlar",
"search_library": "Kütüphanede ara",
"library": "Kütüphane",
"version_available_install": "{{version}} sürümü mevcut. Yeniden başlatıp yüklemek için tıklayın.",
"version_available_download": "{{version}} sürümü mevcut. İndirmek için tıklayın."
},
@@ -203,7 +268,108 @@
"create_start_menu_shortcut": "Başlat Menüsüne kısayol oluştur",
"invalid_wine_prefix_path": "Geçersiz Wine ön ek yolu",
"invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.",
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir"
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir",
"already_in_library": "Zaten kütüphanede",
"create_shortcut_simple": "Kısayol oluştur",
"properties": "Özellikler",
"new_download_option": "Yeni",
"add_to_favorites": "Favorilere ekle",
"remove_from_favorites": "Favorilerden çıkar",
"failed_update_favorites": "Favoriler güncellenemedi",
"game_removed_from_library": "Oyun kütüphaneden çıkarıldı",
"failed_remove_from_library": "Kütüphaneden çıkarılamadı",
"files_removed_success": "Dosyalar başarıyla kaldırıldı",
"failed_remove_files": "Dosyalar kaldırılamadı",
"rating_count": "Puan",
"show_more": "Daha fazla göster",
"show_less": "Daha az göster",
"reviews": "İncelemeler",
"review_played_for": "Oynama süresi",
"leave_a_review": "İnceleme Yap",
"write_review_placeholder": "Bu oyun hakkındaki düşüncelerini paylaş...",
"sort_newest": "En yeni",
"no_reviews_yet": "Henüz inceleme yok",
"be_first_to_review": "Bu oyun hakkındaki düşüncelerini paylaşan ilk kişi ol!",
"sort_oldest": "En eski",
"sort_highest_score": "En yüksek puan",
"sort_lowest_score": "En düşük puan",
"sort_most_voted": "En çok oy",
"rating": "Puan",
"rating_stats": "Puan",
"rating_very_negative": "Çok Olumsuz",
"rating_negative": "Olumsuz",
"rating_neutral": "Nötr",
"rating_positive": "Olumlu",
"rating_very_positive": "Çok Olumlu",
"submit_review": "Gönder",
"submitting": "Gönderiliyor...",
"review_submitted_successfully": "İnceleme başarıyla gönderildi!",
"review_submission_failed": "İnceleme gönderilemedi. Lütfen tekrar dene.",
"review_cannot_be_empty": "İnceleme metin alanı boş olamaz.",
"review_deleted_successfully": "İnceleme başarıyla silindi.",
"review_deletion_failed": "İnceleme silinemedi. Lütfen tekrar dene.",
"loading_reviews": "İncelemeler yükleniyor...",
"loading_more_reviews": "Daha fazla inceleme yükleniyor...",
"load_more_reviews": "Daha fazla inceleme yükle",
"you_seemed_to_enjoy_this_game": "Bu oyunu beğenmiş görünüyorsun",
"would_you_recommend_this_game": "Bu oyun hakkında bir inceleme yazmak ister misin?",
"yes": "Evet",
"maybe_later": "Belki sonra",
"backup_failed": "Yedekleme başarısız",
"update_playtime_title": "Oynama süresini güncelle",
"update_playtime_description": "{{game}} için oynama süresini manuel olarak güncelle",
"update_playtime": "Oynama süresini güncelle",
"update_playtime_success": "Oynama süresi başarıyla güncellendi",
"update_playtime_error": "Oynama süresi güncellenemedi",
"update_game_playtime": "Oyun oynama süresini güncelle",
"manual_playtime_warning": "Saatlerin manuel olarak güncellendiği işaretlenecek ve bu geri alınamaz.",
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
"game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı",
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"artifact_renamed": "Yedekleme başarıyla yeniden adlandırıldı",
"rename_artifact": "Yedeklemeyi Yeniden Adlandır",
"rename_artifact_description": "Yedeklemeyi daha açıklayıcı bir isimle yeniden adlandır",
"artifact_name_label": "Yedekleme adı",
"artifact_name_placeholder": "Yedekleme için bir isim gir",
"save_changes": "Değişiklikleri kaydet",
"required_field": "Bu alan gereklidir",
"max_length_field": "Bu alan {{length}} karakterden az olmalıdır",
"freeze_backup": "Otomatik yedeklemeler tarafından üzerine yazılmasın diye sabitle",
"unfreeze_backup": "Sabitlemeyi kaldır",
"backup_frozen": "Yedekleme sabitlendi",
"backup_unfrozen": "Yedekleme sabitlemesi kaldırıldı",
"backup_freeze_failed": "Yedekleme sabitlenemedi",
"backup_freeze_failed_description": "Otomatik yedeklemeler için en az bir boş alan bırakmalısın",
"edit_game_modal_button": "Oyun varlıklarını özelleştir",
"game_details": "Oyun Detayları",
"currency_symbol": "₺",
"currency_country": "tr",
"prices": "Fiyatlar",
"no_prices_found": "Fiyat bulunamadı",
"view_all_prices": "Tüm fiyatları görüntülemek için tıkla",
"retail_price": "Perakende fiyatı",
"keyshop_price": "Anahtar dükkanı fiyatı",
"historical_retail": "Geçmiş perakende",
"historical_keyshop": "Geçmiş anahtar dükkanı",
"language": "Dil",
"caption": "Altyazı",
"audio": "Ses",
"filter_by_source": "Kaynağa göre filtrele",
"no_repacks_found": "Bu oyun için kaynak bulunamadı",
"delete_review": "İncelemeyi sil",
"remove_review": "İncelemeyi Kaldır",
"delete_review_modal_title": "İncelemeni silmek istediğinden emin misin?",
"delete_review_modal_description": "Bu işlem geri alınamaz.",
"delete_review_modal_delete_button": "Sil",
"delete_review_modal_cancel_button": "İptal",
"vote_failed": "Oyun kaydı başarısız oldu. Lütfen tekrar dene.",
"show_original": "Orijinali göster",
"show_translation": "Çeviriyi göster",
"show_original_translated_from": "Orijinali göster ({{language}} dilinden çevrilmiştir)",
"hide_original": "Orijinali gizle",
"review_from_blocked_user": "Engellenen kullanıcıdan gelen inceleme",
"show": "Göster",
"hide": "Gizle"
},
"activation": {
"title": "Hydra'yı Etkinleştir",
@@ -379,7 +545,33 @@
"hidden": "Gizli",
"test_notification": "Test bildirimi",
"notification_preview": "Başarı Bildirimi Önizlemesi",
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında"
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında",
"adding": "Ekleniyor…",
"failed_add_download_source": "İndirme kaynağı eklenemedi. Lütfen tekrar dene.",
"download_source_already_exists": "Bu indirme kaynağı URL'si zaten mevcut.",
"download_source_pending_matching": "Yakında güncellenecek",
"download_source_matched": "Güncel",
"download_source_matching": "Güncelleniyor",
"download_source_failed": "Hata",
"download_source_no_information": "Bilgi mevcut değil",
"removed_all_download_sources": "Tüm indirme kaynakları kaldırıldı",
"download_sources_synced_successfully": "Tüm indirme kaynakları senkronize edildi",
"importing": "İçe aktarılıyor...",
"hydra_cloud": "Hydra Cloud",
"debrid": "Debrid",
"debrid_description": "Debrid servisleri, internet hızınızla sınırlı, çeşitli dosya barındırma hizmetlerinde barındırılan dosyaları hızla indirmenize olanak tanıyan premium sınırsız indiricilerdir.",
"enable_steam_achievements": "Steam başarımları aramasını etkinleştir",
"achievement_sound_volume": "Başarım ses seviyesi",
"select_achievement_sound": "Başarım sesi seç",
"change_achievement_sound": "Başarım sesini değiştir",
"remove_achievement_sound": "Başarım sesini kaldır",
"preview_sound": "Sesi önizle",
"select": "Seç",
"preview": "Önizle",
"remove": "Kaldır",
"no_sound_file_selected": "Ses dosyası seçilmedi",
"autoplay_trailers_on_game_page": "Oyun sayfasında fragmanları otomatik olarak oynat",
"hide_to_tray_on_game_start": "Oyun başlatıldığında Hydra'yı sistem tepsisine gizle"
},
"notifications": {
"download_complete": "İndirme tamamlandı",
@@ -406,7 +598,8 @@
"game_card": {
"available_one": "Mevcut",
"available_other": "Mevcut",
"no_downloads": "İndirme mevcut değil"
"no_downloads": "İndirme mevcut değil",
"calculating": "Hesaplanıyor"
},
"binary_not_found_modal": {
"title": "Programlar Yüklü Değil",
@@ -498,7 +691,46 @@
"achievements_unlocked": "Açılan başarımlar",
"earned_points": "Kazanılan puanlar",
"show_achievements_on_profile": "Başarımlarını profilinde göster",
"show_points_on_profile": "Kazanılan puanlarını profilinde göster"
"show_points_on_profile": "Kazanılan puanlarını profilinde göster",
"amount_hours_short": "{{amount}}s",
"amount_minutes_short": "{{amount}}d",
"pinned": "Sabitlenmiş",
"sort_by": "Sırala:",
"achievements_earned": "Kazanılan başarımlar",
"played_recently": "Son oynanan",
"playtime": "Oynama süresi",
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
"error_adding_friend": "Arkadaş isteği gönderilemedi. Lütfen arkadaş kodunu kontrol et",
"friend_code_length_error": "Arkadaş kodu 8 karakter olmalıdır",
"game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı",
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
"user_reviews": "İncelemeler",
"delete_review": "İncelemeyi Sil",
"loading_reviews": "İncelemeler yükleniyor..."
},
"library": {
"library": "Kütüphane",
"play": "Oyna",
"download": "İndir",
"downloading": "İndiriliyor",
"game": "oyun",
"games": "oyunlar",
"grid_view": "Izgara görünümü",
"compact_view": "Kompakt görünüm",
"large_view": "Büyük görünüm",
"no_games_title": "Kütüphanen boş",
"no_games_description": "Başlamak için katalogdan oyun ekle veya indir",
"amount_hours": "{{amount}} saat",
"amount_minutes": "{{amount}} dakika",
"amount_hours_short": "{{amount}}s",
"amount_minutes_short": "{{amount}}d",
"manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi",
"all_games": "Tüm Oyunlar",
"recently_played": "Son Oynanan",
"favorites": "Favoriler"
},
"achievement": {
"achievement_unlocked": "Başarım açıldı",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,20 @@
import { registerEvent } from "../register-event";
import { PythonRPC } from "@main/services/python-rpc";
const processProfileImage = async (
const processProfileImageEvent = async (
_event: Electron.IpcMainInvokeEvent,
path: string
) => {
return processProfileImage(path, "webp");
};
export const processProfileImage = async (path: string, extension?: string) => {
return PythonRPC.rpc
.post<{
imagePath: string;
mimeType: string;
}>("/profile-image", { image_path: path })
}>("/profile-image", { image_path: path, target_extension: extension })
.then((response) => response.data);
};
registerEvent("processProfileImage", processProfileImage);
registerEvent("processProfileImage", processProfileImageEvent);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -8,9 +8,11 @@ export const friendRequestEvent = async (payload: FriendRequest) => {
friendRequestCount: payload.friendRequestCount,
});
const user = await HydraApi.get(`/users/${payload.senderId}`);
if (payload.senderId) {
const user = await HydraApi.get(`/users/${payload.senderId}`);
if (user) {
publishNewFriendRequestNotification(user);
if (user) {
publishNewFriendRequestNotification(user);
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,10 @@ import { useState } from "react";
import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared";
import { useDate } from "@renderer/hooks";
import { useDate, useFormat } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./review-item.scss";
@@ -29,13 +30,6 @@ interface ReviewItemProps {
) => void;
}
const getScoreColorClass = (score: number): string => {
if (score >= 1 && score <= 2) return "game-details__review-score--red";
if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
if (score >= 4 && score <= 5) return "game-details__review-score--green";
return "";
};
const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) {
case 1:
@@ -68,6 +62,7 @@ export function ReviewItem({
const navigate = useNavigate();
const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate();
const { numberFormatter } = useFormat();
const [showOriginal, setShowOriginal] = useState(false);
@@ -93,6 +88,21 @@ 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]
: review.reviewHtml;
@@ -116,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,9 +56,12 @@ export interface Game {
launchOptions?: string | null;
favorite?: boolean;
isPinned?: boolean;
achievementCount?: number;
unlockedAchievementCount?: number;
pinnedDate?: Date | null;
automaticCloudSync?: boolean;
hasManuallyUpdatedPlaytime?: boolean;
newDownloadOptionsCount?: number;
}
export interface Download {
@@ -113,6 +116,7 @@ export interface UserPreferences {
achievementNotificationsEnabled?: boolean;
achievementCustomNotificationsEnabled?: boolean;
achievementCustomNotificationPosition?: AchievementCustomNotificationPosition;
achievementSoundVolume?: number;
friendRequestNotificationsEnabled?: boolean;
friendStartGameNotificationsEnabled?: boolean;
showDownloadSpeedInMegabytes?: boolean;

View File

@@ -5,6 +5,8 @@ export interface Theme {
authorName?: string;
isActive: boolean;
code: string;
hasCustomSound?: boolean;
originalSoundPath?: string;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -7538,6 +7538,13 @@ react-i18next@^14.1.0:
"@babel/runtime" "^7.23.9"
html-parse-stringify "^3.0.1"
react-infinite-scroll-component@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f"
integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==
dependencies:
throttle-debounce "^2.1.0"
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -8540,6 +8547,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
throttle-debounce@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2"
integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==
"through@>=2.2.7 <3":
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"