mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
122 Commits
feat/LBX-1
...
feat/souve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4417b26fd | ||
|
|
2919109a11 | ||
|
|
734f8dbce0 | ||
|
|
9769eecec6 | ||
|
|
91adb97013 | ||
|
|
f138b2efcb | ||
|
|
991aa05760 | ||
|
|
aff9e13bca | ||
|
|
240a75c1d0 | ||
|
|
edbe86a1fb | ||
|
|
a01e1b1709 | ||
|
|
60fd90820c | ||
|
|
798f88618e | ||
|
|
40795c34dc | ||
|
|
e335e05628 | ||
|
|
05464f25df | ||
|
|
b9830afca1 | ||
|
|
1cab73bcb4 | ||
|
|
27462c1e1e | ||
|
|
98dc20092a | ||
|
|
9faf34a976 | ||
|
|
d25ac69e74 | ||
|
|
d3fb967229 | ||
|
|
35736dd2d9 | ||
|
|
263d0be4e4 | ||
|
|
0b4d31e482 | ||
|
|
a74b557d13 | ||
|
|
86d5547aa1 | ||
|
|
358f41b4ba | ||
|
|
7f0dc5eee4 | ||
|
|
067f7a00be | ||
|
|
37f085e2c0 | ||
|
|
f8ac284bc2 | ||
|
|
cea5afc7f7 | ||
|
|
dff8d02f3f | ||
|
|
8751e369da | ||
|
|
45eaef23a9 | ||
|
|
3c296fe721 | ||
|
|
1d1bbd2de5 | ||
|
|
246fc14b75 | ||
|
|
387b3ebeac | ||
|
|
b1d72828bb | ||
|
|
4c09f915c6 | ||
|
|
24d65b50b4 | ||
|
|
f77b2116c1 | ||
|
|
6cd65d6239 | ||
|
|
34681b3bc2 | ||
|
|
4b71a3f5bb | ||
|
|
a0f669c97b | ||
|
|
96fca88601 | ||
|
|
3f65bb86a8 | ||
|
|
b494c7c8ec | ||
|
|
e10007b1c3 | ||
|
|
dae825a75a | ||
|
|
677f34fe3d | ||
|
|
1524e73ee6 | ||
|
|
40d428c19e | ||
|
|
affa7a2b2e | ||
|
|
cf16c8245c | ||
|
|
6257529297 | ||
|
|
b8352be274 | ||
|
|
d5e6bed3b7 | ||
|
|
214267df7e | ||
|
|
21f46c9af3 | ||
|
|
cc5e0014f7 | ||
|
|
ccb754fa13 | ||
|
|
142bd3156c | ||
|
|
95a7bc2236 | ||
|
|
78d2be85f2 | ||
|
|
67ea9e78a2 | ||
|
|
67f863e0f3 | ||
|
|
77b6f1b2ad | ||
|
|
5329cc446f | ||
|
|
21a0ad1500 | ||
|
|
9ffaee12d1 | ||
|
|
8555274589 | ||
|
|
a152c89d7f | ||
|
|
879f0baad7 | ||
|
|
c025dc199d | ||
|
|
1552a5f359 | ||
|
|
5b4b258526 | ||
|
|
0268829946 | ||
|
|
3f41f0f7ad | ||
|
|
1545f42d17 | ||
|
|
3105fe1c59 | ||
|
|
4097869ae8 | ||
|
|
bc3d47ed0e | ||
|
|
2adc132c33 | ||
|
|
c4852b89f1 | ||
|
|
e1c60c6e8f | ||
|
|
ccecd8aa4c | ||
|
|
d8d1576cc7 | ||
|
|
29f05b0c3c | ||
|
|
941101702e | ||
|
|
089bdd877d | ||
|
|
bf419edd99 | ||
|
|
28cc25b368 | ||
|
|
a1f419957f | ||
|
|
f1f69e6dbd | ||
|
|
274cb21459 | ||
|
|
af69d19db7 | ||
|
|
594d56db5c | ||
|
|
2634bec292 | ||
|
|
7109c1542c | ||
|
|
68b3de9b7f | ||
|
|
ddbac621fb | ||
|
|
40ab06e87c | ||
|
|
8616729f5d | ||
|
|
1b4a1360a6 | ||
|
|
2da98911b8 | ||
|
|
ba4610705d | ||
|
|
0a4726af44 | ||
|
|
2022ff34cb | ||
|
|
72e6f1e328 | ||
|
|
9b693f2297 | ||
|
|
e3685ba233 | ||
|
|
03d9128768 | ||
|
|
beec415636 | ||
|
|
4fb04b72a3 | ||
|
|
e53e52df1b | ||
|
|
8c0281844e | ||
|
|
cc9d98c360 |
@@ -10,6 +10,7 @@
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
[](https://github.com/hydralauncher/hydra/releases)
|
||||
[](https://community.chocolatey.org/packages/hydralauncher)
|
||||
|
||||

|
||||
|
||||
|
||||
2
proto
2
proto
Submodule proto updated: 7a23620f93...6f11c99c57
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 l’ajout 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 l’image",
|
||||
"edit_game_modal_icon": "Icône",
|
||||
"edit_game_modal_select_icon": "Sélectionner une icône",
|
||||
"edit_game_modal_icon_preview": "Aperçu de l’icô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 l’image 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 l’image de l’icône ici",
|
||||
"edit_game_modal_drop_logo_image_here": "Déposez l’image du logo ici",
|
||||
"edit_game_modal_drop_hero_image_here": "Déposez l’image de la bannière ici",
|
||||
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer l’icô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 l’installation du plugin Decky : {{error}}",
|
||||
"decky_plugin_installation_error": "Erreur lors de l’installation 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 l’envoi de l’avis. Veuillez réessayer.",
|
||||
"review_cannot_be_empty": "Le champ de l’avis ne peut pas être vide.",
|
||||
"review_deleted_successfully": "Avis supprimé avec succès.",
|
||||
"review_deletion_failed": "Échec de la suppression de l’avis.",
|
||||
"loading_reviews": "Chargement des avis…",
|
||||
"loading_more_reviews": "Chargement de plus d’avis…",
|
||||
"load_more_reviews": "Charger plus d’avis",
|
||||
"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 l’avis",
|
||||
"remove_review": "Retirer l’avis",
|
||||
"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 l’enregistrement de votre vote. Veuillez réessayer.",
|
||||
"show_original": "Afficher l’original",
|
||||
"show_translation": "Afficher la traduction",
|
||||
"show_original_translated_from": "Afficher l’original (traduit depuis {{language}})",
|
||||
"hide_original": "Masquer l’original",
|
||||
"review_from_blocked_user": "Avis d’un 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 n’est 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 l’ajout 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 d’un jeu"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Téléchargement terminé",
|
||||
|
||||
@@ -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ő",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
src/main/events/misc/open-folder.ts
Normal file
11
src/main/events/misc/open-folder.ts
Normal 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);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const clearAllLocalNotifications = async () => {
|
||||
await LocalNotificationManager.clearAll();
|
||||
};
|
||||
|
||||
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);
|
||||
11
src/main/events/notifications/delete-local-notification.ts
Normal file
11
src/main/events/notifications/delete-local-notification.ts
Normal 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);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotificationsCount = async () => {
|
||||
return LocalNotificationManager.getUnreadCount();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);
|
||||
8
src/main/events/notifications/get-local-notifications.ts
Normal file
8
src/main/events/notifications/get-local-notifications.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotifications = async () => {
|
||||
return LocalNotificationManager.getNotifications();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotifications", getLocalNotifications);
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const markAllLocalNotificationsRead = async () => {
|
||||
await LocalNotificationManager.markAllAsRead();
|
||||
};
|
||||
|
||||
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);
|
||||
@@ -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);
|
||||
@@ -1,4 +1,3 @@
|
||||
import "./get-me";
|
||||
import "./process-profile-image";
|
||||
import "./sync-friend-requests";
|
||||
import "./update-profile";
|
||||
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./keys";
|
||||
export * from "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./downloadSourcesCheckTimestamp";
|
||||
export * from "./local-notifications";
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
11
src/main/level/sublevels/local-notifications.ts
Normal file
11
src/main/level/sublevels/local-notifications.ts
Normal 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",
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
150
src/main/services/achievements/achievement-image-service.ts
Normal file
150
src/main/services/achievements/achievement-image-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const getModifiedSinceHeader = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (userLanguage != cachedAchievements.language) {
|
||||
if (userLanguage !== cachedAchievements.language) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
86
src/main/services/hosters/buzzheavier.ts
Normal file
86
src/main/services/hosters/buzzheavier.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
129
src/main/services/hosters/fuckingfast.ts
Normal file
129
src/main/services/hosters/fuckingfast.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export * from "./qiwi";
|
||||
export * from "./datanodes";
|
||||
export * from "./mediafire";
|
||||
export * from "./pixeldrain";
|
||||
export * from "./buzzheavier";
|
||||
export * from "./fuckingfast";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
99
src/main/services/notifications/local-notifications.ts
Normal file
99
src/main/services/notifications/local-notifications.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
179
src/main/services/screenshot.ts
Normal file
179
src/main/services/screenshot.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
8
src/main/services/ws/events/notification.ts
Normal file
8
src/main/services/ws/events/notification.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FullscreenImageModal } from "./fullscreen-image-modal";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
17
src/renderer/src/declaration.d.ts
vendored
17
src/renderer/src/declaration.d.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
103
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal file
103
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/renderer/src/pages/notifications/notification-item.scss
Normal file
127
src/renderer/src/pages/notifications/notification-item.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
228
src/renderer/src/pages/notifications/notification-item.tsx
Normal file
228
src/renderer/src/pages/notifications/notification-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/renderer/src/pages/notifications/notifications.scss
Normal file
58
src/renderer/src/pages/notifications/notifications.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
400
src/renderer/src/pages/notifications/notifications.tsx
Normal file
400
src/renderer/src/pages/notifications/notifications.tsx
Normal 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()}</>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
286
src/renderer/src/pages/profile/profile-content/souvenirs-tab.tsx
Normal file
286
src/renderer/src/pages/profile/profile-content/souvenirs-tab.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
100
src/renderer/src/pages/profile/profile-content/wrapped-tab.scss
Normal file
100
src/renderer/src/pages/profile/profile-content/wrapped-tab.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user