Compare commits

..

86 Commits

Author SHA1 Message Date
Chubby Granny Chaser
085f2c74b4 Merge branch 'main' into feat/souvenirs-for-achievements 2026-01-11 14:09:36 +00:00
Chubby Granny Chaser
d9d443ee6d Merge pull request #1932 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-e9d8c310be
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
chore(deps): bump @smithy/config-resolver from 4.3.1 to 4.4.5 in the npm_and_yarn group across 1 directory
2026-01-11 02:38:10 +00:00
Chubby Granny Chaser
a912b57ccc Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-e9d8c310be 2026-01-11 02:20:44 +00:00
Moyase
447c146035 Merge pull request #1923 from hydralauncher/fix/archive-extraction
fix: archives with password doesn't extract properly
2026-01-11 04:12:01 +02:00
Moyase
39ff44f9d1 Merge branch 'main' into fix/archive-extraction 2026-01-11 04:09:00 +02:00
Chubby Granny Chaser
dbe101b7df Merge pull request #1927 from Sneezedip/main
Fix translation for hydra_cloud_feature_found (pt-PT)
2026-01-11 02:08:39 +00:00
dependabot[bot]
e7a62c16fa chore(deps): bump @smithy/config-resolver
Bumps the npm_and_yarn group with 1 update in the / directory: [@smithy/config-resolver](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/config-resolver).


Updates `@smithy/config-resolver` from 4.3.1 to 4.4.5
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/config-resolver/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/config-resolver@4.4.5/packages/config-resolver)

---
updated-dependencies:
- dependency-name: "@smithy/config-resolver"
  dependency-version: 4.4.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-08 23:29:01 +00:00
Sneezedip
f37ccbb4c0 Fix translation for hydra_cloud_feature_found 2026-01-06 12:33:14 -01:00
Moyasee
feb8d78e01 fix: update password index initialization in tryPassword function for correct behavior 2026-01-04 21:10:37 +02:00
Zamitto
7e7390885e feat: adding ww feedback button
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2026-01-03 19:55:48 -03:00
Moyase
64815f4f8d Merge pull request #1910 from hydralauncher/feat/vikingfile-support
feat: VikingFile support and display url availability
2026-01-03 23:42:55 +02:00
Moyasee
4dfdc4d798 chore: remove commented code in DownloadSettingsModal 2026-01-03 23:40:07 +02:00
Moyasee
9bbfab2aff Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 23:35:00 +02:00
Moyasee
01938f8905 refactor: simplify downloader sorting and enhance availability indicators in DownloadSettingsModal 2026-01-03 23:34:19 +02:00
Moyase
f60ad5908d Merge branch 'main' into feat/vikingfile-support 2026-01-03 23:28:17 +02:00
Moyasee
fe6553bcdc chore: remove unused HTTPS import in vikingfile service 2026-01-03 23:23:35 +02:00
Moyasee
87895bb715 refactor: enhance disabled state styling and logic in DownloadSettingsModal 2026-01-03 23:22:40 +02:00
Chubby Granny Chaser
290209f372 chore: remove unnecessary blank lines in RealDebridInfoModal component files 2026-01-03 21:07:35 +00:00
Chubby Granny Chaser
87fcbaa56e chore: bump version to 3.8.0 and update translations for downloader status and notifications 2026-01-03 21:07:09 +00:00
Zamitto
c32ce14630 Merge pull request #1915 from hydralauncher/feat/add-workwonders
feat: add workwonders
2026-01-03 17:19:17 -03:00
Zamitto
e52f10a5ff chore: bump ww version 2026-01-03 17:17:16 -03:00
Moyase
bcdbe31596 Merge pull request #1914 from hydralauncher/fix/friend-request-endpoint
fix: update API endpoint for deleting friend requests in useUserDetai…
2026-01-03 22:08:45 +02:00
Zamitto
7ed514b6ef feat: parse locale before ww init 2026-01-03 16:54:42 -03:00
Moyase
42386ae0b5 Merge branch 'main' into fix/friend-request-endpoint 2026-01-03 21:48:52 +02:00
Moyase
04d8f900a6 Merge pull request #1913 from hydralauncher/fix/friends-and-karma-ui
refactor: remove karma description from translations across multiple …
2026-01-03 21:48:41 +02:00
Zamitto
8b3bcd88b1 feat: add workwonders 2026-01-03 16:42:49 -03:00
Moyasee
b2bffeb2b0 fix: update API endpoint for deleting friend requests in useUserDetails hook 2026-01-03 21:01:39 +02:00
Moyase
0a194eaa29 Merge branch 'main' into fix/friends-and-karma-ui 2026-01-03 20:13:08 +02:00
Moyasee
07c277c033 refactor: remove karma description from translations across multiple languages 2026-01-03 20:11:22 +02:00
Moyasee
345696ad06 Merge branch 'feat/vikingfile-support' of https://github.com/hydralauncher/hydra into feat/vikingfile-support 2026-01-03 19:47:39 +02:00
Moyasee
6c4e8c406f refactor: update HTTP module imports to use node: prefix for consistency 2026-01-03 19:44:39 +02:00
Moyase
c46a1e7848 Merge branch 'main' into feat/vikingfile-support 2026-01-03 19:41:49 +02:00
Moyase
590e09a8c3 Merge pull request #1912 from hydralauncher/fix/notifications-page-ui
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
feat: add unread filter option and enhance notifications UI
2026-01-03 19:41:30 +02:00
Moyasee
c1d7ea27f3 feat: add unread filter option and enhance notifications UI 2026-01-03 19:37:47 +02:00
Chubby Granny Chaser
15dbd3b2ad Merge pull request #1909 from hydralauncher/fix/library-game-covers
fix: library cards not using placeholder and icon as a game cover
2026-01-03 16:19:33 +00:00
Moyasee
4584783f44 refactor: enhance download progress tracking in DownloadManager 2026-01-03 04:47:46 +02:00
Moyasee
765ec70dd0 refactor: streamline downloader logic in DownloadSettingsModal 2026-01-03 01:40:21 +02:00
Moyasee
de483da51c fix: handle download not found exception in HttpDownloader and enforce IPv4 in HTTP agents 2026-01-03 01:08:25 +02:00
Moyasee
2bc0266775 feat: add loading state to download button and enhance UI with spinner 2026-01-03 00:18:07 +02:00
Moyasee
c9729fb3eb chore: update build and release workflows to include MAIN_VITE_NIMBUS_API_URL 2026-01-02 23:59:21 +02:00
Moyasee
9a7ad148e3 fix: use logger for error handling in VikingFile.ts 2026-01-02 23:24:20 +02:00
Moyasee
d929fbaeaa refactor: simplify header assignment in HttpDownloader 2026-01-02 23:23:08 +02:00
Moyasee
8fa33119d6 feat: add support for VikingFile and display if link is available 2026-01-02 23:20:08 +02:00
Moyasee
92d87c5d33 refactor: remove unnecessary useEffect in LibraryGameCard 2025-12-31 01:59:25 +02:00
Moyasee
af884d3772 refactor: simplify cover image assignment in LibraryGameCard 2025-12-30 14:09:04 +02:00
Moyasee
dc31ac0831 fix: library cards not using placeholder and icon as a game cover 2025-12-30 00:25:45 +02:00
Moyasee
a4417b26fd chore: update subproject commit reference and clean up imports in settings-general 2025-12-27 22:17:12 +02:00
Moyasee
2919109a11 feat: move new achievement related settings into achievement tab in settings 2025-12-27 22:13:48 +02:00
Moyasee
734f8dbce0 fix: merge conflicts 2025-12-27 21:50:28 +02:00
Chubby Granny Chaser
9769eecec6 Merge pull request #1907 from hydralauncher/fix/lint-buzz
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
refactor: improve code formatting and consistency in DownloadManager
2025-12-27 00:56:29 +00:00
Moyasee
3105fe1c59 feat: displaying achievements in game accordion and proper styling 2025-11-25 21:41:52 +02:00
Moyasee
4097869ae8 feat: displaying achievements in game accordion and proper styling 2025-11-25 21:21:57 +02:00
Moyasee
bc3d47ed0e feat: deleting achievement souvenirs 2025-11-18 18:26:22 +02:00
Moyasee
e1c60c6e8f fix: memo thing 2025-11-11 20:56:25 +02:00
Moyasee
ccecd8aa4c fix: formatting 2025-11-11 20:49:47 +02:00
Moyase
d8d1576cc7 Merge branch 'main' into feat/souvenirs-for-achievements 2025-11-11 20:29:23 +02:00
Moyasee
29f05b0c3c fix: deleted duplicate css class 2025-11-09 18:23:11 +02:00
Moyasee
941101702e ci: moving souvenirs into separate tab 2025-11-09 18:18:16 +02:00
Moyase
089bdd877d Merge branch 'main' into feat/souvenirs-for-achievements 2025-11-09 17:46:08 +02:00
Moyasee
bf419edd99 fix: refactoring function 2025-10-26 08:37:21 +02:00
Moyasee
28cc25b368 ci: formatting 2025-10-26 08:32:28 +02:00
Moyasee
a1f419957f ci: changed css variables to image from souvenirs, made image add in a single api call with updating achievements, renamed variables 2025-10-24 23:50:59 +03:00
Moyasee
f1f69e6dbd Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/souvenirs-for-achievements 2025-10-24 20:50:24 +03:00
Moyasee
274cb21459 fix: disabling open screenshot folder button for linux users 2025-10-24 08:22:53 +03:00
Moyasee
af69d19db7 ci: formatting 2025-10-23 10:00:54 +03:00
Moyasee
594d56db5c feat: Changing settings ui. Added new section achievements 2025-10-23 09:55:04 +03:00
Moyasee
2634bec292 feat: added open screenshots folder in settings-behavior 2025-10-22 22:49:07 +03:00
Moyasee
7109c1542c ci: api change + fullscreen modal fix 2025-10-22 22:14:47 +03:00
Chubby Granny Chaser
68b3de9b7f fix: moving constant 2025-10-22 17:37:00 +01:00
Moyasee
ddbac621fb fix: added linux precheck and tooltip to the screenshot checkbox 2025-10-22 10:54:49 +03:00
Moyasee
40ab06e87c ci: refactored service to event, fixed dynamic import 2025-10-22 10:44:29 +03:00
Moyasee
8616729f5d Merge branch 'feat/souvenirs-for-achievements' of https://github.com/hydralauncher/hydra into feat/souvenirs-for-achievements 2025-10-22 10:41:56 +03:00
Moyasee
1b4a1360a6 ci: refactored service to event, fixed dynamic import 2025-10-22 10:40:52 +03:00
Chubby Granny Chaser
2da98911b8 Merge branch 'main' into feat/souvenirs-for-achievements 2025-10-21 20:47:11 +01:00
Moyasee
ba4610705d ci: imports fix, readonly marking, using dialog, removed unnecesary assertions 2025-10-21 21:19:50 +03:00
Moyasee
0a4726af44 ci: imports fix, readonly marking, using dialog, removed unnecesary assertions 2025-10-21 21:17:55 +03:00
Moyasee
2022ff34cb fix: empty block 2025-10-21 21:09:31 +03:00
Moyasee
72e6f1e328 ci: deleting comments 2025-10-21 21:08:01 +03:00
Moyasee
9b693f2297 ci: fixed files after merging 2025-10-20 16:26:25 +03:00
Moyasee
e3685ba233 ci: merging main to branch 2025-10-20 15:51:15 +03:00
Moyasee
03d9128768 ci: merging main to branch 2025-10-20 15:50:04 +03:00
Moyasee
beec415636 ci: souvenir gradient box fix 2025-10-20 08:40:17 +03:00
Moyasee
4fb04b72a3 ci: added responsive design to souvenir cards in user profile 2025-10-20 03:24:28 +03:00
Moyasee
e53e52df1b ci: another lint fixes 2025-10-20 01:50:30 +03:00
Moyasee
8c0281844e ci: fix lint errors 2025-10-20 01:45:00 +03:00
Moyasee
cc9d98c360 feat: displaying recent achievements on profile 2025-10-20 01:42:50 +03:00
75 changed files with 3700 additions and 573 deletions

View File

@@ -1,6 +1,7 @@
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,6 +57,7 @@ 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 }}
@@ -73,6 +74,7 @@ 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,9 +54,10 @@ 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 }}
@@ -71,9 +72,10 @@ 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

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.6",
"version": "3.8.0",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -91,6 +91,7 @@
"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"

View File

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

View File

@@ -175,6 +175,7 @@
"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",
@@ -184,6 +185,12 @@
"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",
@@ -436,6 +443,7 @@
"launch_with_system": "Launch Hydra on system start-up",
"general": "General",
"behavior": "Behavior",
"achievements": "Achievements",
"download_sources": "Download sources",
"language": "Language",
"api_token": "API Token",
@@ -559,6 +567,8 @@
"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_achievement_screenshots": "Enable achievement screenshots",
"open_screenshots_directory": "Open screenshots directory",
"enable_new_download_options_badges": "Show new download options badges",
"achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left",
@@ -635,6 +645,7 @@
"amount_minutes_short": "{{amount}}m",
"last_time_played": "Last played {{period}}",
"activity": "Recent Activity",
"souvenirs": "Souvenirs",
"library": "Library",
"pinned": "Pinned",
"sort_by": "Sort by:",
@@ -728,10 +739,15 @@
"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...",
"souvenir_deleted_successfully": "Souvenir deleted successfully",
"souvenir_deletion_failed": "Failed to delete souvenir",
"delete_souvenir_modal_title": "Are you sure you want to delete this souvenir?",
"delete_souvenir_modal_description": "This action cannot be undone.",
"delete_souvenir_modal_delete_button": "Delete",
"delete_souvenir_modal_cancel_button": "Cancel",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
@@ -795,6 +811,7 @@
"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",

View File

@@ -182,6 +182,12 @@
"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",
@@ -651,6 +657,7 @@
"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",
@@ -661,12 +668,16 @@
"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",
@@ -690,6 +701,7 @@
"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",
@@ -710,11 +722,13 @@
"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"
},
@@ -767,5 +781,41 @@
"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,8 +673,7 @@
"game_removed_from_pinned": "Peli poistettu kiinnitetyistä",
"game_added_to_pinned": "Peli lisätty kiinnitettyihin",
"karma": "Karma",
"karma_count": "karmaa",
"karma_description": "Ansittu positiivisilla arvosteluäänillä"
"karma_count": "karmaa"
},
"achievement": {
"achievement_unlocked": "Saavutus avattu",

View File

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

View File

@@ -172,6 +172,12 @@
"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",
@@ -654,6 +660,7 @@
"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",
@@ -666,12 +673,16 @@
"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",
@@ -695,6 +706,7 @@
"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",
@@ -720,10 +732,12 @@
"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"
},
@@ -776,5 +790,41 @@
"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

@@ -508,7 +508,7 @@
"show_and_compare_achievements": "Mostra e compara as tuas conquistas com as de outros utilizadores",
"animated_profile_banner": "Banner animado no perfil",
"cloud_saving": "Progresso dos jogos na nuvem",
"hydra_cloud_feature_found": "Descubriste uma funcionalidade Hydra Cloud!",
"hydra_cloud_feature_found": "Descobriste uma funcionalidade Hydra Cloud!",
"learn_more": "Saber mais"
}
}

View File

@@ -182,6 +182,12 @@
"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": "Открыть папку",
@@ -651,6 +657,7 @@
"sending": "Отправка",
"friend_request_sent": "Запрос в друзья отправлен",
"friends": "Друзья",
"badges": "Значки",
"friends_list": "Список друзей",
"user_not_found": "Пользователь не найден",
"block_user": "Заблокировать пользователя",
@@ -661,12 +668,16 @@
"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": "Вы не заблокировали ни одного пользователя",
@@ -690,6 +701,7 @@
"report_reason_other": "Другое",
"profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:",
"copy_friend_code": "Копировать код друга",
"upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...",
"background_image_updated": "Фоновое изображение обновлено",
@@ -709,9 +721,11 @@
"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": "Удалить отзыв"
},
@@ -764,5 +778,41 @@
"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,7 +706,6 @@
"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,8 +668,7 @@
"game_removed_from_pinned": "Гру видалено із закріплених",
"game_added_to_pinned": "Гру додано до закріплених",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Зароблена позитивними оцінками на відгуках"
"karma_count": "карма"
},
"achievement": {
"achievement_unlocked": "Досягнення розблоковано",

View File

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

View File

@@ -31,6 +31,11 @@ export const logsPath = path.join(
`logs${isStaging ? "-staging" : ""}`
);
export const screenshotsPath = path.join(
SystemPath.getPath("userData"),
"Screenshots"
);
export const achievementSoundPath = app.isPackaged
? path.join(process.resourcesPath, "achievement.wav")
: path.join(__dirname, "..", "..", "resources", "achievement.wav");

View File

@@ -1,4 +1,9 @@
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
import {
appVersion,
defaultDownloadsPath,
isStaging,
screenshotsPath,
} from "@main/constants";
import { ipcMain } from "electron";
import "./auth";
@@ -16,7 +21,6 @@ import "./themes";
import "./torrenting";
import "./user";
import "./user-preferences";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");
@@ -24,3 +28,4 @@ ipcMain.handle("getVersion", () => appVersion);
ipcMain.handle("isStaging", () => isStaging);
ipcMain.handle("isPortableVersion", () => isPortableVersion());
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
ipcMain.handle("getScreenshotsPath", () => screenshotsPath);

View File

@@ -0,0 +1,11 @@
import { shell } from "electron";
import { registerEvent } from "../register-event";
const openFolder = async (
_event: Electron.IpcMainInvokeEvent,
folderPath: string
) => {
return shell.openPath(folderPath);
};
registerEvent("openFolder", openFolder);

View File

@@ -3,6 +3,8 @@ import { registerEvent } from "../register-event";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
export const getUnlockedAchievements = async (
objectId: string,
@@ -31,6 +33,28 @@ export const getUnlockedAchievements = async (
const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? [];
let remoteUserAchievements: UserAchievement[] = [];
try {
const userDetails = await db.get<string, any>(levelKeys.user, {
valueEncoding: "json",
});
if (userDetails?.id) {
remoteUserAchievements = await HydraApi.get<UserAchievement[]>(
`/users/${userDetails.id}/games/achievements`,
{
shop,
objectId,
language: userPreferences?.language ?? "en",
}
);
}
} catch (error) {
if (!(error instanceof UserNotLoggedInError)) {
console.warn("Failed to fetch remote user achievements:", error);
}
}
return achievementsData
.map((achievementData) => {
const unlockedAchievementData = unlockedAchievements.find(
@@ -42,6 +66,16 @@ export const getUnlockedAchievements = async (
}
);
// Find corresponding remote achievement data for image URL
const remoteAchievementData = remoteUserAchievements.find(
(remoteAchievement) => {
return (
remoteAchievement.name.toUpperCase() ==
achievementData.name.toUpperCase()
);
}
);
const icongray = achievementData.icongray.endsWith("/")
? achievementData.icon
: achievementData.icongray;
@@ -51,6 +85,7 @@ export const getUnlockedAchievements = async (
...achievementData,
unlocked: true,
unlockTime: unlockedAchievementData.unlockTime,
imageUrl: remoteAchievementData?.imageUrl || null,
};
}
@@ -63,6 +98,7 @@ export const getUnlockedAchievements = async (
!achievementData.hidden || showHiddenAchievementsDescription
? achievementData.description
: undefined,
imageUrl: remoteAchievementData?.imageUrl || null,
};
})
.sort((a, b) => {

View File

@@ -46,7 +46,7 @@ export class SevenZip {
onProgress?: (progress: ExtractionProgress) => void
): Promise<ExtractionResult> {
return new Promise((resolve, reject) => {
const tryPassword = (index = -1) => {
const tryPassword = (index = 0) => {
const password = passwords[index] ?? "";
logger.info(
`Trying password "${password || "(empty)"}" on ${filePath}`
@@ -115,7 +115,7 @@ export class SevenZip {
});
};
tryPassword();
tryPassword(0);
});
}

View File

@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { fileTypeFromFile } from "file-type";
import { HydraApi } from "@main/services/hydra-api";
import { gameAchievementsSublevel, levelKeys, db } from "@main/level";
import { logger } from "@main/services/logger";
import type { GameShop, User } from "@types";
export class AchievementImageService {
private static async uploadImageToCDN(imagePath: string): Promise<string> {
const stat = fs.statSync(imagePath);
const fileBuffer = fs.readFileSync(imagePath);
const fileSizeInBytes = stat.size;
const response = await HydraApi.post<{
presignedUrl: string;
imageKey: string;
}>("/presigned-urls/achievement-image", {
imageExt: path.extname(imagePath).slice(1),
imageLength: fileSizeInBytes,
});
const mimeType = await fileTypeFromFile(imagePath);
await axios.put(response.presignedUrl, fileBuffer, {
headers: {
"Content-Type": mimeType?.mime,
},
});
return response.imageKey;
}
private static async storeImageLocally(imagePath: string): Promise<string> {
const fileBuffer = fs.readFileSync(imagePath);
const base64Image = fileBuffer.toString("base64");
const mimeType = await fileTypeFromFile(imagePath);
return `data:${mimeType?.mime || "image/jpeg"};base64,${base64Image}`;
}
private static async hasActiveSubscription(): Promise<boolean> {
return db
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
.then((user) => {
const expiresAt = new Date(user?.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
})
.catch(() => false);
}
private static async updateLocalAchievementData(
shop: GameShop,
gameId: string,
imageUrl: string
): Promise<void> {
const achievementKey = levelKeys.game(shop, gameId);
const existingData = await gameAchievementsSublevel
.get(achievementKey)
.catch(() => null);
if (existingData) {
await gameAchievementsSublevel.put(achievementKey, {
...existingData,
imageUrl,
});
}
}
private static cleanupImageFile(imagePath: string): void {
try {
fs.unlinkSync(imagePath);
} catch (error) {
logger.error(`Failed to cleanup screenshot file ${imagePath}:`, error);
}
}
/**
* Uploads an achievement image either to CDN (for subscribers) or stores locally
* @param gameId - The game identifier
* @param achievementName - The achievement name
* @param imagePath - Path to the image file to upload
* @param shop - The game shop (optional)
* @returns Promise with success status and imageKey (for subscribers) or imageUrl (for non-subscribers)
*/
static async uploadAchievementImage(
gameId: string,
achievementName: string,
imagePath: string
): Promise<{ success: boolean; imageKey?: string; imageUrl?: string }> {
try {
const hasSubscription = await this.hasActiveSubscription();
if (hasSubscription) {
const imageKey = await this.uploadImageToCDN(imagePath);
logger.log(
`Achievement image uploaded to CDN for ${gameId}:${achievementName}`
);
return { success: true, imageKey };
} else {
const imageUrl = await this.storeImageLocally(imagePath);
logger.log(
`Achievement image stored locally for ${gameId}:${achievementName}`
);
return { success: true, imageUrl };
}
} catch (error) {
logger.error(
`Failed to upload achievement image for ${gameId}:${achievementName}:`,
error
);
throw error;
}
}
/**
* Uploads achievement image and updates local database, with automatic cleanup
* @param gameId - The game identifier
* @param achievementName - The achievement name
* @param imagePath - Path to the image file to upload
* @param shop - The game shop
* @returns Promise with success status and imageKey or imageUrl
*/
static async uploadAndUpdateAchievementImage(
gameId: string,
achievementName: string,
imagePath: string,
shop: GameShop
): Promise<{ success: boolean; imageKey?: string; imageUrl?: string }> {
try {
const result = await this.uploadAchievementImage(
gameId,
achievementName,
imagePath
);
if (result.imageUrl) {
await this.updateLocalAchievementData(shop, gameId, result.imageUrl);
}
this.cleanupImageFile(imagePath);
return result;
} catch (error) {
this.cleanupImageFile(imagePath);
throw error;
}
}
}

View File

@@ -13,7 +13,7 @@ const getModifiedSinceHeader = (
return undefined;
}
if (userLanguage != cachedAchievements.language) {
if (userLanguage !== cachedAchievements.language) {
return undefined;
}

View File

@@ -15,6 +15,8 @@ import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { getGameAchievementData } from "./get-game-achievement-data";
import { AchievementWatcherManager } from "./achievement-watcher-manager";
import { ScreenshotService } from "../screenshot";
import { AchievementImageService } from "./achievement-image-service";
const isRareAchievement = (points: number) => {
const rawPercentage = (50 - Math.sqrt(points)) * 2;
@@ -53,11 +55,8 @@ const saveAchievementsOnLocal = async (
});
};
export const mergeAchievements = async (
game: Game,
achievements: UnlockedAchievement[],
publishNotification: boolean
) => {
// Helpers extracted to lower cognitive complexity
const getLocalData = async (game: Game) => {
const gameKey = levelKeys.game(game.shop, game.objectId);
let localGameAchievement = await gameAchievementsSublevel.get(gameKey);
@@ -73,11 +72,20 @@ export const mergeAchievements = async (
localGameAchievement = await gameAchievementsSublevel.get(gameKey);
}
const achievementsData = localGameAchievement?.achievements ?? [];
const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? [];
return {
achievementsData: localGameAchievement?.achievements ?? [],
unlockedAchievements: localGameAchievement?.unlockedAchievements ?? [],
userPreferences,
gameKey,
};
};
const computeNewAndMergedAchievements = (
incoming: UnlockedAchievement[],
unlockedAchievements: UnlockedAchievement[]
) => {
const newAchievementsMap = new Map(
achievements.toReversed().map((achievement) => {
incoming.toReversed().map((achievement) => {
return [achievement.name.toUpperCase(), achievement];
})
);
@@ -97,68 +105,154 @@ export const mergeAchievements = async (
};
});
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
return {
newAchievements,
mergedLocalAchievements: unlockedAchievements.concat(newAchievements),
};
};
const publishAchievementNotificationIfNeeded = (
game: Game,
newAchievements: UnlockedAchievement[],
unlockedAchievements: UnlockedAchievement[],
achievementsData: any[],
userPreferences: UserPreferences,
mergedLocalCount: number,
publishNotification: boolean
) => {
if (
newAchievements.length &&
publishNotification &&
userPreferences.achievementNotificationsEnabled !== false
!newAchievements.length ||
!publishNotification ||
userPreferences.achievementNotificationsEnabled === false
) {
const filteredAchievements = newAchievements
.toSorted((a, b) => {
return a.unlockTime - b.unlockTime;
})
.map((achievement) => {
return achievementsData.find((steamAchievement) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
});
})
.filter((achievement) => !!achievement);
const achievementsInfo: AchievementNotificationInfo[] =
filteredAchievements.map((achievement, index) => {
return {
title: achievement.displayName,
description: achievement.description,
points: achievement.points,
isHidden: achievement.hidden,
isRare: achievement.points
? isRareAchievement(achievement.points)
: false,
isPlatinum:
index === filteredAchievements.length - 1 &&
newAchievements.length + unlockedAchievements.length ===
achievementsData.length,
iconUrl: achievement.icon,
};
});
achievementsLogger.log(
"Publishing achievement notification",
game.objectId,
game.title
);
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementsInfo
);
} else {
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
}
return;
}
const filteredAchievements = newAchievements
.toSorted((a, b) => a.unlockTime - b.unlockTime)
.map((achievement) => {
return achievementsData.find((steamAchievement: any) => {
return (
achievement.name.toUpperCase() === steamAchievement.name.toUpperCase()
);
});
})
.filter((achievement) => !!achievement);
const achievementsInfo: AchievementNotificationInfo[] =
filteredAchievements.map((achievement: any, index: number) => {
return {
title: achievement.displayName,
description: achievement.description,
points: achievement.points,
isHidden: achievement.hidden,
isRare: achievement.points
? isRareAchievement(achievement.points)
: false,
isPlatinum:
index === filteredAchievements.length - 1 &&
newAchievements.length + unlockedAchievements.length ===
achievementsData.length,
iconUrl: achievement.icon,
};
});
achievementsLogger.log(
"Publishing achievement notification",
game.objectId,
game.title
);
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementsInfo
);
} else {
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalCount,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
}
};
const addImagesToNewAchievementsIfEnabled = async (
newAchievements: UnlockedAchievement[],
achievementsData: any[],
mergedLocalAchievements: UnlockedAchievement[],
game: Game,
userPreferences: UserPreferences
): Promise<UnlockedAchievement[]> => {
const achievementsWithImages = [...mergedLocalAchievements];
if (
!newAchievements.length ||
userPreferences.enableAchievementScreenshots !== true
) {
return achievementsWithImages;
}
try {
for (const achievement of newAchievements) {
try {
const achievementData = achievementsData.find(
(steamAchievement: any) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
}
);
const achievementDisplayName =
achievementData?.displayName || achievement.name;
const screenshotPath = await ScreenshotService.captureDesktopScreenshot(
game.title,
achievementDisplayName
);
const uploadResult =
await AchievementImageService.uploadAchievementImage(
game.objectId,
achievement.name,
screenshotPath
);
const achievementIndex = achievementsWithImages.findIndex(
(a) => a.name.toUpperCase() === achievement.name.toUpperCase()
);
if (achievementIndex !== -1 && uploadResult.imageKey) {
achievementsWithImages[achievementIndex] = {
...achievementsWithImages[achievementIndex],
imageKey: uploadResult.imageKey,
};
}
} catch (error) {
achievementsLogger.error("Failed to upload achievement image", error);
}
}
} catch (error) {
achievementsLogger.error(
"Failed to capture screenshot for achievement",
error
);
}
return achievementsWithImages;
};
const syncAchievements = async (
game: Game,
publishNotification: boolean,
achievementsWithImages: UnlockedAchievement[],
newAchievements: UnlockedAchievement[],
gameKey: string
) => {
const shouldSyncWithRemote =
game.remoteId &&
(newAchievements.length || AchievementWatcherManager.hasFinishedPreSearch);
@@ -168,26 +262,26 @@ export const mergeAchievements = async (
"/profile/games/achievements",
{
id: game.remoteId,
achievements: mergedLocalAchievements,
achievements: achievementsWithImages,
},
{ needsSubscription: !newAchievements.length }
)
.then((response) => {
.then(async (response) => {
if (response) {
return saveAchievementsOnLocal(
await saveAchievementsOnLocal(
response.objectId,
response.shop,
response.achievements,
publishNotification
);
} else {
await saveAchievementsOnLocal(
game.objectId,
game.shop,
achievementsWithImages,
publishNotification
);
}
return saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
publishNotification
);
})
.catch((err) => {
if (err instanceof SubscriptionRequiredError) {
@@ -201,7 +295,7 @@ export const mergeAchievements = async (
return saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
achievementsWithImages,
publishNotification
);
})
@@ -212,10 +306,48 @@ export const mergeAchievements = async (
await saveAchievementsOnLocal(
game.objectId,
game.shop,
mergedLocalAchievements,
achievementsWithImages,
publishNotification
);
}
};
export const mergeAchievements = async (
game: Game,
achievements: UnlockedAchievement[],
publishNotification: boolean
) => {
const { achievementsData, unlockedAchievements, userPreferences, gameKey } =
await getLocalData(game);
const { newAchievements, mergedLocalAchievements } =
computeNewAndMergedAchievements(achievements, unlockedAchievements);
publishAchievementNotificationIfNeeded(
game,
newAchievements,
unlockedAchievements,
achievementsData,
userPreferences,
mergedLocalAchievements.length,
publishNotification
);
const achievementsWithImages = await addImagesToNewAchievementsIfEnabled(
newAchievements,
achievementsData,
mergedLocalAchievements,
game,
userPreferences
);
await syncAchievements(
game,
publishNotification,
achievementsWithImages,
newAchievements,
gameKey
);
return newAchievements.length;
};

View File

@@ -8,6 +8,7 @@ import {
DatanodesApi,
MediafireApi,
PixelDrainApi,
VikingFileApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
@@ -150,14 +151,28 @@ export class DownloadManager {
if (!isDownloadingMetadata && !isCheckingFiles) {
if (!download) return null;
await downloadsSublevel.put(downloadId, {
const updatedDownload = {
...download,
bytesDownloaded,
fileSize,
progress,
folderName,
status: "active",
});
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;
}
return {
@@ -499,6 +514,29 @@ 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

@@ -1,4 +1,6 @@
import axios from "axios";
import http from "node:http";
import https from "node:https";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
@@ -28,6 +30,12 @@ export class BuzzheavierApi {
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`;
@@ -43,6 +51,12 @@ export class BuzzheavierApi {
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"];

View File

@@ -5,3 +5,4 @@ export * from "./mediafire";
export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./fuckingfast";
export * from "./vikingfile";

View File

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

@@ -13,6 +13,7 @@ export * from "./game-files-manager";
export * from "./common-redist-manager";
export * from "./aria2";
export * from "./ws";
export * from "./screenshot";
export * from "./system-path";
export * from "./library-sync";
export * from "./wine";

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import http from "node:http";
import cp from "node:child_process";
import fs from "node:fs";
@@ -31,6 +32,9 @@ 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

@@ -0,0 +1,179 @@
import { desktopCapturer, nativeImage, app } from "electron";
import fs from "node:fs";
import path from "node:path";
import { logger } from "./logger";
import { screenshotsPath } from "@main/constants";
export class ScreenshotService {
private static readonly SCREENSHOT_QUALITY = 80;
private static readonly SCREENSHOT_FORMAT = "jpeg";
private static readonly MAX_WIDTH = 1280;
private static readonly MAX_HEIGHT = 720;
private static compressImage(
image: Electron.NativeImage
): Electron.NativeImage {
const size = image.getSize();
let newWidth = size.width;
let newHeight = size.height;
if (newWidth > this.MAX_WIDTH || newHeight > this.MAX_HEIGHT) {
const aspectRatio = newWidth / newHeight;
if (newWidth > newHeight) {
newWidth = this.MAX_WIDTH;
newHeight = Math.round(newWidth / aspectRatio);
} else {
newHeight = this.MAX_HEIGHT;
newWidth = Math.round(newHeight * aspectRatio);
}
}
if (newWidth !== size.width || newHeight !== size.height) {
return image.resize({ width: newWidth, height: newHeight });
}
return image;
}
public static async captureDesktopScreenshot(
gameTitle?: string,
achievementName?: string
): Promise<string> {
try {
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: 1920, height: 1080 },
});
if (sources.length === 0) {
throw new Error("No desktop sources available for screenshot");
}
console.log("sources", sources);
const primaryScreen = sources[0];
const originalImage = nativeImage.createFromDataURL(
primaryScreen.thumbnail.toDataURL()
);
const compressedImage = this.compressImage(originalImage);
let finalDir = screenshotsPath;
let filename: string;
if (gameTitle && achievementName) {
const sanitizedGameTitle = gameTitle.replaceAll(/[<>:"/\\|?*]/g, "_");
const gameDir = path.join(screenshotsPath, sanitizedGameTitle);
finalDir = gameDir;
const sanitizedAchievementName = achievementName.replaceAll(
/[<>:"/\\|?*]/g,
"_"
);
filename = `${sanitizedAchievementName}.${this.SCREENSHOT_FORMAT}`;
} else {
const timestamp = Date.now();
filename = `achievement_screenshot_${timestamp}.${this.SCREENSHOT_FORMAT}`;
}
if (!fs.existsSync(finalDir)) {
fs.mkdirSync(finalDir, { recursive: true });
}
const screenshotPath = path.join(finalDir, filename);
const jpegBuffer = compressedImage.toJPEG(this.SCREENSHOT_QUALITY);
fs.writeFileSync(screenshotPath, jpegBuffer);
logger.log(`Compressed screenshot saved to: ${screenshotPath}`);
return screenshotPath;
} catch (error) {
logger.error("Failed to capture desktop screenshot:", error);
throw error;
}
}
public static async cleanupOldScreenshots(): Promise<void> {
try {
const userDataPath = app.getPath("userData");
const screenshotsDir = path.join(userDataPath, "screenshots");
if (!fs.existsSync(screenshotsDir)) {
return;
}
const getAllFiles = (
dir: string
): Array<{ name: string; path: string; mtime: Date }> => {
const files: Array<{ name: string; path: string; mtime: Date }> = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
files.push(...getAllFiles(itemPath));
} else if (item.endsWith(`.${this.SCREENSHOT_FORMAT}`)) {
files.push({
name: item,
path: itemPath,
mtime: stat.mtime,
});
}
}
return files;
};
const allFiles = getAllFiles(screenshotsDir).sort(
(a, b) => b.mtime.getTime() - a.mtime.getTime()
);
const filesToDelete = allFiles.slice(50);
for (const file of filesToDelete) {
try {
fs.unlinkSync(file.path);
logger.log(`Cleaned up old screenshot: ${file.name}`);
} catch (error) {
logger.error(`Failed to delete screenshot ${file.name}:`, error);
}
}
const cleanupEmptyDirs = (dir: string) => {
if (dir === screenshotsDir) return;
try {
const items = fs.readdirSync(dir);
if (items.length === 0) {
fs.rmdirSync(dir);
logger.log(`Cleaned up empty directory: ${dir}`);
}
} catch (error) {
logger.error(`Failed to read directory ${dir}:`, error);
}
};
const gameDirectories = fs
.readdirSync(screenshotsDir)
.map((item) => path.join(screenshotsDir, item))
.filter((itemPath) => {
try {
return fs.statSync(itemPath).isDirectory();
} catch {
return false;
}
});
for (const gameDir of gameDirectories) {
cleanupEmptyDirs(gameDir);
}
} catch (error) {
logger.error("Failed to cleanup old screenshots:", error);
}
}
}

View File

@@ -1,4 +1,9 @@
import { User, type ProfileVisibility, type UserDetails } from "@types";
import {
User,
type ProfileVisibility,
type UserDetails,
type UserPreferences,
} from "@types";
import { HydraApi } from "../hydra-api";
import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger";
@@ -6,7 +11,24 @@ import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
export const getUserData = async () => {
return HydraApi.get<UserDetails>(`/profile/me`)
let language = "en";
try {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
if (userPreferences?.language) {
const supportedLanguages = ["pt", "ru", "es"];
const userLang = userPreferences.language.split("-")[0];
language = supportedLanguages.includes(userLang) ? userLang : "en";
}
} catch (error) {
logger.error("Failed to get user preferences for language", error);
}
const params = new URLSearchParams({ language });
return HydraApi.get<UserDetails>(`/profile/me?${params.toString()}`)
.then(async (me) => {
try {
const user = await db.get<string, User>(levelKeys.user, {

View File

@@ -7,6 +7,7 @@ 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

@@ -363,9 +363,11 @@ contextBridge.exposeInMainWorld("electron", {
ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
getScreenshotsPath: () => ipcRenderer.invoke("getScreenshotsPath"),
isStaging: () => ipcRenderer.invoke("isStaging"),
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
openFolder: (path: string) => ipcRenderer.invoke("openFolder", path),
openCheckout: () => ipcRenderer.invoke("openCheckout"),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),

View File

@@ -47,6 +47,17 @@ button {
font-family: inherit;
}
dialog {
padding: 0;
margin: 0;
border: none;
background: transparent;
max-width: none;
max-height: none;
width: auto;
height: auto;
}
h1,
h2,
h3,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { WorkWondersSdk } from "workwonders-sdk";
import {
useAppDispatch,
useAppSelector,
@@ -52,6 +52,8 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const workwondersRef = useRef<WorkWondersSdk | null>(null);
const {
hasActiveSubscription,
fetchUserDetails,
@@ -114,7 +116,30 @@ export function App() {
return () => unsubscribe();
}, [updateLibrary]);
useEffect(() => {
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 () => {
const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) {
@@ -125,21 +150,26 @@ export function App() {
dispatch(setProfileBackground(profileBackground));
}
fetchUserDetails()
.then((response) => {
if (response) {
updateUserDetails(response);
}
})
.finally(() => {
if (document.getElementById("external-resources")) return;
const userPreferences = await window.electron.getUserPreferences();
const userDetails = await fetchUserDetails().catch(() => null);
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]);
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 onSignIn = useCallback(() => {
fetchUserDetails().then((response) => {
@@ -203,6 +233,7 @@ export function App() {
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
workwondersRef.current?.notifyUrlChange();
}, [location.pathname, location.search]);
useEffect(() => {

View File

@@ -0,0 +1,133 @@
.fullscreen-image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
cursor: pointer;
&__backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
&__container {
position: relative;
max-width: 95vw;
max-height: 95vh;
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
&__close-button {
position: fixed;
top: 52px;
right: 32px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: background-color 0.2s ease;
z-index: 10000;
pointer-events: auto;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&__image-container {
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
max-height: 100%;
}
&__image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
animation: scaleIn 0.2s ease-out;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 768px) {
.fullscreen-image-modal {
&__close-button {
top: 16px;
right: 16px;
width: 36px;
height: 36px;
}
&__container {
max-width: 100vw;
max-height: 100vh;
padding: 48px 16px 16px;
}
}
}
@media (max-height: 420px) {
.fullscreen-image-modal {
&__close-button {
top: 8px;
right: 8px;
width: 32px;
height: 32px;
}
&__container {
padding-top: 40px;
}
}
}

View File

@@ -0,0 +1,66 @@
import { useEffect } from "react";
import { XIcon } from "@primer/octicons-react";
import "./fullscreen-image-modal.scss";
interface FullscreenImageModalProps {
isOpen: boolean;
imageUrl: string;
imageAlt: string;
onClose: () => void;
}
export function FullscreenImageModal({
isOpen,
imageUrl,
imageAlt,
onClose,
}: Readonly<FullscreenImageModalProps>) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "unset";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<dialog className="fullscreen-image-modal" aria-modal="true" open>
<button
type="button"
className="fullscreen-image-modal__backdrop"
onClick={onClose}
aria-label="Close fullscreen image"
/>
<div className="fullscreen-image-modal__container">
<button
className="fullscreen-image-modal__close-button"
onClick={onClose}
aria-label="Close fullscreen image"
>
<XIcon size={24} />
</button>
<div className="fullscreen-image-modal__image-container">
<img
src={imageUrl}
alt={imageAlt}
className="fullscreen-image-modal__image"
loading="eager"
/>
</div>
</div>
</dialog>
);
}

View File

@@ -0,0 +1 @@
export { FullscreenImageModal } from "./fullscreen-image-modal";

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Supernova";
export const VERSION_CODENAME = "Harbinger";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
@@ -14,6 +14,7 @@ export const DOWNLOADER_NAME = {
[Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus",
[Downloader.VikingFile]: "VikingFile",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

@@ -1,6 +1,12 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { Badge, UserProfile, UserStats, UserGame } from "@types";
import type {
Badge,
UserProfile,
UserStats,
UserGame,
ProfileAchievement,
} from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@@ -202,22 +208,56 @@ export function UserProfileContextProvider({
getUserStats();
getUserLibraryGames();
return window.electron.hydraApi
.get<UserProfile>(`/users/${userId}`)
.then((userProfile) => {
setUserProfile(userProfile);
const currentLanguage = i18n.language.split("-")[0];
const supportedLanguages = ["pt", "ru", "es"];
const language = supportedLanguages.includes(currentLanguage)
? currentLanguage
: "en";
const params = new URLSearchParams({ language });
// Fetch main profile data
const profilePromise = window.electron.hydraApi
.get<UserProfile>(`/users/${userId}?${params.toString()}`)
.catch(() => {
showErrorToast(t("user_not_found"));
navigate(-1);
throw new Error("Profile not found");
});
// Fetch achievements separately
const achievementsPromise = window.electron.hydraApi
.get<
ProfileAchievement[]
>(`/users/${userId}/achievements?${params.toString()}`)
.catch(() => null); // If achievements fail, just return null
return Promise.all([profilePromise, achievementsPromise]).then(
([userProfile, achievements]) => {
// Merge achievements into the profile
const profileWithAchievements = {
...userProfile,
achievements: achievements || null,
};
setUserProfile(profileWithAchievements);
if (userProfile.profileImageUrl) {
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
(color) => setHeroBackground(color)
);
}
})
.catch(() => {
showErrorToast(t("user_not_found"));
navigate(-1);
});
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
}
);
}, [
navigate,
getUserStats,
getUserLibraryGames,
showErrorToast,
userId,
t,
i18n,
]);
const getBadges = useCallback(async () => {
const language = i18n.language.split("-")[0];

View File

@@ -282,7 +282,9 @@ declare global {
isStaging: () => Promise<boolean>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
getScreenshotsPath: () => Promise<string>;
isPortableVersion: () => Promise<boolean>;
openFolder: (path: string) => Promise<string>;
showOpenDialog: (
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;

View File

@@ -59,6 +59,7 @@ export function useUserDetails() {
username: userDetails?.username || "",
subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
workwondersJwt: userDetails?.workwondersJwt || "",
karma: userDetails?.karma || 0,
});
},
@@ -111,7 +112,7 @@ export function useUserDetails() {
);
const undoFriendship = (userId: string) =>
window.electron.hydraApi.delete(`/profile/friends/${userId}`);
window.electron.hydraApi.delete(`/profile/friend-requests/${userId}`);
const blockUser = (userId: string) =>
window.electron.hydraApi.post(`/users/${userId}/block`);

View File

@@ -2,9 +2,11 @@ import { useDate } from "@renderer/hooks";
import type { UserAchievement } from "@types";
import { useTranslation } from "react-i18next";
import "./achievements.scss";
import { EyeClosedIcon } from "@primer/octicons-react";
import { EyeClosedIcon, SearchIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { useState } from "react";
import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal";
interface AchievementListProps {
achievements: UserAchievement[];
@@ -16,17 +18,34 @@ export function AchievementList({
const { t } = useTranslation("achievement");
const { showHydraCloudModal } = useSubscription();
const { formatDateTime } = useDate();
const [fullscreenImage, setFullscreenImage] = useState<{
url: string;
alt: string;
} | null>(null);
const handleImageClick = (imageUrl: string, achievementName: string) => {
setFullscreenImage({
url: imageUrl,
alt: `${achievementName} screenshot`,
});
};
const closeFullscreenImage = () => {
setFullscreenImage(null);
};
return (
<ul className="achievements__list">
{achievements.map((achievement) => (
<li key={achievement.name} className="achievements__item">
<img
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div className="achievements__item-icon-container">
<img
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
</div>
<div className="achievements__item-content">
<h4 className="achievements__item-title">
@@ -44,6 +63,41 @@ export function AchievementList({
</div>
<div className="achievements__item-meta">
{achievement.imageUrl && achievement.unlocked && (
<div className="achievements__item-image-container">
<div className="achievements__item-custom-image-wrapper">
<button
type="button"
className="achievements__item-image-button"
onClick={() =>
achievement.imageUrl &&
handleImageClick(
achievement.imageUrl,
achievement.displayName
)
}
aria-label={`View ${achievement.displayName} screenshot in fullscreen`}
style={{
cursor: "pointer",
padding: 0,
border: "none",
background: "transparent",
}}
>
<img
className="achievements__item-custom-image"
src={achievement.imageUrl}
alt={`${achievement.displayName} screenshot`}
loading="lazy"
/>
</button>
<div className="achievements__item-custom-image-overlay">
<SearchIcon size={20} />
</div>
</div>
</div>
)}
{achievement.points != undefined ? (
<div
className="achievements__item-points"
@@ -66,6 +120,7 @@ export function AchievementList({
<p className="achievements__item-points-value">???</p>
</button>
)}
{achievement.unlockTime != null && (
<div
className="achievements__item-unlock-time"
@@ -79,6 +134,13 @@ export function AchievementList({
</div>
</li>
))}
<FullscreenImageModal
isOpen={fullscreenImage !== null}
imageUrl={fullscreenImage?.url || ""}
imageAlt={fullscreenImage?.alt || ""}
onClose={closeFullscreenImage}
/>
</ul>
);
}

View File

@@ -50,6 +50,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
className="achievements-content__profile-avatar"
src={user.profileImageUrl}
alt={user.displayName}
loading="lazy"
/>
) : (
<PersonIcon size={24} />
@@ -150,6 +151,7 @@ export function AchievementsContent({
className="achievements-content__comparison__small-avatar"
src={user.profileImageUrl}
alt={user.displayName}
loading="lazy"
/>
) : (
<PersonIcon size={24} />
@@ -166,6 +168,7 @@ export function AchievementsContent({
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="achievements-content__achievements-list__image"
alt={gameTitle}
loading="lazy"
/>
<section
@@ -186,6 +189,7 @@ export function AchievementsContent({
src={shopDetails?.assets?.logoImageUrl ?? ""}
className="achievements-content__achievements-list__section__container__hero__content__game-logo"
alt={gameTitle}
loading="lazy"
/>
</Link>
</div>

View File

@@ -117,6 +117,70 @@ $logo-max-width: 200px;
}
}
&-image-container {
display: flex;
align-items: center;
justify-content: center;
margin: 4px 0;
}
&-icon-container {
position: relative;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&-custom-image {
width: 120px;
height: 60px;
border-radius: 4px;
object-fit: cover;
opacity: 0.9;
transition: all 0.2s ease;
&:hover {
opacity: 1;
}
}
&-custom-image-wrapper {
position: relative;
display: inline-block;
cursor: pointer;
&:hover {
.achievements__item-custom-image {
filter: grayscale(50%) brightness(0.7);
}
.achievements__item-custom-image-overlay {
opacity: 1;
}
}
}
&-custom-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
color: white;
svg {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8));
}
}
&-content {
flex: 1;
}
@@ -153,6 +217,7 @@ $logo-max-width: 200px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: end;
}
&-points {

View File

@@ -19,23 +19,173 @@
color: globals.$body-color;
}
&__downloaders {
display: grid;
gap: globals.$spacing-unit;
grid-template-columns: repeat(2, 1fr);
&__downloaders-list-wrapper {
border: 1px solid globals.$border-color;
overflow: hidden;
background-color: globals.$dark-background-color;
}
&__downloader-option {
position: relative;
&__downloaders-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&:only-child {
grid-column: 1 / -1;
&::-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);
}
}
&__downloader-icon {
position: absolute;
left: calc(globals.$spacing-unit * 2);
&__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;
}
&__path-error {
@@ -49,4 +199,17 @@
&__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,17 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
Badge,
Button,
CheckboxField,
Link,
Modal,
TextField,
} from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import {
DownloadIcon,
SyncIcon,
CheckCircleFillIcon,
} from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUri } 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 {
@@ -51,6 +59,7 @@ export function DownloadSettingsModal({
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
null
);
const [showRealDebridModal, setShowRealDebridModal] = useState(false);
const { isFeatureEnabled, Feature } = useFeature();
@@ -78,18 +87,89 @@ export function DownloadSettingsModal({
}
}, [visible, checkFolderWritePermission, selectedPath]);
const downloaders = useMemo(() => {
return getDownloadersForUris(repack?.uris ?? []);
}, [repack?.uris]);
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 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;
}
@@ -112,26 +192,12 @@ export function DownloadSettingsModal({
.then((defaultDownloadsPath) => setSelectedPath(defaultDownloadsPath));
}
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;
});
const availableDownloaders = downloadOptions
.filter((option) => option.isAvailable)
.map((option) => option.downloader);
setSelectedDownloader(getDefaultDownloader(filteredDownloaders));
}, [
Feature,
isFeatureEnabled,
getDefaultDownloader,
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
setSelectedDownloader(getDefaultDownloader(availableDownloaders));
}, [getDefaultDownloader, userPreferences?.downloadsPath, downloadOptions]);
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
@@ -186,33 +252,144 @@ export function DownloadSettingsModal({
<div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span>
<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));
<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;
return (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
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")}
/>
);
}
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
);
})}
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>
</div>
</div>
@@ -264,13 +441,34 @@ export function DownloadSettingsModal({
disabled={
downloadStarting ||
selectedDownloader === null ||
!hasWritePermission
!hasWritePermission ||
downloadOptions.some(
(option) =>
option.downloader === selectedDownloader &&
(option.isAvailableButNotConfigured ||
(!option.isAvailable && option.canHandle) ||
!option.canHandle)
)
}
>
<DownloadIcon />
{t("download_now")}
{downloadStarting ? (
<>
<SyncIcon className="download-settings-modal__loading-spinner" />
{t("loading")}
</>
) : (
<>
<DownloadIcon />
{t("download_now")}
</>
)}
</Button>
</div>
<RealDebridInfoModal
visible={showRealDebridModal}
onClose={() => setShowRealDebridModal(false)}
/>
</Modal>
);
}

View File

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

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

@@ -114,6 +114,7 @@ export function Sidebar() {
}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<p>{achievement.displayName}</p>
@@ -164,6 +165,7 @@ export function Sidebar() {
}`}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<p>{achievement.displayName}</p>

View File

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

View File

@@ -8,6 +8,72 @@
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;
@@ -15,6 +81,12 @@
justify-content: flex-end;
}
&__content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
}
&__list {
display: flex;
flex-direction: column;
@@ -23,14 +95,22 @@
&__empty {
display: flex;
flex: 1;
width: 100%;
height: 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;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
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";
@@ -18,6 +18,11 @@ import type {
} 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();
@@ -34,12 +39,14 @@ export default function Notifications() {
>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set());
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 {
@@ -65,7 +72,11 @@ export default function Notifications() {
}, [i18n.language]);
const fetchApiNotifications = useCallback(
async (skip = 0, append = false) => {
async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
if (!userDetails) return;
try {
@@ -74,7 +85,7 @@ export default function Notifications() {
await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications",
{
params: { filter: "all", take: 20, skip },
params: { filter: filterParam, take: 20, skip },
needsAuth: true,
}
);
@@ -101,24 +112,24 @@ export default function Notifications() {
[userDetails]
);
const fetchAllNotifications = useCallback(async () => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(),
]);
setIsLoading(false);
}, [
fetchLocalNotifications,
fetchBadges,
fetchApiNotifications,
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();
}, [fetchAllNotifications]);
fetchAllNotifications(filter);
}, [fetchAllNotifications, filter]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(
@@ -130,6 +141,13 @@ export default function Notifications() {
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();
@@ -144,23 +162,28 @@ export default function Notifications() {
.filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const }));
const localWithSource: MergedNotification[] = localNotifications.map(
(n) => ({
// 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]);
}, [apiNotifications, localNotifications, filter]);
const displayedNotifications = useMemo(() => {
return mergedNotifications.filter((n) => !clearingIds.has(n.id));
}, [mergedNotifications, clearingIds]);
return mergedNotifications;
}, [mergedNotifications]);
const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged"));
@@ -251,42 +274,86 @@ export default function Notifications() {
[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 {
// Mark all as clearing for animation
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
setIsClearing(true);
// Wait for exit animation
await new Promise((resolve) => setTimeout(resolve, 300));
// Clear any existing timeouts
clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Clear all API notifications
if (userDetails && apiNotifications.length > 0) {
// 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,
});
setApiNotifications([]);
}
// Clear all local notifications
await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange();
showSuccessToast(t("cleared_all"));
} catch (error) {
logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
}
}, [
apiNotifications,
localNotifications,
displayedNotifications,
isClearing,
removeNotificationWithDelay,
userDetails,
showSuccessToast,
showErrorToast,
@@ -296,9 +363,19 @@ export default function Notifications() {
const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true);
fetchApiNotifications(pagination.skip, true, filter);
}
}, [pagination, isLoading, fetchApiNotifications]);
}, [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"));
@@ -317,10 +394,13 @@ export default function Notifications() {
return (
<motion.div
key={key}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }}
exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
transition={{ duration: 0.2 }}
>
{notification.source === "local" ? (
@@ -343,8 +423,57 @@ export default function Notifications() {
);
};
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 && mergedNotifications.length === 0) {
if (isLoading && hasNoNotifications) {
return (
<div className="notifications__loading">
<span>{t("loading")}</span>
@@ -352,36 +481,61 @@ export default function Notifications() {
);
}
if (mergedNotifications.length === 0) {
return (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>{t("empty_description")}</p>
</div>
);
}
return (
<div className="notifications">
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
<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>
<div className="notifications__list">
<AnimatePresence mode="popLayout">
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</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 && (
{pagination.hasMore && !isClearing && (
<div className="notifications__load-more">
<Button
theme="outline"

View File

@@ -100,8 +100,10 @@
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: none;
cursor: pointer;
transition: all ease 0.2s;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.1);

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import "../../../pages/game-details/modals/delete-review-modal.scss";
interface DeleteSouvenirModalProps {
visible: boolean;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteSouvenirModal({
visible,
onClose,
onConfirm,
}: Readonly<DeleteSouvenirModalProps>) {
const { t } = useTranslation("user_profile");
const handleDeleteSouvenir = () => {
onConfirm();
onClose();
};
return (
<Modal
visible={visible}
title={t("delete_souvenir_modal_title")}
description={t("delete_souvenir_modal_description")}
onClose={onClose}
>
<div className="delete-review-modal__actions">
<Button onClick={onClose} theme="outline">
{t("delete_souvenir_modal_cancel_button")}
</Button>
<Button onClick={handleDeleteSouvenir} theme="danger">
{t("delete_souvenir_modal_delete_button")}
</Button>
</div>
</Modal>
);
}

View File

@@ -206,6 +206,313 @@
display: block;
}
}
&__images-section {
margin-bottom: calc(globals.$spacing-unit * 3);
}
&__images-grid {
display: grid;
gap: calc(globals.$spacing-unit * 2);
padding-bottom: calc(globals.$spacing-unit);
grid-template-columns: repeat(2, 1fr);
@container #{globals.$app-container} (min-width: 1000px) {
grid-template-columns: repeat(3, 1fr);
}
@container #{globals.$app-container} (min-width: 1300px) {
grid-template-columns: repeat(4, 1fr);
}
@container #{globals.$app-container} (min-width: 2000px) {
grid-template-columns: repeat(5, 1fr);
}
@container #{globals.$app-container} (min-width: 2600px) {
grid-template-columns: repeat(6, 1fr);
}
@container #{globals.$app-container} (min-width: 3000px) {
grid-template-columns: repeat(8, 1fr);
}
}
&__image-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
transition: all ease 0.2s;
position: relative;
container-type: inline-size;
&:hover {
transform: translateY(-4px);
background: rgba(0, 0, 0, 0.4);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
@container (max-width: 240px) {
.profile-content__image-achievement-icon {
width: 20px;
height: 20px;
}
.profile-content__image-achievement-name {
font-size: 13px;
}
.profile-content__image-game-title {
font-size: 11px;
}
}
@container (max-width: 280px) {
.profile-content__image-card-content {
gap: calc(globals.$spacing-unit * 0.75);
}
}
}
&__image-card-header {
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
position: relative;
}
&__image-achievement-image-wrapper {
position: relative;
width: 100%;
height: 100%;
.profile-content__image-button {
width: 100%;
height: 100%;
display: block;
}
&:hover .profile-content__image-achievement-image-overlay {
opacity: 1;
}
}
&__image-achievement-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
display: block;
}
&__image-achievement-image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
svg {
color: white;
}
}
&__image-delete-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
background: transparent;
border: 1px solid rgba(244, 67, 54, 0.5);
border-radius: 6px;
padding: 0;
color: rgba(244, 67, 54, 0.9);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 32px;
height: 32px;
&:hover {
background: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.8);
color: #ff7961;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: rgba(244, 67, 54, 0.2);
}
}
// Show overlay on keyboard focus for accessibility
&__image-button:focus-visible + &__image-achievement-image-overlay {
opacity: 1;
}
&__image-card-content {
padding: 16px;
background: #121212;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
overflow: hidden;
}
&__image-card-row {
display: flex;
gap: calc(globals.$spacing-unit * 1.5);
align-items: center;
}
&__image-achievement-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
&__image-achievement-description {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
&__image-card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 8px;
flex-shrink: 0;
height: 100%;
}
&__image-unlock-time {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.9);
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
z-index: 2;
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.1);
}
&__image-achievement-info {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
position: relative;
z-index: 2;
}
&__image-achievement-icon {
width: 24px;
height: 24px;
border-radius: 4px;
flex-shrink: 0;
&--large {
width: 36px;
height: 36px;
}
}
&__image-achievement-name {
font-size: 14px;
font-weight: 600;
color: white;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
flex: 1;
min-width: 0;
}
&__image-game-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 1);
position: relative;
z-index: 2;
min-width: 0;
}
&__image-game-left {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
flex: 1;
min-width: 0;
}
&__image-game-icon {
width: 16px;
height: 16px;
border-radius: 2px;
flex-shrink: 0;
}
&__image-game-title {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
line-height: 1.2;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__image-item {
flex-shrink: 0;
position: relative;
border-radius: 8px;
overflow: hidden;
transition: transform ease 0.2s;
&:hover {
transform: scale(1.05);
}
}
&__image {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
transition: border-color ease 0.2s;
&:hover {
border-color: rgba(255, 255, 255, 0.3);
}
}
}
// Reviews minimal styles

View File

@@ -10,6 +10,7 @@ import {
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import type { GameShop } from "@types";
import { LockedProfile } from "./locked-profile";
@@ -18,14 +19,17 @@ import { BadgesBox } from "./badges-box";
import { FriendsBox, FriendsBoxAddButton } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { logger } from "@renderer/logger";
import { AnimatePresence } from "framer-motion";
import { ProfileSection } from "../profile-section/profile-section";
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { ProfileTabs, type ProfileTabType } from "./profile-tabs";
import { LibraryTab } from "./library-tab";
import { ReviewsTab } from "./reviews-tab";
import { AnimatePresence } from "framer-motion";
import { SouvenirsTab } from "./souvenirs-tab";
import "./profile-content.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently";
@@ -85,6 +89,7 @@ export function ProfileContent() {
userStats,
libraryGames,
pinnedGames,
getUserProfile,
getUserLibraryGames,
loadMoreLibraryGames,
hasMoreLibraryGames,
@@ -94,6 +99,10 @@ export function ProfileContent() {
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const [fullscreenImage, setFullscreenImage] = useState<{
url: string;
alt: string;
} | null>(null);
const statsAnimation = useRef(-1);
const [activeTab, setActiveTab] = useState<ProfileTabType>("library");
@@ -206,7 +215,7 @@ export function ProfileContent() {
setReviews((prev) => prev.filter((review) => review.id !== reviewId));
setReviewsTotalCount((prev) => prev - 1);
} catch (error) {
console.error("Failed to delete review:", error);
logger.error("Failed to delete review:", error);
}
};
@@ -301,7 +310,7 @@ export function ProfileContent() {
`/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}`
);
} catch (error) {
console.error("Failed to vote on review:", error);
logger.error("Failed to vote on review:", error);
// Rollback optimistic update on error
setReviews((prev) =>
@@ -335,6 +344,17 @@ export function ProfileContent() {
setIsAnimationRunning(true);
};
const handleImageClick = (imageUrl: string, achievementName: string) => {
setFullscreenImage({
url: imageUrl,
alt: `${achievementName} screenshot`,
});
};
const closeFullscreenImage = () => {
setFullscreenImage(null);
};
useEffect(() => {
let zero = performance.now();
if (!isAnimationRunning) return;
@@ -381,46 +401,70 @@ export function ProfileContent() {
return (
<section className="profile-content__section">
<div className="profile-content__main">
<ProfileTabs
activeTab={activeTab}
reviewsTotalCount={reviewsTotalCount}
onTabChange={setActiveTab}
/>
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
<div className="profile-content__tab-panels">
<AnimatePresence mode="wait">
{activeTab === "library" && (
<LibraryTab
sortBy={sortBy}
onSortChange={setSortBy}
pinnedGames={pinnedGames}
libraryGames={libraryGames}
hasMoreLibraryGames={hasMoreLibraryGames}
isLoadingLibraryGames={isLoadingLibraryGames}
statsIndex={statsIndex}
userStats={userStats}
animatedGameIdsRef={animatedGameIdsRef}
onLoadMore={handleLoadMore}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
isMe={isMe}
/>
)}
{hasAnyGames && (
<div>
<ProfileTabs
activeTab={activeTab}
reviewsTotalCount={reviewsTotalCount}
souvenirsCount={userProfile?.achievements?.length || 0}
onTabChange={setActiveTab}
/>
{activeTab === "reviews" && (
<ReviewsTab
reviews={reviews}
isLoadingReviews={isLoadingReviews}
votingReviews={votingReviews}
userDetailsId={userDetails?.id}
formatPlayTime={formatPlayTime}
getRatingText={getRatingText}
onVote={handleVoteReview}
onDelete={handleDeleteClick}
/>
)}
</AnimatePresence>
</div>
<div className="profile-content__tab-panels">
<AnimatePresence mode="wait">
{activeTab === "library" && (
<LibraryTab
sortBy={sortBy}
onSortChange={setSortBy}
pinnedGames={pinnedGames}
libraryGames={libraryGames}
hasMoreLibraryGames={hasMoreLibraryGames}
isLoadingLibraryGames={isLoadingLibraryGames}
statsIndex={statsIndex}
userStats={userStats}
animatedGameIdsRef={animatedGameIdsRef}
onLoadMore={handleLoadMore}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
isMe={isMe}
/>
)}
{activeTab === "reviews" && (
<ReviewsTab
reviews={reviews}
isLoadingReviews={isLoadingReviews}
votingReviews={votingReviews}
userDetailsId={userDetails?.id}
formatPlayTime={formatPlayTime}
getRatingText={getRatingText}
onVote={handleVoteReview}
onDelete={handleDeleteClick}
/>
)}
{activeTab === "souvenirs" && (
<SouvenirsTab
achievements={userProfile?.achievements || []}
onImageClick={handleImageClick}
isMe={isMe}
onAchievementDeleted={getUserProfile}
/>
)}
</AnimatePresence>
</div>
</div>
)}
</div>
{shouldShowRightContent && (
@@ -475,15 +519,25 @@ export function ProfileContent() {
statsIndex,
libraryGames,
pinnedGames,
sortBy,
activeTab,
// ensure reviews UI updates correctly
reviews,
reviewsTotalCount,
isLoadingReviews,
votingReviews,
deleteModalVisible,
handleOnMouseEnterGameCard,
handleOnMouseLeaveGameCard,
handleImageClick,
handleLoadMore,
formatPlayTime,
getRatingText,
handleVoteReview,
handleDeleteClick,
userDetails,
animatedGameIdsRef,
hasMoreLibraryGames,
isLoadingLibraryGames,
]);
return (
@@ -491,6 +545,13 @@ export function ProfileContent() {
<ProfileHero />
{content}
<FullscreenImageModal
isOpen={fullscreenImage !== null}
imageUrl={fullscreenImage?.url || ""}
imageAlt={fullscreenImage?.alt || ""}
onClose={closeFullscreenImage}
/>
</div>
);
}

View File

@@ -2,17 +2,19 @@ import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import "./profile-content.scss";
export type ProfileTabType = "library" | "reviews";
export type ProfileTabType = "library" | "reviews" | "souvenirs";
interface ProfileTabsProps {
activeTab: ProfileTabType;
reviewsTotalCount: number;
souvenirsCount: number;
onTabChange: (tab: ProfileTabType) => void;
}
export function ProfileTabs({
activeTab,
reviewsTotalCount,
souvenirsCount,
onTabChange,
}: Readonly<ProfileTabsProps>) {
const { t } = useTranslation("user_profile");
@@ -64,6 +66,29 @@ export function ProfileTabs({
/>
)}
</div>
<div className="profile-content__tab-wrapper">
<button
type="button"
className={`profile-content__tab ${activeTab === "souvenirs" ? "profile-content__tab--active" : ""}`}
onClick={() => onTabChange("souvenirs")}
>
{t("souvenirs")}
{souvenirsCount > 0 && (
<span className="profile-content__tab-badge">{souvenirsCount}</span>
)}
</button>
{activeTab === "souvenirs" && (
<motion.div
className="profile-content__tab-underline"
layoutId="tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { ChevronDownIcon, ChevronRightIcon } from "@primer/octicons-react";
import { TrashIcon, Maximize2 } from "lucide-react";
import { useState, useMemo } from "react";
import type { ProfileAchievement } from "@types";
import { useToast, useDate } from "@renderer/hooks";
import { logger } from "@renderer/logger";
import { DeleteSouvenirModal } from "./delete-souvenir-modal";
import "./profile-content.scss";
interface SouvenirGameGroupProps {
gameTitle: string;
gameIconUrl: string | null;
achievements: ProfileAchievement[];
isMe: boolean;
deletingIds: Set<string>;
onImageClick: (imageUrl: string, achievementName: string) => void;
onDeleteClick: (achievement: ProfileAchievement) => void;
}
function SouvenirGameGroup({
gameTitle,
gameIconUrl,
achievements,
isMe,
deletingIds,
onImageClick,
onDeleteClick,
}: Readonly<SouvenirGameGroupProps>) {
const { formatDistance } = useDate();
const [isExpanded, setIsExpanded] = useState(true);
return (
<div className="profile-content__images-section">
<button
className="profile-content__section-header"
onClick={() => setIsExpanded(!isExpanded)}
type="button"
style={{
width: "100%",
background: "none",
border: "none",
cursor: "pointer",
color: "inherit",
padding: 0,
}}
>
<div className="profile-content__section-title-group">
<div className="profile-content__collapse-button">
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
</div>
{gameIconUrl && (
<img
src={gameIconUrl}
alt=""
style={{
width: 24,
height: 24,
borderRadius: 4,
objectFit: "cover",
}}
/>
)}
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{gameTitle}
</h3>
<span className="profile-content__section-badge">
{achievements.length}
</span>
</div>
</button>
{isExpanded && (
<div className="profile-content__images-grid">
{achievements.map((achievement, index) => (
<div
key={`${achievement.gameTitle}-${achievement.name}-${index}`}
className="profile-content__image-card"
>
<div className="profile-content__image-card-header">
<div className="profile-content__image-achievement-image-wrapper">
<button
type="button"
className="profile-content__image-button"
onClick={() =>
onImageClick(
achievement.imageUrl,
achievement.displayName
)
}
aria-label={`View ${achievement.displayName} screenshot in fullscreen`}
style={{
cursor: "pointer",
padding: 0,
border: "none",
background: "transparent",
}}
>
<img
src={achievement.imageUrl}
alt={achievement.displayName}
className="profile-content__image-achievement-image"
loading="lazy"
/>
</button>
<div className="profile-content__image-achievement-image-overlay">
<Maximize2 size={24} />
</div>
<span className="profile-content__image-unlock-time">
{formatDistance(
new Date(achievement.unlockTime),
new Date(),
{
addSuffix: true,
}
)}
</span>
</div>
</div>
<div className="profile-content__image-card-content">
<div className="profile-content__image-card-row">
{achievement.achievementIcon && (
<img
src={achievement.achievementIcon}
alt=""
className="profile-content__image-achievement-icon profile-content__image-achievement-icon--large"
loading="lazy"
/>
)}
<div className="profile-content__image-achievement-text">
<span className="profile-content__image-achievement-name">
{achievement.displayName}
</span>
<p className="profile-content__image-achievement-description">
{achievement.description}
</p>
</div>
<div className="profile-content__image-card-right">
{isMe && (
<button
type="button"
className="profile-content__image-delete-button"
onClick={() => onDeleteClick(achievement)}
aria-label={`Delete ${achievement.displayName} souvenir`}
disabled={deletingIds.has(achievement.id)}
>
<TrashIcon size={14} />
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
interface SouvenirsTabProps {
achievements: ProfileAchievement[];
onImageClick: (imageUrl: string, achievementName: string) => void;
isMe: boolean;
onAchievementDeleted: () => void;
}
export function SouvenirsTab({
achievements,
onImageClick,
isMe,
onAchievementDeleted,
}: Readonly<SouvenirsTabProps>) {
const { t } = useTranslation("user_profile");
const { showSuccessToast, showErrorToast } = useToast();
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [achievementToDelete, setAchievementToDelete] =
useState<ProfileAchievement | null>(null);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const handleDeleteAchievement = async (achievement: ProfileAchievement) => {
if (deletingIds.has(achievement.id)) return;
setDeletingIds((prev) => new Set(prev).add(achievement.id));
try {
await window.electron.hydraApi.delete(
`/profile/games/achievements/${achievement.gameId}/${achievement.name}/image`
);
showSuccessToast(
t("souvenir_deleted_successfully", "Souvenir deleted successfully")
);
onAchievementDeleted();
} catch (error) {
logger.error("Failed to delete souvenir:", error);
showErrorToast(
t("souvenir_deletion_failed", "Failed to delete souvenir")
);
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(achievement.id);
return next;
});
}
};
const handleDeleteClick = (achievement: ProfileAchievement) => {
setAchievementToDelete(achievement);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = () => {
if (achievementToDelete) {
handleDeleteAchievement(achievementToDelete);
setAchievementToDelete(null);
}
};
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
setAchievementToDelete(null);
};
const groupedAchievements = useMemo(() => {
const groups: Record<string, ProfileAchievement[]> = {};
for (const achievement of achievements) {
if (!groups[achievement.gameId]) {
groups[achievement.gameId] = [];
}
groups[achievement.gameId].push(achievement);
}
return groups;
}, [achievements]);
return (
<>
<motion.div
key="souvenirs"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{achievements.length === 0 && (
<div className="profile-content__no-souvenirs">
<p>{t("no_souvenirs", "No souvenirs yet")}</p>
</div>
)}
{Object.entries(groupedAchievements).map(
([gameId, groupAchievements]) => {
const firstAchievement = groupAchievements[0];
return (
<SouvenirGameGroup
key={gameId}
gameTitle={firstAchievement.gameTitle}
gameIconUrl={firstAchievement.gameIconUrl}
achievements={groupAchievements}
isMe={isMe}
deletingIds={deletingIds}
onImageClick={onImageClick}
onDeleteClick={handleDeleteClick}
/>
);
}
)}
</motion.div>
<DeleteSouvenirModal
visible={deleteModalVisible}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -123,11 +123,6 @@ export function UserStatsBox() {
{t("karma_count")}
</p>
</div>
<div className="user-stats__karma-info">
<small className="user-stats__karma-info-text">
{t("karma_description")}
</small>
</div>
</li>
)}
</ul>

View File

@@ -0,0 +1,214 @@
@use "../../scss/globals.scss";
.settings-achievements {
&__checkbox-container {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
&--enabled {
opacity: 1;
cursor: pointer;
}
&--with-tooltip {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
&--tooltip {
cursor: pointer;
}
}
&__button-container {
margin-top: 16px;
}
&__section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
&-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
> * + * {
margin-top: 16px;
}
&--achievements {
// First section sits flush with container top
margin-top: 0;
padding-top: 0;
border-top: none;
}
}
&__achievement-custom-notification-position__select-variation {
max-width: 300px;
}
&__test-achievement-notification-button {
margin-top: 8px;
}
&__volume-control {
display: flex;
flex-direction: column;
gap: 12px;
label {
font-size: 14px;
color: globals.$muted-color;
}
}
&__volume-slider-wrapper {
display: flex;
align-items: center;
gap: 8px;
width: 200px;
position: relative;
--volume-percent: 0%;
}
&__volume-icon {
color: globals.$muted-color;
flex-shrink: 0;
}
&__volume-value {
font-size: 14px;
color: globals.$body-color;
font-weight: 500;
min-width: 40px;
text-align: right;
flex-shrink: 0;
}
&__volume-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: globals.$dark-background-color;
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
transition: background 0.2s;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
margin-top: -6px;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
}
&:active {
transform: scale(1.05);
}
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
margin-top: -6px;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
}
&:active {
transform: scale(1.05);
}
}
&::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
globals.$muted-color 0%,
globals.$muted-color var(--volume-percent),
globals.$dark-background-color var(--volume-percent),
globals.$dark-background-color 100%
);
}
&::-moz-range-track {
width: 100%;
height: 6px;
border-radius: 3px;
background: globals.$dark-background-color;
}
&::-moz-range-progress {
height: 6px;
border-radius: 3px;
background: globals.$muted-color;
}
&:focus {
outline: none;
&::-webkit-slider-thumb {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
&::-moz-range-thumb {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
}
&::-ms-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: globals.$muted-color;
cursor: pointer;
border: 2px solid globals.$background-color;
}
&::-ms-track {
width: 100%;
height: 6px;
background: transparent;
border-color: transparent;
color: transparent;
}
&::-ms-fill-lower {
background: globals.$muted-color;
border-radius: 3px;
}
&::-ms-fill-upper {
background: globals.$dark-background-color;
border-radius: 3px;
}
}
}

View File

@@ -0,0 +1,264 @@
import {
useContext,
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from "react";
import { useTranslation } from "react-i18next";
import { CheckboxField, Button, SelectField } from "@renderer/components";
import { useAppSelector } from "@renderer/hooks";
import { settingsContext } from "@renderer/context";
import "./settings-achievements.scss";
import { QuestionIcon, UnmuteIcon } from "@primer/octicons-react";
import { AchievementCustomNotificationPosition } from "@types";
export function SettingsAchievements() {
const { t } = useTranslation("settings");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { updateUserPreferences } = useContext(settingsContext);
const [form, setForm] = useState({
showHiddenAchievementsDescription: false,
enableSteamAchievements: false,
enableAchievementScreenshots: false,
achievementNotificationsEnabled: true,
achievementCustomNotificationsEnabled: true,
achievementCustomNotificationPosition:
"top-left" as AchievementCustomNotificationPosition,
achievementSoundVolume: 15,
});
const volumeUpdateTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (userPreferences) {
setForm((prev) => ({
...prev,
showHiddenAchievementsDescription:
userPreferences.showHiddenAchievementsDescription ?? false,
enableSteamAchievements:
userPreferences.enableSteamAchievements ?? false,
enableAchievementScreenshots:
userPreferences.enableAchievementScreenshots ?? false,
achievementNotificationsEnabled:
userPreferences.achievementNotificationsEnabled ?? true,
achievementCustomNotificationsEnabled:
userPreferences.achievementCustomNotificationsEnabled ?? true,
achievementCustomNotificationPosition:
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementSoundVolume: Math.round(
(userPreferences.achievementSoundVolume ?? 0.15) * 100
),
}));
}
}, [userPreferences]);
const achievementCustomNotificationPositionOptions = useMemo(() => {
return [
"top-left",
"top-center",
"top-right",
"bottom-left",
"bottom-center",
"bottom-right",
].map((position) => ({
key: position,
value: position,
label: t(position),
}));
}, [t]);
const handleChange = async (values: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...values }));
await updateUserPreferences(values);
};
const handleVolumeChange = useCallback(
(newVolume: number) => {
setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume }));
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
volumeUpdateTimeoutRef.current = setTimeout(() => {
updateUserPreferences({ achievementSoundVolume: newVolume / 100 });
}, 300);
},
[updateUserPreferences]
);
const handleChangeAchievementCustomNotificationPosition = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const value = event.target.value as AchievementCustomNotificationPosition;
await handleChange({ achievementCustomNotificationPosition: value });
window.electron.updateAchievementCustomNotificationWindow();
};
return (
<div className="settings-achievements">
<div className="settings-achievements__section settings-achievements__section--achievements">
<CheckboxField
label={t("show_hidden_achievement_description")}
checked={form.showHiddenAchievementsDescription}
onChange={() =>
handleChange({
showHiddenAchievementsDescription:
!form.showHiddenAchievementsDescription,
})
}
/>
<div className="settings-achievements__checkbox-container--with-tooltip">
<CheckboxField
label={t("enable_steam_achievements")}
checked={form.enableSteamAchievements}
onChange={() =>
handleChange({
enableSteamAchievements: !form.enableSteamAchievements,
})
}
/>
<small
className="settings-achievements__checkbox-container--tooltip"
data-open-article="steam-achievements"
>
<QuestionIcon size={12} />
</small>
</div>
<div className="settings-achievements__checkbox-container--with-tooltip">
<CheckboxField
label={t("enable_achievement_screenshots")}
checked={form.enableAchievementScreenshots}
disabled={window.electron.platform === "linux"}
onChange={() =>
handleChange({
enableAchievementScreenshots:
!form.enableAchievementScreenshots,
})
}
/>
<small
className="settings-achievements__checkbox-container--tooltip"
data-open-article="achievement-souvenirs"
>
<QuestionIcon size={12} />
</small>
</div>
<div className="settings-achievements__button-container">
<Button
theme="outline"
disabled={window.electron.platform === "linux"}
onClick={async () => {
const screenshotsPath =
await window.electron.getScreenshotsPath();
window.electron.openFolder(screenshotsPath);
}}
>
{t("open_screenshots_directory")}
</Button>
</div>
</div>
<div className="settings-achievements__section settings-achievements__section--notifications">
<h3 className="settings-achievements__section-title">
{t("notifications")}
</h3>
<CheckboxField
label={t("enable_achievement_notifications")}
checked={form.achievementNotificationsEnabled}
onChange={async () => {
await handleChange({
achievementNotificationsEnabled:
!form.achievementNotificationsEnabled,
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
<CheckboxField
label={t("enable_achievement_custom_notifications")}
checked={form.achievementCustomNotificationsEnabled}
disabled={!form.achievementNotificationsEnabled}
onChange={async () => {
await handleChange({
achievementCustomNotificationsEnabled:
!form.achievementCustomNotificationsEnabled,
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
{form.achievementNotificationsEnabled &&
form.achievementCustomNotificationsEnabled && (
<>
<SelectField
className="settings-achievements__achievement-custom-notification-position__select-variation"
label={t("achievement_custom_notification_position")}
value={form.achievementCustomNotificationPosition}
onChange={handleChangeAchievementCustomNotificationPosition}
options={achievementCustomNotificationPositionOptions}
/>
<Button
className="settings-achievements__test-achievement-notification-button"
onClick={() =>
window.electron.showAchievementTestNotification()
}
>
{t("test_notification")}
</Button>
</>
)}
{form.achievementNotificationsEnabled && (
<div className="settings-achievements__volume-control">
<label htmlFor="achievement-volume">
{t("achievement_sound_volume")}
</label>
<div className="settings-achievements__volume-slider-wrapper">
<UnmuteIcon
size={16}
className="settings-achievements__volume-icon"
/>
<input
id="achievement-volume"
type="range"
min="0"
max="100"
value={form.achievementSoundVolume}
onChange={(e) => {
const volumePercent = parseInt(e.target.value, 10);
if (!isNaN(volumePercent)) {
handleVolumeChange(volumePercent);
}
}}
className="settings-achievements__volume-slider"
style={
{
"--volume-percent": `${form.achievementSoundVolume}%`,
} as React.CSSProperties
}
/>
<span className="settings-achievements__volume-value">
{form.achievementSoundVolume}%
</span>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -21,4 +21,25 @@
cursor: pointer;
}
}
&__button-container {
margin-top: 16px;
}
&__section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
&-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
// Add spacing between elements in the section
> * + * {
margin-top: 16px;
}
}
}

View File

@@ -5,7 +5,6 @@ import { CheckboxField } from "@renderer/components";
import { useAppSelector } from "@renderer/hooks";
import { settingsContext } from "@renderer/context";
import "./settings-behavior.scss";
import { QuestionIcon } from "@primer/octicons-react";
export function SettingsBehavior() {
const userPreferences = useAppSelector(
@@ -23,10 +22,8 @@ export function SettingsBehavior() {
disableNsfwAlert: false,
enableAutoInstall: false,
seedAfterDownloadComplete: false,
showHiddenAchievementsDescription: false,
showDownloadSpeedInMegabytes: false,
extractFilesByDefault: true,
enableSteamAchievements: false,
autoplayGameTrailers: true,
hideToTrayOnGameStart: false,
enableNewDownloadOptionsBadges: true,
@@ -45,13 +42,9 @@ export function SettingsBehavior() {
enableAutoInstall: userPreferences.enableAutoInstall ?? false,
seedAfterDownloadComplete:
userPreferences.seedAfterDownloadComplete ?? false,
showHiddenAchievementsDescription:
userPreferences.showHiddenAchievementsDescription ?? false,
showDownloadSpeedInMegabytes:
userPreferences.showDownloadSpeedInMegabytes ?? false,
extractFilesByDefault: userPreferences.extractFilesByDefault ?? true,
enableSteamAchievements:
userPreferences.enableSteamAchievements ?? false,
autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true,
hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false,
enableNewDownloadOptionsBadges:
@@ -163,17 +156,6 @@ export function SettingsBehavior() {
}
/>
<CheckboxField
label={t("show_hidden_achievement_description")}
checked={form.showHiddenAchievementsDescription}
onChange={() =>
handleChange({
showHiddenAchievementsDescription:
!form.showHiddenAchievementsDescription,
})
}
/>
<CheckboxField
label={t("show_download_speed_in_megabytes")}
checked={form.showDownloadSpeedInMegabytes}
@@ -194,25 +176,6 @@ export function SettingsBehavior() {
}
/>
<div className={`settings-behavior__checkbox-container--with-tooltip`}>
<CheckboxField
label={t("enable_steam_achievements")}
checked={form.enableSteamAchievements}
onChange={() =>
handleChange({
enableSteamAchievements: !form.enableSteamAchievements,
})
}
/>
<small
className="settings-behavior__checkbox-container--tooltip"
data-open-article="steam-achievements"
>
<QuestionIcon size={12} />
</small>
</div>
<CheckboxField
label={t("enable_new_download_options_badges")}
checked={form.enableNewDownloadOptionsBadges}

View File

@@ -1,11 +1,4 @@
import {
useContext,
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from "react";
import { useContext, useEffect, useState } from "react";
import {
TextField,
Button,
@@ -19,9 +12,8 @@ import languageResources from "@locales";
import { orderBy } from "lodash-es";
import { settingsContext } from "@renderer/context";
import "./settings-general.scss";
import { DesktopDownloadIcon, UnmuteIcon } from "@primer/octicons-react";
import { DesktopDownloadIcon } from "@primer/octicons-react";
import { logger } from "@renderer/logger";
import { AchievementCustomNotificationPosition } from "@types";
interface LanguageOption {
option: string;
@@ -46,11 +38,6 @@ export function SettingsGeneral() {
repackUpdatesNotificationsEnabled: false,
friendRequestNotificationsEnabled: false,
friendStartGameNotificationsEnabled: true,
achievementNotificationsEnabled: true,
achievementCustomNotificationsEnabled: true,
achievementCustomNotificationPosition:
"top-left" as AchievementCustomNotificationPosition,
achievementSoundVolume: 15,
language: "",
customStyles: window.localStorage.getItem("customStyles") || "",
});
@@ -59,8 +46,6 @@ export function SettingsGeneral() {
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
const volumeUpdateTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
window.electron.getDefaultDownloadsPath().then((path) => {
setDefaultDownloadsPath(path);
@@ -91,9 +76,6 @@ export function SettingsGeneral() {
return () => {
clearInterval(interval);
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
};
}, []);
@@ -117,15 +99,6 @@ export function SettingsGeneral() {
userPreferences.downloadNotificationsEnabled ?? false,
repackUpdatesNotificationsEnabled:
userPreferences.repackUpdatesNotificationsEnabled ?? false,
achievementNotificationsEnabled:
userPreferences.achievementNotificationsEnabled ?? true,
achievementCustomNotificationsEnabled:
userPreferences.achievementCustomNotificationsEnabled ?? true,
achievementCustomNotificationPosition:
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementSoundVolume: Math.round(
(userPreferences.achievementSoundVolume ?? 0.15) * 100
),
friendRequestNotificationsEnabled:
userPreferences.friendRequestNotificationsEnabled ?? false,
friendStartGameNotificationsEnabled:
@@ -135,21 +108,6 @@ export function SettingsGeneral() {
}
}, [userPreferences, defaultDownloadsPath]);
const achievementCustomNotificationPositionOptions = useMemo(() => {
return [
"top-left",
"top-center",
"top-right",
"bottom-left",
"bottom-center",
"bottom-right",
].map((position) => ({
key: position,
value: position,
label: t(position),
}));
}, [t]);
const handleLanguageChange = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
@@ -164,31 +122,6 @@ export function SettingsGeneral() {
await updateUserPreferences(values);
};
const handleVolumeChange = useCallback(
(newVolume: number) => {
setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume }));
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
volumeUpdateTimeoutRef.current = setTimeout(() => {
updateUserPreferences({ achievementSoundVolume: newVolume / 100 });
}, 300);
},
[updateUserPreferences]
);
const handleChangeAchievementCustomNotificationPosition = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const value = event.target.value as AchievementCustomNotificationPosition;
await handleChange({ achievementCustomNotificationPosition: value });
window.electron.updateAchievementCustomNotificationWindow();
};
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
defaultPath: form.downloadsPath,
@@ -293,86 +226,6 @@ export function SettingsGeneral() {
}
/>
<CheckboxField
label={t("enable_achievement_notifications")}
checked={form.achievementNotificationsEnabled}
onChange={async () => {
await handleChange({
achievementNotificationsEnabled:
!form.achievementNotificationsEnabled,
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
<CheckboxField
label={t("enable_achievement_custom_notifications")}
checked={form.achievementCustomNotificationsEnabled}
disabled={!form.achievementNotificationsEnabled}
onChange={async () => {
await handleChange({
achievementCustomNotificationsEnabled:
!form.achievementCustomNotificationsEnabled,
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
{form.achievementNotificationsEnabled &&
form.achievementCustomNotificationsEnabled && (
<>
<SelectField
className="settings-general__achievement-custom-notification-position__select-variation"
label={t("achievement_custom_notification_position")}
value={form.achievementCustomNotificationPosition}
onChange={handleChangeAchievementCustomNotificationPosition}
options={achievementCustomNotificationPositionOptions}
/>
<Button
className="settings-general__test-achievement-notification-button"
onClick={() => window.electron.showAchievementTestNotification()}
>
{t("test_notification")}
</Button>
</>
)}
{form.achievementNotificationsEnabled && (
<div className="settings-general__volume-control">
<label htmlFor="achievement-volume">
{t("achievement_sound_volume")}
</label>
<div className="settings-general__volume-slider-wrapper">
<UnmuteIcon size={16} className="settings-general__volume-icon" />
<input
id="achievement-volume"
type="range"
min="0"
max="100"
value={form.achievementSoundVolume}
onChange={(e) => {
const volumePercent = parseInt(e.target.value, 10);
if (!isNaN(volumePercent)) {
handleVolumeChange(volumePercent);
}
}}
className="settings-general__volume-slider"
style={
{
"--volume-percent": `${form.achievementSoundVolume}%`,
} as React.CSSProperties
}
/>
<span className="settings-general__volume-value">
{form.achievementSoundVolume}%
</span>
</div>
</div>
)}
<h2 className="settings-general__section-title">{t("common_redist")}</h2>
<p className="settings-general__common-redist-description">

View File

@@ -2,26 +2,35 @@
.settings {
&__container {
padding: 24px;
padding: 16px;
width: 100%;
display: flex;
align-items: flex-start;
}
&__sidebar {
width: 240px;
min-width: 240px;
margin-right: 12px;
background-color: globals.$background-color;
border: solid 1px globals.$border-color;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
align-self: flex-start;
}
&__content {
background-color: globals.$background-color;
width: 100%;
height: 100%;
flex: 1;
padding: calc(globals.$spacing-unit * 3);
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 15px 0px #000000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-radius: 8px;
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
}
&__categories {
display: flex;
gap: globals.$spacing-unit;
}
}

View File

@@ -1,8 +1,8 @@
import { Button } from "@renderer/components";
import { useTranslation } from "react-i18next";
import { SettingsGeneral } from "./settings-general";
import { SettingsBehavior } from "./settings-behavior";
import { SettingsDownloadSources } from "./settings-download-sources";
import { SettingsAchievements } from "./settings-achievements";
import {
SettingsContextConsumer,
SettingsContextProvider,
@@ -13,6 +13,16 @@ import { useMemo } from "react";
import "./settings.scss";
import { SettingsAppearance } from "./aparence/settings-appearance";
import { SettingsDebrid } from "./settings-debrid";
import cn from "classnames";
import {
GearIcon,
ToolsIcon,
TrophyIcon,
DownloadIcon,
PaintbrushIcon,
CloudIcon,
PersonIcon,
} from "@primer/octicons-react";
export default function Settings() {
const { t } = useTranslation("settings");
@@ -21,20 +31,34 @@ export default function Settings() {
const categories = useMemo(() => {
const categories = [
{ tabLabel: t("general"), contentTitle: t("general") },
{ tabLabel: t("behavior"), contentTitle: t("behavior") },
{ tabLabel: t("download_sources"), contentTitle: t("download_sources") },
{ tabLabel: t("general"), contentTitle: t("general"), Icon: GearIcon },
{ tabLabel: t("behavior"), contentTitle: t("behavior"), Icon: ToolsIcon },
{
tabLabel: t("achievements"),
contentTitle: t("achievements"),
Icon: TrophyIcon,
},
{
tabLabel: t("download_sources"),
contentTitle: t("download_sources"),
Icon: DownloadIcon,
},
{
tabLabel: t("appearance"),
contentTitle: t("appearance"),
Icon: PaintbrushIcon,
},
{ tabLabel: t("debrid"), contentTitle: t("debrid") },
{ tabLabel: t("debrid"), contentTitle: t("debrid"), Icon: CloudIcon },
];
if (userDetails)
return [
...categories,
{ tabLabel: t("account"), contentTitle: t("account") },
{
tabLabel: t("account"),
contentTitle: t("account"),
Icon: PersonIcon,
},
];
return categories;
}, [userDetails, t]);
@@ -53,14 +77,18 @@ export default function Settings() {
}
if (currentCategoryIndex === 2) {
return <SettingsDownloadSources />;
return <SettingsAchievements />;
}
if (currentCategoryIndex === 3) {
return <SettingsAppearance appearance={appearance} />;
return <SettingsDownloadSources />;
}
if (currentCategoryIndex === 4) {
return <SettingsAppearance appearance={appearance} />;
}
if (currentCategoryIndex === 5) {
return <SettingsDebrid />;
}
@@ -69,21 +97,32 @@ export default function Settings() {
return (
<section className="settings__container">
<div className="settings__content">
<section className="settings__categories">
<aside className="settings__sidebar">
<ul className="settings__categories sidebar__menu">
{categories.map((category, index) => (
<Button
<li
key={category.contentTitle}
theme={
currentCategoryIndex === index ? "primary" : "outline"
}
onClick={() => setCurrentCategoryIndex(index)}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
currentCategoryIndex === index,
})}
>
{category.tabLabel}
</Button>
<button
type="button"
className="sidebar__menu-item-button"
onClick={() => setCurrentCategoryIndex(index)}
>
<category.Icon size={16} />
<span className="sidebar__menu-item-button-label">
{category.tabLabel}
</span>
</button>
</li>
))}
</section>
</ul>
</aside>
<div className="settings__content">
<h2>{categories[currentCategoryIndex].contentTitle}</h2>
{renderCategory()}
</div>

View File

@@ -10,6 +10,7 @@ export enum Downloader {
Hydra,
Buzzheavier,
FuckingFast,
VikingFile,
}
export enum DownloadSourceStatus {

View File

@@ -124,6 +124,9 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://fuckingfast.co")) {
return [Downloader.FuckingFast];
}
if (uri.startsWith("https://vikingfile.com")) {
return [Downloader.VikingFile];
}
if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid];

View File

@@ -5,6 +5,8 @@ export type ShortcutLocation = "desktop" | "start_menu";
export interface UnlockedAchievement {
name: string;
unlockTime: number;
imageKey?: string | null;
imageUrl?: string | null;
}
export interface SteamAchievement {
@@ -20,4 +22,5 @@ export interface SteamAchievement {
export interface UserAchievement extends SteamAchievement {
unlocked: boolean;
unlockTime: number | null;
imageUrl?: string | null;
}

View File

@@ -20,6 +20,7 @@ export interface GameRepack {
title: string;
fileSize: string | null;
uris: string[];
unavailableUris: string[];
uploadDate: string | null;
downloadSourceId: string;
downloadSourceName: string;
@@ -187,13 +188,28 @@ export interface UserDetails {
profileVisibility: ProfileVisibility;
bio: string;
featurebaseJwt: string;
workwondersJwt: string;
subscription: Subscription | null;
karma: number;
achievements: ProfileAchievement[] | null;
quirks?: {
backupsPerGameLimit: number;
};
}
export interface ProfileAchievement {
id: string;
name: string;
displayName: string;
imageUrl: string;
unlockTime: number;
gameTitle: string;
gameIconUrl: string | null;
achievementIcon: string | null;
gameId: string;
description: string;
}
export interface UserProfile {
id: string;
displayName: string;
@@ -210,6 +226,7 @@ export interface UserProfile {
bio: string;
hasActiveSubscription: boolean;
karma: number;
achievements: ProfileAchievement[] | null;
quirks: {
backupsPerGameLimit: number;
};

View File

@@ -89,7 +89,8 @@ export interface GameAchievement {
achievements: SteamAchievement[];
unlockedAchievements: UnlockedAchievement[];
updatedAt: number | undefined;
language: string | undefined;
imageUrl?: string | null;
language?: string;
}
export type AchievementCustomNotificationPosition =
@@ -125,6 +126,7 @@ export interface UserPreferences {
showDownloadSpeedInMegabytes?: boolean;
extractFilesByDefault?: boolean;
enableSteamAchievements?: boolean;
enableAchievementScreenshots?: boolean;
autoplayGameTrailers?: boolean;
hideToTrayOnGameStart?: boolean;
enableNewDownloadOptionsBadges?: boolean;

View File

@@ -2203,14 +2203,15 @@
tslib "^2.6.2"
"@smithy/config-resolver@^4.3.0", "@smithy/config-resolver@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.3.1.tgz#f1a0ed6faa52377909440002e1632be9fc901840"
integrity sha512-tWDwrWy37CDVGeaP8AIGZPFL2RoFtmd5Y+nTzLw5qroXNedT2S66EY2d+XzB1zxulCd6nfDXnAQu4auq90aj5Q==
version "4.4.5"
resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.5.tgz#35e792b6db00887bdd029df9b41780ca005d064b"
integrity sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==
dependencies:
"@smithy/node-config-provider" "^4.3.1"
"@smithy/types" "^4.7.0"
"@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.11.0"
"@smithy/util-config-provider" "^4.2.0"
"@smithy/util-middleware" "^4.2.1"
"@smithy/util-endpoints" "^3.2.7"
"@smithy/util-middleware" "^4.2.7"
tslib "^2.6.2"
"@smithy/core@^3.15.0", "@smithy/core@^3.16.0":
@@ -2421,6 +2422,16 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/node-config-provider@^4.3.7":
version "4.3.7"
resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz#c023fa857b008c314f621fb5b124724c157b2fd3"
integrity sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==
dependencies:
"@smithy/property-provider" "^4.2.7"
"@smithy/shared-ini-file-loader" "^4.4.2"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/node-http-handler@^4.3.0", "@smithy/node-http-handler@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.4.0.tgz#e1f6ae4a90cd7257699263bf8e06e653ff0e5f83"
@@ -2440,6 +2451,14 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/property-provider@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.7.tgz#cd0044e13495cf4064b3a6ed3299e5f549ba7513"
integrity sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/protocol-http@^5.3.0", "@smithy/protocol-http@^5.3.1":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.1.tgz#add01f73290f1e8fd49d7102b63e3fe53a5e6e18"
@@ -2480,6 +2499,14 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/shared-ini-file-loader@^4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz#8fa1b459de485b11185fe8c64182e3205a280ba9"
integrity sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/signature-v4@^5.3.0":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.1.tgz#c3d711c29d37f3db4daf51750eea75204c4f51d4"
@@ -2507,6 +2534,13 @@
"@smithy/util-stream" "^4.5.1"
tslib "^2.6.2"
"@smithy/types@^4.11.0":
version "4.11.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.11.0.tgz#c02f6184dcb47c4f0b387a32a7eca47956cc09f1"
integrity sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==
dependencies:
tslib "^2.6.2"
"@smithy/types@^4.6.0", "@smithy/types@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.7.0.tgz#42d707276d9184aef705f04e04615cd1979d044f"
@@ -2601,6 +2635,15 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/util-endpoints@^3.2.7":
version "3.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz#78cd5dd4aac8d9977f49d256d1e3418a09cade72"
integrity sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==
dependencies:
"@smithy/node-config-provider" "^4.3.7"
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-hex-encoding@^4.2.0":
version "4.2.0"
resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz#1c22ea3d1e2c3a81ff81c0a4f9c056a175068a7b"
@@ -2616,6 +2659,14 @@
"@smithy/types" "^4.7.0"
tslib "^2.6.2"
"@smithy/util-middleware@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.7.tgz#1cae2c4fd0389ac858d29f7170c33b4443e83524"
integrity sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==
dependencies:
"@smithy/types" "^4.11.0"
tslib "^2.6.2"
"@smithy/util-retry@^4.2.0", "@smithy/util-retry@^4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.1.tgz#8336368586a458cdce86fc92d6fb11fd1db41521"
@@ -6354,6 +6405,11 @@ keyv@^4.0.0, keyv@^4.5.3:
dependencies:
json-buffer "3.0.1"
ky@^1.11.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.14.1.tgz#16f20b3bf3939abcc04e2a9613f47360fe5f64c9"
integrity sha512-hYje4L9JCmpEQBtudo+v52X5X8tgWXUYyPcxKSuxQNboqufecl9VMWjGiucAFH060AwPXHZuH+WB2rrqfkmafw==
language-subtag-registry@^0.3.20:
version "0.3.23"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7"
@@ -9123,6 +9179,13 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
workwonders-sdk@0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/workwonders-sdk/-/workwonders-sdk-0.0.10.tgz#377167370a39c905c5228f8972c37c19004b7b21"
integrity sha512-bnswhlLRz1TCiqGV8l+VEOBej7u1SAkzLMEv6A60Sp0+S4j4pnmSve92KeOts/GYtUeNDuNM7fLPwZwMKY3sAg==
dependencies:
ky "^1.11.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"