Compare commits

...

122 Commits

Author SHA1 Message Date
Moyasee
a4417b26fd chore: update subproject commit reference and clean up imports in settings-general 2025-12-27 22:17:12 +02:00
Moyasee
2919109a11 feat: move new achievement related settings into achievement tab in settings 2025-12-27 22:13:48 +02:00
Moyasee
734f8dbce0 fix: merge conflicts 2025-12-27 21:50:28 +02:00
Chubby Granny Chaser
9769eecec6 Merge pull request #1907 from hydralauncher/fix/lint-buzz
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
refactor: improve code formatting and consistency in DownloadManager
2025-12-27 00:56:29 +00:00
Moyasee
91adb97013 refactor: improve code formatting and consistency in DownloadManager 2025-12-27 02:50:27 +02:00
Chubby Granny Chaser
f138b2efcb Merge pull request #1906 from Wkeynhk/main
Buzzheavier fix
2025-12-27 00:47:44 +00:00
Wkeynhk
991aa05760 Sonar fix2 2025-12-27 03:23:36 +03:00
Wkeynhk
aff9e13bca Sonar fix 2025-12-27 03:17:42 +03:00
Wkeynhk
240a75c1d0 Buzzheavier fix 2025-12-27 03:02:35 +03:00
Chubby Granny Chaser
edbe86a1fb Merge pull request #1904 from hydralauncher/feat/LBX-155
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
fix: notification item styling
2025-12-26 22:54:13 +00:00
Moyasee
a01e1b1709 style: formatting 2025-12-27 00:51:26 +02:00
Moyase
60fd90820c Merge branch 'main' into feat/LBX-155 2025-12-27 00:44:35 +02:00
Moyasee
798f88618e style: enhance notification item styles with color adjustments and SVG inheritance 2025-12-27 00:42:23 +02:00
Moyasee
40795c34dc Merge branch 'feat/LBX-155' of https://github.com/hydralauncher/hydra into feat/LBX-155 2025-12-27 00:39:01 +02:00
Moyasee
e335e05628 style: update notification item styles for improved layout and alignment 2025-12-27 00:32:15 +02:00
Chubby Granny Chaser
05464f25df Merge pull request #1882 from keipa/patch-1
Adding chocolatey publishing
2025-12-26 22:20:57 +00:00
Chubby Granny Chaser
b9830afca1 Merge branch 'main' into patch-1 2025-12-26 22:20:33 +00:00
Chubby Granny Chaser
1cab73bcb4 Merge pull request #1865 from hydralauncher/feat/disabling-update-badges
feat: checkbox to disable new game update badges
2025-12-26 21:57:44 +00:00
Chubby Granny Chaser
27462c1e1e Merge branch 'main' into feat/disabling-update-badges 2025-12-26 21:53:45 +00:00
Chubby Granny Chaser
98dc20092a Merge pull request #1902 from hydralauncher/feat/LBX-155
Feat: Notification Hub (WIP)
2025-12-26 21:50:39 +00:00
Chubby Granny Chaser
9faf34a976 Merge branch 'main' into feat/disabling-update-badges 2025-12-26 21:49:35 +00:00
Chubby Granny Chaser
d25ac69e74 Merge branch 'main' into feat/LBX-155 2025-12-26 21:49:10 +00:00
Chubby Granny Chaser
d3fb967229 Merge pull request #1895 from hydralauncher/feat/wrapped-in-profile
feat: add Wrapped 2025 view in profile
2025-12-26 21:48:38 +00:00
Chubby Granny Chaser
35736dd2d9 Merge branch 'main' into feat/wrapped-in-profile 2025-12-26 21:39:24 +00:00
Moyasee
263d0be4e4 refactor: improve code formatting and logging consistency in download manager and buzzheavier services 2025-12-26 23:38:01 +02:00
Moyasee
0b4d31e482 refactor: update notification item components to use button elements for better accessibility 2025-12-26 23:37:35 +02:00
Moyase
a74b557d13 Merge branch 'main' into feat/LBX-155 2025-12-26 20:01:35 +02:00
Chubby Granny Chaser
86d5547aa1 Merge pull request #1903 from Wkeynhk/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Adding new hosters
2025-12-25 23:13:24 +00:00
Wkeynhk
358f41b4ba . 2025-12-26 01:34:09 +03:00
Wkeynhk
7f0dc5eee4 . 2025-12-26 01:25:56 +03:00
Wkeynhk
067f7a00be . 2025-12-26 01:19:04 +03:00
Wkeynhk
37f085e2c0 . 2025-12-26 01:13:36 +03:00
Wkeynhk
f8ac284bc2 New hosters 2025-12-25 14:16:11 +03:00
Moyasee
cea5afc7f7 feat: enhance add friend modal with friend code display and copy functionality 2025-12-24 13:22:57 +02:00
Moyasee
dff8d02f3f refactor: remove unused userDetails variable from App component 2025-12-23 18:48:56 +02:00
Moyasee
8751e369da refactor: remove sync friend requests functionality and related components 2025-12-23 18:47:31 +02:00
Moyasee
45eaef23a9 fix: move the view all button in badges box to the bottom left 2025-12-23 16:52:04 +02:00
Moyasee
3c296fe721 feat: add all badges modal and enhance badges display in profile 2025-12-23 15:43:52 +02:00
Moyasee
1d1bbd2de5 fix: forcing unread styling for friend request received notification 2025-12-23 13:40:50 +02:00
Moyasee
246fc14b75 fix: update notification display logic and impove empty state styling 2025-12-23 13:11:02 +02:00
Nikolay Rovdo
387b3ebeac Merge branch 'main' into patch-1 2025-12-22 15:04:41 +01:00
Chubby Granny Chaser
b1d72828bb Merge branch 'main' into feat/wrapped-in-profile 2025-12-19 02:03:03 +00:00
Chubby Granny Chaser
4c09f915c6 Merge pull request #1901 from hydralauncher/fix/fullscreen-modal
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
style: enhance overlay styling and adjust close button positioning
2025-12-19 02:02:46 +00:00
Moyasee
24d65b50b4 style: enhance overlay styling and adjust close button positioning 2025-12-18 22:56:50 +02:00
Moyase
f77b2116c1 Merge branch 'main' into feat/wrapped-in-profile 2025-12-18 22:44:25 +02:00
Moyasee
6cd65d6239 feat: implement add friend modal and enhance friends box with add friend functionality 2025-12-18 22:41:28 +02:00
Chubby Granny Chaser
34681b3bc2 Merge pull request #1897 from Stormm232/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
v3.7.6 - Hungarian Translation
2025-12-18 19:33:22 +00:00
Chubby Granny Chaser
4b71a3f5bb Merge branch 'main' into main 2025-12-18 19:33:15 +00:00
Chubby Granny Chaser
a0f669c97b Merge pull request #1896 from hydralauncher/fix/peak-download-speed
Fix: peak download speed using current network speed fix
2025-12-18 19:33:00 +00:00
Chubby Granny Chaser
96fca88601 Merge branch 'main' into fix/peak-download-speed 2025-12-18 19:32:11 +00:00
Chubby Granny Chaser
3f65bb86a8 Merge pull request #1899 from hydralauncher/feat/LBX-301
Feat: Ability to open profile image in fullscreen
2025-12-18 19:30:51 +00:00
Chubby Granny Chaser
b494c7c8ec Merge branch 'main' into main 2025-12-18 19:30:27 +00:00
Chubby Granny Chaser
e10007b1c3 Merge branch 'main' into feat/LBX-301 2025-12-18 19:29:52 +00:00
Chubby Granny Chaser
dae825a75a Merge pull request #1900 from moi952/feat/translation-fr-v3.7.6
feat: add missing French translations
2025-12-18 19:29:33 +00:00
moi952
677f34fe3d feat: add missing French translations 2025-12-16 22:41:56 +01:00
Moyasee
1524e73ee6 refactor(fullscreen-media-modal): replace div with dialog element for improved semantics 2025-12-16 15:49:10 +02:00
Moyasee
40d428c19e refactor(fullscreen-media-modal): remove unused useCallback import 2025-12-16 15:43:43 +02:00
Moyasee
affa7a2b2e feat: add fullscreen media modal to profile hero for avatar display 2025-12-16 15:42:34 +02:00
Moyasee
cf16c8245c refactor(modal): change title type to React.ReactNode for better flexibility 2025-12-16 15:21:46 +02:00
Moyasee
6257529297 feat: enhance keyboard accessibility for notification items and friends modal 2025-12-16 15:20:13 +02:00
Moyasee
b8352be274 feat: add local notifications management and UI integration 2025-12-16 15:18:21 +02:00
Kiwo.2
d5e6bed3b7 Re-written some parts, Adjusted to new version 2025-12-14 15:58:27 +01:00
Kiwo.2
214267df7e Re-written some parts, Adjusted to new version 2025-12-14 15:09:40 +01:00
Kiwo.2
21f46c9af3 Merge branch 'main' of https://github.com/Stormm232/hydra 2025-12-14 15:05:52 +01:00
Kiwo.2
cc5e0014f7 Re-written some parts, Adjusted to new version 2025-12-14 15:04:51 +01:00
Moyasee
ccb754fa13 feat(profile): add hasCompletedWrapped2025 flag to UserProfile and update ProfileHero rendering logic 2025-12-14 15:14:54 +02:00
Moyasee
142bd3156c fix: ensure downloader value is properly checked and converted to number in DownloadGroup component 2025-12-14 11:27:29 +02:00
Moyasee
95a7bc2236 feat: add new translation keys for network and peak in English and Portuguese locales 2025-12-13 18:32:16 +02:00
Moyasee
78d2be85f2 style: format peak speed calculation for improved readability in DownloadGroup component 2025-12-13 18:15:21 +02:00
Moyasee
67ea9e78a2 feat: enhance download tracking by adding speed history management in download slice 2025-12-13 18:14:52 +02:00
Moyasee
67f863e0f3 fix: remove unused peak speed update dispatch in DownloadGroup component 2025-12-13 17:41:15 +02:00
Moyasee
77b6f1b2ad feat: add peak speed tracking and management in download slice 2025-12-13 16:10:57 +02:00
Moyasee
5329cc446f Merge branch 'feat/wrapped-in-profile' of https://github.com/hydralauncher/hydra into feat/wrapped-in-profile 2025-12-13 09:17:02 +02:00
Moyasee
21a0ad1500 feat(profile): add translation for "View My Wrapped 2025" button 2025-12-13 09:16:54 +02:00
Moyase
9ffaee12d1 Merge branch 'main' into feat/wrapped-in-profile 2025-12-12 20:44:15 +02:00
Moyasee
8555274589 ci: formatting 2025-12-12 20:43:14 +02:00
Moyasee
a152c89d7f feat(profile): update Wrapped 2025 button and add new gradient style 2025-12-12 20:42:54 +02:00
Moyasee
879f0baad7 refactor(profile): remove overlay from WrappedFullscreenModal to streamline UI 2025-12-12 17:51:31 +02:00
Moyasee
c025dc199d style(profile): add overlay to WrappedFullscreenModal for improved UI interaction 2025-12-12 17:49:12 +02:00
Chubby Granny Chaser
1552a5f359 Merge pull request #1894 from hydralauncher/feat/LBX-188
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Feat: proper archive extraction and deletion after completion
2025-12-12 15:39:42 +00:00
Moyasee
5b4b258526 refactor: remove WrappedConfirmModal and integrate WrappedFullscreenModal in profile 2025-12-12 17:29:44 +02:00
Moyasee
0268829946 feat: add Wrapped 2025 view in profile 2025-12-12 13:53:12 +02:00
Chubby Granny Chaser
3f41f0f7ad Merge branch 'main' into feat/disabling-update-badges 2025-12-10 17:26:35 +00:00
Nikolay Rovdo
1545f42d17 Adding chocolatey publishing 2025-11-30 14:51:24 +01:00
Moyasee
3105fe1c59 feat: displaying achievements in game accordion and proper styling 2025-11-25 21:41:52 +02:00
Moyasee
4097869ae8 feat: displaying achievements in game accordion and proper styling 2025-11-25 21:21:57 +02:00
Moyasee
bc3d47ed0e feat: deleting achievement souvenirs 2025-11-18 18:26:22 +02:00
Moyasee
2adc132c33 fix: removed void from main.ts 2025-11-15 16:57:44 +02:00
Moyasee
c4852b89f1 feat: checkbox to disable new game update badges 2025-11-15 16:46:02 +02:00
Moyasee
e1c60c6e8f fix: memo thing 2025-11-11 20:56:25 +02:00
Moyasee
ccecd8aa4c fix: formatting 2025-11-11 20:49:47 +02:00
Moyase
d8d1576cc7 Merge branch 'main' into feat/souvenirs-for-achievements 2025-11-11 20:29:23 +02:00
Moyasee
29f05b0c3c fix: deleted duplicate css class 2025-11-09 18:23:11 +02:00
Moyasee
941101702e ci: moving souvenirs into separate tab 2025-11-09 18:18:16 +02:00
Moyase
089bdd877d Merge branch 'main' into feat/souvenirs-for-achievements 2025-11-09 17:46:08 +02:00
Moyasee
bf419edd99 fix: refactoring function 2025-10-26 08:37:21 +02:00
Moyasee
28cc25b368 ci: formatting 2025-10-26 08:32:28 +02:00
Moyasee
a1f419957f ci: changed css variables to image from souvenirs, made image add in a single api call with updating achievements, renamed variables 2025-10-24 23:50:59 +03:00
Moyasee
f1f69e6dbd Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/souvenirs-for-achievements 2025-10-24 20:50:24 +03:00
Moyasee
274cb21459 fix: disabling open screenshot folder button for linux users 2025-10-24 08:22:53 +03:00
Moyasee
af69d19db7 ci: formatting 2025-10-23 10:00:54 +03:00
Moyasee
594d56db5c feat: Changing settings ui. Added new section achievements 2025-10-23 09:55:04 +03:00
Moyasee
2634bec292 feat: added open screenshots folder in settings-behavior 2025-10-22 22:49:07 +03:00
Moyasee
7109c1542c ci: api change + fullscreen modal fix 2025-10-22 22:14:47 +03:00
Chubby Granny Chaser
68b3de9b7f fix: moving constant 2025-10-22 17:37:00 +01:00
Moyasee
ddbac621fb fix: added linux precheck and tooltip to the screenshot checkbox 2025-10-22 10:54:49 +03:00
Moyasee
40ab06e87c ci: refactored service to event, fixed dynamic import 2025-10-22 10:44:29 +03:00
Moyasee
8616729f5d Merge branch 'feat/souvenirs-for-achievements' of https://github.com/hydralauncher/hydra into feat/souvenirs-for-achievements 2025-10-22 10:41:56 +03:00
Moyasee
1b4a1360a6 ci: refactored service to event, fixed dynamic import 2025-10-22 10:40:52 +03:00
Chubby Granny Chaser
2da98911b8 Merge branch 'main' into feat/souvenirs-for-achievements 2025-10-21 20:47:11 +01:00
Moyasee
ba4610705d ci: imports fix, readonly marking, using dialog, removed unnecesary assertions 2025-10-21 21:19:50 +03:00
Moyasee
0a4726af44 ci: imports fix, readonly marking, using dialog, removed unnecesary assertions 2025-10-21 21:17:55 +03:00
Moyasee
2022ff34cb fix: empty block 2025-10-21 21:09:31 +03:00
Moyasee
72e6f1e328 ci: deleting comments 2025-10-21 21:08:01 +03:00
Moyasee
9b693f2297 ci: fixed files after merging 2025-10-20 16:26:25 +03:00
Moyasee
e3685ba233 ci: merging main to branch 2025-10-20 15:51:15 +03:00
Moyasee
03d9128768 ci: merging main to branch 2025-10-20 15:50:04 +03:00
Moyasee
beec415636 ci: souvenir gradient box fix 2025-10-20 08:40:17 +03:00
Moyasee
4fb04b72a3 ci: added responsive design to souvenir cards in user profile 2025-10-20 03:24:28 +03:00
Moyasee
e53e52df1b ci: another lint fixes 2025-10-20 01:50:30 +03:00
Moyasee
8c0281844e ci: fix lint errors 2025-10-20 01:45:00 +03:00
Moyasee
cc9d98c360 feat: displaying recent achievements on profile 2025-10-20 01:42:50 +03:00
125 changed files with 6538 additions and 1635 deletions

View File

@@ -10,6 +10,7 @@
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![chocolatey](https://img.shields.io/chocolatey/v/hydralauncher.svg)](https://community.chocolatey.org/packages/hydralauncher)
![Hydra Launcher Home Page](./docs/screenshot.png)

2
proto

Submodule proto updated: 7a23620f93...6f11c99c57

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends",
"notifications": "Notifications",
"need_help": "Need help?",
"favorites": "Favorites",
"playable_button_title": "Show only games you can play now",
@@ -420,7 +421,9 @@
"delete_archive_title": "Would you like to delete {{fileName}}?",
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
"yes": "Yes",
"no": "No"
"no": "No",
"network": "NETWORK",
"peak": "PEAK"
},
"settings": {
"downloads_path": "Downloads path",
@@ -433,6 +436,7 @@
"launch_with_system": "Launch Hydra on system start-up",
"general": "General",
"behavior": "Behavior",
"achievements": "Achievements",
"download_sources": "Download sources",
"language": "Language",
"api_token": "API Token",
@@ -556,6 +560,9 @@
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download",
"enable_steam_achievements": "Enable search for Steam achievements",
"enable_achievement_screenshots": "Enable achievement screenshots",
"open_screenshots_directory": "Open screenshots directory",
"enable_new_download_options_badges": "Show new download options badges",
"achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left",
"top-center": "Top center",
@@ -631,6 +638,7 @@
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Last played {{period}}",
"activity": "Recent Activity",
"souvenirs": "Souvenirs",
"library": "Library",
"pinned": "Pinned",
"sort_by": "Sort by:",
@@ -660,6 +668,7 @@
"sending": "Sending",
"friend_request_sent": "Friend request sent",
"friends": "Friends",
"badges": "Badges",
"friends_list": "Friends list",
"user_not_found": "User not found",
"block_user": "Block user",
@@ -670,12 +679,16 @@
"ignore_request": "Ignore request",
"cancel_request": "Cancel request",
"undo_friendship": "Undo friendship",
"friendship_removed": "Friend removed",
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}",
"blocked_users": "Blocked users",
"unblock": "Unblock",
"no_friends_added": "You have no added friends",
"view_all": "View all",
"load_more": "Load more",
"loading": "Loading",
"pending": "Pending",
"no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users",
@@ -699,6 +712,7 @@
"report_reason_other": "Other",
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated",
@@ -721,7 +735,16 @@
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews..."
"loading_reviews": "Loading reviews...",
"souvenir_deleted_successfully": "Souvenir deleted successfully",
"souvenir_deletion_failed": "Failed to delete souvenir",
"delete_souvenir_modal_title": "Are you sure you want to delete this souvenir?",
"delete_souvenir_modal_description": "This action cannot be undone.",
"delete_souvenir_modal_delete_button": "Delete",
"delete_souvenir_modal_cancel_button": "Cancel",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
},
"library": {
"library": "Library",
@@ -772,5 +795,40 @@
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
"learn_more": "Learn More",
"debrid_description": "Download up to 4x faster with Nimbus"
},
"notifications_page": {
"title": "Notifications",
"mark_all_as_read": "Mark all as read",
"clear_all": "Clear All",
"loading": "Loading...",
"empty_title": "No notifications",
"empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.",
"filter_all": "All",
"filter_friends": "Friends",
"filter_badges": "Badges",
"filter_upvotes": "Upvotes",
"filter_local": "Local",
"load_more": "Load more",
"dismiss": "Dismiss",
"accept": "Accept",
"refuse": "Refuse",
"notification": "Notification",
"friend_request_received_title": "New friend request!",
"friend_request_received_description": "{{displayName}} wants to be your friend",
"friend_request_accepted_title": "Friend request accepted!",
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
"badge_received_title": "You got a new badge!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
"review_upvote_description": "Your review received {{count}} new upvotes",
"marked_all_as_read": "All notifications marked as read",
"failed_to_mark_as_read": "Failed to mark notifications as read",
"cleared_all": "All notifications cleared",
"failed_to_clear": "Failed to clear notifications",
"failed_to_load": "Failed to load notifications",
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
}
}

View File

@@ -27,7 +27,69 @@
"friends": "Amis",
"need_help": "Besoin d'aide ?",
"favorites": "Favoris",
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant"
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant",
"library": "Bibliothèque",
"add_custom_game_tooltip": "Ajouter un jeu personnalisé",
"show_playable_only_tooltip": "Afficher uniquement les jeux jouables",
"custom_game_modal": "Ajouter un jeu personnalisé",
"custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable",
"custom_game_modal_executable_path": "Chemin de l'exécutable",
"custom_game_modal_select_executable": "Sélectionner un fichier exécutable",
"custom_game_modal_title": "Titre",
"custom_game_modal_enter_title": "Entrer le titre",
"custom_game_modal_browse": "Parcourir",
"custom_game_modal_cancel": "Annuler",
"custom_game_modal_add": "Ajouter le jeu",
"custom_game_modal_adding": "Ajout du jeu…",
"custom_game_modal_success": "Jeu personnalisé ajouté avec succès",
"custom_game_modal_failed": "Échec de lajout du jeu personnalisé",
"custom_game_modal_executable": "Exécutable",
"edit_game_modal": "Personnaliser les ressources",
"edit_game_modal_description": "Personnalisez les ressources et les détails du jeu",
"edit_game_modal_title": "Titre",
"edit_game_modal_enter_title": "Entrer le titre",
"edit_game_modal_image": "Image",
"edit_game_modal_select_image": "Sélectionner une image",
"edit_game_modal_browse": "Parcourir",
"edit_game_modal_image_preview": "Aperçu de limage",
"edit_game_modal_icon": "Icône",
"edit_game_modal_select_icon": "Sélectionner une icône",
"edit_game_modal_icon_preview": "Aperçu de licône",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Sélectionner un logo",
"edit_game_modal_logo_preview": "Aperçu du logo",
"edit_game_modal_hero": "Bannière de la bibliothèque",
"edit_game_modal_select_hero": "Sélectionner limage de bannière",
"edit_game_modal_hero_preview": "Aperçu de la bannière",
"edit_game_modal_cancel": "Annuler",
"edit_game_modal_update": "Mettre à jour",
"edit_game_modal_updating": "Mise à jour…",
"edit_game_modal_fill_required": "Veuillez remplir tous les champs requis",
"edit_game_modal_success": "Ressources mises à jour avec succès",
"edit_game_modal_failed": "Échec de la mise à jour des ressources",
"edit_game_modal_image_filter": "Image",
"edit_game_modal_icon_resolution": "Résolution recommandée : 256x256px",
"edit_game_modal_logo_resolution": "Résolution recommandée : 640x360px",
"edit_game_modal_hero_resolution": "Résolution recommandée : 1920x620px",
"edit_game_modal_assets": "Ressources",
"edit_game_modal_drop_icon_image_here": "Déposez limage de licône ici",
"edit_game_modal_drop_logo_image_here": "Déposez limage du logo ici",
"edit_game_modal_drop_hero_image_here": "Déposez limage de la bannière ici",
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer licône",
"edit_game_modal_drop_to_replace_logo": "Déposez pour remplacer le logo",
"edit_game_modal_drop_to_replace_hero": "Déposez pour remplacer la bannière",
"install_decky_plugin": "Installer le plugin Decky",
"update_decky_plugin": "Mettre à jour le plugin Decky",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"install_decky_plugin_title": "Installer le plugin Decky Hydra",
"install_decky_plugin_message": "Cela téléchargera et installera le plugin Hydra pour Decky Loader. Des permissions élevées peuvent être requises. Continuer ?",
"update_decky_plugin_title": "Mettre à jour le plugin Decky Hydra",
"update_decky_plugin_message": "Une nouvelle version du plugin Decky Hydra est disponible. Souhaitez-vous la mettre à jour maintenant ?",
"decky_plugin_installed": "Plugin Decky v{{version}} installé avec succès",
"decky_plugin_installation_failed": "Échec de linstallation du plugin Decky : {{error}}",
"decky_plugin_installation_error": "Erreur lors de linstallation du plugin Decky : {{error}}",
"confirm": "Confirmer",
"cancel": "Annuler"
},
"header": {
"search": "Rechercher",
@@ -37,7 +99,15 @@
"search_results": "Résultats de la recherche",
"settings": "Paramètres",
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger."
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.",
"search_library": "Rechercher dans la bibliothèque",
"recent_searches": "Recherches récentes",
"suggestions": "Suggestions",
"clear_history": "Effacer",
"remove_from_history": "Supprimer de l'historique",
"loading": "Chargement…",
"no_results": "Aucun résultat",
"library": "Bibliothèque"
},
"bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours",
@@ -47,7 +117,8 @@
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Installation terminée",
"installation_complete_message": "Redistribuables communs installés avec succès"
"installation_complete_message": "Redistribuables communs installés avec succès",
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
},
"catalogue": {
"search": "Filtrer…",
@@ -198,7 +269,113 @@
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
"game_removed_from_favorites": "Jeu retiré des favoris",
"game_added_to_favorites": "Jeu ajouté aux favoris",
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés"
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés",
"already_in_library": "Déjà dans la bibliothèque",
"create_shortcut_simple": "Créer un raccourci",
"properties": "Propriétés",
"extracting": "Extraction en cours",
"new_download_option": "Nouveau",
"create_steam_shortcut": "Créer un raccourci Steam",
"you_might_need_to_restart_steam": "Vous devrez peut-être redémarrer Steam pour voir les changements",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris",
"failed_update_favorites": "Échec de la mise à jour des favoris",
"game_removed_from_library": "Jeu retiré de la bibliothèque",
"failed_remove_from_library": "Échec de la suppression du jeu de la bibliothèque",
"files_removed_success": "Fichiers supprimés avec succès",
"failed_remove_files": "Échec de la suppression des fichiers",
"rating_count": "Évaluations",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"reviews": "Avis",
"review_played_for": "Temps de jeu",
"leave_a_review": "Laisser un avis",
"write_review_placeholder": "Partagez votre avis sur ce jeu…",
"sort_newest": "Les plus récents",
"sort_oldest": "Les plus anciens",
"sort_highest_score": "Meilleure note",
"sort_lowest_score": "Note la plus basse",
"sort_most_voted": "Les plus votés",
"no_reviews_yet": "Aucun avis pour le moment",
"be_first_to_review": "Soyez le premier à donner votre avis !",
"rating": "Note",
"rating_stats": "Évaluation",
"rating_very_negative": "Très négatif",
"rating_negative": "Négatif",
"rating_neutral": "Neutre",
"rating_positive": "Positif",
"rating_very_positive": "Très positif",
"submit_review": "Envoyer",
"submitting": "Envoi…",
"review_submitted_successfully": "Avis envoyé avec succès !",
"review_submission_failed": "Échec de lenvoi de lavis. Veuillez réessayer.",
"review_cannot_be_empty": "Le champ de lavis ne peut pas être vide.",
"review_deleted_successfully": "Avis supprimé avec succès.",
"review_deletion_failed": "Échec de la suppression de lavis.",
"loading_reviews": "Chargement des avis…",
"loading_more_reviews": "Chargement de plus davis…",
"load_more_reviews": "Charger plus davis",
"you_seemed_to_enjoy_this_game": "Vous semblez avoir apprécié ce jeu",
"would_you_recommend_this_game": "Souhaitez-vous laisser un avis sur ce jeu ?",
"yes": "Oui",
"maybe_later": "Peut-être plus tard",
"backup_failed": "Échec de la sauvegarde",
"update_playtime_title": "Mettre à jour le temps de jeu",
"update_playtime_description": "Mettre à jour manuellement le temps de jeu pour {{game}}",
"update_playtime": "Mettre à jour le temps de jeu",
"update_playtime_success": "Temps de jeu mis à jour avec succès",
"update_playtime_error": "Échec de la mise à jour du temps de jeu",
"update_game_playtime": "Mettre à jour le temps de jeu",
"manual_playtime_warning": "Vos heures seront marquées comme modifiées manuellement et cela ne peut pas être annulé.",
"manual_playtime_tooltip": "Ce temps de jeu a été modifié manuellement",
"game_removed_from_pinned": "Jeu retiré des épinglés",
"game_added_to_pinned": "Jeu ajouté aux épinglés",
"create_start_menu_shortcut": "Créer un raccourci dans le menu Démarrer",
"invalid_wine_prefix_path": "Chemin du préfixe Wine invalide",
"invalid_wine_prefix_path_description": "Le chemin du préfixe Wine est invalide. Veuillez vérifier et réessayer.",
"missing_wine_prefix": "Un préfixe Wine est requis pour créer une sauvegarde sous Linux",
"artifact_renamed": "Sauvegarde renommée avec succès",
"rename_artifact": "Renommer la sauvegarde",
"rename_artifact_description": "Renommez la sauvegarde avec un nom plus descriptif",
"artifact_name_label": "Nom de la sauvegarde",
"artifact_name_placeholder": "Entrez un nom pour la sauvegarde",
"save_changes": "Enregistrer les modifications",
"required_field": "Ce champ est requis",
"max_length_field": "Ce champ doit contenir moins de {{length}} caractères",
"freeze_backup": "Épingler pour éviter lécrasement automatique",
"unfreeze_backup": "Désépingler",
"backup_frozen": "Sauvegarde épinglée",
"backup_unfrozen": "Sauvegarde désépinglée",
"backup_freeze_failed": "Échec de lépinglage de la sauvegarde",
"backup_freeze_failed_description": "Vous devez laisser au moins un emplacement libre pour les sauvegardes automatiques",
"edit_game_modal_button": "Personnaliser les ressources du jeu",
"game_details": "Détails du jeu",
"prices": "Prix",
"no_prices_found": "Aucun prix trouvé",
"view_all_prices": "Cliquer pour voir tous les prix",
"retail_price": "Prix officiel",
"keyshop_price": "Prix Keyshop",
"historical_retail": "Historique officiel",
"historical_keyshop": "Historique Keyshop",
"language": "Langue",
"caption": "Sous-titres",
"audio": "Audio",
"filter_by_source": "Filtrer par source",
"no_repacks_found": "Aucune source trouvée pour ce jeu",
"delete_review": "Supprimer lavis",
"remove_review": "Retirer lavis",
"delete_review_modal_title": "Voulez-vous vraiment supprimer votre avis ?",
"delete_review_modal_description": "Cette action est irréversible.",
"delete_review_modal_delete_button": "Supprimer",
"delete_review_modal_cancel_button": "Annuler",
"vote_failed": "Échec de lenregistrement de votre vote. Veuillez réessayer.",
"show_original": "Afficher loriginal",
"show_translation": "Afficher la traduction",
"show_original_translated_from": "Afficher loriginal (traduit depuis {{language}})",
"hide_original": "Masquer loriginal",
"review_from_blocked_user": "Avis dun utilisateur bloqué",
"show": "Afficher",
"hide": "Masquer"
},
"activation": {
"title": "Activer Hydra",
@@ -237,7 +414,11 @@
"resume_seeding": "Reprendre le partage",
"options": "Gérer",
"extract": "Extraire les fichiers",
"extracting": "Extraction des fichiers…"
"extracting": "Extraction des fichiers…",
"delete_archive_title": "Voulez-vous supprimer {{fileName}} ?",
"delete_archive_description": "Le fichier a été extrait avec succès et nest plus nécessaire.",
"yes": "Oui",
"no": "Non"
},
"settings": {
"downloads_path": "Chemin des téléchargements",
@@ -366,7 +547,40 @@
"bottom-left": "En bas à gauche",
"bottom-center": "En bas au centre",
"bottom-right": "En bas à droite",
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu"
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu",
"adding": "Ajout…",
"failed_add_download_source": "Échec de lajout de la source de téléchargement. Veuillez réessayer.",
"download_source_already_exists": "Cette URL de source existe déjà",
"download_source_pending_matching": "Mise à jour imminente",
"download_source_matched": "À jour",
"download_source_matching": "Mise à jour",
"download_source_failed": "Erreur",
"download_source_no_information": "Aucune information disponible",
"removed_all_download_sources": "Toutes les sources de téléchargement supprimées",
"download_sources_synced_successfully": "Toutes les sources de téléchargement ont été synchronisées",
"importing": "Importation…",
"hydra_cloud": "Hydra Cloud",
"debrid": "Debrid",
"enable_steam_achievements": "Activer la recherche de succès Steam",
"alignment": "Alignement",
"variation": "Variation",
"default": "Par défaut",
"rare": "Rare",
"platinum": "Platine",
"hidden": "Caché",
"test_notification": "Notification de test",
"achievement_sound_volume": "Volume du son de succès",
"select_achievement_sound": "Sélectionner un son de succès",
"change_achievement_sound": "Changer le son de succès",
"remove_achievement_sound": "Supprimer le son de succès",
"preview_sound": "Prévisualiser le son",
"select": "Sélectionner",
"preview": "Aperçu",
"remove": "Supprimer",
"no_sound_file_selected": "Aucun fichier sonore sélectionné",
"notification_preview": "Aperçu de la notification de succès",
"autoplay_trailers_on_game_page": "Lire automatiquement les bandes-annonces sur la page du jeu",
"hide_to_tray_on_game_start": "Réduire Hydra dans la barre système au lancement dun jeu"
},
"notifications": {
"download_complete": "Téléchargement terminé",

View File

@@ -22,7 +22,7 @@
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"home": "Főoldal",
"queued": "A(z) {{title}} (Várakozósorban van)",
"queued": "{{title}} (Várakozásban)",
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés",
"friends": "Barátok",
@@ -94,6 +94,12 @@
"header": {
"search": "Keresés",
"search_library": "Könyvtár böngészése",
"recent_searches": "Korábbi Keresések",
"suggestions": "Találatok",
"clear_history": "Törlés",
"remove_from_history": "Törlés az előzményekből",
"loading": "Töltés...",
"no_results": "Nincs találat",
"home": "Főoldal",
"catalogue": "Katalógus",
"library": "Könyvtár",
@@ -109,6 +115,7 @@
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Telepítés befejezve",
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
@@ -165,7 +172,7 @@
"playing_now": "Játékban: ",
"change": "Változtatás",
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
"download_now": "Letöltés",
"no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók",
@@ -196,6 +203,7 @@
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
"download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve",
"extracting": "Kicsomagolás",
"last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új",
"create_steam_shortcut": "Steam parancsikon létrehozása",
@@ -397,7 +405,7 @@
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
"install": "Telepít",
"download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozósoron lévő letöltések",
"queued_downloads": "Várakozásban lévő letöltések",
"downloads_completed": "Befejezett",
"queued": "Várakozásban",
"no_downloads_title": "Oly üres..",
@@ -408,7 +416,11 @@
"resume_seeding": "Seedelés folytatása",
"options": "Kezelés",
"extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…"
"extracting": "Fájlok kibontása…",
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
"delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ",
"yes": "Igen",
"no": "Nem"
},
"settings": {
"downloads_path": "Letöltési útvonalak",
@@ -669,7 +681,7 @@
"no_blocked_users": "Nincs letiltott felhasználó",
"friend_code_copied": "Barát kód kimásolva",
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>",
"locked_profile": "Ez a profil privát",
"image_process_failure": "Hiba a kép feldolgozása közben",
"required_field": "Ez a mező kötelező",

View File

@@ -408,7 +408,9 @@
"delete_archive_title": "Deseja deletar {{fileName}}?",
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
"yes": "Sim",
"no": "Não"
"no": "Não",
"network": "REDE",
"peak": "PICO"
},
"settings": {
"downloads_path": "Diretório dos downloads",

View File

@@ -31,6 +31,11 @@ export const logsPath = path.join(
`logs${isStaging ? "-staging" : ""}`
);
export const screenshotsPath = path.join(
SystemPath.getPath("userData"),
"Screenshots"
);
export const achievementSoundPath = app.isPackaged
? path.join(process.resourcesPath, "achievement.wav")
: path.join(__dirname, "..", "..", "resources", "achievement.wav");

View File

@@ -1,4 +1,9 @@
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
import {
appVersion,
defaultDownloadsPath,
isStaging,
screenshotsPath,
} from "@main/constants";
import { ipcMain } from "electron";
import "./auth";
@@ -16,7 +21,6 @@ import "./themes";
import "./torrenting";
import "./user";
import "./user-preferences";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");
@@ -24,3 +28,4 @@ ipcMain.handle("getVersion", () => appVersion);
ipcMain.handle("isStaging", () => isStaging);
ipcMain.handle("isPortableVersion", () => isPortableVersion());
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
ipcMain.handle("getScreenshotsPath", () => screenshotsPath);

View File

@@ -0,0 +1,11 @@
import { shell } from "electron";
import { registerEvent } from "../register-event";
const openFolder = async (
_event: Electron.IpcMainInvokeEvent,
folderPath: string
) => {
return shell.openPath(folderPath);
};
registerEvent("openFolder", openFolder);

View File

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

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const deleteLocalNotification = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.deleteNotification(id);
};
registerEvent("deleteLocalNotification", deleteLocalNotification);

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotificationsCount = async () => {
return LocalNotificationManager.getUnreadCount();
};
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotifications = async () => {
return LocalNotificationManager.getNotifications();
};
registerEvent("getLocalNotifications", getLocalNotifications);

View File

@@ -1,3 +1,9 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";
import "./get-local-notifications";
import "./get-local-notifications-count";
import "./mark-local-notification-read";
import "./mark-all-local-notifications-read";
import "./delete-local-notification";
import "./clear-all-local-notifications";

View File

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

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markLocalNotificationRead = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.markAsRead(id);
};
registerEvent("markLocalNotificationRead", markLocalNotificationRead);

View File

@@ -1,4 +1,3 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile";

View File

@@ -1,24 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi, WindowManager } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import type { FriendRequestSync } from "@types";
export const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => {
WindowManager.mainWindow?.webContents.send(
"on-sync-friend-requests",
res
);
return res;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequestCount: 0 } as FriendRequestSync;
}
throw err;
});
};
registerEvent("syncFriendRequests", syncFriendRequests);

View File

@@ -41,7 +41,6 @@ const startGameDownload = async (
const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */
await downloadsSublevel.del(gameKey);
if (game) {
@@ -124,6 +123,42 @@ const startGameDownload = async (
}
if (err instanceof Error) {
if (downloader === Downloader.Buzzheavier) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "Buzzheavier: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "Buzzheavier: File not found",
};
}
}
if (downloader === Downloader.FuckingFast) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "FuckingFast: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "FuckingFast: File not found",
};
}
}
return { ok: false, error: err.message };
}

View File

@@ -3,6 +3,8 @@ import { registerEvent } from "../register-event";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
export const getUnlockedAchievements = async (
objectId: string,
@@ -31,6 +33,28 @@ export const getUnlockedAchievements = async (
const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? [];
let remoteUserAchievements: UserAchievement[] = [];
try {
const userDetails = await db.get<string, any>(levelKeys.user, {
valueEncoding: "json",
});
if (userDetails?.id) {
remoteUserAchievements = await HydraApi.get<UserAchievement[]>(
`/users/${userDetails.id}/games/achievements`,
{
shop,
objectId,
language: userPreferences?.language ?? "en",
}
);
}
} catch (error) {
if (!(error instanceof UserNotLoggedInError)) {
console.warn("Failed to fetch remote user achievements:", error);
}
}
return achievementsData
.map((achievementData) => {
const unlockedAchievementData = unlockedAchievements.find(
@@ -42,6 +66,16 @@ export const getUnlockedAchievements = async (
}
);
// Find corresponding remote achievement data for image URL
const remoteAchievementData = remoteUserAchievements.find(
(remoteAchievement) => {
return (
remoteAchievement.name.toUpperCase() ==
achievementData.name.toUpperCase()
);
}
);
const icongray = achievementData.icongray.endsWith("/")
? achievementData.icon
: achievementData.icongray;
@@ -51,6 +85,7 @@ export const getUnlockedAchievements = async (
...achievementData,
unlocked: true,
unlockTime: unlockedAchievementData.unlockTime,
imageUrl: remoteAchievementData?.imageUrl || null,
};
}
@@ -63,6 +98,7 @@ export const getUnlockedAchievements = async (
!achievementData.hidden || showHiddenAchievementsDescription
? achievementData.description
: undefined,
imageUrl: remoteAchievementData?.imageUrl || null,
};
})
.sort((a, b) => {

View File

@@ -1,4 +1,4 @@
// @generated by protobuf-ts 2.10.0
// @generated by protobuf-ts 2.11.1
// @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
*/
export interface FriendRequest {
/**
* @generated from protobuf field: int32 friend_request_count = 1;
* @generated from protobuf field: int32 friend_request_count = 1
*/
friendRequestCount: number;
/**
* @generated from protobuf field: optional string sender_id = 2;
* @generated from protobuf field: optional string sender_id = 2
*/
senderId?: string;
}
@@ -28,18 +28,27 @@ export interface FriendRequest {
*/
export interface FriendGameSession {
/**
* @generated from protobuf field: string object_id = 1;
* @generated from protobuf field: string object_id = 1
*/
objectId: string;
/**
* @generated from protobuf field: string shop = 2;
* @generated from protobuf field: string shop = 2
*/
shop: string;
/**
* @generated from protobuf field: string friend_id = 3;
* @generated from protobuf field: string friend_id = 3
*/
friendId: string;
}
/**
* @generated from protobuf message Notification
*/
export interface Notification {
/**
* @generated from protobuf field: int32 notification_count = 1
*/
notificationCount: number;
}
/**
* @generated from protobuf message Envelope
*/
@@ -51,17 +60,24 @@ export interface Envelope {
| {
oneofKind: "friendRequest";
/**
* @generated from protobuf field: FriendRequest friend_request = 1;
* @generated from protobuf field: FriendRequest friend_request = 1
*/
friendRequest: FriendRequest;
}
| {
oneofKind: "friendGameSession";
/**
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
* @generated from protobuf field: FriendGameSession friend_game_session = 2
*/
friendGameSession: FriendGameSession;
}
| {
oneofKind: "notification";
/**
* @generated from protobuf field: Notification notification = 3
*/
notification: Notification;
}
| {
oneofKind: undefined;
};
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
*/
export const FriendGameSession = new FriendGameSession$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Notification$Type extends MessageType<Notification> {
constructor() {
super("Notification", [
{
no: 1,
name: "notification_count",
kind: "scalar",
T: 5 /*ScalarType.INT32*/,
},
]);
}
create(value?: PartialMessage<Notification>): Notification {
const message = globalThis.Object.create(this.messagePrototype!);
message.notificationCount = 0;
if (value !== undefined)
reflectionMergePartial<Notification>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Notification
): Notification {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int32 notification_count */ 1:
message.notificationCount = reader.int32();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: Notification,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* int32 notification_count = 1; */
if (message.notificationCount !== 0)
writer.tag(1, WireType.Varint).int32(message.notificationCount);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message Notification
*/
export const Notification = new Notification$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> {
constructor() {
super("Envelope", [
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload",
T: () => FriendGameSession,
},
{
no: 3,
name: "notification",
kind: "message",
oneof: "payload",
T: () => Notification,
},
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
),
};
break;
case /* Notification notification */ 3:
message.payload = {
oneofKind: "notification",
notification: Notification.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).notification
),
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(2, WireType.LengthDelimited).fork(),
options
).join();
/* Notification notification = 3; */
if (message.payload.oneofKind === "notification")
Notification.internalBinaryWrite(
message.payload.notification,
writer.tag(3, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(

View File

@@ -8,3 +8,4 @@ export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";
export * from "./local-notifications";

View File

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

View File

@@ -0,0 +1,11 @@
import type { LocalNotification } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const localNotificationsSublevel = db.sublevel<
string,
LocalNotification
>(levelKeys.localNotifications, {
valueEncoding: "json",
});

View File

@@ -57,8 +57,10 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
// Check for new download options on startup (if enabled)
(async () => {
await DownloadSourcesChecker.checkForChanges();
})();
WSClient.connect();
});

View File

@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { fileTypeFromFile } from "file-type";
import { HydraApi } from "@main/services/hydra-api";
import { gameAchievementsSublevel, levelKeys, db } from "@main/level";
import { logger } from "@main/services/logger";
import type { GameShop, User } from "@types";
export class AchievementImageService {
private static async uploadImageToCDN(imagePath: string): Promise<string> {
const stat = fs.statSync(imagePath);
const fileBuffer = fs.readFileSync(imagePath);
const fileSizeInBytes = stat.size;
const response = await HydraApi.post<{
presignedUrl: string;
imageKey: string;
}>("/presigned-urls/achievement-image", {
imageExt: path.extname(imagePath).slice(1),
imageLength: fileSizeInBytes,
});
const mimeType = await fileTypeFromFile(imagePath);
await axios.put(response.presignedUrl, fileBuffer, {
headers: {
"Content-Type": mimeType?.mime,
},
});
return response.imageKey;
}
private static async storeImageLocally(imagePath: string): Promise<string> {
const fileBuffer = fs.readFileSync(imagePath);
const base64Image = fileBuffer.toString("base64");
const mimeType = await fileTypeFromFile(imagePath);
return `data:${mimeType?.mime || "image/jpeg"};base64,${base64Image}`;
}
private static async hasActiveSubscription(): Promise<boolean> {
return db
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
.then((user) => {
const expiresAt = new Date(user?.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
})
.catch(() => false);
}
private static async updateLocalAchievementData(
shop: GameShop,
gameId: string,
imageUrl: string
): Promise<void> {
const achievementKey = levelKeys.game(shop, gameId);
const existingData = await gameAchievementsSublevel
.get(achievementKey)
.catch(() => null);
if (existingData) {
await gameAchievementsSublevel.put(achievementKey, {
...existingData,
imageUrl,
});
}
}
private static cleanupImageFile(imagePath: string): void {
try {
fs.unlinkSync(imagePath);
} catch (error) {
logger.error(`Failed to cleanup screenshot file ${imagePath}:`, error);
}
}
/**
* Uploads an achievement image either to CDN (for subscribers) or stores locally
* @param gameId - The game identifier
* @param achievementName - The achievement name
* @param imagePath - Path to the image file to upload
* @param shop - The game shop (optional)
* @returns Promise with success status and imageKey (for subscribers) or imageUrl (for non-subscribers)
*/
static async uploadAchievementImage(
gameId: string,
achievementName: string,
imagePath: string
): Promise<{ success: boolean; imageKey?: string; imageUrl?: string }> {
try {
const hasSubscription = await this.hasActiveSubscription();
if (hasSubscription) {
const imageKey = await this.uploadImageToCDN(imagePath);
logger.log(
`Achievement image uploaded to CDN for ${gameId}:${achievementName}`
);
return { success: true, imageKey };
} else {
const imageUrl = await this.storeImageLocally(imagePath);
logger.log(
`Achievement image stored locally for ${gameId}:${achievementName}`
);
return { success: true, imageUrl };
}
} catch (error) {
logger.error(
`Failed to upload achievement image for ${gameId}:${achievementName}:`,
error
);
throw error;
}
}
/**
* Uploads achievement image and updates local database, with automatic cleanup
* @param gameId - The game identifier
* @param achievementName - The achievement name
* @param imagePath - Path to the image file to upload
* @param shop - The game shop
* @returns Promise with success status and imageKey or imageUrl
*/
static async uploadAndUpdateAchievementImage(
gameId: string,
achievementName: string,
imagePath: string,
shop: GameShop
): Promise<{ success: boolean; imageKey?: string; imageUrl?: string }> {
try {
const result = await this.uploadAchievementImage(
gameId,
achievementName,
imagePath
);
if (result.imageUrl) {
await this.updateLocalAchievementData(shop, gameId, result.imageUrl);
}
this.cleanupImageFile(imagePath);
return result;
} catch (error) {
this.cleanupImageFile(imagePath);
throw error;
}
}
}

View File

@@ -13,7 +13,7 @@ const getModifiedSinceHeader = (
return undefined;
}
if (userLanguage != cachedAchievements.language) {
if (userLanguage !== cachedAchievements.language) {
return undefined;
}

View File

@@ -15,6 +15,8 @@ import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { getGameAchievementData } from "./get-game-achievement-data";
import { AchievementWatcherManager } from "./achievement-watcher-manager";
import { ScreenshotService } from "../screenshot";
import { AchievementImageService } from "./achievement-image-service";
const isRareAchievement = (points: number) => {
const rawPercentage = (50 - Math.sqrt(points)) * 2;
@@ -53,11 +55,8 @@ const saveAchievementsOnLocal = async (
});
};
export const mergeAchievements = async (
game: Game,
achievements: UnlockedAchievement[],
publishNotification: boolean
) => {
// Helpers extracted to lower cognitive complexity
const getLocalData = async (game: Game) => {
const gameKey = levelKeys.game(game.shop, game.objectId);
let localGameAchievement = await gameAchievementsSublevel.get(gameKey);
@@ -73,11 +72,20 @@ export const mergeAchievements = async (
localGameAchievement = await gameAchievementsSublevel.get(gameKey);
}
const achievementsData = localGameAchievement?.achievements ?? [];
const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? [];
return {
achievementsData: localGameAchievement?.achievements ?? [],
unlockedAchievements: localGameAchievement?.unlockedAchievements ?? [],
userPreferences,
gameKey,
};
};
const computeNewAndMergedAchievements = (
incoming: UnlockedAchievement[],
unlockedAchievements: UnlockedAchievement[]
) => {
const newAchievementsMap = new Map(
achievements.toReversed().map((achievement) => {
incoming.toReversed().map((achievement) => {
return [achievement.name.toUpperCase(), achievement];
})
);
@@ -97,68 +105,154 @@ export const mergeAchievements = async (
};
});
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
return {
newAchievements,
mergedLocalAchievements: unlockedAchievements.concat(newAchievements),
};
};
const publishAchievementNotificationIfNeeded = (
game: Game,
newAchievements: UnlockedAchievement[],
unlockedAchievements: UnlockedAchievement[],
achievementsData: any[],
userPreferences: UserPreferences,
mergedLocalCount: number,
publishNotification: boolean
) => {
if (
newAchievements.length &&
publishNotification &&
userPreferences.achievementNotificationsEnabled !== false
!newAchievements.length ||
!publishNotification ||
userPreferences.achievementNotificationsEnabled === false
) {
const filteredAchievements = newAchievements
.toSorted((a, b) => {
return a.unlockTime - b.unlockTime;
})
.map((achievement) => {
return achievementsData.find((steamAchievement) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
});
})
.filter((achievement) => !!achievement);
const achievementsInfo: AchievementNotificationInfo[] =
filteredAchievements.map((achievement, index) => {
return {
title: achievement.displayName,
description: achievement.description,
points: achievement.points,
isHidden: achievement.hidden,
isRare: achievement.points
? isRareAchievement(achievement.points)
: false,
isPlatinum:
index === filteredAchievements.length - 1 &&
newAchievements.length + unlockedAchievements.length ===
achievementsData.length,
iconUrl: achievement.icon,
};
});
achievementsLogger.log(
"Publishing achievement notification",
game.objectId,
game.title
);
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementsInfo
);
} else {
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
}
return;
}
const filteredAchievements = newAchievements
.toSorted((a, b) => a.unlockTime - b.unlockTime)
.map((achievement) => {
return achievementsData.find((steamAchievement: any) => {
return (
achievement.name.toUpperCase() === steamAchievement.name.toUpperCase()
);
});
})
.filter((achievement) => !!achievement);
const achievementsInfo: AchievementNotificationInfo[] =
filteredAchievements.map((achievement: any, index: number) => {
return {
title: achievement.displayName,
description: achievement.description,
points: achievement.points,
isHidden: achievement.hidden,
isRare: achievement.points
? isRareAchievement(achievement.points)
: false,
isPlatinum:
index === filteredAchievements.length - 1 &&
newAchievements.length + unlockedAchievements.length ===
achievementsData.length,
iconUrl: achievement.icon,
};
});
achievementsLogger.log(
"Publishing achievement notification",
game.objectId,
game.title
);
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementsInfo
);
} else {
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalCount,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
}
};
const addImagesToNewAchievementsIfEnabled = async (
newAchievements: UnlockedAchievement[],
achievementsData: any[],
mergedLocalAchievements: UnlockedAchievement[],
game: Game,
userPreferences: UserPreferences
): Promise<UnlockedAchievement[]> => {
const achievementsWithImages = [...mergedLocalAchievements];
if (
!newAchievements.length ||
userPreferences.enableAchievementScreenshots !== true
) {
return achievementsWithImages;
}
try {
for (const achievement of newAchievements) {
try {
const achievementData = achievementsData.find(
(steamAchievement: any) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
}
);
const achievementDisplayName =
achievementData?.displayName || achievement.name;
const screenshotPath = await ScreenshotService.captureDesktopScreenshot(
game.title,
achievementDisplayName
);
const uploadResult =
await AchievementImageService.uploadAchievementImage(
game.objectId,
achievement.name,
screenshotPath
);
const achievementIndex = achievementsWithImages.findIndex(
(a) => a.name.toUpperCase() === achievement.name.toUpperCase()
);
if (achievementIndex !== -1 && uploadResult.imageKey) {
achievementsWithImages[achievementIndex] = {
...achievementsWithImages[achievementIndex],
imageKey: uploadResult.imageKey,
};
}
} catch (error) {
achievementsLogger.error("Failed to upload achievement image", error);
}
}
} catch (error) {
achievementsLogger.error(
"Failed to capture screenshot for achievement",
error
);
}
return achievementsWithImages;
};
const syncAchievements = async (
game: Game,
publishNotification: boolean,
achievementsWithImages: UnlockedAchievement[],
newAchievements: UnlockedAchievement[],
gameKey: string
) => {
const shouldSyncWithRemote =
game.remoteId &&
(newAchievements.length || AchievementWatcherManager.hasFinishedPreSearch);
@@ -168,26 +262,26 @@ export const mergeAchievements = async (
"/profile/games/achievements",
{
id: game.remoteId,
achievements: mergedLocalAchievements,
achievements: achievementsWithImages,
},
{ needsSubscription: !newAchievements.length }
)
.then((response) => {
.then(async (response) => {
if (response) {
return saveAchievementsOnLocal(
await saveAchievementsOnLocal(
response.objectId,
response.shop,
response.achievements,
publishNotification
);
} else {
await saveAchievementsOnLocal(
game.objectId,
game.shop,
achievementsWithImages,
publishNotification
);
}
return saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
publishNotification
);
})
.catch((err) => {
if (err instanceof SubscriptionRequiredError) {
@@ -201,7 +295,7 @@ export const mergeAchievements = async (
return saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
achievementsWithImages,
publishNotification
);
})
@@ -212,10 +306,48 @@ export const mergeAchievements = async (
await saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
achievementsWithImages,
publishNotification
);
}
};
export const mergeAchievements = async (
game: Game,
achievements: UnlockedAchievement[],
publishNotification: boolean
) => {
const { achievementsData, unlockedAchievements, userPreferences, gameKey } =
await getLocalData(game);
const { newAchievements, mergedLocalAchievements } =
computeNewAndMergedAchievements(achievements, unlockedAchievements);
publishAchievementNotificationIfNeeded(
game,
newAchievements,
unlockedAchievements,
achievementsData,
userPreferences,
mergedLocalAchievements.length,
publishNotification
);
const achievementsWithImages = await addImagesToNewAchievementsIfEnabled(
newAchievements,
achievementsData,
mergedLocalAchievements,
game,
userPreferences
);
await syncAchievements(
game,
publishNotification,
achievementsWithImages,
newAchievements,
gameKey
);
return newAchievements.length;
};

View File

@@ -5,10 +5,12 @@ import {
updateDownloadSourcesCheckBaseline,
updateDownloadSourcesSinceValue,
downloadSourcesSublevel,
db,
levelKeys,
} from "@main/level";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
import type { Game } from "@types";
import type { Game, UserPreferences } from "@types";
interface DownloadSourcesChangeResponse {
shop: string;
@@ -101,6 +103,20 @@ export class DownloadSourcesChecker {
logger.info("DownloadSourcesChecker.checkForChanges() called");
try {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (userPreferences?.enableNewDownloadOptionsBadges === false) {
logger.info(
"New download options badges are disabled, skipping download sources check"
);
return;
}
// Get all installed games (excluding custom games)
const installedGames = await gamesSublevel.values().all();
const nonCustomGames = installedGames.filter(

View File

@@ -20,14 +20,84 @@ import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { orderBy } from "lodash-es";
import { sortBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
export class DownloadManager {
private static downloadingGameId: string | null = null;
private static extractFilename(
url: string,
originalUrl?: string
): string | undefined {
if (originalUrl?.includes("#")) {
const hashPart = originalUrl.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
}
if (url.includes("#")) {
const hashPart = url.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
}
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts[pathParts.length - 1];
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
} catch {
// Invalid URL
}
return undefined;
}
private static sanitizeFilename(filename: string): string {
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
}
private static createDownloadPayload(
directUrl: string,
originalUrl: string,
downloadId: string,
savePath: string
) {
const filename =
this.extractFilename(originalUrl, directUrl) ||
this.extractFilename(directUrl);
const sanitizedFilename = filename
? this.sanitizeFilename(filename)
: undefined;
if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
} else {
logger.log(
`[DownloadManager] No filename extracted, aria2 will use default`
);
}
return {
action: "start" as const,
game_id: downloadId,
url: directUrl,
save_path: savePath,
out: sanitizedFilename,
allow_multiple_connections: true,
};
}
public static async startRPC(
download?: Download,
downloadsToSeed?: Download[]
@@ -121,15 +191,19 @@ export class DownloadManager {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
{ valueEncoding: "json" }
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
);
}
const shouldExtract = download.automaticallyExtract;
// Handle download completion BEFORE sending progress to renderer
// This ensures extraction starts and DB is updated before UI reacts
if (progress === 1 && download) {
publishDownloadCompleteNotification(game);
@@ -143,7 +217,6 @@ export class DownloadManager {
shouldSeed: true,
queued: false,
extracting: shouldExtract,
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
});
} else {
await downloadsSublevel.put(gameId, {
@@ -152,22 +225,12 @@ export class DownloadManager {
shouldSeed: false,
queued: false,
extracting: shouldExtract,
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
});
this.cancelDownload(gameId);
}
if (shouldExtract) {
// Send initial extraction progress BEFORE download progress
// This ensures the UI shows extraction immediately
WindowManager.mainWindow?.webContents.send(
"on-extraction-progress",
game.shop,
game.objectId,
0
);
const gameFilesManager = new GameFilesManager(
game.shop,
game.objectId
@@ -179,27 +242,25 @@ export class DownloadManager {
)
) {
gameFilesManager.extractDownloadedFile();
} else {
} else if (download.folderName) {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName!)
path.join(download.downloadPath, download.folderName)
)
.then(() => {
gameFilesManager.setExtractionComplete();
});
.then(() => gameFilesManager.setExtractionComplete());
}
}
const downloads = await downloadsSublevel
.values()
.all()
.then((games) => {
return orderBy(
.then((games) =>
sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"desc"
);
});
"DESC"
)
);
const [nextItemOnQueue] = downloads;
@@ -209,18 +270,6 @@ export class DownloadManager {
this.downloadingGameId = null;
}
}
// Send progress to renderer after completion handling
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
structuredClone({
...status,
game,
})
);
}
}
}
@@ -279,13 +328,8 @@ export class DownloadManager {
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", {
action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
@@ -318,7 +362,6 @@ export class DownloadManager {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink);
return {
@@ -360,9 +403,50 @@ export class DownloadManager {
save_path: download.downloadPath,
};
}
case Downloader.Buzzheavier: {
logger.log(
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
);
try {
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(
`[DownloadManager] Error processing Buzzheavier download:`,
error
);
throw error;
}
}
case Downloader.FuckingFast: {
logger.log(
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
);
try {
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(
`[DownloadManager] Error processing FuckingFast download:`,
error
);
throw error;
}
}
case Downloader.Mediafire: {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
@@ -379,7 +463,6 @@ export class DownloadManager {
};
case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
return {
@@ -392,7 +475,6 @@ export class DownloadManager {
}
case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return;
return {
action: "start",
@@ -407,7 +489,6 @@ export class DownloadManager {
const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri
);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return {

View File

@@ -0,0 +1,86 @@
import axios from "axios";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
handleHosterError,
} from "./fuckingfast";
import { logger } from "@main/services";
export class BuzzheavierApi {
private static readonly BUZZHEAVIER_DOMAINS = [
"buzzheavier.com",
"bzzhr.co",
"fuckingfast.net",
];
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
try {
const baseUrl = url.split("#")[0];
logger.log(
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
);
await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const downloadUrl = `${baseUrl}/download`;
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
const headResponse = await axios.head(downloadUrl, {
headers: {
"hx-current-url": baseUrl,
"hx-request": "true",
referer: baseUrl,
"User-Agent": HOSTER_USER_AGENT,
},
maxRedirects: 0,
validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000,
});
const hxRedirect = headResponse.headers["hx-redirect"];
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
if (!hxRedirect) {
logger.error(
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
);
throw new Error(
"Could not extract download link. File may be deleted or is a directory."
);
}
const domain = new URL(baseUrl).hostname;
const directLink = hxRedirect.startsWith("/dl/")
? `https://${domain}${hxRedirect}`
: hxRedirect;
logger.log(`[Buzzheavier] Extracted direct link`);
return directLink;
} catch (error) {
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
);
}
return this.getBuzzheavierDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -0,0 +1,129 @@
import axios from "axios";
import { logger } from "@main/services";
export const HOSTER_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
export async function extractHosterFilename(
url: string,
directUrl?: string
): Promise<string> {
if (url.includes("#")) {
const fragment = url.split("#")[1];
if (fragment && !fragment.startsWith("http")) {
return fragment;
}
}
if (directUrl) {
try {
const response = await axios.head(directUrl, {
timeout: 10000,
headers: { "User-Agent": HOSTER_USER_AGENT },
});
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].replace(/['"]/g, "");
}
}
} catch {
// Ignore errors
}
const urlPath = new URL(directUrl).pathname;
const filename = urlPath.split("/").pop()?.split("?")[0];
if (filename) {
return filename;
}
}
return "downloaded_file";
}
export function handleHosterError(error: unknown): never {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new Error("File not found");
}
if (error.response?.status === 429) {
throw new Error("Rate limit exceeded. Please try again later.");
}
if (error.response?.status === 403) {
throw new Error("Access denied. File may be private or deleted.");
}
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
}
throw error;
}
// ============================================
// FuckingFast API Class
// ============================================
export class FuckingFastApi {
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
private static readonly FUCKINGFAST_REGEX =
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getFuckingFastDirectLink(url: string): Promise<string> {
try {
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
const response = await axios.get(url, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const html = response.data;
if (html.toLowerCase().includes("rate limit")) {
logger.error(`[FuckingFast] Rate limit detected`);
throw new Error(
"Rate limit exceeded. Please wait a few minutes and try again."
);
}
if (html.includes("File Not Found Or Deleted")) {
logger.error(`[FuckingFast] File not found or deleted`);
throw new Error("File not found or deleted");
}
const match = this.FUCKINGFAST_REGEX.exec(html);
if (!match || !match[1]) {
logger.error(`[FuckingFast] Could not extract download link`);
throw new Error("Could not extract download link from page");
}
logger.log(`[FuckingFast] Successfully extracted direct link`);
return match[1];
} catch (error) {
logger.error(`[FuckingFast] Error:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
);
}
return this.getFuckingFastDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -3,3 +3,5 @@ export * from "./qiwi";
export * from "./datanodes";
export * from "./mediafire";
export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./fuckingfast";

View File

@@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = false;
private static readonly ADD_LOG_INTERCEPTOR = true;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;

View File

@@ -13,6 +13,7 @@ export * from "./game-files-manager";
export * from "./common-redist-manager";
export * from "./aria2";
export * from "./ws";
export * from "./screenshot";
export * from "./system-path";
export * from "./library-sync";
export * from "./wine";
@@ -20,3 +21,4 @@ export * from "./lock";
export * from "./decky-plugin";
export * from "./user";
export * from "./download-sources-checker";
export * from "./notifications/local-notifications";

View File

@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image";
import { LocalNotificationManager } from "./local-notifications";
const getStaticImage = async (path: string) => {
return processProfileImage(path, "jpg")
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
}
);
const title = t("download_complete", { ns: "notifications" });
const body = t("game_ready_to_install", {
ns: "notifications",
title: game.title,
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
title,
body,
icon: await downloadImage(game.iconUrl),
}).show();
}
// Create local notification
await LocalNotificationManager.createNotification(
"DOWNLOAD_COMPLETE",
title,
body,
{
pictureUrl: game.iconUrl,
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
const title = t("new_update_available", {
ns: "notifications",
version,
});
const body = t("restart_to_install_update", {
ns: "notifications",
});
new Notification({
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
title,
body,
icon: trayIcon,
})
.on("click", () => {
restartAndInstallUpdate();
})
.show();
// Create local notification
await LocalNotificationManager.createNotification(
"UPDATE_AVAILABLE",
title,
body
);
};
export const publishNewFriendRequestNotification = async (
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
};
export const publishExtractionCompleteNotification = async (game: Game) => {
const title = t("extraction_complete", { ns: "notifications" });
const body = t("game_extracted", {
ns: "notifications",
title: game.title,
});
new Notification({
title: t("extraction_complete", { ns: "notifications" }),
body: t("game_extracted", {
ns: "notifications",
title: game.title,
}),
title,
body,
icon: trayIcon,
}).show();
// Create local notification
await LocalNotificationManager.createNotification(
"EXTRACTION_COMPLETE",
title,
body,
{
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNewAchievementNotification = async (info: {

View File

@@ -0,0 +1,99 @@
import { localNotificationsSublevel } from "@main/level";
import { WindowManager } from "../window-manager";
import type { LocalNotification, LocalNotificationType } from "@types";
import crypto from "node:crypto";
export class LocalNotificationManager {
private static generateId(): string {
return crypto.randomBytes(8).toString("hex");
}
static async createNotification(
type: LocalNotificationType,
title: string,
description: string,
options?: {
pictureUrl?: string | null;
url?: string | null;
}
): Promise<LocalNotification> {
const id = this.generateId();
const notification: LocalNotification = {
id,
type,
title,
description,
pictureUrl: options?.pictureUrl ?? null,
url: options?.url ?? null,
isRead: false,
createdAt: new Date().toISOString(),
};
await localNotificationsSublevel.put(id, notification);
// Notify renderer about new notification
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send(
"on-local-notification-created",
notification
);
}
return notification;
}
static async getNotifications(): Promise<LocalNotification[]> {
const notifications: LocalNotification[] = [];
for await (const [, value] of localNotificationsSublevel.iterator()) {
notifications.push(value);
}
// Sort by createdAt descending
return notifications.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
static async getUnreadCount(): Promise<number> {
let count = 0;
for await (const [, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
count++;
}
}
return count;
}
static async markAsRead(id: string): Promise<void> {
const notification = await localNotificationsSublevel.get(id);
if (notification) {
notification.isRead = true;
await localNotificationsSublevel.put(id, notification);
}
}
static async markAllAsRead(): Promise<void> {
const batch = localNotificationsSublevel.batch();
for await (const [key, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
value.isRead = true;
batch.put(key, value);
}
}
await batch.write();
}
static async deleteNotification(id: string): Promise<void> {
await localNotificationsSublevel.del(id);
}
static async clearAll(): Promise<void> {
await localNotificationsSublevel.clear();
}
}

View File

@@ -0,0 +1,179 @@
import { desktopCapturer, nativeImage, app } from "electron";
import fs from "node:fs";
import path from "node:path";
import { logger } from "./logger";
import { screenshotsPath } from "@main/constants";
export class ScreenshotService {
private static readonly SCREENSHOT_QUALITY = 80;
private static readonly SCREENSHOT_FORMAT = "jpeg";
private static readonly MAX_WIDTH = 1280;
private static readonly MAX_HEIGHT = 720;
private static compressImage(
image: Electron.NativeImage
): Electron.NativeImage {
const size = image.getSize();
let newWidth = size.width;
let newHeight = size.height;
if (newWidth > this.MAX_WIDTH || newHeight > this.MAX_HEIGHT) {
const aspectRatio = newWidth / newHeight;
if (newWidth > newHeight) {
newWidth = this.MAX_WIDTH;
newHeight = Math.round(newWidth / aspectRatio);
} else {
newHeight = this.MAX_HEIGHT;
newWidth = Math.round(newHeight * aspectRatio);
}
}
if (newWidth !== size.width || newHeight !== size.height) {
return image.resize({ width: newWidth, height: newHeight });
}
return image;
}
public static async captureDesktopScreenshot(
gameTitle?: string,
achievementName?: string
): Promise<string> {
try {
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: 1920, height: 1080 },
});
if (sources.length === 0) {
throw new Error("No desktop sources available for screenshot");
}
console.log("sources", sources);
const primaryScreen = sources[0];
const originalImage = nativeImage.createFromDataURL(
primaryScreen.thumbnail.toDataURL()
);
const compressedImage = this.compressImage(originalImage);
let finalDir = screenshotsPath;
let filename: string;
if (gameTitle && achievementName) {
const sanitizedGameTitle = gameTitle.replaceAll(/[<>:"/\\|?*]/g, "_");
const gameDir = path.join(screenshotsPath, sanitizedGameTitle);
finalDir = gameDir;
const sanitizedAchievementName = achievementName.replaceAll(
/[<>:"/\\|?*]/g,
"_"
);
filename = `${sanitizedAchievementName}.${this.SCREENSHOT_FORMAT}`;
} else {
const timestamp = Date.now();
filename = `achievement_screenshot_${timestamp}.${this.SCREENSHOT_FORMAT}`;
}
if (!fs.existsSync(finalDir)) {
fs.mkdirSync(finalDir, { recursive: true });
}
const screenshotPath = path.join(finalDir, filename);
const jpegBuffer = compressedImage.toJPEG(this.SCREENSHOT_QUALITY);
fs.writeFileSync(screenshotPath, jpegBuffer);
logger.log(`Compressed screenshot saved to: ${screenshotPath}`);
return screenshotPath;
} catch (error) {
logger.error("Failed to capture desktop screenshot:", error);
throw error;
}
}
public static async cleanupOldScreenshots(): Promise<void> {
try {
const userDataPath = app.getPath("userData");
const screenshotsDir = path.join(userDataPath, "screenshots");
if (!fs.existsSync(screenshotsDir)) {
return;
}
const getAllFiles = (
dir: string
): Array<{ name: string; path: string; mtime: Date }> => {
const files: Array<{ name: string; path: string; mtime: Date }> = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
files.push(...getAllFiles(itemPath));
} else if (item.endsWith(`.${this.SCREENSHOT_FORMAT}`)) {
files.push({
name: item,
path: itemPath,
mtime: stat.mtime,
});
}
}
return files;
};
const allFiles = getAllFiles(screenshotsDir).sort(
(a, b) => b.mtime.getTime() - a.mtime.getTime()
);
const filesToDelete = allFiles.slice(50);
for (const file of filesToDelete) {
try {
fs.unlinkSync(file.path);
logger.log(`Cleaned up old screenshot: ${file.name}`);
} catch (error) {
logger.error(`Failed to delete screenshot ${file.name}:`, error);
}
}
const cleanupEmptyDirs = (dir: string) => {
if (dir === screenshotsDir) return;
try {
const items = fs.readdirSync(dir);
if (items.length === 0) {
fs.rmdirSync(dir);
logger.log(`Cleaned up empty directory: ${dir}`);
}
} catch (error) {
logger.error(`Failed to read directory ${dir}:`, error);
}
};
const gameDirectories = fs
.readdirSync(screenshotsDir)
.map((item) => path.join(screenshotsDir, item))
.filter((itemPath) => {
try {
return fs.statSync(itemPath).isDirectory();
} catch {
return false;
}
});
for (const gameDir of gameDirectories) {
cleanupEmptyDirs(gameDir);
}
} catch (error) {
logger.error("Failed to cleanup old screenshots:", error);
}
}
}

View File

@@ -1,4 +1,9 @@
import { User, type ProfileVisibility, type UserDetails } from "@types";
import {
User,
type ProfileVisibility,
type UserDetails,
type UserPreferences,
} from "@types";
import { HydraApi } from "../hydra-api";
import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger";
@@ -6,7 +11,24 @@ import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
export const getUserData = async () => {
return HydraApi.get<UserDetails>(`/profile/me`)
let language = "en";
try {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
if (userPreferences?.language) {
const supportedLanguages = ["pt", "ru", "es"];
const userLang = userPreferences.language.split("-")[0];
language = supportedLanguages.includes(userLang) ? userLang : "en";
}
} catch (error) {
logger.error("Failed to get user preferences for language", error);
}
const params = new URLSearchParams({ language });
return HydraApi.get<UserDetails>(`/profile/me?${params.toString()}`)
.then(async (me) => {
try {
const user = await db.get<string, User>(levelKeys.user, {

View File

@@ -36,9 +36,9 @@ export class WindowManager {
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{
width: 1200,
height: 720,
height: 860,
minWidth: 1024,
minHeight: 540,
minHeight: 860,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon,
@@ -106,7 +106,7 @@ export class WindowManager {
valueEncoding: "json",
}
);
return data ?? { isMaximized: false, height: 720, width: 1200 };
return data ?? { isMaximized: false, height: 860, width: 1200 };
}
private static updateInitialConfig(
@@ -224,7 +224,7 @@ export class WindowManager {
? {
x: undefined,
y: undefined,
height: this.initialConfigInitializationMainWindow.height ?? 720,
height: this.initialConfigInitializationMainWindow.height ?? 860,
width: this.initialConfigInitializationMainWindow.width ?? 1200,
isMaximized: true,
}

View File

@@ -0,0 +1,8 @@
import type { Notification } from "@main/generated/envelope";
import { WindowManager } from "@main/services/window-manager";
export const notificationEvent = (payload: Notification) => {
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
notificationCount: payload.notificationCount,
});
};

View File

@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
import { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session";
import { notificationEvent } from "./events/notification";
export class WSClient {
private static ws: WebSocket | null = null;
@@ -51,6 +52,10 @@ export class WSClient {
if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession);
}
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}
});
this.ws.on("close", () => this.handleDisconnect("close"));

View File

@@ -15,6 +15,7 @@ import type {
GameAchievement,
Theme,
FriendRequestSync,
NotificationSync,
ShortcutLocation,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
@@ -362,9 +363,11 @@ contextBridge.exposeInMainWorld("electron", {
ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
getScreenshotsPath: () => ipcRenderer.invoke("getScreenshotsPath"),
isStaging: () => ipcRenderer.invoke("isStaging"),
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
openFolder: (path: string) => ipcRenderer.invoke("openFolder", path),
openCheckout: () => ipcRenderer.invoke("openCheckout"),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
@@ -497,7 +500,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -507,6 +509,15 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-sync-friend-requests", listener);
},
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: NotificationSync
) => cb(notification);
ipcRenderer.on("on-sync-notification-count", listener);
return () =>
ipcRenderer.removeListener("on-sync-notification-count", listener);
},
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
@@ -550,6 +561,26 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
getLocalNotificationsCount: () =>
ipcRenderer.invoke("getLocalNotificationsCount"),
markLocalNotificationRead: (id: string) =>
ipcRenderer.invoke("markLocalNotificationRead", id),
markAllLocalNotificationsRead: () =>
ipcRenderer.invoke("markAllLocalNotificationsRead"),
deleteLocalNotification: (id: string) =>
ipcRenderer.invoke("deleteLocalNotification", id),
clearAllLocalNotifications: () =>
ipcRenderer.invoke("clearAllLocalNotifications"),
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: unknown
) => cb(notification);
ipcRenderer.on("on-local-notification-created", listener);
return () =>
ipcRenderer.removeListener("on-local-notification-created", listener);
},
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,

View File

@@ -47,6 +47,17 @@ button {
font-family: inherit;
}
dialog {
padding: 0;
margin: 0;
border: none;
background: transparent;
max-width: none;
max-height: none;
width: auto;
height: auto;
}
h1,
h2,
h3,

View File

@@ -23,7 +23,6 @@ import {
clearExtraction,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
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 { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
@@ -54,12 +53,7 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const {
userDetails,
hasActiveSubscription,
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
hideFriendsModal,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
@@ -135,7 +129,6 @@ export function App() {
.then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
}
})
.finally(() => {
@@ -152,7 +145,6 @@ export function App() {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
showSuccessToast(t("successfully_signed_in"));
}
});
@@ -305,15 +297,6 @@ export function App() {
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main>
<Sidebar />

View File

@@ -0,0 +1,133 @@
.fullscreen-image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
cursor: pointer;
&__backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
&__container {
position: relative;
max-width: 95vw;
max-height: 95vh;
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
&__close-button {
position: fixed;
top: 52px;
right: 32px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: background-color 0.2s ease;
z-index: 10000;
pointer-events: auto;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&__image-container {
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
max-height: 100%;
}
&__image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
animation: scaleIn 0.2s ease-out;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 768px) {
.fullscreen-image-modal {
&__close-button {
top: 16px;
right: 16px;
width: 36px;
height: 36px;
}
&__container {
max-width: 100vw;
max-height: 100vh;
padding: 48px 16px 16px;
}
}
}
@media (max-height: 420px) {
.fullscreen-image-modal {
&__close-button {
top: 8px;
right: 8px;
width: 32px;
height: 32px;
}
&__container {
padding-top: 40px;
}
}
}

View File

@@ -0,0 +1,66 @@
import { useEffect } from "react";
import { XIcon } from "@primer/octicons-react";
import "./fullscreen-image-modal.scss";
interface FullscreenImageModalProps {
isOpen: boolean;
imageUrl: string;
imageAlt: string;
onClose: () => void;
}
export function FullscreenImageModal({
isOpen,
imageUrl,
imageAlt,
onClose,
}: Readonly<FullscreenImageModalProps>) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<dialog className="fullscreen-image-modal" aria-modal="true" open>
<button
type="button"
className="fullscreen-image-modal__backdrop"
onClick={onClose}
aria-label="Close fullscreen image"
/>
<div className="fullscreen-image-modal__container">
<button
className="fullscreen-image-modal__close-button"
onClick={onClose}
aria-label="Close fullscreen image"
>
<XIcon size={24} />
</button>
<div className="fullscreen-image-modal__image-container">
<img
src={imageUrl}
alt={imageAlt}
className="fullscreen-image-modal__image"
loading="eager"
/>
</div>
</div>
</dialog>
);
}

View File

@@ -0,0 +1 @@
export { FullscreenImageModal } from "./fullscreen-image-modal";

View File

@@ -0,0 +1,82 @@
@use "../../scss/globals.scss";
.fullscreen-media-modal__overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(2px);
z-index: globals.$backdrop-z-index;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
}
.fullscreen-media-modal {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: relative;
margin: 0;
padding: 0;
border: none;
background: transparent;
max-width: none;
max-height: none;
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 4);
cursor: pointer;
color: globals.$body-color;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
border: 1px solid globals.$border-color;
padding: globals.$spacing-unit;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
z-index: 10;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
}
&__image-container {
max-width: 90%;
max-height: 90%;
display: flex;
justify-content: center;
align-items: center;
}
&__image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
@keyframes image-appear {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./fullscreen-media-modal.scss";
export interface FullscreenMediaModalProps {
visible: boolean;
onClose: () => void;
src: string | null | undefined;
alt?: string;
}
export function FullscreenMediaModal({
visible,
onClose,
src,
alt,
}: FullscreenMediaModalProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("modal");
useEffect(() => {
if (visible) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}
return () => {};
}, [onClose, visible]);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (containerRef.current) {
const clickedOnImage = containerRef.current.contains(e.target as Node);
if (!clickedOnImage) {
onClose();
}
}
};
if (visible) {
window.addEventListener("mousedown", onMouseDown);
}
return () => {
window.removeEventListener("mousedown", onMouseDown);
};
}, [onClose, visible]);
if (!visible || !src) return null;
return createPortal(
<div className="fullscreen-media-modal__overlay">
<dialog className="fullscreen-media-modal" open aria-label={alt}>
<button
type="button"
onClick={onClose}
className="fullscreen-media-modal__close-button"
aria-label={t("close")}
>
<XIcon size={24} />
</button>
<div
ref={containerRef}
className="fullscreen-media-modal__image-container"
>
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
</div>
</dialog>
</div>,
document.body
);
}

View File

@@ -82,6 +82,7 @@ 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("/notifications")) return headerTitle;
if (location.pathname.startsWith("/library"))
return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results");
@@ -323,7 +324,8 @@ export function Header() {
<SearchDropdown
visible={
isDropdownVisible &&
(historyItems.length > 0 ||
(searchValue.trim().length > 0 ||
historyItems.length > 0 ||
suggestions.length > 0 ||
isLoadingSuggestions)
}

View File

@@ -20,3 +20,4 @@ export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating";
export * from "./search-dropdown/search-dropdown";
export * from "./fullscreen-media-modal/fullscreen-media-modal";

View File

@@ -10,7 +10,7 @@ import cn from "classnames";
export interface ModalProps {
visible: boolean;
title: string;
title: React.ReactNode;
description?: string;
onClose: () => void;
large?: boolean;
@@ -115,7 +115,6 @@ export function Modal({
"modal--large": large,
})}
role="dialog"
aria-labelledby={title}
aria-describedby={description}
ref={modalContentRef}
data-hydra-dialog

View File

@@ -5,7 +5,7 @@
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
max-height: 300px;
max-height: 350px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
@@ -92,23 +92,8 @@ export function SearchDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [visible, onClose, searchContainerRef]);
const handleItemClick = useCallback(
(
type: "history" | "suggestion",
item: SearchHistoryEntry | SearchSuggestion
) => {
if (type === "history") {
onSelectHistory((item as SearchHistoryEntry).query);
} else {
onSelectSuggestion(item as SearchSuggestion);
}
},
[onSelectHistory, onSelectSuggestion]
);
if (!visible) return null;
const totalItems = historyItems.length + suggestions.length;
const hasHistory = historyItems.length > 0;
const hasSuggestions = suggestions.length > 0;
@@ -158,7 +143,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("history", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("history", item)}
onClick={() => onSelectHistory(item.query)}
>
<ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text">
@@ -200,7 +185,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("suggestion", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("suggestion", item)}
onClick={() => onSelectSuggestion(item)}
>
{item.iconUrl ? (
<img
@@ -227,13 +212,6 @@ export function SearchDropdown({
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div>
)}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div>
);

View File

@@ -5,6 +5,7 @@ import cn from "classnames";
import { useLocation } from "react-router-dom";
import { useState } from "react";
import { GameContextMenu } from "..";
import { useAppSelector } from "@renderer/hooks";
interface SidebarGameItemProps {
game: LibraryGame;
@@ -18,6 +19,9 @@ export function SidebarGameItem({
getGameTitle,
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
@@ -81,11 +85,12 @@ export function SidebarGameItem({
{getGameTitle(game)}
</span>
{(game.newDownloadOptionsCount ?? 0) > 0 && (
<span className="sidebar__game-badge">
+{game.newDownloadOptionsCount}
</span>
)}
{userPreferences?.enableNewDownloadOptionsBadges !== false &&
(game.newDownloadOptionsCount ?? 0) > 0 && (
<span className="sidebar__game-badge">
+{game.newDownloadOptionsCount}
</span>
)}
</button>
</li>

View File

@@ -46,7 +46,7 @@
white-space: nowrap;
}
&__friends-button {
&__notification-button {
color: globals.$muted-color;
cursor: pointer;
border-radius: 50%;
@@ -62,7 +62,7 @@
}
}
&__friends-button-badge {
&__notification-button-badge {
background-color: globals.$success-color;
display: flex;
justify-content: center;
@@ -73,6 +73,8 @@
position: absolute;
top: -5px;
right: -5px;
font-size: 10px;
font-weight: bold;
}
&__game-running-icon {

View File

@@ -1,12 +1,13 @@
import { useNavigate } from "react-router-dom";
import { PeopleIcon } from "@primer/octicons-react";
import { BellIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared";
import { logger } from "@renderer/logger";
import type { NotificationCountResponse } from "@types";
import "./sidebar-profile.scss";
export function SidebarProfile() {
@@ -14,11 +15,75 @@ export function SidebarProfile() {
const { t } = useTranslation("sidebar");
const { userDetails, friendRequestCount, showFriendsModal } =
useUserDetails();
const { userDetails } = useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [notificationCount, setNotificationCount] = useState(0);
const fetchNotificationCount = useCallback(async () => {
try {
// Always fetch local notification count
const localCount = await window.electron.getLocalNotificationsCount();
// Fetch API notification count only if logged in
let apiCount = 0;
if (userDetails) {
try {
const response =
await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count",
{ needsAuth: true }
);
apiCount = response.count;
} catch {
// Ignore API errors
}
}
setNotificationCount(localCount + apiCount);
} catch (error) {
logger.error("Failed to fetch notification count", error);
}
}, [userDetails]);
useEffect(() => {
fetchNotificationCount();
const interval = setInterval(fetchNotificationCount, 60000);
return () => clearInterval(interval);
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
useEffect(() => {
const handleNotificationsChange = () => {
fetchNotificationCount();
};
window.addEventListener("notificationsChanged", handleNotificationsChange);
return () => {
window.removeEventListener(
"notificationsChanged",
handleNotificationsChange
);
};
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onSyncNotificationCount(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
const handleProfileClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow(AuthPage.SignIn);
@@ -28,28 +93,24 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails.id}`);
};
const friendsButton = useMemo(() => {
if (!userDetails) return null;
const notificationsButton = useMemo(() => {
return (
<button
type="button"
className="sidebar-profile__friends-button"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
className="sidebar-profile__notification-button"
onClick={() => navigate("/notifications")}
title={t("notifications")}
>
{friendRequestCount > 0 && (
<small className="sidebar-profile__friends-button-badge">
{friendRequestCount > 99 ? "99+" : friendRequestCount}
{notificationCount > 0 && (
<small className="sidebar-profile__notification-button-badge">
{notificationCount > 99 ? "99+" : notificationCount}
</small>
)}
<PeopleIcon size={16} />
<BellIcon size={16} />
</button>
);
}, [userDetails, t, friendRequestCount, showFriendsModal]);
}, [t, notificationCount, navigate]);
const gameRunningDetails = () => {
if (!userDetails || !gameRunning) return null;
@@ -98,7 +159,7 @@ export function SidebarProfile() {
</div>
</button>
{friendsButton}
{notificationsButton}
</div>
);
}

View File

@@ -10,6 +10,8 @@ export const DOWNLOADER_NAME = {
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier",
[Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus",
};

View File

@@ -1,6 +1,12 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { Badge, UserProfile, UserStats, UserGame } from "@types";
import type {
Badge,
UserProfile,
UserStats,
UserGame,
ProfileAchievement,
} from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@@ -202,22 +208,56 @@ export function UserProfileContextProvider({
getUserStats();
getUserLibraryGames();
return window.electron.hydraApi
.get<UserProfile>(`/users/${userId}`)
.then((userProfile) => {
setUserProfile(userProfile);
const currentLanguage = i18n.language.split("-")[0];
const supportedLanguages = ["pt", "ru", "es"];
const language = supportedLanguages.includes(currentLanguage)
? currentLanguage
: "en";
const params = new URLSearchParams({ language });
// Fetch main profile data
const profilePromise = window.electron.hydraApi
.get<UserProfile>(`/users/${userId}?${params.toString()}`)
.catch(() => {
showErrorToast(t("user_not_found"));
navigate(-1);
throw new Error("Profile not found");
});
// Fetch achievements separately
const achievementsPromise = window.electron.hydraApi
.get<
ProfileAchievement[]
>(`/users/${userId}/achievements?${params.toString()}`)
.catch(() => null); // If achievements fail, just return null
return Promise.all([profilePromise, achievementsPromise]).then(
([userProfile, achievements]) => {
// Merge achievements into the profile
const profileWithAchievements = {
...userProfile,
achievements: achievements || null,
};
setUserProfile(profileWithAchievements);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
})
.catch(() => {
showErrorToast(t("user_not_found"));
navigate(-1);
});
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
}
);
}, [
navigate,
getUserStats,
getUserLibraryGames,
showErrorToast,
userId,
t,
i18n,
]);
const getBadges = useCallback(async () => {
const language = i18n.language.split("-")[0];

View File

@@ -14,6 +14,7 @@ import type {
GameStats,
UserDetails,
FriendRequestSync,
NotificationSync,
GameArtifact,
LudusaviBackup,
UserAchievement,
@@ -31,6 +32,7 @@ import type {
Game,
DiskUsage,
DownloadSource,
LocalNotification,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -280,7 +282,9 @@ declare global {
isStaging: () => Promise<boolean>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
getScreenshotsPath: () => Promise<string>;
isPortableVersion: () => Promise<boolean>;
openFolder: (path: string) => Promise<string>;
showOpenDialog: (
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;
@@ -387,10 +391,12 @@ declare global {
processProfileImage: (
path: string
) => Promise<{ imagePath: string; mimeType: string }>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer;
onSyncNotificationCount: (
cb: (notification: NotificationSync) => void
) => () => Electron.IpcRenderer;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
@@ -398,6 +404,15 @@ declare global {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
getLocalNotifications: () => Promise<LocalNotification[]>;
getLocalNotificationsCount: () => Promise<number>;
markLocalNotificationRead: (id: string) => Promise<void>;
markAllLocalNotificationsRead: () => Promise<void>;
deleteLocalNotification: (id: string) => Promise<void>;
clearAllLocalNotifications: () => Promise<void>;
onLocalNotificationCreated: (
cb: (notification: LocalNotification) => void
) => () => Electron.IpcRenderer;
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,

View File

@@ -12,6 +12,8 @@ export interface DownloadState {
gameId: string | null;
gamesWithDeletionInProgress: string[];
extraction: ExtractionInfo | null;
peakSpeeds: Record<string, number>;
speedHistory: Record<string, number[]>;
}
const initialState: DownloadState = {
@@ -19,6 +21,8 @@ const initialState: DownloadState = {
gameId: null,
gamesWithDeletionInProgress: [],
extraction: null,
peakSpeeds: {},
speedHistory: {},
};
export const downloadSlice = createSlice({
@@ -28,6 +32,27 @@ export const downloadSlice = createSlice({
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
// Track peak speed and speed history atomically when packet arrives
if (action.payload?.gameId && action.payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = action.payload;
// Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0;
if (downloadSpeed > currentPeak) {
state.peakSpeeds[gameId] = downloadSpeed;
}
// Update speed history for chart
if (!state.speedHistory[gameId]) {
state.speedHistory[gameId] = [];
}
state.speedHistory[gameId].push(downloadSpeed);
// Keep only last 120 entries
if (state.speedHistory[gameId].length > 120) {
state.speedHistory[gameId].shift();
}
}
},
clearDownload: (state) => {
state.lastPacket = null;
@@ -62,6 +87,20 @@ export const downloadSlice = createSlice({
clearExtraction: (state) => {
state.extraction = null;
},
updatePeakSpeed: (
state,
action: PayloadAction<{ gameId: string; speed: number }>
) => {
const { gameId, speed } = action.payload;
const currentPeak = state.peakSpeeds[gameId] || 0;
if (speed > currentPeak) {
state.peakSpeeds[gameId] = speed;
}
},
clearPeakSpeed: (state, action: PayloadAction<string>) => {
state.peakSpeeds[action.payload] = 0;
state.speedHistory[action.payload] = [];
},
},
});
@@ -72,4 +111,6 @@ export const {
removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
updatePeakSpeed,
clearPeakSpeed,
} = downloadSlice.actions;

View File

@@ -1,5 +1,4 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types";
export interface UserDetailsState {
@@ -7,9 +6,6 @@ export interface UserDetailsState {
profileBackground: null | string;
friendRequests: FriendRequest[];
friendRequestCount: number;
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
}
const initialState: UserDetailsState = {
@@ -17,9 +13,6 @@ const initialState: UserDetailsState = {
profileBackground: null,
friendRequests: [],
friendRequestCount: 0,
isFriendsModalVisible: false,
friendRequetsModalTab: null,
friendModalUserId: "",
};
export const userDetailsSlice = createSlice({
@@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({
setFriendRequestCount: (state, action: PayloadAction<number>) => {
state.friendRequestCount = action.payload;
},
setFriendsModalVisible: (
state,
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload.initialTab;
state.friendModalUserId = action.payload.userId;
},
setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false;
state.friendRequetsModalTab = null;
},
},
});
@@ -58,6 +39,4 @@ export const {
setProfileBackground,
setFriendRequests,
setFriendRequestCount,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions;

View File

@@ -4,8 +4,6 @@ import {
setProfileBackground,
setUserDetails,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import type {
FriendRequestAction,
@@ -13,20 +11,12 @@ import type {
UserDetails,
FriendRequest,
} from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() {
const dispatch = useAppDispatch();
const {
userDetails,
profileBackground,
friendRequests,
friendRequestCount,
isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const { userDetails, profileBackground, friendRequests, friendRequestCount } =
useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
@@ -85,24 +75,11 @@ export function useUserDetails() {
return window.electron.hydraApi
.get<FriendRequest[]>("/profile/friend-requests")
.then((friendRequests) => {
window.electron.syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests();
},
[dispatch, fetchFriendRequests]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback(
async (userId: string) => {
return window.electron.hydraApi
@@ -152,12 +129,7 @@ export function useUserDetails() {
profileBackground,
friendRequests,
friendRequestCount,
friendRequetsModalTab,
isFriendsModalVisible,
friendModalUserId,
hasActiveSubscription,
showFriendsModal,
hideFriendsModal,
fetchUserDetails,
signOut,
clearUserDetails,

View File

@@ -31,6 +31,7 @@ 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 Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log;
@@ -76,6 +77,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />

View File

@@ -2,9 +2,11 @@ import { useDate } from "@renderer/hooks";
import type { UserAchievement } from "@types";
import { useTranslation } from "react-i18next";
import "./achievements.scss";
import { EyeClosedIcon } from "@primer/octicons-react";
import { EyeClosedIcon, SearchIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { useState } from "react";
import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal";
interface AchievementListProps {
achievements: UserAchievement[];
@@ -16,17 +18,34 @@ export function AchievementList({
const { t } = useTranslation("achievement");
const { showHydraCloudModal } = useSubscription();
const { formatDateTime } = useDate();
const [fullscreenImage, setFullscreenImage] = useState<{
url: string;
alt: string;
} | null>(null);
const handleImageClick = (imageUrl: string, achievementName: string) => {
setFullscreenImage({
url: imageUrl,
alt: `${achievementName} screenshot`,
});
};
const closeFullscreenImage = () => {
setFullscreenImage(null);
};
return (
<ul className="achievements__list">
{achievements.map((achievement) => (
<li key={achievement.name} className="achievements__item">
<img
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div className="achievements__item-icon-container">
<img
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
</div>
<div className="achievements__item-content">
<h4 className="achievements__item-title">
@@ -44,6 +63,41 @@ export function AchievementList({
</div>
<div className="achievements__item-meta">
{achievement.imageUrl && achievement.unlocked && (
<div className="achievements__item-image-container">
<div className="achievements__item-custom-image-wrapper">
<button
type="button"
className="achievements__item-image-button"
onClick={() =>
achievement.imageUrl &&
handleImageClick(
achievement.imageUrl,
achievement.displayName
)
}
aria-label={`View ${achievement.displayName} screenshot in fullscreen`}
style={{
cursor: "pointer",
padding: 0,
border: "none",
background: "transparent",
}}
>
<img
className="achievements__item-custom-image"
src={achievement.imageUrl}
alt={`${achievement.displayName} screenshot`}
loading="lazy"
/>
</button>
<div className="achievements__item-custom-image-overlay">
<SearchIcon size={20} />
</div>
</div>
</div>
)}
{achievement.points != undefined ? (
<div
className="achievements__item-points"
@@ -66,6 +120,7 @@ export function AchievementList({
<p className="achievements__item-points-value">???</p>
</button>
)}
{achievement.unlockTime != null && (
<div
className="achievements__item-unlock-time"
@@ -79,6 +134,13 @@ export function AchievementList({
</div>
</li>
))}
<FullscreenImageModal
isOpen={fullscreenImage !== null}
imageUrl={fullscreenImage?.url || ""}
imageAlt={fullscreenImage?.alt || ""}
onClose={closeFullscreenImage}
/>
</ul>
);
}

View File

@@ -50,6 +50,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
className="achievements-content__profile-avatar"
src={user.profileImageUrl}
alt={user.displayName}
loading="lazy"
/>
) : (
<PersonIcon size={24} />
@@ -150,6 +151,7 @@ export function AchievementsContent({
className="achievements-content__comparison__small-avatar"
src={user.profileImageUrl}
alt={user.displayName}
loading="lazy"
/>
) : (
<PersonIcon size={24} />
@@ -166,6 +168,7 @@ export function AchievementsContent({
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="achievements-content__achievements-list__image"
alt={gameTitle}
loading="lazy"
/>
<section
@@ -186,6 +189,7 @@ export function AchievementsContent({
src={shopDetails?.assets?.logoImageUrl ?? ""}
className="achievements-content__achievements-list__section__container__hero__content__game-logo"
alt={gameTitle}
loading="lazy"
/>
</Link>
</div>

View File

@@ -117,6 +117,70 @@ $logo-max-width: 200px;
}
}
&-image-container {
display: flex;
align-items: center;
justify-content: center;
margin: 4px 0;
}
&-icon-container {
position: relative;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&-custom-image {
width: 120px;
height: 60px;
border-radius: 4px;
object-fit: cover;
opacity: 0.9;
transition: all 0.2s ease;
&:hover {
opacity: 1;
}
}
&-custom-image-wrapper {
position: relative;
display: inline-block;
cursor: pointer;
&:hover {
.achievements__item-custom-image {
filter: grayscale(50%) brightness(0.7);
}
.achievements__item-custom-image-overlay {
opacity: 1;
}
}
}
&-custom-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
color: white;
svg {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8));
}
}
&-content {
flex: 1;
}
@@ -153,6 +217,7 @@ $logo-max-width: 200px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: end;
}
&-points {

View File

@@ -412,10 +412,12 @@ function HeroDownloadView({
</div>
)}
{game.download?.downloader && (
{game.download?.downloader !== undefined && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
<Badge>
{DOWNLOADER_NAME[Number(game.download.downloader)]}
</Badge>
</div>
</div>
)}
@@ -512,8 +514,9 @@ export function DownloadGroup({
const { formatDistance } = useDate();
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
const speedHistoryRef = useRef<Record<string, number[]>>({});
// Get speed history and peak speeds from Redux (centralized state)
const speedHistory = useAppSelector((state) => state.download.speedHistory);
const peakSpeeds = useAppSelector((state) => state.download.peakSpeeds);
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
{}
);
@@ -576,68 +579,8 @@ export function DownloadGroup({
});
}, [library, lastPacket?.gameId]);
useEffect(() => {
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
const gameId = lastPacket.gameId;
const currentPeak = peakSpeeds[gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
setPeakSpeeds((prev) => ({
...prev,
[gameId]: lastPacket.downloadSpeed,
}));
}
if (!speedHistoryRef.current[gameId]) {
speedHistoryRef.current[gameId] = [];
}
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
if (speedHistoryRef.current[gameId].length > 120) {
speedHistoryRef.current[gameId].shift();
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
useEffect(() => {
for (const game of library) {
if (
game.download &&
game.download.progress < 0.01 &&
game.download.status !== "paused"
) {
// Fresh download - clear any old data
if (speedHistoryRef.current[game.id]?.length > 0) {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}
}
}
}, [library]);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
for (const game of library) {
if (
game.download?.progress === 1 &&
speedHistoryRef.current[game.id]?.length > 0
) {
const timeout = setTimeout(() => {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}, 10_000);
timeouts.push(timeout);
}
}
return () => {
for (const timeout of timeouts) {
clearTimeout(timeout);
}
};
}, [library]);
// Speed history and peak speeds are now tracked in Redux (in setLastPacket reducer)
// No local effect needed - data is updated atomically when packets arrive
useEffect(() => {
if (library.length > 0 && title === t("download_in_progress")) {
@@ -842,7 +785,14 @@ export function DownloadGroup({
? (lastPacket?.downloadSpeed ?? 0)
: 0;
const finalDownloadSize = getFinalDownloadSize(game);
const peakSpeed = peakSpeeds[game.id] || 0;
// Use lastPacket.gameId for lookup since that's the key used to store the data
// Fall back to game.id if lastPacket is not available
const dataKey = lastPacket?.gameId ?? game.id;
const gameSpeedHistory = speedHistory[dataKey] ?? [];
const storedPeak = peakSpeeds[dataKey];
// Use stored peak if available and > 0, otherwise use current speed as initial value
const peakSpeed =
storedPeak !== undefined && storedPeak > 0 ? storedPeak : downloadSpeed;
let currentProgress = game.download?.progress || 0;
if (isGameExtracting) {
@@ -864,7 +814,7 @@ export function DownloadGroup({
currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={speedHistoryRef.current[game.id] || []}
speedHistory={gameSpeedHistory}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
@@ -908,7 +858,9 @@ export function DownloadGroup({
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
<Badge>
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
</Badge>
</div>
<div className="download-group__simple-meta-row">
{extraction?.visibleId === game.id ? (

View File

@@ -1,6 +1,7 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
@@ -55,6 +56,8 @@ const getImageWithCustomPriority = (
export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const [searchParams] = useSearchParams();
const reviewsRef = useRef<HTMLDivElement>(null);
const {
objectId,
@@ -137,6 +140,16 @@ export function GameDetailsContent() {
getGameArtifacts();
}, [getGameArtifacts]);
// Scroll to reviews section if reviews=true in URL
useEffect(() => {
const shouldScrollToReviews = searchParams.get("reviews") === "true";
if (shouldScrollToReviews && reviewsRef.current) {
setTimeout(() => {
reviewsRef.current?.scrollIntoView({ behavior: "smooth" });
}, 500);
}
}, [searchParams, objectId]);
const isCustomGame = game?.shop === "custom";
const heroImage = isCustomGame
@@ -229,15 +242,17 @@ export function GameDetailsContent() {
)}
{shop !== "custom" && shop && objectId && (
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
<div ref={reviewsRef}>
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
</div>
)}
</div>

View File

@@ -21,7 +21,12 @@ import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
import {
useDate,
useFeature,
useAppDispatch,
useAppSelector,
} from "@renderer/hooks";
import { clearNewDownloadOptions } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { getGameKey } from "@renderer/helpers";
@@ -70,6 +75,9 @@ export function RepacksModal({
const { formatDate } = useDate();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") {
@@ -129,10 +137,12 @@ export function RepacksModal({
}
};
if (visible) {
if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) {
fetchLastCheckTimestamp();
} else {
setIsLoadingTimestamp(false);
}
}, [visible, repacks]);
}, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]);
useEffect(() => {
if (
@@ -363,11 +373,13 @@ export function RepacksModal({
>
<p className="repacks-modal__repack-title">
{repack.title}
{isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
{userPreferences?.enableNewDownloadOptionsBadges !==
false &&
isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
</p>
{isLastDownloadedOption && (

View File

@@ -114,6 +114,7 @@ export function Sidebar() {
}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<p>{achievement.displayName}</p>
@@ -164,6 +165,7 @@ export function Sidebar() {
}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<p>{achievement.displayName}</p>

View File

@@ -0,0 +1,103 @@
import { useCallback } from "react";
import {
XIcon,
DownloadIcon,
PackageIcon,
SyncIcon,
TrophyIcon,
ClockIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDate } from "@renderer/hooks";
import cn from "classnames";
import type { LocalNotification } from "@types";
import "./notification-item.scss";
interface LocalNotificationItemProps {
notification: LocalNotification;
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
}
export function LocalNotificationItem({
notification,
onDismiss,
onMarkAsRead,
}: Readonly<LocalNotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(notification.url);
}
}, [notification, onMarkAsRead, navigate]);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getIcon = () => {
switch (notification.type) {
case "DOWNLOAD_COMPLETE":
return <DownloadIcon size={24} />;
case "EXTRACTION_COMPLETE":
return <PackageIcon size={24} />;
case "UPDATE_AVAILABLE":
return <SyncIcon size={24} />;
case "ACHIEVEMENT_UNLOCKED":
return <TrophyIcon size={24} />;
default:
return <DownloadIcon size={24} />;
}
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread": !notification.isRead,
})}
onClick={handleClick}
>
<div className="notification-item__picture">
{notification.pictureUrl ? (
<img src={notification.pictureUrl} alt="" />
) : (
getIcon()
)}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{notification.title}</span>
<span className="notification-item__description">
{notification.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
</button>
);
}

View File

@@ -0,0 +1,127 @@
@use "../../scss/globals.scss";
.notification-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
transition: all ease 0.2s;
position: relative;
opacity: 0.4;
width: 100%;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
opacity: 0.6;
}
&--unread {
border-left: 3px solid globals.$brand-teal;
opacity: 1;
&:hover {
opacity: 1;
}
.notification-item__title {
color: #fff;
}
}
&__picture {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
color: #fff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
svg {
color: inherit;
}
}
&__badge-picture {
border-radius: 8px;
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__review-picture {
color: #f5a623;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__title {
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
&__description {
font-size: globals.$small-font-size;
color: globals.$body-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-size: globals.$small-font-size;
color: rgba(255, 255, 255, 0.5);
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
flex-shrink: 0;
}
&__dismiss {
position: absolute;
top: calc(globals.$spacing-unit / 2);
right: calc(globals.$spacing-unit / 2);
background: transparent;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 50%;
transition: all ease 0.2s;
opacity: 0.5;
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
}
}

View File

@@ -0,0 +1,228 @@
import { useCallback, useMemo } from "react";
import {
XIcon,
PersonIcon,
ClockIcon,
StarFillIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@renderer/components";
import { useDate, useUserDetails } from "@renderer/hooks";
import cn from "classnames";
import type { Notification, Badge } from "@types";
import "./notification-item.scss";
const parseNotificationUrl = (notificationUrl: string): string => {
const url = new URL(notificationUrl, "http://localhost");
const userId = url.searchParams.get("userId");
const badgeName = url.searchParams.get("name");
const gameTitle = url.searchParams.get("title");
const showReviews = url.searchParams.get("reviews");
if (url.pathname === "/profile" && userId) {
return `/profile/${userId}`;
}
if (url.pathname === "/badges" && badgeName) {
return `/badges/${badgeName}`;
}
if (url.pathname.startsWith("/game/")) {
const params = new URLSearchParams();
if (gameTitle) params.set("title", gameTitle);
if (showReviews) params.set("reviews", showReviews);
const queryString = params.toString();
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
}
return notificationUrl;
};
interface NotificationItemProps {
notification: Notification;
badges: Badge[];
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
onAcceptFriendRequest?: (senderId: string) => void;
onRefuseFriendRequest?: (senderId: string) => void;
}
export function NotificationItem({
notification,
badges,
onDismiss,
onMarkAsRead,
onAcceptFriendRequest,
onRefuseFriendRequest,
}: Readonly<NotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const { updateFriendRequestState } = useUserDetails();
const badge = useMemo(() => {
if (notification.type !== "BADGE_RECEIVED") return null;
return badges.find((b) => b.name === notification.variables.badgeName);
}, [notification, badges]);
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(parseNotificationUrl(notification.url));
}
}, [notification, onMarkAsRead, navigate]);
const handleAccept = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "ACCEPTED");
onAcceptFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onAcceptFriendRequest, onDismiss]
);
const handleRefuse = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "REFUSED");
onRefuseFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onRefuseFriendRequest, onDismiss]
);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getNotificationContent = () => {
switch (notification.type) {
case "FRIEND_REQUEST_RECEIVED":
return {
title: t("friend_request_received_title"),
description: t("friend_request_received_description", {
displayName: notification.variables.senderDisplayName,
}),
showActions: true,
};
case "FRIEND_REQUEST_ACCEPTED":
return {
title: t("friend_request_accepted_title"),
description: t("friend_request_accepted_description", {
displayName: notification.variables.accepterDisplayName,
}),
showActions: false,
};
case "BADGE_RECEIVED":
return {
title: t("badge_received_title"),
description: badge?.description || notification.variables.badgeName,
showActions: false,
};
case "REVIEW_UPVOTE":
return {
title: t("review_upvote_title", {
gameTitle: notification.variables.gameTitle,
}),
description: t("review_upvote_description", {
count: Number.parseInt(
notification.variables.upvoteCount || "1",
10
),
}),
showActions: false,
};
default:
return {
title: t("notification"),
description: "",
showActions: false,
};
}
};
const content = getNotificationContent();
const isBadge = notification.type === "BADGE_RECEIVED";
const isReview = notification.type === "REVIEW_UPVOTE";
const getIcon = () => {
if (notification.pictureUrl) {
return <img src={notification.pictureUrl} alt="" />;
}
if (isReview) {
return <StarFillIcon size={24} />;
}
return <PersonIcon size={24} />;
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread":
!notification.isRead ||
notification.type === "FRIEND_REQUEST_RECEIVED",
})}
onClick={handleClick}
>
<div
className={cn("notification-item__picture", {
"notification-item__badge-picture": isBadge,
"notification-item__review-picture": isReview,
})}
>
{getIcon()}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{content.title}</span>
<span className="notification-item__description">
{content.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
{content.showActions &&
notification.type === "FRIEND_REQUEST_RECEIVED" && (
<div className="notification-item__actions">
<Button theme="primary" onClick={handleAccept}>
{t("accept")}
</Button>
<Button theme="outline" onClick={handleRefuse}>
{t("refuse")}
</Button>
</div>
)}
{notification.type !== "FRIEND_REQUEST_RECEIVED" && (
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
)}
</button>
);
}

View File

@@ -0,0 +1,58 @@
@use "../../scss/globals.scss";
.notifications {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 800px;
margin: 0 auto;
&__actions {
display: flex;
gap: globals.$spacing-unit;
justify-content: flex-end;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__empty {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__icon-container {
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);
}
&__loading {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
}
&__load-more {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -0,0 +1,400 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@renderer/components";
import { useAppDispatch, useToast, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { logger } from "@renderer/logger";
import { NotificationItem } from "./notification-item";
import { LocalNotificationItem } from "./local-notification-item";
import type {
Notification,
LocalNotification,
NotificationsResponse,
MergedNotification,
Badge,
} from "@types";
import "./notifications.scss";
export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast();
const { userDetails } = useUserDetails();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setHeaderTitle(t("title")));
}, [dispatch, t]);
const [apiNotifications, setApiNotifications] = useState<Notification[]>([]);
const [localNotifications, setLocalNotifications] = useState<
LocalNotification[]
>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set());
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
skip: 0,
});
const fetchLocalNotifications = useCallback(async () => {
try {
const notifications = await window.electron.getLocalNotifications();
setLocalNotifications(notifications);
} catch (error) {
logger.error("Failed to fetch local notifications", error);
}
}, []);
const fetchBadges = useCallback(async () => {
try {
const language = i18n.language.split("-")[0];
const params = new URLSearchParams({ locale: language });
const badgesResponse = await window.electron.hydraApi.get<Badge[]>(
`/badges?${params.toString()}`,
{ needsAuth: false }
);
setBadges(badgesResponse);
} catch (error) {
logger.error("Failed to fetch badges", error);
}
}, [i18n.language]);
const fetchApiNotifications = useCallback(
async (skip = 0, append = false) => {
if (!userDetails) return;
try {
setIsLoading(true);
const response =
await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications",
{
params: { filter: "all", take: 20, skip },
needsAuth: true,
}
);
logger.log("Notifications API response:", response);
if (append) {
setApiNotifications((prev) => [...prev, ...response.notifications]);
} else {
setApiNotifications(response.notifications);
}
setPagination({
total: response.pagination.total,
hasMore: response.pagination.hasMore,
skip: response.pagination.skip + response.pagination.take,
});
} catch (error) {
logger.error("Failed to fetch API notifications", error);
} finally {
setIsLoading(false);
}
},
[userDetails]
);
const fetchAllNotifications = useCallback(async () => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(),
]);
setIsLoading(false);
}, [
fetchLocalNotifications,
fetchBadges,
fetchApiNotifications,
userDetails,
]);
useEffect(() => {
fetchAllNotifications();
}, [fetchAllNotifications]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(
(notification) => {
setLocalNotifications((prev) => [notification, ...prev]);
}
);
return () => unsubscribe();
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
// High priority notifications (priority === 1) - keep in API order
const highPriority: MergedNotification[] = apiNotifications
.filter((n) => n.priority === 1)
.map((n) => ({ ...n, source: "api" as const }));
// Low priority: other API notifications + local notifications, merged and sorted by date
const lowPriorityApi: MergedNotification[] = apiNotifications
.filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const }));
const localWithSource: MergedNotification[] = localNotifications.map(
(n) => ({
...n,
source: "local" as const,
})
);
const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate
);
return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications]);
const displayedNotifications = useMemo(() => {
return mergedNotifications.filter((n) => !clearingIds.has(n.id));
}, [mergedNotifications, clearingIds]);
const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged"));
}, []);
const handleMarkAsRead = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.patch(
`/profile/notifications/${id}/read`,
{
data: { id },
needsAuth: true,
}
);
setApiNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
} else {
await window.electron.markLocalNotificationRead(id);
setLocalNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
}
notifyCountChange();
} catch (error) {
logger.error("Failed to mark notification as read", error);
}
},
[notifyCountChange]
);
const handleMarkAllAsRead = useCallback(async () => {
try {
// Mark all API notifications as read
if (userDetails && apiNotifications.some((n) => !n.isRead)) {
await window.electron.hydraApi.patch(
`/profile/notifications/all/read`,
{ needsAuth: true }
);
setApiNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
}
// Mark all local notifications as read
await window.electron.markAllLocalNotificationsRead();
setLocalNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
notifyCountChange();
showSuccessToast(t("marked_all_as_read"));
} catch (error) {
logger.error("Failed to mark all as read", error);
showErrorToast(t("failed_to_mark_as_read"));
}
}, [
apiNotifications,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleDismiss = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.delete(
`/profile/notifications/${id}`,
{ needsAuth: true }
);
setApiNotifications((prev) => prev.filter((n) => n.id !== id));
setPagination((prev) => ({ ...prev, total: prev.total - 1 }));
} else {
await window.electron.deleteLocalNotification(id);
setLocalNotifications((prev) => prev.filter((n) => n.id !== id));
}
notifyCountChange();
} catch (error) {
logger.error("Failed to dismiss notification", error);
showErrorToast(t("failed_to_dismiss"));
}
},
[showErrorToast, t, notifyCountChange]
);
const handleClearAll = useCallback(async () => {
try {
// Mark all as clearing for animation
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
// Wait for exit animation
await new Promise((resolve) => setTimeout(resolve, 300));
// Clear all API notifications
if (userDetails && apiNotifications.length > 0) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true,
});
setApiNotifications([]);
}
// Clear all local notifications
await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange();
showSuccessToast(t("cleared_all"));
} catch (error) {
logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear"));
}
}, [
apiNotifications,
localNotifications,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true);
}
}, [pagination, isLoading, fetchApiNotifications]);
const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted"));
}, [showSuccessToast, t]);
const handleRefuseFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_refused"));
}, [showSuccessToast, t]);
const renderNotification = (notification: MergedNotification) => {
const key =
notification.source === "local"
? `local-${notification.id}`
: `api-${notification.id}`;
return (
<motion.div
key={key}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }}
transition={{ duration: 0.2 }}
>
{notification.source === "local" ? (
<LocalNotificationItem
notification={notification}
onDismiss={(id) => handleDismiss(id, "local")}
onMarkAsRead={(id) => handleMarkAsRead(id, "local")}
/>
) : (
<NotificationItem
notification={notification}
badges={badges}
onDismiss={(id) => handleDismiss(id, "api")}
onMarkAsRead={(id) => handleMarkAsRead(id, "api")}
onAcceptFriendRequest={handleAcceptFriendRequest}
onRefuseFriendRequest={handleRefuseFriendRequest}
/>
)}
</motion.div>
);
};
const renderContent = () => {
if (isLoading && mergedNotifications.length === 0) {
return (
<div className="notifications__loading">
<span>{t("loading")}</span>
</div>
);
}
if (mergedNotifications.length === 0) {
return (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>{t("empty_description")}</p>
</div>
);
}
return (
<div className="notifications">
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
</div>
<div className="notifications__list">
<AnimatePresence mode="popLayout">
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
{pagination.hasMore && (
<div className="notifications__load-more">
<Button
theme="outline"
onClick={handleLoadMore}
disabled={isLoading}
>
{isLoading ? t("loading") : t("load_more")}
</Button>
</div>
)}
</div>
);
};
return <>{renderContent()}</>;
}

View File

@@ -0,0 +1,120 @@
@use "../../../scss/globals.scss";
.add-friend-modal {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
width: 100%;
min-width: 400px;
&__my-code {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
padding: calc(globals.$spacing-unit * 1.5);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
&__my-code-label {
font-size: 0.875rem;
color: globals.$muted-color;
font-weight: 500;
}
&__my-code-value {
font-size: 0.875rem;
color: globals.$body-color;
font-family: monospace;
font-weight: 600;
flex: 1;
}
&__copy-icon-button {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 4px;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
color: globals.$body-color;
}
}
&__actions {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: globals.$spacing-unit;
}
&__button {
align-self: flex-end;
white-space: nowrap;
}
&__pending-status {
color: globals.$body-color;
font-size: globals.$small-font-size;
text-align: center;
padding: calc(globals.$spacing-unit / 2);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
margin-top: calc(globals.$spacing-unit * -1);
}
&__pending-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
h3 {
margin: 0;
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
}
&__pending-list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 300px;
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__friend-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
cursor: pointer;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__friend-name {
flex: 1;
font-weight: 600;
color: globals.$muted-color;
font-size: globals.$body-font-size;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,185 @@
import { Avatar, Button, Modal, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { CopyIcon } from "@primer/octicons-react";
import "./add-friend-modal.scss";
interface AddFriendModalProps {
readonly visible: boolean;
readonly onClose: () => void;
}
export function AddFriendModal({ visible, onClose }: AddFriendModalProps) {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const [friendCode, setFriendCode] = useState("");
const [isAddingFriend, setIsAddingFriend] = useState(false);
const {
sendFriendRequest,
updateFriendRequestState,
friendRequests,
fetchFriendRequests,
userDetails,
} = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const copyMyFriendCode = () => {
if (userDetails?.id) {
navigator.clipboard.writeText(userDetails.id);
showSuccessToast(t("friend_code_copied"));
}
};
useEffect(() => {
if (visible) {
setFriendCode("");
fetchFriendRequests();
}
}, [visible, fetchFriendRequests]);
const handleChangeFriendCode = (e: React.ChangeEvent<HTMLInputElement>) => {
const code = e.target.value.trim().slice(0, 8);
setFriendCode(code);
};
const validateFriendCode = (callback: () => void) => {
if (friendCode.length === 8) {
return callback();
}
showErrorToast(t("friend_code_length_error"));
};
const handleClickAddFriend = () => {
setIsAddingFriend(true);
sendFriendRequest(friendCode)
.then(() => {
setFriendCode("");
showSuccessToast(t("request_sent"));
})
.catch(() => {
showErrorToast(t("error_adding_friend"));
})
.finally(() => {
setIsAddingFriend(false);
});
};
const handleClickSeeProfile = () => {
if (friendCode.length === 8) {
onClose();
navigate(`/profile/${friendCode}`);
}
};
const handleClickRequest = (userId: string) => {
onClose();
navigate(`/profile/${userId}`);
};
const handleCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast(t("try_again"));
});
};
const sentRequests = friendRequests.filter((req) => req.type === "SENT");
const currentRequest =
friendCode.length === 8
? sentRequests.find((req) => req.id === friendCode)
: null;
return (
<Modal visible={visible} title={t("add_friends")} onClose={onClose}>
<div className="add-friend-modal">
{userDetails?.id && (
<div className="add-friend-modal__my-code">
<span className="add-friend-modal__my-code-label">
{t("your_friend_code")}
</span>
<span className="add-friend-modal__my-code-value">
{userDetails.id}
</span>
<button
onClick={copyMyFriendCode}
type="button"
className="add-friend-modal__copy-icon-button"
title={t("copy_friend_code")}
>
<CopyIcon size={16} />
</button>
</div>
)}
<div className="add-friend-modal__actions">
<TextField
label={t("friend_code")}
value={friendCode}
containerProps={{ style: { flex: 1 } }}
onChange={handleChangeFriendCode}
/>
<Button
disabled={isAddingFriend}
type="button"
className="add-friend-modal__button"
onClick={() => validateFriendCode(handleClickAddFriend)}
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
theme="outline"
onClick={() => validateFriendCode(handleClickSeeProfile)}
disabled={isAddingFriend}
className="add-friend-modal__button"
type="button"
>
{t("see_profile")}
</Button>
</div>
{currentRequest && (
<div className="add-friend-modal__pending-status">{t("pending")}</div>
)}
{sentRequests.length > 0 && (
<div className="add-friend-modal__pending-container">
<h3>{t("pending")}</h3>
<div className="add-friend-modal__pending-list">
{sentRequests.map((request) => (
<button
key={request.id}
type="button"
className="add-friend-modal__friend-item"
onClick={() => handleClickRequest(request.id)}
>
<Avatar
src={request.profileImageUrl}
alt={request.displayName}
size={40}
/>
<span className="add-friend-modal__friend-name">
{request.displayName}
</span>
<Button
theme="outline"
onClick={(e) => {
e.stopPropagation();
handleCancelFriendRequest(request.id);
}}
type="button"
>
{t("cancel_request")}
</Button>
</button>
))}
</div>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,87 @@
@use "../../../scss/globals.scss";
.all-badges-modal {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 400px;
margin-top: calc(globals.$spacing-unit * -1);
&__title {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__count {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
}
&__list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
transition: background-color ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__item-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__item-content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.5);
flex: 1;
min-width: 0;
}
&__item-title {
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$body-color;
margin: 0;
}
&__item-description {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
}

View File

@@ -0,0 +1,58 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { Modal } from "@renderer/components";
import { userProfileContext } from "@renderer/context";
import "./all-badges-modal.scss";
interface AllBadgesModalProps {
visible: boolean;
onClose: () => void;
}
export function AllBadgesModal({
visible,
onClose,
}: Readonly<AllBadgesModalProps>) {
const { t } = useTranslation("user_profile");
const { userProfile, badges } = useContext(userProfileContext);
const userBadges = userProfile?.badges
.map((badgeName) => badges.find((b) => b.name === badgeName))
.filter((badge) => badge !== undefined);
const modalTitle = (
<div className="all-badges-modal__title">
{t("badges")}
{userBadges && userBadges.length > 0 && (
<span className="all-badges-modal__count">{userBadges.length}</span>
)}
</div>
);
return (
<Modal visible={visible} title={modalTitle} onClose={onClose}>
<div className="all-badges-modal">
<div className="all-badges-modal__list">
{userBadges?.map((badge) => (
<div key={badge.name} className="all-badges-modal__item">
<div className="all-badges-modal__item-icon">
<img
src={badge.badge.url}
alt={badge.name}
width={32}
height={32}
/>
</div>
<div className="all-badges-modal__item-content">
<h3 className="all-badges-modal__item-title">{badge.title}</h3>
<p className="all-badges-modal__item-description">
{badge.description}
</p>
</div>
</div>
))}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,101 @@
@use "../../../scss/globals.scss";
.all-friends-modal {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 400px;
margin-top: calc(globals.$spacing-unit * -1);
&__title {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__count {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
border-radius: 8px;
cursor: pointer;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
&__info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
flex: 1;
min-width: 0;
}
&__name {
font-weight: 600;
color: globals.$muted-color;
font-size: globals.$body-font-size;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__game {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
font-size: globals.$small-font-size;
color: globals.$body-color;
img {
border-radius: 4px;
}
small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4);
color: globals.$body-color;
}
&__loading {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
}
&__load-more {
display: flex;
justify-content: center;
padding-top: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Modal, Avatar, Button } from "@renderer/components";
import { logger } from "@renderer/logger";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import type { UserFriend } from "@types";
import "./all-friends-modal.scss";
interface AllFriendsModalProps {
visible: boolean;
onClose: () => void;
userId: string;
isMe: boolean;
}
const PAGE_SIZE = 20;
export function AllFriendsModal({
visible,
onClose,
userId,
isMe,
}: AllFriendsModalProps) {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const [friends, setFriends] = useState<UserFriend[]>([]);
const [totalFriends, setTotalFriends] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const fetchFriends = useCallback(
async (pageNum: number, append = false) => {
if (isLoading) return;
setIsLoading(true);
try {
const url = isMe ? "/profile/friends" : `/users/${userId}/friends`;
const response = await window.electron.hydraApi.get<{
totalFriends: number;
friends: UserFriend[];
}>(url, {
params: { take: PAGE_SIZE, skip: pageNum * PAGE_SIZE },
});
if (append) {
setFriends((prev) => [...prev, ...response.friends]);
} else {
setFriends(response.friends);
}
setTotalFriends(response.totalFriends);
setHasMore((pageNum + 1) * PAGE_SIZE < response.totalFriends);
setPage(pageNum + 1);
} catch (error) {
logger.error("Failed to fetch friends", error);
} finally {
setIsLoading(false);
}
},
[userId, isMe, isLoading]
);
useEffect(() => {
if (visible) {
setFriends([]);
setPage(0);
setHasMore(true);
fetchFriends(0, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, userId]);
const handleScroll = useCallback(() => {
if (!listRef.current || isLoading || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
if (scrollTop + clientHeight >= scrollHeight - 50) {
fetchFriends(page, true);
}
}, [isLoading, hasMore, page, fetchFriends]);
const handleFriendClick = (friendId: string) => {
onClose();
navigate(`/profile/${friendId}`);
};
const handleLoadMore = () => {
if (!isLoading && hasMore) {
fetchFriends(page, true);
}
};
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
return <img alt={game.title} width={16} height={16} src={game.iconUrl} />;
}
return <SteamLogo width={16} height={16} />;
};
const modalTitle = (
<div className="all-friends-modal__title">
{t("friends")}
{totalFriends > 0 && (
<span className="all-friends-modal__count">{totalFriends}</span>
)}
</div>
);
return (
<Modal visible={visible} title={modalTitle} onClose={onClose}>
<div className="all-friends-modal">
{friends.length === 0 && !isLoading ? (
<div className="all-friends-modal__empty">
{t("no_friends_added")}
</div>
) : (
<div
ref={listRef}
className="all-friends-modal__list"
onScroll={handleScroll}
>
{friends.map((friend) => (
<div
key={friend.id}
className="all-friends-modal__item"
onClick={() => handleFriendClick(friend.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleFriendClick(friend.id);
}
}}
role="button"
tabIndex={0}
>
<Avatar
size={40}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<div className="all-friends-modal__info">
<span className="all-friends-modal__name">
{friend.displayName}
</span>
{friend.currentGame && (
<div className="all-friends-modal__game">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</div>
))}
</div>
)}
{isLoading && (
<div className="all-friends-modal__loading">{t("loading")}...</div>
)}
{hasMore && !isLoading && friends.length > 0 && (
<div className="all-friends-modal__load-more">
<Button theme="outline" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,95 @@
@use "../../../scss/globals.scss";
.badges-box {
&__box {
padding: calc(globals.$spacing-unit * 2);
}
&__header {
display: flex;
justify-content: flex-end;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
width: 100%;
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
transition: background-color ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__item-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
img {
width: 28px;
height: 28px;
object-fit: contain;
}
}
&__item-content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.5);
flex: 1;
min-width: 0;
}
&__item-title {
font-size: 0.8rem;
font-weight: 600;
color: globals.$body-color;
margin: 0;
}
&__item-description {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
&__view-all-container {
padding-top: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
display: flex;
justify-content: flex-start;
}
&__view-all {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
&:hover {
color: globals.$muted-color;
}
}
}

View File

@@ -0,0 +1,67 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { AllBadgesModal } from "./all-badges-modal";
import "./badges-box.scss";
const MAX_VISIBLE_BADGES = 4;
export function BadgesBox() {
const { userProfile, badges } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const [showAllBadgesModal, setShowAllBadgesModal] = useState(false);
if (!userProfile?.badges.length) return null;
const visibleBadges = userProfile.badges.slice(0, MAX_VISIBLE_BADGES);
const hasMoreBadges = userProfile.badges.length > MAX_VISIBLE_BADGES;
return (
<>
<div className="badges-box__box">
<div className="badges-box__list">
{visibleBadges.map((badgeName) => {
const badge = badges.find((b) => b.name === badgeName);
if (!badge) return null;
return (
<div key={badge.name} className="badges-box__item">
<div className="badges-box__item-icon">
<img
src={badge.badge.url}
alt={badge.name}
width={32}
height={32}
/>
</div>
<div className="badges-box__item-content">
<h3 className="badges-box__item-title">{badge.title}</h3>
<p className="badges-box__item-description">
{badge.description}
</p>
</div>
</div>
);
})}
</div>
{hasMoreBadges && (
<div className="badges-box__view-all-container">
<button
type="button"
className="badges-box__view-all"
onClick={() => setShowAllBadgesModal(true)}
>
{t("view_all")}
</button>
</div>
)}
</div>
<AllBadgesModal
visible={showAllBadgesModal}
onClose={() => setShowAllBadgesModal(false)}
/>
</>
);
}

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import "../../../pages/game-details/modals/delete-review-modal.scss";
interface DeleteSouvenirModalProps {
visible: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteSouvenirModal({
visible,
onClose,
onConfirm,
}: Readonly<DeleteSouvenirModalProps>) {
const { t } = useTranslation("user_profile");
const handleDeleteSouvenir = () => {
onConfirm();
onClose();
};
return (
<Modal
visible={visible}
title={t("delete_souvenir_modal_title")}
description={t("delete_souvenir_modal_description")}
onClose={onClose}
>
<div className="delete-review-modal__actions">
<Button onClick={onClose} theme="outline">
{t("delete_souvenir_modal_cancel_button")}
</Button>
<Button onClick={handleDeleteSouvenir} theme="danger">
{t("delete_souvenir_modal_delete_button")}
</Button>
</div>
</Modal>
);
}

View File

@@ -1,18 +1,34 @@
@use "../../../scss/globals.scss";
.friends-box {
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
&__box {
padding: calc(globals.$spacing-unit * 2);
position: relative;
}
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
&__add-friend-button {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
&:hover {
color: globals.$muted-color;
}
}
&__view-all-container {
padding-top: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
display: flex;
justify-content: flex-start;
}
&__list {
@@ -44,11 +60,12 @@
&__friend-name {
color: globals.$muted-color;
font-weight: bold;
font-size: globals.$body-font-size;
font-size: 0.8rem;
font-weight: 600;
}
&__game-info {
font-size: 0.75rem;
display: flex;
gap: globals.$spacing-unit;
align-items: center;
@@ -63,4 +80,19 @@
&__game-image {
border-radius: 4px;
}
&__view-all {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
&:hover {
color: globals.$muted-color;
}
}
}

View File

@@ -1,15 +1,24 @@
import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useUserDetails } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusIcon } from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar, Link } from "@renderer/components";
import { AllFriendsModal } from "./all-friends-modal";
import { AddFriendModal } from "./add-friend-modal";
import "./friends-box.scss";
const MAX_VISIBLE_FRIENDS = 5;
export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext);
const { userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const [showAllFriendsModal, setShowAllFriendsModal] = useState(false);
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
@@ -28,22 +37,15 @@ export function FriendsBox() {
if (!userProfile?.friends.length) return null;
return (
<div>
<div className="friends-box__section-header">
<div className="profile-content__section-title-group">
<h2>{t("friends")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.friendsCount)}
</span>
)}
</div>
</div>
const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS);
const totalFriends = userProfile.friends.length;
const showViewAllButton = totalFriends > MAX_VISIBLE_FRIENDS;
return (
<>
<div className="friends-box__box">
<ul className="friends-box__list">
{userProfile?.friends.map((friend) => (
{visibleFriends.map((friend) => (
<li
key={friend.id}
title={
@@ -77,7 +79,61 @@ export function FriendsBox() {
</li>
))}
</ul>
{showViewAllButton && (
<div className="friends-box__view-all-container">
<button
type="button"
className="friends-box__view-all"
onClick={() => setShowAllFriendsModal(true)}
>
{t("view_all")}
</button>
</div>
)}
</div>
</div>
{userProfile && (
<>
<AllFriendsModal
visible={showAllFriendsModal}
onClose={() => setShowAllFriendsModal(false)}
userId={userProfile.id}
isMe={isMe}
/>
<AddFriendModal
visible={showAddFriendModal}
onClose={() => setShowAddFriendModal(false)}
/>
</>
)}
</>
);
}
export function FriendsBoxAddButton() {
const { userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
if (!isMe) return null;
return (
<>
<button
type="button"
className="friends-box__add-friend-button"
onClick={() => setShowAddFriendModal(true)}
>
<PlusIcon size={16} />
{t("add_friends")}
</button>
<AddFriendModal
visible={showAddFriendModal}
onClose={() => setShowAddFriendModal(false)}
/>
</>
);
}

View File

@@ -206,6 +206,313 @@
display: block;
}
}
&__images-section {
margin-bottom: calc(globals.$spacing-unit * 3);
}
&__images-grid {
display: grid;
gap: calc(globals.$spacing-unit * 2);
padding-bottom: calc(globals.$spacing-unit);
grid-template-columns: repeat(2, 1fr);
@container #{globals.$app-container} (min-width: 1000px) {
grid-template-columns: repeat(3, 1fr);
}
@container #{globals.$app-container} (min-width: 1300px) {
grid-template-columns: repeat(4, 1fr);
}
@container #{globals.$app-container} (min-width: 2000px) {
grid-template-columns: repeat(5, 1fr);
}
@container #{globals.$app-container} (min-width: 2600px) {
grid-template-columns: repeat(6, 1fr);
}
@container #{globals.$app-container} (min-width: 3000px) {
grid-template-columns: repeat(8, 1fr);
}
}
&__image-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
transition: all ease 0.2s;
position: relative;
container-type: inline-size;
&:hover {
transform: translateY(-4px);
background: rgba(0, 0, 0, 0.4);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
@container (max-width: 240px) {
.profile-content__image-achievement-icon {
width: 20px;
height: 20px;
}
.profile-content__image-achievement-name {
font-size: 13px;
}
.profile-content__image-game-title {
font-size: 11px;
}
}
@container (max-width: 280px) {
.profile-content__image-card-content {
gap: calc(globals.$spacing-unit * 0.75);
}
}
}
&__image-card-header {
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
position: relative;
}
&__image-achievement-image-wrapper {
position: relative;
width: 100%;
height: 100%;
.profile-content__image-button {
width: 100%;
height: 100%;
display: block;
}
&:hover .profile-content__image-achievement-image-overlay {
opacity: 1;
}
}
&__image-achievement-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
display: block;
}
&__image-achievement-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
svg {
color: white;
}
}
&__image-delete-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
background: transparent;
border: 1px solid rgba(244, 67, 54, 0.5);
border-radius: 6px;
padding: 0;
color: rgba(244, 67, 54, 0.9);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 32px;
height: 32px;
&:hover {
background: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.8);
color: #ff7961;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: rgba(244, 67, 54, 0.2);
}
}
// Show overlay on keyboard focus for accessibility
&__image-button:focus-visible + &__image-achievement-image-overlay {
opacity: 1;
}
&__image-card-content {
padding: 16px;
background: #121212;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
overflow: hidden;
}
&__image-card-row {
display: flex;
gap: calc(globals.$spacing-unit * 1.5);
align-items: center;
}
&__image-achievement-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
&__image-achievement-description {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
&__image-card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 8px;
flex-shrink: 0;
height: 100%;
}
&__image-unlock-time {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.9);
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
z-index: 2;
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.1);
}
&__image-achievement-info {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
position: relative;
z-index: 2;
}
&__image-achievement-icon {
width: 24px;
height: 24px;
border-radius: 4px;
flex-shrink: 0;
&--large {
width: 36px;
height: 36px;
}
}
&__image-achievement-name {
font-size: 14px;
font-weight: 600;
color: white;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
flex: 1;
min-width: 0;
}
&__image-game-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 1);
position: relative;
z-index: 2;
min-width: 0;
}
&__image-game-left {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
flex: 1;
min-width: 0;
}
&__image-game-icon {
width: 16px;
height: 16px;
border-radius: 2px;
flex-shrink: 0;
}
&__image-game-title {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
line-height: 1.2;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__image-item {
flex-shrink: 0;
position: relative;
border-radius: 8px;
overflow: hidden;
transition: transform ease 0.2s;
&:hover {
transform: scale(1.05);
}
}
&__image {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
transition: border-color ease 0.2s;
&:hover {
border-color: rgba(255, 255, 255, 0.3);
}
}
}
// Reviews minimal styles

View File

@@ -10,21 +10,26 @@ import {
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon } 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 { BadgesBox } from "./badges-box";
import { FriendsBox, FriendsBoxAddButton } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { UserKarmaBox } from "./user-karma-box";
import { logger } from "@renderer/logger";
import { AnimatePresence } from "framer-motion";
import { ProfileSection } from "../profile-section/profile-section";
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { ProfileTabs } from "./profile-tabs";
import { ProfileTabs, type ProfileTabType } from "./profile-tabs";
import { LibraryTab } from "./library-tab";
import { ReviewsTab } from "./reviews-tab";
import { AnimatePresence } from "framer-motion";
import { SouvenirsTab } from "./souvenirs-tab";
import "./profile-content.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently";
@@ -84,6 +89,7 @@ export function ProfileContent() {
userStats,
libraryGames,
pinnedGames,
getUserProfile,
getUserLibraryGames,
loadMoreLibraryGames,
hasMoreLibraryGames,
@@ -93,9 +99,13 @@ export function ProfileContent() {
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const [fullscreenImage, setFullscreenImage] = useState<{
url: string;
alt: string;
} | null>(null);
const statsAnimation = useRef(-1);
const [activeTab, setActiveTab] = useState<"library" | "reviews">("library");
const [activeTab, setActiveTab] = useState<ProfileTabType>("library");
// User reviews state
const [reviews, setReviews] = useState<UserReview[]>([]);
@@ -186,8 +196,6 @@ export function ProfileContent() {
);
setReviews(response.reviews);
setReviewsTotalCount(response.totalCount);
} catch (error) {
// Error handling for fetching reviews
} finally {
setIsLoadingReviews(false);
}
@@ -207,7 +215,7 @@ export function ProfileContent() {
setReviews((prev) => prev.filter((review) => review.id !== reviewId));
setReviewsTotalCount((prev) => prev - 1);
} catch (error) {
console.error("Failed to delete review:", error);
logger.error("Failed to delete review:", error);
}
};
@@ -302,7 +310,7 @@ export function ProfileContent() {
`/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}`
);
} catch (error) {
console.error("Failed to vote on review:", error);
logger.error("Failed to vote on review:", error);
// Rollback optimistic update on error
setReviews((prev) =>
@@ -336,6 +344,17 @@ export function ProfileContent() {
setIsAnimationRunning(true);
};
const handleImageClick = (imageUrl: string, achievementName: string) => {
setFullscreenImage({
url: imageUrl,
alt: `${achievementName} screenshot`,
});
};
const closeFullscreenImage = () => {
setFullscreenImage(null);
};
useEffect(() => {
let zero = performance.now();
if (!isAnimationRunning) return;
@@ -382,54 +401,103 @@ export function ProfileContent() {
return (
<section className="profile-content__section">
<div className="profile-content__main">
<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>
)}
<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}
/>
)}
{hasAnyGames && (
<div>
<ProfileTabs
activeTab={activeTab}
reviewsTotalCount={reviewsTotalCount}
souvenirsCount={userProfile?.achievements?.length || 0}
onTabChange={setActiveTab}
/>
{activeTab === "reviews" && (
<ReviewsTab
reviews={reviews}
isLoadingReviews={isLoadingReviews}
votingReviews={votingReviews}
userDetailsId={userDetails?.id}
formatPlayTime={formatPlayTime}
getRatingText={getRatingText}
onVote={handleVoteReview}
onDelete={handleDeleteClick}
/>
)}
</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}
/>
)}
{activeTab === "reviews" && (
<ReviewsTab
reviews={reviews}
isLoadingReviews={isLoadingReviews}
votingReviews={votingReviews}
userDetailsId={userDetails?.id}
formatPlayTime={formatPlayTime}
getRatingText={getRatingText}
onVote={handleVoteReview}
onDelete={handleDeleteClick}
/>
)}
{activeTab === "souvenirs" && (
<SouvenirsTab
achievements={userProfile?.achievements || []}
onImageClick={handleImageClick}
isMe={isMe}
onAchievementDeleted={getUserProfile}
/>
)}
</AnimatePresence>
</div>
</div>
)}
</div>
{shouldShowRightContent && (
<div className="profile-content__right-content">
<UserStatsBox />
<UserKarmaBox />
<RecentGamesBox />
<FriendsBox />
{userStats && (
<ProfileSection title={t("stats")} defaultOpen={true}>
<UserStatsBox />
</ProfileSection>
)}
{userProfile?.badges.length > 0 && (
<ProfileSection
title={t("badges")}
count={userProfile.badges.length}
defaultOpen={true}
>
<BadgesBox />
</ProfileSection>
)}
{userProfile?.recentGames.length > 0 && (
<ProfileSection title={t("activity")} defaultOpen={true}>
<RecentGamesBox />
</ProfileSection>
)}
{userProfile?.friends.length > 0 && (
<ProfileSection
title={t("friends")}
count={userStats?.friendsCount || userProfile.friends.length}
action={<FriendsBoxAddButton />}
defaultOpen={true}
>
<FriendsBox />
</ProfileSection>
)}
<ReportProfile />
</div>
)}
@@ -451,15 +519,25 @@ export function ProfileContent() {
statsIndex,
libraryGames,
pinnedGames,
sortBy,
activeTab,
// ensure reviews UI updates correctly
reviews,
reviewsTotalCount,
isLoadingReviews,
votingReviews,
deleteModalVisible,
handleOnMouseEnterGameCard,
handleOnMouseLeaveGameCard,
handleImageClick,
handleLoadMore,
formatPlayTime,
getRatingText,
handleVoteReview,
handleDeleteClick,
userDetails,
animatedGameIdsRef,
hasMoreLibraryGames,
isLoadingLibraryGames,
]);
return (
@@ -467,6 +545,13 @@ export function ProfileContent() {
<ProfileHero />
{content}
<FullscreenImageModal
isOpen={fullscreenImage !== null}
imageUrl={fullscreenImage?.url || ""}
imageAlt={fullscreenImage?.alt || ""}
onClose={closeFullscreenImage}
/>
</div>
);
}

View File

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

View File

@@ -2,19 +2,9 @@
.recent-games {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;
@@ -57,13 +47,15 @@
}
&__game-title {
font-weight: bold;
font-size: 0.8rem;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__game-description {
font-size: 0.75rem;
display: flex;
align-items: center;
gap: globals.$spacing-unit;

View File

@@ -42,38 +42,32 @@ export function RecentGamesBox() {
if (!userProfile?.recentGames.length) return null;
return (
<div>
<div className="recent-games__section-header">
<h2>{t("activity")}</h2>
</div>
<div className="recent-games__box">
<ul className="recent-games__list">
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className="recent-games__list-item"
>
<img
src={game.iconUrl!}
alt={game.title}
className="recent-games__game-image"
/>
<div className="recent-games__box">
<ul className="recent-games__list">
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className="recent-games__list-item"
>
<img
src={game.iconUrl!}
alt={game.title}
className="recent-games__game-image"
/>
<div className="recent-games__game-details">
<span className="recent-games__game-title">{game.title}</span>
<div className="recent-games__game-details">
<span className="recent-games__game-title">{game.title}</span>
<div className="recent-games__game-description">
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
<div className="recent-games__game-description">
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { ChevronDownIcon, ChevronRightIcon } from "@primer/octicons-react";
import { TrashIcon, Maximize2 } from "lucide-react";
import { useState, useMemo } from "react";
import type { ProfileAchievement } from "@types";
import { useToast, useDate } from "@renderer/hooks";
import { logger } from "@renderer/logger";
import { DeleteSouvenirModal } from "./delete-souvenir-modal";
import "./profile-content.scss";
interface SouvenirGameGroupProps {
gameTitle: string;
gameIconUrl: string | null;
achievements: ProfileAchievement[];
isMe: boolean;
deletingIds: Set<string>;
onImageClick: (imageUrl: string, achievementName: string) => void;
onDeleteClick: (achievement: ProfileAchievement) => void;
}
function SouvenirGameGroup({
gameTitle,
gameIconUrl,
achievements,
isMe,
deletingIds,
onImageClick,
onDeleteClick,
}: Readonly<SouvenirGameGroupProps>) {
const { formatDistance } = useDate();
const [isExpanded, setIsExpanded] = useState(true);
return (
<div className="profile-content__images-section">
<button
className="profile-content__section-header"
onClick={() => setIsExpanded(!isExpanded)}
type="button"
style={{
width: "100%",
background: "none",
border: "none",
cursor: "pointer",
color: "inherit",
padding: 0,
}}
>
<div className="profile-content__section-title-group">
<div className="profile-content__collapse-button">
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
</div>
{gameIconUrl && (
<img
src={gameIconUrl}
alt=""
style={{
width: 24,
height: 24,
borderRadius: 4,
objectFit: "cover",
}}
/>
)}
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{gameTitle}
</h3>
<span className="profile-content__section-badge">
{achievements.length}
</span>
</div>
</button>
{isExpanded && (
<div className="profile-content__images-grid">
{achievements.map((achievement, index) => (
<div
key={`${achievement.gameTitle}-${achievement.name}-${index}`}
className="profile-content__image-card"
>
<div className="profile-content__image-card-header">
<div className="profile-content__image-achievement-image-wrapper">
<button
type="button"
className="profile-content__image-button"
onClick={() =>
onImageClick(
achievement.imageUrl,
achievement.displayName
)
}
aria-label={`View ${achievement.displayName} screenshot in fullscreen`}
style={{
cursor: "pointer",
padding: 0,
border: "none",
background: "transparent",
}}
>
<img
src={achievement.imageUrl}
alt={achievement.displayName}
className="profile-content__image-achievement-image"
loading="lazy"
/>
</button>
<div className="profile-content__image-achievement-image-overlay">
<Maximize2 size={24} />
</div>
<span className="profile-content__image-unlock-time">
{formatDistance(
new Date(achievement.unlockTime),
new Date(),
{
addSuffix: true,
}
)}
</span>
</div>
</div>
<div className="profile-content__image-card-content">
<div className="profile-content__image-card-row">
{achievement.achievementIcon && (
<img
src={achievement.achievementIcon}
alt=""
className="profile-content__image-achievement-icon profile-content__image-achievement-icon--large"
loading="lazy"
/>
)}
<div className="profile-content__image-achievement-text">
<span className="profile-content__image-achievement-name">
{achievement.displayName}
</span>
<p className="profile-content__image-achievement-description">
{achievement.description}
</p>
</div>
<div className="profile-content__image-card-right">
{isMe && (
<button
type="button"
className="profile-content__image-delete-button"
onClick={() => onDeleteClick(achievement)}
aria-label={`Delete ${achievement.displayName} souvenir`}
disabled={deletingIds.has(achievement.id)}
>
<TrashIcon size={14} />
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
interface SouvenirsTabProps {
achievements: ProfileAchievement[];
onImageClick: (imageUrl: string, achievementName: string) => void;
isMe: boolean;
onAchievementDeleted: () => void;
}
export function SouvenirsTab({
achievements,
onImageClick,
isMe,
onAchievementDeleted,
}: Readonly<SouvenirsTabProps>) {
const { t } = useTranslation("user_profile");
const { showSuccessToast, showErrorToast } = useToast();
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [achievementToDelete, setAchievementToDelete] =
useState<ProfileAchievement | null>(null);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const handleDeleteAchievement = async (achievement: ProfileAchievement) => {
if (deletingIds.has(achievement.id)) return;
setDeletingIds((prev) => new Set(prev).add(achievement.id));
try {
await window.electron.hydraApi.delete(
`/profile/games/achievements/${achievement.gameId}/${achievement.name}/image`
);
showSuccessToast(
t("souvenir_deleted_successfully", "Souvenir deleted successfully")
);
onAchievementDeleted();
} catch (error) {
logger.error("Failed to delete souvenir:", error);
showErrorToast(
t("souvenir_deletion_failed", "Failed to delete souvenir")
);
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(achievement.id);
return next;
});
}
};
const handleDeleteClick = (achievement: ProfileAchievement) => {
setAchievementToDelete(achievement);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = () => {
if (achievementToDelete) {
handleDeleteAchievement(achievementToDelete);
setAchievementToDelete(null);
}
};
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
setAchievementToDelete(null);
};
const groupedAchievements = useMemo(() => {
const groups: Record<string, ProfileAchievement[]> = {};
for (const achievement of achievements) {
if (!groups[achievement.gameId]) {
groups[achievement.gameId] = [];
}
groups[achievement.gameId].push(achievement);
}
return groups;
}, [achievements]);
return (
<>
<motion.div
key="souvenirs"
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}
>
{achievements.length === 0 && (
<div className="profile-content__no-souvenirs">
<p>{t("no_souvenirs", "No souvenirs yet")}</p>
</div>
)}
{Object.entries(groupedAchievements).map(
([gameId, groupAchievements]) => {
const firstAchievement = groupAchievements[0];
return (
<SouvenirGameGroup
key={gameId}
gameTitle={firstAchievement.gameTitle}
gameIconUrl={firstAchievement.gameIconUrl}
achievements={groupAchievements}
isMe={isMe}
deletingIds={deletingIds}
onImageClick={onImageClick}
onDeleteClick={handleDeleteClick}
/>
);
}
)}
</motion.div>
<DeleteSouvenirModal
visible={deleteModalVisible}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -1,47 +0,0 @@
@use "../../../scss/globals.scss";
.user-karma {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__stats-row {
display: flex;
align-items: center;
color: globals.$body-color;
}
&__description {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
font-weight: 600;
font-size: 1.1rem;
}
&__info {
padding-top: calc(globals.$spacing-unit * 0.5);
}
&__info-text {
color: globals.$muted-color;
font-size: 0.85rem;
line-height: 1.4;
}
}

View File

@@ -1,43 +0,0 @@
import { useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks";
import { Award } from "lucide-react";
import "./user-karma-box.scss";
export function UserKarmaBox() {
const { isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
// Get karma from userDetails (for current user) or userProfile (for other users)
const karma = isMe ? userDetails?.karma : userProfile?.karma;
// Don't show if karma is not available
if (karma === undefined || karma === null) return null;
return (
<div>
<div className="user-karma__section-header">
<h2>{t("karma")}</h2>
</div>
<div className="user-karma__box">
<div className="user-karma__content">
<div className="user-karma__stats-row">
<p className="user-karma__description">
<Award size={20} /> {numberFormatter.format(karma)}{" "}
{t("karma_count")}
</p>
</div>
<div className="user-karma__info">
<small className="user-karma__info-text">
{t("karma_description")}
</small>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,19 +2,9 @@
.user-stats {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;
@@ -42,13 +32,15 @@
}
&__list-title {
font-weight: bold;
font-size: 0.8rem;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__list-description {
font-size: 0.75rem;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
@@ -72,4 +64,10 @@
cursor: pointer;
}
}
&__karma-info-text {
color: globals.$muted-color;
font-size: 0.75rem;
line-height: 1.4;
}
}

View File

@@ -1,16 +1,18 @@
import { useCallback, useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { useFormat } from "@renderer/hooks";
import { useFormat, useUserDetails } from "@renderer/hooks";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { Award } from "lucide-react";
import "./user-stats-box.scss";
export function UserStatsBox() {
const { showHydraCloudModal } = useSubscription();
const { userStats, isMe } = useContext(userProfileContext);
const { userStats, isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
@@ -33,88 +35,102 @@ export function UserStatsBox() {
if (!userStats) return null;
const karma = isMe ? userDetails?.karma : userProfile?.karma;
const hasKarma = karma !== undefined && karma !== null;
return (
<div>
<div className="user-stats__section-header">
<h2>{t("stats")}</h2>
</div>
<div className="user-stats__box">
<ul className="user-stats__list">
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">
{t("achievements_unlocked")}
</h3>
{userStats.unlockedAchievementSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
{t("achievements")}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements")}
className="user-stats__link"
>
<small style={{ color: "var(--color-warning)" }}>
{t("show_achievements_on_profile")}
</small>
</button>
)}
</li>
)}
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("earned_points")}</h3>
{userStats.achievementsPointsEarnedSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<HydraIcon width={20} height={20} />
{numberFormatter.format(
userStats.achievementsPointsEarnedSum.value
)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile:
userStats.achievementsPointsEarnedSum.topPercentile,
})}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className="user-stats__link"
>
<small className="user-stats__link--warning">
{t("show_points_on_profile")}
</small>
</button>
)}
</li>
)}
<div className="user-stats__box">
<ul className="user-stats__list">
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("total_play_time")}</h3>
<h3 className="user-stats__list-title">
{t("achievements_unlocked")}
</h3>
{userStats.unlockedAchievementSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
{t("achievements")}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements")}
className="user-stats__link"
>
<small style={{ color: "var(--color-warning)" }}>
{t("show_achievements_on_profile")}
</small>
</button>
)}
</li>
)}
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("earned_points")}</h3>
{userStats.achievementsPointsEarnedSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<HydraIcon width={20} height={20} />
{numberFormatter.format(
userStats.achievementsPointsEarnedSum.value
)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile:
userStats.achievementsPointsEarnedSum.topPercentile,
})}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className="user-stats__link"
>
<small className="user-stats__link--warning">
{t("show_points_on_profile")}
</small>
</button>
)}
</li>
)}
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("total_play_time")}</h3>
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<ClockIcon />
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
})}
</p>
</div>
</li>
{hasKarma && karma !== undefined && karma !== null && (
<li className="user-stats__list-item user-stats__list-item--karma">
<h3 className="user-stats__list-title">{t("karma")}</h3>
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<ClockIcon />
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
})}
<Award size={20} /> {numberFormatter.format(karma)}{" "}
{t("karma_count")}
</p>
</div>
<div className="user-stats__karma-info">
<small className="user-stats__karma-info-text">
{t("karma_description")}
</small>
</div>
</li>
</ul>
</div>
)}
</ul>
</div>
);
}

View File

@@ -0,0 +1,100 @@
@use "../../../scss/globals.scss";
.wrapped-fullscreen-modal {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: transparent;
width: 100%;
height: 100%;
&__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.9);
border: none;
z-index: 1;
}
&__container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: calc(globals.$spacing-unit * 2);
pointer-events: none;
z-index: 2;
}
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 5);
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: background 0.2s ease;
z-index: 10;
pointer-events: auto;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
&__content {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
pointer-events: auto;
background: rgba(0, 0, 0, 0.5);
}
&__loader {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
&__spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: white;
border-radius: 50%;
animation: wrapped-spin 0.8s linear infinite;
}
&__iframe {
width: 100%;
height: 100%;
border: none;
}
}
@keyframes wrapped-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { XIcon } from "@primer/octicons-react";
import "./wrapped-tab.scss";
interface WrappedFullscreenModalProps {
userId: string;
isOpen: boolean;
onClose: () => void;
}
interface ScaleConfig {
scale: number;
width: number;
height: number;
}
const SCALE_CONFIGS: Record<number, ScaleConfig> = {
0.25: { scale: 0.25, width: 270, height: 480 },
0.3: { scale: 0.3, width: 324, height: 576 },
0.5: { scale: 0.5, width: 540, height: 960 },
};
const getScaleConfigForHeight = (height: number): ScaleConfig => {
if (height >= 1000) return SCALE_CONFIGS[0.5];
if (height >= 650) return SCALE_CONFIGS[0.3];
return SCALE_CONFIGS[0.25];
};
export function WrappedFullscreenModal({
userId,
isOpen,
onClose,
}: Readonly<WrappedFullscreenModalProps>) {
const [config, setConfig] = useState<ScaleConfig>(SCALE_CONFIGS[0.5]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!isOpen) return;
const updateConfig = () => {
setConfig(getScaleConfigForHeight(window.innerHeight));
};
updateConfig();
window.addEventListener("resize", updateConfig);
return () => window.removeEventListener("resize", updateConfig);
}, [isOpen]);
useEffect(() => {
if (isOpen) {
setIsLoading(true);
}
}, [isOpen]);
if (!isOpen) return null;
return (
<dialog className="wrapped-fullscreen-modal" aria-modal="true" open>
<button
type="button"
className="wrapped-fullscreen-modal__backdrop"
onClick={onClose}
aria-label="Close wrapped"
/>
<div className="wrapped-fullscreen-modal__container">
<button
type="button"
className="wrapped-fullscreen-modal__close-button"
onClick={onClose}
aria-label="Close wrapped"
>
<XIcon size={24} />
</button>
<div
className="wrapped-fullscreen-modal__content"
style={{ width: config.width, height: config.height }}
>
{isLoading && (
<div className="wrapped-fullscreen-modal__loader">
<div className="wrapped-fullscreen-modal__spinner" />
</div>
)}
<iframe
src={`https://hydrawrapped.com/embed/${userId}?scale=${config.scale}`}
className="wrapped-fullscreen-modal__iframe"
title="Wrapped 2025"
onLoad={() => setIsLoading(false)}
/>
</div>
</div>
</dialog>
);
}

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