feat: merge with v3.7.4
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled

This commit is contained in:
Chubby Granny Chaser
2025-11-29 01:33:23 +00:00
102 changed files with 5957 additions and 465 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -2,11 +2,9 @@
**When submitting this pull request, I confirm the following (please check the boxes):** **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 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 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. - [ ] 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:** **Fill in the PR content:**
-

View File

@@ -2,6 +2,9 @@ name: Build
on: on:
pull_request: pull_request:
push:
branches:
- main
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -137,7 +137,7 @@ jobs:
if git diff --staged --quiet; then if git diff --staged --quiet; then
echo "No changes to commit" echo "No changes to commit"
else else
COMMIT_MSG="v${{ steps.get-version.outputs.version }}" COMMIT_MSG="${{ steps.get-version.outputs.version }}"
git commit -m "$COMMIT_MSG" git commit -m "$COMMIT_MSG"

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.7.3", "version": "3.7.4",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -75,6 +75,7 @@
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-infinite-scroll-component": "^6.1.0",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1", "react-redux": "^9.1.1",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",

View File

@@ -153,8 +153,11 @@ def profile_image():
data = request.get_json() data = request.get_json()
image_path = data.get('image_path') image_path = data.get('image_path')
# use webp as default value for target_extension
target_extension = data.get('target_extension') or 'webp'
try: 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 return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400

View File

@@ -4,7 +4,7 @@ import os, uuid, tempfile
class ProfileImageProcessor: class ProfileImageProcessor:
@staticmethod @staticmethod
def get_parsed_image_data(image_path): def get_parsed_image_data(image_path, target_extension):
Image.MAX_IMAGE_PIXELS = 933120000 Image.MAX_IMAGE_PIXELS = 933120000
image = Image.open(image_path) image = Image.open(image_path)
@@ -16,7 +16,7 @@ class ProfileImageProcessor:
return image_path, mime_type return image_path, mime_type
else: else:
new_uuid = str(uuid.uuid4()) 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) image.save(new_image_path)
new_image = Image.open(new_image_path) new_image = Image.open(new_image_path)
@@ -26,5 +26,5 @@ class ProfileImageProcessor:
@staticmethod @staticmethod
def process_image(image_path): def process_image(image_path, target_extension):
return ProfileImageProcessor.get_parsed_image_data(image_path) return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)

View File

@@ -13,6 +13,7 @@
}, },
"sidebar": { "sidebar": {
"catalogue": "Catalogue", "catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads", "downloads": "Downloads",
"settings": "Settings", "settings": "Settings",
"my_library": "My library", "my_library": "My library",
@@ -92,8 +93,16 @@
}, },
"header": { "header": {
"search": "Search games", "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", "home": "Home",
"catalogue": "Catalogue", "catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads", "downloads": "Downloads",
"search_results": "Search results", "search_results": "Search results",
"settings": "Settings", "settings": "Settings",
@@ -194,6 +203,7 @@
"download_in_progress": "Download in progress", "download_in_progress": "Download in progress",
"download_paused": "Download paused", "download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option", "last_downloaded_option": "Last downloaded option",
"new_download_option": "New",
"create_steam_shortcut": "Create Steam shortcut", "create_steam_shortcut": "Create Steam shortcut",
"create_shortcut_success": "Shortcut created successfully", "create_shortcut_success": "Shortcut created successfully",
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes", "you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
@@ -555,6 +565,15 @@
"platinum": "Platinum", "platinum": "Platinum",
"hidden": "Hidden", "hidden": "Hidden",
"test_notification": "Test notification", "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", "notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game", "enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
@@ -693,7 +712,31 @@
"game_added_to_pinned": "Game added to pinned", "game_added_to_pinned": "Game added to pinned",
"karma": "Karma", "karma": "Karma",
"karma_count": "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": {
"achievement_unlocked": "Achievement unlocked", "achievement_unlocked": "Achievement unlocked",

View File

@@ -13,6 +13,7 @@
}, },
"sidebar": { "sidebar": {
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas", "downloads": "Descargas",
"settings": "Ajustes", "settings": "Ajustes",
"my_library": "Mi Librería", "my_library": "Mi Librería",
@@ -192,6 +193,7 @@
"download_in_progress": "Descarga en progreso", "download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada", "download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción de descarga", "last_downloaded_option": "Última opción de descarga",
"new_download_option": "Nuevo",
"create_steam_shortcut": "Crear atajo de Steam", "create_steam_shortcut": "Crear atajo de Steam",
"create_shortcut_success": "Atajo creado con éxito", "create_shortcut_success": "Atajo creado con éxito",
"you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios", "you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
@@ -325,6 +327,7 @@
"maybe_later": "Tal vez después", "maybe_later": "Tal vez después",
"no_repacks_found": "Sin fuentes encontradas para este juego", "no_repacks_found": "Sin fuentes encontradas para este juego",
"no_reviews_yet": "Sin reseñas aún", "no_reviews_yet": "Sin reseñas aún",
"review_played_for": "Jugado por",
"properties": "Propiedades", "properties": "Propiedades",
"rating": "Calificación", "rating": "Calificación",
"rating_count": "Calificación", "rating_count": "Calificación",
@@ -542,6 +545,12 @@
"platinum": "Platino", "platinum": "Platino",
"hidden": "Oculto", "hidden": "Oculto",
"test_notification": "Probar notificación", "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", "notification_preview": "Probar notificación de logro",
"debrid": "Debrid", "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.", "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_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas", "karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:", "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": {
"achievement_unlocked": "Logro desbloqueado", "achievement_unlocked": "Logro desbloqueado",
@@ -712,5 +725,26 @@
"hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!", "hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!",
"learn_more": "Descubrir más", "learn_more": "Descubrir más",
"debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus" "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"
} }
} }

View File

@@ -8,11 +8,12 @@
"no_results": "Nincs találat", "no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...", "start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott", "hot": "Most felkapott",
"weekly": "📅 A hét felkapottjai", "weekly": "📅 Heti kiemeltek",
"achievements": "🏆 Achievement támogatott" "achievements": "🏆 Achievement támogatott"
}, },
"sidebar": { "sidebar": {
"catalogue": "Katalógus", "catalogue": "Katalógus",
"library": "Könyvtár",
"downloads": "Letöltések", "downloads": "Letöltések",
"settings": "Beállítások", "settings": "Beállítások",
"my_library": "Könyvtáram", "my_library": "Könyvtáram",
@@ -81,7 +82,7 @@
"update_decky_plugin": "Decky Plugin Frissítése", "update_decky_plugin": "Decky Plugin Frissítése",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})", "decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint", "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_title": "Hydra Decky Plugin Frissítése",
"update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?", "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", "decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
@@ -92,8 +93,10 @@
}, },
"header": { "header": {
"search": "Keresés", "search": "Keresés",
"search_library": "Könyvtár böngészése",
"home": "Főoldal", "home": "Főoldal",
"catalogue": "Katalógus", "catalogue": "Katalógus",
"library": "Könyvtár",
"downloads": "Letöltések", "downloads": "Letöltések",
"search_results": "Keresési találatok", "search_results": "Keresési találatok",
"settings": "Beállítások", "settings": "Beállítások",
@@ -117,7 +120,7 @@
"tags": "Címkék", "tags": "Címkék",
"publishers": "Kiadók", "publishers": "Kiadók",
"download_sources": "Letöltési források", "download_sources": "Letöltési források",
"result_count": "{{resultCount}} találatok", "result_count": "{{resultCount}} találat",
"filter_count": "{{filterCount}} elérhető", "filter_count": "{{filterCount}} elérhető",
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése" "clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
}, },
@@ -166,11 +169,11 @@
"download_now": "Letöltés", "download_now": "Letöltés",
"no_shop_details": "A bolt adatai nem érhetőek el.", "no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók", "download_options": "Letöltési opciók",
"download_path": "Letöltis hely", "download_path": "Letöltési hely",
"previous_screenshot": "Előző screenshot", "previous_screenshot": "Előző screenshot",
"next_screenshot": "Következő screenshot", "next_screenshot": "Következő screenshot",
"screenshot": "Screenshot {{number}}", "screenshot": "Screenshot {{number}}",
"open_screenshot": "Screenshot megnyitása {{number}}", "open_screenshot": "{{number}} Screenshot megnyitása ",
"download_settings": "Letöltési beállítások", "download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód", "downloader": "Letöltési mód",
"select_executable": "Tallózás", "select_executable": "Tallózás",
@@ -194,6 +197,7 @@
"download_in_progress": "Letöltés folyamatban", "download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve", "download_paused": "Letöltés szüneteltetve",
"last_downloaded_option": "Utoljára letöltött", "last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új",
"create_steam_shortcut": "Steam parancsikon létrehozása", "create_steam_shortcut": "Steam parancsikon létrehozása",
"create_shortcut_success": "A parancsikon létrehozása sikeres", "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.", "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_more": "Mutass többet",
"show_less": "Mutass kevesebbet", "show_less": "Mutass kevesebbet",
"reviews": "Vélemények", "reviews": "Vélemények",
"review_played_for": "Játszva",
"leave_a_review": "Hagyd itt a véleményed", "leave_a_review": "Hagyd itt a véleményed",
"write_review_placeholder": "Oszd meg gondolatod a játékról...", "write_review_placeholder": "Oszd meg gondolatod a játékról...",
"sort_newest": "Legújabb", "sort_newest": "Legújabb",
@@ -361,7 +366,10 @@
"show_original": "Eredeti megjelenítése", "show_original": "Eredeti megjelenítése",
"show_translation": "Fordítás megjelenítése", "show_translation": "Fordítás megjelenítése",
"show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})", "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": { "activation": {
"title": "Hydra Aktiválása", "title": "Hydra Aktiválása",
@@ -488,11 +496,11 @@
"no_email_account": "Még nincs beállított emailed", "no_email_account": "Még nincs beállított emailed",
"account_data_updated_successfully": "Fiókadatok változtatása sikeres", "account_data_updated_successfully": "Fiókadatok változtatása sikeres",
"renew_subscription": "Hydra Cloud Megújítása", "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", "no_subscription": "Élvezd a Hydrát a lehető legjobb módon",
"become_subscriber": "Légy Hydra Cloud tag", "become_subscriber": "Légy Hydra Cloud tag",
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva", "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", "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.", "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", "editor_tab_code": "Code",
@@ -551,10 +559,19 @@
"platinum": "Platina", "platinum": "Platina",
"hidden": "Rejtett", "hidden": "Rejtett",
"test_notification": "Értesítés tesztelése", "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", "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", "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", "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": { "notifications": {
"download_complete": "Letöltés befejezve", "download_complete": "Letöltés befejezve",
@@ -670,7 +687,7 @@
"report_reason_other": "Egyéb", "report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve", "profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:", "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…", "uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve", "background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák", "stats": "Statisztikák",
@@ -689,7 +706,31 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma", "karma": "Karma",
"karma_count": "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": {
"achievement_unlocked": "Achievement feloldva", "achievement_unlocked": "Achievement feloldva",

View File

@@ -13,6 +13,7 @@
}, },
"sidebar": { "sidebar": {
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads", "downloads": "Downloads",
"settings": "Ajustes", "settings": "Ajustes",
"my_library": "Biblioteca", "my_library": "Biblioteca",
@@ -182,6 +183,7 @@
"download_in_progress": "Download em andamento", "download_in_progress": "Download em andamento",
"download_paused": "Download pausado", "download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada", "last_downloaded_option": "Última opção baixada",
"new_download_option": "Novo",
"create_steam_shortcut": "Criar atalho na Steam", "create_steam_shortcut": "Criar atalho na Steam",
"create_shortcut_success": "Atalho criado com sucesso", "create_shortcut_success": "Atalho criado com sucesso",
"you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações", "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_lowest_score": "Menor Nota",
"sort_most_voted": "Mais Votadas", "sort_most_voted": "Mais Votadas",
"no_reviews_yet": "Ainda não há avaliações", "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!", "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
"rating": "Avaliação", "rating": "Avaliação",
"rating_stats": "Avaliação", "rating_stats": "Avaliação",
@@ -541,6 +544,12 @@
"platinum": "Platina", "platinum": "Platina",
"hidden": "Oculta", "hidden": "Oculta",
"test_notification": "Testar notificação", "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", "notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
@@ -697,7 +706,11 @@
"karma": "Karma", "karma": "Karma",
"karma_count": "karma", "karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações", "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": {
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",
@@ -727,5 +740,26 @@
"hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!", "hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!",
"learn_more": "Saiba mais", "learn_more": "Saiba mais",
"debrid_description": "Baixe até 4x mais rápido com Nimbus" "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"
} }
} }

View File

@@ -184,7 +184,8 @@
"review_from_blocked_user": "Avaliação de utilizador bloqueado", "review_from_blocked_user": "Avaliação de utilizador bloqueado",
"review_played_for": "Jogaste por", "review_played_for": "Jogaste por",
"show": "Mostrar", "show": "Mostrar",
"hide": "Ocultar" "hide": "Ocultar",
"review_played_for": "Jogado por"
}, },
"activation": { "activation": {
"title": "Ativação", "title": "Ativação",
@@ -470,7 +471,11 @@
"achievements_unlocked": "Conquistas desbloqueadas", "achievements_unlocked": "Conquistas desbloqueadas",
"earned_points": "Pontos ganhos", "earned_points": "Pontos ganhos",
"show_achievements_on_profile": "Mostre as suas conquistas no perfil", "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": {
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",

View File

@@ -13,6 +13,7 @@
}, },
"sidebar": { "sidebar": {
"catalogue": "Каталог", "catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки", "downloads": "Загрузки",
"settings": "Настройки", "settings": "Настройки",
"my_library": "Библиотека", "my_library": "Библиотека",
@@ -194,6 +195,7 @@
"download_in_progress": "Идёт загрузка", "download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена", "download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки", "last_downloaded_option": "Последний вариант загрузки",
"new_download_option": "Новый",
"create_steam_shortcut": "Создать ярлык Steam", "create_steam_shortcut": "Создать ярлык Steam",
"create_shortcut_success": "Ярлык создан", "create_shortcut_success": "Ярлык создан",
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения", "you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
@@ -228,6 +230,7 @@
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Сначала новые", "sort_newest": "Сначала новые",
"no_reviews_yet": "Пока нет отзывов", "no_reviews_yet": "Пока нет отзывов",
"review_played_for": "Играли",
"be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Сначала старые", "sort_oldest": "Сначала старые",
"sort_highest_score": "Высший балл", "sort_highest_score": "Высший балл",
@@ -555,6 +558,12 @@
"platinum": "Платиновый", "platinum": "Платиновый",
"hidden": "Скрытый", "hidden": "Скрытый",
"test_notification": "Тестовое уведомление", "test_notification": "Тестовое уведомление",
"achievement_sound_volume": "Громкость звука достижения",
"select_achievement_sound": "Выбрать звук достижения",
"select": "Выбрать",
"preview": "Предпросмотр",
"remove": "Удалить",
"no_sound_file_selected": "Файл звука не выбран",
"notification_preview": "Предварительный просмотр уведомления о достижении", "notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру", "enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
@@ -693,7 +702,11 @@
"game_added_to_pinned": "Игра добавлена в закрепленные", "game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма", "karma": "Карма",
"karma_count": "карма", "karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов" "karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Достижение разблокировано", "achievement_unlocked": "Достижение разблокировано",
@@ -723,5 +736,26 @@
"hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!", "hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!",
"learn_more": "Подробнее", "learn_more": "Подробнее",
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus" "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": "Избранное"
} }
} }

View File

@@ -16,6 +16,7 @@
"downloads": "İndirilenler", "downloads": "İndirilenler",
"settings": "Ayarlar", "settings": "Ayarlar",
"my_library": "Kütüphanem", "my_library": "Kütüphanem",
"library": "Kütüphane",
"downloading_metadata": "{{title}} (Meta verileri indiriliyor…)", "downloading_metadata": "{{title}} (Meta verileri indiriliyor…)",
"paused": "{{title}} (Duraklatıldı)", "paused": "{{title}} (Duraklatıldı)",
"downloading": "{{title}} (%{{percentage}} - İndiriliyor…)", "downloading": "{{title}} (%{{percentage}} - İndiriliyor…)",
@@ -26,7 +27,69 @@
"sign_in": "Giriş Yap", "sign_in": "Giriş Yap",
"friends": "Arkadaşlar", "friends": "Arkadaşlar",
"need_help": "Yardıma mı ihtiyacınız var?", "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": { "header": {
"search": "Oyunlarda Ara", "search": "Oyunlarda Ara",
@@ -35,6 +98,8 @@
"downloads": "İndirilenler", "downloads": "İndirilenler",
"search_results": "Arama Sonuçları", "search_results": "Arama Sonuçları",
"settings": "Ayarlar", "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_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." "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", "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": "Geçersiz Wine ön ek yolu",
"invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.", "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": { "activation": {
"title": "Hydra'yı Etkinleştir", "title": "Hydra'yı Etkinleştir",
@@ -379,7 +545,33 @@
"hidden": "Gizli", "hidden": "Gizli",
"test_notification": "Test bildirimi", "test_notification": "Test bildirimi",
"notification_preview": "Başarı Bildirimi Önizlemesi", "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": { "notifications": {
"download_complete": "İndirme tamamlandı", "download_complete": "İndirme tamamlandı",
@@ -406,7 +598,8 @@
"game_card": { "game_card": {
"available_one": "Mevcut", "available_one": "Mevcut",
"available_other": "Mevcut", "available_other": "Mevcut",
"no_downloads": "İndirme mevcut değil" "no_downloads": "İndirme mevcut değil",
"calculating": "Hesaplanıyor"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "Programlar Yüklü Değil", "title": "Programlar Yüklü Değil",
@@ -498,7 +691,46 @@
"achievements_unlocked": "Açılan başarımlar", "achievements_unlocked": "Açılan başarımlar",
"earned_points": "Kazanılan puanlar", "earned_points": "Kazanılan puanlar",
"show_achievements_on_profile": "Başarımlarını profilinde göster", "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": {
"achievement_unlocked": "Başarım açıldı", "achievement_unlocked": "Başarım açıldı",

View File

@@ -27,7 +27,68 @@
"friends": "好友", "friends": "好友",
"favorites": "收藏", "favorites": "收藏",
"need_help": "需要帮助?", "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": { "header": {
"search": "搜索游戏", "search": "搜索游戏",
@@ -218,7 +279,93 @@
"reset_achievements_title": "您确定吗?", "reset_achievements_title": "您确定吗?",
"save_changes": "保存更改", "save_changes": "保存更改",
"unfreeze_backup": "取消固定", "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": { "activation": {
"title": "激活 Hydra", "title": "激活 Hydra",
@@ -394,7 +541,24 @@
"update_email": "更新邮箱", "update_email": "更新邮箱",
"update_password": "更新密码", "update_password": "更新密码",
"variation": "变体", "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": { "notifications": {
"download_complete": "下载完成", "download_complete": "下载完成",
@@ -421,7 +585,8 @@
"game_card": { "game_card": {
"no_downloads": "无可用下载选项", "no_downloads": "无可用下载选项",
"available_one": "可用", "available_one": "可用",
"available_other": "可用" "available_other": "可用",
"calculating": "正在计算"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "程序未安装", "title": "程序未安装",
@@ -515,7 +680,23 @@
"show_achievements_on_profile": "在您的个人资料上显示成就", "show_achievements_on_profile": "在您的个人资料上显示成就",
"show_points_on_profile": "在您的个人资料上显示获得的积分", "show_points_on_profile": "在您的个人资料上显示获得的积分",
"stats": "统计", "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": {
"achievement_unlocked": "成就已解锁", "achievement_unlocked": "成就已解锁",

View File

@@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); 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 MAIN_LOOP_INTERVAL = 2000;
export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15;
export const DECKY_PLUGINS_LOCATION = path.join( export const DECKY_PLUGINS_LOCATION = path.join(
SystemPath.getPath("home"), SystemPath.getPath("home"),
"homebrew", "homebrew",

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -18,7 +18,9 @@ import "./library/close-game";
import "./library/delete-game-folder"; import "./library/delete-game-folder";
import "./library/get-game-by-object-id"; import "./library/get-game-by-object-id";
import "./library/get-library"; import "./library/get-library";
import "./library/refresh-library-assets";
import "./library/extract-game-download"; import "./library/extract-game-download";
import "./library/clear-new-download-options";
import "./library/open-game"; import "./library/open-game";
import "./library/open-game-executable-path"; import "./library/open-game-executable-path";
import "./library/open-game-installer"; import "./library/open-game-installer";
@@ -64,6 +66,8 @@ import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox"; import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source"; import "./download-sources/add-download-source";
import "./download-sources/sync-download-sources"; 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/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
@@ -91,6 +95,11 @@ import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme"; import "./themes/get-active-custom-theme";
import "./themes/close-editor-window"; import "./themes/close-editor-window";
import "./themes/toggle-custom-theme"; 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/remove-download-source";
import "./download-sources/get-download-sources"; import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers"; import { isPortableVersion } from "@main/helpers";

View File

@@ -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);

View File

@@ -2,6 +2,7 @@ import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { import {
downloadsSublevel, downloadsSublevel,
gameAchievementsSublevel,
gamesShopAssetsSublevel, gamesShopAssetsSublevel,
gamesSublevel, gamesSublevel,
} from "@main/level"; } from "@main/level";
@@ -18,15 +19,28 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
const download = await downloadsSublevel.get(key); const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.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 { return {
id: key, id: key,
...game, ...game,
download: download ?? null, download: download ?? null,
unlockedAchievementCount,
achievementCount: game.achievementCount ?? 0,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets, ...gameAssets,
// Ensure compatibility with LibraryGame type // Preserve custom image URLs from game if they exist
libraryHeroImageUrl: customIconUrl: game.customIconUrl,
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, customLogoImageUrl: game.customLogoImageUrl,
} as LibraryGame; customHeroImageUrl: game.customHeroImageUrl,
};
}) })
); );
}); });

View File

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

View File

@@ -1,16 +1,20 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { PythonRPC } from "@main/services/python-rpc"; import { PythonRPC } from "@main/services/python-rpc";
const processProfileImage = async ( const processProfileImageEvent = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
path: string path: string
) => { ) => {
return processProfileImage(path, "webp");
};
export const processProfileImage = async (path: string, extension?: string) => {
return PythonRPC.rpc return PythonRPC.rpc
.post<{ .post<{
imagePath: string; imagePath: string;
mimeType: string; mimeType: string;
}>("/profile-image", { image_path: path }) }>("/profile-image", { image_path: path, target_extension: extension })
.then((response) => response.data); .then((response) => response.data);
}; };
registerEvent("processProfileImage", processProfileImage); registerEvent("processProfileImage", processProfileImageEvent);

View File

@@ -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<void> => {
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);

View File

@@ -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<string | null> => {
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<string, string> = {
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);

View File

@@ -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<string | null> => {
const theme = await themesSublevel.get(themeId);
return getThemeSoundPath(themeId, theme?.name);
};
registerEvent("getThemeSoundPath", getThemeSoundPathEvent);

View File

@@ -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<void> => {
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);

View File

@@ -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<void> => {
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);

View File

@@ -2,6 +2,8 @@ import axios from "axios";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import UserAgent from "user-agents"; import UserAgent from "user-agents";
import path from "node:path"; import path from "node:path";
import fs from "node:fs";
import { THEMES_PATH } from "@main/constants";
export const getFileBuffer = async (url: string) => export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) => fetch(url, { method: "GET" }).then((response) =>
@@ -31,9 +33,64 @@ export const isPortableVersion = () => {
}; };
export const normalizePath = (str: string) => export const normalizePath = (str: string) =>
path.posix.normalize(str).replace(/\\/g, "/"); path.posix.normalize(str).replaceAll("\\", "/");
export const addTrailingSlash = (str: string) => export const addTrailingSlash = (str: string) =>
str.endsWith("/") ? str : `${str}/`; 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"; export * from "./reg-parser";

View File

@@ -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<void> => {
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<void> => {
const utcTimestamp = new Date(timestamp).toISOString();
await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp);
};

View File

@@ -7,3 +7,4 @@ export * from "./game-achievements";
export * from "./keys"; export * from "./keys";
export * from "./themes"; export * from "./themes";
export * from "./download-sources"; export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";

View File

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

View File

@@ -16,6 +16,8 @@ import {
Ludusavi, Ludusavi,
Lock, Lock,
DeckyPlugin, DeckyPlugin,
DownloadSourcesChecker,
WSClient,
} from "@main/services"; } from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources"; import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -56,7 +58,10 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user"); const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi(); void syncDownloadSourcesFromApi();
// WSClient.connect();
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
WSClient.connect();
}); });
const downloads = await downloadsSublevel const downloads = await downloadsSublevel

View File

@@ -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<void> {
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);
}
}
}

View File

@@ -1,6 +1,7 @@
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support"; import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie"; import { CookieJar } from "tough-cookie";
import { logger } from "@main/services";
export class DatanodesApi { export class DatanodesApi {
private static readonly jar = new CookieJar(); private static readonly jar = new CookieJar();
@@ -20,51 +21,42 @@ export class DatanodesApi {
await this.jar.setCookie("lang=english;", "https://datanodes.to"); await this.jar.setCookie("lang=english;", "https://datanodes.to");
const payload = new URLSearchParams({ const formData = new FormData();
op: "download2", formData.append("op", "download2");
id: fileCode, formData.append("id", fileCode);
method_free: "Free Download >>", formData.append("rand", "");
dl: "1", 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( const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download", "https://datanodes.to/download",
payload, formData,
{ {
headers: { headers: {
"User-Agent": accept: "*/*",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0", "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", 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) { if (typeof response.data === "object" && response.data.url) {
return decodeURIComponent(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"); throw new Error("Failed to get the download link");
} catch (error) { } catch (error) {
console.error("Error fetching download URL:", error); logger.error("Error fetching download URL:", error);
throw error; throw error;
} }
} }

View File

@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level"; import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels"; import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types"; import type { Auth, User } from "@types";
import { WSClient } from "./ws";
export interface HydraApiOptions { export interface HydraApiOptions {
needsAuth?: boolean; needsAuth?: boolean;
@@ -29,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance; private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes 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) { private static secondsToMilliseconds(seconds: number) {
return seconds * 1000; return seconds * 1000;
@@ -103,8 +104,8 @@ export class HydraApi {
await clearGamesRemoteIds(); await clearGamesRemoteIds();
uploadGamesBatch(); uploadGamesBatch();
// WSClient.close(); WSClient.close();
// WSClient.connect(); WSClient.connect();
const { syncDownloadSourcesFromApi } = await import("./user"); const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi(); syncDownloadSourcesFromApi();
@@ -399,4 +400,45 @@ export class HydraApi {
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .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;
}
}
} }

View File

@@ -19,3 +19,4 @@ export * from "./wine";
export * from "./lock"; export * from "./lock";
export * from "./decky-plugin"; export * from "./decky-plugin";
export * from "./user"; export * from "./user";
export * from "./download-sources-checker";

View File

@@ -9,6 +9,8 @@ type ProfileGame = {
hasManuallyUpdatedPlaytime: boolean; hasManuallyUpdatedPlaytime: boolean;
isFavorite?: boolean; isFavorite?: boolean;
isPinned?: boolean; isPinned?: boolean;
achievementCount: number;
unlockedAchievementCount: number;
} & ShopAssets; } & ShopAssets;
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
@@ -39,6 +41,8 @@ export const mergeWithRemoteGames = async () => {
playTimeInMilliseconds: updatedPlayTime, playTimeInMilliseconds: updatedPlayTime,
favorite: game.isFavorite ?? localGame.favorite, favorite: game.isFavorite ?? localGame.favorite,
isPinned: game.isPinned ?? localGame.isPinned, isPinned: game.isPinned ?? localGame.isPinned,
achievementCount: game.achievementCount,
unlockedAchievementCount: game.unlockedAchievementCount,
}); });
} else { } else {
await gamesSublevel.put(gameKey, { await gamesSublevel.put(gameKey, {
@@ -55,18 +59,27 @@ export const mergeWithRemoteGames = async () => {
isDeleted: false, isDeleted: false,
favorite: game.isFavorite ?? false, favorite: game.isFavorite ?? false,
isPinned: game.isPinned ?? false, isPinned: game.isPinned ?? false,
achievementCount: game.achievementCount,
unlockedAchievementCount: game.unlockedAchievementCount,
}); });
} }
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); 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, { await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(), updatedAt: Date.now(),
...localGameShopAsset, ...localGameShopAsset,
shop: game.shop, shop: game.shop,
objectId: game.objectId, objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists title: localGame?.title || game.title, // Preserve local title if it exists
coverImageUrl: game.coverImageUrl, coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl, libraryImageUrl: game.libraryImageUrl,
logoImageUrl: game.logoImageUrl, logoImageUrl: game.logoImageUrl,

View File

@@ -11,9 +11,17 @@ import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger"; import { logger } from "../logger";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import type { Game, UserPreferences, UserProfile } from "@types"; 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 { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path"; 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) { async function downloadImage(url: string | null) {
if (!url) return undefined; if (!url) return undefined;
@@ -30,8 +38,9 @@ async function downloadImage(url: string | null) {
response.data.pipe(writer); response.data.pipe(writer);
return new Promise<string | undefined>((resolve) => { return new Promise<string | undefined>((resolve) => {
writer.on("finish", () => { writer.on("finish", async () => {
resolve(outputPath); const staticImagePath = await getStaticImage(outputPath);
resolve(staticImagePath);
}); });
writer.on("error", () => { writer.on("error", () => {
logger.error("Failed to download image", { url }); logger.error("Failed to download image", { url });
@@ -40,6 +49,27 @@ async function downloadImage(url: string | null) {
}); });
} }
async function getAchievementSoundPath(): Promise<string> {
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) => { export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences, levelKeys.userPreferences,
@@ -145,7 +175,8 @@ export const publishCombinedNewAchievementNotification = async (
if (WindowManager.mainWindow) { if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") { } 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) { if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") { } else if (process.platform !== "linux") {
sound.play(achievementSoundPath); const soundPath = await getAchievementSoundPath();
sound.play(soundPath);
} }
}; };

View File

@@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => {
if (!steamGameUrl) return null; if (!steamGameUrl) return null;
return { return {
title: $title.textContent, title: $title.getAttribute("data-title") || "",
objectId: steamGameUrl.split("/").pop(), objectId: steamGameUrl.split("/").pop(),
} as Steam250Game; } as Steam250Game;
}) })

View File

@@ -8,9 +8,11 @@ export const friendRequestEvent = async (payload: FriendRequest) => {
friendRequestCount: payload.friendRequestCount, friendRequestCount: payload.friendRequestCount,
}); });
const user = await HydraApi.get(`/users/${payload.senderId}`); if (payload.senderId) {
const user = await HydraApi.get(`/users/${payload.senderId}`);
if (user) { if (user) {
publishNewFriendRequestNotification(user); publishNewFriendRequestNotification(user);
}
} }
}; };

View File

@@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("removeDownloadSource", url, removeAll), ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesCheckBaseline: () =>
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
getDownloadSourcesSinceValue: () =>
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
/* Library */ /* Library */
toggleAutomaticCloudSync: ( toggleAutomaticCloudSync: (
@@ -179,6 +183,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId), ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) => removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: ( updateLaunchOptions: (
@@ -196,6 +202,7 @@ contextBridge.exposeInMainWorld("electron", {
verifyExecutablePathInUse: (executablePath: string) => verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"), getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) => openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId), ipcRenderer.invoke("openGameInstaller", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) => openGameInstallerPath: (shop: GameShop, objectId: string) =>
@@ -570,6 +577,25 @@ contextBridge.exposeInMainWorld("electron", {
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"), getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) => toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), 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 */ /* Editor */
openEditorWindow: (themeId: string) => openEditorWindow: (themeId: string) =>
@@ -580,6 +606,17 @@ contextBridge.exposeInMainWorld("electron", {
return () => return () =>
ipcRenderer.removeListener("on-custom-theme-updated", listener); 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) => closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId), ipcRenderer.invoke("closeEditorWindow", themeId),
}); });

View File

@@ -90,6 +90,7 @@ img {
progress[value] { progress[value] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none;
} }
.container { .container {

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { import {
@@ -10,6 +9,7 @@ import {
useToast, useToast,
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { import {
@@ -25,7 +25,12 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription"; import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; 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"; import "./app.scss";
export interface AppProps { export interface AppProps {
@@ -36,6 +41,9 @@ export function App() {
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary, library } = useLibrary(); const { updateLibrary, library } = useLibrary();
// Listen for new download options updates
useDownloadOptionsListener();
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
@@ -216,9 +224,11 @@ export function App() {
return () => unsubscribe(); return () => unsubscribe();
}, [loadAndApplyTheme]); }, [loadAndApplyTheme]);
const playAudio = useCallback(() => { const playAudio = useCallback(async () => {
const audio = new Audio(achievementSound); const soundUrl = await getAchievementSoundUrl();
audio.volume = 0.2; const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play(); audio.play();
}, []); }, []);
@@ -279,7 +289,11 @@ export function App() {
<article className="container"> <article className="container">
<Header /> <Header />
<section ref={contentRef} className="container__content"> <section
ref={contentRef}
id="scrollableDiv"
className="container__content"
>
<Outlet /> <Outlet />
</section> </section>
</article> </article>

View File

@@ -47,7 +47,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
> >
<div className="game-card__backdrop"> <div className="game-card__backdrop">
<img <img
src={game.libraryImageUrl} src={game.libraryImageUrl ?? undefined}
alt={game.title} alt={game.title}
className="game-card__cover" className="game-card__cover"
loading="lazy" loading="lazy"

View File

@@ -70,8 +70,10 @@ export function GameContextMenu({
onClick: () => { onClick: () => {
if (isGameRunning) { if (isGameRunning) {
void handleCloseGame(); void handleCloseGame();
} else { } else if (canPlay) {
void handlePlayGame(); void handlePlayGame();
} else {
handleOpenDownloadOptions();
} }
}, },
disabled: isDeleting, disabled: isDeleting,

View File

@@ -3,22 +3,30 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; 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 "./header.scss";
import { AutoUpdateSubHeader } from "./auto-update-sub-header"; import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters } from "@renderer/features"; import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames"; import cn from "classnames";
import { SearchDropdown } from "@renderer/components";
const pathTitle: Record<string, string> = { const pathTitle: Record<string, string> = {
"/": "home", "/": "home",
"/catalogue": "catalogue", "/catalogue": "catalogue",
"/library": "library",
"/downloads": "downloads", "/downloads": "downloads",
"/settings": "settings", "/settings": "settings",
}; };
export function Header() { export function Header() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -27,32 +35,95 @@ export function Header() {
(state) => state.window (state) => state.window
); );
const searchValue = useAppSelector( const catalogueSearchValue = useAppSelector(
(state) => state.catalogueSearch.filters.title (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 dispatch = useAppDispatch();
const [isFocused, setIsFocused] = useState(false); 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 { 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(() => { const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) 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"); if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]); return t(pathTitle[location.pathname]);
}, [location.pathname, headerTitle, t]); }, [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 = () => { const focusInput = () => {
setIsFocused(true); setIsFocused(true);
inputRef.current?.focus(); inputRef.current?.focus();
}; };
const handleFocus = () => {
if (isFocused && isDropdownVisible) {
updateDropdownPosition();
return;
}
setIsFocused(true);
setActiveIndex(-1);
setTimeout(() => {
updateDropdownPosition();
setIsDropdownVisible(true);
}, 220);
};
const handleBlur = () => { const handleBlur = () => {
setIsFocused(false); setTimeout(() => {
setIsFocused(false);
setIsDropdownVisible(false);
setActiveIndex(-1);
}, 200);
}; };
const handleBackButtonClick = () => { const handleBackButtonClick = () => {
@@ -60,18 +131,121 @@ export function Header() {
}; };
const handleSearch = (value: string) => { 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"); 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<HTMLInputElement>) => {
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(() => { 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: "" })); 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 ( return (
<> <>
@@ -104,6 +278,7 @@ export function Header() {
<section className="header__section"> <section className="header__section">
<div <div
ref={searchContainerRef}
className={cn("header__search", { className={cn("header__search", {
"header__search--focused": isFocused, "header__search--focused": isFocused,
})} })}
@@ -120,18 +295,19 @@ export function Header() {
ref={inputRef} ref={inputRef}
type="text" type="text"
name="search" name="search"
placeholder={t("search")} placeholder={isOnLibraryPage ? t("search_library") : t("search")}
value={searchValue} value={searchValue}
className="header__search-input" className="header__search-input"
onChange={(event) => handleSearch(event.target.value)} onChange={(event) => handleSearch(event.target.value)}
onFocus={() => setIsFocused(true)} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown}
/> />
{searchValue && ( {searchValue && (
<button <button
type="button" type="button"
onClick={() => dispatch(setFilters({ title: "" }))} onClick={handleClearSearch}
className="header__action-button" className="header__action-button"
> >
<XIcon /> <XIcon />
@@ -141,6 +317,27 @@ export function Header() {
</section> </section>
</header> </header>
<AutoUpdateSubHeader /> <AutoUpdateSubHeader />
<SearchDropdown
visible={
isDropdownVisible &&
(historyItems.length > 0 ||
suggestions.length > 0 ||
isLoadingSuggestions)
}
position={dropdownPosition}
historyItems={historyItems}
suggestions={suggestions}
isLoadingSuggestions={isLoadingSuggestions}
onSelectHistory={handleSelectHistory}
onSelectSuggestion={handleSelectSuggestion}
onRemoveHistoryItem={handleRemoveHistoryItem}
onClearHistory={handleClearHistory}
onClose={handleCloseDropdown}
activeIndex={activeIndex}
currentQuery={searchValue}
searchContainerRef={searchContainerRef}
/>
</> </>
); );
} }

View File

@@ -50,14 +50,14 @@ export function Hero() {
> >
<div className="hero__backdrop"> <div className="hero__backdrop">
<img <img
src={game.libraryHeroImageUrl} src={game.libraryHeroImageUrl ?? undefined}
alt={game.description ?? ""} alt={game.description ?? ""}
className="hero__media" className="hero__media"
/> />
<div className="hero__content"> <div className="hero__content">
<img <img
src={game.logoImageUrl} src={game.logoImageUrl ?? undefined}
width="250px" width="250px"
alt={game.description ?? ""} alt={game.description ?? ""}
loading="eager" loading="eager"

View File

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

View File

@@ -0,0 +1,105 @@
import React from "react";
interface HighlightTextProps {
text: string;
query: string;
}
export function HighlightText({ text, query }: HighlightTextProps) {
if (!query.trim()) {
return <>{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 ? (
<mark key={index} className="search-dropdown__highlight">
{part.text}
</mark>
) : (
<React.Fragment key={index}>{part.text}</React.Fragment>
)
)}
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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<HTMLDivElement>;
}
export function SearchDropdown({
visible,
position,
historyItems,
suggestions,
isLoadingSuggestions,
onSelectHistory,
onSelectSuggestion,
onRemoveHistoryItem,
onClearHistory,
onClose,
activeIndex,
currentQuery,
searchContainerRef,
}: SearchDropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(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 = (
<div
ref={dropdownRef}
className="search-dropdown"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
>
{hasHistory && (
<div className="search-dropdown__section">
<div className="search-dropdown__section-header">
<span className="search-dropdown__section-title">
{t("recent_searches")}
</span>
<button
type="button"
className="search-dropdown__clear-button"
onClick={onClearHistory}
title={t("clear_history")}
>
<TrashIcon size={14} />
</button>
</div>
<ul className="search-dropdown__list">
{historyItems.map((item, index) => (
<li
key={`history-${item.query}-${item.timestamp}`}
className="search-dropdown__item-container"
>
<button
type="button"
className={cn("search-dropdown__item", {
"search-dropdown__item--active":
activeIndex === getItemIndex("history", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("history", item)}
>
<ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text">
{item.query}
</span>
</button>
<button
type="button"
className="search-dropdown__item-remove"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onRemoveHistoryItem(item.query);
}}
title={t("remove_from_history")}
>
<XIcon size={12} />
</button>
</li>
))}
</ul>
</div>
)}
{hasSuggestions && (
<div className="search-dropdown__section">
<div className="search-dropdown__section-header">
<span className="search-dropdown__section-title">
{t("suggestions")}
</span>
</div>
<ul className="search-dropdown__list">
{suggestions.map((item, index) => (
<li key={`suggestion-${item.objectId}-${item.shop}`}>
<button
type="button"
className={cn("search-dropdown__item", {
"search-dropdown__item--active":
activeIndex === getItemIndex("suggestion", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("suggestion", item)}
>
{item.iconUrl ? (
<img
src={item.iconUrl}
alt=""
className="search-dropdown__item-icon search-dropdown__item-icon--image"
/>
) : (
<SearchIcon
size={16}
className="search-dropdown__item-icon"
/>
)}
<span className="search-dropdown__item-text">
<HighlightText text={item.title} query={currentQuery} />
</span>
</button>
</li>
))}
</ul>
</div>
)}
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div>
)}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div>
);
return createPortal(dropdownContent, document.body);
}

View File

@@ -3,6 +3,7 @@ import {
DownloadIcon, DownloadIcon,
GearIcon, GearIcon,
HomeIcon, HomeIcon,
BookIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
export const routes = [ export const routes = [
@@ -16,6 +17,11 @@ export const routes = [
nameKey: "catalogue", nameKey: "catalogue",
render: () => <AppsIcon />, render: () => <AppsIcon />,
}, },
{
path: "/library",
nameKey: "library",
render: () => <BookIcon />,
},
{ {
path: "/downloads", path: "/downloads",
nameKey: "downloads", nameKey: "downloads",

View File

@@ -80,6 +80,12 @@ export function SidebarGameItem({
<span className="sidebar__menu-item-button-label"> <span className="sidebar__menu-item-button-label">
{getGameTitle(game)} {getGameTitle(game)}
</span> </span>
{(game.newDownloadOptionsCount ?? 0) > 0 && (
<span className="sidebar__game-badge">
+{game.newDownloadOptionsCount}
</span>
)}
</button> </button>
</li> </li>

View File

@@ -115,6 +115,19 @@
background-size: cover; 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 { &__section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -14,12 +14,15 @@ export interface UserProfileContext {
isMe: boolean; isMe: boolean;
userStats: UserStats | null; userStats: UserStats | null;
getUserProfile: () => Promise<void>; getUserProfile: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>; getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise<void>;
loadMoreLibraryGames: (sortBy?: string) => Promise<boolean>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>; setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string; backgroundImage: string;
badges: Badge[]; badges: Badge[];
libraryGames: UserGame[]; libraryGames: UserGame[];
pinnedGames: UserGame[]; pinnedGames: UserGame[];
hasMoreLibraryGames: boolean;
isLoadingLibraryGames: boolean;
} }
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -30,12 +33,15 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false, isMe: false,
userStats: null, userStats: null,
getUserProfile: async () => {}, getUserProfile: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {}, getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {},
loadMoreLibraryGames: async (_sortBy?: string) => false,
setSelectedBackgroundImage: () => {}, setSelectedBackgroundImage: () => {},
backgroundImage: "", backgroundImage: "",
badges: [], badges: [],
libraryGames: [], libraryGames: [],
pinnedGames: [], pinnedGames: [],
hasMoreLibraryGames: false,
isLoadingLibraryGames: false,
}); });
const { Provider } = userProfileContext; const { Provider } = userProfileContext;
@@ -62,6 +68,9 @@ export function UserProfileContextProvider({
DEFAULT_USER_PROFILE_BACKGROUND DEFAULT_USER_PROFILE_BACKGROUND
); );
const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); 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; const isMe = userDetails?.id === userProfile?.id;
@@ -93,7 +102,13 @@ export function UserProfileContextProvider({
}, [userId]); }, [userId]);
const getUserLibraryGames = useCallback( const getUserLibraryGames = useCallback(
async (sortBy?: string) => { async (sortBy?: string, reset = true) => {
if (reset) {
setLibraryPage(0);
setHasMoreLibraryGames(true);
setIsLoadingLibraryGames(true);
}
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("take", "12"); params.append("take", "12");
@@ -115,18 +130,74 @@ export function UserProfileContextProvider({
if (response) { if (response) {
setLibraryGames(response.library); setLibraryGames(response.library);
setPinnedGames(response.pinnedGames); setPinnedGames(response.pinnedGames);
setHasMoreLibraryGames(response.library.length === 12);
} else { } else {
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHasMoreLibraryGames(false);
} }
} catch (error) { } catch (error) {
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHasMoreLibraryGames(false);
} finally {
setIsLoadingLibraryGames(false);
} }
}, },
[userId] [userId]
); );
const loadMoreLibraryGames = useCallback(
async (sortBy?: string): Promise<boolean> => {
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 () => { const getUserProfile = useCallback(async () => {
getUserStats(); getUserStats();
getUserLibraryGames(); getUserLibraryGames();
@@ -164,6 +235,8 @@ export function UserProfileContextProvider({
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
setLibraryPage(0);
setHasMoreLibraryGames(true);
getUserProfile(); getUserProfile();
getBadges(); getBadges();
@@ -177,12 +250,15 @@ export function UserProfileContextProvider({
isMe, isMe,
getUserProfile, getUserProfile,
getUserLibraryGames, getUserLibraryGames,
loadMoreLibraryGames,
setSelectedBackgroundImage, setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(), backgroundImage: getBackgroundImageUrl(),
userStats, userStats,
badges, badges,
libraryGames, libraryGames,
pinnedGames, pinnedGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
}} }}
> >
{children} {children}

View File

@@ -142,6 +142,10 @@ declare global {
shop: GameShop, shop: GameShop,
objectId: string objectId: string
) => Promise<void>; ) => Promise<void>;
clearNewDownloadOptions: (
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: ( toggleGamePin: (
shop: GameShop, shop: GameShop,
objectId: string, objectId: string,
@@ -159,6 +163,7 @@ declare global {
) => Promise<void>; ) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>; verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>; getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -214,6 +219,8 @@ declare global {
) => Promise<void>; ) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>; getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>; syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>; getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
@@ -409,11 +416,28 @@ declare global {
getCustomThemeById: (themeId: string) => Promise<Theme | null>; getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>; getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>; toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
copyThemeAchievementSound: (
themeId: string,
sourcePath: string
) => Promise<void>;
removeThemeAchievementSound: (themeId: string) => Promise<void>;
getThemeSoundPath: (themeId: string) => Promise<string | null>;
getThemeSoundDataUrl: (themeId: string) => Promise<string | null>;
importThemeSoundFromStore: (
themeId: string,
themeName: string,
storeUrl: string
) => Promise<void>;
/* Editor */ /* Editor */
openEditorWindow: (themeId: string) => Promise<void>; openEditorWindow: (themeId: string) => Promise<void>;
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer; onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>; closeEditorWindow: (themeId?: string) => Promise<void>;
/* Download Options */
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => () => Electron.IpcRenderer;
} }
interface Window { interface Window {

View File

@@ -5,10 +5,12 @@ import type { LibraryGame } from "@types";
export interface LibraryState { export interface LibraryState {
value: LibraryGame[]; value: LibraryGame[];
searchQuery: string;
} }
const initialState: LibraryState = { const initialState: LibraryState = {
value: [], value: [],
searchQuery: "",
}; };
export const librarySlice = createSlice({ export const librarySlice = createSlice({
@@ -18,7 +20,34 @@ export const librarySlice = createSlice({
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => { setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
state.value = action.payload; 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<string>) => {
state.searchQuery = action.payload;
},
}, },
}); });
export const { setLibrary } = librarySlice.actions; export const {
setLibrary,
updateGameNewDownloadOptions,
clearNewDownloadOptions,
setLibrarySearchQuery,
} = librarySlice.actions;

View File

@@ -121,3 +121,35 @@ export const formatNumber = (num: number): string => {
export const generateUUID = (): string => { export const generateUUID = (): string => {
return uuidv4(); return uuidv4();
}; };
export const getAchievementSoundUrl = async (): Promise<string> => {
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<number> => {
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;
}
};

View File

@@ -6,3 +6,7 @@ export * from "./redux";
export * from "./use-user-details"; export * from "./use-user-details";
export * from "./use-format"; export * from "./use-format";
export * from "./use-feature"; 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";

View File

@@ -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]);
}

View File

@@ -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,
};
}

View File

@@ -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<SearchHistoryEntry[]>([]);
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,
};
}

View File

@@ -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<SearchSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(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 };
}

View File

@@ -3,12 +3,14 @@ import { useState, useCallback } from "react";
interface SectionCollapseState { interface SectionCollapseState {
pinned: boolean; pinned: boolean;
library: boolean; library: boolean;
reviews: boolean;
} }
export function useSectionCollapse() { export function useSectionCollapse() {
const [collapseState, setCollapseState] = useState<SectionCollapseState>({ const [collapseState, setCollapseState] = useState<SectionCollapseState>({
pinned: false, pinned: false,
library: false, library: false,
reviews: false,
}); });
const toggleSection = useCallback((section: keyof SectionCollapseState) => { const toggleSection = useCallback((section: keyof SectionCollapseState) => {
@@ -23,5 +25,6 @@ export function useSectionCollapse() {
toggleSection, toggleSection,
isPinnedCollapsed: collapseState.pinned, isPinnedCollapsed: collapseState.pinned,
isLibraryCollapsed: collapseState.library, isLibraryCollapsed: collapseState.library,
isReviewsCollapsed: collapseState.reviews,
}; };
} }

View File

@@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile"; import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements"; import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor"; import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log; console.log = logger.log;
@@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route element={<App />}> <Route element={<App />}>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} /> <Route path="/catalogue" element={<Catalogue />} />
<Route path="/library" element={<Library />} />
<Route path="/downloads" element={<Downloads />} /> <Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} /> <Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
AchievementCustomNotificationPosition, AchievementCustomNotificationPosition,
AchievementNotificationInfo, AchievementNotificationInfo,
} from "@types"; } 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 { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import app from "../../../app.scss?inline"; import app from "../../../app.scss?inline";
import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline";
@@ -33,9 +37,11 @@ export function AchievementNotification() {
const [shadowRootRef, setShadowRootRef] = useState<HTMLElement | null>(null); const [shadowRootRef, setShadowRootRef] = useState<HTMLElement | null>(null);
const playAudio = useCallback(() => { const playAudio = useCallback(async () => {
const audio = new Audio(achievementSound); const soundUrl = await getAchievementSoundUrl();
audio.volume = 0.1; const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play(); audio.play();
}, []); }, []);

View File

@@ -100,20 +100,48 @@ export function GallerySlider() {
src?: string; src?: string;
poster?: string; poster?: string;
videoSrc?: string; videoSrc?: string;
videoType?: string;
alt: string; alt: string;
}> = []; }> = [];
if (shopDetails?.movies) { if (shopDetails?.movies) {
shopDetails.movies.forEach((video, index) => { shopDetails.movies.forEach((video, index) => {
items.push({ // Prefer new formats: HLS (best browser support), then DASH H264, then DASH AV1
id: String(video.id), // Fallback to old format: mp4/webm if new formats are not available
type: "video", let videoSrc: string | undefined;
poster: video.thumbnail, let videoType: string | undefined;
videoSrc: video.mp4.max.startsWith("http://")
? video.mp4.max.replace("http://", "https://") if (video.hls_h264) {
: video.mp4.max, videoSrc = video.hls_h264;
alt: t("video", { number: String(index + 1) }), 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} autoPlay={autoplayEnabled}
tabIndex={-1} tabIndex={-1}
> >
<source src={item.videoSrc} /> {item.videoSrc && (
<source src={item.videoSrc} type={item.videoType} />
)}
</video> </video>
) : ( ) : (
<img <img

View File

@@ -231,44 +231,50 @@ $hero-height: 350px;
} }
&__randomizer-button { &__randomizer-button {
padding: calc(globals.$spacing-unit * 1.5); position: fixed;
background-color: rgba(0, 0, 0, 0.6); bottom: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 2);
z-index: 100;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border-radius: 8px; border-radius: 8px;
transition: all ease 0.2s; transition: all ease 0.2s;
cursor: pointer; cursor: pointer;
min-height: 40px; min-height: 40px;
min-width: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color; color: globals.$muted-color;
border: solid 1px globals.$border-color; border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); box-shadow:
0px 0px 10px 0px rgba(0, 0, 0, 0.8),
0px 2px 8px 0px rgba(255, 255, 255, 0.1);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1); animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
overflow: visible;
&:active { &:disabled {
opacity: 0.9; opacity: globals.$disabled-opacity;
cursor: not-allowed;
} }
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(255, 255, 255, 0.12);
color: globals.$body-color; color: globals.$body-color;
} }
} }
&__stars-icon-container { &__stars-icon-container {
width: 20px; width: 16px;
height: 16px; height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative; position: relative;
} }
&__stars-icon { &__stars-icon {
width: 26px; width: 70px;
position: absolute; position: absolute;
top: -3px; top: -28px;
left: -27px;
} }
} }

View File

@@ -45,12 +45,26 @@
&__repack-title { &__repack-title {
color: globals.$muted-color; color: globals.$muted-color;
word-break: break-word; word-break: break-word;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
} }
&__repack-info { &__repack-info {
font-size: globals.$small-font-size; font-size: globals.$small-font-size;
} }
&__new-badge {
background-color: rgba(34, 197, 94, 0.15);
color: rgb(187, 247, 208);
padding: 2px 8px;
border-radius: 6px;
font-size: 9px;
text-align: center;
flex-shrink: 0;
border: 1px solid rgba(34, 197, 94, 0.5);
}
&__no-results { &__no-results {
width: 100%; width: 100%;
padding: calc(globals.$spacing-unit * 4) 0; padding: calc(globals.$spacing-unit * 4) 0;

View File

@@ -15,14 +15,14 @@ import {
TextField, TextField,
CheckboxField, CheckboxField,
} from "@renderer/components"; } from "@renderer/components";
import type { DownloadSource } from "@types"; import type { DownloadSource, GameRepack } from "@types";
import type { GameRepack } from "@types";
import { DownloadSettingsModal } from "./download-settings-modal"; import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import { useDate, useFeature } from "@renderer/hooks"; import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
import { clearNewDownloadOptions } from "@renderer/features";
import "./repacks-modal.scss"; import "./repacks-modal.scss";
export interface RepacksModalProps { export interface RepacksModalProps {
@@ -53,6 +53,13 @@ export function RepacksModal({
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>( const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
{} {}
); );
const [lastCheckTimestamp, setLastCheckTimestamp] = useState<string | null>(
null
);
const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true);
const [viewedRepackIds, setViewedRepackIds] = useState<Set<string>>(
new Set()
);
const { game, repacks } = useContext(gameDetailsContext); const { game, repacks } = useContext(gameDetailsContext);
@@ -60,6 +67,7 @@ export function RepacksModal({
const { formatDate } = useDate(); const { formatDate } = useDate();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch();
const getHashFromMagnet = (magnet: string) => { const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") { if (!magnet || typeof magnet !== "string") {
@@ -97,6 +105,34 @@ export function RepacksModal({
fetchDownloadSources(); 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(() => { const sortedRepacks = useMemo(() => {
return orderBy( return orderBy(
repacks, repacks,
@@ -139,6 +175,7 @@ export function RepacksModal({
const handleRepackClick = (repack: GameRepack) => { const handleRepackClick = (repack: GameRepack) => {
setRepack(repack); setRepack(repack);
setShowSelectFolderModal(true); setShowSelectFolderModal(true);
setViewedRepackIds((prev) => new Set(prev).add(repack.id));
}; };
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => { const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
@@ -158,6 +195,20 @@ export function RepacksModal({
return repack.uris.some((uri) => uri.includes(game.download!.uri)); 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); const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -273,7 +324,14 @@ export function RepacksModal({
onClick={() => handleRepackClick(repack)} onClick={() => handleRepackClick(repack)}
className="repacks-modal__repack-button" className="repacks-modal__repack-button"
> >
<p className="repacks-modal__repack-title">{repack.title}</p> <p className="repacks-modal__repack-title">
{repack.title}
{isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
</p>
{isLastDownloadedOption && ( {isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge> <Badge>{t("last_downloaded_option")}</Badge>

View File

@@ -8,11 +8,23 @@
&__review-header { &__review-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: calc(globals.$spacing-unit * 1);
margin-bottom: calc(globals.$spacing-unit * 1.5); 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 { &__review-user {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -90,6 +90,7 @@ export function ReviewItem({
} }
}; };
// Format playtime similar to hero panel
const formatPlayTime = (playTimeInSeconds: number) => { const formatPlayTime = (playTimeInSeconds: number) => {
const minutes = playTimeInSeconds / 60; const minutes = playTimeInSeconds / 60;
@@ -103,6 +104,7 @@ export function ReviewItem({
return t("amount_hours", { amount: numberFormatter.format(hours) }); return t("amount_hours", { amount: numberFormatter.format(hours) });
}; };
// Determine which content to show - always show original for own reviews
const displayContent = needsTranslation const displayContent = needsTranslation
? review.translations[userLanguage] ? review.translations[userLanguage]
: review.reviewHtml; : review.reviewHtml;
@@ -128,60 +130,62 @@ export function ReviewItem({
return ( return (
<div className="game-details__review-item"> <div className="game-details__review-item">
<div className="game-details__review-header"> <div className="game-details__review-header">
<div className="game-details__review-user"> <div className="game-details__review-header-top">
<button <div className="game-details__review-user">
onClick={() => navigate(`/profile/${review.user.id}`)}
title={review.user.displayName}
>
<Avatar
src={review.user.profileImageUrl}
alt={review.user.displayName || "User"}
size={40}
/>
</button>
<div className="game-details__review-user-info">
<button <button
className="game-details__review-display-name game-details__review-display-name--clickable" onClick={() => navigate(`/profile/${review.user.id}`)}
onClick={() => title={review.user.displayName}
review.user.id && navigate(`/profile/${review.user.id}`)
}
> >
{review.user.displayName || "Anonymous"} <Avatar
src={review.user.profileImageUrl}
alt={review.user.displayName || "User"}
size={40}
/>
</button> </button>
<div className="game-details__review-meta-row"> <div className="game-details__review-user-info">
<div <button
className="game-details__review-score-stars" className="game-details__review-display-name game-details__review-display-name--clickable"
title={getRatingText(review.score, t)} onClick={() =>
review.user.id && navigate(`/profile/${review.user.id}`)
}
> >
<Star {review.user.displayName || "Anonymous"}
size={12} </button>
className="game-details__review-star game-details__review-star--filled"
/>
<span className="game-details__review-score-text">
{review.score}/5
</span>
</div>
{Boolean(
review.playTimeInSeconds && review.playTimeInSeconds > 0
) && (
<div className="game-details__review-playtime">
<ClockIcon size={12} />
<span>
{t("review_played_for")}{" "}
{formatPlayTime(review.playTimeInSeconds || 0)}
</span>
</div>
)}
</div> </div>
</div> </div>
</div>
<div className="game-details__review-right">
<div className="game-details__review-date"> <div className="game-details__review-date">
{formatDistance(new Date(review.createdAt), new Date(), { {formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true, addSuffix: true,
})} })}
</div> </div>
</div> </div>
<div className="game-details__review-header-bottom">
<div className="game-details__review-meta-row">
<div
className="game-details__review-score-stars"
title={getRatingText(review.score, t)}
>
<Star
size={12}
className="game-details__review-star game-details__review-star--filled"
/>
<span className="game-details__review-score-text">
{review.score}/5
</span>
</div>
{Boolean(
review.playTimeInSeconds && review.playTimeInSeconds > 0
) && (
<div className="game-details__review-playtime">
<ClockIcon size={12} />
<span>
{t("review_played_for")}{" "}
{formatPlayTime(review.playTimeInSeconds || 0)}
</span>
</div>
)}
</div>
</div>
</div> </div>
<div> <div>
<div <div

View File

@@ -0,0 +1,55 @@
@use "../../scss/globals.scss";
.library-filter-options {
&__tabs {
display: flex;
gap: calc(globals.$spacing-unit);
position: relative;
}
&__tab-wrapper {
position: relative;
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&--active {
color: white;
}
}
&__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;
}
}

View File

@@ -0,0 +1,103 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import "./filter-options.scss";
export type FilterOption = "all" | "recently_played" | "favorites";
interface FilterOptionsProps {
filterBy: FilterOption;
onFilterChange: (filterBy: FilterOption) => void;
allGamesCount: number;
recentlyPlayedCount: number;
favoritesCount: number;
}
export function FilterOptions({
filterBy,
onFilterChange,
allGamesCount,
recentlyPlayedCount,
favoritesCount,
}: Readonly<FilterOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-filter-options__tabs">
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "all" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("all")}
>
{t("all_games")}
{allGamesCount > 0 && (
<span className="library-filter-options__tab-badge">
{allGamesCount}
</span>
)}
</button>
{filterBy === "all" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "recently_played" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("recently_played")}
>
{t("recently_played")}
{recentlyPlayedCount > 0 && (
<span className="library-filter-options__tab-badge">
{recentlyPlayedCount}
</span>
)}
</button>
{filterBy === "recently_played" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "favorites" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("favorites")}
>
{t("favorites")}
{favoritesCount > 0 && (
<span className="library-filter-options__tab-badge">
{favoritesCount}
</span>
)}
</button>
{filterBy === "favorites" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</div>
);
}

View File

@@ -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;
}
}

View File

@@ -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<LibraryGameCardLargeProps>) {
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 (
<button
type="button"
className="library-game-card-large"
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div
className="library-game-card-large__background"
style={backgroundStyle}
/>
<div className="library-game-card-large__gradient" />
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card-large__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card-large__playtime-text">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
</div>
</div>
<div className="library-game-card-large__logo-container">
{logoImage ? (
<img
src={logoImage}
alt={game.title}
className="library-game-card-large__logo"
/>
) : (
<h3 className="library-game-card-large__title">{game.title}</h3>
)}
</div>
<div className="library-game-card-large__info-bar">
{/* Achievements section */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card-large__achievements">
<div className="library-game-card-large__achievement-header">
<div className="library-game-card-large__achievements-gap">
<TrophyIcon
size={14}
className="library-game-card-large__achievement-trophy"
/>
<span className="library-game-card-large__achievement-count">
{unlockedAchievementsCount} / {game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card-large__achievement-percentage">
{Math.round(
(unlockedAchievementsCount / (game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card-large__achievement-progress">
<div
className="library-game-card-large__achievement-bar"
style={achievementBarStyle}
/>
</div>
</div>
)}
</div>
</div>
</button>
);
});

View File

@@ -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;
}

View File

@@ -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<LibraryGameCardProps>) {
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
useGameCard(game, onContextMenu);
const coverImage = (
game.customIconUrl ??
game.coverImageUrl ??
game.libraryImageUrl ??
game.libraryHeroImageUrl ??
game.iconUrl ??
""
).replaceAll("\\", "/");
return (
<button
type="button"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="library-game-card__wrapper"
title={game.title}
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div className="library-game-card__overlay">
<div className="library-game-card__top-section">
<div className="library-game-card__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card__playtime-long">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
<span className="library-game-card__playtime-short">
{formatPlayTime(game.playTimeInMilliseconds, true)}
</span>
</div>
</div>
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card__achievements">
<div className="library-game-card__achievement-header">
<div className="library-game-card__achievements-gap">
<TrophyIcon
size={13}
className="library-game-card__achievement-trophy"
/>
<span className="library-game-card__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card__achievement-progress">
<div
className="library-game-card__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
</div>
<img
src={coverImage ?? undefined}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
/>
</button>
);
});

View File

@@ -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);
}
}
}
}

View File

@@ -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<ViewMode>(() => {
const savedViewMode = localStorage.getItem("library-view-mode");
return (savedViewMode as ViewMode) || "compact";
});
const [filterBy, setFilterBy] = useState<FilterOption>("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 (
<section className="library__content">
{hasGames && (
<div className="library__page-header">
<div className="library__controls-row">
<div className="library__controls-left">
<FilterOptions
filterBy={filterBy}
onFilterChange={setFilterBy}
allGamesCount={filterCounts.allGamesCount}
recentlyPlayedCount={filterCounts.recentlyPlayedCount}
favoritesCount={filterCounts.favoritesCount}
/>
</div>
<div className="library__controls-right">
<ViewOptions
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
/>
</div>
</div>
</div>
)}
{!hasGames && (
<div className="library__no-games">
<div className="library__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_games_title")}</h2>
<p>{t("no_games_description")}</p>
</div>
)}
{hasGames && (
<AnimatePresence mode="wait">
{viewMode === "large" && (
<motion.div
key={`${filterBy}-large`}
className="library__games-list library__games-list--large"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
onContextMenu={handleOpenContextMenu}
/>
))}
</motion.div>
)}
{viewMode !== "large" && (
<motion.ul
key={`${filterBy}-${viewMode}`}
className={`library__games-grid library__games-grid--${viewMode}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
<li
key={`${game.shop}-${game.objectId}`}
style={{ listStyle: "none" }}
>
<LibraryGameCard
game={game}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
onContextMenu={handleOpenContextMenu}
/>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
)}
{contextMenu.game && (
<GameContextMenu
game={contextMenu.game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
)}
</section>
);
}

View File

@@ -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;
}
}
}
}

View File

@@ -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<ViewOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-view-options__container">
<div className="library-view-options__options">
<button
className={`library-view-options__option ${viewMode === "compact" ? "active" : ""}`}
onClick={() => onViewModeChange("compact")}
title={t("compact_view")}
>
<SquareIcon size={16} />
</button>
<button
className={`library-view-options__option ${viewMode === "grid" ? "active" : ""}`}
onClick={() => onViewModeChange("grid")}
title={t("grid_view")}
>
<AppsIcon size={16} />
</button>
<button
className={`library-view-options__option ${viewMode === "large" ? "active" : ""}`}
onClick={() => onViewModeChange("large")}
title={t("large_view")}
>
<RowsIcon size={16} />
</button>
</div>
</div>
);
}

View File

@@ -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<Set<string>>;
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<LibraryTabProps>) {
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const hasGames = libraryGames.length > 0;
const hasPinnedGames = pinnedGames.length > 0;
const hasAnyGames = hasGames || hasPinnedGames;
return (
<motion.div
key="library"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={onSortChange} />
)}
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
{hasAnyGames && (
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
</div>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</li>
))}
</ul>
</div>
)}
{hasGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
</div>
<InfiniteScroll
dataLength={libraryGames.length}
next={onLoadMore}
hasMore={hasMoreLibraryGames}
loader={null}
scrollThreshold={0.9}
style={{ overflow: "visible" }}
scrollableTarget="scrollableDiv"
>
<ul className="profile-content__games-grid">
{libraryGames?.map((game, index) => {
const hasAnimated = animatedGameIdsRef.current.has(
game.objectId
);
const isNewGame = !hasAnimated && !isLoadingLibraryGames;
return (
<motion.li
key={`${sortBy}-${game.objectId}`}
style={{ listStyle: "none" }}
initial={
isNewGame
? { opacity: 0.5, y: 15, scale: 0.96 }
: false
}
animate={
isNewGame ? { opacity: 1, y: 0, scale: 1 } : false
}
transition={
isNewGame
? {
duration: 0.15,
ease: "easeOut",
delay: index * 0.01,
}
: undefined
}
onAnimationComplete={() => {
if (isNewGame) {
animatedGameIdsRef.current.add(game.objectId);
}
}}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
sortBy={sortBy}
/>
</motion.li>
);
})}
</ul>
</InfiniteScroll>
</div>
)}
</div>
)}
</motion.div>
);
}

View File

@@ -101,6 +101,11 @@
gap: calc(globals.$spacing-unit); gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2); margin-bottom: calc(globals.$spacing-unit * 2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
&__tab-wrapper {
position: relative;
} }
&__tab { &__tab {
@@ -111,19 +116,40 @@
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
border-bottom: 2px solid transparent; transition: color ease 0.2s;
transition: all ease 0.2s; display: flex;
align-items: center;
&:hover { gap: calc(globals.$spacing-unit * 0.5);
color: rgba(255, 255, 255, 0.8);
}
&--active { &--active {
color: white; 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 { &__games-grid {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -175,5 +201,245 @@
backdrop-filter: blur(10px); 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;
} }
} }

View File

@@ -1,29 +1,82 @@
import { userProfileContext } from "@renderer/context"; 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 { 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 { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { GameShop } from "@types";
import { LockedProfile } from "./locked-profile"; import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile"; import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box"; import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box"; import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box"; import { UserStatsBox } from "./user-stats-box";
import { UserKarmaBox } from "./user-karma-box"; import { UserKarmaBox } from "./user-karma-box";
import { UserLibraryGameCard } from "./user-library-game-card"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
import { SortOptions } from "./sort-options"; import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { motion, AnimatePresence } from "framer-motion"; import { ProfileTabs } from "./profile-tabs";
import { import { LibraryTab } from "./library-tab";
sectionVariants, import { ReviewsTab } from "./reviews-tab";
chevronVariants, import { AnimatePresence } from "framer-motion";
GAME_STATS_ANIMATION_DURATION_IN_MS,
} from "./profile-animations";
import "./profile-content.scss"; import "./profile-content.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently"; 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() { export function ProfileContent() {
const { const {
userProfile, userProfile,
@@ -32,16 +85,43 @@ export function ProfileContent() {
libraryGames, libraryGames,
pinnedGames, pinnedGames,
getUserLibraryGames, getUserLibraryGames,
loadMoreLibraryGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
} = useContext(userProfileContext); } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const [statsIndex, setStatsIndex] = useState(0); const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>("playedRecently"); const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const statsAnimation = useRef(-1); const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
const [activeTab, setActiveTab] = useState<"library" | "reviews">("library");
// User reviews state
const [reviews, setReviews] = useState<UserReview[]>([]);
const [reviewsTotalCount, setReviewsTotalCount] = useState(0);
const [isLoadingReviews, setIsLoadingReviews] = useState(false);
const [votingReviews, setVotingReviews] = useState<Set<string>>(new Set());
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [reviewToDelete, setReviewToDelete] = useState<string | null>(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation("user_profile"); 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(() => { useEffect(() => {
dispatch(setHeaderTitle("")); dispatch(setHeaderTitle(""));
@@ -53,10 +133,201 @@ export function ProfileContent() {
useEffect(() => { useEffect(() => {
if (userProfile) { 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]); }, [sortBy, getUserLibraryGames, userProfile]);
const animatedGameIdsRef = useRef<Set<string>>(new Set());
const currentSortByRef = useRef<SortOption>(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<UserReviewsResponse>(
`/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 = () => { const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false); setIsAnimationRunning(false);
}; };
@@ -86,8 +357,6 @@ export function ProfileContent() {
}; };
}, [setStatsIndex, isAnimationRunning]); }, [setStatsIndex, isAnimationRunning]);
const { numberFormatter } = useFormat();
const usersAreFriends = useMemo(() => { const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED"; return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]); }, [userProfile]);
@@ -113,112 +382,46 @@ export function ProfileContent() {
return ( return (
<section className="profile-content__section"> <section className="profile-content__section">
<div className="profile-content__main"> <div className="profile-content__main">
{hasAnyGames && ( <ProfileTabs
<SortOptions sortBy={sortBy} onSortChange={setSortBy} /> activeTab={activeTab}
)} reviewsTotalCount={reviewsTotalCount}
onTabChange={setActiveTab}
/>
{!hasAnyGames && ( <div className="profile-content__tab-panels">
<div className="profile-content__no-games"> <AnimatePresence mode="wait">
<div className="profile-content__telescope-icon"> {activeTab === "library" && (
<TelescopeIcon size={24} /> <LibraryTab
</div> sortBy={sortBy}
<h2>{t("no_recent_activity_title")}</h2> onSortChange={setSortBy}
{isMe && <p>{t("no_recent_activity_description")}</p>} pinnedGames={pinnedGames}
</div> libraryGames={libraryGames}
)} hasMoreLibraryGames={hasMoreLibraryGames}
isLoadingLibraryGames={isLoadingLibraryGames}
{hasAnyGames && ( statsIndex={statsIndex}
<div> userStats={userStats}
{hasPinnedGames && ( animatedGameIdsRef={animatedGameIdsRef}
<div style={{ marginBottom: "2rem" }}> onLoadMore={handleLoadMore}
<div className="profile-content__section-header"> onMouseEnter={handleOnMouseEnterGameCard}
<div className="profile-content__section-title-group"> onMouseLeave={handleOnMouseLeaveGameCard}
<button isMe={isMe}
type="button" />
className="profile-content__collapse-button"
onClick={() => toggleSection("pinned")}
aria-label={
isPinnedCollapsed
? "Expand pinned section"
: "Collapse pinned section"
}
>
<motion.div
variants={chevronVariants}
animate={isPinnedCollapsed ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
</div>
<AnimatePresence initial={true} mode="wait">
{!isPinnedCollapsed && (
<motion.div
key="pinned-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li
key={game.objectId}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
)} )}
{hasGames && ( {activeTab === "reviews" && (
<div> <ReviewsTab
<div className="profile-content__section-header"> reviews={reviews}
<div className="profile-content__section-title-group"> isLoadingReviews={isLoadingReviews}
<h2>{t("library")}</h2> votingReviews={votingReviews}
{userStats && ( userDetailsId={userDetails?.id}
<span className="profile-content__section-badge"> formatPlayTime={formatPlayTime}
{numberFormatter.format(userStats.libraryCount)} getRatingText={getRatingText}
</span> onVote={handleVoteReview}
)} onDelete={handleDeleteClick}
</div> />
</div>
<ul className="profile-content__games-grid">
{libraryGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</div>
)} )}
</div> </AnimatePresence>
)} </div>
</div> </div>
{shouldShowRightContent && ( {shouldShowRightContent && (
@@ -230,6 +433,12 @@ export function ProfileContent() {
<ReportProfile /> <ReportProfile />
</div> </div>
)} )}
<DeleteReviewModal
visible={deleteModalVisible}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
</section> </section>
); );
}, [ }, [
@@ -242,9 +451,15 @@ export function ProfileContent() {
statsIndex, statsIndex,
libraryGames, libraryGames,
pinnedGames, pinnedGames,
isPinnedCollapsed,
toggleSection,
sortBy, sortBy,
activeTab,
// ensure reviews UI updates correctly
reviews,
reviewsTotalCount,
isLoadingReviews,
votingReviews,
deleteModalVisible,
]); ]);
return ( return (

View File

@@ -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<ProfileReviewItemProps>) {
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 (
<motion.div
key={review.id}
className="user-reviews__review-item"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="user-reviews__review-header">
<div className="user-reviews__review-header-top">
<div className="user-reviews__review-game">
<div className="user-reviews__game-info">
<div className="user-reviews__game-details">
<img
src={review.game.iconUrl}
alt={review.game.title}
className="user-reviews__game-icon"
/>
<button
className="user-reviews__game-title user-reviews__game-title--clickable"
onClick={() => navigate(buildGameDetailsPath(review.game))}
>
{review.game.title}
</button>
</div>
</div>
</div>
<div className="user-reviews__review-date">
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
</div>
</div>
<div className="user-reviews__review-header-bottom">
<div className="user-reviews__review-meta-row">
<div
className="user-reviews__review-score-stars"
title={getRatingText(review.score, tGameDetails)}
>
<Star
size={12}
className="user-reviews__review-star user-reviews__review-star--filled"
/>
<span className="user-reviews__review-score-text">
{review.score}/5
</span>
</div>
{Boolean(
review.playTimeInSeconds && review.playTimeInSeconds > 0
) && (
<div className="user-reviews__review-playtime">
<ClockIcon size={12} />
<span>
{tGameDetails("review_played_for")}{" "}
{formatPlayTime(review.playTimeInSeconds || 0)}
</span>
</div>
)}
</div>
</div>
</div>
<div>
<div
className="user-reviews__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(displayContent),
}}
/>
{needsTranslation && (
<>
<button
className="user-reviews__review-translation-toggle"
onClick={() => setShowOriginal(!showOriginal)}
>
<Languages size={13} />
{showOriginal
? tGameDetails("hide_original")
: tGameDetails("show_original_translated_from", {
language: getLanguageName(review.detectedLanguage),
})}
</button>
{showOriginal && (
<div
className="user-reviews__review-content"
style={{
opacity: 0.6,
marginTop: "12px",
}}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
)}
</>
)}
</div>
<div className="user-reviews__review-actions">
<div className="user-reviews__review-votes">
<motion.button
className={`user-reviews__vote-button ${review.hasUpvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() => 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 }}
>
<ThumbsUp size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.upvotes}
</motion.span>
</AnimatePresence>
</motion.button>
<motion.button
className={`user-reviews__vote-button ${review.hasDownvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() => 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 }}
>
<ThumbsDown size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.downvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.downvotes}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
{isOwnReview && (
<button
className="user-reviews__delete-review-button"
onClick={() => onDelete(review.id)}
title={t("delete_review")}
>
<TrashIcon size={14} />
<span>{t("delete_review")}</span>
</button>
)}
</div>
</motion.div>
);
}

View File

@@ -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<ProfileTabsProps>) {
const { t } = useTranslation("user_profile");
return (
<div className="profile-content__tabs">
<div className="profile-content__tab-wrapper">
<button
type="button"
className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`}
onClick={() => onTabChange("library")}
>
{t("library")}
</button>
{activeTab === "library" && (
<motion.div
className="profile-content__tab-underline"
layoutId="tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="profile-content__tab-wrapper">
<button
type="button"
className={`profile-content__tab ${activeTab === "reviews" ? "profile-content__tab--active" : ""}`}
onClick={() => onTabChange("reviews")}
>
{t("user_reviews")}
{reviewsTotalCount > 0 && (
<span className="profile-content__tab-badge">
{reviewsTotalCount}
</span>
)}
</button>
{activeTab === "reviews" && (
<motion.div
className="profile-content__tab-underline"
layoutId="tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</div>
);
}

View File

@@ -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<string>;
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<ReviewsTabProps>) {
const { t } = useTranslation("user_profile");
return (
<motion.div
key="reviews"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{isLoadingReviews && (
<div className="user-reviews__loading">{t("loading_reviews")}</div>
)}
{!isLoadingReviews && reviews.length === 0 && (
<div className="user-reviews__empty">
<p>{t("no_reviews", "No reviews yet")}</p>
</div>
)}
{!isLoadingReviews && reviews.length > 0 && (
<div className="user-reviews__list">
{reviews.map((review) => {
const isOwnReview = userDetailsId === review.user.id;
return (
<ProfileReviewItem
key={review.id}
review={review}
isOwnReview={isOwnReview}
isVoting={votingReviews.has(review.id)}
formatPlayTime={formatPlayTime}
getRatingText={getRatingText}
onVote={onVote}
onDelete={onDelete}
/>
);
})}
</div>
)}
</motion.div>
);
}

View File

@@ -36,6 +36,7 @@
box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5);
width: 100%; width: 100%;
position: relative; position: relative;
overflow: hidden;
&:before { &:before {
content: ""; content: "";
@@ -193,8 +194,28 @@
border-radius: 4px; border-radius: 4px;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 100%; display: block;
min-height: 100%; }
&__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 { &__achievements-progress {

View File

@@ -2,7 +2,7 @@ import { UserGame } from "@types";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat, useToast } from "@renderer/hooks"; import { useFormat, useToast } from "@renderer/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCallback, useContext, useState } from "react"; import { useCallback, useContext, useEffect, useState } from "react";
import { import {
buildGameAchievementPath, buildGameAchievementPath,
buildGameDetailsPath, buildGameDetailsPath,
@@ -15,6 +15,7 @@ import {
AlertFillIcon, AlertFillIcon,
PinIcon, PinIcon,
PinSlashIcon, PinSlashIcon,
ImageIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip"; import { Tooltip } from "react-tooltip";
@@ -44,6 +45,11 @@ export function UserLibraryGameCard({
const navigate = useNavigate(); const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false); const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const [isPinning, setIsPinning] = useState(false); const [isPinning, setIsPinning] = useState(false);
const [imageError, setImageError] = useState(false);
useEffect(() => {
setImageError(false);
}, [game.coverImageUrl]);
const getStatsItemCount = useCallback(() => { const getStatsItemCount = useCallback(() => {
let statsCount = 1; let statsCount = 1;
@@ -233,11 +239,18 @@ export function UserLibraryGameCard({
)} )}
</div> </div>
<img {imageError || !game.coverImageUrl ? (
src={game.coverImageUrl ?? undefined} <div className="user-library-game__cover-placeholder">
alt={game.title} <ImageIcon size={48} />
className="user-library-game__game-image" </div>
/> ) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button> </button>
</li> </li>
<Tooltip <Tooltip

View File

@@ -51,6 +51,16 @@ export const ImportThemeModal = ({
if (!currentTheme) return; if (!currentTheme) return;
try {
await window.electron.importThemeSoundFromStore(
theme.id,
themeName,
THEME_WEB_STORE_URL
);
} catch (soundError) {
logger.error("Failed to import theme sound", soundError);
}
const activeTheme = await window.electron.getActiveCustomTheme(); const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) { if (activeTheme) {

View File

@@ -17,4 +17,159 @@
&__test-achievement-notification-button { &__test-achievement-notification-button {
align-self: flex-start; align-self: flex-start;
} }
&__volume-control {
display: flex;
flex-direction: column;
gap: 12px;
label {
font-size: 14px;
color: globals.$muted-color;
}
}
&__volume-slider-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 200px;
position: relative;
--volume-percent: 0%;
}
&__volume-icon {
color: globals.$muted-color;
flex-shrink: 0;
}
&__volume-value {
font-size: 14px;
color: globals.$body-color;
font-weight: 500;
min-width: 40px;
text-align: right;
flex-shrink: 0;
}
&__volume-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: globals.$dark-background-color;
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
transition: background 0.2s;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
margin-top: -6px;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
}
&:active {
transform: scale(1.05);
}
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
margin-top: -6px;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
}
&:active {
transform: scale(1.05);
}
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
globals.$muted-color 0%,
globals.$muted-color var(--volume-percent),
globals.$dark-background-color var(--volume-percent),
globals.$dark-background-color 100%
);
}
&::-moz-range-track {
width: 100%;
height: 6px;
border-radius: 3px;
background: globals.$dark-background-color;
}
&::-moz-range-progress {
height: 6px;
border-radius: 3px;
background: globals.$muted-color;
}
&:focus {
outline: none;
&::-webkit-slider-thumb {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
&::-moz-range-thumb {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
}
&::-ms-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
}
&::-ms-track {
width: 100%;
height: 6px;
background: transparent;
border-color: transparent;
color: transparent;
}
&::-ms-fill-lower {
background: globals.$muted-color;
border-radius: 3px;
}
&::-ms-fill-upper {
background: globals.$dark-background-color;
border-radius: 3px;
}
}
} }

View File

@@ -1,4 +1,11 @@
import { useContext, useEffect, useMemo, useState } from "react"; import {
useContext,
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from "react";
import { import {
TextField, TextField,
Button, Button,
@@ -12,7 +19,7 @@ import languageResources from "@locales";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import { settingsContext } from "@renderer/context"; import { settingsContext } from "@renderer/context";
import "./settings-general.scss"; import "./settings-general.scss";
import { DesktopDownloadIcon } from "@primer/octicons-react"; import { DesktopDownloadIcon, UnmuteIcon } from "@primer/octicons-react";
import { logger } from "@renderer/logger"; import { logger } from "@renderer/logger";
import { AchievementCustomNotificationPosition } from "@types"; import { AchievementCustomNotificationPosition } from "@types";
@@ -43,6 +50,7 @@ export function SettingsGeneral() {
achievementCustomNotificationsEnabled: true, achievementCustomNotificationsEnabled: true,
achievementCustomNotificationPosition: achievementCustomNotificationPosition:
"top-left" as AchievementCustomNotificationPosition, "top-left" as AchievementCustomNotificationPosition,
achievementSoundVolume: 15,
language: "", language: "",
customStyles: window.localStorage.getItem("customStyles") || "", customStyles: window.localStorage.getItem("customStyles") || "",
}); });
@@ -51,6 +59,8 @@ export function SettingsGeneral() {
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState(""); const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
const volumeUpdateTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => { useEffect(() => {
window.electron.getDefaultDownloadsPath().then((path) => { window.electron.getDefaultDownloadsPath().then((path) => {
setDefaultDownloadsPath(path); setDefaultDownloadsPath(path);
@@ -81,6 +91,9 @@ export function SettingsGeneral() {
return () => { return () => {
clearInterval(interval); clearInterval(interval);
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
}; };
}, []); }, []);
@@ -110,6 +123,9 @@ export function SettingsGeneral() {
userPreferences.achievementCustomNotificationsEnabled ?? true, userPreferences.achievementCustomNotificationsEnabled ?? true,
achievementCustomNotificationPosition: achievementCustomNotificationPosition:
userPreferences.achievementCustomNotificationPosition ?? "top-left", userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementSoundVolume: Math.round(
(userPreferences.achievementSoundVolume ?? 0.15) * 100
),
friendRequestNotificationsEnabled: friendRequestNotificationsEnabled:
userPreferences.friendRequestNotificationsEnabled ?? false, userPreferences.friendRequestNotificationsEnabled ?? false,
friendStartGameNotificationsEnabled: friendStartGameNotificationsEnabled:
@@ -148,6 +164,21 @@ export function SettingsGeneral() {
await updateUserPreferences(values); 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 ( const handleChangeAchievementCustomNotificationPosition = async (
event: React.ChangeEvent<HTMLSelectElement> event: React.ChangeEvent<HTMLSelectElement>
) => { ) => {
@@ -309,6 +340,39 @@ export function SettingsGeneral() {
</> </>
)} )}
{form.achievementNotificationsEnabled && (
<div className="settings-general__volume-control">
<label htmlFor="achievement-volume">
{t("achievement_sound_volume")}
</label>
<div className="settings-general__volume-slider-wrapper">
<UnmuteIcon size={16} className="settings-general__volume-icon" />
<input
id="achievement-volume"
type="range"
min="0"
max="100"
value={form.achievementSoundVolume}
onChange={(e) => {
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
}
/>
<span className="settings-general__volume-value">
{form.achievementSoundVolume}%
</span>
</div>
</div>
)}
<h2 className="settings-general__section-title">{t("common_redist")}</h2> <h2 className="settings-general__section-title">{t("common_redist")}</h2>
<p className="settings-general__common-redist-description"> <p className="settings-general__common-redist-description">

View File

@@ -47,6 +47,8 @@
position: relative; position: relative;
border: 1px solid globals.$muted-color; border: 1px solid globals.$muted-color;
border-radius: 2px; border-radius: 2px;
flex: 1;
min-width: 0;
} }
&__footer { &__footer {
@@ -80,7 +82,7 @@
} }
&__info { &__info {
padding: 16px; padding: 8px;
p { p {
font-size: 16px; font-size: 16px;
@@ -93,12 +95,39 @@
&__notification-preview { &__notification-preview {
padding-top: 12px; padding-top: 12px;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: center;
gap: 16px; gap: 16px;
&__select-variation { &__select-variation {
flex: inherit; 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;
}
} }

View File

@@ -3,11 +3,16 @@ import "./theme-editor.scss";
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
import { AchievementCustomNotificationPosition, Theme } from "@types"; import { AchievementCustomNotificationPosition, Theme } from "@types";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { Button, SelectField } from "@renderer/components"; import { Button, SelectField, TextField } from "@renderer/components";
import { CheckIcon } from "@primer/octicons-react"; import {
CheckIcon,
UploadIcon,
TrashIcon,
PlayIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import cn from "classnames"; 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 { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import { generateAchievementCustomNotificationTest } from "@shared"; import { generateAchievementCustomNotificationTest } from "@shared";
import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu";
@@ -27,6 +32,7 @@ export default function ThemeEditor() {
const [theme, setTheme] = useState<Theme | null>(null); const [theme, setTheme] = useState<Theme | null>(null);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [soundPath, setSoundPath] = useState<string>("");
const [isClosingNotifications, setIsClosingNotifications] = useState(false); const [isClosingNotifications, setIsClosingNotifications] = useState(false);
@@ -62,6 +68,9 @@ export default function ThemeEditor() {
if (loadedTheme) { if (loadedTheme) {
setTheme(loadedTheme); setTheme(loadedTheme);
setCode(loadedTheme.code); setCode(loadedTheme.code);
if (loadedTheme.originalSoundPath) {
setSoundPath(loadedTheme.originalSoundPath);
}
if (shadowRootRef) { if (shadowRootRef) {
injectCustomCss(loadedTheme.code, 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(() => { const achievementCustomNotificationPositionOptions = useMemo(() => {
return [ return [
"top-left", "top-left",
@@ -164,35 +240,66 @@ export default function ThemeEditor() {
<div className="theme-editor__footer"> <div className="theme-editor__footer">
<CollapsedMenu title={t("notification_preview")}> <CollapsedMenu title={t("notification_preview")}>
<div className="theme-editor__notification-preview"> <div className="theme-editor__notification-preview">
<SelectField <div className="theme-editor__notification-preview-controls">
className="theme-editor__notification-preview__select-variation" <div className="theme-editor__notification-controls">
label={t("variation")} <SelectField
options={Object.values(notificationVariations).map( className="theme-editor__notification-preview__select-variation"
(variation) => { label={t("variation")}
return { options={Object.values(notificationVariations).map(
key: variation, (variation) => {
value: variation, return {
label: t(variation), key: variation,
}; value: variation,
} label: t(variation),
)} };
onChange={(value) => }
setNotificationVariation( )}
value.target.value as keyof typeof notificationVariations onChange={(value) =>
) setNotificationVariation(
value.target.value as keyof typeof notificationVariations
)
}
/>
<SelectField
label={t("alignment")}
value={notificationAlignment}
onChange={(e) =>
setNotificationAlignment(
e.target.value as AchievementCustomNotificationPosition
)
}
options={achievementCustomNotificationPositionOptions}
/>
</div>
</div>
<TextField
label={t("select_achievement_sound")}
value={soundPath || ""}
placeholder={soundPath ? undefined : t("no_sound_file_selected")}
readOnly
disabled
rightContent={
<Button theme="outline" onClick={handleSelectSound}>
<UploadIcon />
{t("select")}
</Button>
} }
/> />
<SelectField {theme?.hasCustomSound && (
label={t("alignment")} <div className="theme-editor__sound-actions-row">
value={notificationAlignment} <Button theme="outline" onClick={handleRemoveSound}>
onChange={(e) => <TrashIcon />
setNotificationAlignment( {t("remove")}
e.target.value as AchievementCustomNotificationPosition </Button>
) <Button theme="outline" onClick={handlePreviewSound}>
} <PlayIcon />
options={achievementCustomNotificationPositionOptions} {t("preview")}
/> </Button>
</div>
)}
<div className="theme-editor__notification-preview-wrapper"> <div className="theme-editor__notification-preview-wrapper">
<root.div> <root.div>

View File

@@ -23,6 +23,7 @@ export interface GameRepack {
uploadDate: string | null; uploadDate: string | null;
downloadSourceId: string; downloadSourceId: string;
downloadSourceName: string; downloadSourceName: string;
createdAt: string;
} }
export interface DownloadSource { export interface DownloadSource {
@@ -41,9 +42,9 @@ export interface ShopAssets {
shop: GameShop; shop: GameShop;
title: string; title: string;
iconUrl: string | null; iconUrl: string | null;
libraryHeroImageUrl: string; libraryHeroImageUrl: string | null;
libraryImageUrl: string; libraryImageUrl: string | null;
logoImageUrl: string; logoImageUrl: string | null;
logoPosition: string | null; logoPosition: string | null;
coverImageUrl: string | null; coverImageUrl: string | null;
downloadSources: string[]; downloadSources: string[];
@@ -362,6 +363,8 @@ export type LibraryGame = Game &
Partial<ShopAssets> & { Partial<ShopAssets> & {
id: string; id: string;
download: Download | null; download: Download | null;
unlockedAchievementCount?: number;
achievementCount?: number;
}; };
export type UserGameDetails = ShopAssets & { export type UserGameDetails = ShopAssets & {

View File

@@ -56,9 +56,12 @@ export interface Game {
launchOptions?: string | null; launchOptions?: string | null;
favorite?: boolean; favorite?: boolean;
isPinned?: boolean; isPinned?: boolean;
achievementCount?: number;
unlockedAchievementCount?: number;
pinnedDate?: Date | null; pinnedDate?: Date | null;
automaticCloudSync?: boolean; automaticCloudSync?: boolean;
hasManuallyUpdatedPlaytime?: boolean; hasManuallyUpdatedPlaytime?: boolean;
newDownloadOptionsCount?: number;
} }
export interface Download { export interface Download {
@@ -113,6 +116,7 @@ export interface UserPreferences {
achievementNotificationsEnabled?: boolean; achievementNotificationsEnabled?: boolean;
achievementCustomNotificationsEnabled?: boolean; achievementCustomNotificationsEnabled?: boolean;
achievementCustomNotificationPosition?: AchievementCustomNotificationPosition; achievementCustomNotificationPosition?: AchievementCustomNotificationPosition;
achievementSoundVolume?: number;
friendRequestNotificationsEnabled?: boolean; friendRequestNotificationsEnabled?: boolean;
friendStartGameNotificationsEnabled?: boolean; friendStartGameNotificationsEnabled?: boolean;
showDownloadSpeedInMegabytes?: boolean; showDownloadSpeedInMegabytes?: boolean;

View File

@@ -16,8 +16,11 @@ export interface SteamVideoSource {
export interface SteamMovies { export interface SteamMovies {
id: number; id: number;
mp4: SteamVideoSource; dash_av1?: string;
webm: SteamVideoSource; dash_h264?: string;
hls_h264?: string;
mp4?: SteamVideoSource;
webm?: SteamVideoSource;
thumbnail: string; thumbnail: string;
name: string; name: string;
highlight: boolean; highlight: boolean;

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