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/build.yml b/.github/workflows/build.yml index 92fcebc3..5086d8e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,9 @@ name: Build on: pull_request: + push: + branches: + - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} 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/package.json b/package.json index ee039574..e2fec5ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.3", + "version": "3.7.4", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -75,6 +75,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", + "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.4.0", "react-redux": "^9.1.1", "react-router-dom": "^6.22.3", 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 79b78393..d2665928 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "settings": "Settings", "my_library": "My library", @@ -92,8 +93,16 @@ }, "header": { "search": "Search games", + "search_library": "Search library", + "recent_searches": "Recent Searches", + "suggestions": "Suggestions", + "clear_history": "Clear history", + "remove_from_history": "Remove from history", + "loading": "Loading...", + "no_results": "No results", "home": "Home", "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "search_results": "Search results", "settings": "Settings", @@ -194,6 +203,7 @@ "download_in_progress": "Download in progress", "download_paused": "Download paused", "last_downloaded_option": "Last downloaded option", + "new_download_option": "New", "create_steam_shortcut": "Create Steam shortcut", "create_shortcut_success": "Shortcut created successfully", "you_might_need_to_restart_steam": "You might need to restart Steam to see the changes", @@ -555,6 +565,15 @@ "platinum": "Platinum", "hidden": "Hidden", "test_notification": "Test notification", + "achievement_sound_volume": "Achievement sound volume", + "select_achievement_sound": "Select achievement sound", + "change_achievement_sound": "Change achievement sound", + "remove_achievement_sound": "Remove achievement sound", + "preview_sound": "Preview sound", + "select": "Select", + "preview": "Preview", + "remove": "Remove", + "no_sound_file_selected": "No sound file selected", "notification_preview": "Achievement Notification Preview", "enable_friend_start_game_notifications": "When a friend starts playing a game", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", @@ -693,7 +712,31 @@ "game_added_to_pinned": "Game added to pinned", "karma": "Karma", "karma_count": "karma", - "karma_description": "Earned from positive likes on reviews" + "karma_description": "Earned from positive likes on reviews", + "user_reviews": "Reviews", + "delete_review": "Delete Review", + "loading_reviews": "Loading reviews..." + }, + "library": { + "library": "Library", + "play": "Play", + "download": "Download", + "downloading": "Downloading", + "game": "game", + "games": "games", + "grid_view": "Grid view", + "compact_view": "Compact view", + "large_view": "Large view", + "no_games_title": "Your library is empty", + "no_games_description": "Add games from the catalogue or download them to get started", + "amount_hours": "{{amount}} hours", + "amount_minutes": "{{amount}} minutes", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "This playtime has been manually updated", + "all_games": "All Games", + "recently_played": "Recently Played", + "favorites": "Favorites" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 29d1c702..3994af6d 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catálogo", + "library": "Librería", "downloads": "Descargas", "settings": "Ajustes", "my_library": "Mi Librería", @@ -192,6 +193,7 @@ "download_in_progress": "Descarga en progreso", "download_paused": "Descarga pausada", "last_downloaded_option": "Última opción de descarga", + "new_download_option": "Nuevo", "create_steam_shortcut": "Crear atajo de Steam", "create_shortcut_success": "Atajo creado con éxito", "you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios", @@ -325,6 +327,7 @@ "maybe_later": "Tal vez después", "no_repacks_found": "Sin fuentes encontradas para este juego", "no_reviews_yet": "Sin reseñas aún", + "review_played_for": "Jugado por", "properties": "Propiedades", "rating": "Calificación", "rating_count": "Calificación", @@ -542,6 +545,12 @@ "platinum": "Platino", "hidden": "Oculto", "test_notification": "Probar notificación", + "achievement_sound_volume": "Volumen del sonido de logro", + "select_achievement_sound": "Seleccionar sonido de logro", + "select": "Seleccionar", + "preview": "Vista previa", + "remove": "Remover", + "no_sound_file_selected": "No se seleccionó ningún archivo de sonido", "notification_preview": "Probar notificación de logro", "debrid": "Debrid", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", @@ -682,7 +691,11 @@ "karma_count": "karma", "karma_description": "Conseguido por me gustas positivos en reseñas", "sort_by": "Filtrar por:", - "game_added_to_pinned": "Juego añadido a fijados" + "game_added_to_pinned": "Juego añadido a fijados", + "user_reviews": "Reseñas", + "loading_reviews": "Cargando reseñas...", + "no_reviews": "Sin reseñas aún", + "delete_review": "Eliminar reseña" }, "achievement": { "achievement_unlocked": "Logro desbloqueado", @@ -712,5 +725,26 @@ "hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!", "learn_more": "Descubrir más", "debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus" + }, + "library": { + "library": "Librería", + "play": "Jugar", + "download": "Descargar", + "downloading": "Descargando", + "game": "juego", + "games": "juegos", + "grid_view": "Vista de cuadrícula", + "compact_view": "Vista compacta", + "large_view": "Vista grande", + "no_games_title": "Tu librería está vacía", + "no_games_description": "Agregá juegos del catálogo o descargalos para comenzar", + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente", + "all_games": "Todos los Juegos", + "recently_played": "Jugados Recientemente", + "favorites": "Favoritos" } } diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 8aea356b..c2a59873 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", @@ -81,7 +82,7 @@ "update_decky_plugin": "Decky Plugin Frissítése", "decky_plugin_installed_version": "Decky Plugin (v{{version}})", "install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint", - "install_decky_plugin_message": "Ez letölti és telepíteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?", + "install_decky_plugin_message": "Ez letölti és telepíti a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?", "update_decky_plugin_title": "Hydra Decky Plugin Frissítése", "update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?", "decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve", @@ -92,8 +93,10 @@ }, "header": { "search": "Keresés", + "search_library": "Könyvtár böngészése", "home": "Főoldal", "catalogue": "Katalógus", + "library": "Könyvtár", "downloads": "Letöltések", "search_results": "Keresési találatok", "settings": "Beállítások", @@ -117,7 +120,7 @@ "tags": "Címkék", "publishers": "Kiadók", "download_sources": "Letöltési források", - "result_count": "{{resultCount}} találatok", + "result_count": "{{resultCount}} találat", "filter_count": "{{filterCount}} elérhető", "clear_filters": "{{filterCount}} kiválaszott szűrő törlése" }, @@ -166,11 +169,11 @@ "download_now": "Letöltés", "no_shop_details": "A bolt adatai nem érhetőek el.", "download_options": "Letöltési opciók", - "download_path": "Letöltis hely", + "download_path": "Letöltési hely", "previous_screenshot": "Előző screenshot", "next_screenshot": "Következő screenshot", "screenshot": "Screenshot {{number}}", - "open_screenshot": "Screenshot megnyitása {{number}}", + "open_screenshot": "{{number}} Screenshot megnyitása ", "download_settings": "Letöltési beállítások", "downloader": "Letöltési mód", "select_executable": "Tallózás", @@ -194,6 +197,7 @@ "download_in_progress": "Letöltés folyamatban", "download_paused": "Letöltés szüneteltetve", "last_downloaded_option": "Utoljára letöltött", + "new_download_option": "Új", "create_steam_shortcut": "Steam parancsikon létrehozása", "create_shortcut_success": "A parancsikon létrehozása sikeres", "you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.", @@ -223,6 +227,7 @@ "show_more": "Mutass többet", "show_less": "Mutass kevesebbet", "reviews": "Vélemények", + "review_played_for": "Játszva", "leave_a_review": "Hagyd itt a véleményed", "write_review_placeholder": "Oszd meg gondolatod a játékról...", "sort_newest": "Legújabb", @@ -361,7 +366,10 @@ "show_original": "Eredeti megjelenítése", "show_translation": "Fordítás megjelenítése", "show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})", - "hide_original": "Eredeti elrejtése" + "hide_original": "Eredeti elrejtése", + "review_from_blocked_user": "Letiltott felhasználó véleménye", + "show": "Megjelenítés", + "hide": "Elrejtés" }, "activation": { "title": "Hydra Aktiválása", @@ -488,11 +496,11 @@ "no_email_account": "Még nincs beállított emailed", "account_data_updated_successfully": "Fiókadatok változtatása sikeres", "renew_subscription": "Hydra Cloud Megújítása", - "subscription_expired_at": "Az előfizetésed lejárt, ekkor: {{date}}", + "subscription_expired_at": "Az előfizetésed lejárt: {{date}}", "no_subscription": "Élvezd a Hydrát a lehető legjobb módon", "become_subscriber": "Légy Hydra Cloud tag", "subscription_renew_cancelled": "Automatikus megújítás kikapcsolva", - "subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}", + "subscription_renews_on": "Az előfizetésed megújul: {{date}}", "bill_sent_until": "A következő számlát ezen napon küldjük", "no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.", "editor_tab_code": "Code", @@ -551,10 +559,19 @@ "platinum": "Platina", "hidden": "Rejtett", "test_notification": "Értesítés tesztelése", + "achievement_sound_volume": "Achievement hangereje", + "select_achievement_sound": "Achievement hang kiválasztása", + "change_achievement_sound": "Achievement hang megváltoztatása", + "remove_achievement_sound": "Achievement hang eltávolítása", + "preview_sound": "Hang előnézet", + "select": "Kiválaszt", + "preview": "Előnézet", + "remove": "Eltávolít", + "no_sound_file_selected": "Nincs hangfájl kiválasztva", "notification_preview": "Achievement Értesítés Előnézete", "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot", "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", - "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára" + "hide_to_tray_on_game_start": "Hydra elrejtése játék indításakor a tálcára" }, "notifications": { "download_complete": "Letöltés befejezve", @@ -670,7 +687,7 @@ "report_reason_other": "Egyéb", "profile_reported": "Profil bejelentve", "your_friend_code": "A barát kódod:", - "upload_banner": "Borítókép feltöltés", + "upload_banner": "Borítókép feltöltése", "uploading_banner": "Borítókép feltöltése…", "background_image_updated": "Borítókép frissítve", "stats": "Statisztikák", @@ -689,7 +706,31 @@ "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "karma": "Karma", "karma_count": "karma", - "karma_description": "Pozitív értékelésekkel szerzett pontok" + "karma_description": "Pozitív értékelésekkel szerzett pontok", + "user_reviews": "Vélemények", + "delete_review": "Vélemény Törlése", + "loading_reviews": "Vélemények betöltése..." + }, + "library": { + "library": "Könyvtár", + "play": "Játék", + "download": "Letöltés", + "downloading": "Letöltés..", + "game": "játék", + "games": "játékok", + "grid_view": "Rács nézet", + "compact_view": "Kompakt nézet", + "large_view": "Nagy nézet", + "no_games_title": "A könyvtárad üres", + "no_games_description": "Adj játékokat a katalógusból hozzá vagy töltsd le őket hogy bele vágj", + "amount_hours": "{{amount}} óra", + "amount_minutes": "{{amount}} perc", + "amount_hours_short": "{{amount}}ó", + "amount_minutes_short": "{{amount}}p", + "manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve", + "all_games": "Összes Játék", + "recently_played": "Nemrég Játszva", + "favorites": "Kedvencek" }, "achievement": { "achievement_unlocked": "Achievement feloldva", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index f4d02552..df8069ad 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catálogo", + "library": "Biblioteca", "downloads": "Downloads", "settings": "Ajustes", "my_library": "Biblioteca", @@ -182,6 +183,7 @@ "download_in_progress": "Download em andamento", "download_paused": "Download pausado", "last_downloaded_option": "Última opção baixada", + "new_download_option": "Novo", "create_steam_shortcut": "Criar atalho na Steam", "create_shortcut_success": "Atalho criado com sucesso", "you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações", @@ -318,6 +320,7 @@ "sort_lowest_score": "Menor Nota", "sort_most_voted": "Mais Votadas", "no_reviews_yet": "Ainda não há avaliações", + "review_played_for": "Jogado por", "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!", "rating": "Avaliação", "rating_stats": "Avaliação", @@ -541,6 +544,12 @@ "platinum": "Platina", "hidden": "Oculta", "test_notification": "Testar notificação", + "achievement_sound_volume": "Volume do som de conquista", + "select_achievement_sound": "Selecionar som de conquista", + "select": "Selecionar", + "preview": "Reproduzir", + "remove": "Remover", + "no_sound_file_selected": "Nenhum arquivo de som selecionado", "notification_preview": "Prévia da Notificação de Conquistas", "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", @@ -697,7 +706,11 @@ "karma": "Karma", "karma_count": "karma", "karma_description": "Ganho a partir de curtidas positivas em avaliações", - "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente" + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "user_reviews": "Avaliações", + "loading_reviews": "Carregando avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Excluir avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", @@ -727,5 +740,26 @@ "hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!", "learn_more": "Saiba mais", "debrid_description": "Baixe até 4x mais rápido com Nimbus" + }, + "library": { + "library": "Biblioteca", + "play": "Jogar", + "download": "Baixar", + "downloading": "Baixando", + "game": "jogo", + "games": "jogos", + "grid_view": "Visualização em grade", + "compact_view": "Visualização compacta", + "large_view": "Visualização grande", + "no_games_title": "Sua biblioteca está vazia", + "no_games_description": "Adicione jogos do catálogo ou baixe-os para começar", + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "all_games": "Todos os Jogos", + "recently_played": "Jogados Recentemente", + "favorites": "Favoritos" } } diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index bce47d59..f32554ad 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -184,7 +184,8 @@ "review_from_blocked_user": "Avaliação de utilizador bloqueado", "review_played_for": "Jogaste por", "show": "Mostrar", - "hide": "Ocultar" + "hide": "Ocultar", + "review_played_for": "Jogado por" }, "activation": { "title": "Ativação", @@ -470,7 +471,11 @@ "achievements_unlocked": "Conquistas desbloqueadas", "earned_points": "Pontos ganhos", "show_achievements_on_profile": "Mostre as suas conquistas no perfil", - "show_points_on_profile": "Mostre os seus pontos ganhos no perfil" + "show_points_on_profile": "Mostre os seus pontos ganhos no perfil", + "user_reviews": "Avaliações", + "loading_reviews": "A carregar avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Eliminar avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f5f5f6a1..f3823717 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Каталог", + "library": "Библиотека", "downloads": "Загрузки", "settings": "Настройки", "my_library": "Библиотека", @@ -194,6 +195,7 @@ "download_in_progress": "Идёт загрузка", "download_paused": "Загрузка приостановлена", "last_downloaded_option": "Последний вариант загрузки", + "new_download_option": "Новый", "create_steam_shortcut": "Создать ярлык Steam", "create_shortcut_success": "Ярлык создан", "you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения", @@ -228,6 +230,7 @@ "write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "sort_newest": "Сначала новые", "no_reviews_yet": "Пока нет отзывов", + "review_played_for": "Играли", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "sort_oldest": "Сначала старые", "sort_highest_score": "Высший балл", @@ -555,6 +558,12 @@ "platinum": "Платиновый", "hidden": "Скрытый", "test_notification": "Тестовое уведомление", + "achievement_sound_volume": "Громкость звука достижения", + "select_achievement_sound": "Выбрать звук достижения", + "select": "Выбрать", + "preview": "Предпросмотр", + "remove": "Удалить", + "no_sound_file_selected": "Файл звука не выбран", "notification_preview": "Предварительный просмотр уведомления о достижении", "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", @@ -693,7 +702,11 @@ "game_added_to_pinned": "Игра добавлена в закрепленные", "karma": "Карма", "karma_count": "карма", - "karma_description": "Заработана положительными оценками отзывов" + "karma_description": "Заработана положительными оценками отзывов", + "user_reviews": "Отзывы", + "loading_reviews": "Загрузка отзывов...", + "no_reviews": "Пока нет отзывов", + "delete_review": "Удалить отзыв" }, "achievement": { "achievement_unlocked": "Достижение разблокировано", @@ -723,5 +736,26 @@ "hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!", "learn_more": "Подробнее", "debrid_description": "Скачивайте в 4 раза быстрее с Nimbus" + }, + "library": { + "library": "Библиотека", + "play": "Играть", + "download": "Скачать", + "downloading": "Скачивание", + "game": "игра", + "games": "игры", + "grid_view": "Вид сетки", + "compact_view": "Компактный вид", + "large_view": "Большой вид", + "no_games_title": "Ваша библиотека пуста", + "no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать", + "amount_hours": "{{amount}} часов", + "amount_minutes": "{{amount}} минут", + "amount_hours_short": "{{amount}}ч", + "amount_minutes_short": "{{amount}}м", + "manual_playtime_tooltip": "Время игры было обновлено вручную", + "all_games": "Все игры", + "recently_played": "Недавно сыгранные", + "favorites": "Избранное" } } 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/locales/zh/translation.json b/src/locales/zh/translation.json index 7cdd0c92..bfc353d9 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -27,7 +27,68 @@ "friends": "好友", "favorites": "收藏", "need_help": "需要帮助?", - "playable_button_title": "仅显示现在可以游玩的游戏" + "playable_button_title": "仅显示现在可以游玩的游戏", + "add_custom_game_tooltip": "添加自定义游戏", + "cancel": "取消", + "confirm": "确认", + "custom_game_modal": "添加自定义游戏", + "custom_game_modal_add": "添加游戏", + "custom_game_modal_adding": "正在添加游戏...", + "custom_game_modal_browse": "浏览", + "custom_game_modal_cancel": "取消", + "custom_game_modal_description": "通过选择可执行文件将自定义游戏添加到您的库中", + "custom_game_modal_enter_title": "输入标题", + "custom_game_modal_executable": "可执行文件", + "custom_game_modal_executable_path": "可执行文件路径", + "custom_game_modal_failed": "添加自定义游戏失败", + "custom_game_modal_select_executable": "选择可执行文件", + "custom_game_modal_success": "自定义游戏添加成功", + "custom_game_modal_title": "标题", + "decky_plugin_installation_error": "安装 Decky 插件出错: {{error}}", + "decky_plugin_installation_failed": "Decky 插件安装失败: {{error}}", + "decky_plugin_installed": "Decky 插件 v{{version}} 安装成功", + "decky_plugin_installed_version": "Decky 插件 (v{{version}})", + "edit_game_modal": "自定义资源", + "edit_game_modal_assets": "资源", + "edit_game_modal_browse": "浏览", + "edit_game_modal_cancel": "取消", + "edit_game_modal_description": "自定义游戏资源和详情", + "edit_game_modal_drop_hero_image_here": "拖放主图像到此处", + "edit_game_modal_drop_icon_image_here": "拖放图标到此处", + "edit_game_modal_drop_logo_image_here": "拖放Logo到此处", + "edit_game_modal_drop_to_replace_hero": "拖放以替换主图像", + "edit_game_modal_drop_to_replace_icon": "拖放以替换图标", + "edit_game_modal_drop_to_replace_logo": "拖放以替换Logo", + "edit_game_modal_enter_title": "输入标题", + "edit_game_modal_failed": "资源更新失败", + "edit_game_modal_fill_required": "请填写所有必填项", + "edit_game_modal_hero": "库主图", + "edit_game_modal_hero_preview": "库主图预览", + "edit_game_modal_hero_resolution": "推荐分辨率: 1920x620px", + "edit_game_modal_icon": "图标", + "edit_game_modal_icon_preview": "图标预览", + "edit_game_modal_icon_resolution": "推荐分辨率: 256x256px", + "edit_game_modal_image": "图片", + "edit_game_modal_image_filter": "图片", + "edit_game_modal_image_preview": "图片预览", + "edit_game_modal_logo": "Logo", + "edit_game_modal_logo_preview": "Logo预览", + "edit_game_modal_logo_resolution": "推荐分辨率: 640x360px", + "edit_game_modal_select_hero": "选择库主图", + "edit_game_modal_select_icon": "选择图标", + "edit_game_modal_select_image": "选择图片", + "edit_game_modal_select_logo": "选择Logo", + "edit_game_modal_success": "资源更新成功", + "edit_game_modal_title": "标题", + "edit_game_modal_update": "更新", + "edit_game_modal_updating": "正在更新...", + "install_decky_plugin": "安装 Decky 插件", + "install_decky_plugin_message": "这将下载并安装 Hydra 的 Decky Loader 插件。可能需要提升权限。继续吗?", + "install_decky_plugin_title": "安装 Hydra Decky 插件", + "show_playable_only_tooltip": "仅显示可游玩", + "update_decky_plugin": "更新 Decky 插件", + "update_decky_plugin_message": "有新版本的 Hydra Decky 插件可用。现在要更新吗?", + "update_decky_plugin_title": "更新 Hydra Decky 插件" }, "header": { "search": "搜索游戏", @@ -218,7 +279,93 @@ "reset_achievements_title": "您确定吗?", "save_changes": "保存更改", "unfreeze_backup": "取消固定", - "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改" + "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改", + "add_to_favorites": "添加到收藏", + "already_in_library": "已在游戏库中", + "audio": "音频", + "backup_failed": "备份失败", + "be_first_to_review": "成为第一个分享游戏感受的人!", + "caption": "标题", + "create_shortcut_simple": "创建快捷方式", + "currency_country": "zh", + "currency_symbol": "¥", + "delete_review": "删除评价", + "delete_review_modal_cancel_button": "取消", + "delete_review_modal_delete_button": "删除", + "delete_review_modal_description": "此操作无法撤销。", + "delete_review_modal_title": "确定要删除您的评价吗?", + "edit_game_modal_button": "自定义游戏资源", + "failed_remove_files": "文件删除失败", + "failed_remove_from_library": "移出游戏库失败", + "failed_update_favorites": "收藏更新失败", + "files_removed_success": "文件已成功删除", + "filter_by_source": "按来源筛选", + "game_added_to_pinned": "游戏已添加到置顶", + "game_details": "游戏详情", + "game_removed_from_library": "游戏已从库中移除", + "game_removed_from_pinned": "游戏已从置顶移除", + "hide": "隐藏", + "hide_original": "隐藏原文", + "historical_keyshop": "历史密钥商店", + "historical_retail": "历史零售", + "keyshop_price": "密钥商店价格", + "language": "语言", + "leave_a_review": "留下评价", + "load_more_reviews": "加载更多评价", + "loading_more_reviews": "正在加载更多评价...", + "loading_reviews": "正在加载评价...", + "manual_playtime_tooltip": "该游戏时长已手动更新", + "manual_playtime_warning": "您的游戏时长将被标记为手动更新,且无法撤销。", + "maybe_later": "以后再说", + "no_prices_found": "未找到价格信息", + "no_repacks_found": "未找到该游戏的下载来源", + "no_reviews_yet": "暂无评价", + "prices": "价格", + "properties": "属性", + "rating": "评分", + "rating_count": "评分数", + "rating_negative": "差评", + "rating_neutral": "中性", + "rating_positive": "好评", + "rating_stats": "评分统计", + "rating_very_negative": "极差", + "rating_very_positive": "极好", + "remove_from_favorites": "移出收藏", + "remove_review": "移除评价", + "retail_price": "零售价格", + "review_cannot_be_empty": "评价内容不能为空。", + "review_deleted_successfully": "评价已成功删除。", + "review_deletion_failed": "评价删除失败,请重试。", + "review_from_blocked_user": "来自被屏蔽用户的评价", + "review_played_for": "已游玩", + "review_submission_failed": "评价提交失败,请重试。", + "review_submitted_successfully": "评价提交成功!", + "reviews": "评价", + "show": "显示", + "show_less": "收起", + "show_more": "展开", + "show_original": "显示原文", + "show_original_translated_from": "显示原文(由{{language}}翻译)", + "show_translation": "显示翻译", + "sort_highest_score": "最高分", + "sort_lowest_score": "最低分", + "sort_most_voted": "最多投票", + "sort_newest": "最新", + "sort_oldest": "最旧", + "submit_review": "提交", + "submitting": "正在提交...", + "update_game_playtime": "更新游戏时长", + "update_playtime": "更新时长", + "update_playtime_description": "手动更新 {{game}} 的游玩时长", + "update_playtime_error": "游戏时长更新失败", + "update_playtime_success": "游戏时长已成功更新", + "update_playtime_title": "更新游戏时长", + "view_all_prices": "点击查看所有价格", + "vote_failed": "投票失败,请重试。", + "would_you_recommend_this_game": "您想为此游戏留下评价吗?", + "write_review_placeholder": "分享您对本游戏的看法...", + "yes": "是", + "you_seemed_to_enjoy_this_game": "您似乎很喜欢这款游戏" }, "activation": { "title": "激活 Hydra", @@ -394,7 +541,24 @@ "update_email": "更新邮箱", "update_password": "更新密码", "variation": "变体", - "web_store": "网络商店" + "web_store": "网络商店", + "adding": "添加中…", + "autoplay_trailers_on_game_page": "在游戏页面自动播放预告片", + "debrid": "Debrid下载服务", + "debrid_description": "Debrid服务是一种高级不限速下载器,可让您以最快的网速下载托管在各类网盘上的文件,仅受您的网络速度限制。", + "download_source_already_exists": "该下载源URL已存在。", + "download_source_failed": "出错", + "download_source_matched": "已更新", + "download_source_matching": "正在更新", + "download_source_no_information": "暂无信息", + "download_source_pending_matching": "即将更新", + "download_sources_synced_successfully": "所有下载源已同步", + "enable_steam_achievements": "启用Steam成就搜索", + "failed_add_download_source": "添加下载源失败,请重试。", + "hide_to_tray_on_game_start": "启动游戏时隐藏到托盘", + "hydra_cloud": "Hydra Cloud", + "importing": "导入中…", + "removed_all_download_sources": "已移除所有下载源" }, "notifications": { "download_complete": "下载完成", @@ -421,7 +585,8 @@ "game_card": { "no_downloads": "无可用下载选项", "available_one": "可用", - "available_other": "可用" + "available_other": "可用", + "calculating": "正在计算" }, "binary_not_found_modal": { "title": "程序未安装", @@ -515,7 +680,23 @@ "show_achievements_on_profile": "在您的个人资料上显示成就", "show_points_on_profile": "在您的个人资料上显示获得的积分", "stats": "统计", - "top_percentile": "前 {{percentile}}%" + "top_percentile": "前 {{percentile}}%", + "achievements_earned": "已获得成就", + "amount_hours_short": "{{amount}}小时", + "amount_minutes_short": "{{amount}}分钟", + "delete_review": "删除评价", + "game_added_to_pinned": "游戏已添加到置顶", + "game_removed_from_pinned": "游戏已从置顶移除", + "karma": "业力", + "karma_count": "业力值", + "karma_description": "通过评论获得的点赞", + "loading_reviews": "正在加载评价...", + "manual_playtime_tooltip": "该游戏时长已手动更新", + "pinned": "已置顶", + "played_recently": "最近游玩", + "playtime": "游戏时长", + "sort_by": "排序方式:", + "user_reviews": "用户评价" }, "achievement": { "achievement_unlocked": "成就已解锁", diff --git a/src/main/constants.ts b/src/main/constants.ts index 82b99b2a..3c4c10e5 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); +export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes"); + export const MAIN_LOOP_INTERVAL = 2000; +export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15; + export const DECKY_PLUGINS_LOCATION = path.join( SystemPath.getPath("home"), "homebrew", diff --git a/src/main/events/download-sources/get-download-sources-check-baseline.ts b/src/main/events/download-sources/get-download-sources-check-baseline.ts new file mode 100644 index 00000000..2f3ab377 --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-check-baseline.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesCheckBaseline } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesCheckBaselineHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesCheckBaseline(); +}; + +registerEvent( + "getDownloadSourcesCheckBaseline", + getDownloadSourcesCheckBaselineHandler +); diff --git a/src/main/events/download-sources/get-download-sources-since-value.ts b/src/main/events/download-sources/get-download-sources-since-value.ts new file mode 100644 index 00000000..cbd06faf --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-since-value.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesSinceValue } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesSinceValueHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesSinceValue(); +}; + +registerEvent( + "getDownloadSourcesSinceValue", + getDownloadSourcesSinceValueHandler +); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 0ab5499a..2720d3ce 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -18,7 +18,9 @@ import "./library/close-game"; import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; import "./library/get-library"; +import "./library/refresh-library-assets"; import "./library/extract-game-download"; +import "./library/clear-new-download-options"; import "./library/open-game"; import "./library/open-game-executable-path"; import "./library/open-game-installer"; @@ -64,6 +66,8 @@ import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-torbox"; import "./download-sources/add-download-source"; import "./download-sources/sync-download-sources"; +import "./download-sources/get-download-sources-check-baseline"; +import "./download-sources/get-download-sources-since-value"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; @@ -91,6 +95,11 @@ import "./themes/get-custom-theme-by-id"; import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; +import "./themes/copy-theme-achievement-sound"; +import "./themes/remove-theme-achievement-sound"; +import "./themes/get-theme-sound-path"; +import "./themes/get-theme-sound-data-url"; +import "./themes/import-theme-sound-from-store"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; diff --git a/src/main/events/library/clear-new-download-options.ts b/src/main/events/library/clear-new-download-options.ts new file mode 100644 index 00000000..55ebfd8f --- /dev/null +++ b/src/main/events/library/clear-new-download-options.ts @@ -0,0 +1,27 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { logger } from "@main/services"; +import type { GameShop } from "@types"; + +const clearNewDownloadOptions = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string +) => { + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + if (!game) return; + + try { + await gamesSublevel.put(gameKey, { + ...game, + newDownloadOptionsCount: undefined, + }); + logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`); + } catch (error) { + logger.error(`Failed to clear newDownloadOptionsCount: ${error}`); + } +}; + +registerEvent("clearNewDownloadOptions", clearNewDownloadOptions); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 6314f83d..9fb3416b 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -2,6 +2,7 @@ import type { LibraryGame } from "@types"; import { registerEvent } from "../register-event"; import { downloadsSublevel, + gameAchievementsSublevel, gamesShopAssetsSublevel, gamesSublevel, } from "@main/level"; @@ -18,15 +19,28 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + let unlockedAchievementCount = game.unlockedAchievementCount ?? 0; + + if (!game.unlockedAchievementCount) { + const achievements = await gameAchievementsSublevel.get(key); + + unlockedAchievementCount = + achievements?.unlockedAchievements.length ?? 0; + } + return { id: key, ...game, download: download ?? null, + unlockedAchievementCount, + achievementCount: game.achievementCount ?? 0, + // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, - // Ensure compatibility with LibraryGame type - libraryHeroImageUrl: - game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, - } as LibraryGame; + // Preserve custom image URLs from game if they exist + customIconUrl: game.customIconUrl, + customLogoImageUrl: game.customLogoImageUrl, + customHeroImageUrl: game.customHeroImageUrl, + }; }) ); }); diff --git a/src/main/events/library/refresh-library-assets.ts b/src/main/events/library/refresh-library-assets.ts new file mode 100644 index 00000000..d8578f1b --- /dev/null +++ b/src/main/events/library/refresh-library-assets.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { mergeWithRemoteGames } from "@main/services"; + +const refreshLibraryAssets = async () => { + await mergeWithRemoteGames(); +}; + +registerEvent("refreshLibraryAssets", refreshLibraryAssets); 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/themes/copy-theme-achievement-sound.ts b/src/main/events/themes/copy-theme-achievement-sound.ts new file mode 100644 index 00000000..a52e6269 --- /dev/null +++ b/src/main/events/themes/copy-theme-achievement-sound.ts @@ -0,0 +1,40 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; + +const copyThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + sourcePath: string +): Promise => { + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error("Source file does not exist"); + } + + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId, theme.name); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const fileExtension = path.extname(sourcePath); + const destinationPath = path.join(themeDir, `achievement${fileExtension}`); + + await fs.promises.copyFile(sourcePath, destinationPath); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + originalSoundPath: sourcePath, + updatedAt: new Date(), + }); +}; + +registerEvent("copyThemeAchievementSound", copyThemeAchievementSound); diff --git a/src/main/events/themes/get-theme-sound-data-url.ts b/src/main/events/themes/get-theme-sound-data-url.ts new file mode 100644 index 00000000..a93538dd --- /dev/null +++ b/src/main/events/themes/get-theme-sound-data-url.ts @@ -0,0 +1,40 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import fs from "node:fs"; +import path from "node:path"; +import { logger } from "@main/services"; + +const getThemeSoundDataUrl = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + try { + const theme = await themesSublevel.get(themeId); + const soundPath = getThemeSoundPath(themeId, theme?.name); + + if (!soundPath || !fs.existsSync(soundPath)) { + return null; + } + + const buffer = await fs.promises.readFile(soundPath); + const ext = path.extname(soundPath).toLowerCase().slice(1); + + const mimeTypes: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + m4a: "audio/mp4", + }; + + const mimeType = mimeTypes[ext] || "audio/mpeg"; + const base64 = buffer.toString("base64"); + + return `data:${mimeType};base64,${base64}`; + } catch (error) { + logger.error("Failed to get theme sound data URL", error); + return null; + } +}; + +registerEvent("getThemeSoundDataUrl", getThemeSoundDataUrl); diff --git a/src/main/events/themes/get-theme-sound-path.ts b/src/main/events/themes/get-theme-sound-path.ts new file mode 100644 index 00000000..11658c6a --- /dev/null +++ b/src/main/events/themes/get-theme-sound-path.ts @@ -0,0 +1,13 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; + +const getThemeSoundPathEvent = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + const theme = await themesSublevel.get(themeId); + return getThemeSoundPath(themeId, theme?.name); +}; + +registerEvent("getThemeSoundPath", getThemeSoundPathEvent); diff --git a/src/main/events/themes/import-theme-sound-from-store.ts b/src/main/events/themes/import-theme-sound-from-store.ts new file mode 100644 index 00000000..66da6cb3 --- /dev/null +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -0,0 +1,60 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import axios from "axios"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import { logger } from "@main/services"; + +const importThemeSoundFromStore = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + themeName: string, + storeUrl: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + try { + const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`; + + const response = await axios.get(soundUrl, { + responseType: "arraybuffer", + timeout: 10000, + }); + + const themeDir = getThemePath(themeId, theme.name); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const destinationPath = path.join(themeDir, `achievement.${format}`); + await fs.promises.writeFile(destinationPath, response.data); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + updatedAt: new Date(), + }); + + logger.log(`Successfully imported sound for theme ${themeName}`); + return; + } catch (error) { + logger.error( + `Failed to import ${format} sound for theme ${themeName}`, + error + ); + continue; + } + } + + logger.log(`No sound file found for theme ${themeName} in store`); +}; + +registerEvent("importThemeSoundFromStore", importThemeSoundFromStore); diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts new file mode 100644 index 00000000..a8603426 --- /dev/null +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -0,0 +1,48 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import { THEMES_PATH } from "@main/constants"; +import path from "node:path"; + +const removeThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId, theme.name); + const legacyThemeDir = path.join(THEMES_PATH, themeId); + + const removeFromDir = async (dir: string) => { + if (!fs.existsSync(dir)) { + return; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + await fs.promises.unlink(soundPath); + } + } + }; + + await removeFromDir(themeDir); + if (themeDir !== legacyThemeDir) { + await removeFromDir(legacyThemeDir); + } + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: false, + originalSoundPath: undefined, + updatedAt: new Date(), + }); +}; + +registerEvent("removeThemeAchievementSound", removeThemeAchievementSound); diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 2da49a1c..664dbd78 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -2,6 +2,8 @@ import axios from "axios"; import { JSDOM } from "jsdom"; import UserAgent from "user-agents"; import path from "node:path"; +import fs from "node:fs"; +import { THEMES_PATH } from "@main/constants"; export const getFileBuffer = async (url: string) => fetch(url, { method: "GET" }).then((response) => @@ -31,9 +33,64 @@ export const isPortableVersion = () => { }; export const normalizePath = (str: string) => - path.posix.normalize(str).replace(/\\/g, "/"); + path.posix.normalize(str).replaceAll("\\", "/"); export const addTrailingSlash = (str: string) => str.endsWith("/") ? str : `${str}/`; +const sanitizeFolderName = (name: string): string => { + return name + .toLowerCase() + .replaceAll(/[^a-z0-9-_\s]/g, "") + .replaceAll(/\s+/g, "-") + .replaceAll(/-+/g, "-") + .replaceAll(/(^-|-$)/g, ""); +}; + +export const getThemePath = (themeId: string, themeName?: string): string => { + if (themeName) { + const sanitizedName = sanitizeFolderName(themeName); + if (sanitizedName) { + return path.join(THEMES_PATH, sanitizedName); + } + } + return path.join(THEMES_PATH, themeId); +}; + +export const getThemeSoundPath = ( + themeId: string, + themeName?: string +): string | null => { + const themeDir = getThemePath(themeId, themeName); + const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null; + + const checkDir = (dir: string): string | null => { + if (!fs.existsSync(dir)) { + return null; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + return soundPath; + } + } + + return null; + }; + + const soundPath = checkDir(themeDir); + if (soundPath) { + return soundPath; + } + + if (legacyThemeDir) { + return checkDir(legacyThemeDir); + } + + return null; +}; + export * from "./reg-parser"; diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts new file mode 100644 index 00000000..4b60b962 --- /dev/null +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -0,0 +1,59 @@ +import { levelKeys } from "./keys"; +import { db } from "../level"; +import { logger } from "@main/services"; + +// Gets when we last started the app (for next API call's 'since') +export const getDownloadSourcesCheckBaseline = async (): Promise< + string | null +> => { + try { + const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); + return timestamp; + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources check baseline not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources check baseline", + error + ); + } + return null; + } +}; + +// Updates to current time (when app starts) +export const updateDownloadSourcesCheckBaseline = async ( + timestamp: string +): Promise => { + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp); +}; + +// Gets the 'since' value the API used in the last check (for modal comparison) +export const getDownloadSourcesSinceValue = async (): Promise< + string | null +> => { + try { + const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); + return timestamp; + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources since value not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources since value", + error + ); + } + return null; + } +}; + +// Saves the 'since' value we used in the API call (for modal to compare against) +export const updateDownloadSourcesSinceValue = async ( + timestamp: string +): Promise => { + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); +}; diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 3619ae26..4575bbc4 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -7,3 +7,4 @@ export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; export * from "./download-sources"; +export * from "./downloadSourcesCheckTimestamp"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index a28690b2..89c33f8d 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,4 +18,6 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", + downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app + downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison) }; diff --git a/src/main/main.ts b/src/main/main.ts index ffb8f8a9..1cadcebd 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,6 +16,8 @@ import { Ludusavi, Lock, DeckyPlugin, + DownloadSourcesChecker, + WSClient, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -56,7 +58,10 @@ export const loadState = async () => { const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); - // WSClient.connect(); + + // Check for new download options on startup + DownloadSourcesChecker.checkForChanges(); + WSClient.connect(); }); const downloads = await downloadsSublevel diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts new file mode 100644 index 00000000..928e3d52 --- /dev/null +++ b/src/main/services/download-sources-checker.ts @@ -0,0 +1,188 @@ +import { HydraApi } from "./hydra-api"; +import { + gamesSublevel, + getDownloadSourcesCheckBaseline, + updateDownloadSourcesCheckBaseline, + updateDownloadSourcesSinceValue, + downloadSourcesSublevel, +} from "@main/level"; +import { logger } from "./logger"; +import { WindowManager } from "./window-manager"; +import type { Game } from "@types"; + +interface DownloadSourcesChangeResponse { + shop: string; + objectId: string; + newDownloadOptionsCount: number; + downloadSourceIds: string[]; +} + +export class DownloadSourcesChecker { + private static async clearStaleBadges( + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + const previouslyFlaggedGames = nonCustomGames.filter( + (game: Game) => + game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 + ); + + const clearedPayload: { gameId: string; count: number }[] = []; + if (previouslyFlaggedGames.length > 0) { + logger.info( + `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` + ); + for (const game of previouslyFlaggedGames) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: undefined, + }); + clearedPayload.push({ + gameId: `${game.shop}:${game.objectId}`, + count: 0, + }); + } + } + + return clearedPayload; + } + + private static async processApiResponse( + response: unknown, + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + if (!response || !Array.isArray(response)) { + return []; + } + + const gamesWithNewOptions: { gameId: string; count: number }[] = []; + + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find( + (g) => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + ); + + if (game) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount, + }); + } + } + } + + return gamesWithNewOptions; + } + + private static sendNewDownloadOptionsEvent( + clearedPayload: { gameId: string; count: number }[], + gamesWithNewOptions: { gameId: string; count: number }[] + ): void { + const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; + if (eventPayload.length > 0 && WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send( + "on-new-download-options", + eventPayload + ); + } + + logger.info( + `Found new download options for ${gamesWithNewOptions.length} games` + ); + } + + static async checkForChanges(): Promise { + logger.info("DownloadSourcesChecker.checkForChanges() called"); + + try { + // Get all installed games (excluding custom games) + const installedGames = await gamesSublevel.values().all(); + const nonCustomGames = installedGames.filter( + (game: Game) => game.shop !== "custom" + ); + logger.info( + `Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games` + ); + + if (nonCustomGames.length === 0) { + logger.info( + "No non-custom games found, skipping download sources check" + ); + return; + } + + const downloadSources = await downloadSourcesSublevel.values().all(); + const downloadSourceIds = downloadSources.map((source) => source.id); + logger.info( + `Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(", ")}` + ); + + if (downloadSourceIds.length === 0) { + logger.info( + "No download sources found, skipping download sources check" + ); + return; + } + + const previousBaseline = await getDownloadSourcesCheckBaseline(); + const since = + previousBaseline || + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + logger.info(`Using since: ${since} (from last app start)`); + + const clearedPayload = await this.clearStaleBadges(nonCustomGames); + + const games = nonCustomGames.map((game: Game) => ({ + shop: game.shop, + objectId: game.objectId, + })); + + logger.info( + `Checking download sources changes for ${games.length} non-custom games since ${since}` + ); + logger.info( + `Making API call to HydraApi.checkDownloadSourcesChanges with:`, + { + downloadSourceIds, + gamesCount: games.length, + since, + } + ); + + const response = await HydraApi.checkDownloadSourcesChanges( + downloadSourceIds, + games, + since + ); + + logger.info("API call completed, response:", response); + + await updateDownloadSourcesSinceValue(since); + logger.info(`Saved 'since' value: ${since} (for modal comparison)`); + + const now = new Date().toISOString(); + await updateDownloadSourcesCheckBaseline(now); + logger.info( + `Updated baseline to: ${now} (will be 'since' on next app start)` + ); + + const gamesWithNewOptions = await this.processApiResponse( + response, + nonCustomGames + ); + + this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); + + logger.info("Download sources check completed successfully"); + } catch (error) { + logger.error("Failed to check download sources changes:", error); + } + } +} diff --git a/src/main/services/hosters/datanodes.ts b/src/main/services/hosters/datanodes.ts index 29708322..4cfb5242 100644 --- a/src/main/services/hosters/datanodes.ts +++ b/src/main/services/hosters/datanodes.ts @@ -1,6 +1,7 @@ import axios, { AxiosResponse } from "axios"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; +import { logger } from "@main/services"; export class DatanodesApi { private static readonly jar = new CookieJar(); @@ -20,51 +21,42 @@ export class DatanodesApi { await this.jar.setCookie("lang=english;", "https://datanodes.to"); - const payload = new URLSearchParams({ - op: "download2", - id: fileCode, - method_free: "Free Download >>", - dl: "1", - }); + const formData = new FormData(); + formData.append("op", "download2"); + formData.append("id", fileCode); + formData.append("rand", ""); + formData.append("referer", "https://datanodes.to/download"); + formData.append("method_free", "Free Download >>"); + formData.append("method_premium", ""); + formData.append("__dl", "1"); const response: AxiosResponse = await this.session.post( "https://datanodes.to/download", - payload, + formData, { headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0", + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + priority: "u=1, i", + "sec-ch-ua": + '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", Referer: "https://datanodes.to/download", - Origin: "https://datanodes.to", - "Content-Type": "application/x-www-form-urlencoded", }, - maxRedirects: 0, - validateStatus: (status: number) => status === 302 || status < 400, } ); - if (response.status === 302) { - return response.headers["location"]; - } - if (typeof response.data === "object" && response.data.url) { return decodeURIComponent(response.data.url); } - const htmlContent = String(response.data); - if (!htmlContent) { - throw new Error("Empty response received"); - } - - const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/; - const downloadLinkMatch = downloadLinkRegex.exec(htmlContent); - if (downloadLinkMatch) { - return downloadLinkMatch[1]; - } - throw new Error("Failed to get the download link"); } catch (error) { - console.error("Error fetching download URL:", error); + logger.error("Error fetching download URL:", error); throw error; } } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 12090df3..7846571e 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data"; import { db } from "@main/level"; import { levelKeys } from "@main/level/sublevels"; import type { Auth, User } from "@types"; +import { WSClient } from "./ws"; export interface HydraApiOptions { needsAuth?: boolean; @@ -29,7 +30,7 @@ export class HydraApi { private static instance: AxiosInstance; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes - private static readonly ADD_LOG_INTERCEPTOR = true; + private static readonly ADD_LOG_INTERCEPTOR = false; private static secondsToMilliseconds(seconds: number) { return seconds * 1000; @@ -103,8 +104,8 @@ export class HydraApi { await clearGamesRemoteIds(); uploadGamesBatch(); - // WSClient.close(); - // WSClient.connect(); + WSClient.close(); + WSClient.connect(); const { syncDownloadSourcesFromApi } = await import("./user"); syncDownloadSourcesFromApi(); @@ -399,4 +400,45 @@ export class HydraApi { .then((response) => response.data) .catch(this.handleUnauthorizedError); } + + static async checkDownloadSourcesChanges( + downloadSourceIds: string[], + games: Array<{ shop: string; objectId: string }>, + since: string + ) { + logger.info("HydraApi.checkDownloadSourcesChanges called with:", { + downloadSourceIds, + gamesCount: games.length, + since, + isLoggedIn: this.isLoggedIn(), + }); + + try { + const result = await this.post< + Array<{ + shop: string; + objectId: string; + newDownloadOptionsCount: number; + downloadSourceIds: string[]; + }> + >( + "/download-sources/changes", + { + downloadSourceIds, + games, + since, + }, + { needsAuth: true } + ); + + logger.info( + "HydraApi.checkDownloadSourcesChanges completed successfully:", + result + ); + return result; + } catch (error) { + logger.error("HydraApi.checkDownloadSourcesChanges failed:", error); + throw error; + } + } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index da4e6848..a3891dc6 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -19,3 +19,4 @@ export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; export * from "./user"; +export * from "./download-sources-checker"; 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 c00e4961..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,18 +59,27 @@ export const mergeWithRemoteGames = async () => { isDeleted: false, favorite: game.isFavorite ?? false, isPinned: game.isPinned ?? false, + achievementCount: game.achievementCount, + unlockedAchievementCount: game.unlockedAchievementCount, }); } const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); + // Construct coverImageUrl if not provided by backend (Steam games use predictable pattern) + const coverImageUrl = + game.coverImageUrl || + (game.shop === "steam" + ? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg` + : null); + await gamesShopAssetsSublevel.put(gameKey, { updatedAt: Date.now(), ...localGameShopAsset, shop: game.shop, objectId: game.objectId, title: localGame?.title || game.title, // Preserve local title if it exists - coverImageUrl: game.coverImageUrl, + coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, logoImageUrl: game.logoImageUrl, diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index d28c3cd7..a925e7c7 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -11,9 +11,17 @@ import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; import { WindowManager } from "../window-manager"; import type { Game, UserPreferences, UserProfile } from "@types"; -import { db, levelKeys } from "@main/level"; +import { db, levelKeys, themesSublevel } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; +import { getThemeSoundPath } from "@main/helpers"; +import { processProfileImage } from "@main/events/profile/process-profile-image"; + +const getStaticImage = async (path: string) => { + return processProfileImage(path, "jpg") + .then((response) => response.imagePath) + .catch(() => path); +}; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -30,8 +38,9 @@ async function downloadImage(url: string | null) { response.data.pipe(writer); return new Promise((resolve) => { - writer.on("finish", () => { - resolve(outputPath); + writer.on("finish", async () => { + const staticImagePath = await getStaticImage(outputPath); + resolve(staticImagePath); }); writer.on("error", () => { logger.error("Failed to download image", { url }); @@ -40,6 +49,27 @@ async function downloadImage(url: string | null) { }); } +async function getAchievementSoundPath(): Promise { + try { + const allThemes = await themesSublevel.values().all(); + const activeTheme = allThemes.find((theme) => theme.isActive); + + if (activeTheme?.hasCustomSound) { + const themeSoundPath = getThemeSoundPath( + activeTheme.id, + activeTheme.name + ); + if (themeSoundPath) { + return themeSoundPath; + } + } + } catch (error) { + logger.error("Failed to get theme sound path", error); + } + + return achievementSoundPath; +} + export const publishDownloadCompleteNotification = async (game: Game) => { const userPreferences = await db.get( levelKeys.userPreferences, @@ -145,7 +175,8 @@ export const publishCombinedNewAchievementNotification = async ( if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; @@ -205,6 +236,7 @@ export const publishNewAchievementNotification = async (info: { if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; diff --git a/src/main/services/steam-250.ts b/src/main/services/steam-250.ts index 5652b0d3..826e528f 100644 --- a/src/main/services/steam-250.ts +++ b/src/main/services/steam-250.ts @@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => { if (!steamGameUrl) return null; return { - title: $title.textContent, + title: $title.getAttribute("data-title") || "", objectId: steamGameUrl.split("/").pop(), } as Steam250Game; }) 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/preload/index.ts b/src/preload/index.ts index f89ec4db..a2965532 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), + getDownloadSourcesCheckBaseline: () => + ipcRenderer.invoke("getDownloadSourcesCheckBaseline"), + getDownloadSourcesSinceValue: () => + ipcRenderer.invoke("getDownloadSourcesSinceValue"), /* Library */ toggleAutomaticCloudSync: ( @@ -179,6 +183,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("addGameToFavorites", shop, objectId), removeGameFromFavorites: (shop: GameShop, objectId: string) => ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), + clearNewDownloadOptions: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId), toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), updateLaunchOptions: ( @@ -196,6 +202,7 @@ contextBridge.exposeInMainWorld("electron", { verifyExecutablePathInUse: (executablePath: string) => ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), + refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), openGameInstaller: (shop: GameShop, objectId: string) => ipcRenderer.invoke("openGameInstaller", shop, objectId), openGameInstallerPath: (shop: GameShop, objectId: string) => @@ -570,6 +577,25 @@ contextBridge.exposeInMainWorld("electron", { getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"), toggleCustomTheme: (themeId: string, isActive: boolean) => ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), + copyThemeAchievementSound: (themeId: string, sourcePath: string) => + ipcRenderer.invoke("copyThemeAchievementSound", themeId, sourcePath), + removeThemeAchievementSound: (themeId: string) => + ipcRenderer.invoke("removeThemeAchievementSound", themeId), + getThemeSoundPath: (themeId: string) => + ipcRenderer.invoke("getThemeSoundPath", themeId), + getThemeSoundDataUrl: (themeId: string) => + ipcRenderer.invoke("getThemeSoundDataUrl", themeId), + importThemeSoundFromStore: ( + themeId: string, + themeName: string, + storeUrl: string + ) => + ipcRenderer.invoke( + "importThemeSoundFromStore", + themeId, + themeName, + storeUrl + ), /* Editor */ openEditorWindow: (themeId: string) => @@ -580,6 +606,17 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-custom-theme-updated", listener); }, + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + gamesWithNewOptions: { gameId: string; count: number }[] + ) => cb(gamesWithNewOptions); + ipcRenderer.on("on-new-download-options", listener); + return () => + ipcRenderer.removeListener("on-new-download-options", listener); + }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), }); diff --git a/src/renderer/src/app.scss b/src/renderer/src/app.scss index 4c5374e8..0d992553 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -90,6 +90,7 @@ img { progress[value] { -webkit-appearance: none; + appearance: none; } .container { diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 168a4435..391e9c03 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef } from "react"; -import achievementSound from "@renderer/assets/audio/achievement.wav"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -10,6 +9,7 @@ import { useToast, useUserDetails, } from "@renderer/hooks"; +import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { @@ -25,7 +25,12 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; -import { injectCustomCss, removeCustomCss } from "./helpers"; +import { + injectCustomCss, + removeCustomCss, + getAchievementSoundUrl, + getAchievementSoundVolume, +} from "./helpers"; import "./app.scss"; export interface AppProps { @@ -36,6 +41,9 @@ export function App() { const contentRef = useRef(null); const { updateLibrary, library } = useLibrary(); + // Listen for new download options updates + useDownloadOptionsListener(); + const { t } = useTranslation("app"); const { clearDownload, setLastPacket } = useDownload(); @@ -216,9 +224,11 @@ export function App() { return () => unsubscribe(); }, [loadAndApplyTheme]); - const playAudio = useCallback(() => { - const audio = new Audio(achievementSound); - audio.volume = 0.2; + const playAudio = useCallback(async () => { + const soundUrl = await getAchievementSoundUrl(); + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; audio.play(); }, []); @@ -279,7 +289,11 @@ export function App() {
-
+
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} { if (isGameRunning) { void handleCloseGame(); - } else { + } else if (canPlay) { void handlePlayGame(); + } else { + handleOpenDownloadOptions(); } }, disabled: isDeleting, diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 6f97729f..5f2c1d1d 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -3,22 +3,30 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; -import { useAppDispatch, useAppSelector } from "@renderer/hooks"; +import { + useAppDispatch, + useAppSelector, + useSearchHistory, + useSearchSuggestions, +} from "@renderer/hooks"; import "./header.scss"; import { AutoUpdateSubHeader } from "./auto-update-sub-header"; -import { setFilters } from "@renderer/features"; +import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import cn from "classnames"; +import { SearchDropdown } from "@renderer/components"; const pathTitle: Record = { "/": "home", "/catalogue": "catalogue", + "/library": "library", "/downloads": "downloads", "/settings": "settings", }; export function Header() { const inputRef = useRef(null); + const searchContainerRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); @@ -27,32 +35,95 @@ export function Header() { (state) => state.window ); - const searchValue = useAppSelector( + const catalogueSearchValue = useAppSelector( (state) => state.catalogueSearch.filters.title ); + const librarySearchValue = useAppSelector( + (state) => state.library.searchQuery + ); + + const isOnLibraryPage = location.pathname.startsWith("/library"); + const isOnCataloguePage = location.pathname.startsWith("/catalogue"); + + const searchValue = isOnLibraryPage + ? librarySearchValue + : catalogueSearchValue; + 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("/library")) + return headerTitle || t("library"); if (location.pathname.startsWith("/search")) return t("search_results"); 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 = () => { @@ -60,18 +131,121 @@ export function Header() { }; const handleSearch = (value: string) => { - dispatch(setFilters({ title: value.slice(0, 255) })); + if (isOnLibraryPage) { + dispatch(setLibrarySearchQuery(value.slice(0, 255))); + } else { + dispatch(setFilters({ title: value.slice(0, 255) })); + } + setActiveIndex(-1); + }; - if (!location.pathname.startsWith("/catalogue")) { + 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: string; + }) => { + setIsDropdownVisible(false); + inputRef.current?.blur(); + navigate(`/game/${suggestion.shop}/${suggestion.objectId}`); + }; + + const handleClearSearch = () => { + if (isOnLibraryPage) { + dispatch(setLibrarySearchQuery("")); + } 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") && searchValue) { + const prevPath = sessionStorage.getItem("prevPath"); + const currentPath = location.pathname; + + if ( + prevPath?.startsWith("/catalogue") && + !currentPath.startsWith("/catalogue") && + catalogueSearchValue + ) { dispatch(setFilters({ title: "" })); } - }, [location.pathname, searchValue, dispatch]); + + 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 ( <> @@ -104,6 +278,7 @@ export function Header() {
handleSearch(event.target.value)} - onFocus={() => setIsFocused(true)} + onFocus={handleFocus} onBlur={handleBlur} + onKeyDown={handleKeyDown} /> {searchValue && (
+ + 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{text}; + } + + const queryWords = query + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (queryWords.length === 0) { + return <>{text}; + } + + const textWords = text.split(/\b/); + const matches: Array<{ start: number; end: number; text: string }> = []; + + let currentIndex = 0; + textWords.forEach((word) => { + const wordLower = word.toLowerCase(); + + queryWords.forEach((queryWord) => { + if (wordLower === queryWord) { + matches.push({ + start: currentIndex, + end: currentIndex + word.length, + text: word, + }); + } + }); + + currentIndex += word.length; + }); + + if (matches.length === 0) { + return <>{text}; + } + + matches.sort((a, b) => a.start - b.start); + + const mergedMatches: Array<{ start: number; end: number }> = []; + + if (matches.length === 0) { + return <>{text}; + } + + let current = matches[0]; + + for (let i = 1; i < matches.length; i++) { + if (matches[i].start <= current.end) { + current.end = Math.max(current.end, matches[i].end); + } else { + mergedMatches.push(current); + current = matches[i]; + } + } + mergedMatches.push(current); + + const parts: Array<{ text: string; highlight: boolean }> = []; + let lastIndex = 0; + + mergedMatches.forEach((match) => { + if (match.start > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.start), + highlight: false, + }); + } + + parts.push({ + text: text.slice(match.start, match.end), + highlight: true, + }); + + lastIndex = match.end; + }); + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + highlight: false, + }); + } + + return ( + <> + {parts.map((part, index) => + 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..78a6fac1 --- /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: 300px; + 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 4px; + } + + &__section-title { + color: globals.$muted-color; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__clear-button { + color: globals.$muted-color; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all ease 0.2s; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: #dadbe1; + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__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; + border-radius: 4px; + opacity: 0; + transition: all ease 0.15s; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + + &:hover { + color: #ff5555; + background-color: rgba(255, 85, 85, 0.1); + } + } + + &__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.3); + color: #ffc107; + 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..d90c3bf5 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ClockIcon, + SearchIcon, + TrashIcon, + 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]); + + const handleItemClick = useCallback( + ( + type: "history" | "suggestion", + item: SearchHistoryEntry | SearchSuggestion + ) => { + if (type === "history") { + onSelectHistory((item as SearchHistoryEntry).query); + } else { + onSelectSuggestion(item as SearchSuggestion); + } + }, + [onSelectHistory, onSelectSuggestion] + ); + + if (!visible) return null; + + const totalItems = historyItems.length + suggestions.length; + const hasHistory = historyItems.length > 0; + const hasSuggestions = suggestions.length > 0; + + 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")}
+ )} + + {!isLoadingSuggestions && + !hasHistory && + !hasSuggestions && + totalItems === 0 && ( +
{t("no_results")}
+ )} +
+ ); + + return createPortal(dropdownContent, document.body); +} diff --git a/src/renderer/src/components/sidebar/routes.tsx b/src/renderer/src/components/sidebar/routes.tsx index 608718b6..f579329e 100644 --- a/src/renderer/src/components/sidebar/routes.tsx +++ b/src/renderer/src/components/sidebar/routes.tsx @@ -3,6 +3,7 @@ import { DownloadIcon, GearIcon, HomeIcon, + BookIcon, } from "@primer/octicons-react"; export const routes = [ @@ -16,6 +17,11 @@ export const routes = [ nameKey: "catalogue", render: () => , }, + { + path: "/library", + nameKey: "library", + render: () => , + }, { path: "/downloads", nameKey: "downloads", diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 356aa913..23223fc5 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -80,6 +80,12 @@ export function SidebarGameItem({ {getGameTitle(game)} + + {(game.newDownloadOptionsCount ?? 0) > 0 && ( + + +{game.newDownloadOptionsCount} + + )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 49f6e007..80732be8 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -115,6 +115,19 @@ background-size: cover; } + &__game-badge { + background-color: rgba(34, 197, 94, 0.15); + color: rgb(187, 247, 208); + font-size: 10px; + font-weight: 600; + padding: 4px 6px; + border-radius: 6px; + display: flex; + margin-left: auto; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(34, 197, 94, 0.5); + } + &__section-header { display: flex; justify-content: space-between; diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 87e2a669..c2a6864c 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -14,12 +14,15 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; - getUserLibraryGames: (sortBy?: string) => Promise; + getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise; + loadMoreLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; libraryGames: UserGame[]; pinnedGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -30,12 +33,15 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, - getUserLibraryGames: async (_sortBy?: string) => {}, + getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {}, + loadMoreLibraryGames: async (_sortBy?: string) => false, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], libraryGames: [], pinnedGames: [], + hasMoreLibraryGames: false, + isLoadingLibraryGames: false, }); const { Provider } = userProfileContext; @@ -62,6 +68,9 @@ export function UserProfileContextProvider({ DEFAULT_USER_PROFILE_BACKGROUND ); const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); + const [libraryPage, setLibraryPage] = useState(0); + const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true); + const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false); const isMe = userDetails?.id === userProfile?.id; @@ -93,7 +102,13 @@ export function UserProfileContextProvider({ }, [userId]); const getUserLibraryGames = useCallback( - async (sortBy?: string) => { + async (sortBy?: string, reset = true) => { + if (reset) { + setLibraryPage(0); + setHasMoreLibraryGames(true); + setIsLoadingLibraryGames(true); + } + try { const params = new URLSearchParams(); params.append("take", "12"); @@ -115,18 +130,74 @@ export function UserProfileContextProvider({ if (response) { setLibraryGames(response.library); setPinnedGames(response.pinnedGames); + setHasMoreLibraryGames(response.library.length === 12); } else { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); } } catch (error) { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); + } finally { + setIsLoadingLibraryGames(false); } }, [userId] ); + const loadMoreLibraryGames = useCallback( + async (sortBy?: string): Promise => { + if (isLoadingLibraryGames || !hasMoreLibraryGames) { + return false; + } + + setIsLoadingLibraryGames(true); + try { + const nextPage = libraryPage + 1; + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", String(nextPage * 12)); + if (sortBy) { + params.append("sortBy", sortBy); + } + + const queryString = params.toString(); + const url = queryString + ? `/users/${userId}/library?${queryString}` + : `/users/${userId}/library`; + + const response = await window.electron.hydraApi.get<{ + library: UserGame[]; + pinnedGames: UserGame[]; + }>(url); + + if (response && response.library.length > 0) { + setLibraryGames((prev) => { + const existingIds = new Set(prev.map((game) => game.objectId)); + const newGames = response.library.filter( + (game) => !existingIds.has(game.objectId) + ); + return [...prev, ...newGames]; + }); + setLibraryPage(nextPage); + setHasMoreLibraryGames(response.library.length === 12); + return true; + } else { + setHasMoreLibraryGames(false); + return false; + } + } catch (error) { + setHasMoreLibraryGames(false); + return false; + } finally { + setIsLoadingLibraryGames(false); + } + }, + [userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames] + ); + const getUserProfile = useCallback(async () => { getUserStats(); getUserLibraryGames(); @@ -164,6 +235,8 @@ export function UserProfileContextProvider({ setLibraryGames([]); setPinnedGames([]); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); + setLibraryPage(0); + setHasMoreLibraryGames(true); getUserProfile(); getBadges(); @@ -177,12 +250,15 @@ export function UserProfileContextProvider({ isMe, getUserProfile, getUserLibraryGames, + loadMoreLibraryGames, setSelectedBackgroundImage, backgroundImage: getBackgroundImageUrl(), userStats, badges, libraryGames, pinnedGames, + hasMoreLibraryGames, + isLoadingLibraryGames, }} > {children} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index fa4ab3d6..e35ed57b 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -142,6 +142,10 @@ declare global { shop: GameShop, objectId: string ) => Promise; + clearNewDownloadOptions: ( + shop: GameShop, + objectId: string + ) => Promise; toggleGamePin: ( shop: GameShop, objectId: string, @@ -159,6 +163,7 @@ declare global { ) => Promise; verifyExecutablePathInUse: (executablePath: string) => Promise; getLibrary: () => Promise; + refreshLibraryAssets: () => Promise; openGameInstaller: (shop: GameShop, objectId: string) => Promise; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise; @@ -214,6 +219,8 @@ declare global { ) => Promise; getDownloadSources: () => Promise; syncDownloadSources: () => Promise; + getDownloadSourcesCheckBaseline: () => Promise; + getDownloadSourcesSinceValue: () => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; @@ -409,11 +416,28 @@ declare global { getCustomThemeById: (themeId: string) => Promise; getActiveCustomTheme: () => Promise; toggleCustomTheme: (themeId: string, isActive: boolean) => Promise; + copyThemeAchievementSound: ( + themeId: string, + sourcePath: string + ) => Promise; + removeThemeAchievementSound: (themeId: string) => Promise; + getThemeSoundPath: (themeId: string) => Promise; + getThemeSoundDataUrl: (themeId: string) => Promise; + importThemeSoundFromStore: ( + themeId: string, + themeName: string, + storeUrl: string + ) => Promise; /* Editor */ openEditorWindow: (themeId: string) => Promise; onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer; closeEditorWindow: (themeId?: string) => Promise; + + /* Download Options */ + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => () => Electron.IpcRenderer; } interface Window { diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index 6c95aa79..0b575e10 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -5,10 +5,12 @@ import type { LibraryGame } from "@types"; export interface LibraryState { value: LibraryGame[]; + searchQuery: string; } const initialState: LibraryState = { value: [], + searchQuery: "", }; export const librarySlice = createSlice({ @@ -18,7 +20,34 @@ export const librarySlice = createSlice({ setLibrary: (state, action: PayloadAction) => { state.value = action.payload; }, + + updateGameNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string; count: number }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = action.payload.count; + } + }, + clearNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = undefined; + } + }, + setLibrarySearchQuery: (state, action: PayloadAction) => { + state.searchQuery = action.payload; + }, }, }); -export const { setLibrary } = librarySlice.actions; +export const { + setLibrary, + updateGameNewDownloadOptions, + clearNewDownloadOptions, + setLibrarySearchQuery, +} = librarySlice.actions; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index f09cec84..e16aa7a4 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -121,3 +121,35 @@ export const formatNumber = (num: number): string => { export const generateUUID = (): string => { return uuidv4(); }; + +export const getAchievementSoundUrl = async (): Promise => { + const defaultSound = (await import("@renderer/assets/audio/achievement.wav")) + .default; + + try { + const activeTheme = await window.electron.getActiveCustomTheme(); + + if (activeTheme?.hasCustomSound) { + const soundDataUrl = await window.electron.getThemeSoundDataUrl( + activeTheme.id + ); + if (soundDataUrl) { + return soundDataUrl; + } + } + } catch (error) { + console.error("Failed to get theme sound", error); + } + + return defaultSound; +}; + +export const getAchievementSoundVolume = async (): Promise => { + try { + const prefs = await window.electron.getUserPreferences(); + return prefs?.achievementSoundVolume ?? 0.15; + } catch (error) { + console.error("Failed to get sound volume", error); + return 0.15; + } +}; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 73733e2b..4c3c1bd2 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -6,3 +6,7 @@ export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; export * from "./use-feature"; +export * from "./use-download-options-listener"; +export * from "./use-game-card"; +export * from "./use-search-history"; +export * from "./use-search-suggestions"; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts new file mode 100644 index 00000000..dfc9b3b4 --- /dev/null +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useAppDispatch } from "./redux"; +import { updateGameNewDownloadOptions } from "@renderer/features"; + +export function useDownloadOptionsListener() { + const dispatch = useAppDispatch(); + + useEffect(() => { + const unsubscribe = window.electron.onNewDownloadOptions( + (gamesWithNewOptions) => { + for (const { gameId, count } of gamesWithNewOptions) { + dispatch(updateGameNewDownloadOptions({ gameId, count })); + } + } + ); + + return unsubscribe; + }, [dispatch]); +} diff --git a/src/renderer/src/hooks/use-game-card.ts b/src/renderer/src/hooks/use-game-card.ts new file mode 100644 index 00000000..98987189 --- /dev/null +++ b/src/renderer/src/hooks/use-game-card.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useFormat } from "./use-format"; +import { useTranslation } from "react-i18next"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { LibraryGame } from "@types"; + +export function useGameCard( + game: LibraryGame, + onContextMenu: (game: LibraryGame, position: { x: number; y: number }) => void +) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0, isShort = false) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); + }, + [numberFormatter, t] + ); + + const handleCardClick = useCallback(() => { + navigate(buildGameDetailsPath(game)); + }, [navigate, game]); + + const handleContextMenuClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(game, { x: e.clientX, y: e.clientY }); + }, + [game, onContextMenu] + ); + + const handleMenuButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onContextMenu(game, { x: rect.right, y: rect.bottom }); + }, + [game, onContextMenu] + ); + + return { + formatPlayTime, + handleCardClick, + handleContextMenuClick, + handleMenuButtonClick, + }; +} 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..dac6d391 --- /dev/null +++ b/src/renderer/src/hooks/use-search-history.ts @@ -0,0 +1,78 @@ +import { useState, useCallback, useEffect } from "react"; + +export interface SearchHistoryEntry { + query: string; + timestamp: number; + context: "library" | "catalogue"; +} + +const STORAGE_KEY = "search-history"; +const MAX_HISTORY_ENTRIES = 15; + +export function useSearchHistory() { + const [history, setHistory] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored) as SearchHistoryEntry[]; + setHistory(parsed); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + 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); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, + [] + ); + + const removeFromHistory = useCallback((query: string) => { + setHistory((prev) => { + const updated = prev.filter((entry) => entry.query !== query); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + localStorage.removeItem(STORAGE_KEY); + }, []); + + 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..f2baa8db --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppSelector } from "./redux"; +import { debounce } from "lodash-es"; + +export interface SearchSuggestion { + title: string; + objectId: string; + shop: string; + 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 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; + } + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + + try { + const response = await window.electron.hydraApi.get< + Array<{ + title: string; + objectId: string; + shop: string; + 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, + }) + ); + + setSuggestions(catalogueSuggestions); + } catch (error) { + if (!abortController.signal.aborted) { + setSuggestions([]); + } + } 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-section-collapse.ts b/src/renderer/src/hooks/use-section-collapse.ts index 7cd22224..59ffd6af 100644 --- a/src/renderer/src/hooks/use-section-collapse.ts +++ b/src/renderer/src/hooks/use-section-collapse.ts @@ -3,12 +3,14 @@ import { useState, useCallback } from "react"; interface SectionCollapseState { pinned: boolean; library: boolean; + reviews: boolean; } export function useSectionCollapse() { const [collapseState, setCollapseState] = useState({ pinned: false, library: false, + reviews: false, }); const toggleSection = useCallback((section: keyof SectionCollapseState) => { @@ -23,5 +25,6 @@ export function useSectionCollapse() { toggleSection, isPinnedCollapsed: collapseState.pinned, isLibraryCollapsed: collapseState.library, + isReviewsCollapsed: collapseState.reviews, }; } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a1b5f7d0..84c7f815 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings"; import Profile from "./pages/profile/profile"; import Achievements from "./pages/achievements/achievements"; import ThemeEditor from "./pages/theme-editor/theme-editor"; +import Library from "./pages/library/library"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; console.log = logger.log; @@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( }> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index c5c37933..38b2443b 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -1,11 +1,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import achievementSound from "@renderer/assets/audio/achievement.wav"; import { useTranslation } from "react-i18next"; import { AchievementCustomNotificationPosition, AchievementNotificationInfo, } from "@types"; -import { injectCustomCss, removeCustomCss } from "@renderer/helpers"; +import { + injectCustomCss, + removeCustomCss, + getAchievementSoundUrl, + getAchievementSoundVolume, +} from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import app from "../../../app.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; @@ -33,9 +37,11 @@ export function AchievementNotification() { const [shadowRootRef, setShadowRootRef] = useState(null); - const playAudio = useCallback(() => { - const audio = new Audio(achievementSound); - audio.volume = 0.1; + const playAudio = useCallback(async () => { + const soundUrl = await getAchievementSoundUrl(); + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; audio.play(); }, []); 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..56912fcc 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 @@ -100,20 +100,48 @@ 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) }), - }); + // Prefer new formats: HLS (best browser support), then DASH H264, then DASH AV1 + // Fallback to old format: mp4/webm if new formats are not available + 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) { + // Fallback to old format + videoSrc = video.mp4.max; + videoType = "video/mp4"; + } else if (video.webm?.max) { + // Fallback to webm if mp4 is not available + 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) }), + }); + } }); } @@ -172,7 +200,9 @@ export function GallerySlider() { autoPlay={autoplayEnabled} tabIndex={-1} > - + {item.videoSrc && ( + + )} ) : ( >( {} ); + const [lastCheckTimestamp, setLastCheckTimestamp] = useState( + null + ); + const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true); + const [viewedRepackIds, setViewedRepackIds] = useState>( + new Set() + ); const { game, repacks } = useContext(gameDetailsContext); @@ -60,6 +67,7 @@ export function RepacksModal({ const { formatDate } = useDate(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); const getHashFromMagnet = (magnet: string) => { if (!magnet || typeof magnet !== "string") { @@ -97,6 +105,34 @@ export function RepacksModal({ fetchDownloadSources(); }, []); + useEffect(() => { + const fetchLastCheckTimestamp = async () => { + setIsLoadingTimestamp(true); + + const timestamp = await window.electron.getDownloadSourcesSinceValue(); + + setLastCheckTimestamp(timestamp); + setIsLoadingTimestamp(false); + }; + + if (visible) { + fetchLastCheckTimestamp(); + } + }, [visible, repacks]); + + useEffect(() => { + if ( + visible && + game?.newDownloadOptionsCount && + game.newDownloadOptionsCount > 0 + ) { + globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); + + const gameId = `${game.shop}:${game.objectId}`; + dispatch(clearNewDownloadOptions({ gameId })); + } + }, [visible, game, dispatch]); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -139,6 +175,7 @@ export function RepacksModal({ const handleRepackClick = (repack: GameRepack) => { setRepack(repack); setShowSelectFolderModal(true); + setViewedRepackIds((prev) => new Set(prev).add(repack.id)); }; const handleFilter: React.ChangeEventHandler = (event) => { @@ -158,6 +195,20 @@ export function RepacksModal({ return repack.uris.some((uri) => uri.includes(game.download!.uri)); }; + const isNewRepack = (repack: GameRepack): boolean => { + if (isLoadingTimestamp) return false; + + if (viewedRepackIds.has(repack.id)) return false; + + if (!lastCheckTimestamp || !repack.createdAt) { + return false; + } + + const lastCheckUtc = new Date(lastCheckTimestamp).toISOString(); + + return repack.createdAt > lastCheckUtc; + }; + const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); useEffect(() => { @@ -273,7 +324,14 @@ export function RepacksModal({ onClick={() => handleRepackClick(repack)} className="repacks-modal__repack-button" > -

{repack.title}

+

+ {repack.title} + {isNewRepack(repack) && ( + + {t("new_download_option")} + + )} +

{isLastDownloadedOption && ( {t("last_downloaded_option")} diff --git a/src/renderer/src/pages/game-details/review-item.scss b/src/renderer/src/pages/game-details/review-item.scss index 56ffaa36..2b0e1885 100644 --- a/src/renderer/src/pages/game-details/review-item.scss +++ b/src/renderer/src/pages/game-details/review-item.scss @@ -8,11 +8,23 @@ &__review-header { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); margin-bottom: calc(globals.$spacing-unit * 1.5); } + &__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + } + + &__review-header-bottom { + display: flex; + justify-content: flex-start; + align-items: center; + } + &__review-user { display: flex; align-items: center; diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index 34b6d7aa..8346d82d 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -90,6 +90,7 @@ export function ReviewItem({ } }; + // Format playtime similar to hero panel const formatPlayTime = (playTimeInSeconds: number) => { const minutes = playTimeInSeconds / 60; @@ -103,6 +104,7 @@ export function ReviewItem({ return t("amount_hours", { amount: numberFormatter.format(hours) }); }; + // Determine which content to show - always show original for own reviews const displayContent = needsTranslation ? review.translations[userLanguage] : review.reviewHtml; @@ -128,60 +130,62 @@ export function ReviewItem({ return (
-
- -
+
+
-
-
+
- {Boolean( - review.playTimeInSeconds && review.playTimeInSeconds > 0 - ) && ( -
- - - {t("review_played_for")}{" "} - {formatPlayTime(review.playTimeInSeconds || 0)} - -
- )} + {review.user.displayName || "Anonymous"} +
-
-
{formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true, })}
+
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {t("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
+
void; + allGamesCount: number; + recentlyPlayedCount: number; + favoritesCount: number; +} + +export function FilterOptions({ + filterBy, + onFilterChange, + allGamesCount, + recentlyPlayedCount, + favoritesCount, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
+
+ + {filterBy === "all" && ( + + )} +
+
+ + {filterBy === "recently_played" && ( + + )} +
+
+ + {filterBy === "favorites" && ( + + )} +
+
+ ); +} diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss new file mode 100644 index 00000000..8ac59112 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -0,0 +1,209 @@ +@use "../../scss/globals.scss"; + +.library-game-card-large { + width: 100%; + height: 300px; + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all ease 0.2s; + cursor: pointer; + display: flex; + align-items: center; + text-align: left; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + + &:hover { + transform: scale(1.01); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + &__background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: 0; + } + + &__gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%); + z-index: 1; + } + + &__overlay { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: calc(globals.$spacing-unit * 1.5); + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: calc(globals.$spacing-unit); + } + + &__logo-container { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + } + + &__logo { + max-height: 120px; + max-width: 400px; + width: auto; + height: auto; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); + } + + &__title { + font-size: 28px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9); + } + + &__info-bar { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + justify-content: flex-end; + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.95); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + font-size: 12px; + } + + &__playtime-text { + font-weight: 500; + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__achievements { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 auto; + min-width: 0; + } + + &__achievement-header { + display: flex; + align-items: center; + justify-content: space-between; + } + &__achievements-gap { + display: flex; + align-items: center; + gap: 6px; + } + + &__achievement-trophy { + color: #fff; + flex-shrink: 0; + } + + &__achievement-progress { + width: 100%; + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } + } + + &__achievement-bar { + height: 100%; + background-color: globals.$muted-color; + border-radius: 4px; + transition: width 0.3s ease; + } + + &__achievement-count { + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + } + + &__achievement-percentage { + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + } +} diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx new file mode 100644 index 00000000..dd998c59 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -0,0 +1,158 @@ +import { LibraryGame } from "@types"; +import { useGameCard } from "@renderer/hooks"; +import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import { memo, useEffect, useMemo, useState } from "react"; +import "./library-game-card-large.scss"; + +interface LibraryGameCardLargeProps { + game: LibraryGame; + onContextMenu: ( + game: LibraryGame, + position: { x: number; y: number } + ) => void; +} + +const normalizePathForCss = (url: string | null | undefined): string => { + if (!url) return ""; + return url.replaceAll("\\", "/"); +}; + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + const selectedUrl = customUrl || originalUrl || fallbackUrl || ""; + return normalizePathForCss(selectedUrl); +}; + +export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ + game, + onContextMenu, +}: Readonly) { + const { formatPlayTime, handleCardClick, handleContextMenuClick } = + useGameCard(game, onContextMenu); + + const backgroundImage = useMemo( + () => + getImageWithCustomPriority( + game.customHeroImageUrl, + game.libraryHeroImageUrl, + game.libraryImageUrl ?? game.iconUrl + ), + [ + game.customHeroImageUrl, + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl, + ] + ); + + const [unlockedAchievementsCount, setUnlockedAchievementsCount] = useState( + game.unlockedAchievementCount ?? 0 + ); + + useEffect(() => { + if (game.unlockedAchievementCount) return; + + window.electron + .getUnlockedAchievements(game.objectId, game.shop) + .then((achievements) => { + setUnlockedAchievementsCount( + achievements.filter((a) => a.unlocked).length + ); + }); + }, [game]); + + const backgroundStyle = useMemo( + () => + backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : {}, + [backgroundImage] + ); + + const achievementBarStyle = useMemo( + () => ({ + width: `${(unlockedAchievementsCount / (game.achievementCount ?? 1)) * 100}%`, + }), + [unlockedAchievementsCount, game.achievementCount] + ); + + const logoImage = game.customLogoImageUrl ?? game.logoImageUrl; + + return ( + + ); +}); diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss new file mode 100644 index 00000000..ab9a9f2a --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -0,0 +1,241 @@ +@use "../../scss/globals.scss"; + +.library-game-card { + &__wrapper { + cursor: pointer; + transition: all ease 0.2s; + box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); + width: 100%; + aspect-ratio: 3 / 4; + position: relative; + border: none; + background: none; + padding: 0; + border-radius: 4px; + overflow: hidden; + display: block; + container-type: inline-size; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover { + transform: scale(1.02); + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + } + + &__overlay { + position: absolute; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + height: 100%; + width: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%); + padding: 8px; + z-index: 2; + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.8); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &-long { + display: inline; + font-size: 12px; + } + + &-short { + display: none; + font-size: 12px; + } + + // When the card is narrow (less than 140px), show short format + @container (max-width: 140px) { + &-long { + display: none; + } + + &-short { + display: inline; + } + } + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__achievements { + display: flex; + flex-direction: column; + opacity: 1; + transform: translateY(0); + transition: all ease 0.2s; + pointer-events: auto; + width: 100%; + } + + &__achievement-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + color: globals.$muted-color; + overflow: hidden; + height: 18px; + } + + &__achievements-gap { + display: flex; + align-items: center; + gap: 8px; + } + + &__achievement-trophy { + color: #fff; + flex-shrink: 0; + } + + &__achievement-progress { + width: 100%; + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 4px; + overflow: hidden; + + &::-webkit-progress-bar { + background-color: rgba(255, 255, 255, 0.15); + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } + } + + &__achievement-bar { + height: 100%; + background-color: globals.$muted-color; + border-radius: 4px; + transition: width 0.3s ease; + position: relative; + } + + &__achievement-count { + white-space: nowrap; + } + + &__achievement-percentage { + white-space: nowrap; + } + + &__action-button { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.2); + border-radius: 4px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.9); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.8); + border-color: rgba(255, 255, 255, 0.4); + transform: scale(1.1); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); + } + + &:active { + transform: scale(0.95); + } + } + + &__wrapper:hover &__action-button { + opacity: 1; + transform: scale(1); + } + + &__game-image { + object-fit: cover; + border-radius: 4px; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + display: block; + top: 0; + left: 0; + z-index: 0; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Responsive sizing for compact grid cells */ +.library__games-grid--compact .library-game-card__wrapper { + width: 100%; + height: auto; + aspect-ratio: 215 / 320; +} diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx new file mode 100644 index 00000000..a91176cb --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -0,0 +1,109 @@ +import { LibraryGame } from "@types"; +import { useGameCard } from "@renderer/hooks"; +import { memo } from "react"; +import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import "./library-game-card.scss"; + +interface LibraryGameCardProps { + game: LibraryGame; + onMouseEnter: () => void; + onMouseLeave: () => void; + onContextMenu: ( + game: LibraryGame, + position: { x: number; y: number } + ) => void; + onShowTooltip?: (gameId: string) => void; + onHideTooltip?: () => void; +} + +export const LibraryGameCard = memo(function LibraryGameCard({ + game, + onMouseEnter, + onMouseLeave, + onContextMenu, +}: Readonly) { + const { formatPlayTime, handleCardClick, handleContextMenuClick } = + useGameCard(game, onContextMenu); + + const coverImage = ( + game.customIconUrl ?? + game.coverImageUrl ?? + game.libraryImageUrl ?? + game.libraryHeroImageUrl ?? + game.iconUrl ?? + "" + ).replaceAll("\\", "/"); + + return ( + + ); +}); diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss new file mode 100644 index 00000000..ffc68b83 --- /dev/null +++ b/src/renderer/src/pages/library/library.scss @@ -0,0 +1,208 @@ +@use "../../scss/globals.scss"; + +.library { + &__content { + padding: calc(globals.$spacing-unit * 2); + height: 100%; + width: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 3); + align-items: flex-start; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + &__page-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + width: 100%; + } + + &__page-title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + } + + &__controls-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + position: relative; + } + + &__controls-left { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex: 1; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + margin-right: calc(globals.$spacing-unit * 2); + } + + &__controls-right { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__header-controls { + display: flex; + flex-direction: column; + align-items: end; + gap: calc(globals.$spacing-unit * 1); + &__left { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + } + } + &__header-title { + font-size: 20px; + font-weight: 700; + } + &__filter-label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + } + + &__separator { + width: 100%; + height: 1px; + background: rgba(255, 255, 255, 0.1); + border: none; + margin: 0; + } + + &__count { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 8px 16px; + } + + &__count-label { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + } + + &__count-number { + color: rgba(255, 255, 255, 0.9); + font-size: 13px; + font-weight: 600; + } + + &__no-games { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + gap: globals.$spacing-unit; + padding: calc(globals.$spacing-unit * 4); + } + + &__telescope-icon { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__games-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Grid view - larger cards + &--grid { + grid-template-columns: repeat(2, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(4, 1fr); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(5, 1fr); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(6, 1fr); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(8, 1fr); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(12, 1fr); + } + } + + // Compact view - smaller cards with responsive design + &--compact { + grid-template-columns: repeat(3, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(5, 1fr); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(7, 1fr); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(9, 1fr); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(12, 1fr); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(14, 1fr); + } + } + } + + &__games-list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Large view - 2 columns grid + &--large { + display: grid; + grid-template-columns: repeat(1, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(2, 1fr); + } + } + } +} diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx new file mode 100644 index 00000000..0efe8fb2 --- /dev/null +++ b/src/renderer/src/pages/library/library.tsx @@ -0,0 +1,224 @@ +import { useEffect, useMemo, useState, useCallback } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks"; +import { setHeaderTitle } from "@renderer/features"; +import { TelescopeIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { LibraryGame } from "@types"; +import { GameContextMenu } from "@renderer/components"; +import { LibraryGameCard } from "./library-game-card"; +import { LibraryGameCardLarge } from "./library-game-card-large"; +import { ViewOptions, ViewMode } from "./view-options"; +import { FilterOptions, FilterOption } from "./filter-options"; +import "./library.scss"; + +export default function Library() { + const { library, updateLibrary } = useLibrary(); + + const [viewMode, setViewMode] = useState(() => { + const savedViewMode = localStorage.getItem("library-view-mode"); + return (savedViewMode as ViewMode) || "compact"; + }); + const [filterBy, setFilterBy] = useState("all"); + const [contextMenu, setContextMenu] = useState<{ + game: LibraryGame | null; + visible: boolean; + position: { x: number; y: number }; + }>({ game: null, visible: false, position: { x: 0, y: 0 } }); + + const searchQuery = useAppSelector((state) => state.library.searchQuery); + const dispatch = useAppDispatch(); + const { t } = useTranslation("library"); + + const handleViewModeChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + localStorage.setItem("library-view-mode", mode); + }, []); + + useEffect(() => { + dispatch(setHeaderTitle(t("library"))); + + const unsubscribe = window.electron.onLibraryBatchComplete(() => { + updateLibrary(); + }); + + window.electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); + + return () => { + unsubscribe(); + }; + }, [dispatch, t, updateLibrary]); + + const handleOnMouseEnterGameCard = useCallback(() => { + // Optional: pause animations if needed + }, []); + + const handleOnMouseLeaveGameCard = useCallback(() => { + // Optional: resume animations if needed + }, []); + + const handleOpenContextMenu = useCallback( + (game: LibraryGame, position: { x: number; y: number }) => { + setContextMenu({ game, visible: true, position }); + }, + [] + ); + + const handleCloseContextMenu = useCallback(() => { + setContextMenu((prev) => ({ ...prev, visible: false })); + }, []); + + const filteredLibrary = useMemo(() => { + let filtered; + + switch (filterBy) { + case "recently_played": + filtered = library.filter((game) => game.lastTimePlayed !== null); + break; + case "favorites": + filtered = library.filter((game) => game.favorite); + break; + case "all": + default: + filtered = library; + } + + if (!searchQuery.trim()) return filtered; + + const queryLower = searchQuery.toLowerCase(); + return filtered.filter((game) => { + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + return queryIndex === queryLower.length; + }); + }, [library, filterBy, searchQuery]); + + const sortedLibrary = filteredLibrary; + + const filterCounts = useMemo(() => { + const allGamesCount = library.length; + let recentlyPlayedCount = 0; + let favoritesCount = 0; + + for (const game of library) { + if (game.lastTimePlayed !== null) recentlyPlayedCount++; + if (game.favorite) favoritesCount++; + } + + return { + allGamesCount, + recentlyPlayedCount, + favoritesCount, + }; + }, [library]); + + const hasGames = library.length > 0; + + return ( +
+ {hasGames && ( +
+
+
+ +
+ +
+ +
+
+
+ )} + + {!hasGames && ( +
+
+ +
+

{t("no_games_title")}

+

{t("no_games_description")}

+
+ )} + + {hasGames && ( + + {viewMode === "large" && ( + + {sortedLibrary.map((game) => ( + + ))} + + )} + + {viewMode !== "large" && ( + + {sortedLibrary.map((game) => ( +
  • + +
  • + ))} +
    + )} +
    + )} + + {contextMenu.game && ( + + )} +
    + ); +} diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss new file mode 100644 index 00000000..13307864 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.scss @@ -0,0 +1,55 @@ +@use "../../scss/globals.scss"; + +.library-view-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; + } + + &__options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + white-space: nowrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + border: none; + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); + } + + &.active { + color: rgba(0, 0, 0, 0.9); + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; + } + } + } +} diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx new file mode 100644 index 00000000..905fac58 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.tsx @@ -0,0 +1,45 @@ +import { AppsIcon, RowsIcon, SquareIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import "./view-options.scss"; + +export type ViewMode = "grid" | "compact" | "large"; + +interface ViewOptionsProps { + viewMode: ViewMode; + onViewModeChange: (viewMode: ViewMode) => void; +} + +export function ViewOptions({ + viewMode, + onViewModeChange, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
    +
    + + + +
    +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/library-tab.tsx b/src/renderer/src/pages/profile/profile-content/library-tab.tsx new file mode 100644 index 00000000..1bc78c05 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/library-tab.tsx @@ -0,0 +1,178 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { TelescopeIcon } from "@primer/octicons-react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { useFormat } from "@renderer/hooks"; +import type { UserGame } from "@types"; +import { SortOptions } from "./sort-options"; +import { UserLibraryGameCard } from "./user-library-game-card"; +import "./profile-content.scss"; + +type SortOption = "playtime" | "achievementCount" | "playedRecently"; + +interface LibraryTabProps { + sortBy: SortOption; + onSortChange: (sortBy: SortOption) => void; + pinnedGames: UserGame[]; + libraryGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; + statsIndex: number; + userStats: { libraryCount: number } | null; + animatedGameIdsRef: React.MutableRefObject>; + onLoadMore: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; + isMe: boolean; +} + +export function LibraryTab({ + sortBy, + onSortChange, + pinnedGames, + libraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, + statsIndex, + userStats, + animatedGameIdsRef, + onLoadMore, + onMouseEnter, + onMouseLeave, + isMe, +}: Readonly) { + const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const hasGames = libraryGames.length > 0; + const hasPinnedGames = pinnedGames.length > 0; + const hasAnyGames = hasGames || hasPinnedGames; + + return ( + + {hasAnyGames && ( + + )} + + {!hasAnyGames && ( +
    +
    + +
    +

    {t("no_recent_activity_title")}

    + {isMe &&

    {t("no_recent_activity_description")}

    } +
    + )} + + {hasAnyGames && ( +
    + {hasPinnedGames && ( +
    +
    +
    +

    {t("pinned")}

    + + {pinnedGames.length} + +
    +
    + +
      + {pinnedGames?.map((game) => ( +
    • + +
    • + ))} +
    +
    + )} + + {hasGames && ( +
    +
    +
    +

    {t("library")}

    + {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )} +
    +
    + + +
      + {libraryGames?.map((game, index) => { + const hasAnimated = animatedGameIdsRef.current.has( + game.objectId + ); + const isNewGame = !hasAnimated && !isLoadingLibraryGames; + + return ( + { + if (isNewGame) { + animatedGameIdsRef.current.add(game.objectId); + } + }} + > + + + ); + })} +
    +
    +
    + )} +
    + )} +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index c3c71d9a..958fe52d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -101,6 +101,11 @@ gap: calc(globals.$spacing-unit); margin-bottom: calc(globals.$spacing-unit * 2); border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } + + &__tab-wrapper { + position: relative; } &__tab { @@ -111,19 +116,40 @@ cursor: pointer; font-size: 14px; font-weight: 500; - border-bottom: 2px solid transparent; - transition: all ease 0.2s; - - &:hover { - color: rgba(255, 255, 255, 0.8); - } + transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); &--active { color: white; - border-bottom-color: #c9aa71; } } + &__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 9px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 1; + } + + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + &__games-grid { list-style: none; margin: 0; @@ -175,5 +201,245 @@ backdrop-filter: blur(10px); } } + + &__tab-panels { + display: block; + } + } +} + +// Reviews minimal styles +.user-reviews__loading { + padding: calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.8); + text-align: center; + display: flex; + justify-content: center; + align-items: center; +} + +.user-reviews__empty { + text-align: center; + padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.6); +} + +.user-reviews__list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 4); +} + +.user-reviews__review-item { + border-radius: 8px; +} + +.user-reviews__review-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.user-reviews__review-header-bottom { + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-meta-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__review-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: calc(globals.$spacing-unit * 1.5); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-game { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__game-icon { + width: 24px; + height: 24px; + object-fit: cover; +} + +.user-reviews__game-info { + display: flex; + flex-direction: column; +} + +.user-reviews__game-details { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__game-title { + background: none; + border: none; + color: rgba(255, 255, 255, 0.9); + font-weight: 600; + cursor: pointer; + text-align: left; + + &--clickable:hover { + text-decoration: underline; + } +} + +.user-reviews__review-date { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; +} + +.user-reviews__review-score-stars { + display: flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + font-size: 11px; + font-weight: 500; +} + +.user-reviews__review-star { + color: rgba(255, 255, 255, 0.7); + transition: color 0.2s ease; + + &--filled { + color: rgba(255, 255, 255, 0.7); + + svg { + fill: currentColor; + } + } +} + +.user-reviews__review-score-text { + font-weight: 500; +} + +.user-reviews__review-playtime { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 500; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.user-reviews__review-content { + color: rgba(255, 255, 255, 0.85); + line-height: 1.5; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + max-width: 100%; +} + +.user-reviews__review-translation-toggle { + display: inline-flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1); + margin-top: calc(globals.$spacing-unit * 1.5); + padding: 0; + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + transition: all 0.2s ease; + + &:hover { + text-decoration: underline; + color: rgba(255, 255, 255, 0.9); + } +} + +.user-reviews__review-actions { + margin-top: calc(globals.$spacing-unit * 2); + padding-top: calc(globals.$spacing-unit); + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-votes { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__vote-button { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 6px 12px; + color: #ccc; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: #ffffff; + } + + &--active { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); + + svg { + fill: white; + } + } +} + +.user-reviews__delete-review-button { + display: flex; + align-items: center; + gap: 6px; + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + border-radius: 6px; + padding: 6px 10px; + color: #f44336; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(244, 67, 54, 0.2); + border-color: rgba(244, 67, 54, 0.4); + color: #ff7961; } } 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 d2f1f074..8176bace 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,29 +1,82 @@ import { userProfileContext } from "@renderer/context"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; -import { useAppDispatch, useFormat } from "@renderer/hooks"; +import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; -import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserStatsBox } from "./user-stats-box"; import { UserKarmaBox } from "./user-karma-box"; -import { UserLibraryGameCard } from "./user-library-game-card"; -import { SortOptions } from "./sort-options"; -import { useSectionCollapse } from "@renderer/hooks/use-section-collapse"; -import { motion, AnimatePresence } from "framer-motion"; -import { - sectionVariants, - chevronVariants, - GAME_STATS_ANIMATION_DURATION_IN_MS, -} from "./profile-animations"; +import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; +import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { ProfileTabs } from "./profile-tabs"; +import { LibraryTab } from "./library-tab"; +import { ReviewsTab } from "./reviews-tab"; +import { AnimatePresence } from "framer-motion"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface UserReviewsResponse { + totalCount: number; + reviews: UserReview[]; +} + +const getRatingText = (score: number, t: (key: string) => string): string => { + switch (score) { + case 1: + return t("rating_very_negative"); + case 2: + return t("rating_negative"); + case 3: + return t("rating_neutral"); + case 4: + return t("rating_positive"); + case 5: + return t("rating_very_positive"); + default: + return ""; + } +}; + export function ProfileContent() { const { userProfile, @@ -32,16 +85,43 @@ export function ProfileContent() { libraryGames, pinnedGames, getUserLibraryGames, + loadMoreLibraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, } = useContext(userProfileContext); + const { userDetails } = useUserDetails(); const [statsIndex, setStatsIndex] = useState(0); const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [sortBy, setSortBy] = useState("playedRecently"); const statsAnimation = useRef(-1); - const { toggleSection, isPinnedCollapsed } = useSectionCollapse(); + + const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); + + // User reviews state + const [reviews, setReviews] = useState([]); + const [reviewsTotalCount, setReviewsTotalCount] = useState(0); + const [isLoadingReviews, setIsLoadingReviews] = useState(false); + const [votingReviews, setVotingReviews] = useState>(new Set()); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [reviewToDelete, setReviewToDelete] = useState(null); const dispatch = useAppDispatch(); const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const formatPlayTime = (playTimeInSeconds: number) => { + const minutes = playTimeInSeconds / 60; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t("amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + return t("amount_hours", { amount: numberFormatter.format(hours) }); + }; useEffect(() => { dispatch(setHeaderTitle("")); @@ -53,10 +133,201 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { - getUserLibraryGames(sortBy); + // When sortBy changes, clear animated games so all games animate in + if (currentSortByRef.current !== sortBy) { + animatedGameIdsRef.current.clear(); + currentSortByRef.current = sortBy; + } + getUserLibraryGames(sortBy, true); } }, [sortBy, getUserLibraryGames, userProfile]); + const animatedGameIdsRef = useRef>(new Set()); + const currentSortByRef = useRef(sortBy); + + const handleLoadMore = useCallback(() => { + if ( + activeTab === "library" && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); + } + }, [ + activeTab, + hasMoreLibraryGames, + isLoadingLibraryGames, + loadMoreLibraryGames, + sortBy, + ]); + + // Clear reviews state and reset tab when switching users + useEffect(() => { + setReviews([]); + setReviewsTotalCount(0); + setIsLoadingReviews(false); + setActiveTab("library"); + }, [userProfile?.id]); + + useEffect(() => { + if (userProfile?.id) { + fetchUserReviews(); + } + }, [userProfile?.id]); + + const fetchUserReviews = async () => { + if (!userProfile?.id) return; + + setIsLoadingReviews(true); + try { + const response = await window.electron.hydraApi.get( + `/users/${userProfile.id}/reviews`, + { needsAuth: true } + ); + setReviews(response.reviews); + setReviewsTotalCount(response.totalCount); + } catch (error) { + // Error handling for fetching reviews + } finally { + setIsLoadingReviews(false); + } + }; + + const handleDeleteReview = async (reviewId: string) => { + try { + const reviewToDeleteObj = reviews.find( + (review) => review.id === reviewId + ); + if (!reviewToDeleteObj) return; + + await window.electron.hydraApi.delete( + `/games/${reviewToDeleteObj.game.shop}/${reviewToDeleteObj.game.objectId}/reviews/${reviewId}` + ); + // Remove the review from the local state + setReviews((prev) => prev.filter((review) => review.id !== reviewId)); + setReviewsTotalCount((prev) => prev - 1); + } catch (error) { + console.error("Failed to delete review:", error); + } + }; + + const handleDeleteClick = (reviewId: string) => { + setReviewToDelete(reviewId); + setDeleteModalVisible(true); + }; + + const handleDeleteConfirm = () => { + if (reviewToDelete) { + handleDeleteReview(reviewToDelete); + setReviewToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalVisible(false); + setReviewToDelete(null); + }; + + const handleVoteReview = async (reviewId: string, isUpvote: boolean) => { + if (votingReviews.has(reviewId)) return; + + setVotingReviews((prev) => new Set(prev).add(reviewId)); + + const review = reviews.find((r) => r.id === reviewId); + if (!review) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } + + const wasUpvoted = review.hasUpvoted; + const wasDownvoted = review.hasDownvoted; + + // Optimistic update + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + + let newUpvotes = r.upvotes; + let newDownvotes = r.downvotes; + let newHasUpvoted = r.hasUpvoted; + let newHasDownvoted = r.hasDownvoted; + + if (isUpvote) { + if (wasUpvoted) { + // Remove upvote + newUpvotes--; + newHasUpvoted = false; + } else { + // Add upvote + newUpvotes++; + newHasUpvoted = true; + if (wasDownvoted) { + // Remove downvote if it was downvoted + newDownvotes--; + newHasDownvoted = false; + } + } + } else if (wasDownvoted) { + // Remove downvote + newDownvotes--; + newHasDownvoted = false; + } else { + // Add downvote + newDownvotes++; + newHasDownvoted = true; + if (wasUpvoted) { + // Remove upvote if it was upvoted + newUpvotes--; + newHasUpvoted = false; + } + } + + return { + ...r, + upvotes: newUpvotes, + downvotes: newDownvotes, + hasUpvoted: newHasUpvoted, + hasDownvoted: newHasDownvoted, + }; + }) + ); + + try { + const endpoint = isUpvote ? "upvote" : "downvote"; + await window.electron.hydraApi.put( + `/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}` + ); + } catch (error) { + console.error("Failed to vote on review:", error); + + // Rollback optimistic update on error + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + return { + ...r, + upvotes: review.upvotes, + downvotes: review.downvotes, + hasUpvoted: review.hasUpvoted, + hasDownvoted: review.hasDownvoted, + }; + }) + ); + } finally { + setTimeout(() => { + setVotingReviews((prev) => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + }, 500); + } + }; + const handleOnMouseEnterGameCard = () => { setIsAnimationRunning(false); }; @@ -86,8 +357,6 @@ export function ProfileContent() { }; }, [setStatsIndex, isAnimationRunning]); - const { numberFormatter } = useFormat(); - const usersAreFriends = useMemo(() => { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -113,112 +382,46 @@ export function ProfileContent() { return (
    - {hasAnyGames && ( - - )} + - {!hasAnyGames && ( -
    -
    - -
    -

    {t("no_recent_activity_title")}

    - {isMe &&

    {t("no_recent_activity_description")}

    } -
    - )} - - {hasAnyGames && ( -
    - {hasPinnedGames && ( -
    -
    -
    - -

    {t("pinned")}

    - - {pinnedGames.length} - -
    -
    - - - {!isPinnedCollapsed && ( - -
      - {pinnedGames?.map((game) => ( -
    • - -
    • - ))} -
    -
    - )} -
    -
    +
    + + {activeTab === "library" && ( + )} - {hasGames && ( -
    -
    -
    -

    {t("library")}

    - {userStats && ( - - {numberFormatter.format(userStats.libraryCount)} - - )} -
    -
    - -
      - {libraryGames?.map((game) => ( -
    • - -
    • - ))} -
    -
    + {activeTab === "reviews" && ( + )} -
    - )} + +
    {shouldShowRightContent && ( @@ -230,6 +433,12 @@ export function ProfileContent() {
    )} + + ); }, [ @@ -242,9 +451,15 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - isPinnedCollapsed, - toggleSection, + sortBy, + activeTab, + // ensure reviews UI updates correctly + reviews, + reviewsTotalCount, + isLoadingReviews, + votingReviews, + deleteModalVisible, ]); return ( diff --git a/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx new file mode 100644 index 00000000..bea569e7 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx @@ -0,0 +1,252 @@ +import { motion, AnimatePresence } from "framer-motion"; +import { useNavigate } from "react-router-dom"; +import { ClockIcon } from "@primer/octicons-react"; +import { Star, ThumbsUp, ThumbsDown, TrashIcon, Languages } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import type { GameShop } from "@types"; +import { sanitizeHtml } from "@shared"; +import { useDate } from "@renderer/hooks"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ProfileReviewItemProps { + review: UserReview; + isOwnReview: boolean; + isVoting: boolean; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ProfileReviewItem({ + review, + isOwnReview, + isVoting, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const navigate = useNavigate(); + const { formatDistance } = useDate(); + const { t } = useTranslation("user_profile"); + const { t: tGameDetails, i18n } = useTranslation("game_details"); + const [showOriginal, setShowOriginal] = useState(false); + + const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || ""; + + const isDifferentLanguage = + getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language); + + const needsTranslation = + !isOwnReview && isDifferentLanguage && review.translations[i18n.language]; + + const getLanguageName = (languageCode: string | null) => { + if (!languageCode) return ""; + try { + const displayNames = new Intl.DisplayNames([i18n.language], { + type: "language", + }); + return displayNames.of(languageCode) || languageCode.toUpperCase(); + } catch { + return languageCode.toUpperCase(); + } + }; + + const displayContent = needsTranslation + ? review.translations[i18n.language] + : review.reviewHtml; + + return ( + +
    +
    +
    +
    +
    + {review.game.title} + +
    +
    +
    +
    + {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
    +
    +
    +
    +
    + + + {review.score}/5 + +
    + {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
    + + + {tGameDetails("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
    + )} +
    +
    +
    + +
    +
    + {needsTranslation && ( + <> + + {showOriginal && ( +
    + )} + + )} +
    + +
    +
    + onVote(review.id, true)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + + + onVote(review.id, false)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
    + + {isOwnReview && ( + + )} +
    + + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx new file mode 100644 index 00000000..bc76f40c --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -0,0 +1,67 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import "./profile-content.scss"; + +interface ProfileTabsProps { + activeTab: "library" | "reviews"; + reviewsTotalCount: number; + onTabChange: (tab: "library" | "reviews") => void; +} + +export function ProfileTabs({ + activeTab, + reviewsTotalCount, + onTabChange, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( +
    +
    + + {activeTab === "library" && ( + + )} +
    +
    + + {activeTab === "reviews" && ( + + )} +
    +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx new file mode 100644 index 00000000..afcc417b --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx @@ -0,0 +1,96 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; +import { ProfileReviewItem } from "./profile-review-item"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ReviewsTabProps { + reviews: UserReview[]; + isLoadingReviews: boolean; + votingReviews: Set; + userDetailsId?: string; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ReviewsTab({ + reviews, + isLoadingReviews, + votingReviews, + userDetailsId, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( + + {isLoadingReviews && ( +
    {t("loading_reviews")}
    + )} + {!isLoadingReviews && reviews.length === 0 && ( +
    +

    {t("no_reviews", "No reviews yet")}

    +
    + )} + {!isLoadingReviews && reviews.length > 0 && ( +
    + {reviews.map((review) => { + const isOwnReview = userDetailsId === review.user.id; + + return ( + + ); + })} +
    + )} +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index 61640536..76bd25a9 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -36,6 +36,7 @@ box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); width: 100%; position: relative; + overflow: hidden; &:before { content: ""; @@ -193,8 +194,28 @@ border-radius: 4px; width: 100%; height: 100%; - min-width: 100%; - min-height: 100%; + display: block; + } + + &__cover-placeholder { + position: relative; + width: 100%; + padding-bottom: 150%; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + border-radius: 4px; + color: rgba(255, 255, 255, 0.3); + + svg { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } } &__achievements-progress { diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index 72b48a8c..81db6334 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -2,7 +2,7 @@ import { UserGame } from "@types"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import { useFormat, useToast } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { useCallback, useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { buildGameAchievementPath, buildGameDetailsPath, @@ -15,6 +15,7 @@ import { AlertFillIcon, PinIcon, PinSlashIcon, + ImageIcon, } from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { Tooltip } from "react-tooltip"; @@ -44,6 +45,11 @@ export function UserLibraryGameCard({ const navigate = useNavigate(); const [isTooltipHovered, setIsTooltipHovered] = useState(false); const [isPinning, setIsPinning] = useState(false); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + setImageError(false); + }, [game.coverImageUrl]); const getStatsItemCount = useCallback(() => { let statsCount = 1; @@ -233,11 +239,18 @@ export function UserLibraryGameCard({ )}
    - {game.title} + {imageError || !game.coverImageUrl ? ( +
    + +
    + ) : ( + {game.title} setImageError(true)} + /> + )} (); + useEffect(() => { window.electron.getDefaultDownloadsPath().then((path) => { setDefaultDownloadsPath(path); @@ -81,6 +91,9 @@ export function SettingsGeneral() { return () => { clearInterval(interval); + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } }; }, []); @@ -110,6 +123,9 @@ export function SettingsGeneral() { userPreferences.achievementCustomNotificationsEnabled ?? true, achievementCustomNotificationPosition: userPreferences.achievementCustomNotificationPosition ?? "top-left", + achievementSoundVolume: Math.round( + (userPreferences.achievementSoundVolume ?? 0.15) * 100 + ), friendRequestNotificationsEnabled: userPreferences.friendRequestNotificationsEnabled ?? false, friendStartGameNotificationsEnabled: @@ -148,6 +164,21 @@ export function SettingsGeneral() { await updateUserPreferences(values); }; + const handleVolumeChange = useCallback( + (newVolume: number) => { + setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume })); + + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } + + volumeUpdateTimeoutRef.current = setTimeout(() => { + updateUserPreferences({ achievementSoundVolume: newVolume / 100 }); + }, 300); + }, + [updateUserPreferences] + ); + const handleChangeAchievementCustomNotificationPosition = async ( event: React.ChangeEvent ) => { @@ -309,6 +340,39 @@ export function SettingsGeneral() { )} + {form.achievementNotificationsEnabled && ( +
    + +
    + + { + const volumePercent = parseInt(e.target.value, 10); + if (!isNaN(volumePercent)) { + handleVolumeChange(volumePercent); + } + }} + className="settings-general__volume-slider" + style={ + { + "--volume-percent": `${form.achievementSoundVolume}%`, + } as React.CSSProperties + } + /> + + {form.achievementSoundVolume}% + +
    +
    + )} +

    {t("common_redist")}

    diff --git a/src/renderer/src/pages/theme-editor/theme-editor.scss b/src/renderer/src/pages/theme-editor/theme-editor.scss index 38061c88..486f694c 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.scss +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -47,6 +47,8 @@ position: relative; border: 1px solid globals.$muted-color; border-radius: 2px; + flex: 1; + min-width: 0; } &__footer { @@ -80,7 +82,7 @@ } &__info { - padding: 16px; + padding: 8px; p { font-size: 16px; @@ -93,12 +95,39 @@ &__notification-preview { padding-top: 12px; display: flex; - flex-direction: row; - align-items: center; + flex-direction: column; gap: 16px; &__select-variation { flex: inherit; } } + + &__notification-preview-controls { + display: flex; + flex-direction: column; + gap: 16px; + flex-shrink: 0; + } + + &__notification-controls { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + + &__sound-actions { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } + + &__sound-actions-row { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } } diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 9df3e9f4..3f0be9cf 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -3,11 +3,16 @@ import "./theme-editor.scss"; import Editor from "@monaco-editor/react"; import { AchievementCustomNotificationPosition, Theme } from "@types"; import { useSearchParams } from "react-router-dom"; -import { Button, SelectField } from "@renderer/components"; -import { CheckIcon } from "@primer/octicons-react"; +import { Button, SelectField, TextField } from "@renderer/components"; +import { + CheckIcon, + UploadIcon, + TrashIcon, + PlayIcon, +} from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import cn from "classnames"; -import { injectCustomCss } from "@renderer/helpers"; +import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; @@ -27,6 +32,7 @@ export default function ThemeEditor() { const [theme, setTheme] = useState(null); const [code, setCode] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [soundPath, setSoundPath] = useState(""); const [isClosingNotifications, setIsClosingNotifications] = useState(false); @@ -62,6 +68,9 @@ export default function ThemeEditor() { if (loadedTheme) { setTheme(loadedTheme); setCode(loadedTheme.code); + if (loadedTheme.originalSoundPath) { + setSoundPath(loadedTheme.originalSoundPath); + } if (shadowRootRef) { injectCustomCss(loadedTheme.code, shadowRootRef); } @@ -107,6 +116,73 @@ export default function ThemeEditor() { } }; + const handleSelectSound = useCallback(async () => { + if (!theme) return; + + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: "Audio", + extensions: ["wav", "mp3", "ogg", "m4a"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + const originalPath = filePaths[0]; + await window.electron.copyThemeAchievementSound(theme.id, originalPath); + const updatedTheme = await window.electron.getCustomThemeById(theme.id); + if (updatedTheme) { + setTheme(updatedTheme); + if (updatedTheme.originalSoundPath) { + setSoundPath(updatedTheme.originalSoundPath); + } + } + } + }, [theme]); + + const handleRemoveSound = useCallback(async () => { + if (!theme) return; + + await window.electron.removeThemeAchievementSound(theme.id); + const updatedTheme = await window.electron.getCustomThemeById(theme.id); + if (updatedTheme) { + setTheme(updatedTheme); + } + setSoundPath(""); + }, [theme]); + + const handlePreviewSound = useCallback(async () => { + if (!theme) return; + + let soundUrl: string; + + if (theme.hasCustomSound) { + const themeSoundUrl = await window.electron.getThemeSoundDataUrl( + theme.id + ); + if (themeSoundUrl) { + soundUrl = themeSoundUrl; + } else { + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; + soundUrl = defaultSound; + } + } else { + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; + soundUrl = defaultSound; + } + + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; + audio.play(); + }, [theme]); + const achievementCustomNotificationPositionOptions = useMemo(() => { return [ "top-left", @@ -164,35 +240,66 @@ export default function ThemeEditor() {

    - { - return { - key: variation, - value: variation, - label: t(variation), - }; - } - )} - onChange={(value) => - setNotificationVariation( - value.target.value as keyof typeof notificationVariations - ) +
    +
    + { + return { + key: variation, + value: variation, + label: t(variation), + }; + } + )} + onChange={(value) => + setNotificationVariation( + value.target.value as keyof typeof notificationVariations + ) + } + /> + + + setNotificationAlignment( + e.target.value as AchievementCustomNotificationPosition + ) + } + options={achievementCustomNotificationPositionOptions} + /> +
    +
    + + + + {t("select")} + } /> - - setNotificationAlignment( - e.target.value as AchievementCustomNotificationPosition - ) - } - options={achievementCustomNotificationPositionOptions} - /> + {theme?.hasCustomSound && ( +
    + + +
    + )}
    diff --git a/src/types/index.ts b/src/types/index.ts index 18331210..9c4f5b28 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,6 +23,7 @@ export interface GameRepack { uploadDate: string | null; downloadSourceId: string; downloadSourceName: string; + createdAt: string; } export interface DownloadSource { @@ -41,9 +42,9 @@ export interface ShopAssets { shop: GameShop; title: string; iconUrl: string | null; - libraryHeroImageUrl: string; - libraryImageUrl: string; - logoImageUrl: string; + libraryHeroImageUrl: string | null; + libraryImageUrl: string | null; + logoImageUrl: string | null; logoPosition: string | null; coverImageUrl: string | null; downloadSources: string[]; @@ -362,6 +363,8 @@ export type LibraryGame = Game & Partial & { id: string; download: Download | null; + unlockedAchievementCount?: number; + achievementCount?: number; }; export type UserGameDetails = ShopAssets & { diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 1df55b9e..fd930a12 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -56,9 +56,12 @@ export interface Game { launchOptions?: string | null; favorite?: boolean; isPinned?: boolean; + achievementCount?: number; + unlockedAchievementCount?: number; pinnedDate?: Date | null; automaticCloudSync?: boolean; hasManuallyUpdatedPlaytime?: boolean; + newDownloadOptionsCount?: number; } export interface Download { @@ -113,6 +116,7 @@ export interface UserPreferences { achievementNotificationsEnabled?: boolean; achievementCustomNotificationsEnabled?: boolean; achievementCustomNotificationPosition?: AchievementCustomNotificationPosition; + achievementSoundVolume?: number; friendRequestNotificationsEnabled?: boolean; friendStartGameNotificationsEnabled?: boolean; showDownloadSpeedInMegabytes?: boolean; diff --git a/src/types/steam.types.ts b/src/types/steam.types.ts index 4dcf460a..12d012ea 100644 --- a/src/types/steam.types.ts +++ b/src/types/steam.types.ts @@ -16,8 +16,11 @@ export interface SteamVideoSource { export interface SteamMovies { id: number; - mp4: SteamVideoSource; - webm: SteamVideoSource; + dash_av1?: string; + dash_h264?: string; + hls_h264?: string; + mp4?: SteamVideoSource; + webm?: SteamVideoSource; thumbnail: string; name: string; highlight: boolean; diff --git a/src/types/theme.types.ts b/src/types/theme.types.ts index abba8fc1..80976ec0 100644 --- a/src/types/theme.types.ts +++ b/src/types/theme.types.ts @@ -5,6 +5,8 @@ export interface Theme { authorName?: string; isActive: boolean; code: string; + hasCustomSound?: boolean; + originalSoundPath?: string; createdAt: Date; updatedAt: Date; } diff --git a/yarn.lock b/yarn.lock index 6fb80492..da346e42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7538,6 +7538,13 @@ react-i18next@^14.1.0: "@babel/runtime" "^7.23.9" html-parse-stringify "^3.0.1" +react-infinite-scroll-component@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f" + integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ== + dependencies: + throttle-debounce "^2.1.0" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -8540,6 +8547,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +throttle-debounce@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" + integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== + "through@>=2.2.7 <3": version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"