Compare commits

..

3 Commits

Author SHA1 Message Date
Chubby Granny Chaser
e578047929 feat: add translation key for Hydra Wrapped 2025 in multiple languages and implement sidebar route with marquee effect
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-12-08 15:22:05 +00:00
Chubby Granny Chaser
e49d885b30 chore: update package.json to use yarn commands for type checking and building
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-30 15:21:50 +00:00
Chubby Granny Chaser
cb01301a0d feat: add new translation keys for network statistics in multiple languages 2025-11-30 15:07:32 +00:00
141 changed files with 1908 additions and 6226 deletions

View File

@@ -1,7 +1,6 @@
MAIN_VITE_API_URL=
MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
MAIN_VITE_NIMBUS_API_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=
MAIN_VITE_LAUNCHER_SUBDOMAIN=

View File

@@ -57,7 +57,6 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -74,7 +73,6 @@ jobs:
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -54,10 +54,9 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
@@ -72,10 +71,9 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}

View File

@@ -10,7 +10,6 @@
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![chocolatey](https://img.shields.io/chocolatey/v/hydralauncher.svg)](https://community.chocolatey.org/packages/hydralauncher)
![Hydra Launcher Home Page](./docs/screenshot.png)

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.8.0",
"version": "3.7.5",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -19,12 +19,12 @@
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "yarn run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:unpack": "yarn run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
@@ -70,7 +70,6 @@
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"lucide-react": "^0.544.0",
"node-7z": "^3.0.0",
"parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",
@@ -91,7 +90,6 @@
"user-agents": "^1.1.387",
"uuid": "^13.0.0",
"winreg": "^1.2.5",
"workwonders-sdk": "0.0.10",
"ws": "^8.18.1",
"yaml": "^2.6.1",
"yup": "^1.5.0"

2
proto

Submodule proto updated: 6f11c99c57...7a23620f93

View File

@@ -1,5 +1,4 @@
import aria2p
from aria2p.client import ClientException as DownloadNotFound
class HttpDownloader:
def __init__(self):
@@ -12,16 +11,12 @@ class HttpDownloader:
)
)
def start_download(self, url: str, save_path: str, header, out: str = None):
def start_download(self, url: str, save_path: str, header: str, out: str = None):
if self.download:
self.aria2.resume([self.download])
else:
options = {"dir": save_path}
if header:
options["header"] = header
if out:
options["out"] = out
downloads = self.aria2.add(url, options=options)
downloads = self.aria2.add(url, options={"header": header, "dir": save_path, "out": out})
self.download = downloads[0]
def pause_download(self):
@@ -37,11 +32,7 @@ class HttpDownloader:
if self.download == None:
return None
try:
download = self.aria2.get_download(self.download.gid)
except DownloadNotFound:
self.download = None
return None
download = self.aria2.get_download(self.download.gid)
response = {
'folderName': download.name,

View File

@@ -16,6 +16,7 @@
"library": "Library",
"downloads": "Downloads",
"settings": "Settings",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Available",
"my_library": "My library",
"downloading_metadata": "{{title}} (Downloading metadata…)",
"paused": "{{title}} (Paused)",
@@ -26,7 +27,6 @@
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends",
"notifications": "Notifications",
"need_help": "Need help?",
"favorites": "Favorites",
"playable_button_title": "Show only games you can play now",
@@ -116,7 +116,6 @@
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}",
"calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…",
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
"extracting": "Extracting {{title}}… ({{percentage}} complete)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Installation complete",
"installation_complete_message": "Common redistributables installed successfully"
@@ -175,7 +174,6 @@
"repacks_modal_description": "Choose the repack you want to download",
"select_folder_hint": "To change the default folder, go to the <0>Settings</0>",
"download_now": "Download now",
"loading": "Loading...",
"no_shop_details": "Could not retrieve shop details.",
"download_options": "Download options",
"download_path": "Download path",
@@ -185,12 +183,6 @@
"open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings",
"downloader": "Downloader",
"downloader_online": "Online",
"downloader_not_configured": "Available but not configured",
"downloader_offline": "Link is offline",
"downloader_not_available": "Not available",
"recommended": "Recommended",
"go_to_settings": "Go to Settings",
"select_executable": "Select",
"no_executable_selected": "No executable selected",
"open_folder": "Open folder",
@@ -211,7 +203,6 @@
"danger_zone_section_description": "Remove this game from your library or the files downloaded by Hydra",
"download_in_progress": "Download in progress",
"download_paused": "Download paused",
"extracting": "Extracting",
"last_downloaded_option": "Last downloaded option",
"new_download_option": "New",
"create_steam_shortcut": "Create Steam shortcut",
@@ -425,12 +416,10 @@
"options": "Manage",
"extract": "Extract files",
"extracting": "Extracting files…",
"delete_archive_title": "Would you like to delete {{fileName}}?",
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
"yes": "Yes",
"no": "No",
"network": "NETWORK",
"peak": "PEAK"
"network": "Network",
"peak": "Peak",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Downloads path",
@@ -566,7 +555,6 @@
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download",
"enable_steam_achievements": "Enable search for Steam achievements",
"enable_new_download_options_badges": "Show new download options badges",
"achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left",
"top-center": "Top center",
@@ -671,7 +659,6 @@
"sending": "Sending",
"friend_request_sent": "Friend request sent",
"friends": "Friends",
"badges": "Badges",
"friends_list": "Friends list",
"user_not_found": "User not found",
"block_user": "Block user",
@@ -682,17 +669,12 @@
"ignore_request": "Ignore request",
"cancel_request": "Cancel request",
"undo_friendship": "Undo friendship",
"friendship_removed": "Friend removed",
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}",
"blocked_users": "Blocked users",
"unblock": "Unblock",
"no_friends_added": "You have no added friends",
"no_friends_yet": "You haven't added any friends yet",
"view_all": "View all",
"load_more": "Load more",
"loading": "Loading",
"pending": "Pending",
"no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users",
@@ -716,7 +698,6 @@
"report_reason_other": "Other",
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated",
@@ -736,12 +717,10 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
"loading_reviews": "Loading reviews..."
},
"library": {
"library": "Library",
@@ -792,41 +771,5 @@
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
"learn_more": "Learn More",
"debrid_description": "Download up to 4x faster with Nimbus"
},
"notifications_page": {
"title": "Notifications",
"mark_all_as_read": "Mark all as read",
"clear_all": "Clear All",
"loading": "Loading...",
"empty_title": "No notifications",
"empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.",
"filter_all": "All",
"filter_unread": "Unread",
"filter_friends": "Friends",
"filter_badges": "Badges",
"filter_upvotes": "Upvotes",
"filter_local": "Local",
"load_more": "Load more",
"dismiss": "Dismiss",
"accept": "Accept",
"refuse": "Refuse",
"notification": "Notification",
"friend_request_received_title": "New friend request!",
"friend_request_received_description": "{{displayName}} wants to be your friend",
"friend_request_accepted_title": "Friend request accepted!",
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
"badge_received_title": "You got a new badge!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
"review_upvote_description": "Your review received {{count}} new upvotes",
"marked_all_as_read": "All notifications marked as read",
"failed_to_mark_as_read": "Failed to mark notifications as read",
"cleared_all": "All notifications cleared",
"failed_to_clear": "Failed to clear notifications",
"failed_to_load": "Failed to load notifications",
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
}
}

View File

@@ -16,6 +16,7 @@
"library": "Librería",
"downloads": "Descargas",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Disponible",
"my_library": "Mi Librería",
"downloading_metadata": "{{title}} (Descargando metadatos…)",
"paused": "{{title}} (Pausado)",
@@ -182,12 +183,6 @@
"open_screenshot": "Abrir captura número {{number}}",
"download_settings": "Descargar ajustes",
"downloader": "Descargador",
"downloader_online": "En línea",
"downloader_not_configured": "Disponible pero no configurado",
"downloader_offline": "El enlace está fuera de línea",
"downloader_not_available": "No disponible",
"recommended": "Recomendado",
"go_to_settings": "Ir a Ajustes",
"select_executable": "Seleccionar",
"no_executable_selected": "Sin ejecutable seleccionado",
"open_folder": "Abrir carpeta",
@@ -420,7 +415,11 @@
"resume_seeding": "Continuar sembrando",
"options": "Administrar",
"extract": "Extraer archivos",
"extracting": "Extrayendo archivos…"
"extracting": "Extrayendo archivos…",
"network": "Red",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Ruta de descarga",
@@ -464,7 +463,6 @@
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
"button_delete_all_sources": "Eliminar todo",
"added_download_source": "Añadir fuente de descarga",
"adding": "Añadiendo…",
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
"insert_valid_json_url": "Introducí una URL de json válida",
"found_download_option_zero": "Sin opciones de descargas encontrada",
@@ -570,19 +568,6 @@
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
"change_achievement_sound": "Cambiar sonido de logro",
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
"download_source_failed": "Error",
"download_source_matched": "Actualizado",
"download_source_matching": "Actualizando",
"download_source_no_information": "Sin información disponible",
"download_source_pending_matching": "Actualizando pronto",
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
"hydra_cloud": "Hydra Cloud",
"preview_sound": "Vista previa de sonido",
"remove_achievement_sound": "Eliminar sonido de logros",
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
},
"notifications": {
@@ -657,7 +642,6 @@
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amistades",
"badges": "Insignias",
"friends_list": "Lista de amistades",
"user_not_found": "Usuario no encontrado",
"block_user": "Bloquear usuario",
@@ -668,16 +652,12 @@
"ignore_request": "Ignorar solicitud",
"cancel_request": "Cancelar solicitud",
"undo_friendship": "Deshacer amistad",
"friendship_removed": "Amigo eliminado",
"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": "No tenés amistades añadidas",
"view_all": "Ver todo",
"load_more": "Cargar más",
"loading": "Cargando",
"pending": "Pendiente",
"no_pending_invites": "No tenés invitaciones pendientes",
"no_blocked_users": "No has bloqueado a nadie",
@@ -701,7 +681,6 @@
"report_reason_other": "Otros",
"profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad",
"upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…",
"background_image_updated": "Imagen de fondo actualizada",
@@ -722,13 +701,11 @@
"amount_minutes_short": "{{amount}}m",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Conseguido por me gustas positivos en reseñas",
"sort_by": "Filtrar por:",
"game_added_to_pinned": "Juego añadido a fijados",
"user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Mi Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña"
},
@@ -781,41 +758,5 @@
"all_games": "Todos los Juegos",
"recently_played": "Jugados Recientemente",
"favorites": "Favoritos"
},
"notifications_page": {
"title": "Notificaciones",
"mark_all_as_read": "Marcar todo como leído",
"clear_all": "Limpiar todo",
"loading": "Cargando...",
"empty_title": "Sin notificaciones",
"empty_description": "¡Estás al día! Volvé más tarde para ver nuevas actualizaciones.",
"empty_filter_description": "No hay notificaciones que coincidan con este filtro.",
"filter_all": "Todas",
"filter_unread": "No leídas",
"filter_friends": "Amigos",
"filter_badges": "Insignias",
"filter_upvotes": "Votos",
"filter_local": "Locales",
"load_more": "Cargar más",
"dismiss": "Descartar",
"accept": "Aceptar",
"refuse": "Rechazar",
"notification": "Notificación",
"friend_request_received_title": "¡Nueva solicitud de amistad!",
"friend_request_received_description": "{{displayName}} quiere ser tu amigo",
"friend_request_accepted_title": "¡Solicitud de amistad aceptada!",
"friend_request_accepted_description": "{{displayName}} aceptó tu solicitud de amistad",
"badge_received_title": "¡Obtuviste una nueva insignia!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!",
"review_upvote_description": "Tu reseña recibió {{count}} nuevos votos",
"marked_all_as_read": "Todas las notificaciones marcadas como leídas",
"failed_to_mark_as_read": "Error al marcar las notificaciones como leídas",
"cleared_all": "Todas las notificaciones eliminadas",
"failed_to_clear": "Error al eliminar las notificaciones",
"failed_to_load": "Error al cargar las notificaciones",
"failed_to_dismiss": "Error al descartar la notificación",
"friend_request_accepted": "Solicitud de amistad aceptada",
"friend_request_refused": "Solicitud de amistad rechazada"
}
}

View File

@@ -673,7 +673,8 @@
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"karma": "Karma",
"karma_count": "karmaa"
"karma_count": "karmaa",
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
},
"achievement": {
"achievement_unlocked": "Saavutus avattu",

View File

@@ -27,69 +27,7 @@
"friends": "Amis",
"need_help": "Besoin d'aide ?",
"favorites": "Favoris",
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant",
"library": "Bibliothèque",
"add_custom_game_tooltip": "Ajouter un jeu personnalisé",
"show_playable_only_tooltip": "Afficher uniquement les jeux jouables",
"custom_game_modal": "Ajouter un jeu personnalisé",
"custom_game_modal_description": "Ajoutez un jeu personnalisé à votre bibliothèque en sélectionnant un fichier exécutable",
"custom_game_modal_executable_path": "Chemin de l'exécutable",
"custom_game_modal_select_executable": "Sélectionner un fichier exécutable",
"custom_game_modal_title": "Titre",
"custom_game_modal_enter_title": "Entrer le titre",
"custom_game_modal_browse": "Parcourir",
"custom_game_modal_cancel": "Annuler",
"custom_game_modal_add": "Ajouter le jeu",
"custom_game_modal_adding": "Ajout du jeu…",
"custom_game_modal_success": "Jeu personnalisé ajouté avec succès",
"custom_game_modal_failed": "Échec de lajout du jeu personnalisé",
"custom_game_modal_executable": "Exécutable",
"edit_game_modal": "Personnaliser les ressources",
"edit_game_modal_description": "Personnalisez les ressources et les détails du jeu",
"edit_game_modal_title": "Titre",
"edit_game_modal_enter_title": "Entrer le titre",
"edit_game_modal_image": "Image",
"edit_game_modal_select_image": "Sélectionner une image",
"edit_game_modal_browse": "Parcourir",
"edit_game_modal_image_preview": "Aperçu de limage",
"edit_game_modal_icon": "Icône",
"edit_game_modal_select_icon": "Sélectionner une icône",
"edit_game_modal_icon_preview": "Aperçu de licône",
"edit_game_modal_logo": "Logo",
"edit_game_modal_select_logo": "Sélectionner un logo",
"edit_game_modal_logo_preview": "Aperçu du logo",
"edit_game_modal_hero": "Bannière de la bibliothèque",
"edit_game_modal_select_hero": "Sélectionner limage de bannière",
"edit_game_modal_hero_preview": "Aperçu de la bannière",
"edit_game_modal_cancel": "Annuler",
"edit_game_modal_update": "Mettre à jour",
"edit_game_modal_updating": "Mise à jour…",
"edit_game_modal_fill_required": "Veuillez remplir tous les champs requis",
"edit_game_modal_success": "Ressources mises à jour avec succès",
"edit_game_modal_failed": "Échec de la mise à jour des ressources",
"edit_game_modal_image_filter": "Image",
"edit_game_modal_icon_resolution": "Résolution recommandée : 256x256px",
"edit_game_modal_logo_resolution": "Résolution recommandée : 640x360px",
"edit_game_modal_hero_resolution": "Résolution recommandée : 1920x620px",
"edit_game_modal_assets": "Ressources",
"edit_game_modal_drop_icon_image_here": "Déposez limage de licône ici",
"edit_game_modal_drop_logo_image_here": "Déposez limage du logo ici",
"edit_game_modal_drop_hero_image_here": "Déposez limage de la bannière ici",
"edit_game_modal_drop_to_replace_icon": "Déposez pour remplacer licône",
"edit_game_modal_drop_to_replace_logo": "Déposez pour remplacer le logo",
"edit_game_modal_drop_to_replace_hero": "Déposez pour remplacer la bannière",
"install_decky_plugin": "Installer le plugin Decky",
"update_decky_plugin": "Mettre à jour le plugin Decky",
"decky_plugin_installed_version": "Plugin Decky (v{{version}})",
"install_decky_plugin_title": "Installer le plugin Decky Hydra",
"install_decky_plugin_message": "Cela téléchargera et installera le plugin Hydra pour Decky Loader. Des permissions élevées peuvent être requises. Continuer ?",
"update_decky_plugin_title": "Mettre à jour le plugin Decky Hydra",
"update_decky_plugin_message": "Une nouvelle version du plugin Decky Hydra est disponible. Souhaitez-vous la mettre à jour maintenant ?",
"decky_plugin_installed": "Plugin Decky v{{version}} installé avec succès",
"decky_plugin_installation_failed": "Échec de linstallation du plugin Decky : {{error}}",
"decky_plugin_installation_error": "Erreur lors de linstallation du plugin Decky : {{error}}",
"confirm": "Confirmer",
"cancel": "Annuler"
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant"
},
"header": {
"search": "Rechercher",
@@ -99,15 +37,7 @@
"search_results": "Résultats de la recherche",
"settings": "Paramètres",
"version_available_install": "Version {{version}} disponible. Cliquez ici pour redémarrer et installer.",
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger.",
"search_library": "Rechercher dans la bibliothèque",
"recent_searches": "Recherches récentes",
"suggestions": "Suggestions",
"clear_history": "Effacer",
"remove_from_history": "Supprimer de l'historique",
"loading": "Chargement…",
"no_results": "Aucun résultat",
"library": "Bibliothèque"
"version_available_download": "Version {{version}} disponible. Cliquez ici pour télécharger."
},
"bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours",
@@ -117,8 +47,7 @@
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} terminé)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Installation terminée",
"installation_complete_message": "Redistribuables communs installés avec succès",
"extracting": "Extraction de {{title}}… ({{percentage}} terminé)"
"installation_complete_message": "Redistribuables communs installés avec succès"
},
"catalogue": {
"search": "Filtrer…",
@@ -269,113 +198,7 @@
"download_error_not_cached_on_hydra": "Ce téléchargement n'est pas disponible sur Nimbus.",
"game_removed_from_favorites": "Jeu retiré des favoris",
"game_added_to_favorites": "Jeu ajouté aux favoris",
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés",
"already_in_library": "Déjà dans la bibliothèque",
"create_shortcut_simple": "Créer un raccourci",
"properties": "Propriétés",
"extracting": "Extraction en cours",
"new_download_option": "Nouveau",
"create_steam_shortcut": "Créer un raccourci Steam",
"you_might_need_to_restart_steam": "Vous devrez peut-être redémarrer Steam pour voir les changements",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Retirer des favoris",
"failed_update_favorites": "Échec de la mise à jour des favoris",
"game_removed_from_library": "Jeu retiré de la bibliothèque",
"failed_remove_from_library": "Échec de la suppression du jeu de la bibliothèque",
"files_removed_success": "Fichiers supprimés avec succès",
"failed_remove_files": "Échec de la suppression des fichiers",
"rating_count": "Évaluations",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"reviews": "Avis",
"review_played_for": "Temps de jeu",
"leave_a_review": "Laisser un avis",
"write_review_placeholder": "Partagez votre avis sur ce jeu…",
"sort_newest": "Les plus récents",
"sort_oldest": "Les plus anciens",
"sort_highest_score": "Meilleure note",
"sort_lowest_score": "Note la plus basse",
"sort_most_voted": "Les plus votés",
"no_reviews_yet": "Aucun avis pour le moment",
"be_first_to_review": "Soyez le premier à donner votre avis !",
"rating": "Note",
"rating_stats": "Évaluation",
"rating_very_negative": "Très négatif",
"rating_negative": "Négatif",
"rating_neutral": "Neutre",
"rating_positive": "Positif",
"rating_very_positive": "Très positif",
"submit_review": "Envoyer",
"submitting": "Envoi…",
"review_submitted_successfully": "Avis envoyé avec succès !",
"review_submission_failed": "Échec de lenvoi de lavis. Veuillez réessayer.",
"review_cannot_be_empty": "Le champ de lavis ne peut pas être vide.",
"review_deleted_successfully": "Avis supprimé avec succès.",
"review_deletion_failed": "Échec de la suppression de lavis.",
"loading_reviews": "Chargement des avis…",
"loading_more_reviews": "Chargement de plus davis…",
"load_more_reviews": "Charger plus davis",
"you_seemed_to_enjoy_this_game": "Vous semblez avoir apprécié ce jeu",
"would_you_recommend_this_game": "Souhaitez-vous laisser un avis sur ce jeu ?",
"yes": "Oui",
"maybe_later": "Peut-être plus tard",
"backup_failed": "Échec de la sauvegarde",
"update_playtime_title": "Mettre à jour le temps de jeu",
"update_playtime_description": "Mettre à jour manuellement le temps de jeu pour {{game}}",
"update_playtime": "Mettre à jour le temps de jeu",
"update_playtime_success": "Temps de jeu mis à jour avec succès",
"update_playtime_error": "Échec de la mise à jour du temps de jeu",
"update_game_playtime": "Mettre à jour le temps de jeu",
"manual_playtime_warning": "Vos heures seront marquées comme modifiées manuellement et cela ne peut pas être annulé.",
"manual_playtime_tooltip": "Ce temps de jeu a été modifié manuellement",
"game_removed_from_pinned": "Jeu retiré des épinglés",
"game_added_to_pinned": "Jeu ajouté aux épinglés",
"create_start_menu_shortcut": "Créer un raccourci dans le menu Démarrer",
"invalid_wine_prefix_path": "Chemin du préfixe Wine invalide",
"invalid_wine_prefix_path_description": "Le chemin du préfixe Wine est invalide. Veuillez vérifier et réessayer.",
"missing_wine_prefix": "Un préfixe Wine est requis pour créer une sauvegarde sous Linux",
"artifact_renamed": "Sauvegarde renommée avec succès",
"rename_artifact": "Renommer la sauvegarde",
"rename_artifact_description": "Renommez la sauvegarde avec un nom plus descriptif",
"artifact_name_label": "Nom de la sauvegarde",
"artifact_name_placeholder": "Entrez un nom pour la sauvegarde",
"save_changes": "Enregistrer les modifications",
"required_field": "Ce champ est requis",
"max_length_field": "Ce champ doit contenir moins de {{length}} caractères",
"freeze_backup": "Épingler pour éviter lécrasement automatique",
"unfreeze_backup": "Désépingler",
"backup_frozen": "Sauvegarde épinglée",
"backup_unfrozen": "Sauvegarde désépinglée",
"backup_freeze_failed": "Échec de lépinglage de la sauvegarde",
"backup_freeze_failed_description": "Vous devez laisser au moins un emplacement libre pour les sauvegardes automatiques",
"edit_game_modal_button": "Personnaliser les ressources du jeu",
"game_details": "Détails du jeu",
"prices": "Prix",
"no_prices_found": "Aucun prix trouvé",
"view_all_prices": "Cliquer pour voir tous les prix",
"retail_price": "Prix officiel",
"keyshop_price": "Prix Keyshop",
"historical_retail": "Historique officiel",
"historical_keyshop": "Historique Keyshop",
"language": "Langue",
"caption": "Sous-titres",
"audio": "Audio",
"filter_by_source": "Filtrer par source",
"no_repacks_found": "Aucune source trouvée pour ce jeu",
"delete_review": "Supprimer lavis",
"remove_review": "Retirer lavis",
"delete_review_modal_title": "Voulez-vous vraiment supprimer votre avis ?",
"delete_review_modal_description": "Cette action est irréversible.",
"delete_review_modal_delete_button": "Supprimer",
"delete_review_modal_cancel_button": "Annuler",
"vote_failed": "Échec de lenregistrement de votre vote. Veuillez réessayer.",
"show_original": "Afficher loriginal",
"show_translation": "Afficher la traduction",
"show_original_translated_from": "Afficher loriginal (traduit depuis {{language}})",
"hide_original": "Masquer loriginal",
"review_from_blocked_user": "Avis dun utilisateur bloqué",
"show": "Afficher",
"hide": "Masquer"
"automatically_extract_downloaded_files": "Extraire automatiquement les fichiers téléchargés"
},
"activation": {
"title": "Activer Hydra",
@@ -414,11 +237,7 @@
"resume_seeding": "Reprendre le partage",
"options": "Gérer",
"extract": "Extraire les fichiers",
"extracting": "Extraction des fichiers…",
"delete_archive_title": "Voulez-vous supprimer {{fileName}} ?",
"delete_archive_description": "Le fichier a été extrait avec succès et nest plus nécessaire.",
"yes": "Oui",
"no": "Non"
"extracting": "Extraction des fichiers…"
},
"settings": {
"downloads_path": "Chemin des téléchargements",
@@ -547,40 +366,7 @@
"bottom-left": "En bas à gauche",
"bottom-center": "En bas au centre",
"bottom-right": "En bas à droite",
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu",
"adding": "Ajout…",
"failed_add_download_source": "Échec de lajout de la source de téléchargement. Veuillez réessayer.",
"download_source_already_exists": "Cette URL de source existe déjà",
"download_source_pending_matching": "Mise à jour imminente",
"download_source_matched": "À jour",
"download_source_matching": "Mise à jour",
"download_source_failed": "Erreur",
"download_source_no_information": "Aucune information disponible",
"removed_all_download_sources": "Toutes les sources de téléchargement supprimées",
"download_sources_synced_successfully": "Toutes les sources de téléchargement ont été synchronisées",
"importing": "Importation…",
"hydra_cloud": "Hydra Cloud",
"debrid": "Debrid",
"enable_steam_achievements": "Activer la recherche de succès Steam",
"alignment": "Alignement",
"variation": "Variation",
"default": "Par défaut",
"rare": "Rare",
"platinum": "Platine",
"hidden": "Caché",
"test_notification": "Notification de test",
"achievement_sound_volume": "Volume du son de succès",
"select_achievement_sound": "Sélectionner un son de succès",
"change_achievement_sound": "Changer le son de succès",
"remove_achievement_sound": "Supprimer le son de succès",
"preview_sound": "Prévisualiser le son",
"select": "Sélectionner",
"preview": "Aperçu",
"remove": "Supprimer",
"no_sound_file_selected": "Aucun fichier sonore sélectionné",
"notification_preview": "Aperçu de la notification de succès",
"autoplay_trailers_on_game_page": "Lire automatiquement les bandes-annonces sur la page du jeu",
"hide_to_tray_on_game_start": "Réduire Hydra dans la barre système au lancement dun jeu"
"enable_friend_start_game_notifications": "Quand un ami commence à jouer à un jeu"
},
"notifications": {
"download_complete": "Téléchargement terminé",

View File

@@ -22,7 +22,7 @@
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"home": "Főoldal",
"queued": "{{title}} (Várakozásban)",
"queued": "A(z) {{title}} (Várakozósorban van)",
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés",
"friends": "Barátok",
@@ -94,12 +94,6 @@
"header": {
"search": "Keresés",
"search_library": "Könyvtár böngészése",
"recent_searches": "Korábbi Keresések",
"suggestions": "Találatok",
"clear_history": "Törlés",
"remove_from_history": "Törlés az előzményekből",
"loading": "Töltés...",
"no_results": "Nincs találat",
"home": "Főoldal",
"catalogue": "Katalógus",
"library": "Könyvtár",
@@ -115,7 +109,6 @@
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Telepítés befejezve",
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
@@ -172,7 +165,7 @@
"playing_now": "Játékban: ",
"change": "Változtatás",
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
"download_now": "Letöltés",
"no_shop_details": "A bolt adatai nem érhetőek el.",
"download_options": "Letöltési opciók",
@@ -203,7 +196,6 @@
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
"download_in_progress": "Letöltés folyamatban",
"download_paused": "Letöltés szüneteltetve",
"extracting": "Kicsomagolás",
"last_downloaded_option": "Utoljára letöltött",
"new_download_option": "Új",
"create_steam_shortcut": "Steam parancsikon létrehozása",
@@ -405,7 +397,7 @@
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
"install": "Telepít",
"download_in_progress": "Folyamatban lévő",
"queued_downloads": "Várakozásban lévő letöltések",
"queued_downloads": "Várakozósoron lévő letöltések",
"downloads_completed": "Befejezett",
"queued": "Várakozásban",
"no_downloads_title": "Oly üres..",
@@ -416,11 +408,7 @@
"resume_seeding": "Seedelés folytatása",
"options": "Kezelés",
"extract": "Fájlok kibontása",
"extracting": "Fájlok kibontása…",
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
"delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ",
"yes": "Igen",
"no": "Nem"
"extracting": "Fájlok kibontása…"
},
"settings": {
"downloads_path": "Letöltési útvonalak",
@@ -681,7 +669,7 @@
"no_blocked_users": "Nincs letiltott felhasználó",
"friend_code_copied": "Barát kód kimásolva",
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>",
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
"locked_profile": "Ez a profil privát",
"image_process_failure": "Hiba a kép feldolgozása közben",
"required_field": "Ez a mező kötelező",
@@ -718,6 +706,7 @@
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Pozitív értékelésekkel szerzett pontok",
"user_reviews": "Vélemények",
"delete_review": "Vélemény Törlése",
"loading_reviews": "Vélemények betöltése..."

View File

@@ -673,7 +673,8 @@
"game_removed_from_pinned": "Spēle dzēsta no piespraustajiem",
"game_added_to_pinned": "Spēle pievienota piespraustajiem",
"karma": "Karma",
"karma_count": "karma"
"karma_count": "karma",
"karma_description": "Nopelnīta ar pozitīviem atsauksmju vērtējumiem"
},
"achievement": {
"achievement_unlocked": "Sasniegums atbloķēts",

View File

@@ -16,6 +16,7 @@
"library": "Biblioteca",
"downloads": "Downloads",
"settings": "Ajustes",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)",
@@ -115,7 +116,6 @@
"downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}",
"calculating_eta": "Baixando {{title}}… ({{percentage}} concluído) - Calculando tempo restante…",
"checking_files": "Verificando arquivos de {{title}}…",
"extracting": "Extraindo {{title}}… ({{percentage}} concluído)",
"installing_common_redist": "{{log}}…",
"installation_complete": "Instalação concluída",
"installation_complete_message": "Componentes recomendados instalados com sucesso"
@@ -172,12 +172,6 @@
"open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download",
"downloader": "Downloader",
"downloader_online": "Online",
"downloader_not_configured": "Disponível mas não configurado",
"downloader_offline": "Link está offline",
"downloader_not_available": "Não disponível",
"recommended": "Recomendado",
"go_to_settings": "Ir para Configurações",
"select_executable": "Explorar",
"no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta",
@@ -197,7 +191,6 @@
"danger_zone_section_description": "Remova o jogo da sua biblioteca ou os arquivos que foram baixados pelo Hydra",
"download_in_progress": "Download em andamento",
"download_paused": "Download pausado",
"extracting": "Extraindo",
"last_downloaded_option": "Última opção baixada",
"new_download_option": "Novo",
"create_steam_shortcut": "Criar atalho na Steam",
@@ -411,12 +404,10 @@
"options": "Gerenciar",
"extract": "Extrair arquivos",
"extracting": "Extraindo arquivos…",
"delete_archive_title": "Deseja deletar {{fileName}}?",
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
"yes": "Sim",
"no": "Não",
"network": "REDE",
"peak": "PICO"
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Diretório dos downloads",
@@ -660,7 +651,6 @@
"see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos",
"badges": "Insígnias",
"add": "Adicionar",
"sending": "Enviando",
"friends_list": "Lista de amigos",
@@ -673,16 +663,12 @@
"ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade",
"friendship_removed": "Amigo removido",
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}",
"blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados",
"view_all": "Ver todos",
"load_more": "Carregar mais",
"loading": "Carregando",
"pending": "Pendentes",
"no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
@@ -706,7 +692,6 @@
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo",
"upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…",
"background_image_updated": "Imagem de fundo salva",
@@ -732,12 +717,10 @@
"achievements_earned": "Conquistas recebidas",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Ganho a partir de curtidas positivas em avaliações",
"manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente",
"user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Meu Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação"
},
@@ -790,41 +773,5 @@
"all_games": "Todos os Jogos",
"recently_played": "Jogados Recentemente",
"favorites": "Favoritos"
},
"notifications_page": {
"title": "Notificações",
"mark_all_as_read": "Marcar todas como lidas",
"clear_all": "Limpar todas",
"loading": "Carregando...",
"empty_title": "Sem notificações",
"empty_description": "Você está em dia! Volte mais tarde para ver novas atualizações.",
"empty_filter_description": "Nenhuma notificação corresponde a este filtro.",
"filter_all": "Todas",
"filter_unread": "Não lidas",
"filter_friends": "Amigos",
"filter_badges": "Insígnias",
"filter_upvotes": "Votos",
"filter_local": "Locais",
"load_more": "Carregar mais",
"dismiss": "Descartar",
"accept": "Aceitar",
"refuse": "Recusar",
"notification": "Notificação",
"friend_request_received_title": "Nova solicitação de amizade!",
"friend_request_received_description": "{{displayName}} quer ser seu amigo",
"friend_request_accepted_title": "Solicitação de amizade aceita!",
"friend_request_accepted_description": "{{displayName}} aceitou sua solicitação de amizade",
"badge_received_title": "Você recebeu uma nova insígnia!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!",
"review_upvote_description": "Sua avaliação recebeu {{count}} novos votos",
"marked_all_as_read": "Todas as notificações marcadas como lidas",
"failed_to_mark_as_read": "Falha ao marcar notificações como lidas",
"cleared_all": "Todas as notificações limpas",
"failed_to_clear": "Falha ao limpar notificações",
"failed_to_load": "Falha ao carregar notificações",
"failed_to_dismiss": "Falha ao descartar notificação",
"friend_request_accepted": "Solicitação de amizade aceita",
"friend_request_refused": "Solicitação de amizade recusada"
}
}

View File

@@ -15,6 +15,7 @@
"catalogue": "Catálogo",
"downloads": "Transferências",
"settings": "Definições",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Já disponível",
"my_library": "Biblioteca",
"downloading_metadata": "{{title}} (A transferir metadados…)",
"paused": "{{title}} (Em pausa)",
@@ -229,7 +230,13 @@
"seeding": "A semear",
"stop_seeding": "Parar de semear",
"resume_seeding": "Semear",
"options": "Opções"
"options": "Opções",
"extract": "Extrair ficheiros",
"extracting": "A extrair ficheiros…",
"network": "Rede",
"peak": "Pico",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Local das transferências",

View File

@@ -16,6 +16,7 @@
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"hydra_2025_wrapped": "Hydra Wrapped 2025 Доступно",
"my_library": "Библиотека",
"downloading_metadata": "{{title}} (Загрузка метаданных…)",
"paused": "{{title}} (Приостановлено)",
@@ -182,12 +183,6 @@
"open_screenshot": "Открыть скриншот {{number}}",
"download_settings": "Параметры загрузки",
"downloader": "Загрузчик",
"downloader_online": "Онлайн",
"downloader_not_configured": "Доступен, но не настроен",
"downloader_offline": "Ссылка недоступна",
"downloader_not_available": "Недоступно",
"recommended": "Рекомендуется",
"go_to_settings": "Перейти в настройки",
"select_executable": "Выбрать",
"no_executable_selected": "Файл не выбран",
"open_folder": "Открыть папку",
@@ -420,7 +415,11 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Путь загрузок",
@@ -657,7 +656,6 @@
"sending": "Отправка",
"friend_request_sent": "Запрос в друзья отправлен",
"friends": "Друзья",
"badges": "Значки",
"friends_list": "Список друзей",
"user_not_found": "Пользователь не найден",
"block_user": "Заблокировать пользователя",
@@ -668,16 +666,12 @@
"ignore_request": "Игнорировать запрос",
"cancel_request": "Отменить запрос",
"undo_friendship": "Удалить друга",
"friendship_removed": "Друг удален",
"request_accepted": "Запрос принят",
"user_blocked_successfully": "Пользователь успешно заблокирован",
"user_block_modal_text": "{{displayName}} будет заблокирован",
"blocked_users": "Заблокированные пользователи",
"unblock": "Разблокировать",
"no_friends_added": "Вы ещё не добавили ни одного друга",
"view_all": "Показать все",
"load_more": "Загрузить еще",
"loading": "Загрузка",
"pending": "Ожидание",
"no_pending_invites": "У вас нет запросов ожидающих ответа",
"no_blocked_users": "Вы не заблокировали ни одного пользователя",
@@ -701,7 +695,6 @@
"report_reason_other": "Другое",
"profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:",
"copy_friend_code": "Копировать код друга",
"upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...",
"background_image_updated": "Фоновое изображение обновлено",
@@ -721,11 +714,9 @@
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработана положительными оценками отзывов",
"user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Просмотреть мой Wrapped 2025",
"view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}",
"no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв"
},
@@ -778,41 +769,5 @@
"all_games": "Все игры",
"recently_played": "Недавно сыгранные",
"favorites": "Избранное"
},
"notifications_page": {
"title": "Уведомления",
"mark_all_as_read": "Отметить все как прочитанные",
"clear_all": "Очистить все",
"loading": "Загрузка...",
"empty_title": "Нет уведомлений",
"empty_description": "Вы в курсе всех событий! Загляните позже за новыми обновлениями.",
"empty_filter_description": "Нет уведомлений, соответствующих этому фильтру.",
"filter_all": "Все",
"filter_unread": "Непрочитанные",
"filter_friends": "Друзья",
"filter_badges": "Значки",
"filter_upvotes": "Голоса",
"filter_local": "Локальные",
"load_more": "Загрузить еще",
"dismiss": "Отклонить",
"accept": "Принять",
"refuse": "Отклонить",
"notification": "Уведомление",
"friend_request_received_title": "Новый запрос в друзья!",
"friend_request_received_description": "{{displayName}} хочет добавить вас в друзья",
"friend_request_accepted_title": "Запрос в друзья принят!",
"friend_request_accepted_description": "{{displayName}} принял ваш запрос в друзья",
"badge_received_title": "Вы получили новый значок!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!",
"review_upvote_description": "Ваш отзыв получил {{count}} новых голосов",
"marked_all_as_read": "Все уведомления отмечены как прочитанные",
"failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные",
"cleared_all": "Все уведомления очищены",
"failed_to_clear": "Не удалось очистить уведомления",
"failed_to_load": "Не удалось загрузить уведомления",
"failed_to_dismiss": "Не удалось отклонить уведомление",
"friend_request_accepted": "Запрос в друзья принят",
"friend_request_refused": "Запрос в друзья отклонен"
}
}

View File

@@ -706,6 +706,7 @@
"game_added_to_pinned": "Oyun sabitlenmişlere eklendi",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "İncelemelerdeki olumlu beğenilerden kazanılır",
"user_reviews": "İncelemeler",
"delete_review": "İncelemeyi Sil",
"loading_reviews": "İncelemeler yükleniyor..."

View File

@@ -668,7 +668,8 @@
"game_removed_from_pinned": "Гру видалено із закріплених",
"game_added_to_pinned": "Гру додано до закріплених",
"karma": "Карма",
"karma_count": "карма"
"karma_count": "карма",
"karma_description": "Зароблена позитивними оцінками на відгуках"
},
"achievement": {
"achievement_unlocked": "Досягнення розблоковано",

View File

@@ -689,6 +689,7 @@
"game_removed_from_pinned": "游戏已从置顶移除",
"karma": "业力",
"karma_count": "业力值",
"karma_description": "通过评论获得的点赞",
"loading_reviews": "正在加载评价...",
"manual_playtime_tooltip": "该游戏时长已手动更新",
"pinned": "已置顶",

View File

@@ -1,23 +0,0 @@
import fs from "node:fs";
import { registerEvent } from "../register-event";
import { logger } from "@main/services";
const deleteArchive = async (
_event: Electron.IpcMainInvokeEvent,
filePath: string
) => {
try {
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
logger.info(`Deleted archive: ${filePath}`);
return true;
}
return true;
} catch (err) {
logger.error(`Failed to delete archive: ${filePath}`, err);
return false;
}
};
registerEvent("deleteArchive", deleteArchive);

View File

@@ -22,7 +22,6 @@ const extractGameDownload = async (
await downloadsSublevel.put(gameKey, {
...download,
extracting: true,
extractionProgress: 0,
});
const gameFilesManager = new GameFilesManager(shop, objectId);

View File

@@ -8,7 +8,6 @@ import "./close-game";
import "./copy-custom-game-asset";
import "./create-game-shortcut";
import "./create-steam-shortcut";
import "./delete-archive";
import "./delete-game-folder";
import "./extract-game-download";
import "./get-default-wine-prefix-selection-path";

View File

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

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const deleteLocalNotification = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.deleteNotification(id);
};
registerEvent("deleteLocalNotification", deleteLocalNotification);

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotificationsCount = async () => {
return LocalNotificationManager.getUnreadCount();
};
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);

View File

@@ -1,8 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotifications = async () => {
return LocalNotificationManager.getNotifications();
};
registerEvent("getLocalNotifications", getLocalNotifications);

View File

@@ -1,9 +1,3 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";
import "./get-local-notifications";
import "./get-local-notifications-count";
import "./mark-local-notification-read";
import "./mark-all-local-notifications-read";
import "./delete-local-notification";
import "./clear-all-local-notifications";

View File

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

View File

@@ -1,11 +0,0 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markLocalNotificationRead = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.markAsRead(id);
};
registerEvent("markLocalNotificationRead", markLocalNotificationRead);

View File

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

View File

@@ -0,0 +1,24 @@
import { registerEvent } from "../register-event";
import { HydraApi, WindowManager } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import type { FriendRequestSync } from "@types";
export const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => {
WindowManager.mainWindow?.webContents.send(
"on-sync-friend-requests",
res
);
return res;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequestCount: 0 } as FriendRequestSync;
}
throw err;
});
};
registerEvent("syncFriendRequests", syncFriendRequests);

View File

@@ -41,6 +41,7 @@ const startGameDownload = async (
const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */
await downloadsSublevel.del(gameKey);
if (game) {
@@ -81,7 +82,6 @@ const startGameDownload = async (
queued: true,
extracting: false,
automaticallyExtract,
extractionProgress: 0,
};
try {
@@ -123,42 +123,6 @@ const startGameDownload = async (
}
if (err instanceof Error) {
if (downloader === Downloader.Buzzheavier) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "Buzzheavier: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "Buzzheavier: File not found",
};
}
}
if (downloader === Downloader.FuckingFast) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "FuckingFast: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "FuckingFast: File not found",
};
}
}
return { ok: false, error: err.message };
}

View File

@@ -1,4 +1,4 @@
// @generated by protobuf-ts 2.11.1
// @generated by protobuf-ts 2.10.0
// @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
*/
export interface FriendRequest {
/**
* @generated from protobuf field: int32 friend_request_count = 1
* @generated from protobuf field: int32 friend_request_count = 1;
*/
friendRequestCount: number;
/**
* @generated from protobuf field: optional string sender_id = 2
* @generated from protobuf field: optional string sender_id = 2;
*/
senderId?: string;
}
@@ -28,27 +28,18 @@ export interface FriendRequest {
*/
export interface FriendGameSession {
/**
* @generated from protobuf field: string object_id = 1
* @generated from protobuf field: string object_id = 1;
*/
objectId: string;
/**
* @generated from protobuf field: string shop = 2
* @generated from protobuf field: string shop = 2;
*/
shop: string;
/**
* @generated from protobuf field: string friend_id = 3
* @generated from protobuf field: string friend_id = 3;
*/
friendId: string;
}
/**
* @generated from protobuf message Notification
*/
export interface Notification {
/**
* @generated from protobuf field: int32 notification_count = 1
*/
notificationCount: number;
}
/**
* @generated from protobuf message Envelope
*/
@@ -60,24 +51,17 @@ export interface Envelope {
| {
oneofKind: "friendRequest";
/**
* @generated from protobuf field: FriendRequest friend_request = 1
* @generated from protobuf field: FriendRequest friend_request = 1;
*/
friendRequest: FriendRequest;
}
| {
oneofKind: "friendGameSession";
/**
* @generated from protobuf field: FriendGameSession friend_game_session = 2
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
*/
friendGameSession: FriendGameSession;
}
| {
oneofKind: "notification";
/**
* @generated from protobuf field: Notification notification = 3
*/
notification: Notification;
}
| {
oneofKind: undefined;
};
@@ -255,80 +239,6 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
*/
export const FriendGameSession = new FriendGameSession$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Notification$Type extends MessageType<Notification> {
constructor() {
super("Notification", [
{
no: 1,
name: "notification_count",
kind: "scalar",
T: 5 /*ScalarType.INT32*/,
},
]);
}
create(value?: PartialMessage<Notification>): Notification {
const message = globalThis.Object.create(this.messagePrototype!);
message.notificationCount = 0;
if (value !== undefined)
reflectionMergePartial<Notification>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Notification
): Notification {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int32 notification_count */ 1:
message.notificationCount = reader.int32();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: Notification,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* int32 notification_count = 1; */
if (message.notificationCount !== 0)
writer.tag(1, WireType.Varint).int32(message.notificationCount);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message Notification
*/
export const Notification = new Notification$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> {
constructor() {
super("Envelope", [
@@ -346,13 +256,6 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload",
T: () => FriendGameSession,
},
{
no: 3,
name: "notification",
kind: "message",
oneof: "payload",
T: () => Notification,
},
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
@@ -395,17 +298,6 @@ class Envelope$Type extends MessageType<Envelope> {
),
};
break;
case /* Notification notification */ 3:
message.payload = {
oneofKind: "notification",
notification: Notification.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).notification
),
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -444,13 +336,6 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(2, WireType.LengthDelimited).fork(),
options
).join();
/* Notification notification = 3; */
if (message.payload.oneofKind === "notification")
Notification.internalBinaryWrite(
message.payload.notification,
writer.tag(3, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(

View File

@@ -8,4 +8,3 @@ export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";
export * from "./local-notifications";

View File

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

View File

@@ -1,11 +0,0 @@
import type { LocalNotification } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const localNotificationsSublevel = db.sublevel<
string,
LocalNotification
>(levelKeys.localNotifications, {
valueEncoding: "json",
});

View File

@@ -33,7 +33,9 @@ export const loadState = async () => {
await import("./events");
Aria2.spawn();
if (process.platform !== "darwin") {
Aria2.spawn();
}
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken);
@@ -57,10 +59,8 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// Check for new download options on startup (if enabled)
(async () => {
await DownloadSourcesChecker.checkForChanges();
})();
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
WSClient.connect();
});

View File

@@ -1,5 +1,5 @@
import { app } from "electron";
import Seven, { CommandLineSwitches } from "node-7z";
import cp from "node:child_process";
import path from "node:path";
import { logger } from "./logger";
@@ -9,17 +9,6 @@ export const binaryName = {
win32: "7z.exe",
};
export interface ExtractionProgress {
percent: number;
fileCount: number;
file: string;
}
export interface ExtractionResult {
success: boolean;
extractedFiles: string[];
}
export class SevenZip {
private static readonly binaryPath = app.isPackaged
? path.join(process.resourcesPath, binaryName[process.platform])
@@ -43,109 +32,43 @@ export class SevenZip {
cwd?: string;
passwords?: string[];
},
onProgress?: (progress: ExtractionProgress) => void
): Promise<ExtractionResult> {
return new Promise((resolve, reject) => {
const tryPassword = (index = -1) => {
const password = passwords[index] ?? "";
logger.info(
`Trying password "${password || "(empty)"}" on ${filePath}`
);
successCb: () => void,
errorCb: () => void
) {
const tryPassword = (index = -1) => {
const password = passwords[index] ?? "";
logger.info(`Trying password ${password} on ${filePath}`);
const extractedFiles: string[] = [];
let fileCount = 0;
const args = ["x", filePath, "-y", "-p" + password];
const options: CommandLineSwitches = {
$bin: this.binaryPath,
$progress: true,
yes: true,
password: password || undefined,
};
if (outputPath) {
args.push("-o" + outputPath);
}
if (outputPath) {
options.outputDir = outputPath;
const child = cp.execFile(this.binaryPath, args, {
cwd,
});
child.once("exit", (code) => {
if (code === 0) {
successCb();
return;
}
const stream = Seven.extractFull(filePath, outputPath || cwd || ".", {
...options,
$spawnOptions: cwd ? { cwd } : undefined,
});
stream.on("progress", (progress) => {
if (onProgress) {
onProgress({
percent: progress.percent,
fileCount: fileCount,
file: progress.fileCount?.toString() || "",
});
}
});
stream.on("data", (data) => {
if (data.file) {
extractedFiles.push(data.file);
fileCount++;
}
});
stream.on("end", () => {
if (index < passwords.length - 1) {
logger.info(
`Successfully extracted ${filePath} (${extractedFiles.length} files)`
`Failed to extract file: ${filePath} with password: ${password}. Trying next password...`
);
resolve({
success: true,
extractedFiles,
});
});
stream.on("error", (err) => {
logger.error(`Extraction error for ${filePath}:`, err);
tryPassword(index + 1);
} else {
logger.info(`Failed to extract file: ${filePath}`);
if (index < passwords.length - 1) {
logger.info(
`Failed to extract file: ${filePath} with password: "${password}". Trying next password...`
);
tryPassword(index + 1);
} else {
logger.error(
`Failed to extract file: ${filePath} after trying all passwords`
);
reject(new Error(`Failed to extract file: ${filePath}`));
}
});
};
tryPassword();
});
}
public static listFiles(
filePath: string,
password?: string
): Promise<string[]> {
return new Promise((resolve, reject) => {
const files: string[] = [];
const options: CommandLineSwitches = {
$bin: this.binaryPath,
password: password || undefined,
};
const stream = Seven.list(filePath, options);
stream.on("data", (data) => {
if (data.file) {
files.push(data.file);
errorCb();
}
});
};
stream.on("end", () => {
resolve(files);
});
stream.on("error", (err) => {
reject(err);
});
});
tryPassword();
}
}

View File

@@ -7,12 +7,9 @@ export class Aria2 {
private static process: cp.ChildProcess | null = null;
public static spawn() {
const binaryPath =
process.platform === "darwin"
? "aria2c"
: app.isPackaged
? path.join(process.resourcesPath, "aria2c")
: path.join(__dirname, "..", "..", "binaries", "aria2c");
const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "aria2c")
: path.join(__dirname, "..", "..", "binaries", "aria2c");
this.process = cp.spawn(
binaryPath,

View File

@@ -74,16 +74,21 @@ export class DeckyPlugin {
await fs.promises.mkdir(extractPath, { recursive: true });
try {
await SevenZip.extractFile({
filePath: zipPath,
outputPath: extractPath,
});
logger.log(`Plugin extracted to: ${extractPath}`);
return extractPath;
} catch {
throw new Error("Failed to extract plugin");
}
return new Promise((resolve, reject) => {
SevenZip.extractFile(
{
filePath: zipPath,
outputPath: extractPath,
},
() => {
logger.log(`Plugin extracted to: ${extractPath}`);
resolve(extractPath);
},
() => {
reject(new Error("Failed to extract plugin"));
}
);
});
}
private static needsSudo(): boolean {

View File

@@ -5,12 +5,10 @@ import {
updateDownloadSourcesCheckBaseline,
updateDownloadSourcesSinceValue,
downloadSourcesSublevel,
db,
levelKeys,
} from "@main/level";
import { logger } from "./logger";
import { WindowManager } from "./window-manager";
import type { Game, UserPreferences } from "@types";
import type { Game } from "@types";
interface DownloadSourcesChangeResponse {
shop: string;
@@ -103,20 +101,6 @@ export class DownloadSourcesChecker {
logger.info("DownloadSourcesChecker.checkForChanges() called");
try {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (userPreferences?.enableNewDownloadOptionsBadges === false) {
logger.info(
"New download options badges are disabled, skipping download sources check"
);
return;
}
// Get all installed games (excluding custom games)
const installedGames = await gamesSublevel.values().all();
const nonCustomGames = installedGames.filter(

View File

@@ -8,7 +8,6 @@ import {
DatanodesApi,
MediafireApi,
PixelDrainApi,
VikingFileApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
@@ -21,84 +20,14 @@ import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
export class DownloadManager {
private static downloadingGameId: string | null = null;
private static extractFilename(
url: string,
originalUrl?: string
): string | undefined {
if (originalUrl?.includes("#")) {
const hashPart = originalUrl.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
}
if (url.includes("#")) {
const hashPart = url.split("#")[1];
if (hashPart && !hashPart.startsWith("http") && hashPart.includes(".")) {
return hashPart;
}
}
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathParts = pathname.split("/");
const filename = pathParts[pathParts.length - 1];
if (filename?.includes(".") && filename.length > 0) {
return decodeURIComponent(filename);
}
} catch {
// Invalid URL
}
return undefined;
}
private static sanitizeFilename(filename: string): string {
return filename.replaceAll(/[<>:"/\\|?*]/g, "_");
}
private static createDownloadPayload(
directUrl: string,
originalUrl: string,
downloadId: string,
savePath: string
) {
const filename =
this.extractFilename(originalUrl, directUrl) ||
this.extractFilename(directUrl);
const sanitizedFilename = filename
? this.sanitizeFilename(filename)
: undefined;
if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
} else {
logger.log(
`[DownloadManager] No filename extracted, aria2 will use default`
);
}
return {
action: "start" as const,
game_id: downloadId,
url: directUrl,
save_path: savePath,
out: sanitizedFilename,
allow_multiple_connections: true,
};
}
public static async startRPC(
download?: Download,
downloadsToSeed?: Download[]
@@ -151,28 +80,14 @@ export class DownloadManager {
if (!isDownloadingMetadata && !isCheckingFiles) {
if (!download) return null;
const updatedDownload = {
await downloadsSublevel.put(downloadId, {
...download,
bytesDownloaded,
fileSize,
progress,
folderName,
status: "active" as const,
};
await downloadsSublevel.put(downloadId, updatedDownload);
return {
numPeers,
numSeeds,
downloadSpeed,
timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed),
isDownloadingMetadata,
isCheckingFiles,
progress,
gameId: downloadId,
download: updatedDownload,
} as DownloadProgress;
status: "active",
});
}
return {
@@ -206,14 +121,21 @@ export class DownloadManager {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
{
valueEncoding: "json",
}
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
JSON.parse(
JSON.stringify({
...status,
game,
})
)
);
}
@@ -257,25 +179,27 @@ export class DownloadManager {
)
) {
gameFilesManager.extractDownloadedFile();
} else if (download.folderName) {
} else {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName)
path.join(download.downloadPath, download.folderName!)
)
.then(() => gameFilesManager.setExtractionComplete());
.then(() => {
gameFilesManager.setExtractionComplete();
});
}
}
const downloads = await downloadsSublevel
.values()
.all()
.then((games) =>
sortBy(
.then((games) => {
return orderBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
);
"desc"
);
});
const [nextItemOnQueue] = downloads;
@@ -343,8 +267,13 @@ export class DownloadManager {
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
.post("/action", {
action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
@@ -377,6 +306,7 @@ export class DownloadManager {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink);
return {
@@ -418,50 +348,9 @@ export class DownloadManager {
save_path: download.downloadPath,
};
}
case Downloader.Buzzheavier: {
logger.log(
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
);
try {
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(
`[DownloadManager] Error processing Buzzheavier download:`,
error
);
throw error;
}
}
case Downloader.FuckingFast: {
logger.log(
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
);
try {
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(
`[DownloadManager] Error processing FuckingFast download:`,
error
);
throw error;
}
}
case Downloader.Mediafire: {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
@@ -478,6 +367,7 @@ export class DownloadManager {
};
case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
return {
@@ -490,6 +380,7 @@ export class DownloadManager {
}
case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return;
return {
action: "start",
@@ -504,6 +395,7 @@ export class DownloadManager {
const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri
);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return {
@@ -514,29 +406,6 @@ export class DownloadManager {
allow_multiple_connections: true,
};
}
case Downloader.VikingFile: {
logger.log(
`[DownloadManager] Processing VikingFile download for URI: ${download.uri}`
);
try {
const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri);
logger.log(`[DownloadManager] VikingFile direct URL obtained`);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
header:
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
};
} catch (error) {
logger.error(
`[DownloadManager] Error processing VikingFile download:`,
error
);
throw error;
}
}
}
}

View File

@@ -3,58 +3,24 @@ import fs from "node:fs";
import type { GameShop } from "@types";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared";
import { SevenZip, ExtractionProgress } from "./7zip";
import { SevenZip } from "./7zip";
import { WindowManager } from "./window-manager";
import { publishExtractionCompleteNotification } from "./notifications";
import { logger } from "./logger";
const PROGRESS_THROTTLE_MS = 1000;
export class GameFilesManager {
private lastProgressUpdate = 0;
constructor(
private readonly shop: GameShop,
private readonly objectId: string
) {}
private get gameKey() {
return levelKeys.game(this.shop, this.objectId);
}
private async updateExtractionProgress(progress: number, force = false) {
const now = Date.now();
if (!force && now - this.lastProgressUpdate < PROGRESS_THROTTLE_MS) {
return;
}
this.lastProgressUpdate = now;
const download = await downloadsSublevel.get(this.gameKey);
if (!download) return;
await downloadsSublevel.put(this.gameKey, {
...download,
extractionProgress: progress,
});
WindowManager.mainWindow?.webContents.send(
"on-extraction-progress",
this.shop,
this.objectId,
progress
);
}
private async clearExtractionState() {
const download = await downloadsSublevel.get(this.gameKey);
if (!download) return;
const gameKey = levelKeys.game(this.shop, this.objectId);
const download = await downloadsSublevel.get(gameKey);
await downloadsSublevel.put(this.gameKey, {
...download,
await downloadsSublevel.put(gameKey, {
...download!,
extracting: false,
extractionProgress: 0,
});
WindowManager.mainWindow?.webContents.send(
@@ -64,10 +30,6 @@ export class GameFilesManager {
);
}
private readonly handleProgress = (progress: ExtractionProgress) => {
this.updateExtractionProgress(progress.percent / 100);
};
async extractFilesInDirectory(directoryPath: string) {
if (!fs.existsSync(directoryPath)) return;
const files = await fs.promises.readdir(directoryPath);
@@ -80,66 +42,53 @@ export class GameFilesManager {
(file) => /part1\.rar$/i.test(file) || !/part\d+\.rar$/i.test(file)
);
if (filesToExtract.length === 0) return;
await this.updateExtractionProgress(0, true);
const totalFiles = filesToExtract.length;
let completedFiles = 0;
for (const file of filesToExtract) {
try {
const result = await SevenZip.extractFile(
{
filePath: path.join(directoryPath, file),
cwd: directoryPath,
passwords: ["online-fix.me", "steamrip.com"],
},
(progress) => {
const overallProgress =
(completedFiles + progress.percent / 100) / totalFiles;
this.updateExtractionProgress(overallProgress);
}
);
if (result.success) {
completedFiles++;
await this.updateExtractionProgress(
completedFiles / totalFiles,
true
await Promise.all(
filesToExtract.map((file) => {
return new Promise((resolve, reject) => {
SevenZip.extractFile(
{
filePath: path.join(directoryPath, file),
cwd: directoryPath,
passwords: ["online-fix.me", "steamrip.com"],
},
() => {
resolve(true);
},
() => {
reject(new Error(`Failed to extract file: ${file}`));
this.clearExtractionState();
}
);
}
} catch (err) {
logger.error(`Failed to extract file: ${file}`, err);
await this.clearExtractionState();
return;
});
})
);
compressedFiles.forEach((file) => {
const extractionPath = path.join(directoryPath, file);
if (fs.existsSync(extractionPath)) {
fs.unlink(extractionPath, (err) => {
if (err) {
logger.error(`Failed to delete file: ${file}`, err);
this.clearExtractionState();
}
});
}
}
const archivePaths = compressedFiles
.map((file) => path.join(directoryPath, file))
.filter((archivePath) => fs.existsSync(archivePath));
if (archivePaths.length > 0) {
WindowManager.mainWindow?.webContents.send(
"on-archive-deletion-prompt",
archivePaths
);
}
});
}
async setExtractionComplete(publishNotification = true) {
const gameKey = levelKeys.game(this.shop, this.objectId);
const [download, game] = await Promise.all([
downloadsSublevel.get(this.gameKey),
gamesSublevel.get(this.gameKey),
downloadsSublevel.get(gameKey),
gamesSublevel.get(gameKey),
]);
if (!download) return;
await downloadsSublevel.put(this.gameKey, {
...download,
await downloadsSublevel.put(gameKey, {
...download!,
extracting: false,
extractionProgress: 0,
});
WindowManager.mainWindow?.webContents.send(
@@ -148,15 +97,17 @@ export class GameFilesManager {
this.objectId
);
if (publishNotification && game) {
publishExtractionCompleteNotification(game);
if (publishNotification) {
publishExtractionCompleteNotification(game!);
}
}
async extractDownloadedFile() {
const gameKey = levelKeys.game(this.shop, this.objectId);
const [download, game] = await Promise.all([
downloadsSublevel.get(this.gameKey),
gamesSublevel.get(this.gameKey),
downloadsSublevel.get(gameKey),
gamesSublevel.get(gameKey),
]);
if (!download || !game) return false;
@@ -168,39 +119,39 @@ export class GameFilesManager {
path.parse(download.folderName!).name
);
await this.updateExtractionProgress(0, true);
try {
const result = await SevenZip.extractFile(
{
filePath,
outputPath: extractionPath,
passwords: ["online-fix.me", "steamrip.com"],
},
this.handleProgress
);
if (result.success) {
SevenZip.extractFile(
{
filePath,
outputPath: extractionPath,
passwords: ["online-fix.me", "steamrip.com"],
},
async () => {
await this.extractFilesInDirectory(extractionPath);
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
WindowManager.mainWindow?.webContents.send(
"on-archive-deletion-prompt",
[filePath]
);
fs.unlink(filePath, (err) => {
if (err) {
logger.error(
`Failed to delete file: ${download.folderName}`,
err
);
this.clearExtractionState();
}
});
}
await downloadsSublevel.put(this.gameKey, {
...download,
await downloadsSublevel.put(gameKey, {
...download!,
folderName: path.parse(download.folderName!).name,
});
await this.setExtractionComplete();
this.setExtractionComplete();
},
() => {
this.clearExtractionState();
}
} catch (err) {
logger.error(`Failed to extract downloaded file: ${filePath}`, err);
await this.clearExtractionState();
}
);
return true;
}

View File

@@ -1,100 +0,0 @@
import axios from "axios";
import http from "node:http";
import https from "node:https";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
handleHosterError,
} from "./fuckingfast";
import { logger } from "@main/services";
export class BuzzheavierApi {
private static readonly BUZZHEAVIER_DOMAINS = [
"buzzheavier.com",
"bzzhr.co",
"fuckingfast.net",
];
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
try {
const baseUrl = url.split("#")[0];
logger.log(
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
);
await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
});
const downloadUrl = `${baseUrl}/download`;
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
const headResponse = await axios.head(downloadUrl, {
headers: {
"hx-current-url": baseUrl,
"hx-request": "true",
referer: baseUrl,
"User-Agent": HOSTER_USER_AGENT,
},
maxRedirects: 0,
validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
httpsAgent: new https.Agent({
family: 4, // Force IPv4
}),
});
const hxRedirect = headResponse.headers["hx-redirect"];
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
if (!hxRedirect) {
logger.error(
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
);
throw new Error(
"Could not extract download link. File may be deleted or is a directory."
);
}
const domain = new URL(baseUrl).hostname;
const directLink = hxRedirect.startsWith("/dl/")
? `https://${domain}${hxRedirect}`
: hxRedirect;
logger.log(`[Buzzheavier] Extracted direct link`);
return directLink;
} catch (error) {
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
);
}
return this.getBuzzheavierDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -1,129 +0,0 @@
import axios from "axios";
import { logger } from "@main/services";
export const HOSTER_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
export async function extractHosterFilename(
url: string,
directUrl?: string
): Promise<string> {
if (url.includes("#")) {
const fragment = url.split("#")[1];
if (fragment && !fragment.startsWith("http")) {
return fragment;
}
}
if (directUrl) {
try {
const response = await axios.head(directUrl, {
timeout: 10000,
headers: { "User-Agent": HOSTER_USER_AGENT },
});
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].replace(/['"]/g, "");
}
}
} catch {
// Ignore errors
}
const urlPath = new URL(directUrl).pathname;
const filename = urlPath.split("/").pop()?.split("?")[0];
if (filename) {
return filename;
}
}
return "downloaded_file";
}
export function handleHosterError(error: unknown): never {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new Error("File not found");
}
if (error.response?.status === 429) {
throw new Error("Rate limit exceeded. Please try again later.");
}
if (error.response?.status === 403) {
throw new Error("Access denied. File may be private or deleted.");
}
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
}
throw error;
}
// ============================================
// FuckingFast API Class
// ============================================
export class FuckingFastApi {
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
private static readonly FUCKINGFAST_REGEX =
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getFuckingFastDirectLink(url: string): Promise<string> {
try {
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
const response = await axios.get(url, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const html = response.data;
if (html.toLowerCase().includes("rate limit")) {
logger.error(`[FuckingFast] Rate limit detected`);
throw new Error(
"Rate limit exceeded. Please wait a few minutes and try again."
);
}
if (html.includes("File Not Found Or Deleted")) {
logger.error(`[FuckingFast] File not found or deleted`);
throw new Error("File not found or deleted");
}
const match = this.FUCKINGFAST_REGEX.exec(html);
if (!match || !match[1]) {
logger.error(`[FuckingFast] Could not extract download link`);
throw new Error("Could not extract download link from page");
}
logger.log(`[FuckingFast] Successfully extracted direct link`);
return match[1];
} catch (error) {
logger.error(`[FuckingFast] Error:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
);
}
return this.getFuckingFastDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -36,13 +36,16 @@ export class GofileApi {
}
public static async getDownloadLink(id: string) {
const searchParams = new URLSearchParams({
wt: WT,
});
const response = await axios.get<{
status: string;
data: GofileContentsResponse;
}>(`https://api.gofile.io/contents/${id}`, {
}>(`https://api.gofile.io/contents/${id}?${searchParams.toString()}`, {
headers: {
Authorization: `Bearer ${this.token}`,
"X-Website-Token": WT,
},
});

View File

@@ -3,6 +3,3 @@ export * from "./qiwi";
export * from "./datanodes";
export * from "./mediafire";
export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./fuckingfast";
export * from "./vikingfile";

View File

@@ -1,46 +0,0 @@
import axios from "axios";
import { logger } from "../logger";
interface UnlockResponse {
link: string;
hoster: string;
}
export class VikingFileApi {
public static async getDownloadUrl(uri: string): Promise<string> {
const unlockResponse = await axios.post<UnlockResponse>(
`${import.meta.env.MAIN_VITE_NIMBUS_API_URL}/hosters/unlock`,
{ url: uri }
);
if (!unlockResponse.data.link) {
throw new Error("Failed to unlock VikingFile URL");
}
const redirectUrl = unlockResponse.data.link;
try {
const redirectResponse = await axios.head(redirectUrl, {
maxRedirects: 0,
validateStatus: (status) =>
status === 301 || status === 302 || status === 200,
});
if (
redirectResponse.headers.location ||
redirectResponse.status === 301 ||
redirectResponse.status === 302
) {
return redirectResponse.headers.location || redirectUrl;
}
return redirectUrl;
} catch (error) {
logger.error(
`[VikingFile] Error following redirect, using redirect URL:`,
error
);
return redirectUrl;
}
}
}

View File

@@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static readonly ADD_LOG_INTERCEPTOR = false;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;
@@ -58,13 +58,7 @@ export class HydraApi {
const decodedBase64 = atob(payload as string);
const jsonData = JSON.parse(decodedBase64);
const {
accessToken,
expiresIn,
refreshToken,
featurebaseJwt,
workwondersJwt,
} = jsonData;
const { accessToken, expiresIn, refreshToken } = jsonData;
const now = new Date();
@@ -91,8 +85,6 @@ export class HydraApi {
accessToken,
refreshToken,
tokenExpirationTimestamp,
featurebaseJwt,
workwondersJwt,
},
{ valueEncoding: "json" }
);

View File

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

View File

@@ -1,87 +0,0 @@
declare module "node-7z" {
import { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
export interface CommandLineSwitches {
$bin?: string;
$progress?: boolean;
$spawnOptions?: {
cwd?: string;
};
outputDir?: string;
yes?: boolean;
password?: string;
[key: string]: unknown;
}
export interface ProgressInfo {
percent: number;
fileCount?: number;
}
export interface FileInfo {
file?: string;
[key: string]: unknown;
}
export interface ZipStream extends EventEmitter {
on(event: "progress", listener: (progress: ProgressInfo) => void): this;
on(event: "data", listener: (data: FileInfo) => void): this;
on(event: "end", listener: () => void): this;
on(event: "error", listener: (err: Error) => void): this;
info: Map<string, unknown>;
_childProcess?: ChildProcess;
}
export function extractFull(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function extract(
archive: string,
output: string,
options?: CommandLineSwitches
): ZipStream;
export function list(
archive: string,
options?: CommandLineSwitches
): ZipStream;
export function add(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function update(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function deleteFiles(
archive: string,
files: string | string[],
options?: CommandLineSwitches
): ZipStream;
export function test(
archive: string,
options?: CommandLineSwitches
): ZipStream;
const Seven: {
extractFull: typeof extractFull;
extract: typeof extract;
list: typeof list;
add: typeof add;
update: typeof update;
delete: typeof deleteFiles;
test: typeof test;
};
export default Seven;
}

View File

@@ -16,7 +16,6 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image";
import { LocalNotificationManager } from "./local-notifications";
const getStaticImage = async (path: string) => {
return processProfileImage(path, "jpg")
@@ -79,59 +78,37 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
}
);
const title = t("download_complete", { ns: "notifications" });
const body = t("game_ready_to_install", {
ns: "notifications",
title: game.title,
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title,
body,
title: t("download_complete", {
ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
icon: await downloadImage(game.iconUrl),
}).show();
}
// Create local notification
await LocalNotificationManager.createNotification(
"DOWNLOAD_COMPLETE",
title,
body,
{
pictureUrl: game.iconUrl,
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
const title = t("new_update_available", {
ns: "notifications",
version,
});
const body = t("restart_to_install_update", {
ns: "notifications",
});
new Notification({
title,
body,
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
icon: trayIcon,
})
.on("click", () => {
restartAndInstallUpdate();
})
.show();
// Create local notification
await LocalNotificationManager.createNotification(
"UPDATE_AVAILABLE",
title,
body
);
};
export const publishNewFriendRequestNotification = async (
@@ -204,27 +181,14 @@ export const publishCombinedNewAchievementNotification = async (
};
export const publishExtractionCompleteNotification = async (game: Game) => {
const title = t("extraction_complete", { ns: "notifications" });
const body = t("game_extracted", {
ns: "notifications",
title: game.title,
});
new Notification({
title,
body,
title: t("extraction_complete", { ns: "notifications" }),
body: t("game_extracted", {
ns: "notifications",
title: game.title,
}),
icon: trayIcon,
}).show();
// Create local notification
await LocalNotificationManager.createNotification(
"EXTRACTION_COMPLETE",
title,
body,
{
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNewAchievementNotification = async (info: {

View File

@@ -1,99 +0,0 @@
import { localNotificationsSublevel } from "@main/level";
import { WindowManager } from "../window-manager";
import type { LocalNotification, LocalNotificationType } from "@types";
import crypto from "node:crypto";
export class LocalNotificationManager {
private static generateId(): string {
return crypto.randomBytes(8).toString("hex");
}
static async createNotification(
type: LocalNotificationType,
title: string,
description: string,
options?: {
pictureUrl?: string | null;
url?: string | null;
}
): Promise<LocalNotification> {
const id = this.generateId();
const notification: LocalNotification = {
id,
type,
title,
description,
pictureUrl: options?.pictureUrl ?? null,
url: options?.url ?? null,
isRead: false,
createdAt: new Date().toISOString(),
};
await localNotificationsSublevel.put(id, notification);
// Notify renderer about new notification
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send(
"on-local-notification-created",
notification
);
}
return notification;
}
static async getNotifications(): Promise<LocalNotification[]> {
const notifications: LocalNotification[] = [];
for await (const [, value] of localNotificationsSublevel.iterator()) {
notifications.push(value);
}
// Sort by createdAt descending
return notifications.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
static async getUnreadCount(): Promise<number> {
let count = 0;
for await (const [, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
count++;
}
}
return count;
}
static async markAsRead(id: string): Promise<void> {
const notification = await localNotificationsSublevel.get(id);
if (notification) {
notification.isRead = true;
await localNotificationsSublevel.put(id, notification);
}
}
static async markAllAsRead(): Promise<void> {
const batch = localNotificationsSublevel.batch();
for await (const [key, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
value.isRead = true;
batch.put(key, value);
}
}
await batch.write();
}
static async deleteNotification(id: string): Promise<void> {
await localNotificationsSublevel.del(id);
}
static async clearAll(): Promise<void> {
await localNotificationsSublevel.clear();
}
}

View File

@@ -1,5 +1,4 @@
import axios from "axios";
import http from "node:http";
import cp from "node:child_process";
import fs from "node:fs";
@@ -32,9 +31,6 @@ export class PythonRPC {
public static readonly RPC_PORT = "8084";
public static readonly rpc = axios.create({
baseURL: `http://localhost:${this.RPC_PORT}`,
httpAgent: new http.Agent({
family: 4, // Force IPv4
}),
});
private static pythonProcess: cp.ChildProcess | null = null;

View File

@@ -36,9 +36,9 @@ export class WindowManager {
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{
width: 1200,
height: 860,
height: 720,
minWidth: 1024,
minHeight: 860,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon,
@@ -106,7 +106,7 @@ export class WindowManager {
valueEncoding: "json",
}
);
return data ?? { isMaximized: false, height: 860, width: 1200 };
return data ?? { isMaximized: false, height: 720, width: 1200 };
}
private static updateInitialConfig(
@@ -138,8 +138,7 @@ export class WindowManager {
(details, callback) => {
if (
details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("chatwoot") ||
details.url.includes("workwonders")
details.url.includes("chatwoot")
) {
return callback(details);
}
@@ -160,8 +159,7 @@ export class WindowManager {
if (
details.webContentsId !== this.mainWindow?.webContents.id ||
details.url.includes("featurebase") ||
details.url.includes("chatwoot") ||
details.url.includes("workwonders")
details.url.includes("chatwoot")
) {
return callback(details);
}
@@ -224,7 +222,7 @@ export class WindowManager {
? {
x: undefined,
y: undefined,
height: this.initialConfigInitializationMainWindow.height ?? 860,
height: this.initialConfigInitializationMainWindow.height ?? 720,
width: this.initialConfigInitializationMainWindow.width ?? 1200,
isMaximized: true,
}
@@ -356,6 +354,8 @@ export class WindowManager {
public static async createNotificationWindow() {
if (this.notificationWindow) return;
if (process.platform === "darwin") return;
const userPreferences = await db.get<string, UserPreferences | undefined>(
levelKeys.userPreferences,
{

View File

@@ -1,8 +0,0 @@
import type { Notification } from "@main/generated/envelope";
import { WindowManager } from "@main/services/window-manager";
export const notificationEvent = (payload: Notification) => {
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
notificationCount: payload.notificationCount,
});
};

View File

@@ -4,7 +4,6 @@ import { Envelope } from "@main/generated/envelope";
import { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session";
import { notificationEvent } from "./events/notification";
export class WSClient {
private static ws: WebSocket | null = null;
@@ -52,10 +51,6 @@ export class WSClient {
if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession);
}
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}
});
this.ws.on("close", () => this.handleDisconnect("close"));

View File

@@ -7,7 +7,6 @@ interface ImportMetaEnv {
readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string;
readonly MAIN_VITE_NIMBUS_API_URL: string;
readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string;
readonly ELECTRON_RENDERER_URL: string;
}

View File

@@ -15,7 +15,6 @@ import type {
GameAchievement,
Theme,
FriendRequestSync,
NotificationSync,
ShortcutLocation,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
@@ -268,29 +267,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-extraction-complete", listener);
return () => ipcRenderer.removeListener("on-extraction-complete", listener);
},
onExtractionProgress: (
cb: (shop: GameShop, objectId: string, progress: number) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
shop: GameShop,
objectId: string,
progress: number
) => cb(shop, objectId, progress);
ipcRenderer.on("on-extraction-progress", listener);
return () => ipcRenderer.removeListener("on-extraction-progress", listener);
},
onArchiveDeletionPrompt: (cb: (archivePaths: string[]) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
archivePaths: string[]
) => cb(archivePaths);
ipcRenderer.on("on-archive-deletion-prompt", listener);
return () =>
ipcRenderer.removeListener("on-archive-deletion-prompt", listener);
},
deleteArchive: (filePath: string) =>
ipcRenderer.invoke("deleteArchive", filePath),
/* Hardware */
getDiskFreeSpace: (path: string) =>
@@ -498,6 +474,7 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -507,15 +484,6 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-sync-friend-requests", listener);
},
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: NotificationSync
) => cb(notification);
ipcRenderer.on("on-sync-notification-count", listener);
return () =>
ipcRenderer.removeListener("on-sync-notification-count", listener);
},
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
@@ -559,26 +527,6 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
getLocalNotificationsCount: () =>
ipcRenderer.invoke("getLocalNotificationsCount"),
markLocalNotificationRead: (id: string) =>
ipcRenderer.invoke("markLocalNotificationRead", id),
markAllLocalNotificationsRead: () =>
ipcRenderer.invoke("markAllLocalNotificationsRead"),
deleteLocalNotification: (id: string) =>
ipcRenderer.invoke("deleteLocalNotification", id),
clearAllLocalNotifications: () =>
ipcRenderer.invoke("clearAllLocalNotifications"),
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: unknown
) => cb(notification);
ipcRenderer.on("on-local-notification-created", listener);
return () =>
ipcRenderer.removeListener("on-local-notification-created", listener);
},
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { WorkWondersSdk } from "workwonders-sdk";
import {
useAppDispatch,
useAppSelector,
@@ -19,13 +19,11 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setExtractionProgress,
clearExtraction,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
import {
injectCustomCss,
@@ -52,10 +50,13 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const workwondersRef = useRef<WorkWondersSdk | null>(null);
const {
userDetails,
hasActiveSubscription,
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
hideFriendsModal,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
@@ -77,10 +78,6 @@ export function App() {
const { showSuccessToast } = useToast();
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
useEffect(() => {
Promise.all([
levelDBService.get("userPreferences", null, "json"),
@@ -116,30 +113,7 @@ export function App() {
return () => unsubscribe();
}, [updateLibrary]);
const setupWorkWonders = useCallback(
async (token?: string, locale?: string) => {
if (workwondersRef.current) return;
const possibleLocales = ["en", "pt", "ru"];
const parsedLocale =
possibleLocales.find((l) => l === locale?.slice(0, 2)) ?? "en";
workwondersRef.current = new WorkWondersSdk();
await workwondersRef.current.init({
organization: "hydra",
token,
locale: parsedLocale,
});
await workwondersRef.current.initChangelogWidget();
workwondersRef.current.initChangelogWidgetMini();
workwondersRef.current.initFeedbackWidget();
},
[workwondersRef]
);
const setupExternalResources = useCallback(async () => {
useEffect(() => {
const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) {
@@ -150,31 +124,28 @@ export function App() {
dispatch(setProfileBackground(profileBackground));
}
const userPreferences = await window.electron.getUserPreferences();
const userDetails = await fetchUserDetails().catch(() => null);
fetchUserDetails()
.then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
}
})
.finally(() => {
if (document.getElementById("external-resources")) return;
if (userDetails) {
updateUserDetails(userDetails);
}
setupWorkWonders(userDetails?.workwondersJwt, userPreferences?.language);
if (!document.getElementById("external-resources")) {
const $script = document.createElement("script");
$script.id = "external-resources";
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
document.head.appendChild($script);
}
}, [fetchUserDetails, updateUserDetails, dispatch, setupWorkWonders]);
useEffect(() => {
setupExternalResources();
}, [setupExternalResources]);
const $script = document.createElement("script");
$script.id = "external-resources";
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
document.head.appendChild($script);
});
}, [fetchUserDetails, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
showSuccessToast(t("successfully_signed_in"));
}
});
@@ -213,27 +184,15 @@ export function App() {
updateLibrary();
}),
window.electron.onSignOut(() => clearUserDetails()),
window.electron.onExtractionProgress((shop, objectId, progress) => {
dispatch(setExtractionProgress({ shop, objectId, progress }));
}),
window.electron.onExtractionComplete(() => {
dispatch(clearExtraction());
updateLibrary();
}),
window.electron.onArchiveDeletionPrompt((paths) => {
setArchivePaths(paths);
setShowArchiveDeletionModal(true);
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [onSignIn, updateLibrary, clearUserDetails, dispatch]);
}, [onSignIn, updateLibrary, clearUserDetails]);
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
workwondersRef.current?.notifyUrlChange();
}, [location.pathname, location.search]);
useEffect(() => {
@@ -322,11 +281,14 @@ export function App() {
feature={hydraCloudFeature}
/>
<ArchiveDeletionModal
visible={showArchiveDeletionModal}
archivePaths={archivePaths}
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main>
<Sidebar />

View File

@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
useDownload,
useLibrary,
useToast,
@@ -27,8 +26,6 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
@@ -71,20 +68,6 @@ export function BottomPanel() {
return t("installing_common_redist", { log: commonRedistStatus });
}
if (extraction) {
const extractingGame = library.find(
(game) => game.id === extraction.visibleId
);
if (extractingGame) {
const extractionPercentage = Math.round(extraction.progress * 100);
return t("extracting", {
title: extractingGame.title,
percentage: `${extractionPercentage}%`,
});
}
}
const game = lastPacket
? library.find((game) => game.id === lastPacket?.gameId)
: undefined;
@@ -126,7 +109,6 @@ export function BottomPanel() {
eta,
downloadSpeed,
commonRedistStatus,
extraction,
]);
return (
@@ -140,10 +122,10 @@ export function BottomPanel() {
</button>
<button
data-open-workwonders-changelog-mini
data-featurebase-changelog
className="bottom-panel__version-button"
>
<small>
<small data-featurebase-changelog>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>

View File

@@ -1,82 +0,0 @@
@use "../../scss/globals.scss";
.fullscreen-media-modal__overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(2px);
z-index: globals.$backdrop-z-index;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
}
.fullscreen-media-modal {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: relative;
margin: 0;
padding: 0;
border: none;
background: transparent;
max-width: none;
max-height: none;
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 4);
cursor: pointer;
color: globals.$body-color;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
border: 1px solid globals.$border-color;
padding: globals.$spacing-unit;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
z-index: 10;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
}
&__image-container {
max-width: 90%;
max-height: 90%;
display: flex;
justify-content: center;
align-items: center;
}
&__image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
@keyframes image-appear {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -1,87 +0,0 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./fullscreen-media-modal.scss";
export interface FullscreenMediaModalProps {
visible: boolean;
onClose: () => void;
src: string | null | undefined;
alt?: string;
}
export function FullscreenMediaModal({
visible,
onClose,
src,
alt,
}: FullscreenMediaModalProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("modal");
useEffect(() => {
if (visible) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}
return () => {};
}, [onClose, visible]);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (containerRef.current) {
const clickedOnImage = containerRef.current.contains(e.target as Node);
if (!clickedOnImage) {
onClose();
}
}
};
if (visible) {
window.addEventListener("mousedown", onMouseDown);
}
return () => {
window.removeEventListener("mousedown", onMouseDown);
};
}, [onClose, visible]);
if (!visible || !src) return null;
return createPortal(
<div className="fullscreen-media-modal__overlay">
<dialog className="fullscreen-media-modal" open aria-label={alt}>
<button
type="button"
onClick={onClose}
className="fullscreen-media-modal__close-button"
aria-label={t("close")}
>
<XIcon size={24} />
</button>
<div
ref={containerRef}
className="fullscreen-media-modal__image-container"
>
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
</div>
</dialog>
</div>,
document.body
);
}

View File

@@ -82,7 +82,6 @@ export function Header() {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/notifications")) return headerTitle;
if (location.pathname.startsWith("/library"))
return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results");
@@ -324,8 +323,7 @@ export function Header() {
<SearchDropdown
visible={
isDropdownVisible &&
(searchValue.trim().length > 0 ||
historyItems.length > 0 ||
(historyItems.length > 0 ||
suggestions.length > 0 ||
isLoadingSuggestions)
}

View File

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

View File

@@ -10,7 +10,7 @@ import cn from "classnames";
export interface ModalProps {
visible: boolean;
title: React.ReactNode;
title: string;
description?: string;
onClose: () => void;
large?: boolean;
@@ -115,6 +115,7 @@ export function Modal({
"modal--large": large,
})}
role="dialog"
aria-labelledby={title}
aria-describedby={description}
ref={modalContentRef}
data-hydra-dialog

View File

@@ -5,7 +5,7 @@
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
max-height: 350px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
@@ -92,8 +92,23 @@ export function SearchDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [visible, onClose, searchContainerRef]);
const handleItemClick = useCallback(
(
type: "history" | "suggestion",
item: SearchHistoryEntry | SearchSuggestion
) => {
if (type === "history") {
onSelectHistory((item as SearchHistoryEntry).query);
} else {
onSelectSuggestion(item as SearchSuggestion);
}
},
[onSelectHistory, onSelectSuggestion]
);
if (!visible) return null;
const totalItems = historyItems.length + suggestions.length;
const hasHistory = historyItems.length > 0;
const hasSuggestions = suggestions.length > 0;
@@ -143,7 +158,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("history", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelectHistory(item.query)}
onClick={() => handleItemClick("history", item)}
>
<ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text">
@@ -185,7 +200,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("suggestion", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelectSuggestion(item)}
onClick={() => handleItemClick("suggestion", item)}
>
{item.iconUrl ? (
<img
@@ -212,6 +227,13 @@ export function SearchDropdown({
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div>
)}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div>
);

View File

@@ -32,4 +32,15 @@ export const routes = [
nameKey: "settings",
render: () => <GearIcon />,
},
{
path: "https://hydrawrapped.com",
nameKey: "hydra_2025_wrapped",
render: () => (
<img
src="https://cdn.losbroxas.org/thumbnail_hydra_badge2_fb01af31e3.png"
alt="Hydra 2025 Wrapped"
style={{ width: 16, height: 16 }}
/>
),
},
];

View File

@@ -5,7 +5,6 @@ import cn from "classnames";
import { useLocation } from "react-router-dom";
import { useState } from "react";
import { GameContextMenu } from "..";
import { useAppSelector } from "@renderer/hooks";
interface SidebarGameItemProps {
game: LibraryGame;
@@ -19,9 +18,6 @@ export function SidebarGameItem({
getGameTitle,
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
@@ -85,12 +81,11 @@ export function SidebarGameItem({
{getGameTitle(game)}
</span>
{userPreferences?.enableNewDownloadOptionsBadges !== false &&
(game.newDownloadOptionsCount ?? 0) > 0 && (
<span className="sidebar__game-badge">
+{game.newDownloadOptionsCount}
</span>
)}
{(game.newDownloadOptionsCount ?? 0) > 0 && (
<span className="sidebar__game-badge">
+{game.newDownloadOptionsCount}
</span>
)}
</button>
</li>

View File

@@ -46,7 +46,7 @@
white-space: nowrap;
}
&__notification-button {
&__friends-button {
color: globals.$muted-color;
cursor: pointer;
border-radius: 50%;
@@ -62,7 +62,7 @@
}
}
&__notification-button-badge {
&__friends-button-badge {
background-color: globals.$success-color;
display: flex;
justify-content: center;
@@ -73,8 +73,6 @@
position: absolute;
top: -5px;
right: -5px;
font-size: 10px;
font-weight: bold;
}
&__game-running-icon {

View File

@@ -1,13 +1,12 @@
import { useNavigate } from "react-router-dom";
import { BellIcon } from "@primer/octicons-react";
import { PeopleIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared";
import { logger } from "@renderer/logger";
import type { NotificationCountResponse } from "@types";
import "./sidebar-profile.scss";
export function SidebarProfile() {
@@ -15,75 +14,11 @@ export function SidebarProfile() {
const { t } = useTranslation("sidebar");
const { userDetails } = useUserDetails();
const { userDetails, friendRequestCount, showFriendsModal } =
useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [notificationCount, setNotificationCount] = useState(0);
const fetchNotificationCount = useCallback(async () => {
try {
// Always fetch local notification count
const localCount = await window.electron.getLocalNotificationsCount();
// Fetch API notification count only if logged in
let apiCount = 0;
if (userDetails) {
try {
const response =
await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count",
{ needsAuth: true }
);
apiCount = response.count;
} catch {
// Ignore API errors
}
}
setNotificationCount(localCount + apiCount);
} catch (error) {
logger.error("Failed to fetch notification count", error);
}
}, [userDetails]);
useEffect(() => {
fetchNotificationCount();
const interval = setInterval(fetchNotificationCount, 60000);
return () => clearInterval(interval);
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
useEffect(() => {
const handleNotificationsChange = () => {
fetchNotificationCount();
};
window.addEventListener("notificationsChanged", handleNotificationsChange);
return () => {
window.removeEventListener(
"notificationsChanged",
handleNotificationsChange
);
};
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onSyncNotificationCount(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
const handleProfileClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow(AuthPage.SignIn);
@@ -93,24 +28,28 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails.id}`);
};
const notificationsButton = useMemo(() => {
const friendsButton = useMemo(() => {
if (!userDetails) return null;
return (
<button
type="button"
className="sidebar-profile__notification-button"
onClick={() => navigate("/notifications")}
title={t("notifications")}
className="sidebar-profile__friends-button"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
>
{notificationCount > 0 && (
<small className="sidebar-profile__notification-button-badge">
{notificationCount > 99 ? "99+" : notificationCount}
{friendRequestCount > 0 && (
<small className="sidebar-profile__friends-button-badge">
{friendRequestCount > 99 ? "99+" : friendRequestCount}
</small>
)}
<BellIcon size={16} />
<PeopleIcon size={16} />
</button>
);
}, [t, notificationCount, navigate]);
}, [userDetails, t, friendRequestCount, showFriendsModal]);
const gameRunningDetails = () => {
if (!userDetails || !gameRunning) return null;
@@ -159,7 +98,7 @@ export function SidebarProfile() {
</div>
</button>
{notificationsButton}
{friendsButton}
</div>
);
}

View File

@@ -88,6 +88,34 @@
);
}
}
&--wrapped {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.25) 0%,
rgba(123, 104, 238, 0.2) 25%,
rgba(59, 130, 246, 0.25) 50%,
rgba(96, 165, 250, 0.2) 75%,
rgba(74, 144, 226, 0.25) 100%
);
background-size: 200% 200%;
animation: wrapped-gradient-flow 8s ease infinite;
color: globals.$muted-color;
position: relative;
overflow: hidden;
&:hover {
background: linear-gradient(
135deg,
rgba(74, 144, 226, 0.35) 0%,
rgba(123, 104, 238, 0.3) 25%,
rgba(59, 130, 246, 0.35) 50%,
rgba(96, 165, 250, 0.3) 75%,
rgba(74, 144, 226, 0.35) 100%
);
background-size: 200% 200%;
}
}
}
&__menu-item-button {
@@ -106,6 +134,21 @@
overflow: hidden;
}
&__menu-item-marquee {
overflow: hidden;
flex: 1;
min-width: 0;
}
&__menu-item-marquee-content {
display: inline-flex;
}
&__menu-item-marquee-content span {
display: inline-block;
flex-shrink: 0;
}
&__game-icon {
width: 20px;
height: 20px;
@@ -228,3 +271,24 @@
}
}
}
@keyframes wrapped-gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-50% - 1em));
}
}

View File

@@ -22,6 +22,8 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { logger } from "@renderer/logger";
import { motion } from "framer-motion";
import {
CommentDiscussionIcon,
PlayIcon,
@@ -238,8 +240,32 @@ export function Sidebar() {
return game.title;
};
const handleSidebarItemClick = (path: string) => {
if (path !== location.pathname) {
const handleSidebarItemClick = async (path: string) => {
if (path.startsWith("http")) {
if (path === "https://hydrawrapped.com") {
try {
const auth = await window.electron.getAuth();
if (auth) {
const payload = {
accessToken: auth.accessToken,
refreshToken: auth.refreshToken,
expiresIn: 3600,
};
const base64Payload = btoa(JSON.stringify(payload));
window.electron.openExternal(
`${path}?payload=${encodeURIComponent(base64Payload)}`
);
} else {
window.electron.openExternal(path);
}
} catch (error) {
logger.error("Failed to get auth for wrapped:", error);
window.electron.openExternal(path);
}
} else {
window.electron.openExternal(path);
}
} else if (path !== location.pathname) {
navigate(path);
}
};
@@ -297,7 +323,10 @@ export function Sidebar() {
<li
key={nameKey}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active": location.pathname === path,
"sidebar__menu-item--active":
!path.startsWith("http") && location.pathname === path,
"sidebar__menu-item--wrapped":
nameKey === "hydra_2025_wrapped",
})}
>
<button
@@ -306,7 +335,33 @@ export function Sidebar() {
onClick={() => handleSidebarItemClick(path)}
>
{render()}
<span>{t(nameKey)}</span>
{nameKey === "hydra_2025_wrapped" ? (
<div className="sidebar__menu-item-marquee">
<motion.div
className="sidebar__menu-item-marquee-content"
animate={{
x: ["0%", "-50%"],
}}
transition={{
x: {
repeat: Infinity,
repeatType: "loop",
duration: 8,
ease: "linear",
},
}}
>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
<span>
{t(nameKey)} &nbsp;&nbsp;&nbsp;&nbsp;
</span>
</motion.div>
</div>
) : (
<span>{t(nameKey)}</span>
)}
</button>
</li>
))}

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Harbinger";
export const VERSION_CODENAME = "Supernova";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
@@ -10,11 +10,8 @@ export const DOWNLOADER_NAME = {
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier",
[Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus",
[Downloader.VikingFile]: "VikingFile",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

@@ -14,7 +14,6 @@ import type {
GameStats,
UserDetails,
FriendRequestSync,
NotificationSync,
GameArtifact,
LudusaviBackup,
UserAchievement,
@@ -32,7 +31,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
LocalNotification,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -210,13 +208,6 @@ declare global {
onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer;
onExtractionProgress: (
cb: (shop: GameShop, objectId: string, progress: number) => void
) => () => Electron.IpcRenderer;
onArchiveDeletionPrompt: (
cb: (archivePaths: string[]) => void
) => () => Electron.IpcRenderer;
deleteArchive: (filePath: string) => Promise<boolean>;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
@@ -389,12 +380,10 @@ declare global {
processProfileImage: (
path: string
) => Promise<{ imagePath: string; mimeType: string }>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer;
onSyncNotificationCount: (
cb: (notification: NotificationSync) => void
) => () => Electron.IpcRenderer;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
@@ -402,15 +391,6 @@ declare global {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
getLocalNotifications: () => Promise<LocalNotification[]>;
getLocalNotificationsCount: () => Promise<number>;
markLocalNotificationRead: (id: string) => Promise<void>;
markAllLocalNotificationsRead: () => Promise<void>;
deleteLocalNotification: (id: string) => Promise<void>;
clearAllLocalNotifications: () => Promise<void>;
onLocalNotificationCreated: (
cb: (notification: LocalNotification) => void
) => () => Electron.IpcRenderer;
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,

View File

@@ -1,28 +1,17 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { DownloadProgress, GameShop } from "@types";
export interface ExtractionInfo {
visibleId: string;
progress: number;
}
import type { DownloadProgress } from "@types";
export interface DownloadState {
lastPacket: DownloadProgress | null;
gameId: string | null;
gamesWithDeletionInProgress: string[];
extraction: ExtractionInfo | null;
peakSpeeds: Record<string, number>;
speedHistory: Record<string, number[]>;
}
const initialState: DownloadState = {
lastPacket: null,
gameId: null,
gamesWithDeletionInProgress: [],
extraction: null,
peakSpeeds: {},
speedHistory: {},
};
export const downloadSlice = createSlice({
@@ -32,27 +21,6 @@ export const downloadSlice = createSlice({
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
// Track peak speed and speed history atomically when packet arrives
if (action.payload?.gameId && action.payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = action.payload;
// Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0;
if (downloadSpeed > currentPeak) {
state.peakSpeeds[gameId] = downloadSpeed;
}
// Update speed history for chart
if (!state.speedHistory[gameId]) {
state.speedHistory[gameId] = [];
}
state.speedHistory[gameId].push(downloadSpeed);
// Keep only last 120 entries
if (state.speedHistory[gameId].length > 120) {
state.speedHistory[gameId].shift();
}
}
},
clearDownload: (state) => {
state.lastPacket = null;
@@ -70,37 +38,6 @@ export const downloadSlice = createSlice({
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
},
setExtractionProgress: (
state,
action: PayloadAction<{
shop: GameShop;
objectId: string;
progress: number;
}>
) => {
const { shop, objectId, progress } = action.payload;
state.extraction = {
visibleId: `${shop}:${objectId}`,
progress,
};
},
clearExtraction: (state) => {
state.extraction = null;
},
updatePeakSpeed: (
state,
action: PayloadAction<{ gameId: string; speed: number }>
) => {
const { gameId, speed } = action.payload;
const currentPeak = state.peakSpeeds[gameId] || 0;
if (speed > currentPeak) {
state.peakSpeeds[gameId] = speed;
}
},
clearPeakSpeed: (state, action: PayloadAction<string>) => {
state.peakSpeeds[action.payload] = 0;
state.speedHistory[action.payload] = [];
},
},
});
@@ -109,8 +46,4 @@ export const {
clearDownload,
setGameDeleting,
removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
updatePeakSpeed,
clearPeakSpeed,
} = downloadSlice.actions;

View File

@@ -1,4 +1,5 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types";
export interface UserDetailsState {
@@ -6,6 +7,9 @@ export interface UserDetailsState {
profileBackground: null | string;
friendRequests: FriendRequest[];
friendRequestCount: number;
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
}
const initialState: UserDetailsState = {
@@ -13,6 +17,9 @@ const initialState: UserDetailsState = {
profileBackground: null,
friendRequests: [],
friendRequestCount: 0,
isFriendsModalVisible: false,
friendRequetsModalTab: null,
friendModalUserId: "",
};
export const userDetailsSlice = createSlice({
@@ -31,6 +38,18 @@ export const userDetailsSlice = createSlice({
setFriendRequestCount: (state, action: PayloadAction<number>) => {
state.friendRequestCount = action.payload;
},
setFriendsModalVisible: (
state,
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload.initialTab;
state.friendModalUserId = action.payload.userId;
},
setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false;
state.friendRequetsModalTab = null;
},
},
});
@@ -39,4 +58,6 @@ export const {
setProfileBackground,
setFriendRequests,
setFriendRequestCount,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions;

View File

@@ -4,6 +4,8 @@ import {
setProfileBackground,
setUserDetails,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import type {
FriendRequestAction,
@@ -11,12 +13,20 @@ import type {
UserDetails,
FriendRequest,
} from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() {
const dispatch = useAppDispatch();
const { userDetails, profileBackground, friendRequests, friendRequestCount } =
useAppSelector((state) => state.userDetails);
const {
userDetails,
profileBackground,
friendRequests,
friendRequestCount,
isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
@@ -59,7 +69,6 @@ export function useUserDetails() {
username: userDetails?.username || "",
subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
workwondersJwt: userDetails?.workwondersJwt || "",
karma: userDetails?.karma || 0,
});
},
@@ -76,11 +85,24 @@ export function useUserDetails() {
return window.electron.hydraApi
.get<FriendRequest[]>("/profile/friend-requests")
.then((friendRequests) => {
window.electron.syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests();
},
[dispatch, fetchFriendRequests]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback(
async (userId: string) => {
return window.electron.hydraApi
@@ -112,7 +134,7 @@ export function useUserDetails() {
);
const undoFriendship = (userId: string) =>
window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
window.electron.hydraApi.delete(`/profile/friends/${userId}`);
const blockUser = (userId: string) =>
window.electron.hydraApi.post(`/users/${userId}/block`);
@@ -130,7 +152,12 @@ export function useUserDetails() {
profileBackground,
friendRequests,
friendRequestCount,
friendRequetsModalTab,
isFriendsModalVisible,
friendModalUserId,
hasActiveSubscription,
showFriendsModal,
hideFriendsModal,
fetchUserDetails,
signOut,
clearUserDetails,

View File

@@ -31,7 +31,6 @@ import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log;
@@ -77,7 +76,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />

View File

@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { ConfirmationModal } from "@renderer/components";
interface ArchiveDeletionModalProps {
visible: boolean;
archivePaths: string[];
onClose: () => void;
}
export function ArchiveDeletionModal({
visible,
archivePaths,
onClose,
}: Readonly<ArchiveDeletionModalProps>) {
const { t } = useTranslation("downloads");
const fullFileName =
archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : "";
const maxLength = 40;
const fileName =
fullFileName.length > maxLength
? `${fullFileName.slice(0, maxLength)}`
: fullFileName;
const handleConfirm = async () => {
for (const archivePath of archivePaths) {
await window.electron.deleteArchive(archivePath);
}
onClose();
};
return (
<ConfirmationModal
visible={visible}
title={t("delete_archive_title", { fileName })}
descriptionText={t("delete_archive_description")}
confirmButtonLabel={t("yes")}
cancelButtonLabel={t("no")}
onConfirm={handleConfirm}
onClose={onClose}
/>
);
}

View File

@@ -108,11 +108,16 @@
cursor: pointer;
display: flex;
align-items: center;
transition: scale 0.2s ease;
transition: opacity 0.2s ease;
outline: none;
&:hover {
scale: 1.05;
opacity: 0.8;
}
&:focus,
&:focus-visible {
outline: none;
}
}
@@ -390,21 +395,6 @@
flex-shrink: 0;
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid globals.$border-color;
padding: 0;
cursor: pointer;
transition:
opacity 0.2s ease,
transform 0.2s ease;
&:hover {
opacity: 0.9;
}
&:focus,
&:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
img {
width: 100%;
@@ -421,21 +411,6 @@
gap: calc(globals.$spacing-unit / 1);
}
&__simple-title-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
width: 100%;
transition: opacity 0.2s ease;
&:focus,
&:focus-visible {
outline: none;
}
}
&__simple-title {
font-size: 16px;
font-weight: 600;
@@ -536,9 +511,5 @@
background-color: #fff;
transition: width 0.3s ease;
border-radius: 4px;
&--extraction {
background-color: #fff;
}
}
}

View File

@@ -128,20 +128,16 @@ function SpeedChart({
g = 255,
b = 255;
if (color.startsWith("#")) {
let hex = color.replace("#", "");
// Handle shorthand hex colors (e.g., "#fff" -> "#ffffff")
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
r = Number.parseInt(hex.substring(0, 2), 16) || 255;
g = Number.parseInt(hex.substring(2, 4), 16) || 255;
b = Number.parseInt(hex.substring(4, 6), 16) || 255;
const hex = color.replace("#", "");
r = Number.parseInt(hex.substring(0, 2), 16);
g = Number.parseInt(hex.substring(2, 4), 16);
b = Number.parseInt(hex.substring(4, 6), 16);
} else if (color.startsWith("rgb")) {
const matches = color.match(/\d+/g);
if (matches && matches.length >= 3) {
r = Number.parseInt(matches[0]) || 255;
g = Number.parseInt(matches[1]) || 255;
b = Number.parseInt(matches[2]) || 255;
r = Number.parseInt(matches[0]);
g = Number.parseInt(matches[1]);
b = Number.parseInt(matches[2]);
}
}
const displaySpeeds = speeds.slice(-totalBars);
@@ -207,7 +203,6 @@ function SpeedChart({
interface HeroDownloadViewProps {
game: LibraryGame;
isGameDownloading: boolean;
isGameExtracting?: boolean;
downloadSpeed: number;
finalDownloadSize: string;
peakSpeed: number;
@@ -226,7 +221,6 @@ interface HeroDownloadViewProps {
function HeroDownloadView({
game,
isGameDownloading,
isGameExtracting = false,
downloadSpeed,
finalDownloadSize,
peakSpeed,
@@ -284,17 +278,11 @@ function HeroDownloadView({
<div className="download-group__progress-row download-group__progress-row--bar">
<div className="download-group__progress-wrapper">
<div className="download-group__progress-info-row">
{isGameExtracting && (
<span className="download-group__progress-status">
{t("extracting")}
</span>
)}
{!isGameExtracting && lastPacket?.isCheckingFiles && (
{lastPacket?.isCheckingFiles ? (
<span className="download-group__progress-status">
{t("checking_files")}
</span>
)}
{!isGameExtracting && !lastPacket?.isCheckingFiles && (
) : (
<span className="download-group__progress-size">
<DownloadIcon size={14} />
{isGameDownloading && lastPacket
@@ -305,7 +293,7 @@ function HeroDownloadView({
<span></span>
</div>
<div className="download-group__progress-info-row">
{!lastPacket?.isCheckingFiles && !isGameExtracting && (
{!lastPacket?.isCheckingFiles && (
<span className="download-group__progress-time">
{isGameDownloading &&
lastPacket?.timeRemaining &&
@@ -317,50 +305,50 @@ function HeroDownloadView({
)}
</span>
)}
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
{(!lastPacket?.isCheckingFiles || currentProgress > 0) && (
<span className="download-group__progress-percentage">
<AnimatedPercentage value={currentProgress} />
</span>
)}
</div>
<div className="download-group__progress-bar">
<div
className={`download-group__progress-fill ${isGameExtracting ? "download-group__progress-fill--extraction" : ""}`}
className="download-group__progress-fill"
style={{
width: `${currentProgress * 100}%`,
}}
/>
</div>
</div>
{!isGameExtracting && (
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<ColumnsIcon size={14} />
{t("pause")}
</button>
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
<ColumnsIcon size={14} />
{t("pause")}
</button>
</div>
)}
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
</button>
</div>
</div>
</div>
@@ -372,7 +360,7 @@ function HeroDownloadView({
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">
{t("network")}:
{t("network")}
</span>
<span className="download-group__stat-value">
{isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"}
@@ -385,39 +373,38 @@ function HeroDownloadView({
<GraphIcon size={16} />
</span>
<div className="download-group__stat-content">
<span className="download-group__stat-label">{t("peak")}:</span>
<span className="download-group__stat-label">{t("peak")}</span>
<span className="download-group__stat-value">
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
</span>
</div>
</div>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<span className="download-group__stat-label">
Seeds:{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, Peers:{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
</div>
</div>
)}
{game.download?.downloader !== undefined && (
{game.download?.downloader && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<Badge>
{DOWNLOADER_NAME[Number(game.download.downloader)]}
</Badge>
<div
className="download-group__stat-content"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
{game.download?.downloader === Downloader.Torrent &&
isGameDownloading &&
lastPacket &&
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
<span className="download-group__stat-label">
{t("seeds")}{" "}
<span className="download-group__stat-value">
{lastPacket.numSeeds}
</span>
, {t("peers")}{" "}
<span className="download-group__stat-value">
{lastPacket.numPeers}
</span>
</span>
)}
</div>
</div>
)}
@@ -458,8 +445,6 @@ export function DownloadGroup({
(state) => state.userPreferences.value
);
const extraction = useAppSelector((state) => state.download.extraction);
const { updateLibrary } = useLibrary();
const {
@@ -514,9 +499,8 @@ export function DownloadGroup({
const { formatDistance } = useDate();
// Get speed history and peak speeds from Redux (centralized state)
const speedHistory = useAppSelector((state) => state.download.speedHistory);
const peakSpeeds = useAppSelector((state) => state.download.peakSpeeds);
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
const speedHistoryRef = useRef<Record<string, number[]>>({});
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
{}
);
@@ -579,8 +563,68 @@ export function DownloadGroup({
});
}, [library, lastPacket?.gameId]);
// Speed history and peak speeds are now tracked in Redux (in setLastPacket reducer)
// No local effect needed - data is updated atomically when packets arrive
useEffect(() => {
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
const gameId = lastPacket.gameId;
const currentPeak = peakSpeeds[gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
setPeakSpeeds((prev) => ({
...prev,
[gameId]: lastPacket.downloadSpeed,
}));
}
if (!speedHistoryRef.current[gameId]) {
speedHistoryRef.current[gameId] = [];
}
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
if (speedHistoryRef.current[gameId].length > 120) {
speedHistoryRef.current[gameId].shift();
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
useEffect(() => {
for (const game of library) {
if (
game.download &&
game.download.progress < 0.01 &&
game.download.status !== "paused"
) {
// Fresh download - clear any old data
if (speedHistoryRef.current[game.id]?.length > 0) {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}
}
}
}, [library]);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
for (const game of library) {
if (
game.download?.progress === 1 &&
speedHistoryRef.current[game.id]?.length > 0
) {
const timeout = setTimeout(() => {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}, 10_000);
timeouts.push(timeout);
}
}
return () => {
for (const timeout of timeouts) {
clearTimeout(timeout);
}
};
}, [library]);
useEffect(() => {
if (library.length > 0 && title === t("download_in_progress")) {
@@ -778,28 +822,16 @@ export function DownloadGroup({
if (isDownloadingGroup && library.length > 0) {
const game = library[0];
const isGameExtracting = extraction?.visibleId === game.id;
const isGameDownloading =
isGameDownloadingMap[game.id] && !isGameExtracting;
const isGameDownloading = isGameDownloadingMap[game.id];
const downloadSpeed = isGameDownloading
? (lastPacket?.downloadSpeed ?? 0)
: 0;
const finalDownloadSize = getFinalDownloadSize(game);
// Use lastPacket.gameId for lookup since that's the key used to store the data
// Fall back to game.id if lastPacket is not available
const dataKey = lastPacket?.gameId ?? game.id;
const gameSpeedHistory = speedHistory[dataKey] ?? [];
const storedPeak = peakSpeeds[dataKey];
// Use stored peak if available and > 0, otherwise use current speed as initial value
const peakSpeed =
storedPeak !== undefined && storedPeak > 0 ? storedPeak : downloadSpeed;
let currentProgress = game.download?.progress || 0;
if (isGameExtracting) {
currentProgress = extraction.progress;
} else if (isGameDownloading && lastPacket) {
currentProgress = lastPacket.progress;
}
const peakSpeed = peakSpeeds[game.id] || 0;
const currentProgress =
isGameDownloading && lastPacket
? lastPacket.progress
: game.download?.progress || 0;
const dominantColor = dominantColors[game.id] || "#fff";
@@ -807,14 +839,13 @@ export function DownloadGroup({
<HeroDownloadView
game={game}
isGameDownloading={isGameDownloading}
isGameExtracting={isGameExtracting}
downloadSpeed={downloadSpeed}
finalDownloadSize={finalDownloadSize}
peakSpeed={peakSpeed}
currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={gameSpeedHistory}
speedHistory={speedHistoryRef.current[game.id] || []}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
@@ -842,8 +873,14 @@ export function DownloadGroup({
<li key={game.id} className="download-group__simple-card">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
onClick={() => navigate(buildGameDetailsPath(game))}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
}}
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</button>
@@ -851,22 +888,27 @@ export function DownloadGroup({
<div className="download-group__simple-info">
<button
type="button"
className="download-group__simple-title"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button"
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
textAlign: "left",
width: "100%",
}}
>
<h3 className="download-group__simple-title">{game.title}</h3>
{game.title}
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
</Badge>
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
</div>
<div className="download-group__simple-meta-row">
{extraction?.visibleId === game.id ? (
{game.download?.extracting ? (
<span className="download-group__simple-extracting">
{t("extracting")} (
{Math.round(extraction.progress * 100)}%)
{t("extracting")}
</span>
) : (
<span className="download-group__simple-size">

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
@@ -13,7 +13,6 @@ import { ArrowDownIcon } from "@primer/octicons-react";
export default function Downloads() {
const { library, updateLibrary } = useLibrary();
const extraction = useAppSelector((state) => state.download.extraction);
const { t } = useTranslation("downloads");
@@ -40,13 +39,11 @@ export default function Downloads() {
useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
const unsubscribeExtraction = window.electron.onExtractionComplete(() => {
const unsubscribe = window.electron.onExtractionComplete(() => {
updateLibrary();
});
return () => {
unsubscribeExtraction();
};
return () => unsubscribe();
}, [updateLibrary]);
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
@@ -75,10 +72,8 @@ export default function Downloads() {
/* Game has been manually added to the library */
if (!next.download) return prev;
/* Is downloading or extracting */
const isExtracting =
next.download.extracting || extraction?.visibleId === next.id;
if (lastPacket?.gameId === next.id || isExtracting)
/* Is downloading */
if (lastPacket?.gameId === next.id || next.download.extracting)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
@@ -101,7 +96,7 @@ export default function Downloads() {
queued,
complete,
};
}, [library, lastPacket?.gameId, extraction?.visibleId]);
}, [library, lastPacket?.gameId]);
const downloadGroups = [
{

View File

@@ -1,7 +1,6 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
@@ -56,8 +55,6 @@ const getImageWithCustomPriority = (
export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const [searchParams] = useSearchParams();
const reviewsRef = useRef<HTMLDivElement>(null);
const {
objectId,
@@ -140,16 +137,6 @@ export function GameDetailsContent() {
getGameArtifacts();
}, [getGameArtifacts]);
// Scroll to reviews section if reviews=true in URL
useEffect(() => {
const shouldScrollToReviews = searchParams.get("reviews") === "true";
if (shouldScrollToReviews && reviewsRef.current) {
setTimeout(() => {
reviewsRef.current?.scrollIntoView({ behavior: "smooth" });
}, 500);
}
}, [searchParams, objectId]);
const isCustomGame = game?.shop === "custom";
const heroImage = isCustomGame
@@ -242,17 +229,15 @@ export function GameDetailsContent() {
)}
{shop !== "custom" && shop && objectId && (
<div ref={reviewsRef}>
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
</div>
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
)}
</div>

View File

@@ -1,12 +1,7 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDownloadProgress } from "@renderer/helpers";
import {
useAppSelector,
useDate,
useDownload,
useFormat,
} from "@renderer/hooks";
import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
@@ -22,9 +17,6 @@ export function HeroPanelPlaytime() {
const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload();
const { formatDistance } = useDate();
const extraction = useAppSelector((state) => state.download.extraction);
const isExtracting = extraction?.visibleId === game?.id;
useEffect(() => {
if (game?.lastTimePlayed) {
@@ -60,16 +52,6 @@ export function HeroPanelPlaytime() {
const isGameDownloading =
game.download?.status === "active" && lastPacket?.gameId === game.id;
const extractionInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
{t("extracting")}
</Link>
<small>{formatDownloadProgress(extraction?.progress ?? 0)}</small>
</div>
);
const downloadInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
@@ -90,8 +72,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("not_played_yet", { title: game?.title })}</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{hasDownload && downloadInProgressInfo}
</>
);
}
@@ -100,8 +81,7 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("playing_now")}</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{hasDownload && downloadInProgressInfo}
</>
);
}
@@ -133,9 +113,9 @@ export function HeroPanelPlaytime() {
})}
</p>
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{!isExtracting && !hasDownload && (
{hasDownload ? (
downloadInProgressInfo
) : (
<p>
{t("last_time_played", {
period: lastTimePlayed,

View File

@@ -80,11 +80,5 @@
&--disabled {
opacity: globals.$disabled-opacity;
}
&--extraction {
&::-webkit-progress-value {
background-color: #fff;
}
}
}
}

View File

@@ -1,7 +1,7 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector, useDate, useDownload } from "@renderer/hooks";
import { useDate, useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
@@ -18,13 +18,9 @@ export function HeroPanel() {
const { lastPacket } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const isGameDownloading =
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
const isExtracting = extraction?.visibleId === game?.id;
const getInfo = () => {
if (!game) {
const [latestRepack] = repacks;
@@ -53,8 +49,6 @@ export function HeroPanel() {
(game?.download?.status === "active" && game?.download?.progress < 1) ||
game?.download?.status === "paused";
const showExtractionProgressBar = isExtracting;
return (
<div className="hero-panel__container">
<div className="hero-panel">
@@ -78,14 +72,6 @@ export function HeroPanel() {
}`}
/>
)}
{showExtractionProgressBar && (
<progress
max={1}
value={extraction?.progress ?? 0}
className="hero-panel__progress-bar hero-panel__progress-bar--extraction"
/>
)}
</div>
</div>
);

View File

@@ -19,173 +19,23 @@
color: globals.$body-color;
}
&__downloaders-list-wrapper {
border: 1px solid globals.$border-color;
overflow: hidden;
background-color: globals.$dark-background-color;
&__downloaders {
display: grid;
gap: globals.$spacing-unit;
grid-template-columns: repeat(2, 1fr);
}
&__downloaders-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&__downloader-option {
position: relative;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
&:only-child {
grid-column: 1 / -1;
}
}
&__downloader-item {
display: flex;
align-items: center;
gap: 8px;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
border: 1px solid transparent;
border-bottom: 1px solid globals.$border-color;
border-radius: 0;
background-color: transparent;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
color: globals.$body-color;
font-size: 14px;
text-align: left;
height: 48px;
box-sizing: border-box;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&--selected {
background-color: rgba(255, 255, 255, 0.08);
}
&--last {
border-bottom: none;
}
&:disabled {
cursor: default;
&:hover {
background-color: transparent;
}
.download-settings-modal__downloader-name {
opacity: 0.5;
}
.download-settings-modal__availability-indicator-wrapper {
opacity: 0.5;
}
}
}
&__downloader-item-wrapper {
display: flex;
flex-direction: column;
}
&__check-icon {
color: white;
flex-shrink: 0;
}
&__check-icon-wrapper {
margin-left: auto;
display: flex;
align-items: center;
width: 20px;
height: 20px;
justify-content: center;
flex-shrink: 0;
}
&__recommendation-badge {
margin-left: auto;
display: flex;
align-items: center;
height: 20px;
justify-content: center;
flex-shrink: 0;
.badge {
padding: 2px 6px;
font-size: 10px;
line-height: 1.2;
height: 16px;
display: flex;
align-items: center;
white-space: nowrap;
}
}
&__availability-indicator-wrapper {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__availability-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--available {
background-color: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
}
&--unavailable {
background-color: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
}
&--not-present {
background-color: #6b7280;
box-shadow: 0 0 6px rgba(107, 114, 128, 0.5);
}
&--warning {
background-color: #eab308;
box-shadow: 0 0 6px rgba(234, 179, 8, 0.5);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
&__availability-indicator--pulsating {
animation: pulse 2s ease-in-out infinite;
&__downloader-icon {
position: absolute;
left: calc(globals.$spacing-unit * 2);
}
&__path-error {
@@ -199,17 +49,4 @@
&__change-path-button {
align-self: flex-end;
}
&__loading-spinner {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,25 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
Badge,
Button,
CheckboxField,
Link,
Modal,
TextField,
} from "@renderer/components";
import {
DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
import { motion } from "framer-motion";
import { Tooltip } from "react-tooltip";
import { RealDebridInfoModal } from "./real-debrid-info-modal";
import "./download-settings-modal.scss";
export interface DownloadSettingsModalProps {
@@ -59,7 +51,6 @@ export function DownloadSettingsModal({
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
null
);
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
const { isFeatureEnabled, Feature } = useFeature();
@@ -87,89 +78,18 @@ export function DownloadSettingsModal({
}
}, [visible, checkFolderWritePermission, selectedPath]);
const downloadOptions = useMemo(() => {
const unavailableUrisSet = new Set(repack?.unavailableUris ?? []);
const downloaderMap = new Map<
Downloader,
{ hasAvailable: boolean; hasUnavailable: boolean }
>();
if (repack) {
for (const uri of repack.uris) {
const uriDownloaders = getDownloadersForUri(uri);
const isAvailable = !unavailableUrisSet.has(uri);
for (const downloader of uriDownloaders) {
const existing = downloaderMap.get(downloader);
if (existing) {
existing.hasAvailable = existing.hasAvailable || isAvailable;
existing.hasUnavailable = existing.hasUnavailable || !isAvailable;
} else {
downloaderMap.set(downloader, {
hasAvailable: isAvailable,
hasUnavailable: !isAvailable,
});
}
}
}
}
const allDownloaders = Object.values(Downloader).filter(
(value) => typeof value === "number"
) as Downloader[];
const getDownloaderPriority = (option: {
isAvailable: boolean;
canHandle: boolean;
isAvailableButNotConfigured: boolean;
}) => {
if (option.isAvailable) return 0;
if (option.canHandle && !option.isAvailableButNotConfigured) return 1;
if (option.isAvailableButNotConfigured) return 2;
return 3;
};
return allDownloaders
.filter((downloader) => downloader !== Downloader.Hydra) // Temporarily comment out Nimbus
.map((downloader) => {
const status = downloaderMap.get(downloader);
const canHandle = status !== undefined;
const isAvailable = status?.hasAvailable ?? false;
let isConfigured = true;
if (downloader === Downloader.RealDebrid) {
isConfigured = !!userPreferences?.realDebridApiToken;
} else if (downloader === Downloader.TorBox) {
isConfigured = !!userPreferences?.torBoxApiToken;
}
// } else if (downloader === Downloader.Hydra) {
// isConfigured = isFeatureEnabled(Feature.Nimbus);
// }
const isAvailableButNotConfigured =
isAvailable && !isConfigured && canHandle;
return {
downloader,
isAvailable: isAvailable && isConfigured,
canHandle,
isAvailableButNotConfigured,
};
})
.sort((a, b) => getDownloaderPriority(a) - getDownloaderPriority(b));
}, [
repack,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
isFeatureEnabled,
Feature,
]);
const downloaders = useMemo(() => {
return getDownloadersForUris(repack?.uris ?? []);
}, [repack?.uris]);
const getDefaultDownloader = useCallback(
(availableDownloaders: Downloader[]) => {
if (availableDownloaders.length === 0) return null;
if (availableDownloaders.includes(Downloader.Hydra)) {
return Downloader.Hydra;
}
if (availableDownloaders.includes(Downloader.RealDebrid)) {
return Downloader.RealDebrid;
}
@@ -192,12 +112,26 @@ export function DownloadSettingsModal({
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
}
const availableDownloaders = downloadOptions
.filter((option) => option.isAvailable)
.map((option) => option.downloader);
const filteredDownloaders = downloaders.filter((downloader) => {
if (downloader === Downloader.RealDebrid)
return userPreferences?.realDebridApiToken;
if (downloader === Downloader.TorBox)
return userPreferences?.torBoxApiToken;
if (downloader === Downloader.Hydra)
return isFeatureEnabled(Feature.Nimbus);
return true;
});
setSelectedDownloader(getDefaultDownloader(availableDownloaders));
}, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
}, [
Feature,
isFeatureEnabled,
getDefaultDownloader,
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
@@ -252,144 +186,33 @@ export function DownloadSettingsModal({
<div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders-list-wrapper">
<div className="download-settings-modal__downloaders-list">
{downloadOptions.map((option, index) => {
const isSelected = selectedDownloader === option.downloader;
const tooltipId = `availability-indicator-${option.downloader}`;
const isLastItem = index === downloadOptions.length - 1;
<div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => {
const shouldDisableButton =
(downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken) ||
(downloader === Downloader.Hydra &&
!isFeatureEnabled(Feature.Nimbus));
const Indicator = option.isAvailable ? motion.span : "span";
const isDisabled =
!option.canHandle ||
(!option.isAvailable && !option.isAvailableButNotConfigured);
const getAvailabilityIndicator = () => {
if (option.isAvailable) {
return (
<Indicator
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--available download-settings-modal__availability-indicator--pulsating`}
animate={{
scale: [1, 1.1, 1],
opacity: [1, 0.7, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_online")}
/>
);
return (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
}
if (option.isAvailableButNotConfigured) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--warning`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_configured")}
/>
);
}
if (option.canHandle) {
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--unavailable`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_offline")}
/>
);
}
return (
<span
className={`download-settings-modal__availability-indicator download-settings-modal__availability-indicator--not-present`}
data-tooltip-id={tooltipId}
data-tooltip-content={t("downloader_not_available")}
/>
);
};
const getRightContent = () => {
if (isSelected) {
return (
<motion.div
className="download-settings-modal__check-icon-wrapper"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<CheckCircleFillIcon
size={16}
className="download-settings-modal__check-icon"
/>
</motion.div>
);
}
if (
option.downloader === Downloader.RealDebrid &&
option.canHandle
) {
return (
<div className="download-settings-modal__recommendation-badge">
<Badge>{t("recommended")}</Badge>
</div>
);
}
return null;
};
return (
<div
key={option.downloader}
className="download-settings-modal__downloader-item-wrapper"
>
<button
type="button"
className={`download-settings-modal__downloader-item ${
isSelected
? "download-settings-modal__downloader-item--selected"
: ""
} ${
isLastItem
? "download-settings-modal__downloader-item--last"
: ""
}`}
disabled={isDisabled}
onClick={() => {
if (
option.downloader === Downloader.RealDebrid &&
option.isAvailableButNotConfigured
) {
setShowRealDebridModal(true);
} else {
setSelectedDownloader(option.downloader);
}
}}
>
<span className="download-settings-modal__downloader-name">
{DOWNLOADER_NAME[option.downloader]}
</span>
<div className="download-settings-modal__availability-indicator-wrapper">
{getAvailabilityIndicator()}
</div>
<Tooltip id={tooltipId} />
{getRightContent()}
</button>
</div>
);
})}
</div>
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
);
})}
</div>
</div>
@@ -441,34 +264,13 @@ export function DownloadSettingsModal({
disabled={
downloadStarting ||
selectedDownloader === null ||
!hasWritePermission ||
downloadOptions.some(
(option) =>
option.downloader === selectedDownloader &&
(option.isAvailableButNotConfigured ||
(!option.isAvailable && option.canHandle) ||
!option.canHandle)
)
!hasWritePermission
}
>
{downloadStarting ? (
<>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon />
{t("download_now")}
</>
)}
<DownloadIcon />
{t("download_now")}
</Button>
</div>
<RealDebridInfoModal
visible={showRealDebridModal}
onClose={() => setShowRealDebridModal(false)}
/>
</Modal>
);
}

View File

@@ -1,36 +0,0 @@
@use "../../../scss/globals.scss";
.real-debrid-info-modal {
&__content {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2.5);
width: 100%;
max-width: 500px;
}
&__description-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__description {
margin: 0;
color: globals.$body-color;
line-height: 1.6;
}
&__create-account {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
color: #c0c1c7;
text-decoration: underline;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,58 +0,0 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button, Link, Modal } from "@renderer/components";
import { LinkExternalIcon } from "@primer/octicons-react";
import "./real-debrid-info-modal.scss";
const realDebridReferralId = import.meta.env
.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID;
const REAL_DEBRID_URL = realDebridReferralId
? `https://real-debrid.com/?id=${realDebridReferralId}`
: "https://real-debrid.com";
export interface RealDebridInfoModalProps {
visible: boolean;
onClose: () => void;
}
export function RealDebridInfoModal({
visible,
onClose,
}: Readonly<RealDebridInfoModalProps>) {
const { t } = useTranslation("game_details");
const { t: tSettings } = useTranslation("settings");
const navigate = useNavigate();
return (
<Modal
visible={visible}
title={tSettings("enable_real_debrid")}
onClose={onClose}
>
<div className="real-debrid-info-modal__content">
<div className="real-debrid-info-modal__description-container">
<p className="real-debrid-info-modal__description">
{tSettings("real_debrid_description")}
</p>
<Link
to={REAL_DEBRID_URL}
className="real-debrid-info-modal__create-account"
>
<LinkExternalIcon />
{tSettings("create_real_debrid_account")}
</Link>
</div>
<Button
onClick={() => {
onClose();
navigate("/settings?tab=4");
}}
>
{t("go_to_settings")}
</Button>
</div>
</Modal>
);
}

View File

@@ -21,12 +21,7 @@ import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
import {
useDate,
useFeature,
useAppDispatch,
useAppSelector,
} from "@renderer/hooks";
import { useDate, useFeature, useAppDispatch } from "@renderer/hooks";
import { clearNewDownloadOptions } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { getGameKey } from "@renderer/helpers";
@@ -75,9 +70,6 @@ export function RepacksModal({
const { formatDate } = useDate();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") {
@@ -137,12 +129,10 @@ export function RepacksModal({
}
};
if (visible && userPreferences?.enableNewDownloadOptionsBadges !== false) {
if (visible) {
fetchLastCheckTimestamp();
} else {
setIsLoadingTimestamp(false);
}
}, [visible, repacks, userPreferences?.enableNewDownloadOptionsBadges]);
}, [visible, repacks]);
useEffect(() => {
if (
@@ -373,13 +363,11 @@ export function RepacksModal({
>
<p className="repacks-modal__repack-title">
{repack.title}
{userPreferences?.enableNewDownloadOptionsBadges !==
false &&
isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
{isNewRepack(repack) && (
<span className="repacks-modal__new-badge">
{t("new_download_option")}
</span>
)}
</p>
{isLastDownloadedOption && (

View File

@@ -221,26 +221,6 @@
left: 0;
z-index: 0;
}
&__cover-placeholder {
position: relative;
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.04) 50%,
rgba(255, 255, 255, 0.08) 100%
);
border-radius: 4px;
color: rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 0;
}
}
@keyframes pulse {

View File

@@ -1,12 +1,7 @@
import { LibraryGame } from "@types";
import { useGameCard } from "@renderer/hooks";
import { memo, useState } from "react";
import {
ClockIcon,
AlertFillIcon,
TrophyIcon,
ImageIcon,
} from "@primer/octicons-react";
import { memo } from "react";
import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react";
import "./library-game-card.scss";
interface LibraryGameCardProps {
@@ -30,9 +25,14 @@ export const LibraryGameCard = memo(function LibraryGameCard({
const { formatPlayTime, handleCardClick, handleContextMenuClick } =
useGameCard(game, onContextMenu);
const coverImage = game.coverImageUrl?.replaceAll("\\", "/") ?? "";
const [imageError, setImageError] = useState(false);
const coverImage = (
game.customIconUrl ??
game.coverImageUrl ??
game.libraryImageUrl ??
game.libraryHeroImageUrl ??
game.iconUrl ??
""
).replaceAll("\\", "/");
return (
<button
@@ -98,19 +98,12 @@ export const LibraryGameCard = memo(function LibraryGameCard({
)}
</div>
{imageError || !coverImage ? (
<div className="library-game-card__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img
src={coverImage}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
onError={() => setImageError(true)}
/>
)}
<img
src={coverImage ?? undefined}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
/>
</button>
);
});

View File

@@ -1,103 +0,0 @@
import { useCallback } from "react";
import {
XIcon,
DownloadIcon,
PackageIcon,
SyncIcon,
TrophyIcon,
ClockIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDate } from "@renderer/hooks";
import cn from "classnames";
import type { LocalNotification } from "@types";
import "./notification-item.scss";
interface LocalNotificationItemProps {
notification: LocalNotification;
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
}
export function LocalNotificationItem({
notification,
onDismiss,
onMarkAsRead,
}: Readonly<LocalNotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(notification.url);
}
}, [notification, onMarkAsRead, navigate]);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getIcon = () => {
switch (notification.type) {
case "DOWNLOAD_COMPLETE":
return <DownloadIcon size={24} />;
case "EXTRACTION_COMPLETE":
return <PackageIcon size={24} />;
case "UPDATE_AVAILABLE":
return <SyncIcon size={24} />;
case "ACHIEVEMENT_UNLOCKED":
return <TrophyIcon size={24} />;
default:
return <DownloadIcon size={24} />;
}
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread": !notification.isRead,
})}
onClick={handleClick}
>
<div className="notification-item__picture">
{notification.pictureUrl ? (
<img src={notification.pictureUrl} alt="" />
) : (
getIcon()
)}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{notification.title}</span>
<span className="notification-item__description">
{notification.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
</button>
);
}

View File

@@ -1,127 +0,0 @@
@use "../../scss/globals.scss";
.notification-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
transition: all ease 0.2s;
position: relative;
opacity: 0.4;
width: 100%;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
opacity: 0.6;
}
&--unread {
border-left: 3px solid globals.$brand-teal;
opacity: 1;
&:hover {
opacity: 1;
}
.notification-item__title {
color: #fff;
}
}
&__picture {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
color: #fff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
svg {
color: inherit;
}
}
&__badge-picture {
border-radius: 8px;
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__review-picture {
color: #f5a623;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__title {
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
&__description {
font-size: globals.$small-font-size;
color: globals.$body-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-size: globals.$small-font-size;
color: rgba(255, 255, 255, 0.5);
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
flex-shrink: 0;
}
&__dismiss {
position: absolute;
top: calc(globals.$spacing-unit / 2);
right: calc(globals.$spacing-unit / 2);
background: transparent;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 50%;
transition: all ease 0.2s;
opacity: 0.5;
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
}
}

View File

@@ -1,228 +0,0 @@
import { useCallback, useMemo } from "react";
import {
XIcon,
PersonIcon,
ClockIcon,
StarFillIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@renderer/components";
import { useDate, useUserDetails } from "@renderer/hooks";
import cn from "classnames";
import type { Notification, Badge } from "@types";
import "./notification-item.scss";
const parseNotificationUrl = (notificationUrl: string): string => {
const url = new URL(notificationUrl, "http://localhost");
const userId = url.searchParams.get("userId");
const badgeName = url.searchParams.get("name");
const gameTitle = url.searchParams.get("title");
const showReviews = url.searchParams.get("reviews");
if (url.pathname === "/profile" && userId) {
return `/profile/${userId}`;
}
if (url.pathname === "/badges" && badgeName) {
return `/badges/${badgeName}`;
}
if (url.pathname.startsWith("/game/")) {
const params = new URLSearchParams();
if (gameTitle) params.set("title", gameTitle);
if (showReviews) params.set("reviews", showReviews);
const queryString = params.toString();
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
}
return notificationUrl;
};
interface NotificationItemProps {
notification: Notification;
badges: Badge[];
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
onAcceptFriendRequest?: (senderId: string) => void;
onRefuseFriendRequest?: (senderId: string) => void;
}
export function NotificationItem({
notification,
badges,
onDismiss,
onMarkAsRead,
onAcceptFriendRequest,
onRefuseFriendRequest,
}: Readonly<NotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const { updateFriendRequestState } = useUserDetails();
const badge = useMemo(() => {
if (notification.type !== "BADGE_RECEIVED") return null;
return badges.find((b) => b.name === notification.variables.badgeName);
}, [notification, badges]);
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(parseNotificationUrl(notification.url));
}
}, [notification, onMarkAsRead, navigate]);
const handleAccept = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "ACCEPTED");
onAcceptFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onAcceptFriendRequest, onDismiss]
);
const handleRefuse = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "REFUSED");
onRefuseFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onRefuseFriendRequest, onDismiss]
);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getNotificationContent = () => {
switch (notification.type) {
case "FRIEND_REQUEST_RECEIVED":
return {
title: t("friend_request_received_title"),
description: t("friend_request_received_description", {
displayName: notification.variables.senderDisplayName,
}),
showActions: true,
};
case "FRIEND_REQUEST_ACCEPTED":
return {
title: t("friend_request_accepted_title"),
description: t("friend_request_accepted_description", {
displayName: notification.variables.accepterDisplayName,
}),
showActions: false,
};
case "BADGE_RECEIVED":
return {
title: t("badge_received_title"),
description: badge?.description || notification.variables.badgeName,
showActions: false,
};
case "REVIEW_UPVOTE":
return {
title: t("review_upvote_title", {
gameTitle: notification.variables.gameTitle,
}),
description: t("review_upvote_description", {
count: Number.parseInt(
notification.variables.upvoteCount || "1",
10
),
}),
showActions: false,
};
default:
return {
title: t("notification"),
description: "",
showActions: false,
};
}
};
const content = getNotificationContent();
const isBadge = notification.type === "BADGE_RECEIVED";
const isReview = notification.type === "REVIEW_UPVOTE";
const getIcon = () => {
if (notification.pictureUrl) {
return <img src={notification.pictureUrl} alt="" />;
}
if (isReview) {
return <StarFillIcon size={24} />;
}
return <PersonIcon size={24} />;
};
return (
<button
type="button"
className={cn("notification-item", {
"notification-item--unread":
!notification.isRead ||
notification.type === "FRIEND_REQUEST_RECEIVED",
})}
onClick={handleClick}
>
<div
className={cn("notification-item__picture", {
"notification-item__badge-picture": isBadge,
"notification-item__review-picture": isReview,
})}
>
{getIcon()}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{content.title}</span>
<span className="notification-item__description">
{content.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
{content.showActions &&
notification.type === "FRIEND_REQUEST_RECEIVED" && (
<div className="notification-item__actions">
<Button theme="primary" onClick={handleAccept}>
{t("accept")}
</Button>
<Button theme="outline" onClick={handleRefuse}>
{t("refuse")}
</Button>
</div>
)}
{notification.type !== "FRIEND_REQUEST_RECEIVED" && (
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
)}
</button>
);
}

View File

@@ -1,138 +0,0 @@
@use "../../scss/globals.scss";
.notifications {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 800px;
margin: 0 auto;
min-height: calc(100vh - 200px);
&__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
&__filter-tabs {
display: flex;
gap: globals.$spacing-unit;
position: relative;
flex: 1;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__tab-wrapper {
position: relative;
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&--active {
color: white;
}
}
&__tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 20px;
}
&__tab-underline {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: white;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
justify-content: flex-end;
}
&__content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__empty {
display: flex;
flex: 1;
width: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__empty-filter {
display: flex;
justify-content: center;
align-items: center;
padding: calc(globals.$spacing-unit * 6);
color: globals.$body-color;
}
&__icon-container {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__loading {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
}
&__load-more {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -1,554 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@renderer/components";
import { useAppDispatch, useToast, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { logger } from "@renderer/logger";
import { NotificationItem } from "./notification-item";
import { LocalNotificationItem } from "./local-notification-item";
import type {
Notification,
LocalNotification,
NotificationsResponse,
MergedNotification,
Badge,
} from "@types";
import "./notifications.scss";
type NotificationFilter = "all" | "unread";
const STAGGER_DELAY_MS = 70;
const EXIT_DURATION_MS = 250;
export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast();
const { userDetails } = useUserDetails();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setHeaderTitle(t("title")));
}, [dispatch, t]);
const [apiNotifications, setApiNotifications] = useState<Notification[]>([]);
const [localNotifications, setLocalNotifications] = useState<
LocalNotification[]
>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isClearing, setIsClearing] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
skip: 0,
});
const clearingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const fetchLocalNotifications = useCallback(async () => {
try {
const notifications = await window.electron.getLocalNotifications();
setLocalNotifications(notifications);
} catch (error) {
logger.error("Failed to fetch local notifications", error);
}
}, []);
const fetchBadges = useCallback(async () => {
try {
const language = i18n.language.split("-")[0];
const params = new URLSearchParams({ locale: language });
const badgesResponse = await window.electron.hydraApi.get<Badge[]>(
`/badges?${params.toString()}`,
{ needsAuth: false }
);
setBadges(badgesResponse);
} catch (error) {
logger.error("Failed to fetch badges", error);
}
}, [i18n.language]);
const fetchApiNotifications = useCallback(
async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
if (!userDetails) return;
try {
setIsLoading(true);
const response =
await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications",
{
params: { filter: filterParam, take: 20, skip },
needsAuth: true,
}
);
logger.log("Notifications API response:", response);
if (append) {
setApiNotifications((prev) => [...prev, ...response.notifications]);
} else {
setApiNotifications(response.notifications);
}
setPagination({
total: response.pagination.total,
hasMore: response.pagination.hasMore,
skip: response.pagination.skip + response.pagination.take,
});
} catch (error) {
logger.error("Failed to fetch API notifications", error);
} finally {
setIsLoading(false);
}
},
[userDetails]
);
const fetchAllNotifications = useCallback(
async (filterParam: NotificationFilter = "all") => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails
? fetchApiNotifications(0, false, filterParam)
: Promise.resolve(),
]);
setIsLoading(false);
},
[fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails]
);
useEffect(() => {
fetchAllNotifications(filter);
}, [fetchAllNotifications, filter]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(
(notification) => {
setLocalNotifications((prev) => [notification, ...prev]);
}
);
return () => unsubscribe();
}, []);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
clearingTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
// High priority notifications (priority === 1) - keep in API order
const highPriority: MergedNotification[] = apiNotifications
.filter((n) => n.priority === 1)
.map((n) => ({ ...n, source: "api" as const }));
// Low priority: other API notifications + local notifications, merged and sorted by date
const lowPriorityApi: MergedNotification[] = apiNotifications
.filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const }));
// Filter local notifications based on current filter
const filteredLocalNotifications =
filter === "unread"
? localNotifications.filter((n) => !n.isRead)
: localNotifications;
const localWithSource: MergedNotification[] =
filteredLocalNotifications.map((n) => ({
...n,
source: "local" as const,
}));
const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate
);
return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications, filter]);
const displayedNotifications = useMemo(() => {
return mergedNotifications;
}, [mergedNotifications]);
const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged"));
}, []);
const handleMarkAsRead = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.patch(
`/profile/notifications/${id}/read`,
{
data: { id },
needsAuth: true,
}
);
setApiNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
} else {
await window.electron.markLocalNotificationRead(id);
setLocalNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
}
notifyCountChange();
} catch (error) {
logger.error("Failed to mark notification as read", error);
}
},
[notifyCountChange]
);
const handleMarkAllAsRead = useCallback(async () => {
try {
// Mark all API notifications as read
if (userDetails && apiNotifications.some((n) => !n.isRead)) {
await window.electron.hydraApi.patch(
`/profile/notifications/all/read`,
{ needsAuth: true }
);
setApiNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
}
// Mark all local notifications as read
await window.electron.markAllLocalNotificationsRead();
setLocalNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
notifyCountChange();
showSuccessToast(t("marked_all_as_read"));
} catch (error) {
logger.error("Failed to mark all as read", error);
showErrorToast(t("failed_to_mark_as_read"));
}
}, [
apiNotifications,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleDismiss = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.delete(
`/profile/notifications/${id}`,
{ needsAuth: true }
);
setApiNotifications((prev) => prev.filter((n) => n.id !== id));
setPagination((prev) => ({ ...prev, total: prev.total - 1 }));
} else {
await window.electron.deleteLocalNotification(id);
setLocalNotifications((prev) => prev.filter((n) => n.id !== id));
}
notifyCountChange();
} catch (error) {
logger.error("Failed to dismiss notification", error);
showErrorToast(t("failed_to_dismiss"));
}
},
[showErrorToast, t, notifyCountChange]
);
const removeNotificationFromState = useCallback(
(notification: MergedNotification) => {
if (notification.source === "api") {
setApiNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
} else {
setLocalNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
}
},
[]
);
const removeNotificationWithDelay = useCallback(
(notification: MergedNotification, delayMs: number): Promise<void> => {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
removeNotificationFromState(notification);
resolve();
}, delayMs);
clearingTimeoutsRef.current.push(timeout);
});
},
[removeNotificationFromState]
);
const handleClearAll = useCallback(async () => {
if (isClearing) return;
try {
setIsClearing(true);
// Clear any existing timeouts
clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Snapshot current notifications for staggered removal
const notificationsToRemove = [...displayedNotifications];
const totalNotifications = notificationsToRemove.length;
if (totalNotifications === 0) {
setIsClearing(false);
return;
}
// Remove items one by one with staggered delays for visual effect
const removalPromises = notificationsToRemove.map((notification, index) =>
removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS)
);
// Wait for all items to be removed from state
await Promise.all(removalPromises);
// Wait for the last exit animation to complete
await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS));
// Perform actual backend deletions (state is already cleared by staggered removal)
if (userDetails) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true,
});
}
await window.electron.clearAllLocalNotifications();
setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange();
showSuccessToast(t("cleared_all"));
} catch (error) {
logger.error("Failed to clear all notifications", error);
showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
}
}, [
displayedNotifications,
isClearing,
removeNotificationWithDelay,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true, filter);
}
}, [pagination, isLoading, fetchApiNotifications, filter]);
const handleFilterChange = useCallback(
(newFilter: NotificationFilter) => {
if (newFilter !== filter) {
setFilter(newFilter);
setPagination({ total: 0, hasMore: false, skip: 0 });
}
},
[filter]
);
const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted"));
}, [showSuccessToast, t]);
const handleRefuseFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_refused"));
}, [showSuccessToast, t]);
const renderNotification = (notification: MergedNotification) => {
const key =
notification.source === "local"
? `local-${notification.id}`
: `api-${notification.id}`;
return (
<motion.div
key={key}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
transition={{ duration: 0.2 }}
>
{notification.source === "local" ? (
<LocalNotificationItem
notification={notification}
onDismiss={(id) => handleDismiss(id, "local")}
onMarkAsRead={(id) => handleMarkAsRead(id, "local")}
/>
) : (
<NotificationItem
notification={notification}
badges={badges}
onDismiss={(id) => handleDismiss(id, "api")}
onMarkAsRead={(id) => handleMarkAsRead(id, "api")}
onAcceptFriendRequest={handleAcceptFriendRequest}
onRefuseFriendRequest={handleRefuseFriendRequest}
/>
)}
</motion.div>
);
};
const unreadCount = useMemo(() => {
const apiUnread = apiNotifications.filter((n) => !n.isRead).length;
const localUnread = localNotifications.filter((n) => !n.isRead).length;
return apiUnread + localUnread;
}, [apiNotifications, localNotifications]);
const renderFilterTabs = () => (
<div className="notifications__filter-tabs">
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "all" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("all")}
>
{t("filter_all")}
</button>
{filter === "all" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "unread" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("unread")}
>
{t("filter_unread")}
{unreadCount > 0 && (
<span className="notifications__tab-badge">{unreadCount}</span>
)}
</button>
{filter === "unread" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
</div>
);
const hasNoNotifications = mergedNotifications.length === 0;
const shouldDisableActions = isClearing || hasNoNotifications;
const renderContent = () => {
if (isLoading && hasNoNotifications) {
return (
<div className="notifications__loading">
<span>{t("loading")}</span>
</div>
);
}
return (
<div className="notifications">
<div className="notifications__header">
{renderFilterTabs()}
<div className="notifications__actions">
<Button
theme="outline"
onClick={handleMarkAllAsRead}
disabled={shouldDisableActions}
>
{t("mark_all_as_read")}
</Button>
<Button
theme="danger"
onClick={handleClearAll}
disabled={shouldDisableActions}
>
{t("clear_all")}
</Button>
</div>
</div>
{/* Keep AnimatePresence mounted during clearing to preserve exit animations */}
<AnimatePresence mode="wait">
<motion.div
key={filter}
className="notifications__content-wrapper"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{hasNoNotifications && !isClearing ? (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>
{filter === "unread"
? t("empty_filter_description")
: t("empty_description")}
</p>
</div>
) : (
<div className="notifications__list">
<AnimatePresence>
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
)}
</motion.div>
</AnimatePresence>
{pagination.hasMore && !isClearing && (
<div className="notifications__load-more">
<Button
theme="outline"
onClick={handleLoadMore}
disabled={isLoading}
>
{isLoading ? t("loading") : t("load_more")}
</Button>
</div>
)}
</div>
);
};
return <>{renderContent()}</>;
}

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