diff --git a/.env.example b/.env.example index 051d8aa3..e13fc1bd 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ MAIN_VITE_API_URL= MAIN_VITE_AUTH_URL= MAIN_VITE_WS_URL= +MAIN_VITE_NIMBUS_API_URL= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_TORBOX_REFERRAL_CODE= MAIN_VITE_LAUNCHER_SUBDOMAIN= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5086d8e5..31354bc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,7 @@ jobs: MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -73,6 +74,7 @@ jobs: MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df01b358..45163c4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,9 +54,10 @@ jobs: MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} - MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} @@ -71,9 +72,10 @@ jobs: MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} + MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} - MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} diff --git a/README.md b/README.md index c086cb2e..79dc4b6e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) [![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases) +[![chocolatey](https://img.shields.io/chocolatey/v/hydralauncher.svg)](https://community.chocolatey.org/packages/hydralauncher) ![Hydra Launcher Home Page](./docs/screenshot.png) diff --git a/package.json b/package.json index bb74198f..09e7ceda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.6", + "version": "3.8.0", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -91,6 +91,7 @@ "user-agents": "^1.1.387", "uuid": "^13.0.0", "winreg": "^1.2.5", + "workwonders-sdk": "0.0.10", "ws": "^8.18.1", "yaml": "^2.6.1", "yup": "^1.5.0" diff --git a/proto b/proto index 7a23620f..6f11c99c 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 7a23620f930f6fbb84c0abcaab5149a34ab4b4eb +Subproject commit 6f11c99c572420a282ba5149b6866e39b8a4569c diff --git a/python_rpc/http_downloader.py b/python_rpc/http_downloader.py index 71e4b57e..40428bd7 100644 --- a/python_rpc/http_downloader.py +++ b/python_rpc/http_downloader.py @@ -1,4 +1,5 @@ import aria2p +from aria2p.client import ClientException as DownloadNotFound class HttpDownloader: def __init__(self): @@ -11,12 +12,16 @@ class HttpDownloader: ) ) - def start_download(self, url: str, save_path: str, header: str, out: str = None): + def start_download(self, url: str, save_path: str, header, out: str = None): if self.download: self.aria2.resume([self.download]) else: - downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out}) - + options = {"dir": save_path} + if header: + options["header"] = header + if out: + options["out"] = out + downloads = self.aria2.add(url, options=options) self.download = downloads[0] def pause_download(self): @@ -32,7 +37,11 @@ class HttpDownloader: if self.download == None: return None - download = self.aria2.get_download(self.download.gid) + try: + download = self.aria2.get_download(self.download.gid) + except DownloadNotFound: + self.download = None + return None response = { 'folderName': download.name, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9be4ff26..87ad52b3 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", @@ -174,6 +175,7 @@ "repacks_modal_description": "Choose the repack you want to download", "select_folder_hint": "To change the default folder, go to the <0>Settings", "download_now": "Download now", + "loading": "Loading...", "no_shop_details": "Could not retrieve shop details.", "download_options": "Download options", "download_path": "Download path", @@ -183,6 +185,12 @@ "open_screenshot": "Open screenshot {{number}}", "download_settings": "Download settings", "downloader": "Downloader", + "downloader_online": "Online", + "downloader_not_configured": "Available but not configured", + "downloader_offline": "Link is offline", + "downloader_not_available": "Not available", + "recommended": "Recommended", + "go_to_settings": "Go to Settings", "select_executable": "Select", "no_executable_selected": "No executable selected", "open_folder": "Open folder", @@ -420,7 +428,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", @@ -556,6 +566,7 @@ "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_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", @@ -660,6 +671,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 +682,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 +715,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", @@ -718,10 +735,12 @@ "game_added_to_pinned": "Game added to pinned", "karma": "Karma", "karma_count": "karma", - "karma_description": "Earned from positive likes on reviews", "user_reviews": "Reviews", "delete_review": "Delete Review", - "loading_reviews": "Loading reviews..." + "loading_reviews": "Loading reviews...", + "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 +791,41 @@ "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_unread": "Unread", + "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" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 12dae377..666dd065 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -182,6 +182,12 @@ "open_screenshot": "Abrir captura número {{number}}", "download_settings": "Descargar ajustes", "downloader": "Descargador", + "downloader_online": "En línea", + "downloader_not_configured": "Disponible pero no configurado", + "downloader_offline": "El enlace está fuera de línea", + "downloader_not_available": "No disponible", + "recommended": "Recomendado", + "go_to_settings": "Ir a Ajustes", "select_executable": "Seleccionar", "no_executable_selected": "Sin ejecutable seleccionado", "open_folder": "Abrir carpeta", @@ -651,6 +657,7 @@ "sending": "Enviando", "friend_request_sent": "Solicitud de amistad enviada", "friends": "Amistades", + "badges": "Insignias", "friends_list": "Lista de amistades", "user_not_found": "Usuario no encontrado", "block_user": "Bloquear usuario", @@ -661,12 +668,16 @@ "ignore_request": "Ignorar solicitud", "cancel_request": "Cancelar solicitud", "undo_friendship": "Deshacer amistad", + "friendship_removed": "Amigo eliminado", "request_accepted": "Solicitud aceptada", "user_blocked_successfully": "Usuario bloqueado exitosamente", "user_block_modal_text": "Esto va a bloquear a {{displayName}}", "blocked_users": "Usuarios bloqueados", "unblock": "Desbloquear", "no_friends_added": "No tenés amistades añadidas", + "view_all": "Ver todo", + "load_more": "Cargar más", + "loading": "Cargando", "pending": "Pendiente", "no_pending_invites": "No tenés invitaciones pendientes", "no_blocked_users": "No has bloqueado a nadie", @@ -690,6 +701,7 @@ "report_reason_other": "Otros", "profile_reported": "Perfil reportado", "your_friend_code": "Tu código de amistad:", + "copy_friend_code": "Copiar código de amistad", "upload_banner": "Subir banner", "uploading_banner": "Subiendo banner…", "background_image_updated": "Imagen de fondo actualizada", @@ -710,11 +722,13 @@ "amount_minutes_short": "{{amount}}m", "karma": "Karma", "karma_count": "karma", - "karma_description": "Conseguido por me gustas positivos en reseñas", "sort_by": "Filtrar por:", "game_added_to_pinned": "Juego añadido a fijados", "user_reviews": "Reseñas", "loading_reviews": "Cargando reseñas...", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "Ver Mi Wrapped 2025", + "view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}", "no_reviews": "Sin reseñas aún", "delete_review": "Eliminar reseña" }, @@ -767,5 +781,41 @@ "all_games": "Todos los Juegos", "recently_played": "Jugados Recientemente", "favorites": "Favoritos" + }, + "notifications_page": { + "title": "Notificaciones", + "mark_all_as_read": "Marcar todo como leído", + "clear_all": "Limpiar todo", + "loading": "Cargando...", + "empty_title": "Sin notificaciones", + "empty_description": "¡Estás al día! Volvé más tarde para ver nuevas actualizaciones.", + "empty_filter_description": "No hay notificaciones que coincidan con este filtro.", + "filter_all": "Todas", + "filter_unread": "No leídas", + "filter_friends": "Amigos", + "filter_badges": "Insignias", + "filter_upvotes": "Votos", + "filter_local": "Locales", + "load_more": "Cargar más", + "dismiss": "Descartar", + "accept": "Aceptar", + "refuse": "Rechazar", + "notification": "Notificación", + "friend_request_received_title": "¡Nueva solicitud de amistad!", + "friend_request_received_description": "{{displayName}} quiere ser tu amigo", + "friend_request_accepted_title": "¡Solicitud de amistad aceptada!", + "friend_request_accepted_description": "{{displayName}} aceptó tu solicitud de amistad", + "badge_received_title": "¡Obtuviste una nueva insignia!", + "badge_received_description": "{{badgeName}}", + "review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!", + "review_upvote_description": "Tu reseña recibió {{count}} nuevos votos", + "marked_all_as_read": "Todas las notificaciones marcadas como leídas", + "failed_to_mark_as_read": "Error al marcar las notificaciones como leídas", + "cleared_all": "Todas las notificaciones eliminadas", + "failed_to_clear": "Error al eliminar las notificaciones", + "failed_to_load": "Error al cargar las notificaciones", + "failed_to_dismiss": "Error al descartar la notificación", + "friend_request_accepted": "Solicitud de amistad aceptada", + "friend_request_refused": "Solicitud de amistad rechazada" } } diff --git a/src/locales/fi/translation.json b/src/locales/fi/translation.json index fee3ff22..05268771 100644 --- a/src/locales/fi/translation.json +++ b/src/locales/fi/translation.json @@ -673,8 +673,7 @@ "game_removed_from_pinned": "Peli poistettu kiinnitetyistä", "game_added_to_pinned": "Peli lisätty kiinnitettyihin", "karma": "Karma", - "karma_count": "karmaa", - "karma_description": "Ansittu positiivisilla arvosteluäänillä" + "karma_count": "karmaa" }, "achievement": { "achievement_unlocked": "Saavutus avattu", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 8fc07722..48bf6086 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -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é", diff --git a/src/locales/lv/translation.json b/src/locales/lv/translation.json index 26aacb74..4b87dade 100644 --- a/src/locales/lv/translation.json +++ b/src/locales/lv/translation.json @@ -673,8 +673,7 @@ "game_removed_from_pinned": "Spēle dzēsta no piespraustajiem", "game_added_to_pinned": "Spēle pievienota piespraustajiem", "karma": "Karma", - "karma_count": "karma", - "karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem" + "karma_count": "karma" }, "achievement": { "achievement_unlocked": "Sasniegums atbloķēts", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index ee0da176..ee5ef5dd 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -172,6 +172,12 @@ "open_screenshot": "Ver captura de tela {{number}}", "download_settings": "Ajustes do download", "downloader": "Downloader", + "downloader_online": "Online", + "downloader_not_configured": "Disponível mas não configurado", + "downloader_offline": "Link está offline", + "downloader_not_available": "Não disponível", + "recommended": "Recomendado", + "go_to_settings": "Ir para Configurações", "select_executable": "Explorar", "no_executable_selected": "Nenhum executável selecionado", "open_folder": "Abrir pasta", @@ -408,7 +414,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", @@ -652,6 +660,7 @@ "see_profile": "Ver perfil", "friend_request_sent": "Pedido de amizade enviado", "friends": "Amigos", + "badges": "Insígnias", "add": "Adicionar", "sending": "Enviando", "friends_list": "Lista de amigos", @@ -664,12 +673,16 @@ "ignore_request": "Ignorar pedido", "cancel_request": "Cancelar pedido", "undo_friendship": "Desfazer amizade", + "friendship_removed": "Amigo removido", "request_accepted": "Pedido de amizade aceito", "user_blocked_successfully": "Usuário bloqueado com sucesso", "user_block_modal_text": "Bloquear {{displayName}}", "blocked_users": "Usuários bloqueados", "unblock": "Desbloquear", "no_friends_added": "Você ainda não possui amigos adicionados", + "view_all": "Ver todos", + "load_more": "Carregar mais", + "loading": "Carregando", "pending": "Pendentes", "no_pending_invites": "Você não possui convites de amizade pendentes", "no_blocked_users": "Você não tem nenhum usuário bloqueado", @@ -693,6 +706,7 @@ "report_reason_other": "Outro", "profile_reported": "Perfil reportado", "your_friend_code": "Seu código de amigo:", + "copy_friend_code": "Copiar código de amigo", "upload_banner": "Carregar banner", "uploading_banner": "Carregando banner…", "background_image_updated": "Imagem de fundo salva", @@ -718,10 +732,12 @@ "achievements_earned": "Conquistas recebidas", "karma": "Karma", "karma_count": "karma", - "karma_description": "Ganho a partir de curtidas positivas em avaliações", "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", "user_reviews": "Avaliações", "loading_reviews": "Carregando avaliações...", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "Ver Meu Wrapped 2025", + "view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}", "no_reviews": "Ainda não há avaliações", "delete_review": "Excluir avaliação" }, @@ -774,5 +790,41 @@ "all_games": "Todos os Jogos", "recently_played": "Jogados Recentemente", "favorites": "Favoritos" + }, + "notifications_page": { + "title": "Notificações", + "mark_all_as_read": "Marcar todas como lidas", + "clear_all": "Limpar todas", + "loading": "Carregando...", + "empty_title": "Sem notificações", + "empty_description": "Você está em dia! Volte mais tarde para ver novas atualizações.", + "empty_filter_description": "Nenhuma notificação corresponde a este filtro.", + "filter_all": "Todas", + "filter_unread": "Não lidas", + "filter_friends": "Amigos", + "filter_badges": "Insígnias", + "filter_upvotes": "Votos", + "filter_local": "Locais", + "load_more": "Carregar mais", + "dismiss": "Descartar", + "accept": "Aceitar", + "refuse": "Recusar", + "notification": "Notificação", + "friend_request_received_title": "Nova solicitação de amizade!", + "friend_request_received_description": "{{displayName}} quer ser seu amigo", + "friend_request_accepted_title": "Solicitação de amizade aceita!", + "friend_request_accepted_description": "{{displayName}} aceitou sua solicitação de amizade", + "badge_received_title": "Você recebeu uma nova insígnia!", + "badge_received_description": "{{badgeName}}", + "review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!", + "review_upvote_description": "Sua avaliação recebeu {{count}} novos votos", + "marked_all_as_read": "Todas as notificações marcadas como lidas", + "failed_to_mark_as_read": "Falha ao marcar notificações como lidas", + "cleared_all": "Todas as notificações limpas", + "failed_to_clear": "Falha ao limpar notificações", + "failed_to_load": "Falha ao carregar notificações", + "failed_to_dismiss": "Falha ao descartar notificação", + "friend_request_accepted": "Solicitação de amizade aceita", + "friend_request_refused": "Solicitação de amizade recusada" } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 1cf7ae2f..ff863617 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -182,6 +182,12 @@ "open_screenshot": "Открыть скриншот {{number}}", "download_settings": "Параметры загрузки", "downloader": "Загрузчик", + "downloader_online": "Онлайн", + "downloader_not_configured": "Доступен, но не настроен", + "downloader_offline": "Ссылка недоступна", + "downloader_not_available": "Недоступно", + "recommended": "Рекомендуется", + "go_to_settings": "Перейти в настройки", "select_executable": "Выбрать", "no_executable_selected": "Файл не выбран", "open_folder": "Открыть папку", @@ -651,6 +657,7 @@ "sending": "Отправка", "friend_request_sent": "Запрос в друзья отправлен", "friends": "Друзья", + "badges": "Значки", "friends_list": "Список друзей", "user_not_found": "Пользователь не найден", "block_user": "Заблокировать пользователя", @@ -661,12 +668,16 @@ "ignore_request": "Игнорировать запрос", "cancel_request": "Отменить запрос", "undo_friendship": "Удалить друга", + "friendship_removed": "Друг удален", "request_accepted": "Запрос принят", "user_blocked_successfully": "Пользователь успешно заблокирован", "user_block_modal_text": "{{displayName}} будет заблокирован", "blocked_users": "Заблокированные пользователи", "unblock": "Разблокировать", "no_friends_added": "Вы ещё не добавили ни одного друга", + "view_all": "Показать все", + "load_more": "Загрузить еще", + "loading": "Загрузка", "pending": "Ожидание", "no_pending_invites": "У вас нет запросов ожидающих ответа", "no_blocked_users": "Вы не заблокировали ни одного пользователя", @@ -690,6 +701,7 @@ "report_reason_other": "Другое", "profile_reported": "Жалоба на профиль отправлена", "your_friend_code": "Код вашего друга:", + "copy_friend_code": "Копировать код друга", "upload_banner": "Загрузить баннер", "uploading_banner": "Загрузка баннера...", "background_image_updated": "Фоновое изображение обновлено", @@ -709,9 +721,11 @@ "game_added_to_pinned": "Игра добавлена в закрепленные", "karma": "Карма", "karma_count": "карма", - "karma_description": "Заработана положительными оценками отзывов", "user_reviews": "Отзывы", "loading_reviews": "Загрузка отзывов...", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "Просмотреть мой Wrapped 2025", + "view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}", "no_reviews": "Пока нет отзывов", "delete_review": "Удалить отзыв" }, @@ -764,5 +778,41 @@ "all_games": "Все игры", "recently_played": "Недавно сыгранные", "favorites": "Избранное" + }, + "notifications_page": { + "title": "Уведомления", + "mark_all_as_read": "Отметить все как прочитанные", + "clear_all": "Очистить все", + "loading": "Загрузка...", + "empty_title": "Нет уведомлений", + "empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.", + "empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.", + "filter_all": "Все", + "filter_unread": "Непрочитанные", + "filter_friends": "Друзья", + "filter_badges": "Значки", + "filter_upvotes": "Голоса", + "filter_local": "Локальные", + "load_more": "Загрузить еще", + "dismiss": "Отклонить", + "accept": "Принять", + "refuse": "Отклонить", + "notification": "Уведомление", + "friend_request_received_title": "Новый запрос в друзья!", + "friend_request_received_description": "{{displayName}} хочет добавить вас в друзья", + "friend_request_accepted_title": "Запрос в друзья принят!", + "friend_request_accepted_description": "{{displayName}} принял ваш запрос в друзья", + "badge_received_title": "Вы получили новый значок!", + "badge_received_description": "{{badgeName}}", + "review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!", + "review_upvote_description": "Ваш отзыв получил {{count}} новых голосов", + "marked_all_as_read": "Все уведомления отмечены как прочитанные", + "failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные", + "cleared_all": "Все уведомления очищены", + "failed_to_clear": "Не удалось очистить уведомления", + "failed_to_load": "Не удалось загрузить уведомления", + "failed_to_dismiss": "Не удалось отклонить уведомление", + "friend_request_accepted": "Запрос в друзья принят", + "friend_request_refused": "Запрос в друзья отклонен" } } diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 52e1f10f..94d12ee3 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -706,7 +706,6 @@ "game_added_to_pinned": "Oyun sabitlenmişlere eklendi", "karma": "Karma", "karma_count": "karma", - "karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır", "user_reviews": "İncelemeler", "delete_review": "İncelemeyi Sil", "loading_reviews": "İncelemeler yükleniyor..." diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 323d8ad5..32c79f9c 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -668,8 +668,7 @@ "game_removed_from_pinned": "Гру видалено із закріплених", "game_added_to_pinned": "Гру додано до закріплених", "karma": "Карма", - "karma_count": "карма", - "karma_description": "Зароблена позитивними оцінками на відгуках" + "karma_count": "карма" }, "achievement": { "achievement_unlocked": "Досягнення розблоковано", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index bfc353d9..094a49ca 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -689,7 +689,6 @@ "game_removed_from_pinned": "游戏已从置顶移除", "karma": "业力", "karma_count": "业力值", - "karma_description": "通过评论获得的点赞", "loading_reviews": "正在加载评价...", "manual_playtime_tooltip": "该游戏时长已手动更新", "pinned": "已置顶", diff --git a/src/main/events/notifications/clear-all-local-notifications.ts b/src/main/events/notifications/clear-all-local-notifications.ts new file mode 100644 index 00000000..8a72b894 --- /dev/null +++ b/src/main/events/notifications/clear-all-local-notifications.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const clearAllLocalNotifications = async () => { + await LocalNotificationManager.clearAll(); +}; + +registerEvent("clearAllLocalNotifications", clearAllLocalNotifications); diff --git a/src/main/events/notifications/delete-local-notification.ts b/src/main/events/notifications/delete-local-notification.ts new file mode 100644 index 00000000..0d22877b --- /dev/null +++ b/src/main/events/notifications/delete-local-notification.ts @@ -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); diff --git a/src/main/events/notifications/get-local-notifications-count.ts b/src/main/events/notifications/get-local-notifications-count.ts new file mode 100644 index 00000000..072e74d5 --- /dev/null +++ b/src/main/events/notifications/get-local-notifications-count.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const getLocalNotificationsCount = async () => { + return LocalNotificationManager.getUnreadCount(); +}; + +registerEvent("getLocalNotificationsCount", getLocalNotificationsCount); diff --git a/src/main/events/notifications/get-local-notifications.ts b/src/main/events/notifications/get-local-notifications.ts new file mode 100644 index 00000000..b15eef86 --- /dev/null +++ b/src/main/events/notifications/get-local-notifications.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const getLocalNotifications = async () => { + return LocalNotificationManager.getNotifications(); +}; + +registerEvent("getLocalNotifications", getLocalNotifications); diff --git a/src/main/events/notifications/index.ts b/src/main/events/notifications/index.ts index c6e681e8..cbae29e5 100644 --- a/src/main/events/notifications/index.ts +++ b/src/main/events/notifications/index.ts @@ -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"; diff --git a/src/main/events/notifications/mark-all-local-notifications-read.ts b/src/main/events/notifications/mark-all-local-notifications-read.ts new file mode 100644 index 00000000..a8ae3729 --- /dev/null +++ b/src/main/events/notifications/mark-all-local-notifications-read.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const markAllLocalNotificationsRead = async () => { + await LocalNotificationManager.markAllAsRead(); +}; + +registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead); diff --git a/src/main/events/notifications/mark-local-notification-read.ts b/src/main/events/notifications/mark-local-notification-read.ts new file mode 100644 index 00000000..6958c258 --- /dev/null +++ b/src/main/events/notifications/mark-local-notification-read.ts @@ -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); diff --git a/src/main/events/profile/index.ts b/src/main/events/profile/index.ts index 1548249f..664d6ee2 100644 --- a/src/main/events/profile/index.ts +++ b/src/main/events/profile/index.ts @@ -1,4 +1,3 @@ import "./get-me"; import "./process-profile-image"; -import "./sync-friend-requests"; import "./update-profile"; diff --git a/src/main/events/profile/sync-friend-requests.ts b/src/main/events/profile/sync-friend-requests.ts deleted file mode 100644 index 478c337f..00000000 --- a/src/main/events/profile/sync-friend-requests.ts +++ /dev/null @@ -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(`/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); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 4375698f..e44ba936 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -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 }; } diff --git a/src/main/generated/envelope.ts b/src/main/generated/envelope.ts index 0a17a2af..ace32b2d 100644 --- a/src/main/generated/envelope.ts +++ b/src/main/generated/envelope.ts @@ -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 { */ export const FriendGameSession = new FriendGameSession$Type(); // @generated message type with reflection information, may provide speed optimized methods +class Notification$Type extends MessageType { + constructor() { + super("Notification", [ + { + no: 1, + name: "notification_count", + kind: "scalar", + T: 5 /*ScalarType.INT32*/, + }, + ]); + } + create(value?: PartialMessage): Notification { + const message = globalThis.Object.create(this.messagePrototype!); + message.notificationCount = 0; + if (value !== undefined) + reflectionMergePartial(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 { constructor() { super("Envelope", [ @@ -256,6 +346,13 @@ class Envelope$Type extends MessageType { oneof: "payload", T: () => FriendGameSession, }, + { + no: 3, + name: "notification", + kind: "message", + oneof: "payload", + T: () => Notification, + }, ]); } create(value?: PartialMessage): Envelope { @@ -298,6 +395,17 @@ class Envelope$Type extends MessageType { ), }; 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 { 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)( diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 4575bbc4..54cf2e62 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -8,3 +8,4 @@ export * from "./keys"; export * from "./themes"; export * from "./download-sources"; export * from "./downloadSourcesCheckTimestamp"; +export * from "./local-notifications"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 89c33f8d..d055d1e6 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -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", }; diff --git a/src/main/level/sublevels/local-notifications.ts b/src/main/level/sublevels/local-notifications.ts new file mode 100644 index 00000000..847a1c99 --- /dev/null +++ b/src/main/level/sublevels/local-notifications.ts @@ -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", +}); diff --git a/src/main/main.ts b/src/main/main.ts index 86bfb458..82ea7c47 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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(); }); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index 928e3d52..169c199e 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -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( + 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( diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index c208fa32..bc6746e2 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -8,6 +8,7 @@ import { DatanodesApi, MediafireApi, PixelDrainApi, + VikingFileApi, } from "../hosters"; import { PythonRPC } from "../python-rpc"; import { @@ -20,14 +21,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[] @@ -80,14 +151,28 @@ export class DownloadManager { if (!isDownloadingMetadata && !isCheckingFiles) { if (!download) return null; - await downloadsSublevel.put(downloadId, { + const updatedDownload = { ...download, bytesDownloaded, fileSize, progress, folderName, - status: "active", - }); + status: "active" as const, + }; + + await downloadsSublevel.put(downloadId, updatedDownload); + + return { + numPeers, + numSeeds, + downloadSpeed, + timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), + isDownloadingMetadata, + isCheckingFiles, + progress, + gameId: downloadId, + download: updatedDownload, + } as DownloadProgress; } return { @@ -121,15 +206,19 @@ export class DownloadManager { const userPreferences = await db.get( 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 +232,6 @@ export class DownloadManager { shouldSeed: true, queued: false, extracting: shouldExtract, - extractionProgress: shouldExtract ? 0 : download.extractionProgress, }); } else { await downloadsSublevel.put(gameId, { @@ -152,22 +240,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 +257,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 +285,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 +343,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 +377,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 +418,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 +478,6 @@ export class DownloadManager { }; case Downloader.RealDebrid: { const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); - if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); return { @@ -392,7 +490,6 @@ export class DownloadManager { } case Downloader.TorBox: { const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); - if (!url) return; return { action: "start", @@ -407,7 +504,6 @@ export class DownloadManager { const downloadUrl = await HydraDebridClient.getDownloadUrl( download.uri ); - if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); return { @@ -418,6 +514,29 @@ export class DownloadManager { allow_multiple_connections: true, }; } + case Downloader.VikingFile: { + logger.log( + `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` + ); + try { + const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); + logger.log(`[DownloadManager] VikingFile direct URL obtained`); + return { + action: "start", + game_id: downloadId, + url: downloadUrl, + save_path: download.downloadPath, + header: + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + }; + } catch (error) { + logger.error( + `[DownloadManager] Error processing VikingFile download:`, + error + ); + throw error; + } + } } } diff --git a/src/main/services/hosters/buzzheavier.ts b/src/main/services/hosters/buzzheavier.ts new file mode 100644 index 00000000..581f5a87 --- /dev/null +++ b/src/main/services/hosters/buzzheavier.ts @@ -0,0 +1,100 @@ +import axios from "axios"; +import http from "node:http"; +import https from "node:https"; +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 { + 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, + httpAgent: new http.Agent({ + family: 4, // Force IPv4 + }), + httpsAgent: new https.Agent({ + family: 4, // Force IPv4 + }), + }); + + 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, + httpAgent: new http.Agent({ + family: 4, // Force IPv4 + }), + httpsAgent: new https.Agent({ + family: 4, // Force IPv4 + }), + }); + + 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 { + 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 { + return extractHosterFilename(url, directUrl); + } +} diff --git a/src/main/services/hosters/fuckingfast.ts b/src/main/services/hosters/fuckingfast.ts new file mode 100644 index 00000000..00d0ff58 --- /dev/null +++ b/src/main/services/hosters/fuckingfast.ts @@ -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 { + 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 { + 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 { + 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 { + return extractHosterFilename(url, directUrl); + } +} diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts index 3f3b9ac9..e22fb680 100644 --- a/src/main/services/hosters/index.ts +++ b/src/main/services/hosters/index.ts @@ -3,3 +3,6 @@ export * from "./qiwi"; export * from "./datanodes"; export * from "./mediafire"; export * from "./pixeldrain"; +export * from "./buzzheavier"; +export * from "./fuckingfast"; +export * from "./vikingfile"; diff --git a/src/main/services/hosters/vikingfile.ts b/src/main/services/hosters/vikingfile.ts new file mode 100644 index 00000000..0c6d30dc --- /dev/null +++ b/src/main/services/hosters/vikingfile.ts @@ -0,0 +1,46 @@ +import axios from "axios"; +import { logger } from "../logger"; + +interface UnlockResponse { + link: string; + hoster: string; +} + +export class VikingFileApi { + public static async getDownloadUrl(uri: string): Promise { + const unlockResponse = await axios.post( + `${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`, + { url: uri } + ); + + if (!unlockResponse.data.link) { + throw new Error("Failed to unlock VikingFile URL"); + } + + const redirectUrl = unlockResponse.data.link; + + try { + const redirectResponse = await axios.head(redirectUrl, { + maxRedirects: 0, + validateStatus: (status) => + status === 301 || status === 302 || status === 200, + }); + + if ( + redirectResponse.headers.location || + redirectResponse.status === 301 || + redirectResponse.status === 302 + ) { + return redirectResponse.headers.location || redirectUrl; + } + + return redirectUrl; + } catch (error) { + logger.error( + `[VikingFile] Error following redirect, using redirect URL:`, + error + ); + return redirectUrl; + } + } +} diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index fa712105..596b0635 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -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; diff --git a/src/main/services/index.ts b/src/main/services/index.ts index a3891dc6..e6ceef03 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -20,3 +20,4 @@ export * from "./lock"; export * from "./decky-plugin"; export * from "./user"; export * from "./download-sources-checker"; +export * from "./notifications/local-notifications"; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index a925e7c7..0fa07c8c 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -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: { diff --git a/src/main/services/notifications/local-notifications.ts b/src/main/services/notifications/local-notifications.ts new file mode 100644 index 00000000..94b832df --- /dev/null +++ b/src/main/services/notifications/local-notifications.ts @@ -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 { + 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 { + 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 { + let count = 0; + + for await (const [, value] of localNotificationsSublevel.iterator()) { + if (!value.isRead) { + count++; + } + } + + return count; + } + + static async markAsRead(id: string): Promise { + const notification = await localNotificationsSublevel.get(id); + if (notification) { + notification.isRead = true; + await localNotificationsSublevel.put(id, notification); + } + } + + static async markAllAsRead(): Promise { + 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 { + await localNotificationsSublevel.del(id); + } + + static async clearAll(): Promise { + await localNotificationsSublevel.clear(); + } +} diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 2a1dce79..d04b00ab 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import http from "node:http"; import cp from "node:child_process"; import fs from "node:fs"; @@ -31,6 +32,9 @@ export class PythonRPC { public static readonly RPC_PORT = "8084"; public static readonly rpc = axios.create({ baseURL: `http://localhost:${this.RPC_PORT}`, + httpAgent: new http.Agent({ + family: 4, // Force IPv4 + }), }); private static pythonProcess: cp.ChildProcess | null = null; diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 04c77619..26d13228 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -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, } diff --git a/src/main/services/ws/events/notification.ts b/src/main/services/ws/events/notification.ts new file mode 100644 index 00000000..d38ec4c3 --- /dev/null +++ b/src/main/services/ws/events/notification.ts @@ -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, + }); +}; diff --git a/src/main/services/ws/ws-client.ts b/src/main/services/ws/ws-client.ts index e2e9d550..19b4b397 100644 --- a/src/main/services/ws/ws-client.ts +++ b/src/main/services/ws/ws-client.ts @@ -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")); diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index 7b0ed536..888d8329 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_WS_URL: string; + readonly MAIN_VITE_NIMBUS_API_URL: string; readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string; readonly ELECTRON_RENDERER_URL: string; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 5579b6fb..32bc0f88 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,7 @@ import type { GameAchievement, Theme, FriendRequestSync, + NotificationSync, ShortcutLocation, AchievementCustomNotificationPosition, AchievementNotificationInfo, @@ -497,7 +498,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 +507,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 +559,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, diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 6619c890..bc23f844 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; - +import { WorkWondersSdk } from "workwonders-sdk"; import { useAppDispatch, useAppSelector, @@ -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"; @@ -53,13 +52,10 @@ export function App() { const { clearDownload, setLastPacket } = useDownload(); + const workwondersRef = useRef(null); + const { - userDetails, hasActiveSubscription, - isFriendsModalVisible, - friendRequetsModalTab, - friendModalUserId, - hideFriendsModal, fetchUserDetails, updateUserDetails, clearUserDetails, @@ -120,7 +116,30 @@ export function App() { return () => unsubscribe(); }, [updateLibrary]); - useEffect(() => { + const setupWorkWonders = useCallback( + async (token?: string, locale?: string) => { + if (workwondersRef.current) return; + + const possibleLocales = ["en", "pt", "ru"]; + + const parsedLocale = + possibleLocales.find((l) => l === locale?.slice(0, 2)) ?? "en"; + + workwondersRef.current = new WorkWondersSdk(); + await workwondersRef.current.init({ + organization: "hydra", + token, + locale: parsedLocale, + }); + + await workwondersRef.current.initChangelogWidget(); + workwondersRef.current.initChangelogWidgetMini(); + workwondersRef.current.initFeedbackWidget(); + }, + [workwondersRef] + ); + + const setupExternalResources = useCallback(async () => { const cachedUserDetails = window.localStorage.getItem("userDetails"); if (cachedUserDetails) { @@ -131,28 +150,31 @@ export function App() { dispatch(setProfileBackground(profileBackground)); } - fetchUserDetails() - .then((response) => { - if (response) { - updateUserDetails(response); - window.electron.syncFriendRequests(); - } - }) - .finally(() => { - if (document.getElementById("external-resources")) return; + const userPreferences = await window.electron.getUserPreferences(); + const userDetails = await fetchUserDetails().catch(() => null); - const $script = document.createElement("script"); - $script.id = "external-resources"; - $script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`; - document.head.appendChild($script); - }); - }, [fetchUserDetails, updateUserDetails, dispatch]); + if (userDetails) { + updateUserDetails(userDetails); + } + + setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language); + + if (!document.getElementById("external-resources")) { + const $script = document.createElement("script"); + $script.id = "external-resources"; + $script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`; + document.head.appendChild($script); + } + }, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]); + + useEffect(() => { + setupExternalResources(); + }, [setupExternalResources]); const onSignIn = useCallback(() => { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - window.electron.syncFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); @@ -211,6 +233,7 @@ export function App() { useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; + workwondersRef.current?.notifyUrlChange(); }, [location.pathname, location.search]); useEffect(() => { @@ -305,15 +328,6 @@ export function App() { onClose={() => setShowArchiveDeletionModal(false)} /> - {userDetails && ( - - )} -
diff --git a/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.scss b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.scss new file mode 100644 index 00000000..d46958ff --- /dev/null +++ b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.scss @@ -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); + } +} diff --git a/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.tsx b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.tsx new file mode 100644 index 00000000..f3722154 --- /dev/null +++ b/src/renderer/src/components/fullscreen-media-modal/fullscreen-media-modal.tsx @@ -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(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( +
+ + + +
+ {alt} +
+
+
, + document.body + ); +} diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 1cac834c..5c058252 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -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() { 0 || + (searchValue.trim().length > 0 || + historyItems.length > 0 || suggestions.length > 0 || isLoadingSuggestions) } diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index e8876fcb..8bb028bd 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -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"; diff --git a/src/renderer/src/components/modal/modal.tsx b/src/renderer/src/components/modal/modal.tsx index 09a61438..f4892313 100644 --- a/src/renderer/src/components/modal/modal.tsx +++ b/src/renderer/src/components/modal/modal.tsx @@ -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 diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss index 40a55432..6a2cbede 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.scss +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -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; diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx index 4142e4a5..cc7ce5b4 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.tsx +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -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)} > @@ -200,7 +185,7 @@ export function SearchDropdown({ activeIndex === getItemIndex("suggestion", index), })} onMouseDown={(e) => e.preventDefault()} - onClick={() => handleItemClick("suggestion", item)} + onClick={() => onSelectSuggestion(item)} > {item.iconUrl ? ( {t("loading")} )} - - {!isLoadingSuggestions && - !hasHistory && - !hasSuggestions && - totalItems === 0 && ( -
{t("no_results")}
- )} ); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 23223fc5..3414688d 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -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) { 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)}
- {(game.newDownloadOptionsCount ?? 0) > 0 && ( - - +{game.newDownloadOptionsCount} - - )} + {userPreferences?.enableNewDownloadOptionsBadges !== false && + (game.newDownloadOptionsCount ?? 0) > 0 && ( + + +{game.newDownloadOptionsCount} + + )} diff --git a/src/renderer/src/components/sidebar/sidebar-profile.scss b/src/renderer/src/components/sidebar/sidebar-profile.scss index 7e634851..8ec442f2 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.scss +++ b/src/renderer/src/components/sidebar/sidebar-profile.scss @@ -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 { diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 5f336fc3..bd1209ec 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -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( + "/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 ( ); - }, [userDetails, t, friendRequestCount, showFriendsModal]); + }, [t, notificationCount, navigate]); const gameRunningDetails = () => { if (!userDetails || !gameRunning) return null; @@ -98,7 +159,7 @@ export function SidebarProfile() { - {friendsButton} + {notificationsButton} ); } diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index 3329a0cc..d227969b 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -1,6 +1,6 @@ import { Downloader } from "@shared"; -export const VERSION_CODENAME = "Supernova"; +export const VERSION_CODENAME = "Harbinger"; export const DOWNLOADER_NAME = { [Downloader.RealDebrid]: "Real-Debrid", @@ -10,8 +10,11 @@ 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", + [Downloader.VikingFile]: "VikingFile", }; export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 6975967e..4e7fd245 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -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"; @@ -387,10 +389,12 @@ declare global { processProfileImage: ( path: string ) => Promise<{ imagePath: string; mimeType: string }>; - syncFriendRequests: () => Promise; onSyncFriendRequests: ( cb: (friendRequests: FriendRequestSync) => void ) => () => Electron.IpcRenderer; + onSyncNotificationCount: ( + cb: (notification: NotificationSync) => void + ) => () => Electron.IpcRenderer; updateFriendRequest: ( userId: string, action: FriendRequestAction @@ -398,6 +402,15 @@ declare global { /* Notifications */ publishNewRepacksNotification: (newRepacksCount: number) => Promise; + getLocalNotifications: () => Promise; + getLocalNotificationsCount: () => Promise; + markLocalNotificationRead: (id: string) => Promise; + markAllLocalNotificationsRead: () => Promise; + deleteLocalNotification: (id: string) => Promise; + clearAllLocalNotifications: () => Promise; + onLocalNotificationCreated: ( + cb: (notification: LocalNotification) => void + ) => () => Electron.IpcRenderer; onAchievementUnlocked: ( cb: ( position?: AchievementCustomNotificationPosition, diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index 0330cca3..f70421c0 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -12,6 +12,8 @@ export interface DownloadState { gameId: string | null; gamesWithDeletionInProgress: string[]; extraction: ExtractionInfo | null; + peakSpeeds: Record; + speedHistory: Record; } 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) => { 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) => { + state.peakSpeeds[action.payload] = 0; + state.speedHistory[action.payload] = []; + }, }, }); @@ -72,4 +111,6 @@ export const { removeGameFromDeleting, setExtractionProgress, clearExtraction, + updatePeakSpeed, + clearPeakSpeed, } = downloadSlice.actions; diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index 8994f180..0f477ec2 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -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) => { 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; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 6d89f9b4..8b9a7a17 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -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)); @@ -69,6 +59,7 @@ export function useUserDetails() { username: userDetails?.username || "", subscription: userDetails?.subscription || null, featurebaseJwt: userDetails?.featurebaseJwt || "", + workwondersJwt: userDetails?.workwondersJwt || "", karma: userDetails?.karma || 0, }); }, @@ -85,24 +76,11 @@ export function useUserDetails() { return window.electron.hydraApi .get("/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 @@ -134,7 +112,7 @@ export function useUserDetails() { ); const undoFriendship = (userId: string) => - window.electron.hydraApi.delete(`/profile/friends/${userId}`); + window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`); const blockUser = (userId: string) => window.electron.hydraApi.post(`/users/${userId}/block`); @@ -152,12 +130,7 @@ export function useUserDetails() { profileBackground, friendRequests, friendRequestCount, - friendRequetsModalTab, - isFriendsModalVisible, - friendModalUserId, hasActiveSubscription, - showFriendsModal, - hideFriendsModal, fetchUserDetails, signOut, clearUserDetails, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 92220a6e..a012cf39 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( } /> } /> } /> + } /> } /> diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 52fbcdfd..6a22148a 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -412,10 +412,12 @@ function HeroDownloadView({ )} - {game.download?.downloader && ( + {game.download?.downloader !== undefined && (
- {DOWNLOADER_NAME[game.download.downloader]} + + {DOWNLOADER_NAME[Number(game.download.downloader)]} +
)} @@ -512,8 +514,9 @@ export function DownloadGroup({ const { formatDistance } = useDate(); - const [peakSpeeds, setPeakSpeeds] = useState>({}); - const speedHistoryRef = useRef>({}); + // 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>( {} ); @@ -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({
- {DOWNLOADER_NAME[game.download!.downloader]} + + {DOWNLOADER_NAME[Number(game.download!.downloader)]} +
{extraction?.visibleId === game.id ? ( diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 63c4c974..48a4c0a3 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -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(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 && ( - +
+ +
)}
diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.scss b/src/renderer/src/pages/game-details/modals/download-settings-modal.scss index 1b7c51e8..75add6d3 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.scss +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.scss @@ -19,23 +19,173 @@ color: globals.$body-color; } - &__downloaders { - display: grid; - gap: globals.$spacing-unit; - grid-template-columns: repeat(2, 1fr); + &__downloaders-list-wrapper { + border: 1px solid globals.$border-color; + overflow: hidden; + background-color: globals.$dark-background-color; } - &__downloader-option { - position: relative; + &__downloaders-list { + display: flex; + flex-direction: column; + gap: 0; + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; + padding: 0; - &:only-child { - grid-column: 1 / -1; + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 12px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); } } - &__downloader-icon { - position: absolute; - left: calc(globals.$spacing-unit * 2); + &__downloader-item { + display: flex; + align-items: center; + gap: 8px; + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); + border: 1px solid transparent; + border-bottom: 1px solid globals.$border-color; + border-radius: 0; + background-color: transparent; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + color: globals.$body-color; + font-size: 14px; + text-align: left; + height: 48px; + box-sizing: border-box; + + &:hover { + background-color: rgba(255, 255, 255, 0.05); + } + + &--selected { + background-color: rgba(255, 255, 255, 0.08); + } + + &--last { + border-bottom: none; + } + + &:disabled { + cursor: default; + + &:hover { + background-color: transparent; + } + + .download-settings-modal__downloader-name { + opacity: 0.5; + } + + .download-settings-modal__availability-indicator-wrapper { + opacity: 0.5; + } + } + } + + &__downloader-item-wrapper { + display: flex; + flex-direction: column; + } + + &__check-icon { + color: white; + flex-shrink: 0; + } + + &__check-icon-wrapper { + margin-left: auto; + display: flex; + align-items: center; + width: 20px; + height: 20px; + justify-content: center; + flex-shrink: 0; + } + + &__recommendation-badge { + margin-left: auto; + display: flex; + align-items: center; + height: 20px; + justify-content: center; + flex-shrink: 0; + + .badge { + padding: 2px 6px; + font-size: 10px; + line-height: 1.2; + height: 16px; + display: flex; + align-items: center; + white-space: nowrap; + } + } + + &__availability-indicator-wrapper { + display: flex; + align-items: center; + flex-shrink: 0; + } + + &__availability-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--available { + background-color: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); + } + + &--unavailable { + background-color: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); + } + + &--not-present { + background-color: #6b7280; + box-shadow: 0 0 6px rgba(107, 114, 128, 0.5); + } + + &--warning { + background-color: #eab308; + box-shadow: 0 0 6px rgba(234, 179, 8, 0.5); + } + } + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } + } + + &__availability-indicator--pulsating { + animation: pulse 2s ease-in-out infinite; } &__path-error { @@ -49,4 +199,17 @@ &__change-path-button { align-self: flex-end; } + + &__loading-spinner { + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index a6c32b6e..0a2c6721 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -1,17 +1,25 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { + Badge, Button, CheckboxField, Link, Modal, TextField, } from "@renderer/components"; -import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; -import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; +import { + DownloadIcon, + SyncIcon, + CheckCircleFillIcon, +} from "@primer/octicons-react"; +import { Downloader, formatBytes, getDownloadersForUri } from "@shared"; import type { GameRepack } from "@types"; import { DOWNLOADER_NAME } from "@renderer/constants"; import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; +import { motion } from "framer-motion"; +import { Tooltip } from "react-tooltip"; +import { RealDebridInfoModal } from "./real-debrid-info-modal"; import "./download-settings-modal.scss"; export interface DownloadSettingsModalProps { @@ -51,6 +59,7 @@ export function DownloadSettingsModal({ const [hasWritePermission, setHasWritePermission] = useState( null ); + const [showRealDebridModal, setShowRealDebridModal] = useState(false); const { isFeatureEnabled, Feature } = useFeature(); @@ -78,18 +87,89 @@ export function DownloadSettingsModal({ } }, [visible, checkFolderWritePermission, selectedPath]); - const downloaders = useMemo(() => { - return getDownloadersForUris(repack?.uris ?? []); - }, [repack?.uris]); + const downloadOptions = useMemo(() => { + const unavailableUrisSet = new Set(repack?.unavailableUris ?? []); + + const downloaderMap = new Map< + Downloader, + { hasAvailable: boolean; hasUnavailable: boolean } + >(); + + if (repack) { + for (const uri of repack.uris) { + const uriDownloaders = getDownloadersForUri(uri); + const isAvailable = !unavailableUrisSet.has(uri); + + for (const downloader of uriDownloaders) { + const existing = downloaderMap.get(downloader); + if (existing) { + existing.hasAvailable = existing.hasAvailable || isAvailable; + existing.hasUnavailable = existing.hasUnavailable || !isAvailable; + } else { + downloaderMap.set(downloader, { + hasAvailable: isAvailable, + hasUnavailable: !isAvailable, + }); + } + } + } + } + + const allDownloaders = Object.values(Downloader).filter( + (value) => typeof value === "number" + ) as Downloader[]; + + const getDownloaderPriority = (option: { + isAvailable: boolean; + canHandle: boolean; + isAvailableButNotConfigured: boolean; + }) => { + if (option.isAvailable) return 0; + if (option.canHandle && !option.isAvailableButNotConfigured) return 1; + if (option.isAvailableButNotConfigured) return 2; + return 3; + }; + + return allDownloaders + .filter((downloader) => downloader !== Downloader.Hydra) // Temporarily comment out Nimbus + .map((downloader) => { + const status = downloaderMap.get(downloader); + const canHandle = status !== undefined; + const isAvailable = status?.hasAvailable ?? false; + + let isConfigured = true; + if (downloader === Downloader.RealDebrid) { + isConfigured = !!userPreferences?.realDebridApiToken; + } else if (downloader === Downloader.TorBox) { + isConfigured = !!userPreferences?.torBoxApiToken; + } + // } else if (downloader === Downloader.Hydra) { + // isConfigured = isFeatureEnabled(Feature.Nimbus); + // } + + const isAvailableButNotConfigured = + isAvailable && !isConfigured && canHandle; + + return { + downloader, + isAvailable: isAvailable && isConfigured, + canHandle, + isAvailableButNotConfigured, + }; + }) + .sort((a, b) => getDownloaderPriority(a) - getDownloaderPriority(b)); + }, [ + repack, + userPreferences?.realDebridApiToken, + userPreferences?.torBoxApiToken, + isFeatureEnabled, + Feature, + ]); const getDefaultDownloader = useCallback( (availableDownloaders: Downloader[]) => { if (availableDownloaders.length === 0) return null; - if (availableDownloaders.includes(Downloader.Hydra)) { - return Downloader.Hydra; - } - if (availableDownloaders.includes(Downloader.RealDebrid)) { return Downloader.RealDebrid; } @@ -112,26 +192,12 @@ export function DownloadSettingsModal({ .then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath)); } - const filteredDownloaders = downloaders.filter((downloader) => { - if (downloader === Downloader.RealDebrid) - return userPreferences?.realDebridApiToken; - if (downloader === Downloader.TorBox) - return userPreferences?.torBoxApiToken; - if (downloader === Downloader.Hydra) - return isFeatureEnabled(Feature.Nimbus); - return true; - }); + const availableDownloaders = downloadOptions + .filter((option) => option.isAvailable) + .map((option) => option.downloader); - setSelectedDownloader(getDefaultDownloader(filteredDownloaders)); - }, [ - Feature, - isFeatureEnabled, - getDefaultDownloader, - userPreferences?.downloadsPath, - downloaders, - userPreferences?.realDebridApiToken, - userPreferences?.torBoxApiToken, - ]); + setSelectedDownloader(getDefaultDownloader(availableDownloaders)); + }, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]); const handleChooseDownloadsPath = async () => { const { filePaths } = await window.electron.showOpenDialog({ @@ -186,33 +252,144 @@ export function DownloadSettingsModal({
{t("downloader")} -
- {downloaders.map((downloader) => { - const shouldDisableButton = - (downloader === Downloader.RealDebrid && - !userPreferences?.realDebridApiToken) || - (downloader === Downloader.TorBox && - !userPreferences?.torBoxApiToken) || - (downloader === Downloader.Hydra && - !isFeatureEnabled(Feature.Nimbus)); +
+
+ {downloadOptions.map((option, index) => { + const isSelected = selectedDownloader === option.downloader; + const tooltipId = `availability-indicator-${option.downloader}`; + const isLastItem = index === downloadOptions.length - 1; - return ( - - ); - })} + + if (option.isAvailableButNotConfigured) { + return ( + + ); + } + + if (option.canHandle) { + return ( + + ); + } + + return ( + + ); + }; + + const getRightContent = () => { + if (isSelected) { + return ( + + + + ); + } + + if ( + option.downloader === Downloader.RealDebrid && + option.canHandle + ) { + return ( +
+ {t("recommended")} +
+ ); + } + + return null; + }; + + return ( +
+ +
+ ); + })} +
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({ disabled={ downloadStarting || selectedDownloader === null || - !hasWritePermission + !hasWritePermission || + downloadOptions.some( + (option) => + option.downloader === selectedDownloader && + (option.isAvailableButNotConfigured || + (!option.isAvailable && option.canHandle) || + !option.canHandle) + ) } > - - {t("download_now")} + {downloadStarting ? ( + <> + + {t("loading")} + + ) : ( + <> + + {t("download_now")} + + )}
+ + setShowRealDebridModal(false)} + /> ); } diff --git a/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.scss b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.scss new file mode 100644 index 00000000..5a97ae92 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.scss @@ -0,0 +1,36 @@ +@use "../../../scss/globals.scss"; + +.real-debrid-info-modal { + &__content { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2.5); + width: 100%; + max-width: 500px; + } + + &__description-container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + } + + &__description { + margin: 0; + color: globals.$body-color; + line-height: 1.6; + } + + &__create-account { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + color: #c0c1c7; + text-decoration: underline; + font-size: 14px; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.tsx b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.tsx new file mode 100644 index 00000000..be539db7 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/real-debrid-info-modal.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Button, Link, Modal } from "@renderer/components"; +import { LinkExternalIcon } from "@primer/octicons-react"; +import "./real-debrid-info-modal.scss"; + +const realDebridReferralId = import.meta.env + .RENDERER_VITE_REAL_DEBRID_REFERRAL_ID; + +const REAL_DEBRID_URL = realDebridReferralId + ? `https://real-debrid.com/?id=${realDebridReferralId}` + : "https://real-debrid.com"; + +export interface RealDebridInfoModalProps { + visible: boolean; + onClose: () => void; +} + +export function RealDebridInfoModal({ + visible, + onClose, +}: Readonly) { + const { t } = useTranslation("game_details"); + const { t: tSettings } = useTranslation("settings"); + const navigate = useNavigate(); + + return ( + +
+
+

+ {tSettings("real_debrid_description")} +

+ + + {tSettings("create_real_debrid_account")} + +
+ + +
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 08efb014..683ce53a 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -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({ >

{repack.title} - {isNewRepack(repack) && ( - - {t("new_download_option")} - - )} + {userPreferences?.enableNewDownloadOptionsBadges !== + false && + isNewRepack(repack) && ( + + {t("new_download_option")} + + )}

{isLastDownloadedOption && ( diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index ab9a9f2a..a0ef7e29 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -221,6 +221,26 @@ left: 0; z-index: 0; } + + &__cover-placeholder { + position: relative; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + border-radius: 4px; + color: rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 0; + } } @keyframes pulse { diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index a91176cb..101236ae 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -1,7 +1,12 @@ import { LibraryGame } from "@types"; import { useGameCard } from "@renderer/hooks"; -import { memo } from "react"; -import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import { memo, useState } from "react"; +import { + ClockIcon, + AlertFillIcon, + TrophyIcon, + ImageIcon, +} from "@primer/octicons-react"; import "./library-game-card.scss"; interface LibraryGameCardProps { @@ -25,14 +30,9 @@ export const LibraryGameCard = memo(function LibraryGameCard({ const { formatPlayTime, handleCardClick, handleContextMenuClick } = useGameCard(game, onContextMenu); - const coverImage = ( - game.customIconUrl ?? - game.coverImageUrl ?? - game.libraryImageUrl ?? - game.libraryHeroImageUrl ?? - game.iconUrl ?? - "" - ).replaceAll("\\", "/"); + const coverImage = game.coverImageUrl?.replaceAll("\\", "/") ?? ""; + + const [imageError, setImageError] = useState(false); return ( ); }); diff --git a/src/renderer/src/pages/notifications/local-notification-item.tsx b/src/renderer/src/pages/notifications/local-notification-item.tsx new file mode 100644 index 00000000..30380965 --- /dev/null +++ b/src/renderer/src/pages/notifications/local-notification-item.tsx @@ -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) { + 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 ; + case "EXTRACTION_COMPLETE": + return ; + case "UPDATE_AVAILABLE": + return ; + case "ACHIEVEMENT_UNLOCKED": + return ; + default: + return ; + } + }; + + return ( + + + ); +} diff --git a/src/renderer/src/pages/notifications/notification-item.scss b/src/renderer/src/pages/notifications/notification-item.scss new file mode 100644 index 00000000..4e97237d --- /dev/null +++ b/src/renderer/src/pages/notifications/notification-item.scss @@ -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); + } + } +} diff --git a/src/renderer/src/pages/notifications/notification-item.tsx b/src/renderer/src/pages/notifications/notification-item.tsx new file mode 100644 index 00000000..b250ffe8 --- /dev/null +++ b/src/renderer/src/pages/notifications/notification-item.tsx @@ -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) { + 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 ; + } + if (isReview) { + return ; + } + return ; + }; + + return ( + + +
+ )} + + {notification.type !== "FRIEND_REQUEST_RECEIVED" && ( + + )} + + ); +} diff --git a/src/renderer/src/pages/notifications/notifications.scss b/src/renderer/src/pages/notifications/notifications.scss new file mode 100644 index 00000000..20fbc343 --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.scss @@ -0,0 +1,138 @@ +@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; + min-height: calc(100vh - 200px); + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + } + + &__filter-tabs { + display: flex; + gap: globals.$spacing-unit; + position: relative; + flex: 1; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + &__tab-wrapper { + position: relative; + } + + &__tab { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + + &--active { + color: white; + } + } + + &__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 20px; + } + + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + + &__actions { + display: flex; + gap: globals.$spacing-unit; + justify-content: flex-end; + } + + &__content-wrapper { + display: flex; + flex-direction: column; + flex: 1; + } + + &__list { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__empty { + display: flex; + flex: 1; + width: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__empty-filter { + display: flex; + justify-content: center; + align-items: center; + padding: calc(globals.$spacing-unit * 6); + color: globals.$body-color; + } + + &__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); + } +} diff --git a/src/renderer/src/pages/notifications/notifications.tsx b/src/renderer/src/pages/notifications/notifications.tsx new file mode 100644 index 00000000..f1c2d4de --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.tsx @@ -0,0 +1,554 @@ +import { useCallback, useEffect, useMemo, useRef, 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"; + +type NotificationFilter = "all" | "unread"; + +const STAGGER_DELAY_MS = 70; +const EXIT_DURATION_MS = 250; + +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([]); + const [localNotifications, setLocalNotifications] = useState< + LocalNotification[] + >([]); + const [badges, setBadges] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isClearing, setIsClearing] = useState(false); + const [filter, setFilter] = useState("all"); + const [pagination, setPagination] = useState({ + total: 0, + hasMore: false, + skip: 0, + }); + const clearingTimeoutsRef = useRef([]); + + 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( + `/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, + filterParam: NotificationFilter = "all" + ) => { + if (!userDetails) return; + + try { + setIsLoading(true); + const response = + await window.electron.hydraApi.get( + "/profile/notifications", + { + params: { filter: filterParam, 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 (filterParam: NotificationFilter = "all") => { + setIsLoading(true); + await Promise.all([ + fetchLocalNotifications(), + fetchBadges(), + userDetails + ? fetchApiNotifications(0, false, filterParam) + : Promise.resolve(), + ]); + setIsLoading(false); + }, + [fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails] + ); + + useEffect(() => { + fetchAllNotifications(filter); + }, [fetchAllNotifications, filter]); + + useEffect(() => { + const unsubscribe = window.electron.onLocalNotificationCreated( + (notification) => { + setLocalNotifications((prev) => [notification, ...prev]); + } + ); + + return () => unsubscribe(); + }, []); + + // Cleanup timeouts on unmount + useEffect(() => { + return () => { + clearingTimeoutsRef.current.forEach(clearTimeout); + }; + }, []); + + const mergedNotifications = useMemo(() => { + 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 })); + + // Filter local notifications based on current filter + const filteredLocalNotifications = + filter === "unread" + ? localNotifications.filter((n) => !n.isRead) + : localNotifications; + + const localWithSource: MergedNotification[] = + filteredLocalNotifications.map((n) => ({ + ...n, + source: "local" as const, + })); + + const lowPriority = [...lowPriorityApi, ...localWithSource].sort( + sortByDate + ); + + return [...highPriority, ...lowPriority]; + }, [apiNotifications, localNotifications, filter]); + + const displayedNotifications = useMemo(() => { + return mergedNotifications; + }, [mergedNotifications]); + + 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 removeNotificationFromState = useCallback( + (notification: MergedNotification) => { + if (notification.source === "api") { + setApiNotifications((prev) => + prev.filter((n) => n.id !== notification.id) + ); + } else { + setLocalNotifications((prev) => + prev.filter((n) => n.id !== notification.id) + ); + } + }, + [] + ); + + const removeNotificationWithDelay = useCallback( + (notification: MergedNotification, delayMs: number): Promise => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + removeNotificationFromState(notification); + resolve(); + }, delayMs); + + clearingTimeoutsRef.current.push(timeout); + }); + }, + [removeNotificationFromState] + ); + + const handleClearAll = useCallback(async () => { + if (isClearing) return; + + try { + setIsClearing(true); + + // Clear any existing timeouts + clearingTimeoutsRef.current.forEach(clearTimeout); + clearingTimeoutsRef.current = []; + + // Snapshot current notifications for staggered removal + const notificationsToRemove = [...displayedNotifications]; + const totalNotifications = notificationsToRemove.length; + + if (totalNotifications === 0) { + setIsClearing(false); + return; + } + + // Remove items one by one with staggered delays for visual effect + const removalPromises = notificationsToRemove.map((notification, index) => + removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS) + ); + + // Wait for all items to be removed from state + await Promise.all(removalPromises); + + // Wait for the last exit animation to complete + await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS)); + + // Perform actual backend deletions (state is already cleared by staggered removal) + if (userDetails) { + await window.electron.hydraApi.delete(`/profile/notifications/all`, { + needsAuth: true, + }); + } + await window.electron.clearAllLocalNotifications(); + setPagination({ total: 0, hasMore: false, skip: 0 }); + notifyCountChange(); + showSuccessToast(t("cleared_all")); + } catch (error) { + logger.error("Failed to clear all notifications", error); + showErrorToast(t("failed_to_clear")); + } finally { + setIsClearing(false); + clearingTimeoutsRef.current = []; + } + }, [ + displayedNotifications, + isClearing, + removeNotificationWithDelay, + userDetails, + showSuccessToast, + showErrorToast, + t, + notifyCountChange, + ]); + + const handleLoadMore = useCallback(() => { + if (pagination.hasMore && !isLoading) { + fetchApiNotifications(pagination.skip, true, filter); + } + }, [pagination, isLoading, fetchApiNotifications, filter]); + + const handleFilterChange = useCallback( + (newFilter: NotificationFilter) => { + if (newFilter !== filter) { + setFilter(newFilter); + setPagination({ total: 0, hasMore: false, skip: 0 }); + } + }, + [filter] + ); + + 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 ( + + {notification.source === "local" ? ( + handleDismiss(id, "local")} + onMarkAsRead={(id) => handleMarkAsRead(id, "local")} + /> + ) : ( + handleDismiss(id, "api")} + onMarkAsRead={(id) => handleMarkAsRead(id, "api")} + onAcceptFriendRequest={handleAcceptFriendRequest} + onRefuseFriendRequest={handleRefuseFriendRequest} + /> + )} + + ); + }; + + const unreadCount = useMemo(() => { + const apiUnread = apiNotifications.filter((n) => !n.isRead).length; + const localUnread = localNotifications.filter((n) => !n.isRead).length; + return apiUnread + localUnread; + }, [apiNotifications, localNotifications]); + + const renderFilterTabs = () => ( +
+
+ + {filter === "all" && ( + + )} +
+
+ + {filter === "unread" && ( + + )} +
+
+ ); + + const hasNoNotifications = mergedNotifications.length === 0; + const shouldDisableActions = isClearing || hasNoNotifications; + + const renderContent = () => { + if (isLoading && hasNoNotifications) { + return ( +
+ {t("loading")} +
+ ); + } + + return ( +
+
+ {renderFilterTabs()} +
+ + +
+
+ + {/* Keep AnimatePresence mounted during clearing to preserve exit animations */} + + + {hasNoNotifications && !isClearing ? ( +
+
+ +
+

{t("empty_title")}

+

+ {filter === "unread" + ? t("empty_filter_description") + : t("empty_description")} +

+
+ ) : ( +
+ + {displayedNotifications.map(renderNotification)} + +
+ )} +
+
+ + {pagination.hasMore && !isClearing && ( +
+ +
+ )} +
+ ); + }; + + return <>{renderContent()}; +} diff --git a/src/renderer/src/pages/profile/profile-content/add-friend-modal.scss b/src/renderer/src/pages/profile/profile-content/add-friend-modal.scss new file mode 100644 index 00000000..8d2a38dc --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/add-friend-modal.scss @@ -0,0 +1,122 @@ +@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; + border: none; + cursor: pointer; + transition: all ease 0.2s; + text-align: left; + + &: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; + } +} diff --git a/src/renderer/src/pages/profile/profile-content/add-friend-modal.tsx b/src/renderer/src/pages/profile/profile-content/add-friend-modal.tsx new file mode 100644 index 00000000..7f370a39 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/add-friend-modal.tsx @@ -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) => { + 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 ( + +
+ {userDetails?.id && ( +
+ + {t("your_friend_code")} + + + {userDetails.id} + + +
+ )} + +
+ + + +
+ {currentRequest && ( +
{t("pending")}
+ )} + + {sentRequests.length > 0 && ( +
+

{t("pending")}

+
+ {sentRequests.map((request) => ( + + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/all-badges-modal.scss b/src/renderer/src/pages/profile/profile-content/all-badges-modal.scss new file mode 100644 index 00000000..83f8f6ef --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-badges-modal.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/profile/profile-content/all-badges-modal.tsx b/src/renderer/src/pages/profile/profile-content/all-badges-modal.tsx new file mode 100644 index 00000000..8eb50051 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-badges-modal.tsx @@ -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) { + 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 = ( +
+ {t("badges")} + {userBadges && userBadges.length > 0 && ( + {userBadges.length} + )} +
+ ); + + return ( + +
+
+ {userBadges?.map((badge) => ( +
+
+ {badge.name} +
+
+

{badge.title}

+

+ {badge.description} +

+
+
+ ))} +
+
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/all-friends-modal.scss b/src/renderer/src/pages/profile/profile-content/all-friends-modal.scss new file mode 100644 index 00000000..8ecbaa46 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-friends-modal.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx b/src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx new file mode 100644 index 00000000..4344956a --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx @@ -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([]); + const [totalFriends, setTotalFriends] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(0); + const listRef = useRef(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 {game.title}; + } + return ; + }; + + const modalTitle = ( +
+ {t("friends")} + {totalFriends > 0 && ( + {totalFriends} + )} +
+ ); + + return ( + +
+ {friends.length === 0 && !isLoading ? ( +
+ {t("no_friends_added")} +
+ ) : ( +
+ {friends.map((friend) => ( +
handleFriendClick(friend.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleFriendClick(friend.id); + } + }} + role="button" + tabIndex={0} + > + +
+ + {friend.displayName} + + {friend.currentGame && ( +
+ {getGameImage(friend.currentGame)} + {friend.currentGame.title} +
+ )} +
+
+ ))} +
+ )} + + {isLoading && ( +
{t("loading")}...
+ )} + + {hasMore && !isLoading && friends.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/badges-box.scss b/src/renderer/src/pages/profile/profile-content/badges-box.scss new file mode 100644 index 00000000..ce8622ac --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/badges-box.scss @@ -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; + } + } +} diff --git a/src/renderer/src/pages/profile/profile-content/badges-box.tsx b/src/renderer/src/pages/profile/profile-content/badges-box.tsx new file mode 100644 index 00000000..501341b2 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/badges-box.tsx @@ -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 ( + <> +
+
+ {visibleBadges.map((badgeName) => { + const badge = badges.find((b) => b.name === badgeName); + + if (!badge) return null; + + return ( +
+
+ {badge.name} +
+
+

{badge.title}

+

+ {badge.description} +

+
+
+ ); + })} +
+ {hasMoreBadges && ( +
+ +
+ )} +
+ + setShowAllBadgesModal(false)} + /> + + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/friends-box.scss b/src/renderer/src/pages/profile/profile-content/friends-box.scss index 2e5a1bc1..088b204f 100644 --- a/src/renderer/src/pages/profile/profile-content/friends-box.scss +++ b/src/renderer/src/pages/profile/profile-content/friends-box.scss @@ -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; + } + } } diff --git a/src/renderer/src/pages/profile/profile-content/friends-box.tsx b/src/renderer/src/pages/profile/profile-content/friends-box.tsx index bee4b35c..cd0fed24 100644 --- a/src/renderer/src/pages/profile/profile-content/friends-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/friends-box.tsx @@ -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 ( -
-
-
-

{t("friends")}

- {userStats && ( - - {numberFormatter.format(userStats.friendsCount)} - - )} -
-
+ const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS); + const totalFriends = userProfile.friends.length; + const showViewAllButton = totalFriends > MAX_VISIBLE_FRIENDS; + return ( + <>
    - {userProfile?.friends.map((friend) => ( + {visibleFriends.map((friend) => (
  • ))}
+ {showViewAllButton && ( +
+ +
+ )}
-
+ + {userProfile && ( + <> + setShowAllFriendsModal(false)} + userId={userProfile.id} + isMe={isMe} + /> + 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 ( + <> + + setShowAddFriendModal(false)} + /> + ); } diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 8176bace..dfd489ec 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -14,14 +14,15 @@ 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 { 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 { 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"; @@ -95,7 +96,7 @@ export function ProfileContent() { const [sortBy, setSortBy] = useState("playedRecently"); const statsAnimation = useRef(-1); - const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); + const [activeTab, setActiveTab] = useState("library"); // User reviews state const [reviews, setReviews] = useState([]); @@ -186,8 +187,6 @@ export function ProfileContent() { ); setReviews(response.reviews); setReviewsTotalCount(response.totalCount); - } catch (error) { - // Error handling for fetching reviews } finally { setIsLoadingReviews(false); } @@ -426,10 +425,35 @@ export function ProfileContent() { {shouldShowRightContent && (
- - - - + {userStats && ( + + + + )} + {userProfile?.badges.length > 0 && ( + + + + )} + {userProfile?.recentGames.length > 0 && ( + + + + )} + {userProfile?.friends.length > 0 && ( + } + defaultOpen={true} + > + + + )}
)} diff --git a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx index bc76f40c..84d1dd4d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -2,10 +2,12 @@ import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import "./profile-content.scss"; +export type ProfileTabType = "library" | "reviews"; + interface ProfileTabsProps { - activeTab: "library" | "reviews"; + activeTab: ProfileTabType; reviewsTotalCount: number; - onTabChange: (tab: "library" | "reviews") => void; + onTabChange: (tab: ProfileTabType) => void; } export function ProfileTabs({ diff --git a/src/renderer/src/pages/profile/profile-content/recent-games-box.scss b/src/renderer/src/pages/profile/profile-content/recent-games-box.scss index 6478fd79..394fbca7 100644 --- a/src/renderer/src/pages/profile/profile-content/recent-games-box.scss +++ b/src/renderer/src/pages/profile/profile-content/recent-games-box.scss @@ -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; diff --git a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx index 5e13b0a9..e61ca423 100644 --- a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx @@ -42,38 +42,32 @@ export function RecentGamesBox() { if (!userProfile?.recentGames.length) return null; return ( -
-
-

{t("activity")}

-
+
+
    + {userProfile?.recentGames.map((game) => ( +
  • + + {game.title} -
    -
      - {userProfile?.recentGames.map((game) => ( -
    • - - {game.title} +
      + {game.title} -
      - {game.title} - -
      - - {formatPlayTime(game)} -
      +
      + + {formatPlayTime(game)}
      - -
    • - ))} -
    -
    +
+ + + ))} +
); } diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss deleted file mode 100644 index 63015b4d..00000000 --- a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss +++ /dev/null @@ -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; - } -} diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx deleted file mode 100644 index d2232276..00000000 --- a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx +++ /dev/null @@ -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 ( -
-
-

{t("karma")}

-
- -
-
-
-

- {numberFormatter.format(karma)}{" "} - {t("karma_count")} -

-
-
- - {t("karma_description")} - -
-
-
-
- ); -} diff --git a/src/renderer/src/pages/profile/profile-content/user-stats-box.scss b/src/renderer/src/pages/profile/profile-content/user-stats-box.scss index c19fb612..72a4d580 100644 --- a/src/renderer/src/pages/profile/profile-content/user-stats-box.scss +++ b/src/renderer/src/pages/profile/profile-content/user-stats-box.scss @@ -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; + } } diff --git a/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx b/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx index 26ec79f4..8b61cdd6 100644 --- a/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx @@ -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,97 @@ export function UserStatsBox() { if (!userStats) return null; + const karma = isMe ? userDetails?.karma : userProfile?.karma; + const hasKarma = karma !== undefined && karma !== null; + return ( -
-
-

{t("stats")}

-
- -
-
    - {(isMe || userStats.unlockedAchievementSum !== undefined) && ( -
  • -

    - {t("achievements_unlocked")} -

    - {userStats.unlockedAchievementSum !== undefined ? ( -
    -

    - {userStats.unlockedAchievementSum}{" "} - {t("achievements")} -

    -
    - ) : ( - - )} -
  • - )} - - {(isMe || userStats.achievementsPointsEarnedSum !== undefined) && ( -
  • -

    {t("earned_points")}

    - {userStats.achievementsPointsEarnedSum !== undefined ? ( -
    -

    - - {numberFormatter.format( - userStats.achievementsPointsEarnedSum.value - )} -

    -

    - {t("top_percentile", { - percentile: - userStats.achievementsPointsEarnedSum.topPercentile, - })} -

    -
    - ) : ( - - )} -
  • - )} - +
    +
      + {(isMe || userStats.unlockedAchievementSum !== undefined) && (
    • -

      {t("total_play_time")}

      +

      + {t("achievements_unlocked")} +

      + {userStats.unlockedAchievementSum !== undefined ? ( +
      +

      + {userStats.unlockedAchievementSum}{" "} + {t("achievements")} +

      +
      + ) : ( + + )} +
    • + )} + + {(isMe || userStats.achievementsPointsEarnedSum !== undefined) && ( +
    • +

      {t("earned_points")}

      + {userStats.achievementsPointsEarnedSum !== undefined ? ( +
      +

      + + {numberFormatter.format( + userStats.achievementsPointsEarnedSum.value + )} +

      +

      + {t("top_percentile", { + percentile: + userStats.achievementsPointsEarnedSum.topPercentile, + })} +

      +
      + ) : ( + + )} +
    • + )} + +
    • +

      {t("total_play_time")}

      +
      +

      + + {formatPlayTime(userStats.totalPlayTimeInSeconds.value)} +

      +

      + {t("top_percentile", { + percentile: userStats.totalPlayTimeInSeconds.topPercentile, + })} +

      +
      +
    • + + {hasKarma && karma !== undefined && karma !== null && ( +
    • +

      {t("karma")}

      - - {formatPlayTime(userStats.totalPlayTimeInSeconds.value)} -

      -

      - {t("top_percentile", { - percentile: userStats.totalPlayTimeInSeconds.topPercentile, - })} + {numberFormatter.format(karma)}{" "} + {t("karma_count")}

    • -
    -
    + )} +
); } diff --git a/src/renderer/src/pages/profile/profile-content/wrapped-tab.scss b/src/renderer/src/pages/profile/profile-content/wrapped-tab.scss new file mode 100644 index 00000000..0dc45d8d --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/wrapped-tab.scss @@ -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); + } +} diff --git a/src/renderer/src/pages/profile/profile-content/wrapped-tab.tsx b/src/renderer/src/pages/profile/profile-content/wrapped-tab.tsx new file mode 100644 index 00000000..a7ca2797 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/wrapped-tab.tsx @@ -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 = { + 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) { + const [config, setConfig] = useState(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 ( + + + +
+ {isLoading && ( +
+
+
+ )} +