Compare commits

..

4 Commits

Author SHA1 Message Date
Moyasee
f0f272c162 fix: handling exceptions 2025-11-04 22:25:32 +02:00
Moyasee
363e52cdb6 fix: duplication 2025-11-04 22:22:02 +02:00
Moyasee
e04a94d10d fix: duplacation and formatting 2025-11-04 22:10:07 +02:00
Moyasee
6733a3e5b0 feat: ability to crop/resize picture before applying 2025-11-04 21:50:14 +02:00
140 changed files with 3155 additions and 7957 deletions

View File

@@ -28,26 +28,6 @@
- Use async/await instead of promises when possible
- Prefer named exports over default exports for utilities and services
## ESLint Issues
- **Always try to fix ESLint errors properly before disabling rules**
- When encountering ESLint errors, explore these solutions in order:
1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues)
2. **Use minimal markup to satisfy the rule** (e.g., add empty `<track>` elements for videos without captions, add `role` attributes)
3. **Only disable the rule as a last resort** when no reasonable solution exists
- When disabling a rule, always include a comment explaining why it's necessary
- Examples of proper fixes:
- For `jsx-a11y/media-has-caption`: Add `<track kind="captions" />` even if no captions are available
- For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images
- For accessibility rules: Add appropriate ARIA attributes rather than disabling
## TypeScript Array Syntax
- **Always use `T[]` syntax instead of `Array<T>`** for array types
- Prefer: `string[]`, `number[]`, `MyType[]`
- Avoid: `Array<string>`, `Array<number>`, `Array<MyType>`
- This applies to all type annotations, type assertions, and generic type parameters
## Comments
- Keep comments concise and purposeful; avoid verbose explanations.

65
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,37 @@
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,9 +2,11 @@
**When submitting this pull request, I confirm the following (please check the boxes):**
- [ ] I have read the [Hydra documentation](https://docs.hydralauncher.gg/getting-started.html).
- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute).
- [ ] I have checked that there are no duplicate pull requests related to this request.
- [ ] I have considered, and confirm that this submission is valuable to others.
- [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers.
**Fill in the PR content:**
-

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<div align="center">
[<img src="https://raw.githubusercontent.com/hydralauncher/hydra/refs/heads/main/resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
[<img src="./resources/icon.png" width="144"/>](https://help.hydralauncher.gg)
<h1 align="center">Hydra Launcher</h1>

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.5",
"version": "3.7.4",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -63,7 +63,6 @@
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0",
"framer-motion": "^12.15.0",
"hls.js": "^1.5.12",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0",
@@ -85,7 +84,7 @@
"sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1",
"tar": "^7.5.2",
"tar": "^7.4.3",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"uuid": "^13.0.0",

View File

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

View File

@@ -4,7 +4,7 @@ import os, uuid, tempfile
class ProfileImageProcessor:
@staticmethod
def get_parsed_image_data(image_path, target_extension):
def get_parsed_image_data(image_path):
Image.MAX_IMAGE_PIXELS = 933120000
image = Image.open(image_path)
@@ -16,7 +16,7 @@ class ProfileImageProcessor:
return image_path, mime_type
else:
new_uuid = str(uuid.uuid4())
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + "." + target_extension
new_image_path = os.path.join(tempfile.gettempdir(), new_uuid) + ".webp"
image.save(new_image_path)
new_image = Image.open(new_image_path)
@@ -26,5 +26,5 @@ class ProfileImageProcessor:
@staticmethod
def process_image(image_path, target_extension):
return ProfileImageProcessor.get_parsed_image_data(image_path, target_extension)
def process_image(image_path):
return ProfileImageProcessor.get_parsed_image_data(image_path)

View File

@@ -13,7 +13,6 @@
},
"sidebar": {
"catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads",
"settings": "Settings",
"my_library": "My library",
@@ -93,16 +92,8 @@
},
"header": {
"search": "Search games",
"search_library": "Search library",
"recent_searches": "Recent Searches",
"suggestions": "Suggestions",
"clear_history": "Clear",
"remove_from_history": "Remove from history",
"loading": "Loading...",
"no_results": "No results",
"home": "Home",
"catalogue": "Catalogue",
"library": "Library",
"downloads": "Downloads",
"search_results": "Search results",
"settings": "Settings",
@@ -203,7 +194,6 @@
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option",
"new_download_option": "New",
"create_steam_shortcut": "Create Steam shortcut",
"create_shortcut_success": "Shortcut created successfully",
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
@@ -565,15 +555,6 @@
"platinum": "Platinum",
"hidden": "Hidden",
"test_notification": "Test notification",
"achievement_sound_volume": "Achievement sound volume",
"select_achievement_sound": "Select achievement sound",
"change_achievement_sound": "Change achievement sound",
"remove_achievement_sound": "Remove achievement sound",
"preview_sound": "Preview sound",
"select": "Select",
"preview": "Preview",
"remove": "Remove",
"no_sound_file_selected": "No sound file selected",
"notification_preview": "Achievement Notification Preview",
"enable_friend_start_game_notifications": "When a friend starts playing a game",
"autoplay_trailers_on_game_page": "Automatically start playing trailers on game page",
@@ -696,6 +677,18 @@
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated",
"crop_profile_image": "Crop Profile Image",
"crop_background_image": "Crop Background Image",
"crop": "Crop",
"cropping": "Cropping…",
"crop_area": "Crop area",
"resize_handle_nw": "Resize handle - northwest corner",
"resize_handle_ne": "Resize handle - northeast corner",
"resize_handle_sw": "Resize handle - southwest corner",
"resize_handle_se": "Resize handle - southeast corner",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"image_crop_failure": "Failed to crop image. Please try again.",
"stats": "Stats",
"achievements": "achievements",
"games": "Games",
@@ -717,27 +710,6 @@
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews..."
},
"library": {
"library": "Library",
"play": "Play",
"download": "Download",
"downloading": "Downloading",
"game": "game",
"games": "games",
"grid_view": "Grid view",
"compact_view": "Compact view",
"large_view": "Large view",
"no_games_title": "Your library is empty",
"no_games_description": "Add games from the catalogue or download them to get started",
"amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"manual_playtime_tooltip": "This playtime has been manually updated",
"all_games": "All Games",
"recently_played": "Recently Played",
"favorites": "Favorites"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",
"user_achievements": "{{displayName}}'s Achievements",

View File

@@ -13,7 +13,6 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"settings": "Ajustes",
"my_library": "Mi Librería",
@@ -93,16 +92,8 @@
},
"header": {
"search": "Buscar juegos",
"search_library": "Buscar en la librería",
"recent_searches": "Búsquedas Recientes",
"suggestions": "Sugerencias",
"clear_history": "Limpiar",
"remove_from_history": "Eliminar del historial",
"loading": "Cargando...",
"no_results": "Sin resultados",
"home": "Inicio",
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",
@@ -201,7 +192,6 @@
"download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción de descarga",
"new_download_option": "Nuevo",
"create_steam_shortcut": "Crear atajo de Steam",
"create_shortcut_success": "Atajo creado con éxito",
"you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios",
@@ -552,12 +542,6 @@
"platinum": "Platino",
"hidden": "Oculto",
"test_notification": "Probar notificación",
"achievement_sound_volume": "Volumen del sonido de logro",
"select_achievement_sound": "Seleccionar sonido de logro",
"select": "Seleccionar",
"preview": "Vista previa",
"remove": "Remover",
"no_sound_file_selected": "No se seleccionó ningún archivo de sonido",
"notification_preview": "Probar notificación de logro",
"debrid": "Debrid",
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
@@ -732,26 +716,5 @@
"hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!",
"learn_more": "Descubrir más",
"debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus"
},
"library": {
"library": "Librería",
"play": "Jugar",
"download": "Descargar",
"downloading": "Descargando",
"game": "juego",
"games": "juegos",
"grid_view": "Vista de cuadrícula",
"compact_view": "Vista compacta",
"large_view": "Vista grande",
"no_games_title": "Tu librería está vacía",
"no_games_description": "Agregá juegos del catálogo o descargalos para comenzar",
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente",
"all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente",
"favorites": "Favoritos"
}
}

View File

@@ -8,12 +8,11 @@
"no_results": "Nincs találat",
"start_typing": "Kereséshez gépelj...",
"hot": "Most felkapott",
"weekly": "📅 Heti kiemeltek",
"weekly": "📅 A hét felkapottjai",
"achievements": "🏆 Achievement támogatott"
},
"sidebar": {
"catalogue": "Katalógus",
"library": "Könyvtár",
"downloads": "Letöltések",
"settings": "Beállítások",
"my_library": "Könyvtáram",
@@ -82,7 +81,7 @@
"update_decky_plugin": "Decky Plugin Frissítése",
"decky_plugin_installed_version": "Decky Plugin (v{{version}})",
"install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint",
"install_decky_plugin_message": "Ez letölti és telepíti 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íteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?",
"update_decky_plugin_title": "Hydra Decky Plugin Frissítése",
"update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?",
"decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve",
@@ -93,10 +92,8 @@
},
"header": {
"search": "Keresés",
"search_library": "Könyvtár böngészése",
"home": "Főoldal",
"catalogue": "Katalógus",
"library": "Könyvtár",
"downloads": "Letöltések",
"search_results": "Keresési találatok",
"settings": "Beállítások",
@@ -120,7 +117,7 @@
"tags": "Címkék",
"publishers": "Kiadók",
"download_sources": "Letöltési források",
"result_count": "{{resultCount}} találat",
"result_count": "{{resultCount}} találatok",
"filter_count": "{{filterCount}} elérhető",
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
},
@@ -169,11 +166,11 @@
"download_now": "Letöltés",
"no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók",
"download_path": "Letöltési hely",
"download_path": "Letöltis hely",
"previous_screenshot": "Előző screenshot",
"next_screenshot": "Következő screenshot",
"screenshot": "Screenshot {{number}}",
"open_screenshot": "{{number}} Screenshot megnyitása ",
"open_screenshot": "Screenshot megnyitása {{number}}",
"download_settings": "Letöltési beállítások",
"downloader": "Letöltési mód",
"select_executable": "Tallózás",
@@ -197,7 +194,6 @@
"download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve",
"last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új",
"create_steam_shortcut": "Steam parancsikon létrehozása",
"create_shortcut_success": "A parancsikon létrehozása sikeres",
"you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.",
@@ -227,7 +223,6 @@
"show_more": "Mutass többet",
"show_less": "Mutass kevesebbet",
"reviews": "Vélemények",
"review_played_for": "Játszva",
"leave_a_review": "Hagyd itt a véleményed",
"write_review_placeholder": "Oszd meg gondolatod a játékról...",
"sort_newest": "Legújabb",
@@ -366,10 +361,7 @@
"show_original": "Eredeti megjelenítése",
"show_translation": "Fordítás megjelenítése",
"show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})",
"hide_original": "Eredeti elrejtése",
"review_from_blocked_user": "Letiltott felhasználó véleménye",
"show": "Megjelenítés",
"hide": "Elrejtés"
"hide_original": "Eredeti elrejtése"
},
"activation": {
"title": "Hydra Aktiválása",
@@ -496,11 +488,11 @@
"no_email_account": "Még nincs beállított emailed",
"account_data_updated_successfully": "Fiókadatok változtatása sikeres",
"renew_subscription": "Hydra Cloud Megújítása",
"subscription_expired_at": "Az előfizetésed lejárt: {{date}}",
"subscription_expired_at": "Az előfizetésed lejárt, ekkor: {{date}}",
"no_subscription": "Élvezd a Hydrát a lehető legjobb módon",
"become_subscriber": "Légy Hydra Cloud tag",
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva",
"subscription_renews_on": "Az előfizetésed megújul: {{date}}",
"subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}",
"bill_sent_until": "A következő számlát ezen napon küldjük",
"no_themes": "Úgy látszik nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
"editor_tab_code": "Code",
@@ -559,19 +551,10 @@
"platinum": "Platina",
"hidden": "Rejtett",
"test_notification": "Értesítés tesztelése",
"achievement_sound_volume": "Achievement hangereje",
"select_achievement_sound": "Achievement hang kiválasztása",
"change_achievement_sound": "Achievement hang megváltoztatása",
"remove_achievement_sound": "Achievement hang eltávolítása",
"preview_sound": "Hang előnézet",
"select": "Kiválaszt",
"preview": "Előnézet",
"remove": "Eltávolít",
"no_sound_file_selected": "Nincs hangfájl kiválasztva",
"notification_preview": "Achievement Értesítés Előnézete",
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot",
"autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán",
"hide_to_tray_on_game_start": "Hydra elrejtése játék indításakor a tálcára"
"hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára"
},
"notifications": {
"download_complete": "Letöltés befejezve",
@@ -687,7 +670,7 @@
"report_reason_other": "Egyéb",
"profile_reported": "Profil bejelentve",
"your_friend_code": "A barát kódod:",
"upload_banner": "Borítókép feltöltése",
"upload_banner": "Borítókép feltöltés",
"uploading_banner": "Borítókép feltöltése…",
"background_image_updated": "Borítókép frissítve",
"stats": "Statisztikák",
@@ -706,31 +689,7 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Pozitív értékelésekkel szerzett pontok",
"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"
"karma_description": "Pozitív értékelésekkel szerzett pontok"
},
"achievement": {
"achievement_unlocked": "Achievement feloldva",

View File

@@ -13,7 +13,6 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads",
"settings": "Ajustes",
"my_library": "Biblioteca",
@@ -93,19 +92,11 @@
},
"header": {
"search": "Buscar jogos",
"search_library": "Buscar na biblioteca",
"recent_searches": "Buscas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "Carregando...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads",
"search_results": "Resultados da busca",
"settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
},
@@ -191,7 +182,6 @@
"download_in_progress": "Download em andamento",
"download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada",
"new_download_option": "Novo",
"create_steam_shortcut": "Criar atalho na Steam",
"create_shortcut_success": "Atalho criado com sucesso",
"you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações",
@@ -551,12 +541,6 @@
"platinum": "Platina",
"hidden": "Oculta",
"test_notification": "Testar notificação",
"achievement_sound_volume": "Volume do som de conquista",
"select_achievement_sound": "Selecionar som de conquista",
"select": "Selecionar",
"preview": "Reproduzir",
"remove": "Remover",
"no_sound_file_selected": "Nenhum arquivo de som selecionado",
"notification_preview": "Prévia da Notificação de Conquistas",
"enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo",
"autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo",
@@ -747,26 +731,5 @@
"hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!",
"learn_more": "Saiba mais",
"debrid_description": "Baixe até 4x mais rápido com Nimbus"
},
"library": {
"library": "Biblioteca",
"play": "Jogar",
"download": "Baixar",
"downloading": "Baixando",
"game": "jogo",
"games": "jogos",
"grid_view": "Visualização em grade",
"compact_view": "Visualização compacta",
"large_view": "Visualização grande",
"no_games_title": "Sua biblioteca está vazia",
"no_games_description": "Adicione jogos do catálogo ou baixe-os para começar",
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente",
"favorites": "Favoritos"
}
}

View File

@@ -30,19 +30,11 @@
},
"header": {
"search": "Procurar jogos",
"search_library": "Procurar na biblioteca",
"recent_searches": "Pesquisas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "A carregar...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Transferências",
"search_results": "Resultados da pesquisa",
"settings": "Definições",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
},

View File

@@ -13,7 +13,6 @@
},
"sidebar": {
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"my_library": "Библиотека",
@@ -93,16 +92,8 @@
},
"header": {
"search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная",
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"search_results": "Результаты поиска",
"settings": "Настройки",
@@ -203,7 +194,6 @@
"download_in_progress": "Идёт загрузка",
"download_paused": "Загрузка приостановлена",
"last_downloaded_option": "Последний вариант загрузки",
"new_download_option": "Новый",
"create_steam_shortcut": "Создать ярлык Steam",
"create_shortcut_success": "Ярлык создан",
"you_might_need_to_restart_steam": "Возможно, вам потребуется перезапустить Steam, чтобы увидеть изменения",
@@ -565,12 +555,6 @@
"platinum": "Платиновый",
"hidden": "Скрытый",
"test_notification": "Тестовое уведомление",
"achievement_sound_volume": "Громкость звука достижения",
"select_achievement_sound": "Выбрать звук достижения",
"select": "Выбрать",
"preview": "Предпросмотр",
"remove": "Удалить",
"no_sound_file_selected": "Файл звука не выбран",
"notification_preview": "Предварительный просмотр уведомления о достижении",
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
"autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры",
@@ -743,26 +727,5 @@
"hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!",
"learn_more": "Подробнее",
"debrid_description": "Скачивайте в 4 раза быстрее с Nimbus"
},
"library": {
"library": "Библиотека",
"play": "Играть",
"download": "Скачать",
"downloading": "Скачивание",
"game": "игра",
"games": "игры",
"grid_view": "Вид сетки",
"compact_view": "Компактный вид",
"large_view": "Большой вид",
"no_games_title": "Ваша библиотека пуста",
"no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать",
"amount_hours": "{{amount}} часов",
"amount_minutes": "{{amount}} минут",
"amount_hours_short": "{{amount}}ч",
"amount_minutes_short": "{{amount}}м",
"manual_playtime_tooltip": "Время игры было обновлено вручную",
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"favorites": "Избранное"
}
}

View File

@@ -16,7 +16,6 @@
"downloads": "İndirilenler",
"settings": "Ayarlar",
"my_library": "Kütüphanem",
"library": "Kütüphane",
"downloading_metadata": "{{title}} (Meta verileri indiriliyor…)",
"paused": "{{title}} (Duraklatıldı)",
"downloading": "{{title}} (%{{percentage}} - İndiriliyor…)",
@@ -27,69 +26,7 @@
"sign_in": "Giriş Yap",
"friends": "Arkadaşlar",
"need_help": "Yardıma mı ihtiyacınız var?",
"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"
"favorites": "Favoriler"
},
"header": {
"search": "Oyunlarda Ara",
@@ -98,8 +35,6 @@
"downloads": "İndirilenler",
"search_results": "Arama Sonuçları",
"settings": "Ayarlar",
"search_library": "Kütüphanede ara",
"library": "Kütüphane",
"version_available_install": "{{version}} sürümü mevcut. Yeniden başlatıp yüklemek için tıklayın.",
"version_available_download": "{{version}} sürümü mevcut. İndirmek için tıklayın."
},
@@ -268,108 +203,7 @@
"create_start_menu_shortcut": "Başlat Menüsüne kısayol oluştur",
"invalid_wine_prefix_path": "Geçersiz Wine ön ek yolu",
"invalid_wine_prefix_path_description": "Wine ön ek yolu hatalı. Lütfen yolu kontrol edin ve tekrar deneyin.",
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir",
"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"
"missing_wine_prefix": "Linux'ta yedekleme oluşturmak için Wine ön eki gereklidir"
},
"activation": {
"title": "Hydra'yı Etkinleştir",
@@ -545,33 +379,7 @@
"hidden": "Gizli",
"test_notification": "Test bildirimi",
"notification_preview": "Başarı Bildirimi Önizlemesi",
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında",
"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"
"enable_friend_start_game_notifications": "Bir arkadaşınız oyun oynamaya başladığında"
},
"notifications": {
"download_complete": "İndirme tamamlandı",
@@ -598,8 +406,7 @@
"game_card": {
"available_one": "Mevcut",
"available_other": "Mevcut",
"no_downloads": "İndirme mevcut değil",
"calculating": "Hesaplanıyor"
"no_downloads": "İndirme mevcut değil"
},
"binary_not_found_modal": {
"title": "Programlar Yüklü Değil",
@@ -691,46 +498,7 @@
"achievements_unlocked": "Açılan başarımlar",
"earned_points": "Kazanılan puanlar",
"show_achievements_on_profile": "Başarımlarını profilinde göster",
"show_points_on_profile": "Kazanılan puanlarını profilinde göster",
"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"
"show_points_on_profile": "Kazanılan puanlarını profilinde göster"
},
"achievement": {
"achievement_unlocked": "Başarım açıldı",

View File

@@ -27,68 +27,7 @@
"friends": "好友",
"favorites": "收藏",
"need_help": "需要帮助?",
"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 插件"
"playable_button_title": "仅显示现在可以游玩的游戏"
},
"header": {
"search": "搜索游戏",
@@ -279,93 +218,7 @@
"reset_achievements_title": "您确定吗?",
"save_changes": "保存更改",
"unfreeze_backup": "取消固定",
"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": "您似乎很喜欢这款游戏"
"you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改"
},
"activation": {
"title": "激活 Hydra",
@@ -541,24 +394,7 @@
"update_email": "更新邮箱",
"update_password": "更新密码",
"variation": "变体",
"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": "已移除所有下载源"
"web_store": "网络商店"
},
"notifications": {
"download_complete": "下载完成",
@@ -585,8 +421,7 @@
"game_card": {
"no_downloads": "无可用下载选项",
"available_one": "可用",
"available_other": "可用",
"calculating": "正在计算"
"available_other": "可用"
},
"binary_not_found_modal": {
"title": "程序未安装",
@@ -680,23 +515,7 @@
"show_achievements_on_profile": "在您的个人资料上显示成就",
"show_points_on_profile": "在您的个人资料上显示获得的积分",
"stats": "统计",
"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": "用户评价"
"top_percentile": "前 {{percentile}}%"
},
"achievement": {
"achievement_unlocked": "成就已解锁",

View File

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

View File

@@ -1,3 +0,0 @@
import "./get-session-hash";
import "./open-auth-window";
import "./sign-out";

View File

@@ -1,2 +0,0 @@
import "./check-for-updates";
import "./restart-and-install-update";

View File

@@ -1,4 +0,0 @@
import "./get-game-assets";
import "./get-game-shop-details";
import "./get-game-stats";
import "./get-random-game";

View File

@@ -1,4 +0,0 @@
import "./download-game-artifact";
import "./get-game-backup-preview";
import "./select-game-backup-path";
import "./upload-save-game";

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesCheckBaseline } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesCheckBaselineHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesCheckBaseline();
};
registerEvent(
"getDownloadSourcesCheckBaseline",
getDownloadSourcesCheckBaselineHandler
);

View File

@@ -1,13 +0,0 @@
import { getDownloadSourcesSinceValue } from "@main/level";
import { registerEvent } from "../register-event";
const getDownloadSourcesSinceValueHandler = async (
_event: Electron.IpcMainInvokeEvent
) => {
return await getDownloadSourcesSinceValue();
};
registerEvent(
"getDownloadSourcesSinceValue",
getDownloadSourcesSinceValueHandler
);

View File

@@ -1,6 +0,0 @@
import "./add-download-source";
import "./get-download-sources-check-baseline";
import "./get-download-sources-since-value";
import "./get-download-sources";
import "./remove-download-source";
import "./sync-download-sources";

View File

@@ -1,2 +0,0 @@
import "./check-folder-write-permission";
import "./get-disk-free-space";

View File

@@ -1,22 +1,98 @@
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
import { ipcMain } from "electron";
import "./auth";
import "./autoupdater";
import "./catalogue";
import "./cloud-save";
import "./download-sources";
import "./hardware";
import "./library";
import "./leveldb";
import "./misc";
import "./notifications";
import "./profile";
import "./themes";
import "./torrenting";
import "./user";
import "./user-preferences";
import "./catalogue/get-game-shop-details";
import "./catalogue/get-random-game";
import "./catalogue/get-game-stats";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
import "./library/add-custom-game-to-library";
import "./library/update-custom-game";
import "./library/update-game-custom-assets";
import "./library/add-game-to-favorites";
import "./library/remove-game-from-favorites";
import "./library/toggle-game-pin";
import "./library/create-game-shortcut";
import "./library/close-game";
import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/extract-game-download";
import "./library/open-game";
import "./library/open-game-executable-path";
import "./library/open-game-installer";
import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/update-launch-options";
import "./library/verify-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements";
import "./library/change-game-playtime";
import "./library/toggle-automatic-cloud-sync";
import "./library/get-default-wine-prefix-selection-path";
import "./library/cleanup-unused-assets";
import "./library/create-steam-shortcut";
import "./library/copy-custom-game-asset";
import "./misc/open-checkout";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./misc/show-item-in-folder";
import "./misc/install-common-redist";
import "./misc/can-install-common-redist";
import "./misc/save-temp-file";
import "./misc/delete-temp-file";
import "./misc/install-hydra-decky-plugin";
import "./misc/get-hydra-decky-plugin-info";
import "./misc/check-homebrew-folder-exists";
import "./misc/hydra-api-call";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
import "./torrenting/start-game-download";
import "./torrenting/pause-game-seed";
import "./torrenting/resume-game-seed";
import "./torrenting/check-debrid-availability";
import "./user-preferences/get-user-preferences";
import "./user-preferences/update-user-preferences";
import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./user-preferences/authenticate-torbox";
import "./download-sources/add-download-source";
import "./download-sources/sync-download-sources";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-auth";
import "./user/get-unlocked-achievements";
import "./user/get-compared-unlocked-achievements";
import "./profile/get-me";
import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/sync-friend-requests";
import "./cloud-save/download-game-artifact";
import "./cloud-save/get-game-backup-preview";
import "./cloud-save/upload-save-game";
import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification";
import "./notifications/update-achievement-notification-window";
import "./notifications/show-achievement-test-notification";
import "./themes/add-custom-theme";
import "./themes/delete-custom-theme";
import "./themes/get-all-custom-themes";
import "./themes/delete-all-custom-themes";
import "./themes/update-custom-theme";
import "./themes/open-editor-window";
import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import "./download-sources/remove-download-source";
import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");

View File

@@ -1,27 +0,0 @@
import { db } from "@main/level";
const sublevelCache = new Map<
string,
ReturnType<typeof db.sublevel<string, unknown>>
>();
/**
* Gets a sublevel by name, creating it if it doesn't exist.
* All sublevels use "json" encoding by default.
* @param sublevelName - The name of the sublevel to get or create
* @returns The sublevel instance
*/
export const getSublevelByName = (
sublevelName: string
): ReturnType<typeof db.sublevel<string, unknown>> => {
if (sublevelCache.has(sublevelName)) {
return sublevelCache.get(sublevelName)!;
}
// All sublevels use "json" encoding - this cannot be changed per sublevel
const sublevel = db.sublevel<string, unknown>(sublevelName, {
valueEncoding: "json",
});
sublevelCache.set(sublevelName, sublevel);
return sublevel;
};

View File

@@ -1,6 +0,0 @@
import "./leveldb-get";
import "./leveldb-put";
import "./leveldb-del";
import "./leveldb-clear";
import "./leveldb-values";
import "./leveldb-iterator";

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbClear = async (
_event: Electron.IpcMainInvokeEvent,
sublevelName: string
) => {
try {
const sublevel = getSublevelByName(sublevelName);
await sublevel.clear();
} catch (error) {
logger.error("Error in leveldbClear", error);
throw error;
}
};
registerEvent("leveldbClear", leveldbClear);

View File

@@ -1,28 +0,0 @@
import { registerEvent } from "../register-event";
import { db } from "@main/level";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbDel = async (
_event: Electron.IpcMainInvokeEvent,
key: string,
sublevelName?: string | null
) => {
try {
if (sublevelName) {
const sublevel = getSublevelByName(sublevelName);
await sublevel.del(key);
} else {
await db.del(key);
}
} catch (error) {
if (error instanceof Error && error.name === "NotFoundError") {
// NotFoundError on delete is not an error, just return
return;
}
logger.error("Error in leveldbDel", error);
throw error;
}
};
registerEvent("leveldbDel", leveldbDel);

View File

@@ -1,28 +0,0 @@
import { registerEvent } from "../register-event";
import { db } from "@main/level";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbGet = async (
_event: Electron.IpcMainInvokeEvent,
key: string,
sublevelName?: string | null,
valueEncoding: "json" | "utf8" = "json"
) => {
try {
if (sublevelName) {
// Note: sublevels always use "json" encoding, valueEncoding parameter is ignored
const sublevel = getSublevelByName(sublevelName);
return sublevel.get(key);
}
return db.get<string, unknown>(key, { valueEncoding });
} catch (error) {
if (error instanceof Error && error.name === "NotFoundError") {
return null;
}
logger.error("Error in leveldbGet", error);
throw error;
}
};
registerEvent("leveldbGet", leveldbGet);

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbIterator = async (
_event: Electron.IpcMainInvokeEvent,
sublevelName: string
) => {
try {
const sublevel = getSublevelByName(sublevelName);
return sublevel.iterator().all();
} catch (error) {
logger.error("Error in leveldbIterator", error);
throw error;
}
};
registerEvent("leveldbIterator", leveldbIterator);

View File

@@ -1,27 +0,0 @@
import { registerEvent } from "../register-event";
import { db } from "@main/level";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbPut = async (
_event: Electron.IpcMainInvokeEvent,
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding: "json" | "utf8" = "json"
) => {
try {
if (sublevelName) {
// Note: sublevels always use "json" encoding, valueEncoding parameter is ignored
const sublevel = getSublevelByName(sublevelName);
await sublevel.put(key, value);
} else {
await db.put<string, unknown>(key, value, { valueEncoding });
}
} catch (error) {
logger.error("Error in leveldbPut", error);
throw error;
}
};
registerEvent("leveldbPut", leveldbPut);

View File

@@ -1,18 +0,0 @@
import { registerEvent } from "../register-event";
import { getSublevelByName } from "./helpers";
import { logger } from "@main/services";
const leveldbValues = async (
_event: Electron.IpcMainInvokeEvent,
sublevelName: string
) => {
try {
const sublevel = getSublevelByName(sublevelName);
return sublevel.values().all();
} catch (error) {
logger.error("Error in leveldbValues", error);
throw error;
}
};
registerEvent("leveldbValues", leveldbValues);

View File

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

View File

@@ -1,32 +0,0 @@
import "./add-custom-game-to-library";
import "./add-game-to-favorites";
import "./add-game-to-library";
import "./change-game-playtime";
import "./cleanup-unused-assets";
import "./clear-new-download-options";
import "./close-game";
import "./copy-custom-game-asset";
import "./create-game-shortcut";
import "./create-steam-shortcut";
import "./delete-game-folder";
import "./extract-game-download";
import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id";
import "./get-library";
import "./open-game-executable-path";
import "./open-game-installer-path";
import "./open-game-installer";
import "./open-game";
import "./refresh-library-assets";
import "./remove-game-from-favorites";
import "./remove-game-from-library";
import "./remove-game";
import "./reset-game-achievements";
import "./select-game-wine-prefix";
import "./toggle-automatic-cloud-sync";
import "./toggle-game-pin";
import "./update-custom-game";
import "./update-executable-path";
import "./update-game-custom-assets";
import "./update-launch-options";
import "./verify-executable-path";

View File

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

View File

@@ -1,12 +0,0 @@
import "./can-install-common-redist";
import "./check-homebrew-folder-exists";
import "./delete-temp-file";
import "./get-hydra-decky-plugin-info";
import "./hydra-api-call";
import "./install-common-redist";
import "./install-hydra-decky-plugin";
import "./open-checkout";
import "./open-external";
import "./save-temp-file";
import "./show-item-in-folder";
import "./show-open-dialog";

View File

@@ -1,3 +0,0 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";

View File

@@ -1,4 +0,0 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile";

View File

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

View File

@@ -1,40 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,60 +0,0 @@
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

@@ -1,15 +0,0 @@
import "./add-custom-theme";
import "./close-editor-window";
import "./copy-theme-achievement-sound";
import "./delete-all-custom-themes";
import "./delete-custom-theme";
import "./get-active-custom-theme";
import "./get-all-custom-themes";
import "./get-custom-theme-by-id";
import "./get-theme-sound-data-url";
import "./get-theme-sound-path";
import "./import-theme-sound-from-store";
import "./open-editor-window";
import "./remove-theme-achievement-sound";
import "./toggle-custom-theme";
import "./update-custom-theme";

View File

@@ -1,48 +0,0 @@
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

@@ -1,7 +0,0 @@
import "./cancel-game-download";
import "./check-debrid-availability";
import "./pause-game-download";
import "./pause-game-seed";
import "./resume-game-download";
import "./resume-game-seed";
import "./start-game-download";

View File

@@ -13,11 +13,7 @@ const resumeGameDownload = async (
const download = await downloadsSublevel.get(gameKey);
if (
download &&
(download.status === "paused" || download.status === "active") &&
download.progress !== 1
) {
if (download?.status === "paused") {
await DownloadManager.pauseDownload();
for await (const [key, value] of downloadsSublevel.iterator()) {

View File

@@ -1,5 +0,0 @@
import "./authenticate-real-debrid";
import "./authenticate-torbox";
import "./auto-launch";
import "./get-user-preferences";
import "./update-user-preferences";

View File

@@ -1,3 +0,0 @@
import "./get-auth";
import "./get-compared-unlocked-achievements";
import "./get-unlocked-achievements";

View File

@@ -2,8 +2,6 @@ import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents";
import path from "node:path";
import fs from "node:fs";
import { THEMES_PATH } from "@main/constants";
export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
@@ -33,64 +31,9 @@ export const isPortableVersion = () => {
};
export const normalizePath = (str: string) =>
path.posix.normalize(str).replaceAll("\\", "/");
path.posix.normalize(str).replace(/\\/g, "/");
export const addTrailingSlash = (str: string) =>
str.endsWith("/") ? str : `${str}/`;
const sanitizeFolderName = (name: string): string => {
return name
.toLowerCase()
.replaceAll(/[^a-z0-9-_\s]/g, "")
.replaceAll(/\s+/g, "-")
.replaceAll(/-+/g, "-")
.replaceAll(/(^-|-$)/g, "");
};
export const getThemePath = (themeId: string, themeName?: string): string => {
if (themeName) {
const sanitizedName = sanitizeFolderName(themeName);
if (sanitizedName) {
return path.join(THEMES_PATH, sanitizedName);
}
}
return path.join(THEMES_PATH, themeId);
};
export const getThemeSoundPath = (
themeId: string,
themeName?: string
): string | null => {
const themeDir = getThemePath(themeId, themeName);
const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null;
const checkDir = (dir: string): string | null => {
if (!fs.existsSync(dir)) {
return null;
}
const formats = ["wav", "mp3", "ogg", "m4a"];
for (const format of formats) {
const soundPath = path.join(dir, `achievement.${format}`);
if (fs.existsSync(soundPath)) {
return soundPath;
}
}
return null;
};
const soundPath = checkDir(themeDir);
if (soundPath) {
return soundPath;
}
if (legacyThemeDir) {
return checkDir(legacyThemeDir);
}
return null;
};
export * from "./reg-parser";

View File

@@ -1,67 +0,0 @@
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, {
valueEncoding: "utf8",
});
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, {
valueEncoding: "utf8",
});
};
// 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, {
valueEncoding: "utf8",
});
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, {
valueEncoding: "utf8",
});
};

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { downloadsSublevel } from "./level/sublevels/downloads";
import { orderBy } from "lodash-es";
import { sortBy } from "lodash-es";
import { Downloader } from "@shared";
import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types";
@@ -16,7 +16,6 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
DownloadSourcesChecker,
WSClient,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -58,9 +57,6 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
WSClient.connect();
});
@@ -68,7 +64,7 @@ export const loadState = async () => {
.values()
.all()
.then((games) => {
return orderBy(games, "timestamp", "desc");
return sortBy(games, "timestamp", "DESC");
});
downloads.forEach((download) => {

View File

@@ -1,188 +0,0 @@
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

@@ -20,7 +20,7 @@ import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { orderBy } from "lodash-es";
import { sortBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
@@ -194,10 +194,10 @@ export class DownloadManager {
.values()
.all()
.then((games) => {
return orderBy(
return sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"desc"
"DESC"
);
});

View File

@@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = false;
private static readonly ADD_LOG_INTERCEPTOR = true;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;
@@ -400,45 +400,4 @@ export class HydraApi {
.then((response) => response.data)
.catch(this.handleUnauthorizedError);
}
static async checkDownloadSourcesChanges(
downloadSourceIds: string[],
games: Array<{ shop: string; objectId: string }>,
since: string
) {
logger.info("HydraApi.checkDownloadSourcesChanges called with:", {
downloadSourceIds,
gamesCount: games.length,
since,
isLoggedIn: this.isLoggedIn(),
});
try {
const result = await this.post<
Array<{
shop: string;
objectId: string;
newDownloadOptionsCount: number;
downloadSourceIds: string[];
}>
>(
"/download-sources/changes",
{
downloadSourceIds,
games,
since,
},
{ needsAuth: true }
);
logger.info(
"HydraApi.checkDownloadSourcesChanges completed successfully:",
result
);
return result;
} catch (error) {
logger.error("HydraApi.checkDownloadSourcesChanges failed:", error);
throw error;
}
}
}

View File

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

View File

@@ -9,8 +9,6 @@ type ProfileGame = {
hasManuallyUpdatedPlaytime: boolean;
isFavorite?: boolean;
isPinned?: boolean;
achievementCount: number;
unlockedAchievementCount: number;
} & ShopAssets;
export const mergeWithRemoteGames = async () => {
@@ -41,8 +39,6 @@ export const mergeWithRemoteGames = async () => {
playTimeInMilliseconds: updatedPlayTime,
favorite: game.isFavorite ?? localGame.favorite,
isPinned: game.isPinned ?? localGame.isPinned,
achievementCount: game.achievementCount,
unlockedAchievementCount: game.unlockedAchievementCount,
});
} else {
await gamesSublevel.put(gameKey, {
@@ -59,27 +55,18 @@ export const mergeWithRemoteGames = async () => {
isDeleted: false,
favorite: game.isFavorite ?? false,
isPinned: game.isPinned ?? false,
achievementCount: game.achievementCount,
unlockedAchievementCount: game.unlockedAchievementCount,
});
}
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
// Construct coverImageUrl if not provided by backend (Steam games use predictable pattern)
const coverImageUrl =
game.coverImageUrl ||
(game.shop === "steam"
? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg`
: null);
await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(),
...localGameShopAsset,
shop: game.shop,
objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists
coverImageUrl,
coverImageUrl: game.coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl,
logoImageUrl: game.logoImageUrl,

View File

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

View File

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

View File

@@ -13,9 +13,9 @@ export class SystemPath {
};
static checkIfPathsAreAvailable() {
const paths = Object.keys(
SystemPath.paths
) as (keyof typeof SystemPath.paths)[];
const paths = Object.keys(SystemPath.paths) as Array<
keyof typeof SystemPath.paths
>;
paths.forEach((pathName) => {
try {

View File

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

View File

@@ -103,10 +103,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
getDownloadSourcesCheckBaseline: () =>
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
getDownloadSourcesSinceValue: () =>
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
/* Library */
toggleAutomaticCloudSync: (
@@ -183,8 +179,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
updateLaunchOptions: (
@@ -202,7 +196,6 @@ contextBridge.exposeInMainWorld("electron", {
verifyExecutablePathInUse: (executablePath: string) =>
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) =>
@@ -577,25 +570,6 @@ contextBridge.exposeInMainWorld("electron", {
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
toggleCustomTheme: (themeId: string, isActive: boolean) =>
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
copyThemeAchievementSound: (themeId: string, sourcePath: string) =>
ipcRenderer.invoke("copyThemeAchievementSound", themeId, sourcePath),
removeThemeAchievementSound: (themeId: string) =>
ipcRenderer.invoke("removeThemeAchievementSound", themeId),
getThemeSoundPath: (themeId: string) =>
ipcRenderer.invoke("getThemeSoundPath", themeId),
getThemeSoundDataUrl: (themeId: string) =>
ipcRenderer.invoke("getThemeSoundDataUrl", themeId),
importThemeSoundFromStore: (
themeId: string,
themeName: string,
storeUrl: string
) =>
ipcRenderer.invoke(
"importThemeSoundFromStore",
themeId,
themeName,
storeUrl
),
/* Editor */
openEditorWindow: (themeId: string) =>
@@ -606,41 +580,6 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-custom-theme-updated", listener);
},
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
gamesWithNewOptions: { gameId: string; count: number }[]
) => cb(gamesWithNewOptions);
ipcRenderer.on("on-new-download-options", listener);
return () =>
ipcRenderer.removeListener("on-new-download-options", listener);
},
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding),
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) =>
ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding),
del: (key: string, sublevelName?: string | null) =>
ipcRenderer.invoke("leveldbDel", key, sublevelName),
clear: (sublevelName: string) =>
ipcRenderer.invoke("leveldbClear", sublevelName),
values: (sublevelName: string) =>
ipcRenderer.invoke("leveldbValues", sublevelName),
iterator: (sublevelName: string) =>
ipcRenderer.invoke("leveldbIterator", sublevelName),
},
});

View File

@@ -6,7 +6,7 @@
<title>Hydra Launcher</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' * data: local:; media-src 'self' 'unsafe-inline' * data: local: blob:;"
content="default-src 'self' 'unsafe-inline' * data: local:;"
/>
</head>
<body>

View File

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

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import {
@@ -9,7 +10,6 @@ import {
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
@@ -25,14 +25,7 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import {
injectCustomCss,
removeCustomCss,
getAchievementSoundUrl,
getAchievementSoundVolume,
} from "./helpers";
import { levelDBService } from "./services/leveldb.service";
import type { UserPreferences } from "@types";
import { injectCustomCss, removeCustomCss } from "./helpers";
import "./app.scss";
export interface AppProps {
@@ -43,9 +36,6 @@ export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary, library } = useLibrary();
// Listen for new download options updates
useDownloadOptionsListener();
const { t } = useTranslation("app");
const { clearDownload, setLastPacket } = useDownload();
@@ -79,12 +69,11 @@ export function App() {
const { showSuccessToast } = useToast();
useEffect(() => {
Promise.all([
levelDBService.get("userPreferences", null, "json"),
updateLibrary(),
]).then(([preferences]) => {
dispatch(setUserPreferences(preferences as UserPreferences | null));
});
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
([preferences]) => {
dispatch(setUserPreferences(preferences));
}
);
}, [navigate, location.pathname, dispatch, updateLibrary]);
useEffect(() => {
@@ -207,11 +196,7 @@ export function App() {
}, [dispatch, draggingDisabled]);
const loadAndApplyTheme = useCallback(async () => {
const allThemes = (await levelDBService.values("themes")) as {
isActive?: boolean;
code?: string;
}[];
const activeTheme = allThemes.find((theme) => theme.isActive);
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
} else {
@@ -231,11 +216,9 @@ export function App() {
return () => unsubscribe();
}, [loadAndApplyTheme]);
const playAudio = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
audio.play();
}, []);

View File

@@ -18,7 +18,6 @@ interface DropdownMenuProps {
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
alignOffset?: number;
collisionPadding?: number;
}
export function DropdownMenu({
@@ -30,7 +29,6 @@ export function DropdownMenu({
loop = true,
align = "center",
alignOffset = 0,
collisionPadding = 16,
}: Readonly<DropdownMenuProps>) {
return (
<DropdownMenuPrimitive.Root>
@@ -45,7 +43,6 @@ export function DropdownMenu({
loop={loop}
align={align}
alignOffset={alignOffset}
collisionPadding={collisionPadding}
className="dropdown-menu__content"
>
{title && (

View File

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

View File

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

View File

@@ -3,32 +3,22 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import {
useAppDispatch,
useAppSelector,
useSearchHistory,
useSearchSuggestions,
} from "@renderer/hooks";
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import "./header.scss";
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import { setFilters } from "@renderer/features";
import cn from "classnames";
import { SearchDropdown } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
const pathTitle: Record<string, string> = {
"/": "home",
"/catalogue": "catalogue",
"/library": "library",
"/downloads": "downloads",
"/settings": "settings",
};
export function Header() {
const inputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const location = useLocation();
@@ -37,95 +27,32 @@ export function Header() {
(state) => state.window
);
const catalogueSearchValue = useAppSelector(
const searchValue = useAppSelector(
(state) => state.catalogueSearch.filters.title
);
const librarySearchValue = useAppSelector(
(state) => state.library.searchQuery
);
const isOnLibraryPage = location.pathname.startsWith("/library");
const isOnCataloguePage = location.pathname.startsWith("/catalogue");
const searchValue = isOnLibraryPage
? librarySearchValue
: catalogueSearchValue;
const dispatch = useAppDispatch();
const [isFocused, setIsFocused] = useState(false);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState({
x: 0,
y: 0,
});
const { t } = useTranslation("header");
const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } =
useSearchHistory();
const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions(
searchValue,
isOnLibraryPage,
isDropdownVisible && isFocused && !isOnCataloguePage
);
const historyItems = getRecentHistory(
isOnLibraryPage ? "library" : "catalogue",
3
);
const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/library"))
return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);
}, [location.pathname, headerTitle, t]);
const totalItems = historyItems.length + suggestions.length;
const updateDropdownPosition = () => {
if (searchContainerRef.current) {
const rect = searchContainerRef.current.getBoundingClientRect();
setDropdownPosition({
x: rect.left,
y: rect.bottom,
});
}
};
const focusInput = () => {
setIsFocused(true);
inputRef.current?.focus();
};
const handleFocus = () => {
if (isFocused && isDropdownVisible) {
updateDropdownPosition();
return;
}
setIsFocused(true);
setActiveIndex(-1);
setTimeout(() => {
updateDropdownPosition();
setIsDropdownVisible(true);
}, 220);
};
const handleBlur = () => {
setTimeout(() => {
setIsFocused(false);
setIsDropdownVisible(false);
setActiveIndex(-1);
}, 200);
setIsFocused(false);
};
const handleBackButtonClick = () => {
@@ -133,121 +60,18 @@ export function Header() {
};
const handleSearch = (value: string) => {
if (isOnLibraryPage) {
dispatch(setLibrarySearchQuery(value.slice(0, 255)));
} else {
dispatch(setFilters({ title: value.slice(0, 255) }));
}
setActiveIndex(-1);
};
dispatch(setFilters({ title: value.slice(0, 255) }));
const executeSearch = (query: string) => {
const context = isOnLibraryPage ? "library" : "catalogue";
if (query.trim()) {
addToHistory(query, context);
}
handleSearch(query);
if (!isOnLibraryPage && !location.pathname.startsWith("/catalogue")) {
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");
}
setIsDropdownVisible(false);
inputRef.current?.blur();
};
const handleSelectHistory = (query: string) => {
executeSearch(query);
};
const handleSelectSuggestion = (suggestion: {
title: string;
objectId: string;
shop: GameShop;
}) => {
setIsDropdownVisible(false);
inputRef.current?.blur();
navigate(buildGameDetailsPath(suggestion));
};
const handleClearSearch = () => {
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(() => {
const prevPath = sessionStorage.getItem("prevPath");
const currentPath = location.pathname;
if (
prevPath?.startsWith("/catalogue") &&
!currentPath.startsWith("/catalogue") &&
catalogueSearchValue
) {
if (!location.pathname.startsWith("/catalogue") && searchValue) {
dispatch(setFilters({ title: "" }));
}
sessionStorage.setItem("prevPath", currentPath);
}, [location.pathname, catalogueSearchValue, dispatch]);
useEffect(() => {
if (!isDropdownVisible) return;
const handleResize = () => {
updateDropdownPosition();
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [isDropdownVisible]);
}, [location.pathname, searchValue, dispatch]);
return (
<>
@@ -280,7 +104,6 @@ export function Header() {
<section className="header__section">
<div
ref={searchContainerRef}
className={cn("header__search", {
"header__search--focused": isFocused,
})}
@@ -297,19 +120,18 @@ export function Header() {
ref={inputRef}
type="text"
name="search"
placeholder={isOnLibraryPage ? t("search_library") : t("search")}
placeholder={t("search")}
value={searchValue}
className="header__search-input"
onChange={(event) => handleSearch(event.target.value)}
onFocus={handleFocus}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
{searchValue && (
<button
type="button"
onClick={handleClearSearch}
onClick={() => dispatch(setFilters({ title: "" }))}
className="header__action-button"
>
<XIcon />
@@ -319,27 +141,6 @@ export function Header() {
</section>
</header>
<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">
<img
src={game.libraryHeroImageUrl ?? undefined}
src={game.libraryHeroImageUrl}
alt={game.description ?? ""}
className="hero__media"
/>
<div className="hero__content">
<img
src={game.logoImageUrl ?? undefined}
src={game.logoImageUrl}
width="250px"
alt={game.description ?? ""}
loading="eager"

View File

@@ -0,0 +1,107 @@
@use "../../scss/globals.scss";
.image-cropper {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
min-height: 500px;
max-height: 600px;
&__container {
flex: 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
background-color: transparent;
border-radius: 4px;
position: relative;
min-height: 400px;
}
&__image-wrapper {
position: relative;
display: inline-block;
}
&__image {
display: block;
user-select: none;
pointer-events: none;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
&__crop-overlay {
position: absolute;
border: 2px solid globals.$brand-teal;
cursor: move;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
background-color: transparent;
&--circular {
border-radius: 50%;
}
}
&__crop-handle {
position: absolute;
width: 12px;
height: 12px;
background-color: globals.$brand-teal;
border: 2px solid globals.$background-color;
border-radius: 2px;
cursor: nwse-resize;
&--nw {
top: -6px;
left: -6px;
cursor: nwse-resize;
}
&--ne {
top: -6px;
right: -6px;
cursor: nesw-resize;
}
&--sw {
bottom: -6px;
left: -6px;
cursor: nesw-resize;
}
&--se {
bottom: -6px;
right: -6px;
cursor: nwse-resize;
}
}
&__controls {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__zoom-controls {
display: flex;
align-items: center;
justify-content: center;
gap: calc(globals.$spacing-unit * 2);
}
&__zoom-value {
min-width: 60px;
text-align: center;
color: globals.$body-color;
}
&__actions {
display: flex;
gap: calc(globals.$spacing-unit * 2);
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,730 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "../button/button";
import { useTranslation } from "react-i18next";
import { logger } from "@renderer/logger";
import "./image-cropper.scss";
export interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
export interface ImageCropperProps {
imagePath: string;
onCrop: (cropArea: CropArea) => Promise<void>;
onCancel: () => void;
aspectRatio?: number;
circular?: boolean;
minCropSize?: number;
}
export function ImageCropper({
imagePath,
onCrop,
onCancel,
aspectRatio,
circular = false,
minCropSize = 50,
}: Readonly<ImageCropperProps>) {
const { t } = useTranslation("user_profile");
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const zoom = 0.5;
const [cropArea, setCropArea] = useState<CropArea>({
x: 0,
y: 0,
width: 0,
height: 0,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeHandle, setResizeHandle] = useState<string | null>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [cropStart, setCropStart] = useState<CropArea | null>(null);
const [isCropping, setIsCropping] = useState(false);
const getImageSrc = () => {
return imagePath.startsWith("local:") ? imagePath : `local:${imagePath}`;
};
const calculateContainerBounds = useCallback(() => {
const container = containerRef.current;
if (!container) return null;
const containerRect = container.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) return null;
const computedStyle = globalThis.getComputedStyle(container);
const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
const paddingTop = Number.parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = Number.parseFloat(computedStyle.paddingBottom) || 0;
return {
maxWidth: containerRect.width - paddingLeft - paddingRight,
maxHeight: containerRect.height - paddingTop - paddingBottom,
};
}, []);
const calculateDisplayDimensions = useCallback(
(bounds: { maxWidth: number; maxHeight: number } | null) => {
if (!imageLoaded) return { width: 0, height: 0 };
if (!bounds) {
return {
width: imageSize.width * zoom,
height: imageSize.height * zoom,
};
}
const imageAspect = imageSize.width / imageSize.height;
let displayWidth = imageSize.width * zoom;
let displayHeight = imageSize.height * zoom;
if (displayWidth > bounds.maxWidth) {
displayWidth = bounds.maxWidth;
displayHeight = displayWidth / imageAspect;
}
if (displayHeight > bounds.maxHeight) {
displayHeight = bounds.maxHeight;
displayWidth = displayHeight * imageAspect;
}
return { width: displayWidth, height: displayHeight };
},
[imageLoaded, imageSize]
);
const calculateInitialCropArea = useCallback(() => {
const bounds = calculateContainerBounds();
if (!bounds || bounds.maxWidth <= 0 || bounds.maxHeight <= 0) return;
const { width: displayWidth, height: displayHeight } =
calculateDisplayDimensions(bounds);
const effectiveAspectRatio = circular ? 1 : aspectRatio;
let cropWidth: number;
let cropHeight: number;
if (effectiveAspectRatio) {
const displayAspect = displayWidth / displayHeight;
if (displayAspect > effectiveAspectRatio) {
cropHeight = displayHeight * 0.8;
cropWidth = cropHeight * effectiveAspectRatio;
} else {
cropWidth = displayWidth * 0.8;
cropHeight = cropWidth / effectiveAspectRatio;
}
} else {
const cropSize = Math.min(displayWidth, displayHeight) * 0.8;
cropWidth = cropSize;
cropHeight = cropSize;
}
setCropArea({
x: (displayWidth - cropWidth) / 2,
y: (displayHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
});
}, [
calculateContainerBounds,
calculateDisplayDimensions,
aspectRatio,
circular,
]);
const getDisplaySize = useCallback(() => {
const bounds = calculateContainerBounds();
return calculateDisplayDimensions(bounds);
}, [calculateContainerBounds, calculateDisplayDimensions]);
useEffect(() => {
const img = new Image();
const handleImageLoad = () => {
setImageSize({ width: img.width, height: img.height });
setImageLoaded(true);
};
const handleImageError = (error: Event | string) => {
logger.error("Failed to load image:", { src: getImageSrc(), error });
};
img.onload = handleImageLoad;
img.onerror = handleImageError;
img.src = getImageSrc();
}, [imagePath]);
useEffect(() => {
if (!imageLoaded || imageSize.width === 0 || imageSize.height === 0) return;
const performDoubleAnimationFrame = () => {
calculateInitialCropArea();
};
const handleAnimationFrame = () => {
requestAnimationFrame(performDoubleAnimationFrame);
};
const calculateWithDelay = () => {
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) return;
requestAnimationFrame(handleAnimationFrame);
};
const handleResize = () => {
calculateWithDelay();
};
const timeoutId = setTimeout(calculateWithDelay, 200);
const resizeObserver = new ResizeObserver(handleResize);
const container = containerRef.current;
if (container) {
resizeObserver.observe(container);
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [imageLoaded, imageSize, calculateInitialCropArea]);
const getRealCropArea = (): CropArea => {
if (!imageLoaded || imageSize.width === 0 || imageSize.height === 0) {
return cropArea;
}
const displaySize = getDisplaySize();
if (displaySize.width === 0 || displaySize.height === 0) {
return cropArea;
}
const scaleX = imageSize.width / displaySize.width;
const scaleY = imageSize.height / displaySize.height;
return {
x: cropArea.x * scaleX,
y: cropArea.y * scaleY,
width: cropArea.width * scaleX,
height: cropArea.height * scaleY,
};
};
const enforceAspectRatio = (
width: number,
height: number,
ratio: number
): { width: number; height: number } => {
const currentRatio = width / height;
if (currentRatio > ratio) {
return { width, height: width / ratio };
}
return { width: height * ratio, height };
};
const calculateMinSize = useCallback(
(effectiveAspectRatio: number | undefined) => {
if (!effectiveAspectRatio) return minCropSize;
return effectiveAspectRatio > 1
? minCropSize * effectiveAspectRatio
: minCropSize / effectiveAspectRatio;
},
[minCropSize]
);
const getImageWrapperBounds = useCallback(() => {
const imageWrapper = imageRef.current?.parentElement;
if (!imageWrapper) return { width: 0, height: 0 };
const rect = imageWrapper.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}, []);
const constrainCropArea = useCallback(
(area: CropArea): CropArea => {
const displaySize = getDisplaySize();
const wrapperBounds = getImageWrapperBounds();
const actualBounds = {
width:
wrapperBounds.width > 0 ? wrapperBounds.width : displaySize.width,
height:
wrapperBounds.height > 0 ? wrapperBounds.height : displaySize.height,
};
let { x, y, width, height } = area;
const effectiveAspectRatio = circular ? 1 : aspectRatio;
const minSize = calculateMinSize(effectiveAspectRatio);
if (effectiveAspectRatio) {
({ width, height } = enforceAspectRatio(
width,
height,
effectiveAspectRatio
));
const maxWidth = Math.min(
actualBounds.width,
actualBounds.height * effectiveAspectRatio
);
const maxHeight = Math.min(
actualBounds.height,
actualBounds.width / effectiveAspectRatio
);
width = Math.max(minSize, Math.min(width, maxWidth));
height = Math.max(minSize, Math.min(height, maxHeight));
({ width, height } = enforceAspectRatio(
width,
height,
effectiveAspectRatio
));
width = Math.min(width, actualBounds.width);
height = Math.min(height, actualBounds.height);
({ width, height } = enforceAspectRatio(
width,
height,
effectiveAspectRatio
));
} else {
width = Math.max(minSize, Math.min(width, actualBounds.width));
height = Math.max(minSize, Math.min(height, actualBounds.height));
}
x = Math.max(0, Math.min(x, actualBounds.width - width));
y = Math.max(0, Math.min(y, actualBounds.height - height));
return { x, y, width, height };
},
[
getDisplaySize,
getImageWrapperBounds,
circular,
aspectRatio,
calculateMinSize,
]
);
const getRelativeCoordinates = (
e: MouseEvent | React.MouseEvent
): { x: number; y: number } | null => {
const imageWrapper = imageRef.current?.parentElement;
if (!imageWrapper) return null;
const rect = imageWrapper.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
return { x, y };
};
const handleDrag = useCallback(
(coords: { x: number; y: number }) => {
const newX = coords.x - dragStart.x;
const newY = coords.y - dragStart.y;
setCropArea((prev) => constrainCropArea({ ...prev, x: newX, y: newY }));
},
[dragStart, constrainCropArea]
);
const calculateResizeDeltas = (
resizeHandle: string,
coords: { x: number; y: number },
cropStart: CropArea
) => {
const handleMap: Record<
string,
(
coords: { x: number; y: number },
cropStart: CropArea
) => {
deltaX: number;
deltaY: number;
newX: number;
newY: number;
}
> = {
"resize-se": (c, cs) => ({
deltaX: c.x - (cs.x + cs.width),
deltaY: c.y - (cs.y + cs.height),
newX: cs.x,
newY: cs.y,
}),
"resize-sw": (c, cs) => ({
deltaX: cs.x - c.x,
deltaY: c.y - (cs.y + cs.height),
newX: c.x,
newY: cs.y,
}),
"resize-ne": (c, cs) => ({
deltaX: c.x - (cs.x + cs.width),
deltaY: cs.y - c.y,
newX: cs.x,
newY: c.y,
}),
"resize-nw": (c, cs) => ({
deltaX: cs.x - c.x,
deltaY: cs.y - c.y,
newX: c.x,
newY: c.y,
}),
};
const handler = handleMap[resizeHandle];
if (handler) {
return handler(coords, cropStart);
}
return {
deltaX: 0,
deltaY: 0,
newX: cropStart.x,
newY: cropStart.y,
};
};
const adjustPositionForHandle = (
resizeHandle: string,
cropStart: CropArea,
adjustedWidth: number,
adjustedHeight: number
) => {
let adjustedX = cropStart.x;
let adjustedY = cropStart.y;
if (resizeHandle === "resize-nw" || resizeHandle === "resize-sw") {
adjustedX = cropStart.x + cropStart.width - adjustedWidth;
}
if (resizeHandle === "resize-nw" || resizeHandle === "resize-ne") {
adjustedY = cropStart.y + cropStart.height - adjustedHeight;
}
return { x: adjustedX, y: adjustedY };
};
const applyCircularConstraint = (
newWidth: number,
newHeight: number,
cropStart: CropArea,
resizeHandle: string
) => {
const size = Math.min(newWidth, newHeight);
const deltaSize = size - Math.min(cropStart.width, cropStart.height);
const adjustedWidth = cropStart.width + deltaSize;
const adjustedHeight = cropStart.height + deltaSize;
const { x, y } = adjustPositionForHandle(
resizeHandle,
cropStart,
adjustedWidth,
adjustedHeight
);
return { width: adjustedWidth, height: adjustedHeight, x, y };
};
const applyAspectRatioConstraint = (
newWidth: number,
newHeight: number,
cropStart: CropArea,
resizeHandle: string,
aspectRatio: number
) => {
const deltaX = Math.abs(newWidth - cropStart.width);
const deltaY = Math.abs(newHeight - cropStart.height);
let adjustedWidth: number;
let adjustedHeight: number;
if (deltaX > deltaY) {
adjustedWidth = newWidth;
adjustedHeight = newWidth / aspectRatio;
} else {
adjustedHeight = newHeight;
adjustedWidth = newHeight * aspectRatio;
}
const wrapperBounds = getImageWrapperBounds();
const displaySize = getDisplaySize();
const actualBounds = {
width: wrapperBounds.width > 0 ? wrapperBounds.width : displaySize.width,
height:
wrapperBounds.height > 0 ? wrapperBounds.height : displaySize.height,
};
const maxWidth = Math.min(
actualBounds.width,
actualBounds.height * aspectRatio
);
const maxHeight = Math.min(
actualBounds.height,
actualBounds.width / aspectRatio
);
adjustedWidth = Math.min(adjustedWidth, maxWidth);
adjustedHeight = Math.min(adjustedHeight, maxHeight);
const finalRatio = adjustedWidth / adjustedHeight;
if (Math.abs(finalRatio - aspectRatio) > 0.001) {
if (finalRatio > aspectRatio) {
adjustedHeight = adjustedWidth / aspectRatio;
} else {
adjustedWidth = adjustedHeight * aspectRatio;
}
}
const { x, y } = adjustPositionForHandle(
resizeHandle,
cropStart,
adjustedWidth,
adjustedHeight
);
return { width: adjustedWidth, height: adjustedHeight, x, y };
};
const handleResize = useCallback(
(coords: { x: number; y: number }) => {
if (!cropStart || !resizeHandle) return;
const { deltaX, deltaY, newX, newY } = calculateResizeDeltas(
resizeHandle,
coords,
cropStart
);
let adjustedWidth = cropStart.width + deltaX;
let adjustedHeight = cropStart.height + deltaY;
let adjustedX = newX;
let adjustedY = newY;
if (circular) {
const constrained = applyCircularConstraint(
adjustedWidth,
adjustedHeight,
cropStart,
resizeHandle
);
adjustedWidth = constrained.width;
adjustedHeight = constrained.height;
adjustedX = constrained.x;
adjustedY = constrained.y;
} else if (aspectRatio) {
const constrained = applyAspectRatioConstraint(
adjustedWidth,
adjustedHeight,
cropStart,
resizeHandle,
aspectRatio
);
adjustedWidth = constrained.width;
adjustedHeight = constrained.height;
adjustedX = constrained.x;
adjustedY = constrained.y;
}
const newCropArea = constrainCropArea({
x: adjustedX,
y: adjustedY,
width: adjustedWidth,
height: adjustedHeight,
});
setCropArea(newCropArea);
},
[cropStart, resizeHandle, circular, aspectRatio, constrainCropArea]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!imageLoaded) return;
e.preventDefault();
e.stopPropagation();
const coords = getRelativeCoordinates(e);
if (!coords) return;
const handle = (e.target as HTMLElement)?.dataset?.handle;
if (handle?.startsWith("resize-")) {
setIsResizing(true);
setResizeHandle(handle);
setCropStart(cropArea);
} else if (
coords.x >= cropArea.x &&
coords.x <= cropArea.x + cropArea.width &&
coords.y >= cropArea.y &&
coords.y <= cropArea.y + cropArea.height
) {
setIsDragging(true);
setDragStart({ x: coords.x - cropArea.x, y: coords.y - cropArea.y });
}
},
[imageLoaded, cropArea]
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!imageLoaded) return;
const coords = getRelativeCoordinates(e);
if (!coords) return;
if (isDragging && cropStart === null) {
handleDrag(coords);
} else if (isResizing && cropStart && resizeHandle) {
handleResize(coords);
}
},
[
imageLoaded,
isDragging,
isResizing,
cropStart,
resizeHandle,
handleDrag,
handleResize,
]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeHandle(null);
setCropStart(null);
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
}
}, []);
const handleOverlayKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = e.shiftKey ? 10 : 1;
const keyMap: Record<
string,
(area: CropArea) => { x: number; y: number }
> = {
ArrowLeft: (area) => ({ x: area.x - step, y: area.y }),
ArrowRight: (area) => ({ x: area.x + step, y: area.y }),
ArrowUp: (area) => ({ x: area.x, y: area.y - step }),
ArrowDown: (area) => ({ x: area.x, y: area.y + step }),
};
const handler = keyMap[e.key];
if (!handler) return;
e.preventDefault();
const { x: newX, y: newY } = handler(cropArea);
setCropArea((prev) => constrainCropArea({ ...prev, x: newX, y: newY }));
},
[cropArea, constrainCropArea]
);
useEffect(() => {
if (!isDragging && !isResizing) return;
const cleanup = () => {
globalThis.removeEventListener("mousemove", handleMouseMove);
globalThis.removeEventListener("mouseup", handleMouseUp);
};
globalThis.addEventListener("mousemove", handleMouseMove);
globalThis.addEventListener("mouseup", handleMouseUp);
return cleanup;
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleCrop = async () => {
setIsCropping(true);
try {
const realCropArea = getRealCropArea();
await onCrop(realCropArea);
} finally {
setIsCropping(false);
}
};
const displaySize = getDisplaySize();
return (
<div className="image-cropper">
<div className="image-cropper__container" ref={containerRef}>
<div
className="image-cropper__image-wrapper"
style={{
width: `${displaySize.width}px`,
height: `${displaySize.height}px`,
maxWidth: "100%",
maxHeight: "100%",
}}
>
{imageLoaded && (
<img
ref={imageRef}
src={getImageSrc()}
alt="Crop"
className="image-cropper__image"
style={{
width: `${displaySize.width}px`,
height: `${displaySize.height}px`,
maxWidth: "100%",
maxHeight: "100%",
}}
/>
)}
{imageLoaded && cropArea.width > 0 && cropArea.height > 0 && (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<section
className={`image-cropper__crop-overlay ${circular ? "image-cropper__crop-overlay--circular" : ""}`}
style={{
left: `${cropArea.x}px`,
top: `${cropArea.y}px`,
width: `${cropArea.width}px`,
height: `${cropArea.height}px`,
}}
aria-label={t("crop_area")}
onMouseDown={handleMouseDown}
onKeyDown={handleOverlayKeyDown}
>
{(["nw", "ne", "sw", "se"] as const).map((position) => (
<button
key={position}
type="button"
className={`image-cropper__crop-handle image-cropper__crop-handle--${position}`}
data-handle={`resize-${position}`}
aria-label={t(`resize_handle_${position}`)}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
/>
))}
</section>
)}
</div>
</div>
<div className="image-cropper__controls">
<div className="image-cropper__actions">
<Button theme="outline" onClick={onCancel} disabled={isCropping}>
{t("cancel")}
</Button>
<Button
theme="primary"
onClick={handleCrop}
disabled={isCropping || !imageLoaded}
>
{isCropping ? t("cropping") : t("crop")}
</Button>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,107 +0,0 @@
import React from "react";
interface HighlightTextProps {
readonly text: string;
readonly query: string;
}
export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
if (!query.trim()) {
return <>{text}</>;
}
const queryWords = query
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 0);
if (queryWords.length === 0) {
return <>{text}</>;
}
const matches: { start: number; end: number }[] = [];
const textLower = text.toLowerCase();
queryWords.forEach((queryWord) => {
const escapedQuery = queryWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(
`(?:^|[\\s])${escapedQuery}(?=[\\s]|$)|^${escapedQuery}$`,
"gi"
);
let match;
while ((match = regex.exec(textLower)) !== null) {
const matchedText = match[0];
const leadingSpace = matchedText.startsWith(" ") ? 1 : 0;
const start = match.index + leadingSpace;
const end = start + queryWord.length;
matches.push({ start, end });
}
});
if (matches.length === 0) {
return <>{text}</>;
}
matches.sort((a, b) => a.start - b.start);
const mergedMatches: { start: number; end: number }[] = [];
let current = matches[0];
for (let i = 1; i < matches.length; i++) {
if (matches[i].start <= current.end) {
current = {
start: current.start,
end: Math.max(current.end, matches[i].end),
};
} else {
mergedMatches.push(current);
current = matches[i];
}
}
mergedMatches.push(current);
const parts: { text: string; highlight: boolean; key: string }[] = [];
let lastIndex = 0;
mergedMatches.forEach((match) => {
if (match.start > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.start),
highlight: false,
key: `${lastIndex}-${match.start}`,
});
}
parts.push({
text: text.slice(match.start, match.end),
highlight: true,
key: `${match.start}-${match.end}`,
});
lastIndex = match.end;
});
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
highlight: false,
key: `${lastIndex}-${text.length}`,
});
}
return (
<>
{parts.map((part) =>
part.highlight ? (
<mark key={part.key} className="search-dropdown__highlight">
{part.text}
</mark>
) : (
<React.Fragment key={part.key}>{part.text}</React.Fragment>
)
)}
</>
);
}

View File

@@ -1,153 +0,0 @@
@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 8px;
margin-bottom: 4px;
}
&__section-title {
color: globals.$muted-color;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__clear-text-button {
color: globals.$muted-color;
cursor: pointer;
padding: 0;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
transition: color ease 0.2s;
background: transparent;
border: none;
&:hover {
color: #ffffff;
}
}
&__list {
list-style: none;
padding: 0;
margin: 0;
}
&__item-container {
position: relative;
display: flex;
align-items: center;
&:hover .search-dropdown__item-remove {
opacity: 1;
}
}
&__item-remove {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: globals.$muted-color;
padding: 4px;
opacity: 0;
transition: opacity ease 0.15s;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
&:hover {
color: #ff3333;
background-color: rgba(255, 85, 85, 0.2);
}
}
&__item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.1s ease;
color: #dadbe1;
text-align: left;
border: none;
background: transparent;
&:hover,
&--active {
background-color: globals.$background-color;
}
&:focus {
outline: none;
}
}
&__item-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: globals.$muted-color;
&--image {
border-radius: 2px;
object-fit: cover;
}
}
&__item-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
&__loading,
&__empty {
padding: 16px 12px;
text-align: center;
color: globals.$muted-color;
font-size: 14px;
}
&__empty {
font-style: italic;
}
&__highlight {
background-color: rgba(255, 193, 7, 0.4);
color: #ffa000;
font-weight: 600;
padding: 0 2px;
border-radius: 2px;
}
}

View File

@@ -1,241 +0,0 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
import { useTranslation } from "react-i18next";
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
import type { SearchSuggestion } from "@renderer/hooks/use-search-suggestions";
import { HighlightText } from "./highlight-text";
import "./search-dropdown.scss";
export interface SearchDropdownProps {
visible: boolean;
position: { x: number; y: number };
historyItems: SearchHistoryEntry[];
suggestions: SearchSuggestion[];
isLoadingSuggestions: boolean;
onSelectHistory: (query: string) => void;
onSelectSuggestion: (suggestion: SearchSuggestion) => void;
onRemoveHistoryItem: (query: string) => void;
onClearHistory: () => void;
onClose: () => void;
activeIndex: number;
currentQuery: string;
searchContainerRef?: React.RefObject<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-text-button"
onClick={onClearHistory}
>
{t("clear_history")}
</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,7 +3,6 @@ import {
DownloadIcon,
GearIcon,
HomeIcon,
BookIcon,
} from "@primer/octicons-react";
export const routes = [
@@ -17,11 +16,6 @@ export const routes = [
nameKey: "catalogue",
render: () => <AppsIcon />,
},
{
path: "/library",
nameKey: "library",
render: () => <BookIcon />,
},
{
path: "/downloads",
nameKey: "downloads",

View File

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

View File

@@ -115,19 +115,6 @@
background-size: cover;
}
&__game-badge {
background-color: rgba(34, 197, 94, 0.15);
color: rgb(187, 247, 208);
font-size: 10px;
font-weight: 600;
padding: 4px 6px;
border-radius: 6px;
display: flex;
margin-left: auto;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(34, 197, 94, 0.5);
}
&__section-header {
display: flex;
justify-content: space-between;

View File

@@ -1,8 +1,6 @@
import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { orderBy } from "lodash-es";
import { getSteamLanguage } from "@renderer/helpers";
import {
useAppDispatch,
@@ -12,7 +10,6 @@ import {
} from "@renderer/hooks";
import type {
DownloadSource,
GameRepack,
GameShop,
GameStats,
@@ -300,10 +297,7 @@ export function GameDetailsContextProvider({
const fetchDownloadSources = async () => {
try {
const sourcesRaw = (await levelDBService.values(
"downloadSources"
)) as DownloadSource[];
const sources = orderBy(sourcesRaw, "createdAt", "desc");
const sources = await window.electron.getDownloadSources();
const params = {
take: 100,

View File

@@ -2,7 +2,6 @@ import { createContext, useCallback, useEffect, useState } from "react";
import { setUserPreferences } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { levelDBService } from "@renderer/services/leveldb.service";
import type { UserBlocks, UserPreferences } from "@types";
import { useSearchParams } from "react-router-dom";
@@ -135,11 +134,9 @@ export function SettingsContextProvider({
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
await window.electron.updateUserPreferences(values);
levelDBService
.get("userPreferences", null, "json")
.then((userPreferences) => {
dispatch(setUserPreferences(userPreferences as UserPreferences | null));
});
window.electron.getUserPreferences().then((userPreferences) => {
dispatch(setUserPreferences(userPreferences));
});
};
return (

View File

@@ -142,10 +142,6 @@ declare global {
shop: GameShop,
objectId: string
) => Promise<void>;
clearNewDownloadOptions: (
shop: GameShop,
objectId: string
) => Promise<void>;
toggleGamePin: (
shop: GameShop,
objectId: string,
@@ -163,7 +159,6 @@ declare global {
) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
@@ -219,8 +214,6 @@ declare global {
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
getDownloadSourcesSinceValue: () => Promise<string | null>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
@@ -416,47 +409,11 @@ declare global {
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
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 */
openEditorWindow: (themeId: string) => Promise<void>;
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>;
/* Download Options */
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void
) => () => Electron.IpcRenderer;
/* LevelDB Generic CRUD */
leveldb: {
get: (
key: string,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<unknown>;
put: (
key: string,
value: unknown,
sublevelName?: string | null,
valueEncoding?: "json" | "utf8"
) => Promise<void>;
del: (key: string, sublevelName?: string | null) => Promise<void>;
clear: (sublevelName: string) => Promise<void>;
values: (sublevelName: string) => Promise<unknown[]>;
iterator: (sublevelName: string) => Promise<[string, unknown][]>;
};
}
interface Window {

View File

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

View File

@@ -3,7 +3,6 @@ import type { GameShop } from "@types";
import Color from "color";
import { v4 as uuidv4 } from "uuid";
import { THEME_WEB_STORE_URL } from "./constants";
import { levelDBService } from "./services/leveldb.service";
export const formatDownloadProgress = (
progress?: number,
@@ -122,48 +121,3 @@ export const formatNumber = (num: number): string => {
export const generateUUID = (): string => {
return uuidv4();
};
export const getAchievementSoundUrl = async (): Promise<string> => {
const defaultSound = (await import("@renderer/assets/audio/achievement.wav"))
.default;
try {
const allThemes = (await levelDBService.values("themes")) as {
id: string;
isActive?: boolean;
hasCustomSound?: boolean;
}[];
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme?.hasCustomSound) {
const soundDataUrl = await window.electron.getThemeSoundDataUrl(
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 levelDBService.get(
"userPreferences",
null,
"json"
)) as { achievementSoundVolume?: number } | null;
return prefs?.achievementSoundVolume ?? 0.15;
} catch (error) {
console.error("Failed to get sound volume", error);
return 0.15;
}
};
export const getGameKey = (shop: GameShop, objectId: string): string => {
return `${shop}:${objectId}`;
};

View File

@@ -0,0 +1,158 @@
import type { CropArea } from "@renderer/components";
type DrawImageCallback = (
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
cropArea: CropArea
) => void;
const loadImageAndProcess = async (
imagePath: string,
cropArea: CropArea,
outputFormat: string,
drawCallback: DrawImageCallback
): Promise<Uint8Array> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Failed to get canvas context"));
return;
}
drawCallback(ctx, img, cropArea);
const convertBlobToUint8Array = async (
blob: Blob
): Promise<Uint8Array> => {
const buffer = await blob.arrayBuffer();
return new Uint8Array(buffer);
};
const handleBlob = (blob: Blob | null) => {
if (!blob) {
reject(new Error("Failed to create blob from canvas"));
return;
}
convertBlobToUint8Array(blob).then(resolve).catch(reject);
};
canvas.toBlob(handleBlob, outputFormat, 0.95);
};
img.onerror = () => {
reject(new Error("Failed to load image"));
};
img.src = imagePath.startsWith("local:") ? imagePath : `local:${imagePath}`;
});
};
const setCanvasDimensions = (
canvas: HTMLCanvasElement,
width: number,
height: number
): void => {
canvas.width = width;
canvas.height = height;
};
type DrawImageParams = {
sourceX: number;
sourceY: number;
sourceWidth: number;
sourceHeight: number;
destWidth: number;
destHeight: number;
};
const drawCroppedImage = (
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
params: DrawImageParams
): void => {
ctx.drawImage(
img,
params.sourceX,
params.sourceY,
params.sourceWidth,
params.sourceHeight,
0,
0,
params.destWidth,
params.destHeight
);
};
/**
* Crops an image using HTML5 Canvas API
* @param imagePath - Path to the image file
* @param cropArea - Crop area coordinates and dimensions
* @param outputFormat - Output image format (default: 'image/png')
* @returns Promise resolving to cropped image as Uint8Array
*/
export async function cropImage(
imagePath: string,
cropArea: CropArea,
outputFormat: string = "image/png"
): Promise<Uint8Array> {
return loadImageAndProcess(
imagePath,
cropArea,
outputFormat,
(ctx, img, area) => {
const canvas = ctx.canvas;
setCanvasDimensions(canvas, area.width, area.height);
drawCroppedImage(ctx, img, {
sourceX: area.x,
sourceY: area.y,
sourceWidth: area.width,
sourceHeight: area.height,
destWidth: area.width,
destHeight: area.height,
});
}
);
}
/**
* Crops an image to a circular shape
* @param imagePath - Path to the image file
* @param cropArea - Crop area coordinates and dimensions (should be square for circle)
* @param outputFormat - Output image format (default: 'image/png')
* @returns Promise resolving to cropped circular image as Uint8Array
*/
export async function cropImageToCircle(
imagePath: string,
cropArea: CropArea,
outputFormat: string = "image/png"
): Promise<Uint8Array> {
return loadImageAndProcess(
imagePath,
cropArea,
outputFormat,
(ctx, img, area) => {
const size = Math.min(area.width, area.height);
const canvas = ctx.canvas;
setCanvasDimensions(canvas, size, size);
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
drawCroppedImage(ctx, img, {
sourceX: area.x,
sourceY: area.y,
sourceWidth: size,
sourceHeight: size,
destWidth: size,
destHeight: size,
});
}
);
}

View File

@@ -6,8 +6,3 @@ export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
export * from "./use-feature";
export * from "./use-download-options-listener";
export * from "./use-game-card";
export * from "./use-search-history";
export * from "./use-search-suggestions";
export * from "./use-hls-video";

View File

@@ -1,9 +1,8 @@
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
import type { DownloadSource } from "@types";
import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features";
import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -41,9 +40,8 @@ export function useCatalogue() {
}, []);
const getDownloadSources = useCallback(() => {
levelDBService.values("downloadSources").then((results) => {
const sources = results as DownloadSource[];
setDownloadSources(sources.filter((source) => !!source.fingerprint));
window.electron.getDownloadSources().then((results) => {
setDownloadSources(results.filter((source) => !!source.fingerprint));
});
}, []);

View File

@@ -1,19 +0,0 @@
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

@@ -1,66 +0,0 @@
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

@@ -1,102 +0,0 @@
import { useEffect, useRef } from "react";
import Hls from "hls.js";
import { logger } from "@renderer/logger";
interface UseHlsVideoOptions {
videoSrc: string | undefined;
videoType: string | undefined;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
}
export function useHlsVideo(
videoRef: React.RefObject<HTMLVideoElement>,
{ videoSrc, videoType, autoplay, muted, loop }: UseHlsVideoOptions
) {
const hlsRef = useRef<Hls | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video || !videoSrc) return;
const isHls = videoType === "application/x-mpegURL";
if (!isHls) {
return undefined;
}
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
});
hlsRef.current = hls;
hls.loadSource(videoSrc);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoplay) {
video.play().catch((err) => {
logger.warn("Failed to autoplay HLS video:", err);
});
}
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
logger.error("HLS network error, trying to recover");
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
logger.error("HLS media error, trying to recover");
hls.recoverMediaError();
break;
default:
logger.error("HLS fatal error, destroying instance");
hls.destroy();
break;
}
}
});
return () => {
hls.destroy();
hlsRef.current = null;
};
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = videoSrc;
video.load();
if (autoplay) {
video.play().catch((err) => {
logger.warn("Failed to autoplay HLS video:", err);
});
}
return () => {
video.src = "";
};
} else {
logger.warn("HLS playback is not supported in this browser");
return undefined;
}
}, [videoRef, videoSrc, videoType, autoplay, muted, loop]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (muted !== undefined) {
video.muted = muted;
}
if (loop !== undefined) {
video.loop = loop;
}
}, [videoRef, muted, loop]);
return hlsRef.current;
}

View File

@@ -1,89 +0,0 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
export interface SearchHistoryEntry {
query: string;
timestamp: number;
context: "library" | "catalogue";
}
const LEVELDB_KEY = "searchHistory";
const MAX_HISTORY_ENTRIES = 15;
export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
const isInitialized = useRef(false);
useEffect(() => {
const loadHistory = async () => {
if (isInitialized.current) return;
isInitialized.current = true;
try {
const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
| SearchHistoryEntry[]
| null;
if (data) {
setHistory(data);
}
} catch {
setHistory([]);
}
};
loadHistory();
}, []);
const addToHistory = useCallback(
(query: string, context: "library" | "catalogue") => {
if (!query.trim()) return;
const newEntry: SearchHistoryEntry = {
query: query.trim(),
timestamp: Date.now(),
context,
};
setHistory((prev) => {
const filtered = prev.filter(
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
);
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated;
});
},
[]
);
const removeFromHistory = useCallback((query: string) => {
setHistory((prev) => {
const updated = prev.filter((entry) => entry.query !== query);
levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated;
});
}, []);
const clearHistory = useCallback(() => {
setHistory([]);
levelDBService.del(LEVELDB_KEY, null);
}, []);
const getRecentHistory = useCallback(
(context: "library" | "catalogue", limit: number = 3) => {
return history
.filter((entry) => entry.context === context)
.slice(0, limit);
},
[history]
);
return {
history,
addToHistory,
removeFromHistory,
clearHistory,
getRecentHistory,
};
}

View File

@@ -1,163 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppSelector } from "./redux";
import { debounce } from "lodash-es";
import { logger } from "@renderer/logger";
import type { GameShop } from "@types";
export interface SearchSuggestion {
title: string;
objectId: string;
shop: GameShop;
iconUrl: string | null;
source: "library" | "catalogue";
}
export function useSearchSuggestions(
query: string,
isOnLibraryPage: boolean,
enabled: boolean = true
) {
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(null);
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
const getLibrarySuggestions = useCallback(
(searchQuery: string, limit: number = 3): SearchSuggestion[] => {
if (!searchQuery.trim()) return [];
const queryLower = searchQuery.toLowerCase();
const matches: SearchSuggestion[] = [];
for (const game of library) {
if (matches.length >= limit) break;
const titleLower = game.title.toLowerCase();
let queryIndex = 0;
for (
let i = 0;
i < titleLower.length && queryIndex < queryLower.length;
i++
) {
if (titleLower[i] === queryLower[queryIndex]) {
queryIndex++;
}
}
if (queryIndex === queryLower.length) {
matches.push({
title: game.title,
objectId: game.objectId,
shop: game.shop,
iconUrl: game.iconUrl,
source: "library",
});
}
}
return matches;
},
[library]
);
const fetchCatalogueSuggestions = useCallback(
async (searchQuery: string, limit: number = 3) => {
if (!searchQuery.trim() || searchQuery.length < 2) {
setSuggestions([]);
setIsLoading(false);
return;
}
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
const cachedResults = cacheRef.current.get(cacheKey);
if (cachedResults) {
setSuggestions(cachedResults);
setIsLoading(false);
return;
}
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setIsLoading(true);
try {
const response = await window.electron.hydraApi.get<
{
title: string;
objectId: string;
shop: GameShop;
iconUrl: string | null;
}[]
>("/catalogue/search/suggestions", {
params: {
query: searchQuery,
limit,
},
needsAuth: false,
});
if (abortController.signal.aborted) return;
const catalogueSuggestions: SearchSuggestion[] = response.map(
(item) => ({
...item,
source: "catalogue" as const,
})
);
cacheRef.current.set(cacheKey, catalogueSuggestions);
setSuggestions(catalogueSuggestions);
} catch (error) {
if (!abortController.signal.aborted) {
setSuggestions([]);
logger.error("Failed to fetch catalogue suggestions", error);
}
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
},
[]
);
const debouncedFetchCatalogue = useRef(
debounce(fetchCatalogueSuggestions, 300)
).current;
useEffect(() => {
if (!enabled || !query || query.length < 2) {
setSuggestions([]);
setIsLoading(false);
abortControllerRef.current?.abort();
debouncedFetchCatalogue.cancel();
return;
}
if (isOnLibraryPage) {
const librarySuggestions = getLibrarySuggestions(query, 3);
setSuggestions(librarySuggestions);
setIsLoading(false);
} else {
debouncedFetchCatalogue(query, 3);
}
return () => {
debouncedFetchCatalogue.cancel();
abortControllerRef.current?.abort();
};
}, [
query,
isOnLibraryPage,
enabled,
getLibrarySuggestions,
debouncedFetchCatalogue,
]);
return { suggestions, isLoading };
}

View File

@@ -21,7 +21,6 @@ import resources from "@locales";
import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies";
import { levelDBService } from "./services/leveldb.service";
import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home";
import Downloads from "./pages/downloads/downloads";
@@ -30,7 +29,6 @@ import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log;
@@ -49,11 +47,7 @@ i18n
},
})
.then(async () => {
const userPreferences = (await levelDBService.get(
"userPreferences",
null,
"json"
)) as { language?: string } | null;
const userPreferences = await window.electron.getUserPreferences();
if (userPreferences?.language) {
i18n.changeLanguage(userPreferences.language);
@@ -70,7 +64,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route element={<App />}>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/library" element={<Library />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />

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