diff --git a/.cursorrules b/.cursorrules index 5015ab7e..fedb8a3a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -28,6 +28,26 @@ - Use async/await instead of promises when possible - Prefer named exports over default exports for utilities and services +## ESLint Issues + +- **Always try to fix ESLint errors properly before disabling rules** +- When encountering ESLint errors, explore these solutions in order: + 1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues) + 2. **Use minimal markup to satisfy the rule** (e.g., add empty `` elements for videos without captions, add `role` attributes) + 3. **Only disable the rule as a last resort** when no reasonable solution exists +- When disabling a rule, always include a comment explaining why it's necessary +- Examples of proper fixes: + - For `jsx-a11y/media-has-caption`: Add `` even if no captions are available + - For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images + - For accessibility rules: Add appropriate ARIA attributes rather than disabling + +## TypeScript Array Syntax + +- **Always use `T[]` syntax instead of `Array`** for array types +- Prefer: `string[]`, `number[]`, `MyType[]` +- Avoid: `Array`, `Array`, `Array` +- This applies to all type annotations, type assertions, and generic type parameters + ## Comments - Keep comments concise and purposeful; avoid verbose explanations. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index e9a91e0c..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Bug Report -description: Create a report to help us improve. Write in English. -title: "[BUG] Write a title for your bug" -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Thank you for creating a bug report to help us improve! - - type: textarea - id: bug-description - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. - validations: - required: true - - type: textarea - id: bug-reproduce - attributes: - label: Steps to Reproduce - description: Steps to reproduce the behavior. For example, "1. Go to '...', 2. Click on '...', 3. See error" - validations: - required: true - - type: textarea - id: expected-behavior - attributes: - label: Expected behavior - description: A clear and concise description of what you expected to happen. - validations: - required: false - - type: textarea - id: additional-info - attributes: - label: Additional information and data - description: | - Add screenshots and upload your all logs file here. - Logs location on Windows: "%appdata%/hydralauncher/logs" - Logs location on Linux: "~/.config/hydralauncher/logs" - validations: - required: true - - type: input - id: OS - attributes: - label: Operating System - description: Which operating system are you using (e.g., Windows 11/Linux Distro/Steam Deck)? - validations: - required: true - - type: input - id: hydra-version - attributes: - label: Hydra Version - description: Please provide the version of Hydra you are using. - validations: - required: true - - type: checkboxes - id: terms - attributes: - label: Before opening this Issue - options: - - label: I have searched the issues of this repository and believe that this is not a duplicate. - required: true - - label: I am aware that Hydra team does not offer any support or help regarding the downloaded games. - required: true - - label: I have read the [Frequently Asked Questions (FAQ)](https://github.com/hydralauncher/hydra/wiki/FAQ). - required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 295cee45..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Feature Request -description: Request a new feature. -title: "[REQUEST] " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thank you for taking the time to suggest a new feature! - - type: textarea - id: problem-related - attributes: - label: Is your feature request related to a problem? Please describe. - description: A clear and concise description of what the problem is. - validations: - required: true - - type: textarea - id: solution - attributes: - label: Describe the solution you'd like - description: A clear and concise description of what you want to happen. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Describe alternatives you've considered - description: A clear and concise description of any alternative solutions or features you've considered. - validations: - required: false - - type: textarea - id: additional-context - attributes: - label: Additional context - description: Add any other context or screenshots about the feature request here. - validations: - required: false diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 3653dd16..22223374 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -2,11 +2,9 @@ **When submitting this pull request, I confirm the following (please check the boxes):** -- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute). +- [ ] I have read the [Hydra documentation](https://docs.hydralauncher.gg/getting-started.html). - [ ] I have checked that there are no duplicate pull requests related to this request. - [ ] I have considered, and confirm that this submission is valuable to others. - [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers. **Fill in the PR content:** - -- diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index fa12b500..22fcc49a 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -137,7 +137,7 @@ jobs: if git diff --staged --quiet; then echo "No changes to commit" else - COMMIT_MSG="v${{ steps.get-version.outputs.version }}" + COMMIT_MSG="${{ steps.get-version.outputs.version }}" git commit -m "$COMMIT_MSG" diff --git a/README.md b/README.md index 1cdc0f72..79dc4b6e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-[](https://help.hydralauncher.gg) +[](https://help.hydralauncher.gg)

Hydra Launcher

@@ -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 e2fec5ee..bb74198f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.4", + "version": "3.7.6", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -63,12 +63,14 @@ "embla-carousel-react": "^8.6.0", "file-type": "^20.5.0", "framer-motion": "^12.15.0", + "hls.js": "^1.5.12", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", "jsdom": "^24.0.0", "jsonwebtoken": "^9.0.2", "lodash-es": "^4.17.21", "lucide-react": "^0.544.0", + "node-7z": "^3.0.0", "parse-torrent": "^11.0.18", "rc-virtual-list": "^3.18.3", "react-dnd": "^16.0.1", @@ -84,7 +86,7 @@ "sound-play": "^1.1.0", "steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor", "sudo-prompt": "^9.2.1", - "tar": "^7.4.3", + "tar": "^7.5.2", "tough-cookie": "^5.1.1", "user-agents": "^1.1.387", "uuid": "^13.0.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/main.py b/python_rpc/main.py index 36170025..99dd0d8c 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -153,8 +153,11 @@ def profile_image(): data = request.get_json() image_path = data.get('image_path') + # use webp as default value for target_extension + target_extension = data.get('target_extension') or 'webp' + try: - processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path) + processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path, target_extension) return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200 except Exception as e: return jsonify({"error": str(e)}), 400 diff --git a/python_rpc/profile_image_processor.py b/python_rpc/profile_image_processor.py index 45ba5160..eac8c32a 100644 --- a/python_rpc/profile_image_processor.py +++ b/python_rpc/profile_image_processor.py @@ -4,7 +4,7 @@ import os, uuid, tempfile class ProfileImageProcessor: @staticmethod - def get_parsed_image_data(image_path): + def get_parsed_image_data(image_path, target_extension): Image.MAX_IMAGE_PIXELS = 933120000 image = Image.open(image_path) @@ -16,7 +16,7 @@ class ProfileImageProcessor: return image_path, mime_type else: new_uuid = str(uuid.uuid4()) - new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp" + new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension image.save(new_image_path) new_image = Image.open(new_image_path) @@ -26,5 +26,5 @@ class ProfileImageProcessor: @staticmethod - def process_image(image_path): - return ProfileImageProcessor.get_parsed_image_data(image_path) + def process_image(image_path, target_extension): + return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 13515c21..dce99b28 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", @@ -94,6 +95,12 @@ "header": { "search": "Search games", "search_library": "Search library", + "recent_searches": "Recent Searches", + "suggestions": "Suggestions", + "clear_history": "Clear", + "remove_from_history": "Remove from history", + "loading": "Loading...", + "no_results": "No results", "home": "Home", "catalogue": "Catalogue", "library": "Library", @@ -109,6 +116,7 @@ "downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}", "calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…", "checking_files": "Checking {{title}} files… ({{percentage}} complete)", + "extracting": "Extracting {{title}}… ({{percentage}} complete)", "installing_common_redist": "{{log}}…", "installation_complete": "Installation complete", "installation_complete_message": "Common redistributables installed successfully" @@ -196,6 +204,7 @@ "danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra", "download_in_progress": "Download in progress", "download_paused": "Download paused", + "extracting": "Extracting", "last_downloaded_option": "Last downloaded option", "new_download_option": "New", "create_steam_shortcut": "Create Steam shortcut", @@ -408,7 +417,13 @@ "resume_seeding": "Resume seeding", "options": "Manage", "extract": "Extract files", - "extracting": "Extracting files…" + "extracting": "Extracting files…", + "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", + "network": "NETWORK", + "peak": "PEAK" }, "settings": { "downloads_path": "Downloads path", @@ -547,6 +562,7 @@ "enable_steam_achievements": "Enable search for Steam achievements", "enable_achievement_screenshots": "Enable achievement screenshots", "open_screenshots_directory": "Open screenshots directory", + "enable_new_download_options_badges": "Show new download options badges", "achievement_custom_notification_position": "Achievement custom notification position", "top-left": "Top left", "top-center": "Top center", @@ -652,6 +668,7 @@ "sending": "Sending", "friend_request_sent": "Friend request sent", "friends": "Friends", + "badges": "Badges", "friends_list": "Friends list", "user_not_found": "User not found", "block_user": "Block user", @@ -662,12 +679,16 @@ "ignore_request": "Ignore request", "cancel_request": "Cancel request", "undo_friendship": "Undo friendship", + "friendship_removed": "Friend removed", "request_accepted": "Request accepted", "user_blocked_successfully": "User blocked successfully", "user_block_modal_text": "This will block {{displayName}}", "blocked_users": "Blocked users", "unblock": "Unblock", "no_friends_added": "You have no added friends", + "view_all": "View all", + "load_more": "Load more", + "loading": "Loading", "pending": "Pending", "no_pending_invites": "You have no pending invites", "no_blocked_users": "You have no blocked users", @@ -691,6 +712,7 @@ "report_reason_other": "Other", "profile_reported": "Profile reported", "your_friend_code": "Your friend code:", + "copy_friend_code": "Copy friend code", "upload_banner": "Upload banner", "uploading_banner": "Uploading banner…", "background_image_updated": "Background image updated", @@ -719,7 +741,10 @@ "delete_souvenir_modal_title": "Are you sure you want to delete this souvenir?", "delete_souvenir_modal_description": "This action cannot be undone.", "delete_souvenir_modal_delete_button": "Delete", - "delete_souvenir_modal_cancel_button": "Cancel" + "delete_souvenir_modal_cancel_button": "Cancel", + "wrapped_2025": "Wrapped 2025", + "view_my_wrapped_button": "View My Wrapped 2025", + "view_wrapped_button": "View {{displayName}}'s Wrapped 2025" }, "library": { "library": "Library", @@ -770,5 +795,40 @@ "hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!", "learn_more": "Learn More", "debrid_description": "Download up to 4x faster with Nimbus" + }, + "notifications_page": { + "title": "Notifications", + "mark_all_as_read": "Mark all as read", + "clear_all": "Clear All", + "loading": "Loading...", + "empty_title": "No notifications", + "empty_description": "You're all caught up! Check back later for new updates.", + "empty_filter_description": "No notifications match this filter.", + "filter_all": "All", + "filter_friends": "Friends", + "filter_badges": "Badges", + "filter_upvotes": "Upvotes", + "filter_local": "Local", + "load_more": "Load more", + "dismiss": "Dismiss", + "accept": "Accept", + "refuse": "Refuse", + "notification": "Notification", + "friend_request_received_title": "New friend request!", + "friend_request_received_description": "{{displayName}} wants to be your friend", + "friend_request_accepted_title": "Friend request accepted!", + "friend_request_accepted_description": "{{displayName}} accepted your friend request", + "badge_received_title": "You got a new badge!", + "badge_received_description": "{{badgeName}}", + "review_upvote_title": "Your review for {{gameTitle}} got upvotes!", + "review_upvote_description": "Your review received {{count}} new upvotes", + "marked_all_as_read": "All notifications marked as read", + "failed_to_mark_as_read": "Failed to mark notifications as read", + "cleared_all": "All notifications cleared", + "failed_to_clear": "Failed to clear notifications", + "failed_to_load": "Failed to load notifications", + "failed_to_dismiss": "Failed to dismiss notification", + "friend_request_accepted": "Friend request accepted", + "friend_request_refused": "Friend request refused" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 5a65d3cf..12dae377 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -93,8 +93,16 @@ }, "header": { "search": "Buscar juegos", + "search_library": "Buscar en la librería", + "recent_searches": "Búsquedas Recientes", + "suggestions": "Sugerencias", + "clear_history": "Limpiar", + "remove_from_history": "Eliminar del historial", + "loading": "Cargando...", + "no_results": "Sin resultados", "home": "Inicio", "catalogue": "Catálogo", + "library": "Librería", "downloads": "Descargas", "search_results": "Resultados de búsqueda", "settings": "Ajustes", @@ -450,6 +458,7 @@ "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas", "button_delete_all_sources": "Eliminar todo", "added_download_source": "Añadir fuente de descarga", + "adding": "Añadiendo…", "download_sources_synced": "Todas las fuentes de descarga están sincronizadas", "insert_valid_json_url": "Introducí una URL de json válida", "found_download_option_zero": "Sin opciones de descargas encontrada", @@ -555,6 +564,19 @@ "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", + "change_achievement_sound": "Cambiar sonido de logro", + "download_source_already_exists": "Esta fuente de descarga URL ya existe.", + "download_source_failed": "Error", + "download_source_matched": "Actualizado", + "download_source_matching": "Actualizando", + "download_source_no_information": "Sin información disponible", + "download_source_pending_matching": "Actualizando pronto", + "download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas", + "failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.", + "hydra_cloud": "Hydra Cloud", + "preview_sound": "Vista previa de sonido", + "remove_achievement_sound": "Eliminar sonido de logros", + "removed_all_download_sources": "Todas las fuentes de descarga eliminadas", "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego" }, "notifications": { 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/hu/translation.json b/src/locales/hu/translation.json index 8aea356b..b83fec51 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -8,11 +8,12 @@ "no_results": "Nincs találat", "start_typing": "Kereséshez gépelj...", "hot": "Most felkapott", - "weekly": "📅 A hét felkapottjai", + "weekly": "📅 Heti kiemeltek", "achievements": "🏆 Achievement támogatott" }, "sidebar": { "catalogue": "Katalógus", + "library": "Könyvtár", "downloads": "Letöltések", "settings": "Beállítások", "my_library": "Könyvtáram", @@ -21,7 +22,7 @@ "downloading": "{{title}} ({{percentage}} - Letöltés…)", "filter": "Könyvtár szűrése", "home": "Főoldal", - "queued": "A(z) {{title}} (Várakozósorban van)", + "queued": "{{title}} (Várakozásban)", "game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl", "sign_in": "Bejelentkezés", "friends": "Barátok", @@ -81,7 +82,7 @@ "update_decky_plugin": "Decky Plugin Frissítése", "decky_plugin_installed_version": "Decky Plugin (v{{version}})", "install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint", - "install_decky_plugin_message": "Ez letölti és telepíteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?", + "install_decky_plugin_message": "Ez letölti és telepíti a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?", "update_decky_plugin_title": "Hydra Decky Plugin Frissítése", "update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?", "decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve", @@ -92,8 +93,16 @@ }, "header": { "search": "Keresés", + "search_library": "Könyvtár böngészése", + "recent_searches": "Korábbi Keresések", + "suggestions": "Találatok", + "clear_history": "Törlés", + "remove_from_history": "Törlés az előzményekből", + "loading": "Töltés...", + "no_results": "Nincs találat", "home": "Főoldal", "catalogue": "Katalógus", + "library": "Könyvtár", "downloads": "Letöltések", "search_results": "Keresési találatok", "settings": "Beállítások", @@ -106,6 +115,7 @@ "downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}", "calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…", "checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)", + "extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)", "installing_common_redist": "{{log}}…", "installation_complete": "Telepítés befejezve", "installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve" @@ -117,7 +127,7 @@ "tags": "Címkék", "publishers": "Kiadók", "download_sources": "Letöltési források", - "result_count": "{{resultCount}} találatok", + "result_count": "{{resultCount}} találat", "filter_count": "{{filterCount}} elérhető", "clear_filters": "{{filterCount}} kiválaszott szűrő törlése" }, @@ -162,15 +172,15 @@ "playing_now": "Játékban: ", "change": "Változtatás", "repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni", - "select_folder_hint": "A letöltési mappát a <0>Beállítások menüjében változtathatod meg", + "select_folder_hint": "A letöltési mappát a <0>Beállításokban változtathatod meg", "download_now": "Letöltés", "no_shop_details": "A bolt adatai nem érhetőek el.", "download_options": "Letöltési opciók", - "download_path": "Letöltis hely", + "download_path": "Letöltési hely", "previous_screenshot": "Előző screenshot", "next_screenshot": "Következő screenshot", "screenshot": "Screenshot {{number}}", - "open_screenshot": "Screenshot megnyitása {{number}}", + "open_screenshot": "{{number}} Screenshot megnyitása ", "download_settings": "Letöltési beállítások", "downloader": "Letöltési mód", "select_executable": "Tallózás", @@ -193,7 +203,9 @@ "danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve", "download_in_progress": "Letöltés folyamatban", "download_paused": "Letöltés szüneteltetve", + "extracting": "Kicsomagolás", "last_downloaded_option": "Utoljára letöltött", + "new_download_option": "Új", "create_steam_shortcut": "Steam parancsikon létrehozása", "create_shortcut_success": "A parancsikon létrehozása sikeres", "you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.", @@ -223,6 +235,7 @@ "show_more": "Mutass többet", "show_less": "Mutass kevesebbet", "reviews": "Vélemények", + "review_played_for": "Játszva", "leave_a_review": "Hagyd itt a véleményed", "write_review_placeholder": "Oszd meg gondolatod a játékról...", "sort_newest": "Legújabb", @@ -361,7 +374,10 @@ "show_original": "Eredeti megjelenítése", "show_translation": "Fordítás megjelenítése", "show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})", - "hide_original": "Eredeti elrejtése" + "hide_original": "Eredeti elrejtése", + "review_from_blocked_user": "Letiltott felhasználó véleménye", + "show": "Megjelenítés", + "hide": "Elrejtés" }, "activation": { "title": "Hydra Aktiválása", @@ -389,7 +405,7 @@ "delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről", "install": "Telepít", "download_in_progress": "Folyamatban lévő", - "queued_downloads": "Várakozósoron lévő letöltések", + "queued_downloads": "Várakozásban lévő letöltések", "downloads_completed": "Befejezett", "queued": "Várakozásban", "no_downloads_title": "Oly üres..", @@ -400,7 +416,11 @@ "resume_seeding": "Seedelés folytatása", "options": "Kezelés", "extract": "Fájlok kibontása", - "extracting": "Fájlok kibontása…" + "extracting": "Fájlok kibontása…", + "delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}", + "delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ", + "yes": "Igen", + "no": "Nem" }, "settings": { "downloads_path": "Letöltési útvonalak", @@ -488,11 +508,11 @@ "no_email_account": "Még nincs beállított emailed", "account_data_updated_successfully": "Fiókadatok változtatása sikeres", "renew_subscription": "Hydra Cloud Megújítása", - "subscription_expired_at": "Az előfizetésed lejárt, ekkor: {{date}}", + "subscription_expired_at": "Az előfizetésed lejárt: {{date}}", "no_subscription": "Élvezd a Hydrát a lehető legjobb módon", "become_subscriber": "Légy Hydra Cloud tag", "subscription_renew_cancelled": "Automatikus megújítás kikapcsolva", - "subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}", + "subscription_renews_on": "Az előfizetésed megújul: {{date}}", "bill_sent_until": "A következő számlát ezen napon küldjük", "no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.", "editor_tab_code": "Code", @@ -551,10 +571,19 @@ "platinum": "Platina", "hidden": "Rejtett", "test_notification": "Értesítés tesztelése", + "achievement_sound_volume": "Achievement hangereje", + "select_achievement_sound": "Achievement hang kiválasztása", + "change_achievement_sound": "Achievement hang megváltoztatása", + "remove_achievement_sound": "Achievement hang eltávolítása", + "preview_sound": "Hang előnézet", + "select": "Kiválaszt", + "preview": "Előnézet", + "remove": "Eltávolít", + "no_sound_file_selected": "Nincs hangfájl kiválasztva", "notification_preview": "Achievement Értesítés Előnézete", "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot", "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", - "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára" + "hide_to_tray_on_game_start": "Hydra elrejtése játék indításakor a tálcára" }, "notifications": { "download_complete": "Letöltés befejezve", @@ -652,7 +681,7 @@ "no_blocked_users": "Nincs letiltott felhasználó", "friend_code_copied": "Barát kód kimásolva", "undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}", - "privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások menüjébe", + "privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba", "locked_profile": "Ez a profil privát", "image_process_failure": "Hiba a kép feldolgozása közben", "required_field": "Ez a mező kötelező", @@ -670,7 +699,7 @@ "report_reason_other": "Egyéb", "profile_reported": "Profil bejelentve", "your_friend_code": "A barát kódod:", - "upload_banner": "Borítókép feltöltés", + "upload_banner": "Borítókép feltöltése", "uploading_banner": "Borítókép feltöltése…", "background_image_updated": "Borítókép frissítve", "stats": "Statisztikák", @@ -689,7 +718,31 @@ "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "karma": "Karma", "karma_count": "karma", - "karma_description": "Pozitív értékelésekkel szerzett pontok" + "karma_description": "Pozitív értékelésekkel szerzett pontok", + "user_reviews": "Vélemények", + "delete_review": "Vélemény Törlése", + "loading_reviews": "Vélemények betöltése..." + }, + "library": { + "library": "Könyvtár", + "play": "Játék", + "download": "Letöltés", + "downloading": "Letöltés..", + "game": "játék", + "games": "játékok", + "grid_view": "Rács nézet", + "compact_view": "Kompakt nézet", + "large_view": "Nagy nézet", + "no_games_title": "A könyvtárad üres", + "no_games_description": "Adj játékokat a katalógusból hozzá vagy töltsd le őket hogy bele vágj", + "amount_hours": "{{amount}} óra", + "amount_minutes": "{{amount}} perc", + "amount_hours_short": "{{amount}}ó", + "amount_minutes_short": "{{amount}}p", + "manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", + "all_games": "Összes Játék", + "recently_played": "Nemrég Játszva", + "favorites": "Kedvencek" }, "achievement": { "achievement_unlocked": "Achievement feloldva", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 73d5e8fb..719f72f7 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -93,11 +93,19 @@ }, "header": { "search": "Buscar jogos", + "search_library": "Buscar na biblioteca", + "recent_searches": "Buscas Recentes", + "suggestions": "Sugestões", + "clear_history": "Limpar", + "remove_from_history": "Remover do histórico", + "loading": "Carregando...", + "no_results": "Sem resultados", + "home": "Início", "catalogue": "Catálogo", + "library": "Biblioteca", "downloads": "Downloads", "search_results": "Resultados da busca", "settings": "Ajustes", - "home": "Início", "version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.", "version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download." }, @@ -107,6 +115,7 @@ "downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", "calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…", "checking_files": "Verificando arquivos de {{title}}…", + "extracting": "Extraindo {{title}}… ({{percentage}} concluído)", "installing_common_redist": "{{log}}…", "installation_complete": "Instalação concluída", "installation_complete_message": "Componentes recomendados instalados com sucesso" @@ -182,6 +191,7 @@ "danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra", "download_in_progress": "Download em andamento", "download_paused": "Download pausado", + "extracting": "Extraindo", "last_downloaded_option": "Última opção baixada", "new_download_option": "Novo", "create_steam_shortcut": "Criar atalho na Steam", @@ -394,7 +404,13 @@ "resume_seeding": "Semear", "options": "Gerenciar", "extract": "Extrair arquivos", - "extracting": "Extraindo arquivos…" + "extracting": "Extraindo arquivos…", + "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", + "network": "REDE", + "peak": "PICO" }, "settings": { "downloads_path": "Diretório dos downloads", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index c8e4586d..e48e1458 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -30,11 +30,19 @@ }, "header": { "search": "Procurar jogos", + "search_library": "Procurar na biblioteca", + "recent_searches": "Pesquisas Recentes", + "suggestions": "Sugestões", + "clear_history": "Limpar", + "remove_from_history": "Remover do histórico", + "loading": "A carregar...", + "no_results": "Sem resultados", + "home": "Início", "catalogue": "Catálogo", + "library": "Biblioteca", "downloads": "Transferências", "search_results": "Resultados da pesquisa", "settings": "Definições", - "home": "Início", "version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.", "version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download." }, diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index b831ff2e..1cf7ae2f 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -93,8 +93,16 @@ }, "header": { "search": "Поиск", + "search_library": "Поиск в библиотеке", + "recent_searches": "Недавние поиски", + "suggestions": "Предложения", + "clear_history": "Очистить", + "remove_from_history": "Удалить из истории", + "loading": "Загрузка...", + "no_results": "Нет результатов", "home": "Главная", "catalogue": "Каталог", + "library": "Библиотека", "downloads": "Загрузки", "search_results": "Результаты поиска", "settings": "Настройки", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index e8e1cb2b..52e1f10f 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -16,6 +16,7 @@ "downloads": "İndirilenler", "settings": "Ayarlar", "my_library": "Kütüphanem", + "library": "Kütüphane", "downloading_metadata": "{{title}} (Meta verileri indiriliyor…)", "paused": "{{title}} (Duraklatıldı)", "downloading": "{{title}} (%{{percentage}} - İndiriliyor…)", @@ -26,7 +27,69 @@ "sign_in": "Giriş Yap", "friends": "Arkadaşlar", "need_help": "Yardıma mı ihtiyacınız var?", - "favorites": "Favoriler" + "favorites": "Favoriler", + "playable_button_title": "Şu anda oynayabileceğin oyunları göster", + "add_custom_game_tooltip": "Özel Oyun Ekle", + "show_playable_only_tooltip": "Sadece Oynanabilirleri Göster", + "custom_game_modal": "Özel Oyun Ekle", + "custom_game_modal_description": "Çalıştırılabilir bir dosya seçerek kütüphanene özel oyun ekle", + "custom_game_modal_executable_path": "Çalıştırılabilir Dosya Yolu", + "custom_game_modal_select_executable": "Çalıştırılabilir dosya seç", + "custom_game_modal_title": "Başlık", + "custom_game_modal_enter_title": "Başlık gir", + "custom_game_modal_browse": "Gözat", + "custom_game_modal_cancel": "İptal", + "custom_game_modal_add": "Oyun Ekle", + "custom_game_modal_adding": "Oyun Ekleniyor...", + "custom_game_modal_success": "Özel oyun başarıyla eklendi", + "custom_game_modal_failed": "Özel oyun eklenemedi", + "custom_game_modal_executable": "Çalıştırılabilir", + "edit_game_modal": "Varlıkları Özelleştir", + "edit_game_modal_description": "Oyun varlıklarını ve detaylarını özelleştir", + "edit_game_modal_title": "Başlık", + "edit_game_modal_enter_title": "Başlık gir", + "edit_game_modal_image": "Görsel", + "edit_game_modal_select_image": "Görsel seç", + "edit_game_modal_browse": "Gözat", + "edit_game_modal_image_preview": "Görsel önizleme", + "edit_game_modal_icon": "İkon", + "edit_game_modal_select_icon": "İkon seç", + "edit_game_modal_icon_preview": "İkon önizleme", + "edit_game_modal_logo": "Logo", + "edit_game_modal_select_logo": "Logo seç", + "edit_game_modal_logo_preview": "Logo önizleme", + "edit_game_modal_hero": "Kütüphane Hero", + "edit_game_modal_select_hero": "Kütüphane hero görseli seç", + "edit_game_modal_hero_preview": "Kütüphane hero görseli önizleme", + "edit_game_modal_cancel": "İptal et", + "edit_game_modal_update": "Güncelle", + "edit_game_modal_updating": "Güncelleniyor...", + "edit_game_modal_fill_required": "Lütfen tüm gerekli alanları doldur", + "edit_game_modal_success": "Varlıklar başarıyla güncellendi", + "edit_game_modal_failed": "Varlıklar güncellenemedi", + "edit_game_modal_image_filter": "Görsel", + "edit_game_modal_icon_resolution": "Önerilen çözünürlük: 256x256px", + "edit_game_modal_logo_resolution": "Önerilen çözünürlük: 640x360px", + "edit_game_modal_hero_resolution": "Önerilen çözünürlük: 1920x620px", + "edit_game_modal_assets": "Varlıklar", + "edit_game_modal_drop_icon_image_here": "İkon görselini buraya bırak", + "edit_game_modal_drop_logo_image_here": "Logo görselini buraya bırak", + "edit_game_modal_drop_hero_image_here": "Hero görselini buraya bırak", + "edit_game_modal_drop_to_replace_icon": "İkonu değiştirmek için buraya bırak", + "edit_game_modal_drop_to_replace_logo": "Logoyu değiştirmek için buraya bırak", + "edit_game_modal_drop_to_replace_hero": "Hero'yu değiştirmek için buraya bırak", + "install_decky_plugin": "Decky Plugin Kur", + "update_decky_plugin": "Decky Plugin Güncelle", + "decky_plugin_installed_version": "Decky Plugin (v{{version}})", + "install_decky_plugin_title": "Hydra Decky Plugin Kur", + "install_decky_plugin_message": "Bu işlem Decky Loader için Hydra plugin'ini indirecek ve kuracak. Bu işlem yükseltilmiş izinler gerektirebilir. Devam et?", + "update_decky_plugin_title": "Hydra Decky Plugin Güncelle", + "update_decky_plugin_message": "Hydra Decky plugin'inin yeni bir sürümü mevcut. Şimdi güncellemek ister misin?", + "decky_plugin_installed": "Decky plugin v{{version}} başarıyla kuruldu", + "decky_plugin_installation_failed": "Decky plugin kurulamadı: {{error}}", + "decky_plugin_installation_error": "Decky plugin kurulumu hatası: {{error}}", + "confirm": "Onayla", + "cancel": "İptal" }, "header": { "search": "Oyunlarda Ara", @@ -35,6 +98,8 @@ "downloads": "İndirilenler", "search_results": "Arama Sonuçları", "settings": "Ayarlar", + "search_library": "Kütüphanede ara", + "library": "Kütüphane", "version_available_install": "{{version}} sürümü mevcut. Yeniden başlatıp yüklemek için tıklayın.", "version_available_download": "{{version}} sürümü mevcut. İndirmek için tıklayın." }, @@ -203,7 +268,108 @@ "create_start_menu_shortcut": "Başlat Menüsüne kısayol oluştur", "invalid_wine_prefix_path": "Geçersiz Wine ön ek yolu", "invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.", - "missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir" + "missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir", + "already_in_library": "Zaten kütüphanede", + "create_shortcut_simple": "Kısayol oluştur", + "properties": "Özellikler", + "new_download_option": "Yeni", + "add_to_favorites": "Favorilere ekle", + "remove_from_favorites": "Favorilerden çıkar", + "failed_update_favorites": "Favoriler güncellenemedi", + "game_removed_from_library": "Oyun kütüphaneden çıkarıldı", + "failed_remove_from_library": "Kütüphaneden çıkarılamadı", + "files_removed_success": "Dosyalar başarıyla kaldırıldı", + "failed_remove_files": "Dosyalar kaldırılamadı", + "rating_count": "Puan", + "show_more": "Daha fazla göster", + "show_less": "Daha az göster", + "reviews": "İncelemeler", + "review_played_for": "Oynama süresi", + "leave_a_review": "İnceleme Yap", + "write_review_placeholder": "Bu oyun hakkındaki düşüncelerini paylaş...", + "sort_newest": "En yeni", + "no_reviews_yet": "Henüz inceleme yok", + "be_first_to_review": "Bu oyun hakkındaki düşüncelerini paylaşan ilk kişi ol!", + "sort_oldest": "En eski", + "sort_highest_score": "En yüksek puan", + "sort_lowest_score": "En düşük puan", + "sort_most_voted": "En çok oy", + "rating": "Puan", + "rating_stats": "Puan", + "rating_very_negative": "Çok Olumsuz", + "rating_negative": "Olumsuz", + "rating_neutral": "Nötr", + "rating_positive": "Olumlu", + "rating_very_positive": "Çok Olumlu", + "submit_review": "Gönder", + "submitting": "Gönderiliyor...", + "review_submitted_successfully": "İnceleme başarıyla gönderildi!", + "review_submission_failed": "İnceleme gönderilemedi. Lütfen tekrar dene.", + "review_cannot_be_empty": "İnceleme metin alanı boş olamaz.", + "review_deleted_successfully": "İnceleme başarıyla silindi.", + "review_deletion_failed": "İnceleme silinemedi. Lütfen tekrar dene.", + "loading_reviews": "İncelemeler yükleniyor...", + "loading_more_reviews": "Daha fazla inceleme yükleniyor...", + "load_more_reviews": "Daha fazla inceleme yükle", + "you_seemed_to_enjoy_this_game": "Bu oyunu beğenmiş görünüyorsun", + "would_you_recommend_this_game": "Bu oyun hakkında bir inceleme yazmak ister misin?", + "yes": "Evet", + "maybe_later": "Belki sonra", + "backup_failed": "Yedekleme başarısız", + "update_playtime_title": "Oynama süresini güncelle", + "update_playtime_description": "{{game}} için oynama süresini manuel olarak güncelle", + "update_playtime": "Oynama süresini güncelle", + "update_playtime_success": "Oynama süresi başarıyla güncellendi", + "update_playtime_error": "Oynama süresi güncellenemedi", + "update_game_playtime": "Oyun oynama süresini güncelle", + "manual_playtime_warning": "Saatlerin manuel olarak güncellendiği işaretlenecek ve bu geri alınamaz.", + "manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi", + "game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı", + "game_added_to_pinned": "Oyun sabitlenmişlere eklendi", + "artifact_renamed": "Yedekleme başarıyla yeniden adlandırıldı", + "rename_artifact": "Yedeklemeyi Yeniden Adlandır", + "rename_artifact_description": "Yedeklemeyi daha açıklayıcı bir isimle yeniden adlandır", + "artifact_name_label": "Yedekleme adı", + "artifact_name_placeholder": "Yedekleme için bir isim gir", + "save_changes": "Değişiklikleri kaydet", + "required_field": "Bu alan gereklidir", + "max_length_field": "Bu alan {{length}} karakterden az olmalıdır", + "freeze_backup": "Otomatik yedeklemeler tarafından üzerine yazılmasın diye sabitle", + "unfreeze_backup": "Sabitlemeyi kaldır", + "backup_frozen": "Yedekleme sabitlendi", + "backup_unfrozen": "Yedekleme sabitlemesi kaldırıldı", + "backup_freeze_failed": "Yedekleme sabitlenemedi", + "backup_freeze_failed_description": "Otomatik yedeklemeler için en az bir boş alan bırakmalısın", + "edit_game_modal_button": "Oyun varlıklarını özelleştir", + "game_details": "Oyun Detayları", + "currency_symbol": "₺", + "currency_country": "tr", + "prices": "Fiyatlar", + "no_prices_found": "Fiyat bulunamadı", + "view_all_prices": "Tüm fiyatları görüntülemek için tıkla", + "retail_price": "Perakende fiyatı", + "keyshop_price": "Anahtar dükkanı fiyatı", + "historical_retail": "Geçmiş perakende", + "historical_keyshop": "Geçmiş anahtar dükkanı", + "language": "Dil", + "caption": "Altyazı", + "audio": "Ses", + "filter_by_source": "Kaynağa göre filtrele", + "no_repacks_found": "Bu oyun için kaynak bulunamadı", + "delete_review": "İncelemeyi sil", + "remove_review": "İncelemeyi Kaldır", + "delete_review_modal_title": "İncelemeni silmek istediğinden emin misin?", + "delete_review_modal_description": "Bu işlem geri alınamaz.", + "delete_review_modal_delete_button": "Sil", + "delete_review_modal_cancel_button": "İptal", + "vote_failed": "Oyun kaydı başarısız oldu. Lütfen tekrar dene.", + "show_original": "Orijinali göster", + "show_translation": "Çeviriyi göster", + "show_original_translated_from": "Orijinali göster ({{language}} dilinden çevrilmiştir)", + "hide_original": "Orijinali gizle", + "review_from_blocked_user": "Engellenen kullanıcıdan gelen inceleme", + "show": "Göster", + "hide": "Gizle" }, "activation": { "title": "Hydra'yı Etkinleştir", @@ -379,7 +545,33 @@ "hidden": "Gizli", "test_notification": "Test bildirimi", "notification_preview": "Başarı Bildirimi Önizlemesi", - "enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında" + "enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında", + "adding": "Ekleniyor…", + "failed_add_download_source": "İndirme kaynağı eklenemedi. Lütfen tekrar dene.", + "download_source_already_exists": "Bu indirme kaynağı URL'si zaten mevcut.", + "download_source_pending_matching": "Yakında güncellenecek", + "download_source_matched": "Güncel", + "download_source_matching": "Güncelleniyor", + "download_source_failed": "Hata", + "download_source_no_information": "Bilgi mevcut değil", + "removed_all_download_sources": "Tüm indirme kaynakları kaldırıldı", + "download_sources_synced_successfully": "Tüm indirme kaynakları senkronize edildi", + "importing": "İçe aktarılıyor...", + "hydra_cloud": "Hydra Cloud", + "debrid": "Debrid", + "debrid_description": "Debrid servisleri, internet hızınızla sınırlı, çeşitli dosya barındırma hizmetlerinde barındırılan dosyaları hızla indirmenize olanak tanıyan premium sınırsız indiricilerdir.", + "enable_steam_achievements": "Steam başarımları aramasını etkinleştir", + "achievement_sound_volume": "Başarım ses seviyesi", + "select_achievement_sound": "Başarım sesi seç", + "change_achievement_sound": "Başarım sesini değiştir", + "remove_achievement_sound": "Başarım sesini kaldır", + "preview_sound": "Sesi önizle", + "select": "Seç", + "preview": "Önizle", + "remove": "Kaldır", + "no_sound_file_selected": "Ses dosyası seçilmedi", + "autoplay_trailers_on_game_page": "Oyun sayfasında fragmanları otomatik olarak oynat", + "hide_to_tray_on_game_start": "Oyun başlatıldığında Hydra'yı sistem tepsisine gizle" }, "notifications": { "download_complete": "İndirme tamamlandı", @@ -406,7 +598,8 @@ "game_card": { "available_one": "Mevcut", "available_other": "Mevcut", - "no_downloads": "İndirme mevcut değil" + "no_downloads": "İndirme mevcut değil", + "calculating": "Hesaplanıyor" }, "binary_not_found_modal": { "title": "Programlar Yüklü Değil", @@ -498,7 +691,46 @@ "achievements_unlocked": "Açılan başarımlar", "earned_points": "Kazanılan puanlar", "show_achievements_on_profile": "Başarımlarını profilinde göster", - "show_points_on_profile": "Kazanılan puanlarını profilinde göster" + "show_points_on_profile": "Kazanılan puanlarını profilinde göster", + "amount_hours_short": "{{amount}}s", + "amount_minutes_short": "{{amount}}d", + "pinned": "Sabitlenmiş", + "sort_by": "Sırala:", + "achievements_earned": "Kazanılan başarımlar", + "played_recently": "Son oynanan", + "playtime": "Oynama süresi", + "manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi", + "error_adding_friend": "Arkadaş isteği gönderilemedi. Lütfen arkadaş kodunu kontrol et", + "friend_code_length_error": "Arkadaş kodu 8 karakter olmalıdır", + "game_removed_from_pinned": "Oyun sabitlenmişlerden çıkarıldı", + "game_added_to_pinned": "Oyun sabitlenmişlere eklendi", + "karma": "Karma", + "karma_count": "karma", + "karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır", + "user_reviews": "İncelemeler", + "delete_review": "İncelemeyi Sil", + "loading_reviews": "İncelemeler yükleniyor..." + }, + "library": { + "library": "Kütüphane", + "play": "Oyna", + "download": "İndir", + "downloading": "İndiriliyor", + "game": "oyun", + "games": "oyunlar", + "grid_view": "Izgara görünümü", + "compact_view": "Kompakt görünüm", + "large_view": "Büyük görünüm", + "no_games_title": "Kütüphanen boş", + "no_games_description": "Başlamak için katalogdan oyun ekle veya indir", + "amount_hours": "{{amount}} saat", + "amount_minutes": "{{amount}} dakika", + "amount_hours_short": "{{amount}}s", + "amount_minutes_short": "{{amount}}d", + "manual_playtime_tooltip": "Bu oynama süresi manuel olarak güncellendi", + "all_games": "Tüm Oyunlar", + "recently_played": "Son Oynanan", + "favorites": "Favoriler" }, "achievement": { "achievement_unlocked": "Başarım açıldı", diff --git a/src/main/events/auth/index.ts b/src/main/events/auth/index.ts new file mode 100644 index 00000000..e94e9bc5 --- /dev/null +++ b/src/main/events/auth/index.ts @@ -0,0 +1,3 @@ +import "./get-session-hash"; +import "./open-auth-window"; +import "./sign-out"; diff --git a/src/main/events/autoupdater/index.ts b/src/main/events/autoupdater/index.ts new file mode 100644 index 00000000..f6b70367 --- /dev/null +++ b/src/main/events/autoupdater/index.ts @@ -0,0 +1,2 @@ +import "./check-for-updates"; +import "./restart-and-install-update"; diff --git a/src/main/events/catalogue/index.ts b/src/main/events/catalogue/index.ts new file mode 100644 index 00000000..383ba34c --- /dev/null +++ b/src/main/events/catalogue/index.ts @@ -0,0 +1,4 @@ +import "./get-game-assets"; +import "./get-game-shop-details"; +import "./get-game-stats"; +import "./get-random-game"; diff --git a/src/main/events/cloud-save/index.ts b/src/main/events/cloud-save/index.ts new file mode 100644 index 00000000..92e9f528 --- /dev/null +++ b/src/main/events/cloud-save/index.ts @@ -0,0 +1,4 @@ +import "./download-game-artifact"; +import "./get-game-backup-preview"; +import "./select-game-backup-path"; +import "./upload-save-game"; diff --git a/src/main/events/download-sources/index.ts b/src/main/events/download-sources/index.ts new file mode 100644 index 00000000..325d5570 --- /dev/null +++ b/src/main/events/download-sources/index.ts @@ -0,0 +1,6 @@ +import "./add-download-source"; +import "./get-download-sources-check-baseline"; +import "./get-download-sources-since-value"; +import "./get-download-sources"; +import "./remove-download-source"; +import "./sync-download-sources"; diff --git a/src/main/events/hardware/index.ts b/src/main/events/hardware/index.ts new file mode 100644 index 00000000..76823f51 --- /dev/null +++ b/src/main/events/hardware/index.ts @@ -0,0 +1,2 @@ +import "./check-folder-write-permission"; +import "./get-disk-free-space"; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 5aada847..aaaf491d 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -6,108 +6,21 @@ import { } from "@main/constants"; import { ipcMain } from "electron"; -import "./catalogue/get-game-shop-details"; -import "./catalogue/get-random-game"; -import "./catalogue/get-game-stats"; -import "./hardware/get-disk-free-space"; -import "./hardware/check-folder-write-permission"; -import "./library/add-game-to-library"; -import "./library/add-custom-game-to-library"; -import "./library/update-custom-game"; -import "./library/update-game-custom-assets"; -import "./library/add-game-to-favorites"; -import "./library/remove-game-from-favorites"; -import "./library/toggle-game-pin"; -import "./library/create-game-shortcut"; -import "./library/close-game"; -import "./library/delete-game-folder"; -import "./library/get-game-by-object-id"; -import "./library/get-library"; -import "./library/refresh-library-assets"; -import "./library/extract-game-download"; -import "./library/clear-new-download-options"; -import "./library/open-game"; -import "./library/open-game-executable-path"; -import "./library/open-game-installer"; -import "./library/open-game-installer-path"; -import "./library/update-executable-path"; -import "./library/update-launch-options"; -import "./library/verify-executable-path"; -import "./library/remove-game"; -import "./library/remove-game-from-library"; -import "./library/select-game-wine-prefix"; -import "./library/reset-game-achievements"; -import "./library/change-game-playtime"; -import "./library/toggle-automatic-cloud-sync"; -import "./library/get-default-wine-prefix-selection-path"; -import "./library/cleanup-unused-assets"; -import "./library/create-steam-shortcut"; -import "./library/copy-custom-game-asset"; -import "./misc/open-checkout"; -import "./misc/open-external"; -import "./misc/open-folder"; -import "./misc/show-open-dialog"; -import "./misc/show-item-in-folder"; -import "./misc/install-common-redist"; -import "./misc/can-install-common-redist"; -import "./misc/save-temp-file"; -import "./misc/delete-temp-file"; -import "./misc/install-hydra-decky-plugin"; -import "./misc/get-hydra-decky-plugin-info"; -import "./misc/check-homebrew-folder-exists"; -import "./misc/hydra-api-call"; -import "./torrenting/cancel-game-download"; -import "./torrenting/pause-game-download"; -import "./torrenting/resume-game-download"; -import "./torrenting/start-game-download"; -import "./torrenting/pause-game-seed"; -import "./torrenting/resume-game-seed"; -import "./torrenting/check-debrid-availability"; -import "./user-preferences/get-user-preferences"; -import "./user-preferences/update-user-preferences"; -import "./user-preferences/auto-launch"; -import "./autoupdater/check-for-updates"; -import "./autoupdater/restart-and-install-update"; -import "./user-preferences/authenticate-real-debrid"; -import "./user-preferences/authenticate-torbox"; -import "./download-sources/add-download-source"; -import "./download-sources/sync-download-sources"; -import "./download-sources/get-download-sources-check-baseline"; -import "./download-sources/get-download-sources-since-value"; -import "./auth/sign-out"; -import "./auth/open-auth-window"; -import "./auth/get-session-hash"; -import "./user/get-auth"; -import "./user/get-unlocked-achievements"; -import "./user/get-compared-unlocked-achievements"; -import "./profile/get-me"; -import "./profile/update-profile"; -import "./profile/process-profile-image"; -import "./profile/sync-friend-requests"; -import "./cloud-save/download-game-artifact"; -import "./cloud-save/get-game-backup-preview"; -import "./cloud-save/upload-save-game"; -import "./cloud-save/select-game-backup-path"; -import "./notifications/publish-new-repacks-notification"; -import "./notifications/update-achievement-notification-window"; -import "./notifications/show-achievement-test-notification"; -import "./themes/add-custom-theme"; -import "./themes/delete-custom-theme"; -import "./themes/get-all-custom-themes"; -import "./themes/delete-all-custom-themes"; -import "./themes/update-custom-theme"; -import "./themes/open-editor-window"; -import "./themes/get-custom-theme-by-id"; -import "./themes/get-active-custom-theme"; -import "./themes/close-editor-window"; -import "./themes/toggle-custom-theme"; -import "./themes/copy-theme-achievement-sound"; -import "./themes/remove-theme-achievement-sound"; -import "./themes/get-theme-sound-path"; -import "./themes/get-theme-sound-data-url"; -import "./themes/import-theme-sound-from-store"; -import "./download-sources/remove-download-source"; -import "./download-sources/get-download-sources"; +import "./auth"; +import "./autoupdater"; +import "./catalogue"; +import "./cloud-save"; +import "./download-sources"; +import "./hardware"; +import "./library"; +import "./leveldb"; +import "./misc"; +import "./notifications"; +import "./profile"; +import "./themes"; +import "./torrenting"; +import "./user"; +import "./user-preferences"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/leveldb/helpers.ts b/src/main/events/leveldb/helpers.ts new file mode 100644 index 00000000..e171e65a --- /dev/null +++ b/src/main/events/leveldb/helpers.ts @@ -0,0 +1,27 @@ +import { db } from "@main/level"; + +const sublevelCache = new Map< + string, + ReturnType> +>(); + +/** + * Gets a sublevel by name, creating it if it doesn't exist. + * All sublevels use "json" encoding by default. + * @param sublevelName - The name of the sublevel to get or create + * @returns The sublevel instance + */ +export const getSublevelByName = ( + sublevelName: string +): ReturnType> => { + if (sublevelCache.has(sublevelName)) { + return sublevelCache.get(sublevelName)!; + } + + // All sublevels use "json" encoding - this cannot be changed per sublevel + const sublevel = db.sublevel(sublevelName, { + valueEncoding: "json", + }); + sublevelCache.set(sublevelName, sublevel); + return sublevel; +}; diff --git a/src/main/events/leveldb/index.ts b/src/main/events/leveldb/index.ts new file mode 100644 index 00000000..6007bd33 --- /dev/null +++ b/src/main/events/leveldb/index.ts @@ -0,0 +1,6 @@ +import "./leveldb-get"; +import "./leveldb-put"; +import "./leveldb-del"; +import "./leveldb-clear"; +import "./leveldb-values"; +import "./leveldb-iterator"; diff --git a/src/main/events/leveldb/leveldb-clear.ts b/src/main/events/leveldb/leveldb-clear.ts new file mode 100644 index 00000000..cbed1db0 --- /dev/null +++ b/src/main/events/leveldb/leveldb-clear.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbClear = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + await sublevel.clear(); + } catch (error) { + logger.error("Error in leveldbClear", error); + throw error; + } +}; + +registerEvent("leveldbClear", leveldbClear); diff --git a/src/main/events/leveldb/leveldb-del.ts b/src/main/events/leveldb/leveldb-del.ts new file mode 100644 index 00000000..5bcded1d --- /dev/null +++ b/src/main/events/leveldb/leveldb-del.ts @@ -0,0 +1,28 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbDel = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + sublevelName?: string | null +) => { + try { + if (sublevelName) { + const sublevel = getSublevelByName(sublevelName); + await sublevel.del(key); + } else { + await db.del(key); + } + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + // NotFoundError on delete is not an error, just return + return; + } + logger.error("Error in leveldbDel", error); + throw error; + } +}; + +registerEvent("leveldbDel", leveldbDel); diff --git a/src/main/events/leveldb/leveldb-get.ts b/src/main/events/leveldb/leveldb-get.ts new file mode 100644 index 00000000..059f1b30 --- /dev/null +++ b/src/main/events/leveldb/leveldb-get.ts @@ -0,0 +1,28 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbGet = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + sublevelName?: string | null, + valueEncoding: "json" | "utf8" = "json" +) => { + try { + if (sublevelName) { + // Note: sublevels always use "json" encoding, valueEncoding parameter is ignored + const sublevel = getSublevelByName(sublevelName); + return sublevel.get(key); + } + return db.get(key, { valueEncoding }); + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + return null; + } + logger.error("Error in leveldbGet", error); + throw error; + } +}; + +registerEvent("leveldbGet", leveldbGet); diff --git a/src/main/events/leveldb/leveldb-iterator.ts b/src/main/events/leveldb/leveldb-iterator.ts new file mode 100644 index 00000000..a1960c31 --- /dev/null +++ b/src/main/events/leveldb/leveldb-iterator.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbIterator = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + return sublevel.iterator().all(); + } catch (error) { + logger.error("Error in leveldbIterator", error); + throw error; + } +}; + +registerEvent("leveldbIterator", leveldbIterator); diff --git a/src/main/events/leveldb/leveldb-put.ts b/src/main/events/leveldb/leveldb-put.ts new file mode 100644 index 00000000..9c416722 --- /dev/null +++ b/src/main/events/leveldb/leveldb-put.ts @@ -0,0 +1,27 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbPut = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding: "json" | "utf8" = "json" +) => { + try { + if (sublevelName) { + // Note: sublevels always use "json" encoding, valueEncoding parameter is ignored + const sublevel = getSublevelByName(sublevelName); + await sublevel.put(key, value); + } else { + await db.put(key, value, { valueEncoding }); + } + } catch (error) { + logger.error("Error in leveldbPut", error); + throw error; + } +}; + +registerEvent("leveldbPut", leveldbPut); diff --git a/src/main/events/leveldb/leveldb-values.ts b/src/main/events/leveldb/leveldb-values.ts new file mode 100644 index 00000000..0e2c3c0f --- /dev/null +++ b/src/main/events/leveldb/leveldb-values.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbValues = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + return sublevel.values().all(); + } catch (error) { + logger.error("Error in leveldbValues", error); + throw error; + } +}; + +registerEvent("leveldbValues", leveldbValues); diff --git a/src/main/events/library/delete-archive.ts b/src/main/events/library/delete-archive.ts new file mode 100644 index 00000000..9cf64a63 --- /dev/null +++ b/src/main/events/library/delete-archive.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; + +import { registerEvent } from "../register-event"; +import { logger } from "@main/services"; + +const deleteArchive = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +) => { + try { + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + logger.info(`Deleted archive: ${filePath}`); + return true; + } + return true; + } catch (err) { + logger.error(`Failed to delete archive: ${filePath}`, err); + return false; + } +}; + +registerEvent("deleteArchive", deleteArchive); diff --git a/src/main/events/library/extract-game-download.ts b/src/main/events/library/extract-game-download.ts index 8fb24b81..b393e6b7 100644 --- a/src/main/events/library/extract-game-download.ts +++ b/src/main/events/library/extract-game-download.ts @@ -22,6 +22,7 @@ const extractGameDownload = async ( await downloadsSublevel.put(gameKey, { ...download, extracting: true, + extractionProgress: 0, }); const gameFilesManager = new GameFilesManager(shop, objectId); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index f62c60e7..9fb3416b 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -2,9 +2,9 @@ import type { LibraryGame } from "@types"; import { registerEvent } from "../register-event"; import { downloadsSublevel, + gameAchievementsSublevel, gamesShopAssetsSublevel, gamesSublevel, - gameAchievementsSublevel, } from "@main/level"; const getLibrary = async (): Promise => { @@ -19,18 +19,13 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); - let unlockedAchievementCount = 0; - let achievementCount = 0; + let unlockedAchievementCount = game.unlockedAchievementCount ?? 0; - try { + if (!game.unlockedAchievementCount) { const achievements = await gameAchievementsSublevel.get(key); - if (achievements) { - achievementCount = achievements.achievements.length; - unlockedAchievementCount = - achievements.unlockedAchievements.length; - } - } catch { - // No achievements data for this game + + unlockedAchievementCount = + achievements?.unlockedAchievements.length ?? 0; } return { @@ -38,14 +33,14 @@ const getLibrary = async (): Promise => { ...game, download: download ?? null, unlockedAchievementCount, - achievementCount, + achievementCount: game.achievementCount ?? 0, // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, // Preserve custom image URLs from game if they exist customIconUrl: game.customIconUrl, customLogoImageUrl: game.customLogoImageUrl, customHeroImageUrl: game.customHeroImageUrl, - } as LibraryGame; + }; }) ); }); diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts new file mode 100644 index 00000000..75fc5cd9 --- /dev/null +++ b/src/main/events/library/index.ts @@ -0,0 +1,33 @@ +import "./add-custom-game-to-library"; +import "./add-game-to-favorites"; +import "./add-game-to-library"; +import "./change-game-playtime"; +import "./cleanup-unused-assets"; +import "./clear-new-download-options"; +import "./close-game"; +import "./copy-custom-game-asset"; +import "./create-game-shortcut"; +import "./create-steam-shortcut"; +import "./delete-archive"; +import "./delete-game-folder"; +import "./extract-game-download"; +import "./get-default-wine-prefix-selection-path"; +import "./get-game-by-object-id"; +import "./get-library"; +import "./open-game-executable-path"; +import "./open-game-installer-path"; +import "./open-game-installer"; +import "./open-game"; +import "./refresh-library-assets"; +import "./remove-game-from-favorites"; +import "./remove-game-from-library"; +import "./remove-game"; +import "./reset-game-achievements"; +import "./select-game-wine-prefix"; +import "./toggle-automatic-cloud-sync"; +import "./toggle-game-pin"; +import "./update-custom-game"; +import "./update-executable-path"; +import "./update-game-custom-assets"; +import "./update-launch-options"; +import "./verify-executable-path"; diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts new file mode 100644 index 00000000..354e6687 --- /dev/null +++ b/src/main/events/misc/index.ts @@ -0,0 +1,12 @@ +import "./can-install-common-redist"; +import "./check-homebrew-folder-exists"; +import "./delete-temp-file"; +import "./get-hydra-decky-plugin-info"; +import "./hydra-api-call"; +import "./install-common-redist"; +import "./install-hydra-decky-plugin"; +import "./open-checkout"; +import "./open-external"; +import "./save-temp-file"; +import "./show-item-in-folder"; +import "./show-open-dialog"; 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 new file mode 100644 index 00000000..cbae29e5 --- /dev/null +++ b/src/main/events/notifications/index.ts @@ -0,0 +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 new file mode 100644 index 00000000..664d6ee2 --- /dev/null +++ b/src/main/events/profile/index.ts @@ -0,0 +1,3 @@ +import "./get-me"; +import "./process-profile-image"; +import "./update-profile"; diff --git a/src/main/events/profile/process-profile-image.ts b/src/main/events/profile/process-profile-image.ts index 6166f7f8..bec17cb6 100644 --- a/src/main/events/profile/process-profile-image.ts +++ b/src/main/events/profile/process-profile-image.ts @@ -1,16 +1,20 @@ import { registerEvent } from "../register-event"; import { PythonRPC } from "@main/services/python-rpc"; -const processProfileImage = async ( +const processProfileImageEvent = async ( _event: Electron.IpcMainInvokeEvent, path: string ) => { + return processProfileImage(path, "webp"); +}; + +export const processProfileImage = async (path: string, extension?: string) => { return PythonRPC.rpc .post<{ imagePath: string; mimeType: string; - }>("/profile-image", { image_path: path }) + }>("/profile-image", { image_path: path, target_extension: extension }) .then((response) => response.data); }; -registerEvent("processProfileImage", processProfileImage); +registerEvent("processProfileImage", processProfileImageEvent); 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/themes/index.ts b/src/main/events/themes/index.ts new file mode 100644 index 00000000..5f4d4a02 --- /dev/null +++ b/src/main/events/themes/index.ts @@ -0,0 +1,15 @@ +import "./add-custom-theme"; +import "./close-editor-window"; +import "./copy-theme-achievement-sound"; +import "./delete-all-custom-themes"; +import "./delete-custom-theme"; +import "./get-active-custom-theme"; +import "./get-all-custom-themes"; +import "./get-custom-theme-by-id"; +import "./get-theme-sound-data-url"; +import "./get-theme-sound-path"; +import "./import-theme-sound-from-store"; +import "./open-editor-window"; +import "./remove-theme-achievement-sound"; +import "./toggle-custom-theme"; +import "./update-custom-theme"; diff --git a/src/main/events/torrenting/index.ts b/src/main/events/torrenting/index.ts new file mode 100644 index 00000000..408ecf17 --- /dev/null +++ b/src/main/events/torrenting/index.ts @@ -0,0 +1,7 @@ +import "./cancel-game-download"; +import "./check-debrid-availability"; +import "./pause-game-download"; +import "./pause-game-seed"; +import "./resume-game-download"; +import "./resume-game-seed"; +import "./start-game-download"; diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index 48bb1c12..4525e2df 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -13,7 +13,11 @@ const resumeGameDownload = async ( const download = await downloadsSublevel.get(gameKey); - if (download?.status === "paused") { + if ( + download && + (download.status === "paused" || download.status === "active") && + download.progress !== 1 + ) { await DownloadManager.pauseDownload(); for await (const [key, value] of downloadsSublevel.iterator()) { diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 79d55ec3..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) { @@ -82,6 +81,7 @@ const startGameDownload = async ( queued: true, extracting: false, automaticallyExtract, + extractionProgress: 0, }; try { @@ -123,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/events/user-preferences/index.ts b/src/main/events/user-preferences/index.ts new file mode 100644 index 00000000..aab898e6 --- /dev/null +++ b/src/main/events/user-preferences/index.ts @@ -0,0 +1,5 @@ +import "./authenticate-real-debrid"; +import "./authenticate-torbox"; +import "./auto-launch"; +import "./get-user-preferences"; +import "./update-user-preferences"; diff --git a/src/main/events/user/index.ts b/src/main/events/user/index.ts new file mode 100644 index 00000000..cf63116f --- /dev/null +++ b/src/main/events/user/index.ts @@ -0,0 +1,3 @@ +import "./get-auth"; +import "./get-compared-unlocked-achievements"; +import "./get-unlocked-achievements"; 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/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index 4b60b962..36449b4d 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -7,7 +7,9 @@ export const getDownloadSourcesCheckBaseline = async (): Promise< string | null > => { try { - const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); + const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline, { + valueEncoding: "utf8", + }); return timestamp; } catch (error) { if (error instanceof Error && error.name === "NotFoundError") { @@ -27,7 +29,9 @@ export const updateDownloadSourcesCheckBaseline = async ( timestamp: string ): Promise => { const utcTimestamp = new Date(timestamp).toISOString(); - await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp); + await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp, { + valueEncoding: "utf8", + }); }; // Gets the 'since' value the API used in the last check (for modal comparison) @@ -35,7 +39,9 @@ export const getDownloadSourcesSinceValue = async (): Promise< string | null > => { try { - const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); + const timestamp = await db.get(levelKeys.downloadSourcesSinceValue, { + valueEncoding: "utf8", + }); return timestamp; } catch (error) { if (error instanceof Error && error.name === "NotFoundError") { @@ -55,5 +61,7 @@ export const updateDownloadSourcesSinceValue = async ( timestamp: string ): Promise => { const utcTimestamp = new Date(timestamp).toISOString(); - await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, { + valueEncoding: "utf8", + }); }; 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 1cadcebd..82ea7c47 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,5 +1,5 @@ import { downloadsSublevel } from "./level/sublevels/downloads"; -import { sortBy } from "lodash-es"; +import { orderBy } from "lodash-es"; import { Downloader } from "@shared"; import { levelKeys, db } from "./level"; import type { UserPreferences } from "@types"; @@ -33,9 +33,7 @@ export const loadState = async () => { await import("./events"); - if (process.platform !== "darwin") { - Aria2.spawn(); - } + Aria2.spawn(); if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences.realDebridApiToken); @@ -59,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(); }); @@ -68,7 +68,7 @@ export const loadState = async () => { .values() .all() .then((games) => { - return sortBy(games, "timestamp", "DESC"); + return orderBy(games, "timestamp", "desc"); }); downloads.forEach((download) => { diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts index 9a9f85be..0fa333dc 100644 --- a/src/main/services/7zip.ts +++ b/src/main/services/7zip.ts @@ -1,5 +1,5 @@ import { app } from "electron"; -import cp from "node:child_process"; +import Seven, { CommandLineSwitches } from "node-7z"; import path from "node:path"; import { logger } from "./logger"; @@ -9,6 +9,17 @@ export const binaryName = { win32: "7z.exe", }; +export interface ExtractionProgress { + percent: number; + fileCount: number; + file: string; +} + +export interface ExtractionResult { + success: boolean; + extractedFiles: string[]; +} + export class SevenZip { private static readonly binaryPath = app.isPackaged ? path.join(process.resourcesPath, binaryName[process.platform]) @@ -32,43 +43,109 @@ export class SevenZip { cwd?: string; passwords?: string[]; }, - successCb: () => void, - errorCb: () => void - ) { - const tryPassword = (index = -1) => { - const password = passwords[index] ?? ""; - logger.info(`Trying password ${password} on ${filePath}`); + onProgress?: (progress: ExtractionProgress) => void + ): Promise { + return new Promise((resolve, reject) => { + const tryPassword = (index = -1) => { + const password = passwords[index] ?? ""; + logger.info( + `Trying password "${password || "(empty)"}" on ${filePath}` + ); - const args = ["x", filePath, "-y", "-p" + password]; + const extractedFiles: string[] = []; + let fileCount = 0; - if (outputPath) { - args.push("-o" + outputPath); - } + const options: CommandLineSwitches = { + $bin: this.binaryPath, + $progress: true, + yes: true, + password: password || undefined, + }; - const child = cp.execFile(this.binaryPath, args, { - cwd, - }); - - child.once("exit", (code) => { - if (code === 0) { - successCb(); - return; + if (outputPath) { + options.outputDir = outputPath; } - if (index < passwords.length - 1) { + const stream = Seven.extractFull(filePath, outputPath || cwd || ".", { + ...options, + $spawnOptions: cwd ? { cwd } : undefined, + }); + + stream.on("progress", (progress) => { + if (onProgress) { + onProgress({ + percent: progress.percent, + fileCount: fileCount, + file: progress.fileCount?.toString() || "", + }); + } + }); + + stream.on("data", (data) => { + if (data.file) { + extractedFiles.push(data.file); + fileCount++; + } + }); + + stream.on("end", () => { logger.info( - `Failed to extract file: ${filePath} with password: ${password}. Trying next password...` + `Successfully extracted ${filePath} (${extractedFiles.length} files)` ); + resolve({ + success: true, + extractedFiles, + }); + }); - tryPassword(index + 1); - } else { - logger.info(`Failed to extract file: ${filePath}`); + stream.on("error", (err) => { + logger.error(`Extraction error for ${filePath}:`, err); - errorCb(); + if (index < passwords.length - 1) { + logger.info( + `Failed to extract file: ${filePath} with password: "${password}". Trying next password...` + ); + tryPassword(index + 1); + } else { + logger.error( + `Failed to extract file: ${filePath} after trying all passwords` + ); + reject(new Error(`Failed to extract file: ${filePath}`)); + } + }); + }; + + tryPassword(); + }); + } + + public static listFiles( + filePath: string, + password?: string + ): Promise { + return new Promise((resolve, reject) => { + const files: string[] = []; + + const options: CommandLineSwitches = { + $bin: this.binaryPath, + password: password || undefined, + }; + + const stream = Seven.list(filePath, options); + + stream.on("data", (data) => { + if (data.file) { + files.push(data.file); } }); - }; - tryPassword(); + stream.on("end", () => { + resolve(files); + }); + + stream.on("error", (err) => { + reject(err); + }); + }); } } diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts index f6835558..f3f49018 100644 --- a/src/main/services/aria2.ts +++ b/src/main/services/aria2.ts @@ -7,9 +7,12 @@ export class Aria2 { private static process: cp.ChildProcess | null = null; public static spawn() { - const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "aria2c") - : path.join(__dirname, "..", "..", "binaries", "aria2c"); + const binaryPath = + process.platform === "darwin" + ? "aria2c" + : app.isPackaged + ? path.join(process.resourcesPath, "aria2c") + : path.join(__dirname, "..", "..", "binaries", "aria2c"); this.process = cp.spawn( binaryPath, diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts index 4dc1fdad..cb8999c3 100644 --- a/src/main/services/decky-plugin.ts +++ b/src/main/services/decky-plugin.ts @@ -74,21 +74,16 @@ export class DeckyPlugin { await fs.promises.mkdir(extractPath, { recursive: true }); - return new Promise((resolve, reject) => { - SevenZip.extractFile( - { - filePath: zipPath, - outputPath: extractPath, - }, - () => { - logger.log(`Plugin extracted to: ${extractPath}`); - resolve(extractPath); - }, - () => { - reject(new Error("Failed to extract plugin")); - } - ); - }); + try { + await SevenZip.extractFile({ + filePath: zipPath, + outputPath: extractPath, + }); + logger.log(`Plugin extracted to: ${extractPath}`); + return extractPath; + } catch { + throw new Error("Failed to extract plugin"); + } } private static needsSudo(): boolean { 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 4dcebbb0..c36bf8ce 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -24,10 +24,80 @@ import { sortBy } from "lodash-es"; import { TorBoxClient } from "./torbox"; import { GameFilesManager } from "../game-files-manager"; import { HydraDebridClient } from "./hydra-debrid"; +import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; export class DownloadManager { private static downloadingGameId: string | null = null; + private static extractFilename( + url: string, + originalUrl?: string + ): string | undefined { + if (originalUrl?.includes("#")) { + const hashPart = originalUrl.split("#")[1]; + if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) { + return hashPart; + } + } + + if (url.includes("#")) { + const hashPart = url.split("#")[1]; + if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) { + return hashPart; + } + } + + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const pathParts = pathname.split("/"); + const filename = pathParts[pathParts.length - 1]; + + if (filename?.includes(".") && filename.length > 0) { + return decodeURIComponent(filename); + } + } catch { + // Invalid URL + } + + return undefined; + } + + private static sanitizeFilename(filename: string): string { + return filename.replaceAll(/[<>:"/\\|?*]/g, "_"); + } + + private static createDownloadPayload( + directUrl: string, + originalUrl: string, + downloadId: string, + savePath: string + ) { + const filename = + this.extractFilename(originalUrl, directUrl) || + this.extractFilename(directUrl); + const sanitizedFilename = filename + ? this.sanitizeFilename(filename) + : undefined; + + if (sanitizedFilename) { + logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`); + } else { + logger.log( + `[DownloadManager] No filename extracted, aria2 will use default` + ); + } + + return { + action: "start" as const, + game_id: downloadId, + url: directUrl, + save_path: savePath, + out: sanitizedFilename, + allow_multiple_connections: true, + }; + } + public static async startRPC( download?: Download, downloadsToSeed?: Download[] @@ -121,21 +191,14 @@ 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, - }) - ) + JSON.parse(JSON.stringify({ ...status, game })) ); } @@ -179,27 +242,25 @@ export class DownloadManager { ) ) { gameFilesManager.extractDownloadedFile(); - } else { + } else if (download.folderName) { gameFilesManager .extractFilesInDirectory( - path.join(download.downloadPath, download.folderName!) + path.join(download.downloadPath, download.folderName) ) - .then(() => { - gameFilesManager.setExtractionComplete(); - }); + .then(() => gameFilesManager.setExtractionComplete()); } } const downloads = await downloadsSublevel .values() .all() - .then((games) => { - return sortBy( + .then((games) => + sortBy( games.filter((game) => game.status === "paused" && game.queued), "timestamp", "DESC" - ); - }); + ) + ); const [nextItemOnQueue] = downloads; @@ -267,13 +328,8 @@ export class DownloadManager { static async cancelDownload(downloadKey = this.downloadingGameId) { await PythonRPC.rpc - .post("/action", { - action: "cancel", - game_id: downloadKey, - }) - .catch((err) => { - logger.error("Failed to cancel game download", err); - }); + .post("/action", { action: "cancel", game_id: downloadKey }) + .catch((err) => logger.error("Failed to cancel game download", err)); if (downloadKey === this.downloadingGameId) { WindowManager.mainWindow?.setProgressBar(-1); @@ -306,7 +362,6 @@ export class DownloadManager { const id = download.uri.split("/").pop(); const token = await GofileApi.authorize(); const downloadLink = await GofileApi.getDownloadLink(id!); - await GofileApi.checkDownloadUrl(downloadLink); return { @@ -348,9 +403,50 @@ export class DownloadManager { save_path: download.downloadPath, }; } + case Downloader.Buzzheavier: { + logger.log( + `[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}` + ); + try { + const directUrl = await BuzzheavierApi.getDirectLink(download.uri); + logger.log(`[DownloadManager] Buzzheavier direct URL obtained`); + return this.createDownloadPayload( + directUrl, + download.uri, + downloadId, + download.downloadPath + ); + } catch (error) { + logger.error( + `[DownloadManager] Error processing Buzzheavier download:`, + error + ); + throw error; + } + } + case Downloader.FuckingFast: { + logger.log( + `[DownloadManager] Processing FuckingFast download for URI: ${download.uri}` + ); + try { + const directUrl = await FuckingFastApi.getDirectLink(download.uri); + logger.log(`[DownloadManager] FuckingFast direct URL obtained`); + return this.createDownloadPayload( + directUrl, + download.uri, + downloadId, + download.downloadPath + ); + } catch (error) { + logger.error( + `[DownloadManager] Error processing FuckingFast download:`, + error + ); + throw error; + } + } case Downloader.Mediafire: { const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); - return { action: "start", game_id: downloadId, @@ -367,7 +463,6 @@ export class DownloadManager { }; case Downloader.RealDebrid: { const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); - if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); return { @@ -380,7 +475,6 @@ export class DownloadManager { } case Downloader.TorBox: { const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); - if (!url) return; return { action: "start", @@ -395,7 +489,6 @@ export class DownloadManager { const downloadUrl = await HydraDebridClient.getDownloadUrl( download.uri ); - if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); return { diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts index 120b3e8f..f3684a0a 100644 --- a/src/main/services/game-files-manager.ts +++ b/src/main/services/game-files-manager.ts @@ -3,24 +3,58 @@ import fs from "node:fs"; import type { GameShop } from "@types"; import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared"; -import { SevenZip } from "./7zip"; +import { SevenZip, ExtractionProgress } from "./7zip"; import { WindowManager } from "./window-manager"; import { publishExtractionCompleteNotification } from "./notifications"; import { logger } from "./logger"; +const PROGRESS_THROTTLE_MS = 1000; + export class GameFilesManager { + private lastProgressUpdate = 0; + constructor( private readonly shop: GameShop, private readonly objectId: string ) {} - private async clearExtractionState() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const download = await downloadsSublevel.get(gameKey); + private get gameKey() { + return levelKeys.game(this.shop, this.objectId); + } - await downloadsSublevel.put(gameKey, { - ...download!, + private async updateExtractionProgress(progress: number, force = false) { + const now = Date.now(); + + if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) { + return; + } + + this.lastProgressUpdate = now; + + const download = await downloadsSublevel.get(this.gameKey); + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, + extractionProgress: progress, + }); + + WindowManager.mainWindow?.webContents.send( + "on-extraction-progress", + this.shop, + this.objectId, + progress + ); + } + + private async clearExtractionState() { + const download = await downloadsSublevel.get(this.gameKey); + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -30,6 +64,10 @@ export class GameFilesManager { ); } + private readonly handleProgress = (progress: ExtractionProgress) => { + this.updateExtractionProgress(progress.percent / 100); + }; + async extractFilesInDirectory(directoryPath: string) { if (!fs.existsSync(directoryPath)) return; const files = await fs.promises.readdir(directoryPath); @@ -42,53 +80,66 @@ export class GameFilesManager { (file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file) ); - await Promise.all( - filesToExtract.map((file) => { - return new Promise((resolve, reject) => { - SevenZip.extractFile( - { - filePath: path.join(directoryPath, file), - cwd: directoryPath, - passwords: ["online-fix.me", "steamrip.com"], - }, - () => { - resolve(true); - }, - () => { - reject(new Error(`Failed to extract file: ${file}`)); - this.clearExtractionState(); - } - ); - }); - }) - ); + if (filesToExtract.length === 0) return; - compressedFiles.forEach((file) => { - const extractionPath = path.join(directoryPath, file); + await this.updateExtractionProgress(0, true); - if (fs.existsSync(extractionPath)) { - fs.unlink(extractionPath, (err) => { - if (err) { - logger.error(`Failed to delete file: ${file}`, err); + const totalFiles = filesToExtract.length; + let completedFiles = 0; - this.clearExtractionState(); + for (const file of filesToExtract) { + try { + const result = await SevenZip.extractFile( + { + filePath: path.join(directoryPath, file), + cwd: directoryPath, + passwords: ["online-fix.me", "steamrip.com"], + }, + (progress) => { + const overallProgress = + (completedFiles + progress.percent / 100) / totalFiles; + this.updateExtractionProgress(overallProgress); } - }); + ); + + if (result.success) { + completedFiles++; + await this.updateExtractionProgress( + completedFiles / totalFiles, + true + ); + } + } catch (err) { + logger.error(`Failed to extract file: ${file}`, err); + await this.clearExtractionState(); + return; } - }); + } + + const archivePaths = compressedFiles + .map((file) => path.join(directoryPath, file)) + .filter((archivePath) => fs.existsSync(archivePath)); + + if (archivePaths.length > 0) { + WindowManager.mainWindow?.webContents.send( + "on-archive-deletion-prompt", + archivePaths + ); + } } async setExtractionComplete(publishNotification = true) { - const gameKey = levelKeys.game(this.shop, this.objectId); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); - await downloadsSublevel.put(gameKey, { - ...download!, + if (!download) return; + + await downloadsSublevel.put(this.gameKey, { + ...download, extracting: false, + extractionProgress: 0, }); WindowManager.mainWindow?.webContents.send( @@ -97,17 +148,15 @@ export class GameFilesManager { this.objectId ); - if (publishNotification) { - publishExtractionCompleteNotification(game!); + if (publishNotification && game) { + publishExtractionCompleteNotification(game); } } async extractDownloadedFile() { - const gameKey = levelKeys.game(this.shop, this.objectId); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameKey), - gamesSublevel.get(gameKey), + downloadsSublevel.get(this.gameKey), + gamesSublevel.get(this.gameKey), ]); if (!download || !game) return false; @@ -119,39 +168,39 @@ export class GameFilesManager { path.parse(download.folderName!).name ); - SevenZip.extractFile( - { - filePath, - outputPath: extractionPath, - passwords: ["online-fix.me", "steamrip.com"], - }, - async () => { + await this.updateExtractionProgress(0, true); + + try { + const result = await SevenZip.extractFile( + { + filePath, + outputPath: extractionPath, + passwords: ["online-fix.me", "steamrip.com"], + }, + this.handleProgress + ); + + if (result.success) { await this.extractFilesInDirectory(extractionPath); if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) { - fs.unlink(filePath, (err) => { - if (err) { - logger.error( - `Failed to delete file: ${download.folderName}`, - err - ); - - this.clearExtractionState(); - } - }); + WindowManager.mainWindow?.webContents.send( + "on-archive-deletion-prompt", + [filePath] + ); } - await downloadsSublevel.put(gameKey, { - ...download!, + await downloadsSublevel.put(this.gameKey, { + ...download, folderName: path.parse(download.folderName!).name, }); - this.setExtractionComplete(); - }, - () => { - this.clearExtractionState(); + await this.setExtractionComplete(); } - ); + } catch (err) { + logger.error(`Failed to extract downloaded file: ${filePath}`, err); + await this.clearExtractionState(); + } return true; } diff --git a/src/main/services/hosters/buzzheavier.ts b/src/main/services/hosters/buzzheavier.ts new file mode 100644 index 00000000..9ef2d830 --- /dev/null +++ b/src/main/services/hosters/buzzheavier.ts @@ -0,0 +1,86 @@ +import axios from "axios"; +import { + HOSTER_USER_AGENT, + extractHosterFilename, + handleHosterError, +} from "./fuckingfast"; +import { logger } from "@main/services"; + +export class BuzzheavierApi { + private static readonly BUZZHEAVIER_DOMAINS = [ + "buzzheavier.com", + "bzzhr.co", + "fuckingfast.net", + ]; + + private static isSupportedDomain(url: string): boolean { + const lowerUrl = url.toLowerCase(); + return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain)); + } + + private static async getBuzzheavierDirectLink(url: string): Promise { + try { + const baseUrl = url.split("#")[0]; + logger.log( + `[Buzzheavier] Starting download link extraction for: ${baseUrl}` + ); + + await axios.get(baseUrl, { + headers: { "User-Agent": HOSTER_USER_AGENT }, + timeout: 30000, + }); + + const downloadUrl = `${baseUrl}/download`; + logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`); + const headResponse = await axios.head(downloadUrl, { + headers: { + "hx-current-url": baseUrl, + "hx-request": "true", + referer: baseUrl, + "User-Agent": HOSTER_USER_AGENT, + }, + maxRedirects: 0, + validateStatus: (status) => + status === 200 || status === 204 || status === 301 || status === 302, + timeout: 30000, + }); + + const hxRedirect = headResponse.headers["hx-redirect"]; + logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`); + if (!hxRedirect) { + logger.error( + `[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}` + ); + throw new Error( + "Could not extract download link. File may be deleted or is a directory." + ); + } + + const domain = new URL(baseUrl).hostname; + const directLink = hxRedirect.startsWith("/dl/") + ? `https://${domain}${hxRedirect}` + : hxRedirect; + logger.log(`[Buzzheavier] Extracted direct link`); + return directLink; + } catch (error) { + logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error); + handleHosterError(error); + } + } + + public static async getDirectLink(url: string): Promise { + 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/gofile.ts b/src/main/services/hosters/gofile.ts index 5560ad31..fb9b97e3 100644 --- a/src/main/services/hosters/gofile.ts +++ b/src/main/services/hosters/gofile.ts @@ -36,16 +36,13 @@ export class GofileApi { } public static async getDownloadLink(id: string) { - const searchParams = new URLSearchParams({ - wt: WT, - }); - const response = await axios.get<{ status: string; data: GofileContentsResponse; - }>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, { + }>(`https://api.gofile.io/contents/${id}`, { headers: { Authorization: `Bearer ${this.token}`, + "X-Website-Token": WT, }, }); diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts index 3f3b9ac9..5f918811 100644 --- a/src/main/services/hosters/index.ts +++ b/src/main/services/hosters/index.ts @@ -3,3 +3,5 @@ export * from "./qiwi"; export * from "./datanodes"; export * from "./mediafire"; export * from "./pixeldrain"; +export * from "./buzzheavier"; +export * from "./fuckingfast"; diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index a5a78e4a..596b0635 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -58,7 +58,13 @@ export class HydraApi { const decodedBase64 = atob(payload as string); const jsonData = JSON.parse(decodedBase64); - const { accessToken, expiresIn, refreshToken } = jsonData; + const { + accessToken, + expiresIn, + refreshToken, + featurebaseJwt, + workwondersJwt, + } = jsonData; const now = new Date(); @@ -85,6 +91,8 @@ export class HydraApi { accessToken, refreshToken, tokenExpirationTimestamp, + featurebaseJwt, + workwondersJwt, }, { valueEncoding: "json" } ); diff --git a/src/main/services/index.ts b/src/main/services/index.ts index f6dbbb22..6eef244e 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -21,3 +21,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/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 92cd66d8..645b0d1c 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -9,6 +9,8 @@ type ProfileGame = { hasManuallyUpdatedPlaytime: boolean; isFavorite?: boolean; isPinned?: boolean; + achievementCount: number; + unlockedAchievementCount: number; } & ShopAssets; export const mergeWithRemoteGames = async () => { @@ -39,6 +41,8 @@ export const mergeWithRemoteGames = async () => { playTimeInMilliseconds: updatedPlayTime, favorite: game.isFavorite ?? localGame.favorite, isPinned: game.isPinned ?? localGame.isPinned, + achievementCount: game.achievementCount, + unlockedAchievementCount: game.unlockedAchievementCount, }); } else { await gamesSublevel.put(gameKey, { @@ -55,6 +59,8 @@ export const mergeWithRemoteGames = async () => { isDeleted: false, favorite: game.isFavorite ?? false, isPinned: game.isPinned ?? false, + achievementCount: game.achievementCount, + unlockedAchievementCount: game.unlockedAchievementCount, }); } diff --git a/src/main/services/node-7z.d.ts b/src/main/services/node-7z.d.ts new file mode 100644 index 00000000..3877346a --- /dev/null +++ b/src/main/services/node-7z.d.ts @@ -0,0 +1,87 @@ +declare module "node-7z" { + import { ChildProcess } from "node:child_process"; + import { EventEmitter } from "node:events"; + + export interface CommandLineSwitches { + $bin?: string; + $progress?: boolean; + $spawnOptions?: { + cwd?: string; + }; + outputDir?: string; + yes?: boolean; + password?: string; + [key: string]: unknown; + } + + export interface ProgressInfo { + percent: number; + fileCount?: number; + } + + export interface FileInfo { + file?: string; + [key: string]: unknown; + } + + export interface ZipStream extends EventEmitter { + on(event: "progress", listener: (progress: ProgressInfo) => void): this; + on(event: "data", listener: (data: FileInfo) => void): this; + on(event: "end", listener: () => void): this; + on(event: "error", listener: (err: Error) => void): this; + info: Map; + _childProcess?: ChildProcess; + } + + export function extractFull( + archive: string, + output: string, + options?: CommandLineSwitches + ): ZipStream; + + export function extract( + archive: string, + output: string, + options?: CommandLineSwitches + ): ZipStream; + + export function list( + archive: string, + options?: CommandLineSwitches + ): ZipStream; + + export function add( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function update( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function deleteFiles( + archive: string, + files: string | string[], + options?: CommandLineSwitches + ): ZipStream; + + export function test( + archive: string, + options?: CommandLineSwitches + ): ZipStream; + + const Seven: { + extractFull: typeof extractFull; + extract: typeof extract; + list: typeof list; + add: typeof add; + update: typeof update; + delete: typeof deleteFiles; + test: typeof test; + }; + + export default Seven; +} diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index b8ff480c..0fa07c8c 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -15,6 +15,14 @@ import { db, levelKeys, themesSublevel } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; import { getThemeSoundPath } from "@main/helpers"; +import { processProfileImage } from "@main/events/profile/process-profile-image"; +import { LocalNotificationManager } from "./local-notifications"; + +const getStaticImage = async (path: string) => { + return processProfileImage(path, "jpg") + .then((response) => response.imagePath) + .catch(() => path); +}; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -31,8 +39,9 @@ async function downloadImage(url: string | null) { response.data.pipe(writer); return new Promise((resolve) => { - writer.on("finish", () => { - resolve(outputPath); + writer.on("finish", async () => { + const staticImagePath = await getStaticImage(outputPath); + resolve(staticImagePath); }); writer.on("error", () => { logger.error("Failed to download image", { url }); @@ -70,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 ( @@ -173,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/system-path.ts b/src/main/services/system-path.ts index 32b34e11..0b42b0aa 100644 --- a/src/main/services/system-path.ts +++ b/src/main/services/system-path.ts @@ -13,9 +13,9 @@ export class SystemPath { }; static checkIfPathsAreAvailable() { - const paths = Object.keys(SystemPath.paths) as Array< - keyof typeof SystemPath.paths - >; + const paths = Object.keys( + SystemPath.paths + ) as (keyof typeof SystemPath.paths)[]; paths.forEach((pathName) => { try { diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index b11b4a9b..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( @@ -138,7 +138,8 @@ export class WindowManager { (details, callback) => { if ( details.webContentsId !== this.mainWindow?.webContents.id || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } @@ -159,7 +160,8 @@ export class WindowManager { if ( details.webContentsId !== this.mainWindow?.webContents.id || details.url.includes("featurebase") || - details.url.includes("chatwoot") + details.url.includes("chatwoot") || + details.url.includes("workwonders") ) { return callback(details); } @@ -222,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/friend-request.ts b/src/main/services/ws/events/friend-request.ts index 8faa38a5..efee370d 100644 --- a/src/main/services/ws/events/friend-request.ts +++ b/src/main/services/ws/events/friend-request.ts @@ -8,9 +8,11 @@ export const friendRequestEvent = async (payload: FriendRequest) => { friendRequestCount: payload.friendRequestCount, }); - const user = await HydraApi.get(`/users/${payload.senderId}`); + if (payload.senderId) { + const user = await HydraApi.get(`/users/${payload.senderId}`); - if (user) { - publishNewFriendRequestNotification(user); + if (user) { + publishNewFriendRequestNotification(user); + } } }; 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/preload/index.ts b/src/preload/index.ts index e54dcf41..72210420 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,7 @@ import type { GameAchievement, Theme, FriendRequestSync, + NotificationSync, ShortcutLocation, AchievementCustomNotificationPosition, AchievementNotificationInfo, @@ -267,6 +268,29 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-extraction-complete", listener); return () => ipcRenderer.removeListener("on-extraction-complete", listener); }, + onExtractionProgress: ( + cb: (shop: GameShop, objectId: string, progress: number) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + shop: GameShop, + objectId: string, + progress: number + ) => cb(shop, objectId, progress); + ipcRenderer.on("on-extraction-progress", listener); + return () => ipcRenderer.removeListener("on-extraction-progress", listener); + }, + onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + archivePaths: string[] + ) => cb(archivePaths); + ipcRenderer.on("on-archive-deletion-prompt", listener); + return () => + ipcRenderer.removeListener("on-archive-deletion-prompt", listener); + }, + deleteArchive: (filePath: string) => + ipcRenderer.invoke("deleteArchive", filePath), /* Hardware */ getDiskFreeSpace: (path: string) => @@ -476,7 +500,6 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("updateProfile", updateProfile), processProfileImage: (imagePath: string) => ipcRenderer.invoke("processProfileImage", imagePath), - syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"), onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -486,6 +509,15 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-sync-friend-requests", listener); }, + onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + notification: NotificationSync + ) => cb(notification); + ipcRenderer.on("on-sync-notification-count", listener); + return () => + ipcRenderer.removeListener("on-sync-notification-count", listener); + }, updateFriendRequest: (userId: string, action: FriendRequestAction) => ipcRenderer.invoke("updateFriendRequest", userId, action), @@ -529,6 +561,26 @@ contextBridge.exposeInMainWorld("electron", { /* Notifications */ publishNewRepacksNotification: (newRepacksCount: number) => ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), + getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"), + getLocalNotificationsCount: () => + ipcRenderer.invoke("getLocalNotificationsCount"), + markLocalNotificationRead: (id: string) => + ipcRenderer.invoke("markLocalNotificationRead", id), + markAllLocalNotificationsRead: () => + ipcRenderer.invoke("markAllLocalNotificationsRead"), + deleteLocalNotification: (id: string) => + ipcRenderer.invoke("deleteLocalNotification", id), + clearAllLocalNotifications: () => + ipcRenderer.invoke("clearAllLocalNotifications"), + onLocalNotificationCreated: (cb: (notification: unknown) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + notification: unknown + ) => cb(notification); + ipcRenderer.on("on-local-notification-created", listener); + return () => + ipcRenderer.removeListener("on-local-notification-created", listener); + }, onAchievementUnlocked: ( cb: ( position?: AchievementCustomNotificationPosition, @@ -621,4 +673,28 @@ contextBridge.exposeInMainWorld("electron", { }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), + + /* LevelDB Generic CRUD */ + leveldb: { + get: ( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding), + put: ( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => + ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding), + del: (key: string, sublevelName?: string | null) => + ipcRenderer.invoke("leveldbDel", key, sublevelName), + clear: (sublevelName: string) => + ipcRenderer.invoke("leveldbClear", sublevelName), + values: (sublevelName: string) => + ipcRenderer.invoke("leveldbValues", sublevelName), + iterator: (sublevelName: string) => + ipcRenderer.invoke("leveldbIterator", sublevelName), + }, }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 42166e56..6284effc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra Launcher diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 391e9c03..9334b5b9 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -19,11 +19,13 @@ import { setUserDetails, setProfileBackground, setGameRunning, + setExtractionProgress, + 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"; import { injectCustomCss, @@ -31,6 +33,8 @@ import { getAchievementSoundUrl, getAchievementSoundVolume, } from "./helpers"; +import { levelDBService } from "./services/leveldb.service"; +import type { UserPreferences } from "@types"; import "./app.scss"; export interface AppProps { @@ -49,12 +53,7 @@ export function App() { const { clearDownload, setLastPacket } = useDownload(); const { - userDetails, hasActiveSubscription, - isFriendsModalVisible, - friendRequetsModalTab, - friendModalUserId, - hideFriendsModal, fetchUserDetails, updateUserDetails, clearUserDetails, @@ -76,12 +75,17 @@ export function App() { const { showSuccessToast } = useToast(); + const [showArchiveDeletionModal, setShowArchiveDeletionModal] = + useState(false); + const [archivePaths, setArchivePaths] = useState([]); + useEffect(() => { - Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then( - ([preferences]) => { - dispatch(setUserPreferences(preferences)); - } - ); + Promise.all([ + levelDBService.get("userPreferences", null, "json"), + updateLibrary(), + ]).then(([preferences]) => { + dispatch(setUserPreferences(preferences as UserPreferences | null)); + }); }, [navigate, location.pathname, dispatch, updateLibrary]); useEffect(() => { @@ -125,7 +129,6 @@ export function App() { .then((response) => { if (response) { updateUserDetails(response); - window.electron.syncFriendRequests(); } }) .finally(() => { @@ -142,7 +145,6 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - window.electron.syncFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); @@ -181,12 +183,23 @@ export function App() { updateLibrary(); }), window.electron.onSignOut(() => clearUserDetails()), + window.electron.onExtractionProgress((shop, objectId, progress) => { + dispatch(setExtractionProgress({ shop, objectId, progress })); + }), + window.electron.onExtractionComplete(() => { + dispatch(clearExtraction()); + updateLibrary(); + }), + window.electron.onArchiveDeletionPrompt((paths) => { + setArchivePaths(paths); + setShowArchiveDeletionModal(true); + }), ]; return () => { listeners.forEach((unsubscribe) => unsubscribe()); }; - }, [onSignIn, updateLibrary, clearUserDetails]); + }, [onSignIn, updateLibrary, clearUserDetails, dispatch]); useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; @@ -204,7 +217,11 @@ export function App() { }, [dispatch, draggingDisabled]); const loadAndApplyTheme = useCallback(async () => { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + code?: string; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.code) { injectCustomCss(activeTheme.code); } else { @@ -274,14 +291,11 @@ export function App() { feature={hydraCloudFeature} /> - {userDetails && ( - - )} + setShowArchiveDeletionModal(false)} + />
diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 2c32c5da..ed7d31f3 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { + useAppSelector, useDownload, useLibrary, useToast, @@ -26,6 +27,8 @@ export function BottomPanel() { const { lastPacket, progress, downloadSpeed, eta } = useDownload(); + const extraction = useAppSelector((state) => state.download.extraction); + const [version, setVersion] = useState(""); const [sessionHash, setSessionHash] = useState(""); const [commonRedistStatus, setCommonRedistStatus] = useState( @@ -68,6 +71,20 @@ export function BottomPanel() { return t("installing_common_redist", { log: commonRedistStatus }); } + if (extraction) { + const extractingGame = library.find( + (game) => game.id === extraction.visibleId + ); + + if (extractingGame) { + const extractionPercentage = Math.round(extraction.progress * 100); + return t("extracting", { + title: extractingGame.title, + percentage: `${extractionPercentage}%`, + }); + } + } + const game = lastPacket ? library.find((game) => game.id === lastPacket?.gameId) : undefined; @@ -109,6 +126,7 @@ export function BottomPanel() { eta, downloadSpeed, commonRedistStatus, + extraction, ]); return ( @@ -122,10 +140,10 @@ export function BottomPanel() { + +
+ {alt} +
+ +
, + document.body + ); +} diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index edea8d50..51b55e44 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -47,7 +47,7 @@ export function GameCard({ game, ...props }: GameCardProps) { >
{game.title} = { "/": "home", @@ -20,6 +28,7 @@ const pathTitle: Record = { export function Header() { const inputRef = useRef(null); + const searchContainerRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); @@ -37,6 +46,7 @@ export function Header() { ); const isOnLibraryPage = location.pathname.startsWith("/library"); + const isOnCataloguePage = location.pathname.startsWith("/catalogue"); const searchValue = isOnLibraryPage ? librarySearchValue @@ -45,13 +55,34 @@ export function Header() { const dispatch = useAppDispatch(); const [isFocused, setIsFocused] = useState(false); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [dropdownPosition, setDropdownPosition] = useState({ + x: 0, + y: 0, + }); const { t } = useTranslation("header"); + const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = + useSearchHistory(); + + const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( + searchValue, + isOnLibraryPage, + isDropdownVisible && isFocused && !isOnCataloguePage + ); + + const historyItems = getRecentHistory( + isOnLibraryPage ? "library" : "catalogue", + 3 + ); + const title = useMemo(() => { 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"); @@ -59,13 +90,43 @@ export function Header() { return t(pathTitle[location.pathname]); }, [location.pathname, headerTitle, t]); + const totalItems = historyItems.length + suggestions.length; + + const updateDropdownPosition = () => { + if (searchContainerRef.current) { + const rect = searchContainerRef.current.getBoundingClientRect(); + setDropdownPosition({ + x: rect.left, + y: rect.bottom, + }); + } + }; + const focusInput = () => { setIsFocused(true); inputRef.current?.focus(); }; + const handleFocus = () => { + if (isFocused && isDropdownVisible) { + updateDropdownPosition(); + return; + } + + setIsFocused(true); + setActiveIndex(-1); + setTimeout(() => { + updateDropdownPosition(); + setIsDropdownVisible(true); + }, 220); + }; + const handleBlur = () => { - setIsFocused(false); + setTimeout(() => { + setIsFocused(false); + setIsDropdownVisible(false); + setActiveIndex(-1); + }, 200); }; const handleBackButtonClick = () => { @@ -77,10 +138,37 @@ export function Header() { dispatch(setLibrarySearchQuery(value.slice(0, 255))); } else { dispatch(setFilters({ title: value.slice(0, 255) })); - if (!location.pathname.startsWith("/catalogue")) { - navigate("/catalogue"); - } } + setActiveIndex(-1); + }; + + const executeSearch = (query: string) => { + const context = isOnLibraryPage ? "library" : "catalogue"; + if (query.trim()) { + addToHistory(query, context); + } + handleSearch(query); + + if (!isOnLibraryPage && !location.pathname.startsWith("/catalogue")) { + navigate("/catalogue"); + } + + setIsDropdownVisible(false); + inputRef.current?.blur(); + }; + + const handleSelectHistory = (query: string) => { + executeSearch(query); + }; + + const handleSelectSuggestion = (suggestion: { + title: string; + objectId: string; + shop: GameShop; + }) => { + setIsDropdownVisible(false); + inputRef.current?.blur(); + navigate(buildGameDetailsPath(suggestion)); }; const handleClearSearch = () => { @@ -89,14 +177,79 @@ export function Header() { } else { dispatch(setFilters({ title: "" })); } + setActiveIndex(-1); + }; + + const handleRemoveHistoryItem = (query: string) => { + removeFromHistory(query); + }; + + const handleClearHistory = () => { + clearHistory(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + if (activeIndex >= 0 && activeIndex < totalItems) { + if (activeIndex < historyItems.length) { + handleSelectHistory(historyItems[activeIndex].query); + } else { + const suggestionIndex = activeIndex - historyItems.length; + handleSelectSuggestion(suggestions[suggestionIndex]); + } + } else if (searchValue.trim()) { + executeSearch(searchValue); + } + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); + if (!isDropdownVisible) { + setIsDropdownVisible(true); + updateDropdownPosition(); + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((prev) => (prev > -1 ? prev - 1 : -1)); + } else if (event.key === "Escape") { + event.preventDefault(); + setIsDropdownVisible(false); + setActiveIndex(-1); + inputRef.current?.blur(); + } + }; + + const handleCloseDropdown = () => { + setIsDropdownVisible(false); + setActiveIndex(-1); }; useEffect(() => { - if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) { + const prevPath = sessionStorage.getItem("prevPath"); + const currentPath = location.pathname; + + if ( + prevPath?.startsWith("/catalogue") && + !currentPath.startsWith("/catalogue") && + catalogueSearchValue + ) { dispatch(setFilters({ title: "" })); } + + sessionStorage.setItem("prevPath", currentPath); }, [location.pathname, catalogueSearchValue, dispatch]); + useEffect(() => { + if (!isDropdownVisible) return; + + const handleResize = () => { + updateDropdownPosition(); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [isDropdownVisible]); + return ( <>
handleSearch(event.target.value)} - onFocus={() => setIsFocused(true)} + onFocus={handleFocus} onBlur={handleBlur} + onKeyDown={handleKeyDown} /> {searchValue && ( @@ -165,6 +320,28 @@ export function Header() {
+ + 0 || + historyItems.length > 0 || + suggestions.length > 0 || + isLoadingSuggestions) + } + position={dropdownPosition} + historyItems={historyItems} + suggestions={suggestions} + isLoadingSuggestions={isLoadingSuggestions} + onSelectHistory={handleSelectHistory} + onSelectSuggestion={handleSelectSuggestion} + onRemoveHistoryItem={handleRemoveHistoryItem} + onClearHistory={handleClearHistory} + onClose={handleCloseDropdown} + activeIndex={activeIndex} + currentQuery={searchValue} + searchContainerRef={searchContainerRef} + /> ); } diff --git a/src/renderer/src/components/hero/hero.tsx b/src/renderer/src/components/hero/hero.tsx index a1e36719..6fc3dccb 100644 --- a/src/renderer/src/components/hero/hero.tsx +++ b/src/renderer/src/components/hero/hero.tsx @@ -50,14 +50,14 @@ export function Hero() { >
{game.description
{game.description 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/highlight-text.tsx b/src/renderer/src/components/search-dropdown/highlight-text.tsx new file mode 100644 index 00000000..cbcd917d --- /dev/null +++ b/src/renderer/src/components/search-dropdown/highlight-text.tsx @@ -0,0 +1,107 @@ +import React from "react"; + +interface HighlightTextProps { + readonly text: string; + readonly query: string; +} + +export function HighlightText({ text, query }: Readonly) { + if (!query.trim()) { + return <>{text}; + } + + const queryWords = query + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (queryWords.length === 0) { + return <>{text}; + } + + const matches: { start: number; end: number }[] = []; + const textLower = text.toLowerCase(); + + queryWords.forEach((queryWord) => { + const escapedQuery = queryWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp( + `(?:^|[\\s])${escapedQuery}(?=[\\s]|$)|^${escapedQuery}$`, + "gi" + ); + + let match; + while ((match = regex.exec(textLower)) !== null) { + const matchedText = match[0]; + const leadingSpace = matchedText.startsWith(" ") ? 1 : 0; + const start = match.index + leadingSpace; + const end = start + queryWord.length; + + matches.push({ start, end }); + } + }); + + if (matches.length === 0) { + return <>{text}; + } + + matches.sort((a, b) => a.start - b.start); + + const mergedMatches: { start: number; end: number }[] = []; + let current = matches[0]; + + for (let i = 1; i < matches.length; i++) { + if (matches[i].start <= current.end) { + current = { + start: current.start, + end: Math.max(current.end, matches[i].end), + }; + } else { + mergedMatches.push(current); + current = matches[i]; + } + } + mergedMatches.push(current); + + const parts: { text: string; highlight: boolean; key: string }[] = []; + let lastIndex = 0; + + mergedMatches.forEach((match) => { + if (match.start > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.start), + highlight: false, + key: `${lastIndex}-${match.start}`, + }); + } + + parts.push({ + text: text.slice(match.start, match.end), + highlight: true, + key: `${match.start}-${match.end}`, + }); + + lastIndex = match.end; + }); + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + highlight: false, + key: `${lastIndex}-${text.length}`, + }); + } + + return ( + <> + {parts.map((part) => + part.highlight ? ( + + {part.text} + + ) : ( + {part.text} + ) + )} + + ); +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss new file mode 100644 index 00000000..6a2cbede --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -0,0 +1,153 @@ +@use "../../scss/globals.scss"; + +.search-dropdown { + position: fixed; + background-color: globals.$dark-background-color; + border: 1px solid globals.$border-color; + border-radius: 8px; + max-height: 350px; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + margin-top: 4px; + width: 250px; + + &__section { + padding: 4px 0; + + &:not(:last-child) { + border-bottom: 1px solid globals.$border-color; + } + } + + &__section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 8px; + margin-bottom: 4px; + } + + &__section-title { + color: globals.$muted-color; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__clear-text-button { + color: globals.$muted-color; + cursor: pointer; + padding: 0; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + transition: color ease 0.2s; + background: transparent; + border: none; + + &:hover { + color: #ffffff; + } + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + } + + &__item-container { + position: relative; + display: flex; + align-items: center; + + &:hover .search-dropdown__item-remove { + opacity: 1; + } + } + + &__item-remove { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + color: globals.$muted-color; + padding: 4px; + opacity: 0; + transition: opacity ease 0.15s; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + + &:hover { + color: #ff3333; + background-color: rgba(255, 85, 85, 0.2); + } + } + + &__item { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; + color: #dadbe1; + text-align: left; + border: none; + background: transparent; + + &:hover, + &--active { + background-color: globals.$background-color; + } + + &:focus { + outline: none; + } + } + + &__item-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: globals.$muted-color; + + &--image { + border-radius: 2px; + object-fit: cover; + } + } + + &__item-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + } + + &__loading, + &__empty { + padding: 16px 12px; + text-align: center; + color: globals.$muted-color; + font-size: 14px; + } + + &__empty { + font-style: italic; + } + + &__highlight { + background-color: rgba(255, 193, 7, 0.4); + color: #ffa000; + font-weight: 600; + padding: 0 2px; + border-radius: 2px; + } +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx new file mode 100644 index 00000000..cc7ce5b4 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,219 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react"; +import cn from "classnames"; +import { useTranslation } from "react-i18next"; +import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; +import type { SearchSuggestion } from "@renderer/hooks/use-search-suggestions"; +import { HighlightText } from "./highlight-text"; +import "./search-dropdown.scss"; + +export interface SearchDropdownProps { + visible: boolean; + position: { x: number; y: number }; + historyItems: SearchHistoryEntry[]; + suggestions: SearchSuggestion[]; + isLoadingSuggestions: boolean; + onSelectHistory: (query: string) => void; + onSelectSuggestion: (suggestion: SearchSuggestion) => void; + onRemoveHistoryItem: (query: string) => void; + onClearHistory: () => void; + onClose: () => void; + activeIndex: number; + currentQuery: string; + searchContainerRef?: React.RefObject; +} + +export function SearchDropdown({ + visible, + position, + historyItems, + suggestions, + isLoadingSuggestions, + onSelectHistory, + onSelectSuggestion, + onRemoveHistoryItem, + onClearHistory, + onClose, + activeIndex, + currentQuery, + searchContainerRef, +}: SearchDropdownProps) { + const dropdownRef = useRef(null); + const [adjustedPosition, setAdjustedPosition] = useState(position); + const { t } = useTranslation("header"); + + useEffect(() => { + if (!visible) { + setAdjustedPosition(position); + return; + } + + const checkPosition = () => { + if (!dropdownRef.current) return; + + const rect = dropdownRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let adjustedX = position.x; + let adjustedY = position.y; + + if (adjustedX + 250 > viewportWidth - 10) { + adjustedX = Math.max(10, viewportWidth - 250 - 10); + } + + if (adjustedY + rect.height > viewportHeight - 10) { + adjustedY = Math.max(10, viewportHeight - rect.height - 10); + } + + setAdjustedPosition({ x: adjustedX, y: adjustedY }); + }; + + requestAnimationFrame(checkPosition); + }, [visible, position]); + + useEffect(() => { + if (!visible) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + if ( + dropdownRef.current && + !dropdownRef.current.contains(target) && + !searchContainerRef?.current?.contains(target) + ) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [visible, onClose, searchContainerRef]); + + if (!visible) return null; + + const hasHistory = historyItems.length > 0; + const hasSuggestions = suggestions.length > 0; + + const getItemIndex = ( + section: "history" | "suggestion", + indexInSection: number + ) => { + if (section === "history") { + return indexInSection; + } + return historyItems.length + indexInSection; + }; + + const dropdownContent = ( +
+ {hasHistory && ( +
+
+ + {t("recent_searches")} + + +
+
    + {historyItems.map((item, index) => ( +
  • + + +
  • + ))} +
+
+ )} + + {hasSuggestions && ( +
+
+ + {t("suggestions")} + +
+
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {isLoadingSuggestions && !hasSuggestions && !hasHistory && ( +
{t("loading")}
+ )} +
+ ); + + return createPortal(dropdownContent, document.body); +} 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..89de5503 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -10,6 +10,8 @@ export const DOWNLOADER_NAME = { [Downloader.Qiwi]: "Qiwi", [Downloader.Datanodes]: "Datanodes", [Downloader.Mediafire]: "Mediafire", + [Downloader.Buzzheavier]: "Buzzheavier", + [Downloader.FuckingFast]: "FuckingFast", [Downloader.TorBox]: "TorBox", [Downloader.Hydra]: "Nimbus", }; diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index bc1a6351..29feabf5 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -1,6 +1,8 @@ import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { setHeaderTitle } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import { getSteamLanguage } from "@renderer/helpers"; import { useAppDispatch, @@ -10,6 +12,7 @@ import { } from "@renderer/hooks"; import type { + DownloadSource, GameRepack, GameShop, GameStats, @@ -297,7 +300,10 @@ export function GameDetailsContextProvider({ const fetchDownloadSources = async () => { try { - const sources = await window.electron.getDownloadSources(); + const sourcesRaw = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sources = orderBy(sourcesRaw, "createdAt", "desc"); const params = { take: 100, diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 1160ca3e..338c4e45 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useEffect, useState } from "react"; import { setUserPreferences } from "@renderer/features"; import { useAppDispatch } from "@renderer/hooks"; +import { levelDBService } from "@renderer/services/leveldb.service"; import type { UserBlocks, UserPreferences } from "@types"; import { useSearchParams } from "react-router-dom"; @@ -134,9 +135,11 @@ export function SettingsContextProvider({ const updateUserPreferences = async (values: Partial) => { await window.electron.updateUserPreferences(values); - window.electron.getUserPreferences().then((userPreferences) => { - dispatch(setUserPreferences(userPreferences)); - }); + levelDBService + .get("userPreferences", null, "json") + .then((userPreferences) => { + dispatch(setUserPreferences(userPreferences as UserPreferences | null)); + }); }; return ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index ad8d4642..d974438c 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"; @@ -208,6 +210,13 @@ declare global { onExtractionComplete: ( cb: (shop: GameShop, objectId: string) => void ) => () => Electron.IpcRenderer; + onExtractionProgress: ( + cb: (shop: GameShop, objectId: string, progress: number) => void + ) => () => Electron.IpcRenderer; + onArchiveDeletionPrompt: ( + cb: (archivePaths: string[]) => void + ) => () => Electron.IpcRenderer; + deleteArchive: (filePath: string) => Promise; getDefaultWinePrefixSelectionPath: () => Promise; createSteamShortcut: (shop: GameShop, objectId: string) => Promise; @@ -382,10 +391,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 @@ -393,6 +404,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, @@ -440,6 +460,25 @@ declare global { onNewDownloadOptions: ( cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void ) => () => Electron.IpcRenderer; + + /* LevelDB Generic CRUD */ + leveldb: { + get: ( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => Promise; + put: ( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => Promise; + del: (key: string, sublevelName?: string | null) => Promise; + clear: (sublevelName: string) => Promise; + values: (sublevelName: string) => Promise; + iterator: (sublevelName: string) => Promise<[string, unknown][]>; + }; } interface Window { diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index cb638cda..f70421c0 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -1,17 +1,28 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; -import type { DownloadProgress } from "@types"; +import type { DownloadProgress, GameShop } from "@types"; + +export interface ExtractionInfo { + visibleId: string; + progress: number; +} export interface DownloadState { lastPacket: DownloadProgress | null; gameId: string | null; gamesWithDeletionInProgress: string[]; + extraction: ExtractionInfo | null; + peakSpeeds: Record; + speedHistory: Record; } const initialState: DownloadState = { lastPacket: null, gameId: null, gamesWithDeletionInProgress: [], + extraction: null, + peakSpeeds: {}, + speedHistory: {}, }; export const downloadSlice = createSlice({ @@ -21,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; @@ -38,6 +70,37 @@ export const downloadSlice = createSlice({ const index = state.gamesWithDeletionInProgress.indexOf(action.payload); if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1); }, + setExtractionProgress: ( + state, + action: PayloadAction<{ + shop: GameShop; + objectId: string; + progress: number; + }> + ) => { + const { shop, objectId, progress } = action.payload; + state.extraction = { + visibleId: `${shop}:${objectId}`, + progress, + }; + }, + 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] = []; + }, }, }); @@ -46,4 +109,8 @@ export const { clearDownload, setGameDeleting, 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/helpers.ts b/src/renderer/src/helpers.ts index e16aa7a4..0b057754 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -3,6 +3,7 @@ import type { GameShop } from "@types"; import Color from "color"; import { v4 as uuidv4 } from "uuid"; import { THEME_WEB_STORE_URL } from "./constants"; +import { levelDBService } from "./services/leveldb.service"; export const formatDownloadProgress = ( progress?: number, @@ -127,7 +128,12 @@ export const getAchievementSoundUrl = async (): Promise => { .default; try { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + id: string; + isActive?: boolean; + hasCustomSound?: boolean; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.hasCustomSound) { const soundDataUrl = await window.electron.getThemeSoundDataUrl( @@ -146,10 +152,18 @@ export const getAchievementSoundUrl = async (): Promise => { export const getAchievementSoundVolume = async (): Promise => { try { - const prefs = await window.electron.getUserPreferences(); + const prefs = (await levelDBService.get( + "userPreferences", + null, + "json" + )) as { achievementSoundVolume?: number } | null; return prefs?.achievementSoundVolume ?? 0.15; } catch (error) { console.error("Failed to get sound volume", error); return 0.15; } }; + +export const getGameKey = (shop: GameShop, objectId: string): string => { + return `${shop}:${objectId}`; +}; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4d34f219..a1666ede 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -8,3 +8,6 @@ export * from "./use-format"; export * from "./use-feature"; export * from "./use-download-options-listener"; export * from "./use-game-card"; +export * from "./use-search-history"; +export * from "./use-search-suggestions"; +export * from "./use-hls-video"; diff --git a/src/renderer/src/hooks/use-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts index 675f5013..ca2aaa4a 100644 --- a/src/renderer/src/hooks/use-catalogue.ts +++ b/src/renderer/src/hooks/use-catalogue.ts @@ -1,8 +1,9 @@ import axios from "axios"; import { useCallback, useEffect, useState } from "react"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import type { DownloadSource } from "@types"; import { useAppDispatch } from "./redux"; import { setGenres, setTags } from "@renderer/features"; -import type { DownloadSource } from "@types"; export const externalResourcesInstance = axios.create({ baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL, @@ -40,8 +41,9 @@ export function useCatalogue() { }, []); const getDownloadSources = useCallback(() => { - window.electron.getDownloadSources().then((results) => { - setDownloadSources(results.filter((source) => !!source.fingerprint)); + levelDBService.values("downloadSources").then((results) => { + const sources = results as DownloadSource[]; + setDownloadSources(sources.filter((source) => !!source.fingerprint)); }); }, []); diff --git a/src/renderer/src/hooks/use-hls-video.ts b/src/renderer/src/hooks/use-hls-video.ts new file mode 100644 index 00000000..eea4065d --- /dev/null +++ b/src/renderer/src/hooks/use-hls-video.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef } from "react"; +import Hls from "hls.js"; +import { logger } from "@renderer/logger"; + +interface UseHlsVideoOptions { + videoSrc: string | undefined; + videoType: string | undefined; + autoplay?: boolean; + muted?: boolean; + loop?: boolean; +} + +export function useHlsVideo( + videoRef: React.RefObject, + { videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions +) { + const hlsRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video || !videoSrc) return; + + const isHls = videoType === "application/x-mpegURL"; + + if (!isHls) { + return undefined; + } + + if (Hls.isSupported()) { + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: false, + }); + + hlsRef.current = hls; + + hls.loadSource(videoSrc); + hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (autoplay) { + video.play().catch((err) => { + logger.warn("Failed to autoplay HLS video:", err); + }); + } + }); + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + logger.error("HLS network error, trying to recover"); + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + logger.error("HLS media error, trying to recover"); + hls.recoverMediaError(); + break; + default: + logger.error("HLS fatal error, destroying instance"); + hls.destroy(); + break; + } + } + }); + + return () => { + hls.destroy(); + hlsRef.current = null; + }; + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = videoSrc; + video.load(); + if (autoplay) { + video.play().catch((err) => { + logger.warn("Failed to autoplay HLS video:", err); + }); + } + + return () => { + video.src = ""; + }; + } else { + logger.warn("HLS playback is not supported in this browser"); + return undefined; + } + }, [videoRef, videoSrc, videoType, autoplay, muted, loop]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + if (muted !== undefined) { + video.muted = muted; + } + if (loop !== undefined) { + video.loop = loop; + } + }, [videoRef, muted, loop]); + + return hlsRef.current; +} diff --git a/src/renderer/src/hooks/use-search-history.ts b/src/renderer/src/hooks/use-search-history.ts new file mode 100644 index 00000000..e5ce3efa --- /dev/null +++ b/src/renderer/src/hooks/use-search-history.ts @@ -0,0 +1,89 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { levelDBService } from "@renderer/services/leveldb.service"; + +export interface SearchHistoryEntry { + query: string; + timestamp: number; + context: "library" | "catalogue"; +} + +const LEVELDB_KEY = "searchHistory"; +const MAX_HISTORY_ENTRIES = 15; + +export function useSearchHistory() { + const [history, setHistory] = useState([]); + const isInitialized = useRef(false); + + useEffect(() => { + const loadHistory = async () => { + if (isInitialized.current) return; + isInitialized.current = true; + + try { + const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as + | SearchHistoryEntry[] + | null; + + if (data) { + setHistory(data); + } + } catch { + setHistory([]); + } + }; + + loadHistory(); + }, []); + + const addToHistory = useCallback( + (query: string, context: "library" | "catalogue") => { + if (!query.trim()) return; + + const newEntry: SearchHistoryEntry = { + query: query.trim(), + timestamp: Date.now(), + context, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() + ); + const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); + levelDBService.put(LEVELDB_KEY, updated, null, "json"); + return updated; + }); + }, + [] + ); + + const removeFromHistory = useCallback((query: string) => { + setHistory((prev) => { + const updated = prev.filter((entry) => entry.query !== query); + levelDBService.put(LEVELDB_KEY, updated, null, "json"); + return updated; + }); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + levelDBService.del(LEVELDB_KEY, null); + }, []); + + const getRecentHistory = useCallback( + (context: "library" | "catalogue", limit: number = 3) => { + return history + .filter((entry) => entry.context === context) + .slice(0, limit); + }, + [history] + ); + + return { + history, + addToHistory, + removeFromHistory, + clearHistory, + getRecentHistory, + }; +} diff --git a/src/renderer/src/hooks/use-search-suggestions.ts b/src/renderer/src/hooks/use-search-suggestions.ts new file mode 100644 index 00000000..b8986775 --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -0,0 +1,163 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppSelector } from "./redux"; +import { debounce } from "lodash-es"; +import { logger } from "@renderer/logger"; +import type { GameShop } from "@types"; + +export interface SearchSuggestion { + title: string; + objectId: string; + shop: GameShop; + iconUrl: string | null; + source: "library" | "catalogue"; +} + +export function useSearchSuggestions( + query: string, + isOnLibraryPage: boolean, + enabled: boolean = true +) { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const library = useAppSelector((state) => state.library.value); + const abortControllerRef = useRef(null); + const cacheRef = useRef>(new Map()); + + const getLibrarySuggestions = useCallback( + (searchQuery: string, limit: number = 3): SearchSuggestion[] => { + if (!searchQuery.trim()) return []; + + const queryLower = searchQuery.toLowerCase(); + const matches: SearchSuggestion[] = []; + + for (const game of library) { + if (matches.length >= limit) break; + + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + if (queryIndex === queryLower.length) { + matches.push({ + title: game.title, + objectId: game.objectId, + shop: game.shop, + iconUrl: game.iconUrl, + source: "library", + }); + } + } + + return matches; + }, + [library] + ); + + const fetchCatalogueSuggestions = useCallback( + async (searchQuery: string, limit: number = 3) => { + if (!searchQuery.trim() || searchQuery.length < 2) { + setSuggestions([]); + setIsLoading(false); + return; + } + + const cacheKey = `${searchQuery.toLowerCase()}_${limit}`; + const cachedResults = cacheRef.current.get(cacheKey); + + if (cachedResults) { + setSuggestions(cachedResults); + setIsLoading(false); + return; + } + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + + try { + const response = await window.electron.hydraApi.get< + { + title: string; + objectId: string; + shop: GameShop; + iconUrl: string | null; + }[] + >("/catalogue/search/suggestions", { + params: { + query: searchQuery, + limit, + }, + needsAuth: false, + }); + + if (abortController.signal.aborted) return; + + const catalogueSuggestions: SearchSuggestion[] = response.map( + (item) => ({ + ...item, + source: "catalogue" as const, + }) + ); + + cacheRef.current.set(cacheKey, catalogueSuggestions); + setSuggestions(catalogueSuggestions); + } catch (error) { + if (!abortController.signal.aborted) { + setSuggestions([]); + logger.error("Failed to fetch catalogue suggestions", error); + } + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, + [] + ); + + const debouncedFetchCatalogue = useRef( + debounce(fetchCatalogueSuggestions, 300) + ).current; + + useEffect(() => { + if (!enabled || !query || query.length < 2) { + setSuggestions([]); + setIsLoading(false); + abortControllerRef.current?.abort(); + debouncedFetchCatalogue.cancel(); + return; + } + + if (isOnLibraryPage) { + const librarySuggestions = getLibrarySuggestions(query, 3); + setSuggestions(librarySuggestions); + setIsLoading(false); + } else { + debouncedFetchCatalogue(query, 3); + } + + return () => { + debouncedFetchCatalogue.cancel(); + abortControllerRef.current?.abort(); + }; + }, [ + query, + isOnLibraryPage, + enabled, + getLibrarySuggestions, + debouncedFetchCatalogue, + ]); + + return { suggestions, isLoading }; +} diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 6d89f9b4..d8b9bbd2 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)); @@ -85,24 +75,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 @@ -152,12 +129,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 84c7f815..a012cf39 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -21,6 +21,7 @@ import resources from "@locales"; import { logger } from "./logger"; import { addCookieInterceptor } from "./cookies"; +import { levelDBService } from "./services/leveldb.service"; import Catalogue from "./pages/catalogue/catalogue"; import Home from "./pages/home/home"; import Downloads from "./pages/downloads/downloads"; @@ -30,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; @@ -48,7 +50,11 @@ i18n }, }) .then(async () => { - const userPreferences = await window.electron.getUserPreferences(); + const userPreferences = (await levelDBService.get( + "userPreferences", + null, + "json" + )) as { language?: string } | null; if (userPreferences?.language) { i18n.changeLanguage(userPreferences.language); @@ -71,6 +77,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index 38b2443b..a362c545 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -11,6 +11,7 @@ import { getAchievementSoundVolume, } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; +import { levelDBService } from "@renderer/services/leveldb.service"; import app from "../../../app.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; import root from "react-shadow"; @@ -144,7 +145,11 @@ export function AchievementNotification() { const loadAndApplyTheme = useCallback(async () => { if (!shadowRootRef) return; - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + code?: string; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.code) { injectCustomCss(activeTheme.code, shadowRootRef); } else { diff --git a/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx new file mode 100644 index 00000000..ff931a61 --- /dev/null +++ b/src/renderer/src/pages/downloads/archive-deletion-error-modal.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; +import { ConfirmationModal } from "@renderer/components"; + +interface ArchiveDeletionModalProps { + visible: boolean; + archivePaths: string[]; + onClose: () => void; +} + +export function ArchiveDeletionModal({ + visible, + archivePaths, + onClose, +}: Readonly) { + const { t } = useTranslation("downloads"); + + const fullFileName = + archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : ""; + + const maxLength = 40; + const fileName = + fullFileName.length > maxLength + ? `${fullFileName.slice(0, maxLength)}…` + : fullFileName; + + const handleConfirm = async () => { + for (const archivePath of archivePaths) { + await window.electron.deleteArchive(archivePath); + } + onClose(); + }; + + return ( + + ); +} diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index 7602307b..bfd8fbda 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -4,158 +4,541 @@ display: flex; flex-direction: column; gap: calc(globals.$spacing-unit * 2); + margin-inline: calc(globals.$spacing-unit * 3); + padding-block: calc(globals.$spacing-unit * 3); - &__details-with-article { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit / 2); - align-self: flex-start; - cursor: pointer; + &--queued { + padding-bottom: 0; + } + + &--completed { + padding-top: calc(globals.$spacing-unit * 3); } &__header { display: flex; align-items: center; - justify-content: space-between; - gap: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit); - &-divider { + &-title-group { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); flex: 1; - background-color: globals.$border-color; - height: 1px; + + h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + } } &-count { - font-weight: 400; + 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; + flex-shrink: 0; } } - - &__title-wrapper { - display: flex; - align-items: center; - margin-bottom: globals.$spacing-unit; - gap: globals.$spacing-unit; - } - - &__title { - font-weight: bold; - cursor: pointer; - color: globals.$body-color; - text-align: left; - font-size: 16px; - display: block; - - &:hover { - text-decoration: underline; - } - } - - &__downloads { + &--hero { width: 100%; - gap: calc(globals.$spacing-unit * 2); - display: flex; - flex-direction: column; + position: relative; + overflow: hidden; margin: 0; padding: 0; - margin-top: globals.$spacing-unit; + padding-bottom: globals.$spacing-unit; } - &__item { + &__hero-background { + position: absolute; + top: 0; + left: 0; width: 100%; - background-color: globals.$background-color; - display: flex; - border-radius: 8px; - border: solid 1px globals.$border-color; - overflow: hidden; - box-shadow: 0px 0px 5px 0px #000000; - transition: all ease 0.2s; - height: 140px; - min-height: 140px; - max-height: 140px; - position: relative; + height: 120%; + z-index: 0; - &--hydra { - box-shadow: 0px 0px 16px 0px rgba(12, 241, 202, 0.15); + img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 20%; } } - &__cover { - width: 280px; - min-width: 280px; - height: auto; - border-right: solid 1px globals.$border-color; + // PLEASE FIX THE COLORS + &__hero-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.3) 0%, + rgb(5, 5, 5) 70%, + rgb(26, 26, 26) 100% + ); + } + + &__hero-content { position: relative; z-index: 1; - - &-content { - width: 100%; - height: 100%; - padding: globals.$spacing-unit; - display: flex; - align-items: flex-end; - justify-content: flex-end; - } - - &-backdrop { - width: 100%; - height: 100%; - background: linear-gradient( - 0deg, - rgba(0, 0, 0, 0.8) 5%, - transparent 100% - ); - display: flex; - overflow: hidden; - z-index: 1; - } - - &-image { - width: 100%; - height: 100%; - position: absolute; - z-index: -1; - } - } - - &__right-content { - display: flex; - padding: calc(globals.$spacing-unit * 2); - flex: 1; - gap: globals.$spacing-unit; - background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%); - } - - &__details { + padding: calc(globals.$spacing-unit * 4); + padding-bottom: 0; display: flex; flex-direction: column; - flex: 1; - justify-content: center; - gap: calc(globals.$spacing-unit / 2); - font-size: 14px; + gap: calc(globals.$spacing-unit * 2); } - &__actions { + &__hero-logo { + flex: 1; + min-width: 0; display: flex; align-items: center; - gap: globals.$spacing-unit; + + &-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + transition: scale 0.2s ease; + outline: none; + + &:hover { + scale: 1.05; + } + } + + img { + max-width: 180px; + max-height: 60px; + object-fit: contain; + cursor: pointer; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.8; + } + + &:focus { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 4px; + border-radius: 4px; + } + + @container #{globals.$app-container} (min-width: 700px) { + max-width: 220px; + max-height: 75px; + } + + @container #{globals.$app-container} (min-width: 900px) { + max-width: 280px; + max-height: 95px; + } + + @container #{globals.$app-container} (min-width: 1200px) { + max-width: 340px; + max-height: 115px; + } + + @container #{globals.$app-container} (min-width: 1500px) { + max-width: 400px; + max-height: 130px; + } + } + + h1 { + font-size: 20px; + font-weight: 700; + color: #ffffff; + text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9); + margin: 0; + cursor: pointer; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.8; + } + + &:focus { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 4px; + border-radius: 4px; + } + + @container #{globals.$app-container} (min-width: 700px) { + font-size: 26px; + } + + @container #{globals.$app-container} (min-width: 900px) { + font-size: 32px; + } + + @container #{globals.$app-container} (min-width: 1200px) { + font-size: 38px; + } + + @container #{globals.$app-container} (min-width: 1500px) { + font-size: 44px; + } + } } - &__menu-button { - position: absolute; - top: 12px; - right: 12px; - border-radius: 50%; + &__hero-action-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: calc(globals.$spacing-unit * 3); + margin-top: calc(globals.$spacing-unit * 4); + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__hero-buttons { + display: flex; + gap: calc(globals.$spacing-unit); + align-items: center; + flex-shrink: 0; + } + + &__glass-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -4px rgba(0, 0, 0, 0.1); + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } + } + + &__hero-progress { + display: flex; + flex-direction: column; + } + + &__progress-info-row { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + } + + &__progress-row { + display: flex; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 2); + + &--bar { + margin-top: calc(globals.$spacing-unit); + } + } + + &__progress-status { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__progress-percentage { + font-size: 14px; + font-weight: 700; + color: #ffffff; + align-self: flex-end; + display: inline-block; + overflow: hidden; + line-height: 1.2; + + span { + display: inline-block; + } + } + + &__progress-size { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + } + + &__progress-status { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + } + + &__progress-time { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + font-size: 13px; + color: globals.$muted-color; + } + + &__hero-stats { + display: flex; + gap: calc(globals.$spacing-unit * 4); + padding: calc(globals.$spacing-unit * 2); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(26, 26, 26, 0.1); + backdrop-filter: blur(8px); + margin-top: calc(globals.$spacing-unit * 2); + } + + &__stats-column { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + min-width: 200px; + padding-right: calc(globals.$spacing-unit * 2); + border-right: 1px solid rgba(255, 255, 255, 0.1); + align-self: flex-start; + } + + &__speed-chart { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + &__speed-chart-canvas { + width: 100%; + height: 80px; + image-rendering: crisp-edges; + } + + &__stat-item { + display: flex; + align-items: flex-end; + gap: calc(globals.$spacing-unit); + + svg { + opacity: 0.8; + flex-shrink: 0; + } + } + + &__stat-content { + display: flex; + justify-content: space-between; + gap: calc(globals.$spacing-unit / 2); + width: 100%; + } + + &__stat-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 10px; + color: rgba(255, 255, 255, 0.6); + } + + &__stat-value { + color: #ffffff; + font-weight: 700; + font-size: 11px; + line-height: 1.2; + } + + &__simple-list { + width: 100%; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + margin: 0; + padding: 0; + list-style: none; + } + + &__simple-card { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + border-radius: 8px; + } + + &__simple-thumbnail { + width: 200px; + height: 100px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; + background-color: rgba(0, 0, 0, 0.3); + border: 1px solid globals.$border-color; + padding: 0; + cursor: pointer; + transition: + opacity 0.2s ease, + transform 0.2s ease; + + &:hover { + opacity: 0.9; + } + + &:focus, + &:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 2px; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__simple-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 1); + } + + &__simple-title-button { + background: none; border: none; - padding: 8px; + padding: 0; + cursor: pointer; + text-align: left; + width: 100%; + transition: opacity 0.2s ease; + + &:focus, + &:focus-visible { + outline: none; + } + } + + &__simple-title { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__simple-meta { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + } + + &__simple-meta-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + font-size: 13px; + color: globals.$muted-color; + } + + &__simple-size { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + font-weight: 500; + } + + &__simple-extracting { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + font-weight: 500; + color: globals.$muted-color; + } + + &__simple-seeding { + color: #4ade80; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__simple-progress { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + width: 200px; + flex-shrink: 0; + } + + &__simple-progress-text { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + text-align: right; + } + + &__simple-actions { + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__simple-menu-btn { + padding: calc(globals.$spacing-unit); min-height: unset; } - &__hydra-gradient { - background: linear-gradient(90deg, #01483c 0%, #0cf1ca 50%, #01483c 100%); - box-shadow: 0px 0px 8px 0px rgba(12, 241, 202, 0.15); + &__progress-wrapper { + flex: 1; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + } + + &__progress-bar { width: 100%; - position: absolute; - bottom: 0; - height: 2px; - z-index: 1; + height: 8px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + margin-top: calc(globals.$spacing-unit / 2); + + &--small { + height: 6px; + } + } + + &__progress-fill { + height: 100%; + background-color: #fff; + transition: width 0.3s ease; + border-radius: 4px; + + &--extraction { + background-color: #fff; + } } } diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 06e9face..6a22148a 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,37 +1,440 @@ -import { useNavigate } from "react-router-dom"; -import cn from "classnames"; - import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import { Badge, Button } from "@renderer/components"; import { - buildGameDetailsPath, formatDownloadProgress, + buildGameDetailsPath, } from "@renderer/helpers"; -import { Downloader, formatBytes } from "@shared"; +import { Downloader, formatBytes, formatBytesToMbps } from "@shared"; +import { addMilliseconds } from "date-fns"; import { DOWNLOADER_NAME } from "@renderer/constants"; -import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; +import { + useAppSelector, + useDownload, + useLibrary, + useDate, +} from "@renderer/hooks"; import "./download-group.scss"; import { useTranslation } from "react-i18next"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { AnimatePresence, motion } from "framer-motion"; import { DropdownMenu, DropdownMenuItem, } from "@renderer/components/dropdown-menu/dropdown-menu"; import { + ClockIcon, ColumnsIcon, DownloadIcon, FileDirectoryIcon, LinkIcon, PlayIcon, - QuestionIcon, ThreeBarsIcon, TrashIcon, UnlinkIcon, XCircleIcon, + GraphIcon, } from "@primer/octicons-react"; +import { average } from "color.js"; + +interface AnimatedPercentageProps { + value: number; +} + +function AnimatedPercentage({ value }: Readonly) { + const percentageText = formatDownloadProgress(value); + const prevTextRef = useRef(percentageText); + const chars = percentageText.split(""); + const prevChars = prevTextRef.current.split(""); + + useEffect(() => { + prevTextRef.current = percentageText; + }, [percentageText]); + + return ( + <> + {chars.map((char, index) => { + const prevChar = prevChars[index]; + const charChanged = prevChar !== char; + + return ( + + + {char} + + + ); + })} + + ); +} + +interface SpeedChartProps { + speeds: number[]; + peakSpeed: number; + color?: string; +} + +function SpeedChart({ + speeds, + peakSpeed, + color = "rgba(255, 255, 255, 1)", +}: Readonly) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let animationFrameId: number; + let resizeObserver: ResizeObserver | null = null; + + const draw = () => { + const clientWidth = canvas.clientWidth; + const dpr = window.devicePixelRatio || 1; + + canvas.width = clientWidth * dpr; + canvas.height = 100 * dpr; + ctx.scale(dpr, dpr); + + const width = clientWidth; + const height = 100; + const barWidth = 4; + const barGap = 10; + const barSpacing = barWidth + barGap; + + // Calculate how many bars can fit in the available width + const totalBars = Math.max(1, Math.floor((width + barGap) / barSpacing)); + const maxHeight = peakSpeed || Math.max(...speeds, 1); + + ctx.clearRect(0, 0, width, height); + + let r = 255, + g = 255, + b = 255; + if (color.startsWith("#")) { + let hex = color.replace("#", ""); + // Handle shorthand hex colors (e.g., "#fff" -> "#ffffff") + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + r = Number.parseInt(hex.substring(0, 2), 16) || 255; + g = Number.parseInt(hex.substring(2, 4), 16) || 255; + b = Number.parseInt(hex.substring(4, 6), 16) || 255; + } else if (color.startsWith("rgb")) { + const matches = color.match(/\d+/g); + if (matches && matches.length >= 3) { + r = Number.parseInt(matches[0]) || 255; + g = Number.parseInt(matches[1]) || 255; + b = Number.parseInt(matches[2]) || 255; + } + } + const displaySpeeds = speeds.slice(-totalBars); + + for (let i = 0; i < totalBars; i++) { + const x = i * barSpacing; + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.beginPath(); + ctx.roundRect(x, 0, barWidth, height, 3); + ctx.fill(); + + if (i < displaySpeeds.length) { + const speed = displaySpeeds[i] || 0; + const filledHeight = (speed / maxHeight) * height; + + if (filledHeight > 0) { + const gradient = ctx.createLinearGradient( + 0, + height - filledHeight, + 0, + height + ); + + gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0.7)`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.roundRect(x, height - filledHeight, barWidth, filledHeight, 3); + ctx.fill(); + } + } + } + animationFrameId = requestAnimationFrame(draw); + }; + + animationFrameId = requestAnimationFrame(draw); + + // Handle resize - trigger redraw when canvas size changes + resizeObserver = new ResizeObserver(() => { + // Cancel any pending animation frame to force immediate redraw + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + // Trigger a redraw that will recalculate bars based on new width + draw(); + }); + resizeObserver.observe(canvas); + + return () => { + cancelAnimationFrame(animationFrameId); + if (resizeObserver) { + resizeObserver.disconnect(); + } + }; + }, [speeds, peakSpeed, color]); + + return ( + + ); +} + +interface HeroDownloadViewProps { + game: LibraryGame; + isGameDownloading: boolean; + isGameExtracting?: boolean; + downloadSpeed: number; + finalDownloadSize: string; + peakSpeed: number; + currentProgress: number; + dominantColor: string; + lastPacket: ReturnType["lastPacket"]; + speedHistory: number[]; + formatSpeed: (speed: number) => string; + calculateETA: () => string; + pauseDownload: (shop: GameShop, objectId: string) => void; + resumeDownload: (shop: GameShop, objectId: string) => void; + cancelDownload: (shop: GameShop, objectId: string) => void; + t: (key: string) => string; +} + +function HeroDownloadView({ + game, + isGameDownloading, + isGameExtracting = false, + downloadSpeed, + finalDownloadSize, + peakSpeed, + currentProgress, + dominantColor, + lastPacket, + speedHistory, + formatSpeed, + calculateETA, + pauseDownload, + resumeDownload, + cancelDownload, + t, +}: Readonly) { + const navigate = useNavigate(); + + const handleLogoClick = useCallback(() => { + navigate(buildGameDetailsPath(game)); + }, [navigate, game]); + + return ( +
+
+ {game.title} +
+
+ +
+
+
+ {game.logoImageUrl ? ( + + ) : ( + + )} +
+
+ +
+
+
+
+ {isGameExtracting && ( + + {t("extracting")} + + )} + {!isGameExtracting && lastPacket?.isCheckingFiles && ( + + {t("checking_files")} + + )} + {!isGameExtracting && !lastPacket?.isCheckingFiles && ( + + + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `0 B / ${finalDownloadSize}`} + + )} + +
+
+ {!lastPacket?.isCheckingFiles && !isGameExtracting && ( + + {isGameDownloading && + lastPacket?.timeRemaining && + lastPacket.timeRemaining > 0 && ( + <> + + {calculateETA()} + + )} + + )} + + + +
+
+
+
+
+ {!isGameExtracting && ( +
+ {isGameDownloading ? ( + + ) : ( + + )} + +
+ )} +
+
+ +
+
+
+ + + +
+ + {t("network")}: + + + {isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"} + +
+
+ +
+ + + +
+ {t("peak")}: + + {peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"} + +
+
+ + {game.download?.downloader === Downloader.Torrent && + isGameDownloading && + lastPacket && + (lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && ( +
+
+ + Seeds:{" "} + + {lastPacket.numSeeds} + + , Peers:{" "} + + {lastPacket.numPeers} + + +
+
+ )} + + {game.download?.downloader !== undefined && ( +
+
+ + {DOWNLOADER_NAME[Number(game.download.downloader)]} + +
+
+ )} +
+ +
+ +
+
+
+
+ ); +} export interface DownloadGroupProps { library: LibraryGame[]; @@ -48,32 +451,169 @@ export function DownloadGroup({ openGameInstaller, seedingStatus, }: Readonly) { - const navigate = useNavigate(); - const { t } = useTranslation("downloads"); + const navigate = useNavigate(); const userPreferences = useAppSelector( (state) => state.userPreferences.value ); + const extraction = useAppSelector((state) => state.download.extraction); + const { updateLibrary } = useLibrary(); const { lastPacket, - progress, - pauseDownload, - resumeDownload, + pauseDownload: pauseDownloadOriginal, + resumeDownload: resumeDownloadOriginal, cancelDownload, isGameDeleting, pauseSeeding, resumeSeeding, } = useDownload(); + // Wrap resumeDownload with optimistic update + const resumeDownload = useCallback( + async (shop: GameShop, objectId: string) => { + const gameId = `${shop}:${objectId}`; + + // Optimistically mark as downloading + setOptimisticallyResumed((prev) => ({ ...prev, [gameId]: true })); + + try { + await resumeDownloadOriginal(shop, objectId); + } catch (error) { + // If resume fails, remove optimistic state + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + throw error; + } + }, + [resumeDownloadOriginal] + ); + + // Wrap pauseDownload to clear optimistic state + const pauseDownload = useCallback( + async (shop: GameShop, objectId: string) => { + const gameId = `${shop}:${objectId}`; + + // Clear optimistic state when pausing + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + + await pauseDownloadOriginal(shop, objectId); + }, + [pauseDownloadOriginal] + ); + + const { formatDistance } = useDate(); + + // 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>( + {} + ); + const [optimisticallyResumed, setOptimisticallyResumed] = useState< + Record + >({}); + + const extractDominantColor = useCallback( + async (imageUrl: string, gameId: string) => { + if (dominantColors[gameId]) return; + + try { + const color = await average(imageUrl, { amount: 1, format: "hex" }); + const colorString = + typeof color === "string" ? color : color.toString(); + setDominantColors((prev) => ({ ...prev, [gameId]: colorString })); + } catch (error) { + console.error("Failed to extract dominant color:", error); + } + }, + [dominantColors] + ); + + // Clear optimistic state when actual download starts or library updates + useEffect(() => { + if (lastPacket?.gameId) { + const gameId = lastPacket.gameId; + + // Clear optimistic state when actual download starts + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + delete next[gameId]; + return next; + }); + } + }, [lastPacket?.gameId]); + + // Clear optimistic state for games that are no longer active after library update + useEffect(() => { + setOptimisticallyResumed((prev) => { + const next = { ...prev }; + let changed = false; + + for (const gameId in next) { + if (next[gameId]) { + const game = library.find((g) => g.id === gameId); + // Clear if game doesn't exist or download status is not active + if ( + !game || + game.download?.status !== "active" || + lastPacket?.gameId === gameId + ) { + delete next[gameId]; + changed = true; + } + } + } + + return changed ? next : prev; + }); + }, [library, lastPacket?.gameId]); + + // 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")) { + const game = library[0]; + const heroImageUrl = + game.libraryHeroImageUrl || game.libraryImageUrl || ""; + if (heroImageUrl && game.id) { + extractDominantColor(heroImageUrl, game.id); + } + } + }, [library, title, t, extractDominantColor]); + + const isGameSeeding = (game: LibraryGame) => { + const entry = seedingStatus.find((s) => s.gameId === game.id); + if (entry?.status) return entry.status === "seeding"; + return game.download?.status === "seeding"; + }; + + const isGameDownloadingMap = useMemo(() => { + const map: Record = {}; + for (const game of library) { + map[game.id] = + lastPacket?.gameId === game.id || + optimisticallyResumed[game.id] === true; + } + return map; + }, [library, lastPacket?.gameId, optimisticallyResumed]); + const getFinalDownloadSize = (game: LibraryGame) => { const download = game.download!; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; - if (download.fileSize) return formatBytes(download.fileSize); + if (download.fileSize != null) return formatBytes(download.fileSize); if (lastPacket?.download.fileSize && isGameDownloading) return formatBytes(lastPacket.download.fileSize); @@ -81,15 +621,27 @@ export function DownloadGroup({ return "N/A"; }; - const seedingMap = useMemo(() => { - const map = new Map(); + const formatSpeed = (speed: number): string => { + return userPreferences?.showDownloadSpeedInMegabytes + ? `${formatBytes(speed)}/s` + : formatBytesToMbps(speed); + }; - seedingStatus.forEach((seed) => { - map.set(seed.gameId, seed); - }); + const calculateETA = () => { + if ( + !lastPacket || + lastPacket.timeRemaining < 0 || + !Number.isFinite(lastPacket.timeRemaining) + ) { + return ""; + } - return map; - }, [seedingStatus]); + return formatDistance( + addMilliseconds(new Date(), lastPacket.timeRemaining), + new Date(), + { addSuffix: true } + ); + }; const extractGameDownload = useCallback( async (shop: GameShop, objectId: string) => { @@ -99,110 +651,14 @@ export function DownloadGroup({ [updateLibrary] ); - const getGameInfo = (game: LibraryGame) => { - const download = game.download!; - - const isGameDownloading = lastPacket?.gameId === game.id; - const finalDownloadSize = getFinalDownloadSize(game); - const seedingStatus = seedingMap.get(game.id); - - if (download.extracting) { - return

{t("extracting")}

; - } - - if (isGameDeleting(game.id)) { - return

{t("deleting")}

; - } - - if (isGameDownloading) { - if (lastPacket?.isDownloadingMetadata) { - return

{t("downloading_metadata")}

; - } - - if (lastPacket?.isCheckingFiles) { - return ( - <> -

{progress}

-

{t("checking_files")}

- - ); - } - - return ( - <> -

{progress}

- -

- {formatBytes(lastPacket.download.bytesDownloaded)} /{" "} - {finalDownloadSize} -

- - {download.downloader === Downloader.Torrent && ( - - {lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds - - - )} - - ); - } - - if (download.progress === 1) { - const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0); - - return download.status === "seeding" && - download.downloader === Downloader.Torrent ? ( - <> -

- {t("seeding")} - - -

- {uploadSpeed &&

{uploadSpeed}/s

} - - ) : ( -

{t("completed")}

- ); - } - - if (download.status === "paused") { - return ( - <> -

{formatDownloadProgress(download.progress)}

-

{t(download.queued ? "queued" : "paused")}

- - ); - } - - if (download.status === "active") { - return ( - <> -

{formatDownloadProgress(download.progress)}

- -

- {formatBytes(download.bytesDownloaded)} / {finalDownloadSize} -

- - ); - } - - return

{t(download.status as string)}

; - }; - const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const download = lastPacket?.download; - const isGameDownloading = lastPacket?.gameId === game.id; + const isGameDownloading = isGameDownloadingMap[game.id]; const deleting = isGameDeleting(game.id); if (game.download?.progress === 1) { - return [ + const actions = [ { label: t("install"), disabled: deleting, @@ -224,7 +680,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status === "seeding" && + isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { pauseSeeding(game.shop, game.objectId); @@ -235,7 +691,7 @@ export function DownloadGroup({ disabled: deleting, icon: , show: - game.download?.status !== "seeding" && + !isGameSeeding(game) && game.download?.downloader === Downloader.Torrent, onClick: () => { resumeSeeding(game.shop, game.objectId); @@ -250,6 +706,7 @@ export function DownloadGroup({ }, }, ]; + return actions.filter((action) => action.show !== false); } if (isGameDownloading) { @@ -296,80 +753,183 @@ export function DownloadGroup({ ]; }; + const downloadInfo = useMemo( + () => + library.map((game) => ({ + game, + size: getFinalDownloadSize(game), + progress: game.download?.progress || 0, + isSeeding: isGameSeeding(game), + })), + [ + library, + lastPacket?.gameId, + lastPacket?.download.fileSize, + isGameDownloadingMap, + seedingStatus, + ] + ); + if (!library.length) return null; + const isDownloadingGroup = title === t("download_in_progress"); + const isQueuedGroup = title === t("queued_downloads"); + const isCompletedGroup = title === t("downloads_completed"); + + if (isDownloadingGroup && library.length > 0) { + const game = library[0]; + const isGameExtracting = extraction?.visibleId === game.id; + const isGameDownloading = + isGameDownloadingMap[game.id] && !isGameExtracting; + const downloadSpeed = isGameDownloading + ? (lastPacket?.downloadSpeed ?? 0) + : 0; + const finalDownloadSize = getFinalDownloadSize(game); + // 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) { + currentProgress = extraction.progress; + } else if (isGameDownloading && lastPacket) { + currentProgress = lastPacket.progress; + } + + const dominantColor = dominantColors[game.id] || "#fff"; + + return ( + + ); + } + return ( -
+
-

{title}

-
-

{library.length}

+
+

{title}

+

{library.length}

+
-
    - {library.map((game) => { +
      + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { return ( -
    • -
      -
      - {game.title} +
    • + -
      - {DOWNLOADER_NAME[game.download!.downloader]} +
      + +
      +
      + + {DOWNLOADER_NAME[Number(game.download!.downloader)]} + +
      +
      + {extraction?.visibleId === game.id ? ( + + {t("extracting")} ( + {Math.round(extraction.progress * 100)}%) + + ) : ( + + + {size} + + )} + {game.download?.progress === 1 && seeding && ( + + {t("seeding")} + + )}
      -
      -
      -
      - + + {isQueuedGroup && ( +
      + + {formatDownloadProgress(progress)} + +
      +
      - - {getGameInfo(game)}
      - - {getGameActions(game) !== null && ( - - - - )} -
      - - {game.download?.downloader === Downloader.Hydra && ( -
      )} + +
      + {game.download?.progress === 1 && ( + + )} + {isQueuedGroup && game.download?.progress !== 1 && ( + + )} + + + +
    • ); })} diff --git a/src/renderer/src/pages/downloads/downloads.scss b/src/renderer/src/pages/downloads/downloads.scss index 8290a66e..abada8d7 100644 --- a/src/renderer/src/pages/downloads/downloads.scss +++ b/src/renderer/src/pages/downloads/downloads.scss @@ -3,7 +3,6 @@ .downloads { &__container { display: flex; - padding: calc(globals.$spacing-unit * 3); flex-direction: column; width: 100%; } diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index c222ab65..10d817f1 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { useDownload, useLibrary } from "@renderer/hooks"; +import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import { useEffect, useMemo, useRef, useState } from "react"; import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; @@ -13,6 +13,7 @@ import { ArrowDownIcon } from "@primer/octicons-react"; export default function Downloads() { const { library, updateLibrary } = useLibrary(); + const extraction = useAppSelector((state) => state.download.extraction); const { t } = useTranslation("downloads"); @@ -39,11 +40,13 @@ export default function Downloads() { useEffect(() => { window.electron.onSeedingStatus((value) => setSeedingStatus(value)); - const unsubscribe = window.electron.onExtractionComplete(() => { + const unsubscribeExtraction = window.electron.onExtractionComplete(() => { updateLibrary(); }); - return () => unsubscribe(); + return () => { + unsubscribeExtraction(); + }; }, [updateLibrary]); const handleOpenGameInstaller = (shop: GameShop, objectId: string) => @@ -72,8 +75,10 @@ export default function Downloads() { /* Game has been manually added to the library */ if (!next.download) return prev; - /* Is downloading */ - if (lastPacket?.gameId === next.id || next.download.extracting) + /* Is downloading or extracting */ + const isExtracting = + next.download.extracting || extraction?.visibleId === next.id; + if (lastPacket?.gameId === next.id || isExtracting) return { ...prev, downloading: [...prev.downloading, next] }; /* Is either queued or paused */ @@ -96,7 +101,7 @@ export default function Downloads() { queued, complete, }; - }, [library, lastPacket?.gameId]); + }, [library, lastPacket?.gameId, extraction?.visibleId]); const downloadGroups = [ { diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx index c9658636..e19cbf26 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx @@ -8,6 +8,7 @@ import { import useEmblaCarousel from "embla-carousel-react"; import { gameDetailsContext } from "@renderer/context"; import { useAppSelector } from "@renderer/hooks"; +import { VideoPlayer } from "./video-player"; import "./gallery-slider.scss"; export function GallerySlider() { @@ -100,20 +101,44 @@ export function GallerySlider() { src?: string; poster?: string; videoSrc?: string; + videoType?: string; alt: string; }> = []; if (shopDetails?.movies) { shopDetails.movies.forEach((video, index) => { - items.push({ - id: String(video.id), - type: "video", - poster: video.thumbnail, - videoSrc: video.mp4.max.startsWith("http://") - ? video.mp4.max.replace("http://", "https://") - : video.mp4.max, - alt: t("video", { number: String(index + 1) }), - }); + let videoSrc: string | undefined; + let videoType: string | undefined; + + if (video.hls_h264) { + videoSrc = video.hls_h264; + videoType = "application/x-mpegURL"; + } else if (video.dash_h264) { + videoSrc = video.dash_h264; + videoType = "application/dash+xml"; + } else if (video.dash_av1) { + videoSrc = video.dash_av1; + videoType = "application/dash+xml"; + } else if (video.mp4?.max) { + videoSrc = video.mp4.max; + videoType = "video/mp4"; + } else if (video.webm?.max) { + videoSrc = video.webm.max; + videoType = "video/webm"; + } + + if (videoSrc) { + items.push({ + id: String(video.id), + type: "video", + poster: video.thumbnail, + videoSrc: videoSrc.startsWith("http://") + ? videoSrc.replace("http://", "https://") + : videoSrc, + videoType, + alt: video.name || t("video", { number: String(index + 1) }), + }); + } }); } @@ -163,17 +188,17 @@ export function GallerySlider() { {mediaItems.map((item) => (
      {item.type === "video" ? ( - + /> ) : ( (null); + const isHls = videoType === "application/x-mpegURL"; + + useHlsVideo(videoRef, { + videoSrc, + videoType, + autoplay, + muted, + loop, + }); + + if (isHls) { + return ( + + ); + } + + return ( + + ); +} 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/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 270ed030..24c37b18 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -1,7 +1,12 @@ import { useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { formatDownloadProgress } from "@renderer/helpers"; -import { useDate, useDownload, useFormat } from "@renderer/hooks"; +import { + useAppSelector, + useDate, + useDownload, + useFormat, +} from "@renderer/hooks"; import { Link } from "@renderer/components"; import { gameDetailsContext } from "@renderer/context"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; @@ -17,6 +22,9 @@ export function HeroPanelPlaytime() { const { numberFormatter } = useFormat(); const { progress, lastPacket } = useDownload(); const { formatDistance } = useDate(); + const extraction = useAppSelector((state) => state.download.extraction); + + const isExtracting = extraction?.visibleId === game?.id; useEffect(() => { if (game?.lastTimePlayed) { @@ -52,6 +60,16 @@ export function HeroPanelPlaytime() { const isGameDownloading = game.download?.status === "active" && lastPacket?.gameId === game.id; + const extractionInProgressInfo = ( +
      + + {t("extracting")} + + + {formatDownloadProgress(extraction?.progress ?? 0)} +
      + ); + const downloadInProgressInfo = (
      @@ -72,7 +90,8 @@ export function HeroPanelPlaytime() { return ( <>

      {t("not_played_yet", { title: game?.title })}

      - {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -81,7 +100,8 @@ export function HeroPanelPlaytime() { return ( <>

      {t("playing_now")}

      - {hasDownload && downloadInProgressInfo} + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} ); } @@ -113,9 +133,9 @@ export function HeroPanelPlaytime() { })}

      - {hasDownload ? ( - downloadInProgressInfo - ) : ( + {isExtracting && extractionInProgressInfo} + {!isExtracting && hasDownload && downloadInProgressInfo} + {!isExtracting && !hasDownload && (

      {t("last_time_played", { period: lastTimePlayed, diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss index c91e685c..6aa4d311 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss @@ -80,5 +80,11 @@ &--disabled { opacity: globals.$disabled-opacity; } + + &--extraction { + &::-webkit-progress-value { + background-color: #fff; + } + } } } diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx index 799f2c36..48cda106 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; import { useTranslation } from "react-i18next"; -import { useDate, useDownload } from "@renderer/hooks"; +import { useAppSelector, useDate, useDownload } from "@renderer/hooks"; import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelPlaytime } from "./hero-panel-playtime"; @@ -18,9 +18,13 @@ export function HeroPanel() { const { lastPacket } = useDownload(); + const extraction = useAppSelector((state) => state.download.extraction); + const isGameDownloading = game?.download?.status === "active" && lastPacket?.gameId === game?.id; + const isExtracting = extraction?.visibleId === game?.id; + const getInfo = () => { if (!game) { const [latestRepack] = repacks; @@ -49,6 +53,8 @@ export function HeroPanel() { (game?.download?.status === "active" && game?.download?.progress < 1) || game?.download?.status === "paused"; + const showExtractionProgressBar = isExtracting; + return (

      @@ -72,6 +78,14 @@ export function HeroPanel() { }`} /> )} + + {showExtractionProgressBar && ( + + )}
      ); diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index e658fbb8..387c2356 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -1,7 +1,7 @@ import { useContext, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, CheckboxField, Modal, TextField } from "@renderer/components"; -import type { LibraryGame, ShortcutLocation } from "@types"; +import type { Game, LibraryGame, ShortcutLocation } from "@types"; import { gameDetailsContext } from "@renderer/context"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; @@ -11,6 +11,8 @@ import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal"; import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { debounce } from "lodash-es"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { getGameKey } from "@renderer/helpers"; import "./game-options-modal.scss"; import { logger } from "@renderer/logger"; @@ -75,11 +77,19 @@ export function GameOptionsModal({ const debounceUpdateLaunchOptions = useRef( debounce(async (value: string) => { - await window.electron.updateLaunchOptions( - game.shop, - game.objectId, - value - ); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const trimmedValue = value.trim(); + const updated = { + ...gameData, + launchOptions: trimmedValue ? trimmedValue : null, + }; + await levelDBService.put(gameKey, updated, "games"); + } updateGame(); }, 1000) ).current; @@ -213,9 +223,16 @@ export function GameOptionsModal({ const handleClearLaunchOptions = async () => { setLaunchOptions(""); - window.electron - .updateLaunchOptions(game.shop, game.objectId, null) - .then(updateGame); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const updated = { ...gameData, launchOptions: null }; + await levelDBService.put(gameKey, updated, "games"); + } + updateGame(); }; const shouldShowWinePrefixConfiguration = @@ -256,11 +273,15 @@ export function GameOptionsModal({ ) => { setAutomaticCloudSync(event.target.checked); - await window.electron.toggleAutomaticCloudSync( - game.shop, - game.objectId, - event.target.checked - ); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const updated = { ...gameData, automaticCloudSync: event.target.checked }; + await levelDBService.put(gameKey, updated, "games"); + } updateGame(); }; 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 91013da0..683ce53a 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,14 +15,21 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import type { DownloadSource, GameRepack } from "@types"; +import type { DownloadSource, Game, GameRepack } from "@types"; import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; import { Downloader } from "@shared"; import { orderBy } from "lodash-es"; -import { useDate, useFeature, 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"; import "./repacks-modal.scss"; export interface RepacksModalProps { @@ -68,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") { @@ -98,8 +108,11 @@ export function RepacksModal({ useEffect(() => { const fetchDownloadSources = async () => { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); }; fetchDownloadSources(); @@ -109,16 +122,27 @@ export function RepacksModal({ const fetchLastCheckTimestamp = async () => { setIsLoadingTimestamp(true); - const timestamp = await window.electron.getDownloadSourcesSinceValue(); + try { + const timestamp = (await levelDBService.get( + "downloadSourcesSinceValue", + null, + "utf8" + )) as string | null; - setLastCheckTimestamp(timestamp); - setIsLoadingTimestamp(false); + setLastCheckTimestamp(timestamp); + } catch { + setLastCheckTimestamp(null); + } finally { + setIsLoadingTimestamp(false); + } }; - if (visible) { + if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) { fetchLastCheckTimestamp(); + } else { + setIsLoadingTimestamp(false); } - }, [visible, repacks]); + }, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]); useEffect(() => { if ( @@ -126,7 +150,20 @@ export function RepacksModal({ game?.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 ) { - globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); + const gameKey = getGameKey(game.shop, game.objectId); + levelDBService + .get(gameKey, "games") + .then((gameData) => { + if (gameData) { + const updated = { + ...(gameData as Game), + newDownloadOptionsCount: undefined, + }; + return levelDBService.put(gameKey, updated, "games"); + } + return Promise.resolve(); + }) + .catch(() => {}); const gameId = `${game.shop}:${game.objectId}`; dispatch(clearNewDownloadOptions({ gameId })); @@ -204,9 +241,19 @@ export function RepacksModal({ return false; } - const lastCheckUtc = new Date(lastCheckTimestamp).toISOString(); + try { + const lastCheckDate = new Date(lastCheckTimestamp); - return repack.createdAt > lastCheckUtc; + if (isNaN(lastCheckDate.getTime())) { + return false; + } + + const lastCheckUtc = lastCheckDate.toISOString(); + + return repack.createdAt > lastCheckUtc; + } catch { + return false; + } }; const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); @@ -326,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/home/home.tsx b/src/renderer/src/pages/home/home.tsx index b8f632a6..91c9b2ff 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import { useNavigate } from "react-router-dom"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import { Button, GameCard, Hero } from "@renderer/components"; -import type { ShopAssets, Steam250Game } from "@types"; +import type { DownloadSource, ShopAssets, Steam250Game } from "@types"; import flameIconStatic from "@renderer/assets/icons/flame-static.png"; import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif"; @@ -40,7 +42,10 @@ export default function Home() { setCurrentCatalogueCategory(category); setIsLoading(true); - const downloadSources = await window.electron.getDownloadSources(); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const downloadSources = orderBy(sources, "createdAt", "desc"); const params = { take: 12, diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 42b4ab72..dd998c59 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,7 +1,7 @@ import { LibraryGame } from "@types"; import { useGameCard } from "@renderer/hooks"; import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; -import { memo, useMemo } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import "./library-game-card-large.scss"; interface LibraryGameCardLargeProps { @@ -12,12 +12,18 @@ interface LibraryGameCardLargeProps { ) => void; } +const normalizePathForCss = (url: string | null | undefined): string => { + if (!url) return ""; + return url.replaceAll("\\", "/"); +}; + const getImageWithCustomPriority = ( customUrl: string | null | undefined, originalUrl: string | null | undefined, fallbackUrl?: string | null | undefined ) => { - return customUrl || originalUrl || fallbackUrl || ""; + const selectedUrl = customUrl || originalUrl || fallbackUrl || ""; + return normalizePathForCss(selectedUrl); }; export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ @@ -30,26 +36,48 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ const backgroundImage = useMemo( () => getImageWithCustomPriority( + game.customHeroImageUrl, game.libraryHeroImageUrl, - game.libraryImageUrl, - game.iconUrl + game.libraryImageUrl ?? game.iconUrl ), - [game.libraryHeroImageUrl, game.libraryImageUrl, game.iconUrl] + [ + game.customHeroImageUrl, + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl, + ] ); + const [unlockedAchievementsCount, setUnlockedAchievementsCount] = useState( + game.unlockedAchievementCount ?? 0 + ); + + useEffect(() => { + if (game.unlockedAchievementCount) return; + + window.electron + .getUnlockedAchievements(game.objectId, game.shop) + .then((achievements) => { + setUnlockedAchievementsCount( + achievements.filter((a) => a.unlocked).length + ); + }); + }, [game]); + const backgroundStyle = useMemo( - () => ({ backgroundImage: `url(${backgroundImage})` }), + () => + backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {}, [backgroundImage] ); const achievementBarStyle = useMemo( () => ({ - width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`, + width: `${(unlockedAchievementsCount / (game.achievementCount ?? 1)) * 100}%`, }), - [game.unlockedAchievementCount, game.achievementCount] + [unlockedAchievementsCount, game.achievementCount] ); - const logoImage = game.logoImageUrl; + const logoImage = game.customLogoImageUrl ?? game.logoImageUrl; return (
      {Math.round( - ((game.unlockedAchievementCount ?? 0) / - (game.achievementCount ?? 1)) * + (unlockedAchievementsCount / (game.achievementCount ?? 1)) * 100 )} % diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index e6f2e713..a91176cb 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -25,12 +25,14 @@ export const LibraryGameCard = memo(function LibraryGameCard({ const { formatPlayTime, handleCardClick, handleContextMenuClick } = useGameCard(game, onContextMenu); - const coverImage = + const coverImage = ( + game.customIconUrl ?? game.coverImageUrl ?? game.libraryImageUrl ?? game.libraryHeroImageUrl ?? game.iconUrl ?? - undefined; + "" + ).replaceAll("\\", "/"); 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..c8fa7c3f --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.scss @@ -0,0 +1,58 @@ +@use "../../scss/globals.scss"; + +.notifications { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 3); + width: 100%; + max-width: 800px; + margin: 0 auto; + + &__actions { + display: flex; + gap: globals.$spacing-unit; + justify-content: flex-end; + } + + &__list { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__empty { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__icon-container { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__loading { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + } + + &__load-more { + display: flex; + justify-content: center; + padding: calc(globals.$spacing-unit * 2); + } +} diff --git a/src/renderer/src/pages/notifications/notifications.tsx b/src/renderer/src/pages/notifications/notifications.tsx new file mode 100644 index 00000000..f9bd0b46 --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.tsx @@ -0,0 +1,400 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BellIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { AnimatePresence, motion } from "framer-motion"; +import { Button } from "@renderer/components"; +import { useAppDispatch, useToast, useUserDetails } from "@renderer/hooks"; +import { setHeaderTitle } from "@renderer/features"; +import { logger } from "@renderer/logger"; + +import { NotificationItem } from "./notification-item"; +import { LocalNotificationItem } from "./local-notification-item"; +import type { + Notification, + LocalNotification, + NotificationsResponse, + MergedNotification, + Badge, +} from "@types"; +import "./notifications.scss"; + +export default function Notifications() { + const { t, i18n } = useTranslation("notifications_page"); + const { showSuccessToast, showErrorToast } = useToast(); + const { userDetails } = useUserDetails(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setHeaderTitle(t("title"))); + }, [dispatch, t]); + + const [apiNotifications, setApiNotifications] = useState([]); + const [localNotifications, setLocalNotifications] = useState< + LocalNotification[] + >([]); + const [badges, setBadges] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [clearingIds, setClearingIds] = useState>(new Set()); + const [pagination, setPagination] = useState({ + total: 0, + hasMore: false, + skip: 0, + }); + + const fetchLocalNotifications = useCallback(async () => { + try { + const notifications = await window.electron.getLocalNotifications(); + setLocalNotifications(notifications); + } catch (error) { + logger.error("Failed to fetch local notifications", error); + } + }, []); + + const fetchBadges = useCallback(async () => { + try { + const language = i18n.language.split("-")[0]; + const params = new URLSearchParams({ locale: language }); + const badgesResponse = await window.electron.hydraApi.get( + `/badges?${params.toString()}`, + { needsAuth: false } + ); + setBadges(badgesResponse); + } catch (error) { + logger.error("Failed to fetch badges", error); + } + }, [i18n.language]); + + const fetchApiNotifications = useCallback( + async (skip = 0, append = false) => { + if (!userDetails) return; + + try { + setIsLoading(true); + const response = + await window.electron.hydraApi.get( + "/profile/notifications", + { + params: { filter: "all", take: 20, skip }, + needsAuth: true, + } + ); + + logger.log("Notifications API response:", response); + + if (append) { + setApiNotifications((prev) => [...prev, ...response.notifications]); + } else { + setApiNotifications(response.notifications); + } + + setPagination({ + total: response.pagination.total, + hasMore: response.pagination.hasMore, + skip: response.pagination.skip + response.pagination.take, + }); + } catch (error) { + logger.error("Failed to fetch API notifications", error); + } finally { + setIsLoading(false); + } + }, + [userDetails] + ); + + const fetchAllNotifications = useCallback(async () => { + setIsLoading(true); + await Promise.all([ + fetchLocalNotifications(), + fetchBadges(), + userDetails ? fetchApiNotifications(0, false) : Promise.resolve(), + ]); + setIsLoading(false); + }, [ + fetchLocalNotifications, + fetchBadges, + fetchApiNotifications, + userDetails, + ]); + + useEffect(() => { + fetchAllNotifications(); + }, [fetchAllNotifications]); + + useEffect(() => { + const unsubscribe = window.electron.onLocalNotificationCreated( + (notification) => { + setLocalNotifications((prev) => [notification, ...prev]); + } + ); + + return () => unsubscribe(); + }, []); + + const mergedNotifications = useMemo(() => { + const sortByDate = (a: MergedNotification, b: MergedNotification) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + + // High priority notifications (priority === 1) - keep in API order + const highPriority: MergedNotification[] = apiNotifications + .filter((n) => n.priority === 1) + .map((n) => ({ ...n, source: "api" as const })); + + // Low priority: other API notifications + local notifications, merged and sorted by date + const lowPriorityApi: MergedNotification[] = apiNotifications + .filter((n) => n.priority !== 1) + .map((n) => ({ ...n, source: "api" as const })); + + const localWithSource: MergedNotification[] = localNotifications.map( + (n) => ({ + ...n, + source: "local" as const, + }) + ); + + const lowPriority = [...lowPriorityApi, ...localWithSource].sort( + sortByDate + ); + + return [...highPriority, ...lowPriority]; + }, [apiNotifications, localNotifications]); + + const displayedNotifications = useMemo(() => { + return mergedNotifications.filter((n) => !clearingIds.has(n.id)); + }, [mergedNotifications, clearingIds]); + + const notifyCountChange = useCallback(() => { + window.dispatchEvent(new CustomEvent("notificationsChanged")); + }, []); + + const handleMarkAsRead = useCallback( + async (id: string, source: "api" | "local") => { + try { + if (source === "api") { + await window.electron.hydraApi.patch( + `/profile/notifications/${id}/read`, + { + data: { id }, + needsAuth: true, + } + ); + setApiNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) + ); + } else { + await window.electron.markLocalNotificationRead(id); + setLocalNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) + ); + } + notifyCountChange(); + } catch (error) { + logger.error("Failed to mark notification as read", error); + } + }, + [notifyCountChange] + ); + + const handleMarkAllAsRead = useCallback(async () => { + try { + // Mark all API notifications as read + if (userDetails && apiNotifications.some((n) => !n.isRead)) { + await window.electron.hydraApi.patch( + `/profile/notifications/all/read`, + { needsAuth: true } + ); + setApiNotifications((prev) => + prev.map((n) => ({ ...n, isRead: true })) + ); + } + + // Mark all local notifications as read + await window.electron.markAllLocalNotificationsRead(); + setLocalNotifications((prev) => + prev.map((n) => ({ ...n, isRead: true })) + ); + + notifyCountChange(); + showSuccessToast(t("marked_all_as_read")); + } catch (error) { + logger.error("Failed to mark all as read", error); + showErrorToast(t("failed_to_mark_as_read")); + } + }, [ + apiNotifications, + userDetails, + showSuccessToast, + showErrorToast, + t, + notifyCountChange, + ]); + + const handleDismiss = useCallback( + async (id: string, source: "api" | "local") => { + try { + if (source === "api") { + await window.electron.hydraApi.delete( + `/profile/notifications/${id}`, + { needsAuth: true } + ); + setApiNotifications((prev) => prev.filter((n) => n.id !== id)); + setPagination((prev) => ({ ...prev, total: prev.total - 1 })); + } else { + await window.electron.deleteLocalNotification(id); + setLocalNotifications((prev) => prev.filter((n) => n.id !== id)); + } + notifyCountChange(); + } catch (error) { + logger.error("Failed to dismiss notification", error); + showErrorToast(t("failed_to_dismiss")); + } + }, + [showErrorToast, t, notifyCountChange] + ); + + const handleClearAll = useCallback(async () => { + try { + // Mark all as clearing for animation + const allIds = new Set([ + ...apiNotifications.map((n) => n.id), + ...localNotifications.map((n) => n.id), + ]); + setClearingIds(allIds); + + // Wait for exit animation + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Clear all API notifications + if (userDetails && apiNotifications.length > 0) { + await window.electron.hydraApi.delete(`/profile/notifications/all`, { + needsAuth: true, + }); + setApiNotifications([]); + } + + // Clear all local notifications + await window.electron.clearAllLocalNotifications(); + setLocalNotifications([]); + + setClearingIds(new Set()); + setPagination({ total: 0, hasMore: false, skip: 0 }); + notifyCountChange(); + showSuccessToast(t("cleared_all")); + } catch (error) { + logger.error("Failed to clear all notifications", error); + setClearingIds(new Set()); + showErrorToast(t("failed_to_clear")); + } + }, [ + apiNotifications, + localNotifications, + userDetails, + showSuccessToast, + showErrorToast, + t, + notifyCountChange, + ]); + + const handleLoadMore = useCallback(() => { + if (pagination.hasMore && !isLoading) { + fetchApiNotifications(pagination.skip, true); + } + }, [pagination, isLoading, fetchApiNotifications]); + + const handleAcceptFriendRequest = useCallback(() => { + showSuccessToast(t("friend_request_accepted")); + }, [showSuccessToast, t]); + + const handleRefuseFriendRequest = useCallback(() => { + showSuccessToast(t("friend_request_refused")); + }, [showSuccessToast, t]); + + const renderNotification = (notification: MergedNotification) => { + const key = + notification.source === "local" + ? `local-${notification.id}` + : `api-${notification.id}`; + + return ( + + {notification.source === "local" ? ( + handleDismiss(id, "local")} + onMarkAsRead={(id) => handleMarkAsRead(id, "local")} + /> + ) : ( + handleDismiss(id, "api")} + onMarkAsRead={(id) => handleMarkAsRead(id, "api")} + onAcceptFriendRequest={handleAcceptFriendRequest} + onRefuseFriendRequest={handleRefuseFriendRequest} + /> + )} + + ); + }; + + const renderContent = () => { + if (isLoading && mergedNotifications.length === 0) { + return ( +
+ {t("loading")} +
+ ); + } + + if (mergedNotifications.length === 0) { + return ( +
+
+ +
+

{t("empty_title")}

+

{t("empty_description")}

+
+ ); + } + + return ( +
+
+ + +
+ +
+ + {displayedNotifications.map(renderNotification)} + +
+ + {pagination.hasMore && ( +
+ +
+ )} +
+ ); + }; + + 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..6e89ae1a --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/add-friend-modal.scss @@ -0,0 +1,120 @@ +@use "../../../scss/globals.scss"; + +.add-friend-modal { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + min-width: 400px; + + &__my-code { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1.5); + padding: calc(globals.$spacing-unit * 1.5); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + } + + &__my-code-label { + font-size: 0.875rem; + color: globals.$muted-color; + font-weight: 500; + } + + &__my-code-value { + font-size: 0.875rem; + color: globals.$body-color; + font-family: monospace; + font-weight: 600; + flex: 1; + } + + &__copy-icon-button { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: globals.$body-color; + cursor: pointer; + padding: calc(globals.$spacing-unit / 2); + border-radius: 4px; + transition: all ease 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + color: globals.$body-color; + } + } + + &__actions { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: globals.$spacing-unit; + } + + &__button { + align-self: flex-end; + white-space: nowrap; + } + + &__pending-status { + color: globals.$body-color; + font-size: globals.$small-font-size; + text-align: center; + padding: calc(globals.$spacing-unit / 2); + background-color: rgba(255, 255, 255, 0.05); + border-radius: 4px; + margin-top: calc(globals.$spacing-unit * -1); + } + + &__pending-container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + margin-top: calc(globals.$spacing-unit * 2); + + h3 { + margin: 0; + font-size: globals.$body-font-size; + font-weight: 600; + color: globals.$muted-color; + } + } + + &__pending-list { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + max-height: 300px; + overflow-y: auto; + padding-right: globals.$spacing-unit; + } + + &__friend-item { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 1.5); + background-color: rgba(255, 255, 255, 0.05); + border-radius: 8px; + cursor: pointer; + transition: all ease 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__friend-name { + flex: 1; + font-weight: 600; + color: globals.$muted-color; + font-size: globals.$body-font-size; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} 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 905f3b4c..59837462 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -15,17 +15,18 @@ import { useTranslation } from "react-i18next"; import type { GameShop } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; -import { FriendsBox } from "./friends-box"; +import { BadgesBox } from "./badges-box"; +import { FriendsBox, FriendsBoxAddButton } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserStatsBox } from "./user-stats-box"; -import { UserKarmaBox } from "./user-karma-box"; import { logger } from "@renderer/logger"; import { AnimatePresence } from "framer-motion"; +import { ProfileSection } from "../profile-section/profile-section"; +import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal"; -import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { ProfileTabs } from "./profile-tabs"; +import { ProfileTabs, type ProfileTabType } from "./profile-tabs"; import { LibraryTab } from "./library-tab"; import { ReviewsTab } from "./reviews-tab"; import { SouvenirsTab } from "./souvenirs-tab"; @@ -104,9 +105,7 @@ export function ProfileContent() { } | null>(null); const statsAnimation = useRef(-1); - const [activeTab, setActiveTab] = useState< - "library" | "reviews" | "souvenirs" - >("library"); + const [activeTab, setActiveTab] = useState("library"); // User reviews state const [reviews, setReviews] = useState([]); @@ -197,8 +196,6 @@ export function ProfileContent() { ); setReviews(response.reviews); setReviewsTotalCount(response.totalCount); - } catch (error) { - // Error handling for fetching reviews } finally { setIsLoadingReviews(false); } @@ -472,10 +469,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 f3f3ea08..01fd0885 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -2,11 +2,13 @@ import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import "./profile-content.scss"; +export type ProfileTabType = "library" | "reviews" | "souvenirs"; + interface ProfileTabsProps { - activeTab: "library" | "reviews" | "souvenirs"; + activeTab: ProfileTabType; reviewsTotalCount: number; souvenirsCount: number; - onTabChange: (tab: "library" | "reviews" | "souvenirs") => 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..6fbabdca 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,102 @@ 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")}

      +
      + + {t("karma_description")} + +
    • -
    -
    + )} +
); } 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 && ( +
+
+
+ )} +