diff --git a/src/locales/ar/translation.json b/src/locales/ar/translation.json index 782d6b51..034d0cbd 100644 --- a/src/locales/ar/translation.json +++ b/src/locales/ar/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "تم تسجيل الدخول بنجاح" }, "home": { - "featured": "مميز", "surprise_me": "مفاجئني", "no_results": "لم يتم العثور على نتائج", "start_typing": "ابدأ بالكتابة للبحث...", diff --git a/src/locales/be/translation.json b/src/locales/be/translation.json index c9d49626..8d67e693 100644 --- a/src/locales/be/translation.json +++ b/src/locales/be/translation.json @@ -1,7 +1,6 @@ { "language_name": "беларуская мова", "home": { - "featured": "Рэкамэндаванае", "surprise_me": "Здзіві мяне", "no_results": "Няма вынікаў" }, @@ -17,7 +16,6 @@ "home": "Галоўная", "favorites": "Улюбленыя" }, - "header": { "search": "Пошук", "home": "Галоўная", @@ -31,10 +29,7 @@ "downloading_metadata": "Сцягванне мэтаданых {{title}}…", "downloading": "Сцягванне {{title}}… ({{percentage}} скончана) - Канчатак {{eta}} - {{speed}}" }, - "catalogue": { - "next_page": "Наступная старонка", - "previous_page": "Папярэдняя старонка" - }, + "catalogue": {}, "game_details": { "open_download_options": "Адкрыць варыянты сцягвання", "download_options_zero": "Няма варыянтаў сцягвання", diff --git a/src/locales/bg/translation.json b/src/locales/bg/translation.json index 458b9e36..3e289700 100644 --- a/src/locales/bg/translation.json +++ b/src/locales/bg/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Успешно влизане" }, "home": { - "featured": "Препоръчани", "surprise_me": "Изненадай ме", "no_results": "Няма намерени резултати", "start_typing": "Започнете да пишете за търсене...", diff --git a/src/locales/ca/translation.json b/src/locales/ca/translation.json index aa69001f..96eb67e2 100644 --- a/src/locales/ca/translation.json +++ b/src/locales/ca/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Has entrat correctament" }, "home": { - "featured": "Destacats", "surprise_me": "Sorprèn-me", "no_results": "No s'ha trobat res" }, @@ -25,7 +24,6 @@ }, "header": { "search": "Cerca jocs", - "home": "Inici", "catalogue": "Catàleg", "downloads": "Baixades", @@ -41,10 +39,7 @@ "calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…", "checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)" }, - "catalogue": { - "next_page": "Pàgina següent", - "previous_page": "Pàgina anterior" - }, + "catalogue": {}, "game_details": { "open_download_options": "Obre les opcions de baixada", "download_options_zero": "No hi ha opcions de baixada", diff --git a/src/locales/cs/translation.json b/src/locales/cs/translation.json index 9b501b54..6bcc8944 100644 --- a/src/locales/cs/translation.json +++ b/src/locales/cs/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Úspěšně přihlášen" }, "home": { - "featured": "Doporučené", "surprise_me": "Překvap mě", "no_results": "Výsledek nenalezen", "start_typing": "Začni psát pro vyhledávání...", diff --git a/src/locales/da/translation.json b/src/locales/da/translation.json index 618f085c..21a92f72 100644 --- a/src/locales/da/translation.json +++ b/src/locales/da/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Loggede ind successfuldt" }, "home": { - "featured": "Anbefalet", "surprise_me": "Overrask mig", "no_results": "Ingen resultater fundet", "start_typing": "Begynd at skrive for at søge...", @@ -29,7 +28,6 @@ }, "header": { "search": "Søg efter spil", - "home": "Hjem", "catalogue": "Katalog", "downloads": "Downloads", @@ -45,10 +43,7 @@ "calculating_eta": "Downloader {{title}}… ({{percentage}} færdig) - Udregner resterende tid…", "checking_files": "Checker {{title}} filer… ({{percentage}} færdig)" }, - "catalogue": { - "next_page": "Næste side", - "previous_page": "Forrige side" - }, + "catalogue": {}, "game_details": { "open_download_options": "Åben download muligheder", "download_options_zero": "Ingen download mulighed", diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 5101f459..fb285ee0 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Erfolgreich angemeldet" }, "home": { - "featured": "Empfohlen", "surprise_me": "Überrasche mich", "no_results": "Keine Ergebnisse gefunden", "start_typing": "Tippe, um zu suchen...", @@ -59,9 +58,7 @@ "download_sources": "Download-Quellen", "result_count": "{{resultCount}} Ergebnisse", "filter_count": "{{filterCount}} verfügbar", - "clear_filters": "{{filterCount}} ausgewählte löschen", - "next_page": "Nächste Seite", - "previous_page": "Vorherige Seite" + "clear_filters": "{{filterCount}} ausgewählte löschen" }, "game_details": { "open_download_options": "Download-Optionen öffnen", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ea7fff89..93fd5b0a 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -27,7 +27,50 @@ "friends": "Friends", "need_help": "Need help?", "favorites": "Favorites", - "playable_button_title": "Show only games you can play now" + "playable_button_title": "Show only games you can play now", + "add_custom_game_tooltip": "Add Custom Game", + "show_playable_only_tooltip": "Show Playable Only", + "custom_game_modal": "Add Custom Game", + "custom_game_modal_description": "Add a custom game to your library by selecting an executable file", + "custom_game_modal_executable_path": "Executable Path", + "custom_game_modal_select_executable": "Select executable file", + "custom_game_modal_title": "Title", + "custom_game_modal_enter_title": "Enter title", + "custom_game_modal_browse": "Browse", + "custom_game_modal_cancel": "Cancel", + "custom_game_modal_add": "Add Game", + "custom_game_modal_adding": "Adding Game...", + "custom_game_modal_success": "Custom game added successfully", + "custom_game_modal_failed": "Failed to add custom game", + "custom_game_modal_executable": "Executable", + "edit_game_modal": "Customize Assets", + "edit_game_modal_description": "Customize game assets and details", + "edit_game_modal_title": "Title", + "edit_game_modal_enter_title": "Enter title", + "edit_game_modal_image": "Image", + "edit_game_modal_select_image": "Select image", + "edit_game_modal_browse": "Browse", + "edit_game_modal_image_preview": "Image preview", + "edit_game_modal_icon": "Icon", + "edit_game_modal_select_icon": "Select icon", + "edit_game_modal_icon_preview": "Icon preview", + "edit_game_modal_logo": "Logo", + "edit_game_modal_select_logo": "Select logo", + "edit_game_modal_logo_preview": "Logo preview", + "edit_game_modal_hero": "Library Hero", + "edit_game_modal_select_hero": "Select library hero image", + "edit_game_modal_hero_preview": "Library hero image preview", + "edit_game_modal_cancel": "Cancel", + "edit_game_modal_update": "Update", + "edit_game_modal_updating": "Updating...", + "edit_game_modal_fill_required": "Please fill in all required fields", + "edit_game_modal_success": "Assets updated successfully", + "edit_game_modal_failed": "Failed to update assets", + "edit_game_modal_image_filter": "Image", + "edit_game_modal_icon_resolution": "Recommended resolution: 256x256px", + "edit_game_modal_logo_resolution": "Recommended resolution: 640x360px", + "edit_game_modal_hero_resolution": "Recommended resolution: 1920x620px", + "edit_game_modal_assets": "Assets" }, "header": { "search": "Search games", @@ -230,6 +273,7 @@ "backup_unfrozen": "Backup unpinned", "backup_freeze_failed": "Failed to freeze backup", "backup_freeze_failed_description": "You must leave at least one free slot for automatic backups", + "edit_game_modal_button": "Customize game assets", "game_details": "Game Details", "currency_symbol": "$", "currency_country": "us", @@ -462,6 +506,8 @@ "user_profile": { "amount_hours": "{{amount}} hours", "amount_minutes": "{{amount}} minutes", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", "last_time_played": "Last played {{period}}", "activity": "Recent Activity", "library": "Library", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 7f54925a..6f0fc9f1 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1,34 +1,76 @@ { "language_name": "Español", "app": { - "successfully_signed_in": "Sesión iniciada exitosamente" + "successfully_signed_in": "Iniciaste sesión exitosamente" }, "home": { - "featured": "Destacado", "surprise_me": "¡Sorpréndeme!", - "no_results": "Sin resultados encontrados", - "start_typing": "Empieza a escribir para buscar...", - "hot": "Popular Ahora", + "no_results": "No se encontraron resultados", + "start_typing": "Empezá a escribir para buscar...", + "hot": "Tendencias", "weekly": "📅 Mejores juegos de la semana", - "achievements": "🏆 Juegos para completar" + "achievements": "🏆 Juegos para platinar" }, "sidebar": { "catalogue": "Catálogo", "downloads": "Descargas", "settings": "Ajustes", - "my_library": "Mi biblioteca", + "my_library": "Mi Librería", "downloading_metadata": "{{title}} (Descargando metadatos…)", "paused": "{{title}} (Pausado)", "downloading": "{{title}} ({{percentage}} - Descargando…)", - "filter": "Buscar en la biblioteca", + "filter": "Filtrar Librería", "home": "Inicio", "queued": "{{title}} (En cola)", "game_has_no_executable": "El juego no tiene un ejecutable seleccionado", - "sign_in": "Iniciar sesión", + "sign_in": "Iniciar Sesión", "friends": "Amigos", - "need_help": "¿Necesitas ayuda?", + "need_help": "¿Necesitás ayuda?", "favorites": "Favoritos", - "playable_button_title": "Mostrar solo juegos que puedes jugar ahora" + "playable_button_title": "Solo mostrar juegos que podés jugar en este momento", + "add_custom_game_tooltip": "Añadir juego personalizado", + "show_playable_only_tooltip": "Mostrar Solo Jugable", + "custom_game_modal": "Añadir juego personalizado", + "custom_game_modal_description": "Añadí un juego personalizado a tu librería seleccionando el ejecutable", + "custom_game_modal_executable_path": "Ruta del Ejecutable", + "custom_game_modal_select_executable": "Seleccionar archivo ejecutable", + "custom_game_modal_title": "Título", + "custom_game_modal_enter_title": "Ingresá el título", + "custom_game_modal_browse": "Buscar", + "custom_game_modal_cancel": "Cancelar", + "custom_game_modal_add": "Añadir juego", + "custom_game_modal_adding": "Añadiendo juego...", + "custom_game_modal_success": "Juego personalizado añadido exitosamente", + "custom_game_modal_failed": "Error al añadir juego personalizado", + "custom_game_modal_executable": "Ejecutable", + "edit_game_modal": "Personalizar recursos", + "edit_game_modal_description": "Personaliza los recursos y detalles del juego", + "edit_game_modal_title": "Título", + "edit_game_modal_enter_title": "Ingresá el título", + "edit_game_modal_image": "Imagen", + "edit_game_modal_select_image": "Seleccionar imagen", + "edit_game_modal_browse": "Navegar", + "edit_game_modal_image_preview": "Vista previa de imagen", + "edit_game_modal_icon": "Ícono", + "edit_game_modal_select_icon": "Seleccionar ícono", + "edit_game_modal_icon_preview": "Vista previa de ícono", + "edit_game_modal_logo": "Logo", + "edit_game_modal_select_logo": "Seleccionar logo", + "edit_game_modal_logo_preview": "Vista previa del logo", + "edit_game_modal_hero": "Library Hero", + "edit_game_modal_select_hero": "Seleccionar una imagen de Library Hero", + "edit_game_modal_hero_preview": "Vista previa de library hero", + "edit_game_modal_cancel": "Cancelar", + "edit_game_modal_update": "Actualizar", + "edit_game_modal_updating": "Actualizando...", + "edit_game_modal_fill_required": "Por favor rellená todos los espacios requeridos", + "edit_game_modal_success": "Recursos actualizados exitosamente", + "edit_game_modal_failed": "Error al actualizar los recursos", + "edit_game_modal_image_filter": "Imagen", + "edit_game_modal_icon_resolution": "Resolución recomendada: 256x256px", + "edit_game_modal_logo_resolution": "Resolución recomendada: 640x360px", + "edit_game_modal_hero_resolution": "Resolución recomendada: 1920x620px", + "edit_game_modal_assets": "Recursos" }, "header": { "search": "Buscar juegos", @@ -37,348 +79,409 @@ "downloads": "Descargas", "search_results": "Resultados de búsqueda", "settings": "Ajustes", - "version_available_install": "Versión {{version}} disponible. Presiona acá para descargar y reinstalar.", - "version_available_download": "Versión {{version}} disponible. Presiona aquí para descargar." + "version_available_install": "Versión {{version}} disponible. Presiona acá para reiniciar e instalar.", + "version_available_download": "Versión {{version}} disponible. Presiona acá para descargar." }, "bottom_panel": { "no_downloads_in_progress": "Sin descargas en progreso", "downloading_metadata": "Descargando metadatos de {{title}}…", - "downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}", - "calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Calculando tiempo restante…", - "installation_complete": "Instalación completada", - "installation_complete_message": "Common redistributables instalados exitosamente", + "downloading": "Descargando {{title}}… ({{percentage}} completado) - Restante {{eta}} - {{speed}}", + "calculating_eta": "Descargando {{title}}… ({{percentage}} completado) - Comprobando tiempo restante…", + "checking_files": "Revisando archivos de {{title}}… ({{percentage}} completado)", "installing_common_redist": "{{log}}…", - "checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)" + "installation_complete": "Instalación completada", + "installation_complete_message": "Common redistributables instalados correctamente" }, "catalogue": { "search": "Filtrar…", "developers": "Desarrolladores", "genres": "Géneros", - "tags": "Marcadores", + "tags": "Etiquetas", "publishers": "Editores", - "download_sources": "Fuentes de descarga", + "download_sources": "Descargando fuentes", "result_count": "{{resultCount}} resultados", - "filter_count": "{{filterCount}} disponibles", + "filter_count": "{{filterCount}} disponible", "clear_filters": "Limpiar {{filterCount}} seleccionados" }, "game_details": { - "open_download_options": "Ver opciones de descargas", - "automatically_extract_downloaded_files": "Extraer automáticamente archivos descargados", - "download_error_not_cached_on_hydra": "Esta descarga no está disponible en Nimbus.", - "download_options_zero": "No hay opciones de descargas disponibles", + "open_download_options": "Abrir opciones de descargas", + "download_options_zero": "Sin opciones de descargas", "download_options_one": "{{count}} opción de descarga", "download_options_other": "{{count}} opciones de descargas", - "updated_at": "Actualizado el: {{updated_at}}", + "updated_at": "Actualizado el {{updated_at}}", "install": "Instalar", - "resume": "Continuar", - "pause": "Pausa", + "resume": "Resumir", + "pause": "Pausar", "cancel": "Cancelar", - "remove": "Eliminar", - "space_left_on_disk": "{{space}} restantes en el disco", - "eta": "Tiempo restante: {{eta}}", + "remove": "Remover", + "space_left_on_disk": "{{space}} restante en el disco", + "eta": "Conclusión {{eta}}", "calculating_eta": "Calculando tiempo restante…", "downloading_metadata": "Descargando metadatos…", - "filter": "Buscar repacks", + "filter": "Filtrar repacks", "requirements": "Requisitos del Sistema", "minimum": "Mínimos", "recommended": "Recomendados", - "paused": "Pausado", - "release_date": "Fecha de lanzamiento: {{date}}", - "publisher": "Publicado por: {{publisher}}", + "paused": "En Pausa", + "release_date": "Lanzado el {{date}}", + "publisher": "Públicado por {{publisher}}", "hours": "horas", "minutes": "minutos", "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", - "accuracy": "{{accuracy}}% precisión", - "add_to_library": "Agregar a la biblioteca", - "remove_from_library": "Eliminar de la biblioteca", - "no_downloads": "No hay descargas disponibles", - "play_time": "Has jugado {{amount}}", - "last_time_played": "Jugado por última vez: {{period}}", - "not_played_yet": "Aún no has jugado a {{title}}", + "accuracy": "{{accuracy}}% completista", + "add_to_library": "Añadir a la librería", + "already_in_library": "Ya está en la librería", + "remove_from_library": "Eliminar de la librería", + "no_downloads": "Sin descargas disponibles", + "play_time": "Jugado por {{amount}}", + "last_time_played": "Última vez jugado {{period}}", + "not_played_yet": "No has jugado a {{title}} todavía", "next_suggestion": "Siguiente sugerencia", "play": "Jugar", "deleting": "Eliminando instalador…", "close": "Cerrar", "playing_now": "Jugando ahora", "change": "Cambiar", - "repacks_modal_description": "Selecciona el repack que quieres descargar", - "select_folder_hint": "Para cambiar la carpeta predeterminada, ve a <0>Ajustes", + "repacks_modal_description": "Elegí el repack que querés descargar", + "select_folder_hint": "Si querés cambiar la carpeta por defecto, andá a <0>Ajustes", "download_now": "Descargar ahora", "no_shop_details": "No se pudieron obtener detalles de la tienda.", "download_options": "Opciones de descarga", "download_path": "Ruta de descarga", "previous_screenshot": "Anterior captura", "next_screenshot": "Siguiente captura", - "screenshot": "Captura {{number}}", - "open_screenshot": "Abrir captura {{number}}", - "download_settings": "Ajustes de descarga", - "downloader": "Método de descarga", + "screenshot": "Captura número {{number}}", + "open_screenshot": "Abrir captura número {{number}}", + "download_settings": "Descargar ajustes", + "downloader": "Descargador", "select_executable": "Seleccionar", - "no_executable_selected": "No se seleccionó un ejecutable", + "no_executable_selected": "Sin ejecutable seleccionado", "open_folder": "Abrir carpeta", "open_download_location": "Ver archivos descargados", - "create_shortcut": "Crear acceso directo en el escritorio", - "remove_files": "Eliminar archivos", + "create_shortcut": "Crear atajo en el escritorio", + "clear": "Limpiar", + "remove_files": "Remover archivos", "remove_from_library_title": "¿Estás seguro?", - "remove_from_library_description": "Esto eliminará {{game}} de tu biblioteca", + "remove_from_library_description": "Esto va eliminará {{game}} de tu librería", "options": "Opciones", "executable_section_title": "Ejecutable", - "executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"", + "executable_section_description": "Ruta del archivo que se ejecutará cuando presiones \"Jugar\"", "downloads_section_title": "Descargas", - "downloads_section_description": "Buscar actualizaciones u otras versiones de este juego", - "danger_zone_section_title": "Opciones Avanzadas", - "danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)", + "downloads_section_description": "Revisar actualizaciones u otras versiones del juego", + "danger_zone_section_title": "Zona de Peligro", + "danger_zone_section_description": "Remover este juego de tu librería o los archivos descargados por Hydra", "download_in_progress": "Descarga en progreso", "download_paused": "Descarga pausada", + "last_downloaded_option": "Última opción de descarga", "create_steam_shortcut": "Crear atajo de Steam", - "last_downloaded_option": "Última opción descargada", "create_shortcut_success": "Atajo creado con éxito", - "you_might_need_to_restart_steam": "Es posible que necesites reiniciar Steam para ver los cambios", - "create_shortcut_error": "Error al crear un atajo", - "nsfw_content_title": "Este juego contiene contenido inapropiado.", - "nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?", + "you_might_need_to_restart_steam": "Probablemente necesités reiniciar Steam para ver cambios", + "create_shortcut_error": "Error al crear atajo", + "nsfw_content_title": "Este juego tiene contenido inapropiado", + "nsfw_content_description": "{{title}} tiene contenido no apto para todas las edades. ¿Querés continuar igualmente?", "allow_nsfw_content": "Continuar", - "refuse_nsfw_content": "No, gracias", + "refuse_nsfw_content": "Regresar", "stats": "Estadísticas", "download_count": "Descargas", - "player_count": "Jugadores activos", - "download_error": "Esta opción de descarga no está disponible.", + "player_count": "Jugadores activos", + "download_error": "Esta opción de descarga no está disponible", "download": "Descargar", - "executable_path_in_use": "El ejecutable se encuentra en uso por \"{{game}}\"", + "executable_path_in_use": "El ejecutable ya se está usando por \"{{game}}\"", "warning": "Advertencia:", - "hydra_needs_to_remain_open": "Para esta descarga, Hydra necesita mantenerse abierta hasta que concluya. En caso de que Hydra se cierre antes de que concluya, podrías perder todo el progreso.", + "hydra_needs_to_remain_open": "para esta descarga, Hydra necesita estar abierta hasta que termine. Si se cierra antes de completar, perderás todo el progreso.", "achievements": "Logros", "achievements_count": "Logros {{unlockedCount}}/{{achievementsCount}}", "cloud_save": "Guardado en la nube", - "cloud_save_description": "Guarda tu progreso en la nube y continúa jugando en cualquier dispositivo", - "backups": "Copias de Seguridad", + "cloud_save_description": "Guardá tu progreso en la nube y jugá en cualquier dispositivo", + "backups": "Copia de seguridad", "install_backup": "Instalar", "delete_backup": "Eliminar", - "create_backup": "Nueva Copia de Seguridad", - "last_backup_date": "Última copia de seguridad el {{date}}", - "no_backup_preview": "No se encontraron datos de guardados para este juego", + "create_backup": "Nueva copia de seguridad", + "last_backup_date": "Última copia de seguridad {{date}}", + "no_backup_preview": "No se han encotrado puntos de guardado para este juego", "restoring_backup": "Restaurando copia de seguridad ({{progress}} completado)…", "uploading_backup": "Subiendo copia de seguridad…", - "no_backups": "No has creado ninguna copia de seguridad para este juego aún", + "no_backups": "No has creado ninguna copia de seguridad para este juego todavía", "backup_uploaded": "Copia de seguridad subida", "backup_deleted": "Copia de seguridad eliminada", "backup_restored": "Copia de seguridad restaurada", "see_all_achievements": "Ver todos los logros", - "sign_in_to_see_achievements": "Inicia sesión para ver los logros", + "sign_in_to_see_achievements": "Iniciá sesión para ver los logros", "mapping_method_automatic": "Automático", "mapping_method_manual": "Manual", - "mapping_method_label": "Método de mapeo", - "files_automatically_mapped": "Archivos mapeados automáticamente", + "mapping_method_label": "Método de mapeado", + "files_automatically_mapped": "Archivos automáticamente mapeados", "no_backups_created": "Sin copias de seguridad creadas para este juego", - "manage_files": "Gestionar archivos", - "loading_save_preview": "Buscando datos de guardados de juegos…", + "manage_files": "Administrar archivos", + "loading_save_preview": "Buscando por guardado de juegos…", "wine_prefix": "Prefijo de Wine", - "wine_prefix_description": "El prefijo de Wine usado para ejecutar este juego", + "wine_prefix_description": "El prefijo de Wine usado para este juego", + "launch_options": "Opciones para iniciar", + "launch_options_description": "Los usuarios avanzados pueden ingresar sus modificaciones para el inicio de sus juegos (característica experimental)", + "launch_options_placeholder": "Sin parámetro específicado", "no_download_option_info": "Sin información disponible", - "backup_deletion_failed": "La eliminación de la copia de seguridad falló", - "max_number_of_artifacts_reached": "Número máximo de copias de seguridad de este juego alcanzadas", - "achievements_not_sync": "Tus logros no están sincronizados", - "manage_files_description": "Gestiona los archivos que serán respaldados y restaurados", + "backup_deletion_failed": "Error al eliminar copia de seguridad", + "max_number_of_artifacts_reached": "Máximo de copias de seguridad alcanzadas para este juego", + "achievements_not_sync": "Revisá como sincronizar tus logros'", + "manage_files_description": "Elegí que archivos se guardarán y restaurarán de la copia de seguridad", "select_folder": "Seleccionar carpeta", "backup_from": "Copia de seguridad de {{date}}", "automatic_backup_from": "Copia de seguridad automática de {{date}}", - "enable_automatic_cloud_sync": "Habilitar sincronización automática en la nube", - "custom_backup_location_set": "Se configuró la carpeta de copia de seguridad", - "clear": "Limpiar", - "no_directory_selected": "No se seleccionó un directorio", - "launch_options": "Opciones de Inicio", - "launch_options_description": "Los usuarios avanzados pueden introducir sus propias modificaciones de opciones de inicio (característica experimental)", - "launch_options_placeholder": "Sin parámetro específicado", - "no_write_permission": "No se puede descargar en este directorio. Presiona aquí para aprender más.", + "enable_automatic_cloud_sync": "Habilitar sincronización con la nube", + "custom_backup_location_set": "Ubicación de copia de seguridad personalizada", + "no_directory_selected": "Sin directorio seleccionado", + "no_write_permission": "No se puede descargar en este directorio. Presioná acá para más información.", "reset_achievements": "Reiniciar logros", - "reset_achievements_description": "Esto reiniciará todos los logros de {{game}}", - "reset_achievements_title": "¿Estás seguro?", - "reset_achievements_success": "Logros reiniciados exitosamente", - "reset_achievements_error": "Se produjo un error al reiniciar los logros", - "download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de Gofile. Por favor espera a que se reinicie la cuota.", - "download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nueva descargas. Por favor, revisa los ajustes de tu cuenta e intenta de nuevo.", - "download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.", - "download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y aún no se puede verificar el estado de la descarga.", + "reset_achievements_description": "Esto va a reiniciar todos los logros para {{game}}", + "reset_achievements_title": "¿Querés continuar?", + "reset_achievements_success": "Logros reiniciados éxitosamente", + "reset_achievements_error": "Error al reiniciar logros", + "download_error_gofile_quota_exceeded": "Has excedido la cuota mensual de GoFile. Esperá a que se reinice.", + "download_error_real_debrid_account_not_authorized": "Tu cuenta de Real-Debrid no está autorizada para nuevas descargas. Revisá los ajustes de tu cuenta y probá de nuevo.", + "download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y no está disponible el estado de descarga de sondeo todavía.", + "update_playtime_title": "Actualizar tiempo de juego", + "update_playtime_description": "Actualizar manualmente el tiempo de juego para {{game}}", + "update_playtime": "Actualizar tiempo de juego", + "update_playtime_success": "Tiempo de juego actualizado éxitosamente", + "update_playtime_error": "Error al actualizar el tiempo de juego", + "update_game_playtime": "Actualizar tu tiempo de juego", + "manual_playtime_warning": "Tus horas de juego se marcarán como actualizadas manualmente, y esto no se puede deshacer.", + "manual_playtime_tooltip": "Este tiempo de juego se ha actualizad manualmente", + "download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y no está disponible el estado de descarga de sondeo todavía.", + "download_error_not_cached_on_hydra": "Esta descarga no está disponible en Nimbus.", + "game_removed_from_favorites": "Juego eliminado de favoritos", "game_added_to_favorites": "Juego añadido a favoritos", - "game_removed_from_favorites": "Juego removido de favoritos", - "invalid_wine_prefix_path": "Ruta de prefijo de Wine inválida", - "invalid_wine_prefix_path_description": "La ruta del prefijo Wine es inválida. Por favor, checa la ruta y vuelve a intentarlo.", - "missing_wine_prefix": "Se requiere el prefijo Wine para crear una copia de seguridad en Linux" + "game_removed_from_pinned": "Juego removido de fijados", + "game_added_to_pinned": "Juego añadido a fijados", + "automatically_extract_downloaded_files": "Extraer automáticamente archivos descargados", + "create_start_menu_shortcut": "Crear un atajo en el Menú de Inicio", + "invalid_wine_prefix_path": "Ruta inválida del prefijo de Wine", + "invalid_wine_prefix_path_description": "La ruta al prefijo de Wine es inválida. Por favor revisá la ruta y probá de nuevo.", + "missing_wine_prefix": "EL prefijo de Wine es requerido para hacer una copia en Linux", + "artifact_renamed": "Copia de seguridad renombrada éxitosamente", + "rename_artifact": "Renombrar copia de seguridad", + "rename_artifact_description": "Renombrar copia de seguridad con un nombre más descriptivo", + "artifact_name_label": "Nombre de la copia de seguridad", + "artifact_name_placeholder": "Introducí un nombre para la copia de seguridad", + "save_changes": "Guardar cambios", + "required_field": "Este campo es requerido", + "max_length_field": "Este campo debe tener menos de {{length}} carácteres", + "freeze_backup": "Fíjalo así no se re-escríbira por copias de seguridad automáticas", + "unfreeze_backup": "Dejar de fijar", + "backup_frozen": "Copia de seguridad fijada", + "backup_unfrozen": "Copia de seguridad desfijada", + "backup_freeze_failed": "Error al congelar tu copia de seguridad", + "backup_freeze_failed_description": "Tenés que tener mínimo un espacio para copias de seguridad automáticas", + "edit_game_modal_button": "Personalizar recursos de juego", + "game_details": "Detalles del juego", + "currency_symbol": "$", + "currency_country": "us", + "prices": "Precios", + "no_prices_found": "No se encontraron precios", + "view_all_prices": "Presioná acá para ver todos los precios", + "retail_price": "Precio recomendado", + "keyshop_price": "Precio de tiendas de terceros", + "historical_retail": "Precio de tiendas", + "historical_keyshop": "Precio de tiendas de terceros", + "language": "Idioma", + "caption": "Subtítulo", + "audio": "Audio" }, "activation": { "title": "Activar Hydra", - "installation_id": "ID de la Instalación:", - "enter_activation_code": "Introduce tu código de activación", - "message": "Si no sabes donde obtener el código, no deberías de tener esto.", + "installation_id": "ID de Instalación:", + "enter_activation_code": "Introducí tu código de activación", + "message": "Si no sabes donde preguntar por esto, entonces no tenés que tener esto.", "activate": "Activar", "loading": "Cargando…" }, "downloads": { "resume": "Resumir", - "pause": "Pausa", - "eta": "Finalizando en {{eta}}", - "paused": "En Pausa", + "pause": "Pausar", + "eta": "Tiempo de finalizción {{eta}}", + "paused": "Pausado", "verifying": "Verificando…", "completed": "Completado", "removed": "No descargado", "cancel": "Cancelar", - "filter": "Buscar juegos descargados", - "remove": "Eliminar", + "filter": "Filtrar juegos descargados", + "remove": "Remover", "downloading_metadata": "Descargando metadatos…", - "deleting": "Eliminando instalador…", - "delete": "Eliminar instalador", - "delete_modal_title": "¿Estás seguro?", - "delete_modal_description": "Esto eliminará todos los archivos de la instalación del repack del juego de tu computadora. (Si ya instalaste el juego, puedes eliminar esto, no afectará al juego)", + "deleting": "Eliminado instalador…", + "delete": "Remover instalador", + "delete_modal_title": "¿Querés continuar?", + "delete_modal_description": "Esto eliminará todos los archivos del instalador de tu computadora", "install": "Instalar", "download_in_progress": "En progreso", "queued_downloads": "Descargas en cola", "downloads_completed": "Completado", "queued": "En cola", - "no_downloads_title": "Esto está tan... vacío", - "no_downloads_description": "No has descargado nada con Hydra... aún, ¡pero nunca es tarde para comenzar!.", - "checking_files": "Verificando archivos…", - "seeding": "Seeding", - "stop_seeding": "Detener seeding", - "resume_seeding": "Continuar seeding", + "no_downloads_title": "Esto está... tan, ¿vacío?", + "no_downloads_description": "No has descargado nada con Hydra, pero nunca es tarde para comenzar.", + "checking_files": "Revisando archivos…", + "seeding": "Sembrando", + "stop_seeding": "Dejar de sembrar", + "resume_seeding": "Continuar sembrando", + "options": "Administrar", "extract": "Extraer archivos", - "extracting": "Extrayendo archivos…", - "options": "Gestionar" + "extracting": "Extrayendo archivos…" }, "settings": { "downloads_path": "Ruta de descarga", - "common_redist": "Common redistributables", - "common_redist_description": "Las Common redistributables son requeridos para ejecutar algunos juegos. Es recomendado instalarlos para evitar problemas.", - "create_real_debrid_account": "Presiona acá si no tienes una cuenta de Real-Debrid aún", - "create_torbox_account": "Presiona acá si no tienes una cuenta de TorBox aún", - "install_common_redist": "Instalar", - "installing_common_redist": "Instalando…", - "show_download_speed_in_megabytes": "Mostrar velocidad de descargar en megabytes por segundo", - "change": "Cambiar", + "change": "Actualizar", "notifications": "Notificaciones", - "enable_download_notifications": "Cuando se completa una descarga", - "enable_repack_list_notifications": "Cuando se añade un repack nuevo", - "real_debrid_api_token_label": "Token API de Real-Debrid", - "quit_app_instead_hiding": "Salir de Hydra en vez de minimizar en la bandeja del sistema", - "launch_with_system": "Iniciar Hydra al inicio del sistema", + "enable_download_notifications": "Cuando una descarga se completa", + "enable_repack_list_notifications": "Cuando un nuevo repack se añade", + "real_debrid_api_token_label": "Real-Debrid API token", + "quit_app_instead_hiding": "No ocultar Hydra cuando se cierra", + "launch_with_system": "Iniciar Hydra con el sistema", "general": "General", - "behavior": "Otros", - "download_sources": "Fuentes de descarga", + "behavior": "Comportamiento", + "download_sources": "Fuentes de descargas", "language": "Idioma", - "api_token": "Token API", - "enable_real_debrid": "Activar Real-Debrid", - "real_debrid_description": "Real-Debrid es una forma de descargar sin restricciones archivos instantáneamente con la máxima velocidad de tu internet.", - "debrid_invalid_token": "Token de API inválido", - "debrid_api_token_hint": "Puedes obtener tu clave de API <0>aquí", - "real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratuita. Por favor, suscríbete a Real-Debrid", + "api_token": "API Token", + "enable_real_debrid": "Habilitar Real-Debrid", + "real_debrid_description": "Real-Debrid es un descargador que te permite descargar archivos más rápidos, solo límitado por la velocidad de tu internet.", + "debrid_invalid_token": "Token API inválido", + "debrid_api_token_hint": "Podés obtener la el token de tu API <0>acá", + "real_debrid_free_account_error": "La cuenta \"{{username}}\" es una cuenta gratis. Por favor suscribíte a Real-Debrid", "debrid_linked_message": "Cuenta \"{{username}}\" vinculada", "save_changes": "Guardar cambios", - "changes_saved": "Ajustes guardados exitosamente", - "download_sources_description": "Hydra buscará los enlaces de descarga de estas fuentes. La URL de origen debe ser un enlace directo a un archivo .json que contenga los enlaces de descarga", + "changes_saved": "Cambios guardados éxitosamente", + "download_sources_description": "Hydra va a recoger los links de descarga de cada fuente. La URL de origen debe ser un enlace .json que contenga los enlaces de descarga.", "validate_download_source": "Validar", - "remove_download_source": "Eliminar", - "add_download_source": "Añadir fuente de descarga", - "download_count_zero": "No hay descargas en la lista", - "download_count_one": "{{countFormatted}} descarga en la lista", - "download_count_other": "{{countFormatted}} descargas en la lista", - "download_source_url": "Descargar URL de origen", - "add_download_source_description": "Introduce la URL con el archivo .json", - "download_source_up_to_date": "Al día", + "remove_download_source": "Remover", + "add_download_source": "Añadir fuente", + "download_count_zero": "Sin opciones de descarga", + "download_count_one": "{{countFormatted}} opción de descarga", + "download_count_other": "{{countFormatted}} opciones de descarga", + "download_source_url": "Descargar fuente URL", + "add_download_source_description": "Introducí la URL del archivo .json", + "download_source_up_to_date": "Actualizado", "download_source_errored": "Error", "sync_download_sources": "Sincronizar fuentes", "removed_download_source": "Fuente de descarga eliminada", + "removed_download_sources": "Fuente de descarga eliminadas", "cancel_button_confirmation_delete_all_sources": "No", - "confirm_button_confirmation_delete_all_sources": "Sí, eliminar todo", - "description_confirmation_delete_all_sources": "Eliminarás todas las fuentes de descarga", + "confirm_button_confirmation_delete_all_sources": "Si, eliminar todo", "title_confirmation_delete_all_sources": "Eliminar todas las fuentes de descarga", - "removed_download_sources": "Fuentes de descarga eliminadas", - "button_delete_all_sources": "Eliminar todas las fuentes de descarga", - "added_download_source": "Fuente de descarga añadida", - "download_sources_synced": "Todas las fuentes de descargas están actualizadas.", - "insert_valid_json_url": "Introduce una URL JSON válida", - "found_download_option_zero": "No se encontró una opción de descarga", - "found_download_option_one": "Se encontró {{countFormatted}} opción de descarga", - "found_download_option_other": "Se encontraron {{countFormatted}} opciones de descarga", + "description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas", + "button_delete_all_sources": "Eliminar todo", + "added_download_source": "Añadir fuente de descarga", + "download_sources_synced": "Todas las fuentes de descarga están sincronizadas", + "insert_valid_json_url": "Introducí una URL de json válida", + "found_download_option_zero": "Sin opciones de descargas encontrada", + "found_download_option_one": "Encontrada {{countFormatted}} fuente de descarga", + "found_download_option_other": "Encontradas {{countFormatted}} opciones de descargas", "import": "Importar", "public": "Público", "private": "Privado", - "friends_only": "Solo amigos", + "friends_only": "Sólo amigos", "privacy": "Privacidad", "profile_visibility": "Visibilidad del perfil", - "profile_visibility_description": "Elige quién puede ver tu perfil y biblioteca", - "required_field": "Este campo es obligatorio", - "source_already_exists": "Esta fuente ya ha sido agregada.", - "must_be_valid_url": "La fuente debe ser una URL válida.", + "profile_visibility_description": "Elegí quién puede ver tú perfil y biblioteca", + "required_field": "Este campo es requerido", + "source_already_exists": "Esta fuente ya está añadida", + "must_be_valid_url": "La fuente debe ser una URL válida", "blocked_users": "Usuarios bloqueados", - "user_unblocked": "El usuario ha sido desbloqueado", - "enable_achievement_notifications": "Cuando un logro se desbloquea", + "user_unblocked": "Has desbloqueado a este usuario", + "enable_achievement_notifications": "Cuando desbloqueás un logro", "launch_minimized": "Iniciar Hydra minimizado", - "disable_nsfw_alert": "Desactivar alerta NSFW", - "seed_after_download_complete": "Realizar seeding después de que se completa la descarga", - "show_hidden_achievement_description": "Ocultar descripción de logros ocultos antes de desbloquearlos", + "disable_nsfw_alert": "Deshabilitar alerta de NSFW", + "seed_after_download_complete": "Sembrar después de completar una descarga", + "show_hidden_achievement_description": "Mostrar logros ocultos antes de desbloquearlos", "account": "Cuenta", - "account_data_updated_successfully": "Datos de la cuenta actualizados", - "bill_sent_until": "Tú próxima factura se enviará el {{date}}", - "current_email": "Correo actual:", - "manage_subscription": "Gestionar suscripción", - "no_email_account": "No has configurado un correo aún", - "no_subscription": "Disfruta Hydra de la mejor manera", - "no_users_blocked": "No tienes usuarios bloqueados", - "renew_subscription": "Renovar Hydra Cloud", - "subscription_active_until": "Tu Hydra Cloud está activa hasta {{date}}", - "subscription_expired_at": "Tú suscripción expiró el {{date}}", - "subscription_renew_cancelled": "Está desactivada la renovación automática", - "subscription_renews_on": "Tú suscripción se renueva el {{date}}", + "no_users_blocked": "No has bloqueado a ningún usuario", + "subscription_active_until": "Tu Hydra Cloud está activo hasta {{date}}", + "manage_subscription": "Administrar suscripción", "update_email": "Actualizar correo", - "update_password": "Actualizar contraseña", - "appearance": "Apariencia", - "become_subscriber": "Sé Hydra Cloud", - "cancel": "Cancelar", - "clear_themes": "Limpiar", - "create_theme": "Crear", - "create_theme_modal_description": "Crea un nuevo tema para personalizar la apariencia de Hydra", - "create_theme_modal_title": "Crear tema personalizado", - "delete_all_themes": "Eliminar todos los temas", - "delete_all_themes_description": "Esto eliminará todos tus temas personalizados", - "delete_theme": "Eliminar tema", - "delete_theme_description": "Esto eliminará el tema {{theme}}", - "edit_theme": "Editar tema", + "update_password": "Cambiar contraseña", + "current_email": "Correo actual:", + "no_email_account": "No tenés ningún correo vinculado aún", + "account_data_updated_successfully": "Datos de la cuenta actualizados correctamente", + "renew_subscription": "Renovar Hydra Cloud", + "subscription_expired_at": "Tu suscripción expiró el {{date}}", + "no_subscription": "Disfrutá Hydra de la mejor forma", + "become_subscriber": "Sé parte de Hydra Cloud", + "subscription_renew_cancelled": "Renovación automática desactivada", + "subscription_renews_on": "Tu suscripción se renueva el {{date}}", + "bill_sent_until": "Tu próxima factura se enviará este día", + "no_themes": "Parece que no tenés ningún tema aún, pero no te preocupés, presiona acá para hacer tu primera obra maestra.", "editor_tab_code": "Código", "editor_tab_info": "Info", "editor_tab_save": "Guardar", - "enable_torbox": "Habilitar TorBox", - "error_importing_theme": "Error al importar el tema", + "web_store": "Tienda Web", + "clear_themes": "Limpiar", + "create_theme": "Crear", + "create_theme_modal_title": "Crear tema personalizado", + "create_theme_modal_description": "Crear un nuevo tema para personalizar el estilo de Hydra", + "theme_name": "Nombre", + "insert_theme_name": "Introducí un nombre del tema", + "set_theme": "Usar tema", + "unset_theme": "Dejar de usar tema", + "delete_theme": "Eliminar tema", + "edit_theme": "Editar tema", + "delete_all_themes": "Eliminar todos los temas", + "delete_all_themes_description": "Esto va a eliminar todos los temas personalizados", + "delete_theme_description": "Esto va a eliminar el tema {{theme}}", + "cancel": "Cancelar", + "appearance": "Apariencia", + "enable_torbox": "Activar TorBox", + "torbox_description": "TorBox es un servicio premium de seedbox que incluso rivaliza los mejores servidores.", + "torbox_account_linked": "Cuenta de TorBox vinculada", + "create_real_debrid_account": "Presioná acá si todavía no tenés una cuenta de Real-Debrid", + "create_torbox_account": "Presioná acá si todavía no tenés una cuenta de TorBox", + "real_debrid_account_linked": "Cuenta de Real-Debrid vinculada", + "name_min_length": "El nombre del tema debe tener mínimo 3 carácteres", "import_theme": "Importar tema", "import_theme_description": "Vas a importar el tema {{theme}} desde la tienda de temas", - "insert_theme_name": "Introducí el nombre del tema", - "name_min_length": "El tema tiene que tener 3 carácteres de largo mínimo", - "no_themes": "Parece que no tenés ningún tema aún, pero no te preocupes, presiona acá para crear tu primer tema.", - "real_debrid_account_linked": "Cuenta de Real-Debrid vinculada", - "set_theme": "Establecer tema", - "theme_imported": "Tema importado exitosamente", - "theme_name": "Nombre", - "torbox_account_linked": "Cuenta de TorBox vinculada", - "torbox_description": "TorBox es tu servicio premium de seedbox que rivaliza incluso a los mejores servidores del mercado.", - "unset_theme": "Desactivar tema", - "web_store": "Tienda Web", - "enable_friend_request_notifications": "Cuando se recibe una solicitud de amistad", - "enable_auto_install": "Descargar actualizaciones automáticamente" + "error_importing_theme": "Error al importar el tema", + "theme_imported": "Tema importado correctamente", + "enable_friend_request_notifications": "Cuando recibís una solicitud de amistad", + "enable_auto_install": "Descargar actualizaciones automáticamente", + "common_redist": "Common redistributables", + "common_redist_description": "Common redistributables son requeridos para algunos juegos. Es recomendable instalarlos para evitar algunos problemas.", + "install_common_redist": "Instalar", + "installing_common_redist": "Instalando…", + "show_download_speed_in_megabytes": "Mostrar velocidad de descarga en megabytes por segundo", + "extract_files_by_default": "Extraer archivos por defecto después de descargar", + "enable_steam_achievements": "Habilitar búsqueda de logros de Steam", + "achievement_custom_notification_position": "Posición de notificación de logros", + "top-left": "Superior Izquierda", + "top-center": "Superior Centro", + "top-right": "Superior Derecha", + "bottom-left": "Inferior Izquierda", + "bottom-center": "Inferior Centro", + "bottom-right": "Inferior Derecha", + "enable_achievement_custom_notifications": "Habilitar notificación personalizada de logros", + "alignment": "Centrado", + "variation": "Variación", + "default": "Defecto", + "rare": "Raro", + "platinum": "Platino", + "hidden": "Oculto", + "test_notification": "Probar notificación", + "notification_preview": "Probar notificación de logro", + "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego" }, "notifications": { "download_complete": "Descarga completada", + "game_ready_to_install": "{{title}} está listo para instalar", + "repack_list_updated": "Lista de repacks actualizadas", + "repack_count_one": "{{count}} repack añadido", + "repack_count_other": "{{count}} repacks añadidos", + "new_update_available": "Versión {{version}} disponible", + "restart_to_install_update": "Reiniciá Hydra para instalar la actualización", + "notification_achievement_unlocked_title": "Logro desbloqueado para {{game}}", + "notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados", + "new_friend_request_description": "{{displayName}} te envió una solicitud de amistad", + "new_friend_request_title": "Nueva solicitud de amistad", "extraction_complete": "Extracción completada", "game_extracted": "{{title}} extraído exitosamente", - "game_ready_to_install": "{{title}} está listo para instalarse", - "repack_list_updated": "Lista de repacks actualizadas", - "repack_count_one": "{{count}} repack ha sido añadido", - "repack_count_other": "{{count}} repacks añadidos", - "new_update_available": "Version {{version}} disponible", - "restart_to_install_update": "Reinicia Hydra para instalar la actualización", - "notification_achievement_unlocked_title": "Logro desbloqueado de {{game}}", - "notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados", - "new_friend_request_title": "Nueva solicitud de amistad", - "new_friend_request_description": "{{displayName}} te envió una solicitud de amistad", - "friend_started_playing_game": "{{displayName}} está jugando" + "friend_started_playing_game": "{{displayName}} empezó a jugar un juego", + "test_achievement_notification_title": "Esto es una notificación de prueba", + "test_achievement_notification_description": "Piola, ¿verdad?" }, "system_tray": { "open": "Abrir Hydra", @@ -387,15 +490,15 @@ "game_card": { "available_one": "Disponible", "available_other": "Disponibles", - "no_downloads": "No hay descargas disponibles" + "no_downloads": "Sin descargas disponibles" }, "binary_not_found_modal": { "title": "Programas no instalados", - "description": "Los ejecutables de Wine o Lutris no se encontraron en tu sistema", - "instructions": "Comprueba como instalar de forma correcta uno de los dos en tu distro de Linux para ejecutar el juego con normalidad" + "description": "Ejecutables de Wine o Lutris executables no encontrados en tu sistema", + "instructions": "Comprobá la forma correcta de instalar cualquiera de ellos en tu distribución de Linux para que el juego pueda ejecutarse con normalidad" }, "modal": { - "close": "Botón de cierre" + "close": "Botón de cerrar" }, "forms": { "toggle_password_visibility": "Cambiar visibilidad de contraseña" @@ -403,111 +506,120 @@ "user_profile": { "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", - "last_time_played": "Última vez jugado: {{period}}", + "last_time_played": "Jugado por última vez el {{period}}", "activity": "Actividad reciente", - "library": "Biblioteca", - "total_play_time": "Has jugado", - "no_recent_activity_title": "Que raro, no hay nada por acá...", - "no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!", - "display_name": "Nombre en pantalla", + "library": "Librería", + "pinned": "Fijado", + "total_play_time": "Total de tiempo de juego", + "achievements_earned": "Logros conseguidos", + "played_recently": "Jugado recientemente", + "playtime": "Tiempo de juego", + "manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente", + "no_recent_activity_title": "Hmmm… nada por acá", + "no_recent_activity_description": "No has jugado nada recientemente. ¡Te toca cambiar eso!", + "display_name": "Nombre a mostar", "saving": "Guardando", - "save": "Guardar", + "save": "Guardado", "edit_profile": "Editar perfil", "saved_successfully": "Guardado exitosamente", - "try_again": "Por favor, intenta de nuevo", - "sign_out_modal_title": "¿Estás seguro?", + "try_again": "Por favor, intentá de nuevo", + "sign_out_modal_title": "¿Querés continuar?", "cancel": "Cancelar", - "successfully_signed_out": "Sesión cerrada exitosamente", + "successfully_signed_out": "Cerraste sesión exitosamente", "sign_out": "Cerrar sesión", - "playing_for": "Llevas jugando {{amount}}", - "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?", - "add_friends": "Añadir amigos", + "playing_for": "Jugando por {{amount}}", + "sign_out_modal_text": "Tu librería está vinculada con esta cuenta. Cuando cerrés sesión, tu librería ya no será visible, y cualquier progreso no se guardará. ¿Querés continuar con el cierre de sesión?", + "add_friends": "Añadir amistades", "add": "Añadir", - "friend_code": "Código de amigo", + "friend_code": "Código de amistad", "see_profile": "Ver perfil", "sending": "Enviando", "friend_request_sent": "Solicitud de amistad enviada", - "friends": "Amigos", - "friends_list": "Lista de amigos", + "friends": "Amistades", + "friends_list": "Lista de amistades", "user_not_found": "Usuario no encontrado", "block_user": "Bloquear usuario", - "add_friend": "Añadir amigo", + "add_friend": "Añadir amistad", "request_sent": "Solicitud enviada", "request_received": "Solicitud recibida", "accept_request": "Aceptar solicitud", "ignore_request": "Ignorar solicitud", "cancel_request": "Cancelar solicitud", - "undo_friendship": "Eliminar amistad", + "undo_friendship": "Deshacer amistad", "request_accepted": "Solicitud aceptada", "user_blocked_successfully": "Usuario bloqueado exitosamente", "user_block_modal_text": "Esto va a bloquear a {{displayName}}", "blocked_users": "Usuarios bloqueados", "unblock": "Desbloquear", - "no_friends_added": "Todavía no tienes amigos añadidos", + "no_friends_added": "No tenés amistades añadidas", "pending": "Pendiente", - "no_pending_invites": "No tienes invitaciones pendientes", - "no_blocked_users": "No has bloqueado a ningún usuario", - "friend_code_copied": "Código de amigo copiado", - "undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}", - "privacy_hint": "Para ajustar quién puede ver esto, ve a <0>Configuración.", + "no_pending_invites": "No tenés invitaciones pendientes", + "no_blocked_users": "No has bloqueado a nadie", + "friend_code_copied": "Código de amistad copiado", + "undo_friendship_modal_text": "Esto va a deshacer tu amistad con {{displayName}}", + "privacy_hint": "Para cambiar quién puede ver esto, andá a <0>Ajustes", "locked_profile": "Este perfil es privado", - "image_process_failure": "Error al procesar la imagen", - "required_field": "Este campo es obligatorio", - "displayname_min_length": "El nombre a mostrar debe tener al menos 3 caracteres", - "displayname_max_length": "El nombre a mostrar debe tener como máximo 50 caracteres", + "image_process_failure": "Errpr al procesar la imagen", + "required_field": "Este campo es requerido", + "displayname_min_length": "El nombre en pantalla debe tener mínimo 3 caracteres", + "displayname_max_length": "El nombre en pantalla debe tener máximo 50 caracteres", "report_profile": "Reportar este perfil", - "report_reason": "¿Cual es el motivo del reporte?", + "report_reason": "¿Porque estás reportando este perfil?", "report_description": "Información adicional", "report_description_placeholder": "Información adicional", "report": "Reportar", "report_reason_hate": "Discursos de odio", "report_reason_sexual_content": "Contenido sexual", "report_reason_violence": "Violencia", - "report_reason_spam": "Spam / Contenido no deseado", - "report_reason_other": "Otro", + "report_reason_spam": "Spam", + "report_reason_other": "Otros", "profile_reported": "Perfil reportado", - "your_friend_code": "Tu código de amigo:", - "upload_banner": "Subir un banner", + "your_friend_code": "Tu código de amistad:", + "upload_banner": "Subir banner", "uploading_banner": "Subiendo banner…", "background_image_updated": "Imagen de fondo actualizada", - "playing": "Jugando {{game}}", - "achievements": "logros", - "achievements_unlocked": "Logros desbloqueados", - "earned_points": "Puntos Obtenidos", - "show_achievements_on_profile": "Mostrar tus logros en tu perfil", - "show_points_on_profile": "Mostrar tus puntos obtenidos en tu perfil", - "games": "Juegos", - "ranking_updated_weekly": "El Ranking se actualiza semanalmente", "stats": "Estadísticas", - "top_percentile": "Top {{percentile}}%" + "achievements": "logros", + "games": "Juegos", + "top_percentile": "Top {{percentile}}%", + "ranking_updated_weekly": "El ranking se actualiza semanalmente", + "playing": "Jugando {{game}}", + "achievements_unlocked": "Logros desbloqueados", + "earned_points": "Puntos obtenidos", + "show_achievements_on_profile": "Mostrá tus logros en tu perfil", + "show_points_on_profile": "Mostrá los puntos obtenidos en tu perfil", + "error_adding_friend": "No se pudo enviar la solicitud de amistad. Por favor revisá el código", + "friend_code_length_error": "El código de amistad debe tener mínimo 8 caracteres", + "game_removed_from_pinned": "Juego removido de fijados", + "game_added_to_pinned": "Juego añadido a fijados" }, "achievement": { "achievement_unlocked": "Logro desbloqueado", "user_achievements": "Logros de {{displayName}}", - "your_achievements": "Tus Logros", + "your_achievements": "Tus logros", "unlocked_at": "Desbloqueado el: {{date}}", - "subscription_needed": "Se necesita una suscripción a Hydra Cloud necesita para ver este contenido", - "new_achievements_unlocked": "Desbloqueados {{achievementCount}} nuevos logros de {{gameCount}} juegos", + "subscription_needed": "Se requiere una suscripción a Hydra Cloud para ver esto", + "new_achievements_unlocked": "Desbloqueaste {{achievementCount}} nuevos logros de {{gameCount}} juegos", "achievement_progress": "{{unlockedCount}}/{{totalCount}} logros", - "achievements_unlocked_for_game": "Se han desbloqueado {{achievementCount}} nuevos logros de {{gameTitle}}", + "achievements_unlocked_for_game": "Desbloqueaste {{achievementCount}} nuevos logros para {{gameTitle}}", "hidden_achievement_tooltip": "Este es un logro oculto", - "achievement_earn_points": "Obtén {{points}} puntos con este logro", + "achievement_earn_points": "Conseguí {{points}} puntos por este logro", "earned_points": "Puntos obtenidos:", "available_points": "Puntos disponibles:", - "how_to_earn_achievements_points": "¿Cómo obtener puntos de logros?" + "how_to_earn_achievements_points": "¿Como conseguir puntos por logros?" }, "hydra_cloud": { "subscription_tour_title": "Suscripción Hydra Cloud", - "debrid_description": "Descargas hasta x4 más rápidas con Nimbus", - "subscribe_now": "Suscribirse ahora", + "subscribe_now": "suscribíte ahora", "cloud_saving": "Guardado en la nube", - "cloud_achievements": "Guarda tus logros en la nube", - "animated_profile_picture": "Fotos de perfil animadas", + "cloud_achievements": "Guardá tus logros en la nube", + "animated_profile_picture": "Foto de perfil animada", "premium_support": "Soporte Premium", - "show_and_compare_achievements": "Muestra y compara tus logros con otros usuarios", - "animated_profile_banner": "Fondo de perfil animado", + "show_and_compare_achievements": "Mostrá y compará tus logros con otros usuarios", + "animated_profile_banner": "Banner de perfil animado", "hydra_cloud": "Hydra Cloud", - "hydra_cloud_feature_found": "¡Has descubierto una característica de Hydra Cloud!", - "learn_more": "Aprender más" + "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" } } diff --git a/src/locales/et/translation.json b/src/locales/et/translation.json index 119e1aab..c5566eeb 100644 --- a/src/locales/et/translation.json +++ b/src/locales/et/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Edukalt sisse logitud" }, "home": { - "featured": "Esile toodud", "surprise_me": "Üllata mind", "no_results": "Tulemusi ei leitud", "start_typing": "Alusta otsimiseks kirjutamist...", @@ -45,10 +44,7 @@ "calculating_eta": "{{title}} allalaadimine… ({{percentage}} valmis) - Järelejäänud aja arvutamine…", "checking_files": "{{title}} failide kontrollimine… ({{percentage}} valmis)" }, - "catalogue": { - "next_page": "Järgmine leht", - "previous_page": "Eelmine leht" - }, + "catalogue": {}, "game_details": { "open_download_options": "Ava allalaadimise valikud", "download_options_zero": "Allalaadimise valikuid pole", diff --git a/src/locales/fa/translation.json b/src/locales/fa/translation.json index be18263a..69a49b79 100644 --- a/src/locales/fa/translation.json +++ b/src/locales/fa/translation.json @@ -1,7 +1,6 @@ { "language_name": "فارسی", "home": { - "featured": "پیشنهادی", "surprise_me": "سوپرایزم کن", "no_results": "اتمام‌ای پیدا نشد" }, @@ -17,7 +16,6 @@ "home": "خانه", "favorites": "علاقه‌مندی‌ها" }, - "header": { "search": "جستجوی بازی‌ها", "home": "خانه", @@ -31,10 +29,7 @@ "downloading_metadata": "درحال دانلود متادیتاهای {{title}}…", "downloading": "در حال دانلود {{title}}… ({{percentage}} تکمیل شده) - اتمام {{eta}} - {{speed}}" }, - "catalogue": { - "next_page": "صفحه‌ی بعدی", - "previous_page": "صفحه‌ی قبلی" - }, + "catalogue": {}, "game_details": { "open_download_options": "بازکردن آپشن‌های دانلود", "download_options_zero": "هیچ آپشن دانلودی وجود ندارد", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 1c129a64..8fc07722 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Connecté avec succès" }, "home": { - "featured": "En vedette", "surprise_me": "Surprenez-moi", "no_results": "Aucun résultat trouvé", "start_typing": "Commencez à taper pour rechercher...", diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 0cea87b0..efed5e2d 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -1,7 +1,6 @@ { "language_name": "Magyar", "home": { - "featured": "Featured", "surprise_me": "Lepj meg", "no_results": "Nem található" }, @@ -19,7 +18,6 @@ }, "header": { "search": "Keresés", - "home": "Főoldal", "catalogue": "Katalógus", "downloads": "Letöltések", @@ -31,10 +29,7 @@ "downloading_metadata": "{{title}} metaadatainak letöltése…", "downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}" }, - "catalogue": { - "next_page": "Következő olda", - "previous_page": "Előző olda" - }, + "catalogue": {}, "game_details": { "open_download_options": "Letöltési lehetőségek", "download_options_zero": "Nincs letöltési lehetőség", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 4fa347fc..fc72fc51 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Berhasil masuk" }, "home": { - "featured": "Unggulan", "surprise_me": "Kejutkan saya", "no_results": "Tidak ada hasil ditemukan" }, @@ -25,7 +24,6 @@ }, "header": { "search": "Cari game", - "home": "Beranda", "catalogue": "Katalog", "downloads": "Unduhan", @@ -41,10 +39,7 @@ "calculating_eta": "Mengunduh {{title}}… ({{percentage}} selesai) - Menghitung waktu yang tersisa…", "checking_files": "Memeriksa file {{title}}… ({{percentage}} selesai)" }, - "catalogue": { - "next_page": "Halaman Berikutnya", - "previous_page": "Halaman Sebelumnya" - }, + "catalogue": {}, "game_details": { "open_download_options": "Buka opsi unduhan", "download_options_zero": "Tidak ada opsi unduhan", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index b23d1244..ac37ffe9 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1,7 +1,6 @@ { "language_name": "Italiano", "home": { - "featured": "In primo piano", "surprise_me": "Sorprendimi", "no_results": "Nessun risultato trovato" }, @@ -20,7 +19,6 @@ }, "header": { "search": "Cerca", - "home": "Home", "catalogue": "Catalogo", "downloads": "Download", @@ -32,10 +30,7 @@ "downloading_metadata": "Scaricamento metadati di {{title}}…", "downloading": "Download di {{title}}… ({{percentage}} completato) - Conclusione {{eta}} - {{speed}}" }, - "catalogue": { - "next_page": "Pagina successiva", - "previous_page": "Pagina precedente" - }, + "catalogue": {}, "game_details": { "open_download_options": "Apri opzioni di download", "download_options_zero": "Nessuna opzione di download", diff --git a/src/locales/kk/translation.json b/src/locales/kk/translation.json index bfb009a7..48fb8181 100644 --- a/src/locales/kk/translation.json +++ b/src/locales/kk/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Сәтті кіру" }, "home": { - "featured": "Ұсынылған", "surprise_me": "Таңқалдыр", "no_results": "Ештеңе табылмады" }, @@ -23,7 +22,6 @@ "sign_in": "Кіру", "favorites": "Таңдаулылар" }, - "header": { "search": "Іздеу", "home": "Басты бет", @@ -40,10 +38,7 @@ "downloading": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Аяқтау {{eta}} - {{speed}}", "calculating_eta": "Жүктеу {{title}}… ({{percentage}} аяқталды) - Қалған уақытты есептеу…" }, - "catalogue": { - "next_page": "Келесі бет", - "previous_page": "Алдыңғы бет" - }, + "catalogue": {}, "game_details": { "open_download_options": "Жүктеу нұсқаларын ашу", "download_options_zero": "Жүктеу нұсқалары жоқ", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 9ec389b1..a9b9c0e5 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -1,7 +1,6 @@ { "language_name": "한국어", "home": { - "featured": "추천", "surprise_me": "무작위 추천", "no_results": "결과 없음" }, @@ -17,7 +16,6 @@ "home": "홈", "favorites": "즐겨찾기" }, - "header": { "search": "게임 검색하기", "home": "홈", @@ -31,10 +29,7 @@ "downloading_metadata": "{{title}}의 메타데이터를 다운로드 중…", "downloading": "{{title}}의 파일들을 다운로드 중… ({{percentage}} 완료) - 완료까지 {{eta}} - {{speed}}" }, - "catalogue": { - "next_page": "다음 페이지", - "previous_page": "이전 페이지" - }, + "catalogue": {}, "game_details": { "open_download_options": "다운로드 선택지 열기", "download_options_zero": "다운로드 선택지 없음", diff --git a/src/locales/nb/translation.json b/src/locales/nb/translation.json index 8898ec7b..95bda8fe 100644 --- a/src/locales/nb/translation.json +++ b/src/locales/nb/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Logget inn vellykket" }, "home": { - "featured": "Anbefalinger", "surprise_me": "Overrask meg", "no_results": "Ingen resultater fundet", "start_typing": "Begynn å skrive for å søke...", @@ -29,7 +28,6 @@ }, "header": { "search": "Søk efter spill", - "home": "Hjem", "catalogue": "Katalog", "downloads": "Nedlastinger", @@ -45,10 +43,7 @@ "calculating_eta": "Laster ned {{title}}… ({{percentage}} ferdig) - Regner ut resterende tid…", "checking_files": "Sjekker {{title}} filer… ({{percentage}} ferdig)" }, - "catalogue": { - "next_page": "Neste side", - "previous_page": "Forrige side" - }, + "catalogue": {}, "game_details": { "open_download_options": "Åpne nedlastingsmuligheter", "download_options_zero": "Ingen nedlastingsmulighet", diff --git a/src/locales/nl/translation.json b/src/locales/nl/translation.json index 72d20c74..baa6df6e 100644 --- a/src/locales/nl/translation.json +++ b/src/locales/nl/translation.json @@ -1,7 +1,6 @@ { "language_name": "Nederlands", "home": { - "featured": "Uitgelicht", "surprise_me": "Verrasing", "no_results": "Geen resultaten gevonden" }, @@ -19,7 +18,6 @@ }, "header": { "search": "Zoek spellen", - "home": "Home", "catalogue": "Bibliotheek", "downloads": "Downloads", @@ -31,10 +29,7 @@ "downloading_metadata": "Downloading {{title}} metadata…", "downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}" }, - "catalogue": { - "next_page": "Volgende Pagina", - "previous_page": "Vorige Pagina" - }, + "catalogue": {}, "game_details": { "open_download_options": "Open download Instellingen", "download_options_zero": "Geen download Instellingen", diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index 86751b0e..2e0d1696 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -1,7 +1,6 @@ { "language_name": "Polski", "home": { - "featured": "Wyróżnione", "surprise_me": "Zaskocz mnie", "no_results": "Nie znaleziono wyników" }, @@ -20,7 +19,6 @@ }, "header": { "search": "Szukaj", - "home": "Główna", "catalogue": "Katalog", "downloads": "Pobrane", @@ -32,10 +30,7 @@ "downloading_metadata": "Pobieranie {{title}} metadata…", "downloading": "Pobieranie {{title}}… (ukończone w {{percentage}}) - Podsumowanie {{eta}} - {{speed}}" }, - "catalogue": { - "next_page": "Następna strona", - "previous_page": "Poprzednia strona" - }, + "catalogue": {}, "game_details": { "open_download_options": "Otwórz opcje pobierania", "download_options_zero": "Brak opcji pobierania", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 7f7f8cc1..b88c38a5 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -26,7 +26,22 @@ "sign_in": "Login", "friends": "Amigos", "need_help": "Precisa de ajuda?", - "favorites": "Favoritos" + "favorites": "Favoritos", + "add_custom_game_tooltip": "Adicionar jogo personalizado", + "custom_game_modal": "Adicionar jogo personalizado", + "edit_game_modal_title": "Título", + "playable_button_title": "", + "custom_game_modal_add": "Adicionar Jogo", + "custom_game_modal_adding": "Adicionando...", + "custom_game_modal_browse": "Buscar", + "custom_game_modal_cancel": "Cancelar", + "edit_game_modal_assets": "Imagens", + "edit_game_modal_icon": "Ícone", + "edit_game_modal_browse": "Buscar", + "edit_game_modal_cancel": "Cancelar", + "edit_game_modal_enter_title": "Insira o título", + "edit_game_modal_logo": "Logo", + "edit_game_modal": "Personalizar detalhes" }, "header": { "search": "Buscar jogos", @@ -219,7 +234,18 @@ "historical_keyshop": "Preço histórico em keyshops", "language": "Idioma", "caption": "Legenda", - "audio": "Áudio" + "audio": "Áudio", + "edit_game_modal_button": "Alterar detalhes do jogo", + "game_added_to_pinned": "Jogo adicionado aos fixados", + "game_removed_from_pinned": "Jogo removido dos fixados", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "manual_playtime_warning": "As suas horas de jogo serão marcadas como atualizadas manualmente. Esta ação não pode ser desfeita.", + "missing_wine_prefix": "Um prefixo Wine é necessário para criar um backup no Linux", + "update_game_playtime": "Modificar tempo de jogo", + "update_playtime": "Modificar tempo de jogo", + "update_playtime_description": "Atualizar manualmente o tempo de jogo de {{game}}", + "update_playtime_error": "Falha ao atualizar tempo de jogo", + "update_playtime_title": "Atualizar tempo de jogo" }, "activation": { "title": "Ativação", @@ -394,7 +420,8 @@ "hidden": "Oculta", "test_notification": "Testar notificação", "notification_preview": "Prévia da Notificação de Conquistas", - "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo" + "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", + "editor_tab_code": "Código" }, "notifications": { "download_complete": "Download concluído", @@ -523,7 +550,15 @@ "show_achievements_on_profile": "Exiba suas conquistas no perfil", "show_points_on_profile": "Exiba seus pontos ganhos no perfil", "error_adding_friend": "Não foi possível enviar o pedido de amizade. Verifique o código de amizade inserido", - "friend_code_length_error": "Código de amigo deve ter 8 caracteres" + "friend_code_length_error": "Código de amigo deve ter 8 caracteres", + "top_percentile": "Top {{percentile}}%", + "playtime": "Tempo de jogo", + "played_recently": "Jogado recentemente", + "pinned": "Fixado", + "amount_minutes_short": "{{amount}}h", + "amount_hours_short": "{{amount}}h", + "game_added_to_pinned": "Jogo adicionado aos fixados", + "achievements_earned": "Conquistas recebidas" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 6c32b35b..654e94ec 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Sessão iniciada com sucesso" }, "home": { - "featured": "Destaques", "hot": "Populares", "weekly": "📅 Mais descarregados esta semana", "achievements": "🏆 Para completar", @@ -26,7 +25,8 @@ "game_has_no_executable": "O jogo não tem um executável selecionado", "sign_in": "Iniciar sessão", "friends": "Amigos", - "favorites": "Favoritos" + "favorites": "Favoritos", + "edit_game_modal_cancel": "Cancelar" }, "header": { "search": "Procurar jogos", @@ -247,9 +247,6 @@ "download_count_zero": "Sem downloads na lista", "download_count_one": "{{countFormatted}} download na lista", "download_count_other": "{{countFormatted}} downloads na lista", - "download_options_zero": "Sem downloads disponíveis", - "download_options_one": "{{countFormatted}} download disponível", - "download_options_other": "{{countFormatted}} downloads disponíveis", "download_source_url": "URL da fonte", "add_download_source_description": "Insere o URL que contém o ficheiro .json", "download_source_up_to_date": "Sincronizada", @@ -359,8 +356,6 @@ "instructions": "Verifica a forma correta de instalar algum deles na tua distribuição Linux, para garantir a execução normal do jogo" }, "catalogue": { - "next_page": "Página seguinte", - "previous_page": "Página anterior", "search": "Filtrar…", "developers": "Desenvolvedores", "genres": "Géneros", @@ -427,7 +422,6 @@ "friend_code_copied": "Código de amigo copiado", "undo_friendship_modal_text": "Isto vai remover a tua amizade com {{displayName}}", "privacy_hint": "Para controlar quem pode ver o teu perfil, acede às <0>Definições", - "profile_locked": "Este perfil é privado", "image_process_failure": "Falha ao processar a imagem", "required_field": "Este campo é obrigatório", "displayname_min_length": "O nome de apresentação deve ter pelo menos 3 caracteres", diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index c5a81881..8ed6fd39 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -1,7 +1,6 @@ { "language_name": "Română", "home": { - "featured": "Recomandate", "surprise_me": "Surprinde-mă", "no_results": "Niciun rezultat găsit" }, @@ -19,7 +18,6 @@ }, "header": { "search": "Caută jocuri", - "home": "Acasă", "catalogue": "Catalog", "downloads": "Descărcări", @@ -32,10 +30,7 @@ "downloading": "Se descarcă {{title}}... ({{percentage}} complet) - Concluzie {{eta}} - {{speed}}", "calculating_eta": "Se descarcă {{title}}... ({{percentage}} complet) - Calculare timp rămas..." }, - "catalogue": { - "next_page": "Pagina următoare", - "previous_page": "Pagina anterioară" - }, + "catalogue": {}, "game_details": { "open_download_options": "Deschide opțiunile de descărcare", "download_options_zero": "Nicio opțiune de descărcare", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index b1714804..03413554 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -4,14 +4,12 @@ "successfully_signed_in": "Успешный вход" }, "home": { - "featured": "Рекомендации", "surprise_me": "Удиви меня", "no_results": "Ничего не найдено", "hot": "Сейчас популярно", "start_typing": "Начинаю вводить текст...", "weekly": "📅 Лучшие игры недели", - "achievements": "🏆 Игры с достижениями", - "already_in_library": "Уже в библиотеке" + "achievements": "🏆 Игры с достижениями" }, "sidebar": { "catalogue": "Каталог", @@ -29,7 +27,47 @@ "friends": "Друзья", "need_help": "Нужна помощь?", "favorites": "Избранное", - "playable_button_title": "Показать только игры, в которые можно играть сейчас" + "playable_button_title": "Показать только установленные игры.", + "custom_game_modal": "Добавить пользовательскую игру", + "custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл", + "custom_game_modal_executable_path": "Путь к исполняемому файлу", + "custom_game_modal_select_executable": "Выберите исполняемый файл", + "custom_game_modal_title": "Название игры", + "custom_game_modal_enter_title": "Введите название игры", + "custom_game_modal_browse": "Обзор", + "custom_game_modal_cancel": "Отмена", + "custom_game_modal_add": "Добавить игру", + "custom_game_modal_adding": "Добавление игры...", + "custom_game_modal_success": "Пользовательская игра успешно добавлена", + "custom_game_modal_failed": "Не удалось добавить пользовательскую игру", + "custom_game_modal_executable": "Исполняемый файл", + "edit_game_modal": "Настроить ресурсы", + "edit_game_modal_description": "Настройте ресурсы и детали игры", + "edit_game_modal_title": "Название", + "edit_game_modal_enter_title": "Введите название", + "edit_game_modal_image": "Изображение", + "edit_game_modal_select_image": "Выберите изображение", + "edit_game_modal_browse": "Обзор", + "edit_game_modal_image_preview": "Предпросмотр изображения", + "edit_game_modal_icon": "Иконка", + "edit_game_modal_select_icon": "Выберите иконку", + "edit_game_modal_icon_preview": "Предпросмотр иконки", + "edit_game_modal_logo": "Логотип", + "edit_game_modal_select_logo": "Выберите логотип", + "edit_game_modal_logo_preview": "Предпросмотр логотипа", + "edit_game_modal_hero": "Изображение обложку игры", + "edit_game_modal_select_hero": "Выберите обложку игры", + "edit_game_modal_hero_preview": "Предпросмотр обложки игры", + "edit_game_modal_cancel": "Отмена", + "edit_game_modal_update": "Обновить", + "edit_game_modal_updating": "Обновление...", + "edit_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля", + "edit_game_modal_success": "Ресурсы успешно обновлены", + "edit_game_modal_failed": "Не удалось обновить ресурсы", + "edit_game_modal_image_filter": "Изображение", + "edit_game_modal_icon_resolution": "Рекомендуемое разрешение: 256x256px", + "edit_game_modal_logo_resolution": "Рекомендуемое разрешение: 640x360px", + "edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px" }, "header": { "search": "Поиск", @@ -446,6 +484,8 @@ "user_profile": { "amount_hours": "{{amount}} часов", "amount_minutes": "{{amount}} минут", + "amount_hours_short": "{{amount}}ч", + "amount_minutes_short": "{{amount}}м", "last_time_played": "Последняя игра {{period}}", "activity": "Недавняя активность", "library": "Библиотека", diff --git a/src/locales/sv/translation.json b/src/locales/sv/translation.json index 0972effa..901e4ca7 100644 --- a/src/locales/sv/translation.json +++ b/src/locales/sv/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Inloggningen lyckades" }, "home": { - "featured": "Utvalt", "surprise_me": "Överraska mig", "no_results": "Inga resultat hittades", "start_typing": "Börja skriva för att söka...", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index c3fe2081..e8e1cb2b 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Başarıyla giriş yapıldı" }, "home": { - "featured": "Öne Çıkanlar", "surprise_me": "Beni Şaşırt", "no_results": "Sonuç bulunamadı", "start_typing": "Aramak için yazmaya başlayın...", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 48a3972d..26aa8aae 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Успішний вхід в систему" }, "home": { - "featured": "Рекомендоване", "surprise_me": "Здивуй мене", "no_results": "Результатів не знайдено", "start_typing": "Почніть набирати текст для пошуку...", diff --git a/src/locales/uz/translation.json b/src/locales/uz/translation.json index d20a9677..24e508af 100644 --- a/src/locales/uz/translation.json +++ b/src/locales/uz/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Kirish muvaffaqiyatli amalga oshirildi" }, "home": { - "featured": "Tavsiya etilgan", "surprise_me": "Hayratda qoldir", "no_results": "Natijalar topilmadi", "hot": "Eng mashhur", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 0323d991..7cdd0c92 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "已成功登录" }, "home": { - "featured": "特色推荐", "surprise_me": "向我推荐", "no_results": "没有找到结果", "start_typing": "键入以开始搜素...", @@ -51,8 +50,6 @@ "installing_common_redist": "{{log}}…" }, "catalogue": { - "next_page": "下一页", - "previous_page": "上一页", "clear_filters": "清除已选的 {{filterCount}} 项", "developers": "开发商", "download_sources": "下载源", diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index f0ec4343..bf5f8b81 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,7 +10,16 @@ const saveGameShopAssets = async ( ): Promise => { const key = levelKeys.game(shop, objectId); const existingAssets = await gamesShopAssetsSublevel.get(key); - return gamesShopAssetsSublevel.put(key, { ...existingAssets, ...assets }); + + // Preserve existing title if it differs from the incoming title (indicating it was customized) + const shouldPreserveTitle = + existingAssets?.title && existingAssets.title !== assets.title; + + return gamesShopAssetsSublevel.put(key, { + ...existingAssets, + ...assets, + title: shouldPreserveTitle ? existingAssets.title : assets.title, + }); }; registerEvent("saveGameShopAssets", saveGameShopAssets); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 6bd74b69..d4c461f8 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -14,6 +14,9 @@ import "./catalogue/get-developers"; 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"; @@ -37,7 +40,9 @@ 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"; @@ -46,6 +51,8 @@ import "./misc/show-item-in-folder"; import "./misc/get-badges"; import "./misc/install-common-redist"; import "./misc/can-install-common-redist"; +import "./misc/save-temp-file"; +import "./misc/delete-temp-file"; import "./torrenting/cancel-game-download"; import "./torrenting/pause-game-download"; import "./torrenting/resume-game-download"; diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts new file mode 100644 index 00000000..47fd3436 --- /dev/null +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -0,0 +1,65 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; +import { randomUUID } from "crypto"; +import type { GameShop } from "@types"; + +const addCustomGameToLibrary = async ( + _event: Electron.IpcMainInvokeEvent, + title: string, + executablePath: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string +) => { + const objectId = randomUUID(); + const shop: GameShop = "custom"; + const gameKey = levelKeys.game(shop, objectId); + + const existingGames = await gamesSublevel.iterator().all(); + const existingGame = existingGames.find( + ([_key, game]) => game.executablePath === executablePath && !game.isDeleted + ); + + if (existingGame) { + throw new Error( + "A game with this executable path already exists in your library" + ); + } + + const assets = { + objectId, + shop, + title, + iconUrl: iconUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || "", + libraryImageUrl: iconUrl || "", + logoImageUrl: logoImageUrl || "", + logoPosition: null, + coverImageUrl: iconUrl || "", + }; + await gamesShopAssetsSublevel.put(gameKey, assets); + + const game = { + title, + iconUrl: iconUrl || null, + logoImageUrl: logoImageUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || null, + objectId, + shop, + remoteId: null, + isDeleted: false, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + executablePath, + launchOptions: null, + favorite: false, + automaticCloudSync: false, + hasManuallyUpdatedPlaytime: false, + }; + + await gamesSublevel.put(gameKey, game); + + return game; +}; + +registerEvent("addCustomGameToLibrary", addCustomGameToLibrary); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 01495a39..4fdeae30 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -30,6 +30,8 @@ const addGameToLibrary = async ( game = { title, iconUrl: gameAssets?.iconUrl ?? null, + libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null, + logoImageUrl: gameAssets?.logoImageUrl ?? null, objectId, shop, remoteId: null, @@ -41,12 +43,14 @@ const addGameToLibrary = async ( await gamesSublevel.put(gameKey, game); } - await createGame(game).catch(() => {}); + if (game) { + await createGame(game).catch(() => {}); - AchievementWatcherManager.firstSyncWithRemoteIfNeeded( - game.shop, - game.objectId - ); + AchievementWatcherManager.firstSyncWithRemoteIfNeeded( + game.shop, + game.objectId + ); + } }; registerEvent("addGameToLibrary", addGameToLibrary); diff --git a/src/main/events/library/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts index ff37c33e..8ad252bd 100644 --- a/src/main/events/library/change-game-playtime.ts +++ b/src/main/events/library/change-game-playtime.ts @@ -13,16 +13,20 @@ const changeGamePlaytime = async ( const gameKey = levelKeys.game(shop, objectId); const game = await gamesSublevel.get(gameKey); if (!game) return; - await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, { - playTimeInSeconds, - }); + + if (game.remoteId) { + await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, { + playTimeInSeconds, + }); + } + await gamesSublevel.put(gameKey, { ...game, playTimeInMilliseconds: playTimeInSeconds * 1000, hasManuallyUpdatedPlaytime: true, }); } catch (error) { - throw new Error(`Failed to update game favorite status: ${error}`); + throw new Error(`Failed to update game playtime: ${error}`); } }; diff --git a/src/main/events/library/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts new file mode 100644 index 00000000..22490c07 --- /dev/null +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -0,0 +1,76 @@ +import { ipcMain } from "electron"; +import fs from "fs"; +import path from "path"; +import { ASSETS_PATH } from "@main/constants"; + +const getCustomGamesAssetsPath = () => { + return path.join(ASSETS_PATH, "custom-games"); +}; + +const getAllCustomGameAssets = async (): Promise => { + const assetsPath = getCustomGamesAssetsPath(); + + if (!fs.existsSync(assetsPath)) { + return []; + } + + const files = await fs.promises.readdir(assetsPath); + return files.map((file) => path.join(assetsPath, file)); +}; + +const getUsedAssetPaths = async (): Promise> => { + // Get all custom games from the level database + const { gamesSublevel } = await import("@main/level"); + const allGames = await gamesSublevel.iterator().all(); + + const customGames = allGames + .map(([_key, game]) => game) + .filter((game) => game.shop === "custom" && !game.isDeleted); + + const usedPaths = new Set(); + + customGames.forEach((game) => { + // Extract file paths from local URLs + if (game.iconUrl?.startsWith("local:")) { + usedPaths.add(game.iconUrl.replace("local:", "")); + } + if (game.logoImageUrl?.startsWith("local:")) { + usedPaths.add(game.logoImageUrl.replace("local:", "")); + } + if (game.libraryHeroImageUrl?.startsWith("local:")) { + usedPaths.add(game.libraryHeroImageUrl.replace("local:", "")); + } + }); + + return usedPaths; +}; + +export const cleanupUnusedAssets = async (): Promise<{ + deletedCount: number; + errors: string[]; +}> => { + try { + const allAssets = await getAllCustomGameAssets(); + const usedAssets = await getUsedAssetPaths(); + + const errors: string[] = []; + let deletedCount = 0; + + for (const assetPath of allAssets) { + if (!usedAssets.has(assetPath)) { + try { + await fs.promises.unlink(assetPath); + deletedCount++; + } catch (error) { + errors.push(`Failed to delete ${assetPath}: ${error}`); + } + } + } + + return { deletedCount, errors }; + } catch (error) { + throw new Error(`Failed to cleanup unused assets: ${error}`); + } +}; + +ipcMain.handle("cleanupUnusedAssets", cleanupUnusedAssets); diff --git a/src/main/events/library/copy-custom-game-asset.ts b/src/main/events/library/copy-custom-game-asset.ts new file mode 100644 index 00000000..07c3d6f7 --- /dev/null +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -0,0 +1,42 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "crypto"; +import { ASSETS_PATH } from "@main/constants"; + +const copyCustomGameAsset = async ( + _event: Electron.IpcMainInvokeEvent, + sourcePath: string, + assetType: "icon" | "logo" | "hero" +): Promise => { + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error("Source file does not exist"); + } + + // Ensure assets directory exists + if (!fs.existsSync(ASSETS_PATH)) { + fs.mkdirSync(ASSETS_PATH, { recursive: true }); + } + + // Create custom games assets subdirectory + const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games"); + if (!fs.existsSync(customGamesAssetsPath)) { + fs.mkdirSync(customGamesAssetsPath, { recursive: true }); + } + + // Get file extension + const fileExtension = path.extname(sourcePath); + + // Generate unique filename + const uniqueId = randomUUID(); + const fileName = `${assetType}-${uniqueId}${fileExtension}`; + const destinationPath = path.join(customGamesAssetsPath, fileName); + + // Copy the file + await fs.promises.copyFile(sourcePath, destinationPath); + + // Return the local URL format + return `local:${destinationPath}`; +}; + +registerEvent("copyCustomGameAsset", copyCustomGameAsset); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ce859908..6314f83d 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -23,7 +23,10 @@ const getLibrary = async (): Promise => { ...game, download: download ?? null, ...gameAssets, - }; + // Ensure compatibility with LibraryGame type + libraryHeroImageUrl: + game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + } as LibraryGame; }) ); }); diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index 6a33ffaf..fbb60ab2 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -1,7 +1,70 @@ import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import { gamesSublevel, levelKeys } from "@main/level"; -import type { GameShop } from "@types"; +import { HydraApi, logger } from "@main/services"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; +import type { GameShop, Game } from "@types"; +import fs from "node:fs"; + +const collectAssetPathsToDelete = (game: Game): string[] => { + const assetPathsToDelete: string[] = []; + + const assetUrls = + game.shop === "custom" + ? [game.iconUrl, game.logoImageUrl, game.libraryHeroImageUrl] + : [game.customIconUrl, game.customLogoImageUrl, game.customHeroImageUrl]; + + for (const url of assetUrls) { + if (url?.startsWith("local:")) { + assetPathsToDelete.push(url.replace("local:", "")); + } + } + + return assetPathsToDelete; +}; + +const updateGameAsDeleted = async ( + game: Game, + gameKey: string +): Promise => { + const updatedGame = { + ...game, + isDeleted: true, + executablePath: null, + ...(game.shop !== "custom" && { + customIconUrl: null, + customLogoImageUrl: null, + customHeroImageUrl: null, + }), + }; + + await gamesSublevel.put(gameKey, updatedGame); +}; + +const resetShopAssets = async (gameKey: string): Promise => { + const existingAssets = await gamesShopAssetsSublevel.get(gameKey); + if (existingAssets) { + const resetAssets = { + ...existingAssets, + title: existingAssets.title, + }; + await gamesShopAssetsSublevel.put(gameKey, resetAssets); + } +}; + +const deleteAssetFiles = async ( + assetPathsToDelete: string[] +): Promise => { + if (assetPathsToDelete.length === 0) return; + + for (const assetPath of assetPathsToDelete) { + try { + if (fs.existsSync(assetPath)) { + await fs.promises.unlink(assetPath); + } + } catch (error) { + logger.warn(`Failed to delete asset ${assetPath}:`, error); + } + } +}; const removeGameFromLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -11,17 +74,21 @@ const removeGameFromLibrary = async ( const gameKey = levelKeys.game(shop, objectId); const game = await gamesSublevel.get(gameKey); - if (game) { - await gamesSublevel.put(gameKey, { - ...game, - isDeleted: true, - executablePath: null, - }); + if (!game) return; - if (game?.remoteId) { - HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); - } + const assetPathsToDelete = collectAssetPathsToDelete(game); + + await updateGameAsDeleted(game, gameKey); + + if (game.shop !== "custom") { + await resetShopAssets(gameKey); } + + if (game?.remoteId) { + HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); + } + + await deleteAssetFiles(assetPathsToDelete); }; registerEvent("removeGameFromLibrary", removeGameFromLibrary); diff --git a/src/main/events/library/update-custom-game.ts b/src/main/events/library/update-custom-game.ts new file mode 100644 index 00000000..8129fc57 --- /dev/null +++ b/src/main/events/library/update-custom-game.ts @@ -0,0 +1,98 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; +import fs from "node:fs"; +import { logger } from "@main/services"; + +interface UpdateCustomGameParams { + shop: GameShop; + objectId: string; + title: string; + iconUrl?: string; + logoImageUrl?: string; + libraryHeroImageUrl?: string; + originalIconPath?: string; + originalLogoPath?: string; + originalHeroPath?: string; +} + +const updateCustomGame = async ( + _event: Electron.IpcMainInvokeEvent, + params: UpdateCustomGameParams +) => { + const { + shop, + objectId, + title, + iconUrl, + logoImageUrl, + libraryHeroImageUrl, + originalIconPath, + originalLogoPath, + originalHeroPath, + } = params; + const gameKey = levelKeys.game(shop, objectId); + + const existingGame = await gamesSublevel.get(gameKey); + if (!existingGame) { + throw new Error("Game not found"); + } + + const oldAssetPaths: string[] = []; + + const assetPairs = [ + { existing: existingGame.iconUrl, new: iconUrl }, + { existing: existingGame.logoImageUrl, new: logoImageUrl }, + { existing: existingGame.libraryHeroImageUrl, new: libraryHeroImageUrl }, + ]; + + for (const { existing, new: newUrl } of assetPairs) { + if (existing?.startsWith("local:") && (!newUrl || existing !== newUrl)) { + oldAssetPaths.push(existing.replace("local:", "")); + } + } + + const updatedGame = { + ...existingGame, + title, + iconUrl: iconUrl || null, + logoImageUrl: logoImageUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || null, + originalIconPath: originalIconPath || existingGame.originalIconPath || null, + originalLogoPath: originalLogoPath || existingGame.originalLogoPath || null, + originalHeroPath: originalHeroPath || existingGame.originalHeroPath || null, + }; + + await gamesSublevel.put(gameKey, updatedGame); + + const existingAssets = await gamesShopAssetsSublevel.get(gameKey); + if (existingAssets) { + const updatedAssets = { + ...existingAssets, + title, + iconUrl: iconUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || "", + libraryImageUrl: iconUrl || "", + logoImageUrl: logoImageUrl || "", + coverImageUrl: iconUrl || "", + }; + + await gamesShopAssetsSublevel.put(gameKey, updatedAssets); + } + + if (oldAssetPaths.length > 0) { + for (const assetPath of oldAssetPaths) { + try { + if (fs.existsSync(assetPath)) { + await fs.promises.unlink(assetPath); + } + } catch (error) { + logger.warn(`Failed to delete old asset ${assetPath}:`, error); + } + } + } + + return updatedGame; +}; + +registerEvent("updateCustomGame", updateCustomGame); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts new file mode 100644 index 00000000..1f912901 --- /dev/null +++ b/src/main/events/library/update-game-custom-assets.ts @@ -0,0 +1,162 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; +import type { GameShop, Game } from "@types"; +import fs from "node:fs"; +import { logger } from "@main/services"; + +const collectOldAssetPaths = ( + existingGame: Game, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null +): string[] => { + const oldAssetPaths: string[] = []; + + const assetPairs = [ + { existing: existingGame.customIconUrl, new: customIconUrl }, + { existing: existingGame.customLogoImageUrl, new: customLogoImageUrl }, + { existing: existingGame.customHeroImageUrl, new: customHeroImageUrl }, + ]; + + for (const { existing, new: newUrl } of assetPairs) { + if ( + existing && + newUrl !== undefined && + existing !== newUrl && + existing.startsWith("local:") + ) { + oldAssetPaths.push(existing.replace("local:", "")); + } + } + + return oldAssetPaths; +}; + +interface UpdateGameDataParams { + gameKey: string; + existingGame: Game; + title: string; + customIconUrl?: string | null; + customLogoImageUrl?: string | null; + customHeroImageUrl?: string | null; + customOriginalIconPath?: string | null; + customOriginalLogoPath?: string | null; + customOriginalHeroPath?: string | null; +} + +const updateGameData = async (params: UpdateGameDataParams): Promise => { + const { + gameKey, + existingGame, + title, + customIconUrl, + customLogoImageUrl, + customHeroImageUrl, + customOriginalIconPath, + customOriginalLogoPath, + customOriginalHeroPath, + } = params; + const updatedGame = { + ...existingGame, + title, + ...(customIconUrl !== undefined && { customIconUrl }), + ...(customLogoImageUrl !== undefined && { customLogoImageUrl }), + ...(customHeroImageUrl !== undefined && { customHeroImageUrl }), + ...(customOriginalIconPath !== undefined && { customOriginalIconPath }), + ...(customOriginalLogoPath !== undefined && { customOriginalLogoPath }), + ...(customOriginalHeroPath !== undefined && { customOriginalHeroPath }), + }; + + await gamesSublevel.put(gameKey, updatedGame); + return updatedGame; +}; + +const updateShopAssets = async ( + gameKey: string, + title: string +): Promise => { + const existingAssets = await gamesShopAssetsSublevel.get(gameKey); + if (existingAssets) { + const updatedAssets = { + ...existingAssets, + title, + }; + await gamesShopAssetsSublevel.put(gameKey, updatedAssets); + } +}; + +const deleteOldAssetFiles = async (oldAssetPaths: string[]): Promise => { + if (oldAssetPaths.length === 0) return; + + for (const assetPath of oldAssetPaths) { + try { + if (fs.existsSync(assetPath)) { + await fs.promises.unlink(assetPath); + } + } catch (error) { + logger.warn(`Failed to delete old custom asset ${assetPath}:`, error); + } + } +}; + +interface UpdateGameCustomAssetsParams { + shop: GameShop; + objectId: string; + title: string; + customIconUrl?: string | null; + customLogoImageUrl?: string | null; + customHeroImageUrl?: string | null; + customOriginalIconPath?: string | null; + customOriginalLogoPath?: string | null; + customOriginalHeroPath?: string | null; +} + +const updateGameCustomAssets = async ( + _event: Electron.IpcMainInvokeEvent, + params: UpdateGameCustomAssetsParams +) => { + const { + shop, + objectId, + title, + customIconUrl, + customLogoImageUrl, + customHeroImageUrl, + customOriginalIconPath, + customOriginalLogoPath, + customOriginalHeroPath, + } = params; + const gameKey = levelKeys.game(shop, objectId); + + const existingGame = await gamesSublevel.get(gameKey); + if (!existingGame) { + throw new Error("Game not found"); + } + + const oldAssetPaths = collectOldAssetPaths( + existingGame, + customIconUrl, + customLogoImageUrl, + customHeroImageUrl + ); + + const updatedGame = await updateGameData({ + gameKey, + existingGame, + title, + customIconUrl, + customLogoImageUrl, + customHeroImageUrl, + customOriginalIconPath, + customOriginalLogoPath, + customOriginalHeroPath, + }); + + await updateShopAssets(gameKey, title); + + await deleteOldAssetFiles(oldAssetPaths); + + return updatedGame; +}; + +registerEvent("updateGameCustomAssets", updateGameCustomAssets); diff --git a/src/main/events/misc/delete-temp-file.ts b/src/main/events/misc/delete-temp-file.ts new file mode 100644 index 00000000..7ca88fa1 --- /dev/null +++ b/src/main/events/misc/delete-temp-file.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import { registerEvent } from "../register-event"; + +const deleteTempFile = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (error) { + // Silently fail - temp files will be cleaned up by OS eventually + console.warn(`Failed to delete temp file: ${error}`); + } +}; + +registerEvent("deleteTempFile", deleteTempFile); diff --git a/src/main/events/misc/save-temp-file.ts b/src/main/events/misc/save-temp-file.ts new file mode 100644 index 00000000..8f253bf2 --- /dev/null +++ b/src/main/events/misc/save-temp-file.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import { app } from "electron"; +import { registerEvent } from "../register-event"; + +const saveTempFile = async ( + _event: Electron.IpcMainInvokeEvent, + fileName: string, + fileData: Uint8Array +): Promise => { + try { + const tempDir = app.getPath("temp"); + const tempFilePath = path.join( + tempDir, + `hydra-temp-${Date.now()}-${fileName}` + ); + + // Write the file data to temp directory + fs.writeFileSync(tempFilePath, fileData); + + return tempFilePath; + } catch (error) { + throw new Error(`Failed to save temp file: ${error}`); + } +}; + +registerEvent("saveTempFile", saveTempFile); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 00281973..8216b519 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -53,6 +53,8 @@ const startGameDownload = async ( await gamesSublevel.put(gameKey, { title, iconUrl: gameAssets?.iconUrl ?? null, + libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null, + logoImageUrl: gameAssets?.logoImageUrl ?? null, objectId, shop, remoteId: null, diff --git a/src/main/index.ts b/src/main/index.ts index af197a6b..106feaf0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -64,6 +64,71 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); + protocol.handle("gradient", (request) => { + const gradientCss = decodeURIComponent( + request.url.slice("gradient:".length) + ); + + // Parse gradient CSS safely without regex to prevent ReDoS + let direction = "45deg"; + let color1 = "#4a90e2"; + let color2 = "#7b68ee"; + + // Simple string parsing approach - more secure than regex + if ( + gradientCss.startsWith("linear-gradient(") && + gradientCss.endsWith(")") + ) { + const content = gradientCss.slice(16, -1); // Remove "linear-gradient(" and ")" + const parts = content.split(",").map((part) => part.trim()); + + if (parts.length >= 3) { + direction = parts[0]; + color1 = parts[1]; + color2 = parts[2]; + } + } + + let x1 = "0%", + y1 = "0%", + x2 = "100%", + y2 = "100%"; + + if (direction === "to right") { + y2 = "0%"; + } else if (direction === "to bottom") { + x2 = "0%"; + } else if (direction === "45deg") { + y1 = "100%"; + y2 = "0%"; + } else if (direction === "225deg") { + x1 = "100%"; + x2 = "0%"; + } else if (direction === "315deg") { + x1 = "100%"; + y1 = "100%"; + x2 = "0%"; + y2 = "0%"; + } + // Note: "135deg" case removed as it uses all default values + + const svgContent = ` + + + + + + + + + + `; + + return new Response(svgContent, { + headers: { "Content-Type": "image/svg+xml" }, + }); + }); + await loadState(); const language = await db diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 152e1138..b5b2d551 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -46,6 +46,8 @@ export const mergeWithRemoteGames = async () => { remoteId: game.id, shop: game.shop, iconUrl: game.iconUrl, + libraryHeroImageUrl: game.libraryHeroImageUrl, + logoImageUrl: game.logoImageUrl, lastTimePlayed: game.lastTimePlayed, playTimeInMilliseconds: game.playTimeInMilliseconds, hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime, @@ -58,7 +60,7 @@ export const mergeWithRemoteGames = async () => { await gamesShopAssetsSublevel.put(gameKey, { shop: game.shop, objectId: game.objectId, - title: game.title, + title: localGame?.title || game.title, // Preserve local title if it exists coverImageUrl: game.coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index d4febfea..f0af90ba 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -11,7 +11,8 @@ export const uploadGamesBatch = async () => { .all() .then((results) => { return results.filter( - (game) => !game.isDeleted && game.remoteId === null + (game) => + !game.isDeleted && game.remoteId === null && game.shop !== "custom" ); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index ca275c91..17c1225f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -128,6 +128,52 @@ contextBridge.exposeInMainWorld("electron", { ), addGameToLibrary: (shop: GameShop, objectId: string, title: string) => ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), + addCustomGameToLibrary: ( + title: string, + executablePath: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => + ipcRenderer.invoke( + "addCustomGameToLibrary", + title, + executablePath, + iconUrl, + logoImageUrl, + libraryHeroImageUrl + ), + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => ipcRenderer.invoke("copyCustomGameAsset", sourcePath, assetType), + saveTempFile: (fileName: string, fileData: Uint8Array) => + ipcRenderer.invoke("saveTempFile", fileName, fileData), + deleteTempFile: (filePath: string) => + ipcRenderer.invoke("deleteTempFile", filePath), + cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"), + updateCustomGame: (params: { + shop: GameShop; + objectId: string; + title: string; + iconUrl?: string; + logoImageUrl?: string; + libraryHeroImageUrl?: string; + originalIconPath?: string; + originalLogoPath?: string; + originalHeroPath?: string; + }) => ipcRenderer.invoke("updateCustomGame", params), + updateGameCustomAssets: (params: { + shop: GameShop; + objectId: string; + title: string; + customIconUrl?: string | null; + customLogoImageUrl?: string | null; + customHeroImageUrl?: string | null; + customOriginalIconPath?: string | null; + customOriginalLogoPath?: string | null; + customOriginalHeroPath?: string | null; + }) => ipcRenderer.invoke("updateGameCustomAssets", params), createGameShortcut: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/assets/play-logo.svg b/src/renderer/src/assets/play-logo.svg new file mode 100644 index 00000000..51ecaa28 --- /dev/null +++ b/src/renderer/src/assets/play-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss new file mode 100644 index 00000000..942384fe --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss @@ -0,0 +1,52 @@ +@use "../../scss/globals.scss"; + +.sidebar-adding-custom-game-modal { + &__container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 3); + width: 100%; + max-width: 500px; + margin: 0 auto; + text-align: center; + } + + &__form { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + text-align: left; + } + + &__image-section { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + text-align: left; + } + + &__image-preview { + display: flex; + justify-content: center; + align-items: center; + padding: globals.$spacing-unit; + border: 1px solid globals.$border-color; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.05); + } + + &__preview-image { + max-width: 120px; + max-height: 80px; + width: auto; + height: auto; + border-radius: 4px; + object-fit: contain; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: calc(globals.$spacing-unit * 2); + } +} diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx new file mode 100644 index 00000000..f50bd814 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { FileDirectoryIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useLibrary, useToast } from "@renderer/hooks"; +import { + buildGameDetailsPath, + generateRandomGradient, +} from "@renderer/helpers"; + +import "./sidebar-adding-custom-game-modal.scss"; + +export interface SidebarAddingCustomGameModalProps { + visible: boolean; + onClose: () => void; +} + +export function SidebarAddingCustomGameModal({ + visible, + onClose, +}: Readonly) { + const { t } = useTranslation("sidebar"); + const { updateLibrary } = useLibrary(); + const { showSuccessToast, showErrorToast } = useToast(); + const navigate = useNavigate(); + + const [gameName, setGameName] = useState(""); + const [executablePath, setExecutablePath] = useState(""); + const [isAdding, setIsAdding] = useState(false); + + const handleSelectExecutable = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("custom_game_modal_executable"), + extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + const selectedPath = filePaths[0]; + setExecutablePath(selectedPath); + + if (!gameName.trim()) { + const fileName = selectedPath.split(/[\\/]/).pop() || ""; + const gameNameFromFile = fileName.replace(/\.[^/.]+$/, ""); + setGameName(gameNameFromFile); + } + } + }; + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + const handleAddGame = async () => { + if (!gameName.trim() || !executablePath.trim()) { + showErrorToast(t("custom_game_modal_fill_required")); + return; + } + + setIsAdding(true); + + try { + // Generate gradient URL only for hero image + const gameNameForSeed = gameName.trim(); + const iconUrl = ""; // Don't use gradient for icon + const logoImageUrl = ""; // Don't use gradient for logo + const libraryHeroImageUrl = generateRandomGradient(); // Only use gradient for hero + + const newGame = await window.electron.addCustomGameToLibrary( + gameNameForSeed, + executablePath, + iconUrl, + logoImageUrl, + libraryHeroImageUrl + ); + + showSuccessToast(t("custom_game_modal_success")); + updateLibrary(); + + const gameDetailsPath = buildGameDetailsPath({ + shop: "custom", + objectId: newGame.objectId, + title: newGame.title, + }); + + navigate(gameDetailsPath); + + setGameName(""); + setExecutablePath(""); + onClose(); + } catch (error) { + console.error("Failed to add custom game:", error); + showErrorToast( + error instanceof Error ? error.message : t("custom_game_modal_failed") + ); + } finally { + setIsAdding(false); + } + }; + + const handleClose = () => { + if (!isAdding) { + setGameName(""); + setExecutablePath(""); + onClose(); + } + }; + + const isFormValid = gameName.trim() && executablePath.trim(); + + return ( + +
+
+ + + {t("custom_game_modal_browse")} + + } + /> + + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 0672f847..7733aee0 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -1,4 +1,5 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react"; +import PlayLogo from "@renderer/assets/play-logo.svg?react"; import { LibraryGame } from "@types"; import cn from "classnames"; import { useLocation } from "react-router-dom"; @@ -16,6 +17,19 @@ export function SidebarGameItem({ }: Readonly) { const location = useLocation(); + const isCustomGame = game.shop === "custom"; + const sidebarIcon = isCustomGame + ? game.libraryImageUrl || game.iconUrl + : game.customIconUrl || game.iconUrl; + + // Determine fallback icon based on game type + const getFallbackIcon = () => { + if (isCustomGame) { + return ; + } + return ; + }; + return (
  • handleSidebarGameClick(event, game)} > - {game.iconUrl ? ( + {sidebarIcon ? ( {game.title} ) : ( - + getFallbackIcon() )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 45b3f598..0fec7c30 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -172,4 +172,24 @@ display: block; } } + + &__add-button { + background: none; + border: none; + color: globals.$muted-color; + cursor: pointer; + padding: 0; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + + &:active { + color: rgba(255, 255, 255, 0.5); + } + + svg { + display: block; + } + } } diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 42e87e32..f77066a2 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; +import { Tooltip } from "react-tooltip"; import type { LibraryGame } from "@types"; @@ -21,8 +22,13 @@ import { buildGameDetailsPath } from "@renderer/helpers"; import { SidebarProfile } from "./sidebar-profile"; import { sortBy } from "lodash-es"; import cn from "classnames"; -import { CommentDiscussionIcon, PlayIcon } from "@primer/octicons-react"; +import { + CommentDiscussionIcon, + PlayIcon, + PlusIcon, +} from "@primer/octicons-react"; import { SidebarGameItem } from "./sidebar-game-item"; +import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal"; import { setFriendRequestCount } from "@renderer/features/user-details-slice"; import { useDispatch } from "react-redux"; @@ -63,11 +69,20 @@ export function Sidebar() { const { showWarningToast } = useToast(); const [showPlayableOnly, setShowPlayableOnly] = useState(false); + const [showAddGameModal, setShowAddGameModal] = useState(false); const handlePlayButtonClick = () => { setShowPlayableOnly(!showPlayableOnly); }; + const handleAddGameButtonClick = () => { + setShowAddGameModal(true); + }; + + const handleCloseAddGameModal = () => { + setShowAddGameModal(false); + }; + useEffect(() => { updateLibrary(); }, [lastPacket?.gameId, updateLibrary]); @@ -254,15 +269,32 @@ export function Sidebar() { {t("my_library")} - + + + + + + + + ); } diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 864fd482..6fe01663 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -198,6 +198,12 @@ export function GameDetailsContextProvider({ dispatch(setHeaderTitle(gameTitle)); }, [objectId, gameTitle, dispatch]); + useEffect(() => { + if (game?.title) { + dispatch(setHeaderTitle(game.title)); + } + }, [game?.title, dispatch]); + useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { const updatedIsGameRunning = diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 87b2d63d..e6277888 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -112,6 +112,43 @@ declare global { objectId: string, title: string ) => Promise; + addCustomGameToLibrary: ( + title: string, + executablePath: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => Promise; + updateCustomGame: (params: { + shop: GameShop; + objectId: string; + title: string; + iconUrl?: string; + logoImageUrl?: string; + libraryHeroImageUrl?: string; + originalIconPath?: string; + originalLogoPath?: string; + originalHeroPath?: string; + }) => Promise; + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => Promise; + cleanupUnusedAssets: () => Promise<{ + deletedCount: number; + errors: string[]; + }>; + updateGameCustomAssets: (params: { + shop: GameShop; + objectId: string; + title: string; + customIconUrl?: string | null; + customLogoImageUrl?: string | null; + customHeroImageUrl?: string | null; + customOriginalIconPath?: string | null; + customOriginalLogoPath?: string | null; + customOriginalHeroPath?: string | null; + }) => Promise; createGameShortcut: ( shop: GameShop, objectId: string, @@ -273,6 +310,8 @@ declare global { onCommonRedistProgress: ( cb: (value: { log: string; complete: boolean }) => void ) => () => Electron.IpcRenderer; + saveTempFile: (fileName: string, fileData: Uint8Array) => Promise; + deleteTempFile: (filePath: string) => Promise; platform: NodeJS.Platform; /* Auto update */ diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index eb7cebb0..01b4d6cc 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -84,3 +84,23 @@ export const injectCustomCss = ( export const removeCustomCss = (target: HTMLElement = document.head) => { target.querySelector("#custom-css")?.remove(); }; + +export const generateRandomGradient = (): string => { + // Use a single consistent gradient with softer colors for custom games as placeholder + const color1 = "#2c3e50"; // Dark blue-gray + const color2 = "#34495e"; // Darker slate + + // Create SVG data URL that works in img tags + const svgContent = ` + + + + + + + + `; + + // Return as data URL that works in img tags + return `data:image/svg+xml;base64,${btoa(svgContent)}`; +}; diff --git a/src/renderer/src/pages/downloads/delete-game-modal.tsx b/src/renderer/src/pages/downloads/delete-game-modal.tsx index 7f4a530e..b8b4d8a6 100644 --- a/src/renderer/src/pages/downloads/delete-game-modal.tsx +++ b/src/renderer/src/pages/downloads/delete-game-modal.tsx @@ -28,12 +28,12 @@ export function DeleteGameModal({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/game-details/description-header/description-header.scss b/src/renderer/src/pages/game-details/description-header/description-header.scss index a6faaee3..8ca78eaa 100644 --- a/src/renderer/src/pages/game-details/description-header/description-header.scss +++ b/src/renderer/src/pages/game-details/description-header/description-header.scss @@ -1,13 +1,17 @@ @use "../../../scss/globals.scss"; .description-header { - width: 100%; - padding: calc(globals.$spacing-unit * 2); + width: calc(100% - calc(globals.$spacing-unit * 2)); + margin: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 1); + padding: calc(globals.$spacing-unit * 1.5); display: flex; justify-content: space-between; align-items: center; background-color: globals.$background-color; height: 72px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); &__info { display: flex; diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss index c7932a34..9483b50e 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss @@ -2,11 +2,20 @@ .gallery-slider { &__container { - padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1); width: 100%; display: flex; flex-direction: column; align-items: center; + max-height: 80vh; + + @media (min-width: 1024px) { + max-height: 70vh; + } + + @media (min-width: 1280px) { + max-height: 60vh; + } } &__viewport { @@ -16,8 +25,19 @@ overflow: hidden; border-radius: 8px; + @media (min-width: 1024px) { + width: 80%; + max-height: 400px; + } + @media (min-width: 1280px) { width: 60%; + max-height: 500px; + } + + @media (min-width: 1536px) { + width: 50%; + max-height: 600px; } } @@ -52,10 +72,18 @@ overflow-y: hidden; gap: calc(globals.$spacing-unit / 2); + @media (min-width: 1024px) { + width: 80%; + } + @media (min-width: 1280px) { width: 60%; } + @media (min-width: 1536px) { + width: 50%; + } + &::-webkit-scrollbar-thumb { width: 20%; } @@ -79,6 +107,19 @@ border: solid 1px globals.$border-color; overflow: hidden; position: relative; + aspect-ratio: 16/9; + + @media (min-width: 1024px) { + width: 15%; + } + + @media (min-width: 1280px) { + width: 12%; + } + + @media (min-width: 1536px) { + width: 10%; + } &:hover { opacity: 0.8; diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 1ce80da4..4e9ecf14 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -1,16 +1,18 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { PencilIcon } from "@primer/octicons-react"; import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; import { GallerySlider } from "./gallery-slider/gallery-slider"; import { Sidebar } from "./sidebar/sidebar"; +import { EditGameModal } from "./modals"; import { useTranslation } from "react-i18next"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { AuthPage } from "@shared"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; -import { useUserDetails } from "@renderer/hooks"; +import { useUserDetails, useLibrary } from "@renderer/hooks"; import { useSubscription } from "@renderer/hooks/use-subscription"; import "./game-details.scss"; @@ -19,12 +21,13 @@ export function GameDetailsContent() { const { t } = useTranslation("game_details"); - const { objectId, shopDetails, game, hasNSFWContentBlocked } = + const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } = useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); const { userDetails, hasActiveSubscription } = useUserDetails(); + const { updateLibrary } = useLibrary(); const { setShowCloudSyncModal, getGameArtifacts } = useContext(cloudSyncContext); @@ -40,15 +43,43 @@ export function GameDetailsContent() { const $images = Array.from(document.querySelectorAll("img")); $images.forEach(($image) => { $image.loading = "lazy"; + // Remove any inline width/height styles that might cause overflow + $image.removeAttribute("width"); + $image.removeAttribute("height"); + $image.removeAttribute("style"); + // Set max-width to prevent overflow + $image.style.maxWidth = "100%"; + $image.style.width = "auto"; + $image.style.height = "auto"; + $image.style.boxSizing = "border-box"; + }); + + // Handle videos the same way + const $videos = Array.from(document.querySelectorAll("video")); + $videos.forEach(($video) => { + // Remove any inline width/height styles that might cause overflow + $video.removeAttribute("width"); + $video.removeAttribute("height"); + $video.removeAttribute("style"); + // Set max-width to prevent overflow + $video.style.maxWidth = "100%"; + $video.style.width = "auto"; + $video.style.height = "auto"; + $video.style.boxSizing = "border-box"; }); return document.body.outerHTML; } + if (game?.shop === "custom") { + return ""; + } + return t("no_shop_details"); - }, [shopDetails, t]); + }, [shopDetails, t, game?.shop]); const [backdropOpacity, setBackdropOpacity] = useState(1); + const [showEditGameModal, setShowEditGameModal] = useState(false); useEffect(() => { setBackdropOpacity(1); @@ -68,10 +99,72 @@ export function GameDetailsContent() { setShowCloudSyncModal(true); }; + const handleEditGameClick = () => { + setShowEditGameModal(true); + }; + + const handleGameUpdated = (_updatedGame: any) => { + updateGame(); + updateLibrary(); + }; + useEffect(() => { getGameArtifacts(); }, [getGameArtifacts]); + const isCustomGame = game?.shop === "custom"; + + // Helper function to get image with custom asset priority + const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined + ) => { + return customUrl || originalUrl || fallbackUrl || ""; + }; + + const heroImage = isCustomGame + ? game?.libraryHeroImageUrl || game?.iconUrl || "" + : getImageWithCustomPriority( + game?.customHeroImageUrl, + shopDetails?.assets?.libraryHeroImageUrl + ); + + const logoImage = isCustomGame + ? game?.logoImageUrl || "" + : getImageWithCustomPriority( + game?.customLogoImageUrl, + shopDetails?.assets?.logoImageUrl + ); + + const renderGameLogo = () => { + if (isCustomGame) { + // For custom games, show logo image if available, otherwise show game title as text + if (logoImage) { + return ( + {game?.title} + ); + } else { + return ( +
    {game?.title}
    + ); + } + } else { + // For non-custom games, show logo image if available + return logoImage ? ( + {game?.title} + ) : null; + } + }; + return (
    {game?.title} @@ -95,26 +188,35 @@ export function GameDetailsContent() { style={{ opacity: backdropOpacity }} >
    - {game?.title} + {renderGameLogo()} - +
    + + + {game?.shop !== "custom" && ( + + )} +
    @@ -134,9 +236,17 @@ export function GameDetailsContent() { /> - + {game?.shop !== "custom" && } + + setShowEditGameModal(false)} + game={game} + shopDetails={shopDetails} + onGameUpdated={handleGameUpdated} + /> ); } diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index e488db5f..6b02dde5 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -43,12 +43,53 @@ $hero-height: 300px; } &__hero-content { - padding: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 1.5); height: 100%; width: 100%; display: flex; justify-content: space-between; align-items: flex-end; + + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 2); + } + } + + &__hero-buttons { + display: flex; + gap: globals.$spacing-unit; + align-items: center; + + &--right { + margin-left: auto; + } + } + + &__edit-custom-game-button { + padding: calc(globals.$spacing-unit * 1.5); + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(20px); + border-radius: 8px; + transition: all ease 0.2s; + cursor: pointer; + min-height: 40px; + min-width: 40px; + display: flex; + align-items: center; + justify-content: center; + color: globals.$muted-color; + border: solid 1px globals.$border-color; + box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); + animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1); + + &:active { + opacity: 0.9; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.5); + color: globals.$body-color; + } } &__hero-logo-backdrop { @@ -79,8 +120,40 @@ $hero-height: 300px; } &__game-logo { - width: 300px; + width: 200px; align-self: flex-end; + + @media (min-width: 768px) { + width: 250px; + } + + @media (min-width: 1024px) { + width: 300px; + } + } + + &__game-logo-text { + width: 200px; + align-self: flex-end; + font-size: 1.8rem; + font-weight: bold; + color: #ffffff; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); + text-align: left; + line-height: 1.2; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + + @media (min-width: 768px) { + width: 250px; + font-size: 2.2rem; + } + + @media (min-width: 1024px) { + width: 300px; + font-size: 2.5rem; + } } &__hero-image-skeleton { @@ -113,32 +186,53 @@ $hero-height: 300px; &__description-content { width: 100%; - height: 100%; + min-height: 100%; min-width: 0; flex: 1; + overflow-x: hidden; } &__description { user-select: text; line-height: 22px; font-size: globals.$body-font-size; - padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5); width: 100%; margin-left: auto; margin-right: auto; + overflow-x: auto; + min-height: auto; + + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2); + } + + @media (min-width: 1024px) { + padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + width: 80%; + } @media (min-width: 1280px) { width: 60%; } - img { + @media (min-width: 1536px) { + width: 50%; + } + + img, + video { border-radius: 5px; margin-top: globals.$spacing-unit; margin-bottom: calc(globals.$spacing-unit * 3); - display: block; - width: 100%; - height: auto; - object-fit: cover; + display: block !important; + max-width: 100% !important; + width: auto !important; + height: auto !important; + object-fit: contain !important; + box-sizing: border-box !important; + word-wrap: break-word; + overflow-wrap: break-word; } a { @@ -155,15 +249,28 @@ $hero-height: 300px; display: flex; flex-direction: column; gap: globals.$spacing-unit; - padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5); width: 100%; margin-left: auto; margin-right: auto; + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2); + } + + @media (min-width: 1024px) { + padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + width: 80%; + } + @media (min-width: 1280px) { width: 60%; line-height: 22px; } + + @media (min-width: 1536px) { + width: 50%; + } } &__randomizer-button { diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index b966e6e7..f0778494 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -178,6 +178,7 @@ export default function GameDetails() { onClose={() => { setShowGameOptionsModal(false); }} + onNavigateHome={() => navigate("/")} /> )} diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index 307de108..d8d98583 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -229,7 +229,7 @@ export function HeroPanelActions() { {game.favorite ? : } - {userDetails && ( + {userDetails && game.shop !== "custom" && ( + - - diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss new file mode 100644 index 00000000..5400df07 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -0,0 +1,181 @@ +.edit-game-modal__container { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + max-width: 500px; +} + +.edit-game-modal__form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.edit-game-modal__asset-selector { + margin-bottom: 8px; +} + +.edit-game-modal__asset-tabs { + display: flex; + gap: 8px; + margin-bottom: 4px; + + button { + flex: 1; + min-width: 0; + } +} + +.edit-game-modal__asset-label { + font-size: 14px; + font-weight: 500; + color: var(--color-text); + margin-bottom: 4px; +} + +.edit-game-modal__image-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.edit-game-modal__resolution-info { + font-size: 12px; + color: var(--color-text-secondary); + margin-top: -4px; + margin-bottom: 4px; +} + +.edit-game-modal__image-preview { + display: flex; + justify-content: center; + align-items: center; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 8px; + background-color: var(--color-background-secondary); + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.1) 25%, + transparent 25% + ), + linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%), + linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%); + background-size: 16px 16px; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0px; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; + position: relative; + + /* Reset button styles when used as button element */ + &[type="button"] { + font: inherit; + color: inherit; + text-align: inherit; + text-decoration: none; + outline: none; + cursor: pointer; + + &:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + } + + &:hover { + border-color: var(--color-primary); + } +} + +.edit-game-modal__drop-zone { + min-height: 120px; + cursor: pointer; + border-style: dashed !important; + + &:hover { + border-color: var(--color-primary); + background-color: rgba(var(--color-primary-rgb), 0.05); + } + + &--active { + border-color: var(--color-primary) !important; + background-color: rgba(var(--color-primary-rgb), 0.1) !important; + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3); + } +} + +.edit-game-modal__drop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(var(--color-primary-rgb), 0.9); + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: white; + font-weight: 600; + font-size: 14px; + backdrop-filter: blur(2px); + animation: fadeIn 0.2s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.edit-game-modal__drop-zone-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--color-text-secondary); + font-size: 14px; + + svg { + width: 24px; + height: 24px; + opacity: 0.6; + } +} + +.edit-game-modal__icon-preview { + max-width: 200px; + margin: 0 auto; +} + +.edit-game-modal__preview-image { + max-width: 100%; + max-height: 120px; + object-fit: contain; + border-radius: 4px; +} + +.edit-game-modal__actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 8px; +} + +.edit-game-modal__actions button { + min-width: 100px; +} diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx new file mode 100644 index 00000000..0f6df95d --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -0,0 +1,642 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageIcon, XIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useToast } from "@renderer/hooks"; +import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types"; + +import "./edit-game-modal.scss"; + +export interface EditGameModalProps { + visible: boolean; + onClose: () => void; + game: LibraryGame | Game | null; + shopDetails?: ShopDetailsWithAssets | null; + onGameUpdated: (updatedGame: LibraryGame | Game) => void; +} + +type AssetType = "icon" | "logo" | "hero"; + +export function EditGameModal({ + visible, + onClose, + game, + shopDetails, + onGameUpdated, +}: Readonly) { + const { t } = useTranslation("sidebar"); + const { showSuccessToast, showErrorToast } = useToast(); + + const [gameName, setGameName] = useState(""); + const [assetPaths, setAssetPaths] = useState({ + icon: "", + logo: "", + hero: "", + }); + const [assetDisplayPaths, setAssetDisplayPaths] = useState({ + icon: "", + logo: "", + hero: "", + }); + const [originalAssetPaths, setOriginalAssetPaths] = useState({ + icon: "", + logo: "", + hero: "", + }); + const [defaultUrls, setDefaultUrls] = useState({ + icon: null as string | null, + logo: null as string | null, + hero: null as string | null, + }); + const [isUpdating, setIsUpdating] = useState(false); + const [selectedAssetType, setSelectedAssetType] = useState("icon"); + + const isCustomGame = (game: LibraryGame | Game): boolean => { + return game.shop === "custom"; + }; + + const extractLocalPath = (url: string | null | undefined): string => { + return url?.startsWith("local:") ? url.replace("local:", "") : ""; + }; + + const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { + setAssetPaths({ + icon: extractLocalPath(game.iconUrl), + logo: extractLocalPath(game.logoImageUrl), + hero: extractLocalPath(game.libraryHeroImageUrl), + }); + setAssetDisplayPaths({ + icon: extractLocalPath(game.iconUrl), + logo: extractLocalPath(game.logoImageUrl), + hero: extractLocalPath(game.libraryHeroImageUrl), + }); + setOriginalAssetPaths({ + icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl), + logo: + (game as any).originalLogoPath || extractLocalPath(game.logoImageUrl), + hero: + (game as any).originalHeroPath || + extractLocalPath(game.libraryHeroImageUrl), + }); + }, []); + + const setNonCustomGameAssets = useCallback( + (game: LibraryGame) => { + setAssetPaths({ + icon: extractLocalPath(game.customIconUrl), + logo: extractLocalPath(game.customLogoImageUrl), + hero: extractLocalPath(game.customHeroImageUrl), + }); + setAssetDisplayPaths({ + icon: extractLocalPath(game.customIconUrl), + logo: extractLocalPath(game.customLogoImageUrl), + hero: extractLocalPath(game.customHeroImageUrl), + }); + setOriginalAssetPaths({ + icon: + (game as any).customOriginalIconPath || + extractLocalPath(game.customIconUrl), + logo: + (game as any).customOriginalLogoPath || + extractLocalPath(game.customLogoImageUrl), + hero: + (game as any).customOriginalHeroPath || + extractLocalPath(game.customHeroImageUrl), + }); + + setDefaultUrls({ + icon: shopDetails?.assets?.iconUrl || game.iconUrl || null, + logo: shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null, + hero: + shopDetails?.assets?.libraryHeroImageUrl || + game.libraryHeroImageUrl || + null, + }); + }, + [shopDetails] + ); + + useEffect(() => { + if (game && visible) { + setGameName(game.title || ""); + + if (isCustomGame(game)) { + setCustomGameAssets(game); + } else { + setNonCustomGameAssets(game as LibraryGame); + } + } + }, [game, visible, shopDetails, setCustomGameAssets, setNonCustomGameAssets]); + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + const handleAssetTypeChange = (assetType: AssetType) => { + setSelectedAssetType(assetType); + }; + + const getAssetPath = (assetType: AssetType): string => { + return assetPaths[assetType]; + }; + + const getAssetDisplayPath = (assetType: AssetType): string => { + // Use original path if available, otherwise fall back to display path + return originalAssetPaths[assetType] || assetDisplayPaths[assetType]; + }; + + const setAssetPath = (assetType: AssetType, path: string): void => { + setAssetPaths((prev) => ({ ...prev, [assetType]: path })); + }; + + const setAssetDisplayPath = (assetType: AssetType, path: string): void => { + setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path })); + }; + + const getDefaultUrl = (assetType: AssetType): string | null => { + return defaultUrls[assetType]; + }; + + const handleSelectAsset = async (assetType: AssetType) => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + const originalPath = filePaths[0]; + try { + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + originalPath, + assetType + ); + setAssetPath(assetType, copiedAssetUrl.replace("local:", "")); + setAssetDisplayPath(assetType, originalPath); + // Store the original path for display purposes + setOriginalAssetPaths((prev) => ({ + ...prev, + [assetType]: originalPath, + })); + } catch (error) { + console.error(`Failed to copy ${assetType} asset:`, error); + setAssetPath(assetType, originalPath); + setAssetDisplayPath(assetType, originalPath); + setOriginalAssetPaths((prev) => ({ + ...prev, + [assetType]: originalPath, + })); + } + } + }; + + const handleRestoreDefault = (assetType: AssetType) => { + setAssetPath(assetType, ""); + setAssetDisplayPath(assetType, ""); + setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" })); + }; + + const getOriginalTitle = (): string => { + if (!game) return ""; + + // For non-custom games, the original title is from shopDetails assets + return shopDetails?.assets?.title || game.title || ""; + }; + + const handleRestoreDefaultTitle = () => { + const originalTitle = getOriginalTitle(); + setGameName(originalTitle); + }; + + const isTitleChanged = useMemo((): boolean => { + if (!game || isCustomGame(game)) return false; + const originalTitle = getOriginalTitle(); + return gameName.trim() !== originalTitle.trim(); + }, [game, gameName, shopDetails]); + + const [dragOverTarget, setDragOverTarget] = useState(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragEnter = (e: React.DragEvent, target: string) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverTarget(target); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOverTarget(null); + } + }; + + const validateImageFile = (file: File): boolean => { + const validTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + ]; + return validTypes.includes(file.type); + }; + + const processDroppedFile = async (file: File, assetType: AssetType) => { + setDragOverTarget(null); + + if (!validateImageFile(file)) { + showErrorToast("Invalid file type. Please select an image file."); + return; + } + + try { + let filePath: string; + + interface ElectronFile extends File { + path?: string; + } + + if ("path" in file && typeof (file as ElectronFile).path === "string") { + filePath = (file as ElectronFile).path!; + } else { + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + const tempFileName = `temp_${Date.now()}_${file.name}`; + const tempPath = await window.electron.saveTempFile?.( + tempFileName, + uint8Array + ); + + if (!tempPath) { + throw new Error( + "Unable to process file. Drag and drop may not be fully supported." + ); + } + + filePath = tempPath; + } + + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePath, + assetType + ); + + const assetPath = copiedAssetUrl.replace("local:", ""); + setAssetPath(assetType, assetPath); + setAssetDisplayPath(assetType, filePath); + + showSuccessToast( + `${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!` + ); + + if (!("path" in file) && filePath) { + try { + await window.electron.deleteTempFile?.(filePath); + } catch (cleanupError) { + console.warn("Failed to clean up temporary file:", cleanupError); + } + } + } catch (error) { + console.error(`Failed to process dropped ${assetType}:`, error); + showErrorToast( + `Failed to process dropped ${assetType}. Please try again.` + ); + } + }; + + const handleAssetDrop = async (e: React.DragEvent, assetType: AssetType) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverTarget(null); + + if (isUpdating) return; + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + await processDroppedFile(files[0], assetType); + } + }; + + // Helper function to prepare custom game assets + const prepareCustomGameAssets = (game: LibraryGame | Game) => { + const iconUrl = assetPaths.icon ? `local:${assetPaths.icon}` : game.iconUrl; + const logoImageUrl = assetPaths.logo + ? `local:${assetPaths.logo}` + : game.logoImageUrl; + const libraryHeroImageUrl = assetPaths.hero + ? `local:${assetPaths.hero}` + : game.libraryHeroImageUrl; + + return { iconUrl, logoImageUrl, libraryHeroImageUrl }; + }; + + // Helper function to prepare non-custom game assets + const prepareNonCustomGameAssets = () => { + return { + customIconUrl: assetPaths.icon ? `local:${assetPaths.icon}` : null, + customLogoImageUrl: assetPaths.logo ? `local:${assetPaths.logo}` : null, + customHeroImageUrl: assetPaths.hero ? `local:${assetPaths.hero}` : null, + }; + }; + + // Helper function to update custom game + const updateCustomGame = async (game: LibraryGame | Game) => { + const { iconUrl, logoImageUrl, libraryHeroImageUrl } = + prepareCustomGameAssets(game); + + return window.electron.updateCustomGame({ + shop: game.shop, + objectId: game.objectId, + title: gameName.trim(), + iconUrl: iconUrl || undefined, + logoImageUrl: logoImageUrl || undefined, + libraryHeroImageUrl: libraryHeroImageUrl || undefined, + originalIconPath: originalAssetPaths.icon || undefined, + originalLogoPath: originalAssetPaths.logo || undefined, + originalHeroPath: originalAssetPaths.hero || undefined, + }); + }; + + // Helper function to update non-custom game + const updateNonCustomGame = async (game: LibraryGame) => { + const { customIconUrl, customLogoImageUrl, customHeroImageUrl } = + prepareNonCustomGameAssets(); + + return window.electron.updateGameCustomAssets({ + shop: game.shop, + objectId: game.objectId, + title: gameName.trim(), + customIconUrl, + customLogoImageUrl, + customHeroImageUrl, + customOriginalIconPath: originalAssetPaths.icon || undefined, + customOriginalLogoPath: originalAssetPaths.logo || undefined, + customOriginalHeroPath: originalAssetPaths.hero || undefined, + }); + }; + + const handleUpdateGame = async () => { + if (!game || !gameName.trim()) { + showErrorToast(t("edit_game_modal_fill_required")); + return; + } + + setIsUpdating(true); + + try { + const updatedGame = + game && isCustomGame(game) + ? await updateCustomGame(game) + : await updateNonCustomGame(game as LibraryGame); + + showSuccessToast(t("edit_game_modal_success")); + onGameUpdated(updatedGame); + onClose(); + } catch (error) { + console.error("Failed to update game:", error); + showErrorToast( + error instanceof Error ? error.message : t("edit_game_modal_failed") + ); + } finally { + setIsUpdating(false); + } + }; + + // Helper function to reset form to initial state + const resetFormToInitialState = useCallback( + (game: LibraryGame | Game) => { + setGameName(game.title || ""); + + if (isCustomGame(game)) { + setCustomGameAssets(game); + // Clear default URLs for custom games + setDefaultUrls({ + icon: null, + logo: null, + hero: null, + }); + } else { + setNonCustomGameAssets(game as LibraryGame); + } + }, + [setCustomGameAssets, setNonCustomGameAssets] + ); + + const handleClose = () => { + if (!isUpdating && game) { + resetFormToInitialState(game); + onClose(); + } + }; + + const isFormValid = gameName.trim(); + + const getPreviewUrl = (assetType: AssetType): string | undefined => { + const assetPath = getAssetPath(assetType); + const defaultUrl = getDefaultUrl(assetType); + + if (game && !isCustomGame(game)) { + return assetPath ? `local:${assetPath}` : defaultUrl || undefined; + } + return assetPath ? `local:${assetPath}` : undefined; + }; + + const renderImageSection = (assetType: AssetType) => { + const assetPath = getAssetPath(assetType); + const assetDisplayPath = getAssetDisplayPath(assetType); + const defaultUrl = getDefaultUrl(assetType); + const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl); + const isDragOver = dragOverTarget === assetType; + + const getTranslationKey = (suffix: string) => + `edit_game_modal_${assetType}${suffix}`; + const getResolutionKey = () => `edit_game_modal_${assetType}_resolution`; + + return ( +
    + + + {game && !isCustomGame(game) && assetPath && ( + + )} +
    + } + /> +
    + {t(getResolutionKey())} +
    + + {hasImage && ( + + )} + + {!hasImage && ( + + )} + + ); + }; + + return ( + +
    +
    + + + + ) + } + /> + +
    +
    + {t("edit_game_modal_assets")} +
    +
    + + + +
    +
    + + {renderImageSection(selectedAssetType)} +
    + +
    + + +
    +
    +
    + ); +} diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 9c20acce..e658fbb8 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -18,12 +18,14 @@ export interface GameOptionsModalProps { visible: boolean; game: LibraryGame; onClose: () => void; + onNavigateHome?: () => void; } export function GameOptionsModal({ visible, game, onClose, + onNavigateHome, }: Readonly) { const { t } = useTranslation("game_details"); @@ -90,6 +92,11 @@ export function GameOptionsModal({ await removeGameFromLibrary(game.shop, game.objectId); updateGame(); onClose(); + + // Redirect to home page if it's a custom game + if (game.shop === "custom" && onNavigateHome) { + onNavigateHome(); + } }; const handleChangeExecutableLocation = async () => { @@ -346,14 +353,16 @@ export function GameOptionsModal({ > {t("create_shortcut")} - + {game.shop !== "custom" && ( + + )} {shouldShowCreateStartMenuShortcut && ( - {game.download?.downloadPath && ( +
    - )} + {game.download?.downloadPath && ( + + )} +
    - + )}
    @@ -486,18 +499,20 @@ export function GameOptionsModal({ {t("remove_from_library")} - + {game.shop !== "custom" && ( + + )} - + {game.shop !== "custom" && ( + + )}
    diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index e02421b9..724e0003 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -1,3 +1,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; +export * from "./edit-game-modal"; diff --git a/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx b/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx index 85cea8cd..eb421ec7 100644 --- a/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx @@ -31,12 +31,12 @@ export function RemoveGameFromLibraryModal({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx b/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx index fc71e2d0..b6eb38a2 100644 --- a/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx @@ -36,12 +36,12 @@ export function ResetAchievementsModal({ })} >
    - -
    diff --git a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss index 8674b044..f86db399 100644 --- a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss +++ b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss @@ -1,6 +1,12 @@ @use "../../../scss/globals.scss"; .sidebar-section { + background-color: globals.$dark-background-color; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + overflow: hidden; + &__button { padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2); display: flex; diff --git a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx index 61c90389..9a29f150 100644 --- a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx +++ b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx @@ -25,7 +25,7 @@ export function HowLongToBeatSection({ return `${value} ${t(durationTranslation[unit])}`; }; - if (!howLongToBeatData && !isLoading) return null; + if (!howLongToBeatData || !isLoading) return null; return ( diff --git a/src/renderer/src/pages/home/home.scss b/src/renderer/src/pages/home/home.scss index 497f074e..478e96a1 100644 --- a/src/renderer/src/pages/home/home.scss +++ b/src/renderer/src/pages/home/home.scss @@ -76,6 +76,15 @@ width: 24px; height: 24px; position: relative; + display: flex; + align-items: center; + justify-content: center; + } + + &__title-flame-icon { + width: 32px; + height: 32px; + object-fit: contain; } &__title { diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index e2f66283..0b762882 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -158,7 +158,7 @@ export default function Home() { Flame animation )} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 8de16d3d..41b11ba3 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -5,7 +5,6 @@ import { useAppDispatch, useFormat } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; -import { UserGame } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; @@ -17,8 +16,6 @@ import { useSectionCollapse } from "@renderer/hooks/use-section-collapse"; import { motion, AnimatePresence } from "framer-motion"; import { sectionVariants, - gameCardVariants, - gameGridVariants, chevronVariants, GAME_STATS_ANIMATION_DURATION_IN_MS, } from "./profile-animations"; @@ -38,8 +35,6 @@ export function ProfileContent() { const [statsIndex, setStatsIndex] = useState(0); const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [sortBy, setSortBy] = useState("playedRecently"); - const [prevLibraryGames, setPrevLibraryGames] = useState([]); - const [prevPinnedGames, setPrevPinnedGames] = useState([]); const statsAnimation = useRef(-1); const { toggleSection, isPinnedCollapsed } = useSectionCollapse(); @@ -92,27 +87,6 @@ export function ProfileContent() { const { numberFormatter } = useFormat(); - const gamesHaveChanged = ( - current: UserGame[], - previous: UserGame[] - ): boolean => { - if (current.length !== previous.length) return true; - return current.some( - (game, index) => game.objectId !== previous[index]?.objectId - ); - }; - - const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames); - const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames); - - useEffect(() => { - setPrevLibraryGames(libraryGames); - }, [libraryGames]); - - useEffect(() => { - setPrevPinnedGames(pinnedGames); - }, [pinnedGames]); - const usersAreFriends = useMemo(() => { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -192,57 +166,21 @@ export function ProfileContent() { exit="collapsed" layout > - - {shouldAnimatePinned ? ( - - {pinnedGames?.map((game, index) => ( - - - - ))} - - ) : ( - pinnedGames?.map((game) => ( -
  • - -
  • - )) - )} - +
      + {pinnedGames?.map((game) => ( +
    • + +
    • + ))} +
    )} @@ -262,54 +200,18 @@ export function ProfileContent() { - - {shouldAnimateLibrary ? ( - - {libraryGames?.map((game, index) => ( - - - - ))} - - ) : ( - libraryGames?.map((game) => ( -
  • - -
  • - )) - )} -
    +
      + {libraryGames?.map((game) => ( +
    • + +
    • + ))} +
    )} @@ -338,8 +240,6 @@ export function ProfileContent() { pinnedGames, isPinnedCollapsed, toggleSection, - shouldAnimateLibrary, - shouldAnimatePinned, sortBy, ]); diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index f072fdd5..5d0d7f2c 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -8,6 +8,7 @@ display: flex; transition: all ease 0.2s; cursor: grab; + container-type: inline-size; &:hover { transform: scale(1.05); @@ -86,32 +87,10 @@ top: 8px; right: 8px; display: flex; - gap: 6px; + gap: 4px; z-index: 2; } - &__favorite-icon { - color: rgba(255, 255, 255, 0.8); - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: solid 1px rgba(255, 255, 255, 0.15); - border-radius: 50%; - padding: 6px; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: all ease 0.2s; - - &:hover { - background: rgba(0, 0, 0, 0.5); - border-color: rgba(255, 255, 255, 0.25); - transform: translateY(-1px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); - } - } - &__pin-button { color: rgba(255, 255, 255, 0.8); background: rgba(0, 0, 0, 0.4); @@ -160,6 +139,27 @@ transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } + + &-long { + display: inline; + font-size: 12px; + } + + &-short { + display: none; + font-size: 12px; + } + + // When the card is narrow (less than 180px), show short format + @container (max-width: 140px) { + &-long { + display: none; + } + + &-short { + display: inline; + } + } } &__manual-playtime { color: globals.$warning-color; diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index 860c6758..eac0912d 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -13,7 +13,6 @@ import { ClockIcon, TrophyIcon, AlertFillIcon, - HeartFillIcon, PinIcon, PinSlashIcon, } from "@primer/octicons-react"; @@ -79,17 +78,22 @@ export function UserLibraryGameCard({ }; const formatPlayTime = useCallback( - (playTimeInSeconds = 0) => { + (playTimeInSeconds = 0, isShort = false) => { const minutes = playTimeInSeconds / 60; if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { - return t("amount_minutes", { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { amount: minutes.toFixed(0), }); } const hours = minutes / 60; - return t("amount_hours", { amount: numberFormatter.format(hours) }); + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); }, [numberFormatter, t] ); @@ -130,33 +134,26 @@ export function UserLibraryGameCard({ onClick={() => navigate(buildUserGameDetailsPath(game))} >
    - {(game.isFavorite || isMe) && ( + {isMe && (
    - {game.isFavorite && ( -
    - -
    - )} - {isMe && ( - - )} +
    )} - )} - {formatPlayTime(game.playTimeInSeconds)} - + + {formatPlayTime(game.playTimeInSeconds)} + + + {formatPlayTime(game.playTimeInSeconds, true)} + +
    {userProfile?.hasActiveSubscription && game.achievementCount > 0 && ( diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx index be46e56a..9439d273 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx @@ -38,12 +38,12 @@ export const DeleteAllThemesModal = ({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx index 5783ce01..c1a5a1e0 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx @@ -41,12 +41,12 @@ export const DeleteThemeModal = ({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index db4abe8c..601e9568 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -77,12 +77,12 @@ export const ImportThemeModal = ({ onClose={onClose} >
    - -
    diff --git a/src/types/game.types.ts b/src/types/game.types.ts index cc19f09c..ed8fb852 100644 --- a/src/types/game.types.ts +++ b/src/types/game.types.ts @@ -1,4 +1,4 @@ -export type GameShop = "steam" | "epic"; +export type GameShop = "steam" | "epic" | "custom"; export type ShortcutLocation = "desktop" | "start_menu"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index c5bd3454..8a6c56a0 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -33,6 +33,17 @@ export interface User { export interface Game { title: string; iconUrl: string | null; + libraryHeroImageUrl: string | null; + logoImageUrl: string | null; + customIconUrl?: string | null; + customLogoImageUrl?: string | null; + customHeroImageUrl?: string | null; + originalIconPath?: string | null; + originalLogoPath?: string | null; + originalHeroPath?: string | null; + customOriginalIconPath?: string | null; + customOriginalLogoPath?: string | null; + customOriginalHeroPath?: string | null; playTimeInMilliseconds: number; unsyncedDeltaPlayTimeInMilliseconds?: number; lastTimePlayed: Date | null;