Merge pull request #1313 from hydralauncher/feat/new-catalogue

Feat/new catalogue
This commit is contained in:
Chubby Granny Chaser 2024-12-23 22:50:51 +00:00 committed by GitHub
commit c0f35c5c54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
108 changed files with 2403 additions and 1126 deletions

View File

@ -26,4 +26,9 @@ module.exports = {
}, },
], ],
}, },
settings: {
react: {
version: "detect",
},
},
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 604 KiB

View File

@ -55,7 +55,6 @@
"electron-log": "^5.2.4", "electron-log": "^5.2.4",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"file-type": "^19.6.0", "file-type": "^19.6.0",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2", "i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1", "i18next-browser-languagedetector": "^7.2.1",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
@ -64,6 +63,7 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"parse-torrent": "^11.0.17", "parse-torrent": "^11.0.17",
"piscina": "^4.7.0", "piscina": "^4.7.0",
"rc-virtual-list": "^3.16.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.4.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -20,7 +20,7 @@ const s3 = new S3Client({
const dist = path.resolve(__dirname, "..", "dist"); const dist = path.resolve(__dirname, "..", "dist");
const extensionsToUpload = [".deb", ".exe"]; const extensionsToUpload = [".deb", ".exe", ".pacman"];
fs.readdir(dist, async (err, files) => { fs.readdir(dist, async (err, files) => {
if (err) throw err; if (err) throw err;

View File

@ -309,7 +309,7 @@
"last_time_played": "لعبت آخر مرة {{period}}", "last_time_played": "لعبت آخر مرة {{period}}",
"activity": "النشاط الأخير", "activity": "النشاط الأخير",
"library": "مكتبة", "library": "مكتبة",
"total_play_time": "إجمالي وقت اللعب: {{amount}}", "total_play_time": "إجمالي وقت اللعب",
"no_recent_activity_title": "هممم... لا شيء هنا", "no_recent_activity_title": "هممم... لا شيء هنا",
"no_recent_activity_description": "لم تلعب أي مباراة مؤخرًا. ", "no_recent_activity_description": "لم تلعب أي مباراة مؤخرًا. ",
"display_name": "اسم العرض", "display_name": "اسم العرض",
@ -383,13 +383,13 @@
"achievement_unlocked": "تم فتح الإنجاز", "achievement_unlocked": "تم فتح الإنجاز",
"user_achievements": "{{displayName}}إنجازات", "user_achievements": "{{displayName}}إنجازات",
"your_achievements": "إنجازاتك", "your_achievements": "إنجازاتك",
"unlocked_at": "مقفلة في:", "unlocked_at": "مقفلة في: {{date}}",
"subscription_needed": "مطلوب اشتراك Hydra Cloud لرؤية هذا المحتوى", "subscription_needed": "مطلوب اشتراك Hydra Cloud لرؤية هذا المحتوى",
"new_achievements_unlocked": "مفتوح {{achievementCount}} انجازات جديدة من {{gameCount}} ألعاب", "new_achievements_unlocked": "مفتوح {{achievementCount}} انجازات جديدة من {{gameCount}} ألعاب",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} الإنجازات", "achievement_progress": "{{unlockedCount}}/{{totalCount}} الإنجازات",
"achievements_unlocked_for_game": "مفتوح {{achievementCount}} انجازات جديدة ل {{gameTitle}}" "achievements_unlocked_for_game": "مفتوح {{achievementCount}} انجازات جديدة ل {{gameTitle}}"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "اشتراك Hydra كلاود", "subscription_tour_title": "اشتراك Hydra كلاود",
"subscribe_now": "اشترك الآن", "subscribe_now": "اشترك الآن",
"cloud_saving": "الحفظ السحابي", "cloud_saving": "الحفظ السحابي",

View File

@ -293,7 +293,7 @@
"last_time_played": "Последно играно {{period}}", "last_time_played": "Последно играно {{period}}",
"activity": "Скорошна активност", "activity": "Скорошна активност",
"library": "Библиотека", "library": "Библиотека",
"total_play_time": "Общо време за игра: {{amount}}", "total_play_time": "Общо време за игра",
"no_recent_activity_title": "Хмм… няма нищо тук", "no_recent_activity_title": "Хмм… няма нищо тук",
"no_recent_activity_description": "Не сте играли игри напоследък. Време е да промените това.!", "no_recent_activity_description": "Не сте играли игри напоследък. Време е да промените това.!",
"display_name": "Показване на името", "display_name": "Показване на името",
@ -362,13 +362,13 @@
"achievement_unlocked": "Постижението е отключено", "achievement_unlocked": "Постижението е отключено",
"user_achievements": "Постиженията на {{displayName}} ", "user_achievements": "Постиженията на {{displayName}} ",
"your_achievements": "Вашите Постижения", "your_achievements": "Вашите Постижения",
"unlocked_at": "Отключено на:", "unlocked_at": "Отключено на: {{date}}",
"subscription_needed": "Необходим е абонамент за Hydra Cloud, за да видите това съдържание", "subscription_needed": "Необходим е абонамент за Hydra Cloud, за да видите това съдържание",
"new_achievements_unlocked": "Отключени {{achievementCount}} нови постижения от {{gameCount}} игра", "new_achievements_unlocked": "Отключени {{achievementCount}} нови постижения от {{gameCount}} игра",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} постижения", "achievement_progress": "{{unlockedCount}}/{{totalCount}} постижения",
"achievements_unlocked_for_game": "Отключени {{achievementCount}} нови постижения за {{gameTitle}}" "achievements_unlocked_for_game": "Отключени {{achievementCount}} нови постижения за {{gameTitle}}"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Hydra Cloud Абонамент", "subscription_tour_title": "Hydra Cloud Абонамент",
"subscribe_now": "Абонирай се сега", "subscribe_now": "Абонирай се сега",
"cloud_saving": "Запазване в облака", "cloud_saving": "Запазване в облака",

View File

@ -224,7 +224,7 @@
"last_time_played": "Última partida {{period}}", "last_time_played": "Última partida {{period}}",
"activity": "Activitat recent", "activity": "Activitat recent",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Temps total de joc:{{amount}}", "total_play_time": "Temps total de joc",
"no_recent_activity_title": "Hmmm… encara no res", "no_recent_activity_title": "Hmmm… encara no res",
"no_recent_activity_description": "No has jugat a cap joc recentment. És el moment de canviar-ho!", "no_recent_activity_description": "No has jugat a cap joc recentment. És el moment de canviar-ho!",
"display_name": "Nom de visualització", "display_name": "Nom de visualització",

View File

@ -293,7 +293,7 @@
"last_time_played": "Naposledy hráno {{period}}", "last_time_played": "Naposledy hráno {{period}}",
"activity": "Nedávná aktivita", "activity": "Nedávná aktivita",
"library": "Knihovna", "library": "Knihovna",
"total_play_time": "Celkový odehraný čas: {{amount}}", "total_play_time": "Celkový odehraný čas",
"no_recent_activity_title": "Hmmm… nic tu není", "no_recent_activity_title": "Hmmm… nic tu není",
"no_recent_activity_description": "V poslední době si nehrál žádnout hru, můžeš to ale napravit!", "no_recent_activity_description": "V poslední době si nehrál žádnout hru, můžeš to ale napravit!",
"display_name": "Zobrazované jméno", "display_name": "Zobrazované jméno",
@ -362,13 +362,13 @@
"achievement_unlocked": "Achievement odemčen", "achievement_unlocked": "Achievement odemčen",
"user_achievements": "Achievementy uživatele {{displayName}}", "user_achievements": "Achievementy uživatele {{displayName}}",
"your_achievements": "Vaše achievementy", "your_achievements": "Vaše achievementy",
"unlocked_at": "Odemčeno:", "unlocked_at": "Odemčeno: {{date}}",
"subscription_needed": "Je vyžadováno předplatné Hydra Cloud pro zobrazení tohoto obsahu", "subscription_needed": "Je vyžadováno předplatné Hydra Cloud pro zobrazení tohoto obsahu",
"new_achievements_unlocked": "Odemčeno {{achievementCount}} nových achievementů z {{gameCount}} her", "new_achievements_unlocked": "Odemčeno {{achievementCount}} nových achievementů z {{gameCount}} her",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementů", "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementů",
"achievements_unlocked_for_game": "Odemčeno {{achievementCount}} nových achievementů pro {{gameTitle}}" "achievements_unlocked_for_game": "Odemčeno {{achievementCount}} nových achievementů pro {{gameTitle}}"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Předplatné Hydra Cloud", "subscription_tour_title": "Předplatné Hydra Cloud",
"subscribe_now": "Připojit se", "subscribe_now": "Připojit se",
"cloud_saving": "Ukládání v cloudu", "cloud_saving": "Ukládání v cloudu",

View File

@ -251,7 +251,7 @@
"last_time_played": "Sidst spillet {{period}}", "last_time_played": "Sidst spillet {{period}}",
"activity": "Seneste aktivitet", "activity": "Seneste aktivitet",
"library": "Bibliotek", "library": "Bibliotek",
"total_play_time": "Samlet spiltid: {{amount}}", "total_play_time": "Samlet spiltid",
"no_recent_activity_title": "Hmmm… ikke noget her", "no_recent_activity_title": "Hmmm… ikke noget her",
"no_recent_activity_description": "Du har ikke spillet nogen spil for nyligt. Dét er det på tide at lave om på!", "no_recent_activity_description": "Du har ikke spillet nogen spil for nyligt. Dét er det på tide at lave om på!",
"display_name": "Brugernavn", "display_name": "Brugernavn",

View File

@ -224,7 +224,7 @@
"last_time_played": "Zuletzt gespielt {{period}}", "last_time_played": "Zuletzt gespielt {{period}}",
"activity": "Letzte Aktivität", "activity": "Letzte Aktivität",
"library": "Bibliothek", "library": "Bibliothek",
"total_play_time": "Gesamtspielzeit: {{amount}}", "total_play_time": "Gesamtspielzeit",
"no_recent_activity_title": "Hmmm… hier ist nichts", "no_recent_activity_title": "Hmmm… hier ist nichts",
"no_recent_activity_description": "Du hast in letzter Zeit keine Spiele gespielt. Es wird Zeit das zu ändern!", "no_recent_activity_description": "Du hast in letzter Zeit keine Spiele gespielt. Es wird Zeit das zu ändern!",
"display_name": "Anzeigename", "display_name": "Anzeigename",

View File

@ -46,8 +46,15 @@
"checking_files": "Checking {{title}} files… ({{percentage}} complete)" "checking_files": "Checking {{title}} files… ({{percentage}} complete)"
}, },
"catalogue": { "catalogue": {
"next_page": "Next page", "search": "Filter…",
"previous_page": "Previous page" "developers": "Developers",
"genres": "Genres",
"tags": "Tags",
"publishers": "Publishers",
"download_sources": "Download sources",
"result_count": "{{resultCount}} results",
"filter_count": "{{filterCount}} available",
"clear_filters": "Clear {{filterCount}} selected"
}, },
"game_details": { "game_details": {
"open_download_options": "Open download options", "open_download_options": "Open download options",
@ -163,7 +170,7 @@
"no_download_option_info": "No information available", "no_download_option_info": "No information available",
"backup_deletion_failed": "Failed to delete backup", "backup_deletion_failed": "Failed to delete backup",
"max_number_of_artifacts_reached": "Maximum number of backups reached for this game", "max_number_of_artifacts_reached": "Maximum number of backups reached for this game",
"achievements_not_sync": "Your achievements are not synchronized", "achievements_not_sync": "See how to synchronize your achievements",
"manage_files_description": "Manage which files will be backed up and restored", "manage_files_description": "Manage which files will be backed up and restored",
"select_folder": "Select folder", "select_folder": "Select folder",
"backup_from": "Backup from {{date}}", "backup_from": "Backup from {{date}}",
@ -301,7 +308,7 @@
"last_time_played": "Last played {{period}}", "last_time_played": "Last played {{period}}",
"activity": "Recent Activity", "activity": "Recent Activity",
"library": "Library", "library": "Library",
"total_play_time": "Total playtime: {{amount}}", "total_play_time": "Total playtime",
"no_recent_activity_title": "Hmmm… nothing here", "no_recent_activity_title": "Hmmm… nothing here",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that!", "no_recent_activity_description": "You haven't played any games recently. It's time to change that!",
"display_name": "Display name", "display_name": "Display name",
@ -364,19 +371,34 @@
"your_friend_code": "Your friend code:", "your_friend_code": "Your friend code:",
"upload_banner": "Upload banner", "upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…", "uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated" "background_image_updated": "Background image updated",
"stats": "Stats",
"achievements": "achievements",
"games": "Games",
"top_percentile": "Top {{percentile}}%",
"ranking_updated_weekly": "Ranking is updated weekly",
"playing": "Playing {{game}}",
"achievements_unlocked": "Achievements Unlocked",
"earned_points": "Earned points",
"show_achievements_on_profile": "Show your achievements on your profile",
"show_points_on_profile": "Show your earned points on your profile"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Achievement unlocked", "achievement_unlocked": "Achievement unlocked",
"user_achievements": "{{displayName}}'s Achievements", "user_achievements": "{{displayName}}'s Achievements",
"your_achievements": "Your Achievements", "your_achievements": "Your Achievements",
"unlocked_at": "Unlocked at:", "unlocked_at": "Unlocked at: {{date}}",
"subscription_needed": "A Hydra Cloud subscription is required to see this content", "subscription_needed": "A Hydra Cloud subscription is required to see this content",
"new_achievements_unlocked": "Unlocked {{achievementCount}} new achievements from {{gameCount}} games", "new_achievements_unlocked": "Unlocked {{achievementCount}} new achievements from {{gameCount}} games",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievements", "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievements",
"achievements_unlocked_for_game": "Unlocked {{achievementCount}} new achievements for {{gameTitle}}" "achievements_unlocked_for_game": "Unlocked {{achievementCount}} new achievements for {{gameTitle}}",
"hidden_achievement_tooltip": "This is a hidden achievement",
"achievement_earn_points": "Earn {{points}} points with this achievement",
"earned_points": "Earned points:",
"available_points": "Available points:",
"how_to_earn_achievements_points": "How to earn achievements points?"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Hydra Cloud Subscription", "subscription_tour_title": "Hydra Cloud Subscription",
"subscribe_now": "Subscribe now", "subscribe_now": "Subscribe now",
"cloud_saving": "Cloud saving", "cloud_saving": "Cloud saving",
@ -384,6 +406,9 @@
"animated_profile_picture": "Animated profile pictures", "animated_profile_picture": "Animated profile pictures",
"premium_support": "Premium Support", "premium_support": "Premium Support",
"show_and_compare_achievements": "Show and compare your achievements to other users", "show_and_compare_achievements": "Show and compare your achievements to other users",
"animated_profile_banner": "Animated profile banner" "animated_profile_banner": "Animated profile banner",
"hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
"learn_more": "Learn More"
} }
} }

View File

@ -46,8 +46,15 @@
"checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)" "checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)"
}, },
"catalogue": { "catalogue": {
"next_page": "Siguiente página", "search": "Filtrar…",
"previous_page": "Pagina anterior" "developers": "Desarrolladores",
"genres": "Géneros",
"tags": "Marcadores",
"publishers": "Distribuidoras",
"download_sources": "Fuentes de descarga",
"result_count": "{{resultCount}} resultados",
"filter_count": "{{filterCount}} disponibles",
"clear_filters": "Limpiar {{filterCount}} seleccionados"
}, },
"game_details": { "game_details": {
"open_download_options": "Ver opciones de descargas", "open_download_options": "Ver opciones de descargas",
@ -295,7 +302,7 @@
"last_time_played": "Última vez jugado: {{period}}", "last_time_played": "Última vez jugado: {{period}}",
"activity": "Actividad reciente", "activity": "Actividad reciente",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Total de tiempo jugado: {{amount}}", "total_play_time": "Has jugado",
"no_recent_activity_title": "Que raro, no hay nada por acá...", "no_recent_activity_title": "Que raro, no hay nada por acá...",
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!", "no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!",
"display_name": "Nombre en pantalla", "display_name": "Nombre en pantalla",
@ -358,19 +365,20 @@
"your_friend_code": "Tu código de amigo:", "your_friend_code": "Tu código de amigo:",
"upload_banner": "Subir un banner", "upload_banner": "Subir un banner",
"uploading_banner": "Subiendo banner…", "uploading_banner": "Subiendo banner…",
"background_image_updated": "Imagen de fondo actualizada" "background_image_updated": "Imagen de fondo actualizada",
"playing": "Jugando {{game}}"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Logro desbloqueado", "achievement_unlocked": "Logro desbloqueado",
"user_achievements": "Logros de {{displayName}}", "user_achievements": "Logros de {{displayName}}",
"your_achievements": "Tus Logros", "your_achievements": "Tus Logros",
"unlocked_at": "Desbloqueado el:", "unlocked_at": "Desbloqueado el: {{date}}",
"subscription_needed": "Se necesita una suscripción a Hydra Cloud necesita para ver este contenido", "subscription_needed": "Se necesita una suscripción a Hydra Cloud necesita para ver este contenido",
"new_achievements_unlocked": "Desbloqueados {{achievementCount}} nuevos logros de {{gameCount}} juegos", "new_achievements_unlocked": "Desbloqueados {{achievementCount}} nuevos logros de {{gameCount}} juegos",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} logros", "achievement_progress": "{{unlockedCount}}/{{totalCount}} logros",
"achievements_unlocked_for_game": "Se han desbloqueado {{achievementCount}} nuevos logros de {{gameTitle}}" "achievements_unlocked_for_game": "Se han desbloqueado {{achievementCount}} nuevos logros de {{gameTitle}}"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Suscripción Hydra Cloud", "subscription_tour_title": "Suscripción Hydra Cloud",
"subscribe_now": "Suscribirse ahora", "subscribe_now": "Suscribirse ahora",
"cloud_saving": "Guardado en la nube", "cloud_saving": "Guardado en la nube",

View File

@ -290,7 +290,7 @@
"last_time_played": "Viimati mängitud {{period}}", "last_time_played": "Viimati mängitud {{period}}",
"activity": "Hiljutine aktiivsus", "activity": "Hiljutine aktiivsus",
"library": "Kogu", "library": "Kogu",
"total_play_time": "Kogu mängitud aeg: {{amount}}", "total_play_time": "Kogu mängitud aeg",
"no_recent_activity_title": "Hmmm… siin pole midagi", "no_recent_activity_title": "Hmmm… siin pole midagi",
"no_recent_activity_description": "Sa pole hiljuti ühtegi mängu mänginud. On aeg seda muuta!", "no_recent_activity_description": "Sa pole hiljuti ühtegi mängu mänginud. On aeg seda muuta!",
"display_name": "Kuvatav nimi", "display_name": "Kuvatav nimi",
@ -359,11 +359,11 @@
"achievement_unlocked": "Saavutus avatud", "achievement_unlocked": "Saavutus avatud",
"user_achievements": "{{displayName}} saavutused", "user_achievements": "{{displayName}} saavutused",
"your_achievements": "Sinu saavutused", "your_achievements": "Sinu saavutused",
"unlocked_at": "Avatud:", "unlocked_at": "Avatud: {{date}}",
"subscription_needed": "Selle sisu nägemiseks on vaja Hydra Cloud tellimust", "subscription_needed": "Selle sisu nägemiseks on vaja Hydra Cloud tellimust",
"new_achievements_unlocked": "Avatud {{achievementCount}} uut saavutust {{gameCount}} mängust" "new_achievements_unlocked": "Avatud {{achievementCount}} uut saavutust {{gameCount}} mängust"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Hydra Cloud Tellimus", "subscription_tour_title": "Hydra Cloud Tellimus",
"subscribe_now": "Telli kohe", "subscribe_now": "Telli kohe",
"cloud_saving": "Pilvesalvestus", "cloud_saving": "Pilvesalvestus",

View File

@ -224,7 +224,7 @@
"last_time_played": "Terakhir dimainkan {{period}}", "last_time_played": "Terakhir dimainkan {{period}}",
"activity": "Aktivitas terbaru", "activity": "Aktivitas terbaru",
"library": "Perpustakaan", "library": "Perpustakaan",
"total_play_time": "Total waktu bermain: {{amount}}", "total_play_time": "Total waktu bermain",
"no_recent_activity_title": "Hmm… kosong di sini", "no_recent_activity_title": "Hmm… kosong di sini",
"no_recent_activity_description": "Kamu belum main game baru-baru ini. Yuk, mulai main!", "no_recent_activity_description": "Kamu belum main game baru-baru ini. Yuk, mulai main!",
"display_name": "Nama tampilan", "display_name": "Nama tampilan",

View File

@ -220,7 +220,7 @@
"last_time_played": "Соңғы ойын {{period}}", "last_time_played": "Соңғы ойын {{period}}",
"activity": "Соңғы әрекет", "activity": "Соңғы әрекет",
"library": "Кітапхана", "library": "Кітапхана",
"total_play_time": "Барлығы ойнаған: {{amount}}", "total_play_time": "Барлығы ойнаған",
"no_recent_activity_title": "Хммм... Мұнда ештеңе жоқ", "no_recent_activity_title": "Хммм... Мұнда ештеңе жоқ",
"no_recent_activity_description": "Сіз ұзақ уақыт бойы ештеңе ойнаған жоқсыз. Мұны өзгерту керек!", "no_recent_activity_description": "Сіз ұзақ уақыт бойы ештеңе ойнаған жоқсыз. Мұны өзгерту керек!",
"display_name": "Көрсету аты", "display_name": "Көрсету аты",

View File

@ -251,7 +251,7 @@
"last_time_played": "Sist spilt {{period}}", "last_time_played": "Sist spilt {{period}}",
"activity": "Seneste aktivitet", "activity": "Seneste aktivitet",
"library": "Bibliotek", "library": "Bibliotek",
"total_play_time": "Samlet spilltid: {{amount}}", "total_play_time": "Samlet spilltid",
"no_recent_activity_title": "Hmmm… ikke noe her", "no_recent_activity_title": "Hmmm… ikke noe her",
"no_recent_activity_description": "Du har ikke spilt noen spill for på det seneste. Det er det på tide at endre på!", "no_recent_activity_description": "Du har ikke spilt noen spill for på det seneste. Det er det på tide at endre på!",
"display_name": "Brukernavn", "display_name": "Brukernavn",

View File

@ -158,7 +158,7 @@
"no_download_option_info": "Sem informações disponíveis", "no_download_option_info": "Sem informações disponíveis",
"backup_deletion_failed": "Falha ao apagar backup", "backup_deletion_failed": "Falha ao apagar backup",
"max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo", "max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo",
"achievements_not_sync": "Suas conquistas não estão sincronizadas", "achievements_not_sync": "Veja como exibir suas conquistas no perfil",
"backup_from": "Backup de {{date}}", "backup_from": "Backup de {{date}}",
"custom_backup_location_set": "Localização customizada selecionada", "custom_backup_location_set": "Localização customizada selecionada",
"select_folder": "Selecione a pasta", "select_folder": "Selecione a pasta",
@ -284,8 +284,15 @@
"instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo" "instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo"
}, },
"catalogue": { "catalogue": {
"next_page": "Próxima página", "search": "Filtrar…",
"previous_page": "Página anterior" "developers": "Desenvolvedores",
"genres": "Gêneros",
"tags": "Marcadores",
"publishers": "Distribuidoras",
"download_sources": "Fontes de download",
"result_count": "{{resultCount}} resultados",
"filter_count": "{{filterCount}} disponíveis",
"clear_filters": "Limpar {{filterCount}} selecionados"
}, },
"modal": { "modal": {
"close": "Botão de fechar" "close": "Botão de fechar"
@ -299,7 +306,7 @@
"last_time_played": "Última sessão {{period}}", "last_time_played": "Última sessão {{period}}",
"activity": "Atividades recentes", "activity": "Atividades recentes",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Tempo total de jogo: {{amount}}", "total_play_time": "Tempo total de jogo",
"no_recent_activity_title": "Hmmm… nada por aqui", "no_recent_activity_title": "Hmmm… nada por aqui",
"no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?", "no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?",
"display_name": "Nome de exibição", "display_name": "Nome de exibição",
@ -362,26 +369,43 @@
"your_friend_code": "Seu código de amigo:", "your_friend_code": "Seu código de amigo:",
"upload_banner": "Carregar banner", "upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…", "uploading_banner": "Carregando banner…",
"background_image_updated": "Imagem de fundo salva" "background_image_updated": "Imagem de fundo salva",
"stats": "Estatísticas",
"achievements": "conquistas",
"games": "Jogos",
"ranking_updated_weekly": "O ranking é atualizado semanalmente",
"playing": "Jogando {{game}}",
"achievements_unlocked": "Conquistas desbloqueadas",
"earned_points": "Pontos ganhos",
"show_achievements_on_profile": "Exiba suas conquistas no perfil",
"show_points_on_profile": "Exiba seus pontos ganhos no perfil"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",
"your_achievements": "Suas Conquistas", "your_achievements": "Suas Conquistas",
"user_achievements": "Conquistas de {{displayName}}", "user_achievements": "Conquistas de {{displayName}}",
"unlocked_at": "Desbloqueado em:", "unlocked_at": "Desbloqueada em: {{date}}",
"subscription_needed": "Você precisa de uma assinatura Hydra Cloud para visualizar este conteúdo", "subscription_needed": "Você precisa de uma assinatura Hydra Cloud para visualizar este conteúdo",
"new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos", "new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} conquistas", "achievement_progress": "{{unlockedCount}}/{{totalCount}} conquistas",
"achievements_unlocked_for_game": "Desbloqueadas {{achievementCount}} novas conquistas em {{gameTitle}}" "achievements_unlocked_for_game": "Desbloqueadas {{achievementCount}} novas conquistas em {{gameTitle}}",
"hidden_achievement_tooltip": "Está é uma conquista oculta",
"achievement_earn_points": "Ganhe {{points}} pontos com essa conquista",
"earned_points": "Pontos ganhos:",
"available_points": "Pontos disponíveis:",
"how_to_earn_achievements_points": "Como desbloquear pontos nas conquistas?"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Assinatura Hydra Cloud", "subscription_tour_title": "Assinatura Hydra Cloud",
"hydra_cloud": "Hydra Cloud",
"subscribe_now": "Inscreva-se agora", "subscribe_now": "Inscreva-se agora",
"cloud_achievements": "Salvamento de conquistas em nuvem", "cloud_achievements": "Salvamento de conquistas em nuvem",
"animated_profile_picture": "Fotos de perfil animadas", "animated_profile_picture": "Fotos de perfil animadas",
"premium_support": "Suporte Premium", "premium_support": "Suporte Premium",
"show_and_compare_achievements": "Exiba e compare suas conquistas com outros usuários", "show_and_compare_achievements": "Exiba e compare suas conquistas com outros usuários",
"animated_profile_banner": "Banner animado no perfil", "animated_profile_banner": "Banner animado no perfil",
"cloud_saving": "Saves de jogos em nuvem" "cloud_saving": "Saves de jogos em nuvem",
"hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!",
"learn_more": "Saiba mais"
} }
} }

View File

@ -287,7 +287,7 @@
"last_time_played": "Última sessão {{period}}", "last_time_played": "Última sessão {{period}}",
"activity": "Atividade recente", "activity": "Atividade recente",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Tempo total de jogo: {{amount}}", "total_play_time": "Tempo total de jogo",
"no_recent_activity_title": "Hmmm… não há nada por aqui", "no_recent_activity_title": "Hmmm… não há nada por aqui",
"no_recent_activity_description": "Parece que não jogaste nada recentemente. Que tal começar agora?", "no_recent_activity_description": "Parece que não jogaste nada recentemente. Que tal começar agora?",
"display_name": "Nome de apresentação", "display_name": "Nome de apresentação",
@ -356,11 +356,11 @@
"achievement_unlocked": "Conquista desbloqueada", "achievement_unlocked": "Conquista desbloqueada",
"your_achievements": "As tuas Conquistas", "your_achievements": "As tuas Conquistas",
"user_achievements": "Conquistas de {{displayName}}", "user_achievements": "Conquistas de {{displayName}}",
"unlocked_at": "Desbloqueada em:", "unlocked_at": "Desbloqueada em: {{date}}",
"subscription_needed": "Precisas de uma subscrição Hydra Cloud para visualizar este conteúdo", "subscription_needed": "Precisas de uma subscrição Hydra Cloud para visualizar este conteúdo",
"new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos" "new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Subscrição Hydra Cloud", "subscription_tour_title": "Subscrição Hydra Cloud",
"subscribe_now": "Subscreve agora", "subscribe_now": "Subscreve agora",
"cloud_achievements": "Gravação de conquistas na nuvem", "cloud_achievements": "Gravação de conquistas na nuvem",

View File

@ -46,8 +46,15 @@
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)" "checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)"
}, },
"catalogue": { "catalogue": {
"next_page": "Следующая страница", "search": "Фильтр…",
"previous_page": "Предыдущая страница" "developers": "Разработчики",
"genres": "Жанры",
"tags": "Маркеры",
"publishers": "Издательства",
"download_sources": "Источники загрузки",
"result_count": "{{resultCount}} результатов",
"filter_count": "{{filterCount}} доступных",
"clear_filters": "Очистить {{filterCount}} выбранных"
}, },
"game_details": { "game_details": {
"open_download_options": "Открыть источники", "open_download_options": "Открыть источники",

View File

@ -231,7 +231,7 @@
"sign_out_modal_text": "Ваша бібліотека пов'язана з поточним обліковим записом. При виході з системи ваша бібліотека буде недоступною, і прогрес не буде збережено. Продовжити вихід?", "sign_out_modal_text": "Ваша бібліотека пов'язана з поточним обліковим записом. При виході з системи ваша бібліотека буде недоступною, і прогрес не буде збережено. Продовжити вихід?",
"sign_out_modal_title": "Ви впевнені?", "sign_out_modal_title": "Ви впевнені?",
"successfully_signed_out": "Успішний вихід з акаунту", "successfully_signed_out": "Успішний вихід з акаунту",
"total_play_time": "Всього зіграно: {{amount}}", "total_play_time": "Всього зіграно",
"try_again": "Будь ласка, попробуйте ще раз" "try_again": "Будь ласка, попробуйте ще раз"
} }
} }

View File

@ -290,7 +290,7 @@
"last_time_played": "上次游玩时间 {{period}}", "last_time_played": "上次游玩时间 {{period}}",
"activity": "近期活动", "activity": "近期活动",
"library": "库", "library": "库",
"total_play_time": "总游戏时长: {{amount}}", "total_play_time": "总游戏时长",
"no_recent_activity_title": "Emmm… 这里暂时啥都没有", "no_recent_activity_title": "Emmm… 这里暂时啥都没有",
"no_recent_activity_description": "你最近没玩过任何游戏。是时候做出改变了!", "no_recent_activity_description": "你最近没玩过任何游戏。是时候做出改变了!",
"display_name": "昵称", "display_name": "昵称",
@ -359,11 +359,11 @@
"achievement_unlocked": "成就已解锁", "achievement_unlocked": "成就已解锁",
"user_achievements": "{{displayName}}的成就", "user_achievements": "{{displayName}}的成就",
"your_achievements": "你的成就", "your_achievements": "你的成就",
"unlocked_at": "解锁于:", "unlocked_at": "解锁于: {{date}}",
"subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容", "subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容",
"new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就" "new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就"
}, },
"tour": { "hydra_cloud": {
"subscription_tour_title": "Hydra 云订阅", "subscription_tour_title": "Hydra 云订阅",
"subscribe_now": "现在订购", "subscribe_now": "现在订购",
"cloud_saving": "云存档", "cloud_saving": "云存档",

View File

@ -1,10 +1,8 @@
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import { import {
DownloadQueue, DownloadQueue,
DownloadSource,
Game, Game,
GameShopCache, GameShopCache,
Repack,
UserPreferences, UserPreferences,
UserAuth, UserAuth,
GameAchievement, GameAchievement,
@ -17,12 +15,10 @@ export const dataSource = new DataSource({
type: "better-sqlite3", type: "better-sqlite3",
entities: [ entities: [
Game, Game,
Repack,
UserAuth, UserAuth,
UserPreferences, UserPreferences,
UserSubscription, UserSubscription,
GameShopCache, GameShopCache,
DownloadSource,
DownloadQueue, DownloadQueue,
GameAchievement, GameAchievement,
], ],

View File

@ -1,41 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from "typeorm";
import type { Repack } from "./repack.entity";
import { DownloadSourceStatus } from "@shared";
@Entity("download_source")
export class DownloadSource {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true, unique: true })
url: string;
@Column("text")
name: string;
@Column("text", { nullable: true })
etag: string | null;
@Column("int", { default: 0 })
downloadCount: number;
@Column("text", { default: DownloadSourceStatus.UpToDate })
status: DownloadSourceStatus;
@OneToMany("Repack", "downloadSource", { cascade: true })
repacks: Repack[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -5,9 +5,7 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
OneToOne, OneToOne,
JoinColumn,
} from "typeorm"; } from "typeorm";
import { Repack } from "./repack.entity";
import type { GameShop, GameStatus } from "@types"; import type { GameShop, GameStatus } from "@types";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
@ -72,13 +70,6 @@ export class Game {
@Column("text", { nullable: true }) @Column("text", { nullable: true })
uri: string | null; uri: string | null;
/**
* @deprecated
*/
@OneToOne("Repack", "game", { nullable: true })
@JoinColumn()
repack: Repack;
@OneToOne("DownloadQueue", "game") @OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue; downloadQueue: DownloadQueue;

View File

@ -1,10 +1,8 @@
export * from "./game.entity"; export * from "./game.entity";
export * from "./repack.entity";
export * from "./user-auth.entity"; export * from "./user-auth.entity";
export * from "./user-preferences.entity"; export * from "./user-preferences.entity";
export * from "./user-subscription.entity"; export * from "./user-subscription.entity";
export * from "./game-shop-cache.entity"; export * from "./game-shop-cache.entity";
export * from "./game.entity"; export * from "./game.entity";
export * from "./game-achievements.entity"; export * from "./game-achievements.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity"; export * from "./download-queue.entity";

View File

@ -1,45 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
} from "typeorm";
import { DownloadSource } from "./download-source.entity";
@Entity("repack")
export class Repack {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
title: string;
/**
* @deprecated Use uris instead
*/
@Column("text", { unique: true })
magnet: string;
@Column("text")
repacker: string;
@Column("text")
fileSize: string;
@Column("datetime")
uploadDate: Date | string;
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
downloadSource: DownloadSource;
@Column("text", { default: "[]" })
uris: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -1,9 +1,6 @@
import type { GameShop } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { CatalogueCategory, steamUrlBuilder } from "@shared"; import { CatalogueCategory } from "@shared";
import { steamGamesWorker } from "@main/workers";
const getCatalogue = async ( const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -14,26 +11,11 @@ const getCatalogue = async (
skip: "0", skip: "0",
}); });
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>( return HydraApi.get(
`/catalogue/${category}?${params.toString()}`, `/catalogue/${category}?${params.toString()}`,
{}, {},
{ needsAuth: false } { needsAuth: false }
); );
return Promise.all(
response.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
return {
title: steamGame.name,
shop: game.shop,
cover: steamUrlBuilder.library(game.objectId),
objectId: game.objectId,
};
})
);
}; };
registerEvent("getCatalogue", getCatalogue); registerEvent("getCatalogue", getCatalogue);

View File

@ -0,0 +1,10 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>(`/catalogue/developers`, null, {
needsAuth: false,
});
};
registerEvent("getDevelopers", getDevelopers);

View File

@ -1,29 +0,0 @@
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { steamUrlBuilder } from "@shared";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take = 12,
skip = 0
): Promise<CatalogueEntry[]> => {
const searchParams = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
});
const games = await HydraApi.get<CatalogueEntry[]>(
`/games/catalogue?${searchParams.toString()}`,
undefined,
{ needsAuth: false }
);
return games.map((game) => ({
...game,
cover: steamUrlBuilder.library(game.objectId),
}));
};
registerEvent("getGames", getGames);

View File

@ -0,0 +1,10 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<string[]>(`/catalogue/publishers`, null, {
needsAuth: false,
});
};
registerEvent("getPublishers", getPublishers);

View File

@ -1,23 +1,18 @@
import type { CatalogueSearchPayload } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import type { CatalogueEntry } from "@types";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
const searchGamesEvent = async ( const searchGames = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
query: string payload: CatalogueSearchPayload,
): Promise<CatalogueEntry[]> => { take: number,
const games = await HydraApi.get< skip: number
{ objectId: string; title: string; shop: string }[] ) => {
>("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false }); return HydraApi.post(
"/catalogue/search",
return games.map((game) => { { ...payload, take, skip },
return convertSteamGameToCatalogueEntry({ { needsAuth: false }
id: Number(game.objectId), );
name: game.title,
clientIcon: null,
});
});
}; };
registerEvent("searchGames", searchGamesEvent); registerEvent("searchGames", searchGames);

View File

@ -1,9 +0,0 @@
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => knexClient("download_source").where({ id }).delete();
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@ -1,7 +0,0 @@
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
knexClient.select("*").from("download_source");
registerEvent("getDownloadSources", getDownloadSources);

View File

@ -0,0 +1,17 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const putDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
objectIds: string[]
) => {
return HydraApi.put<{ fingerprint: string }>(
"/download-sources",
{
objectIds,
},
{ needsAuth: false }
);
};
registerEvent("putDownloadSource", putDownloadSource);

View File

@ -1,31 +0,0 @@
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { steamGamesWorker } from "@main/workers";
import { steamUrlBuilder } from "@shared";
export interface SearchGamesArgs {
query?: string;
take?: number;
skip?: number;
}
export const convertSteamGameToCatalogueEntry = (
game: SteamGame
): CatalogueEntry => ({
objectId: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: steamUrlBuilder.library(String(game.id)),
});
export const getSteamGameById = async (
objectId: string
): Promise<CatalogueEntry | null> => {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
if (!steamGame) return null;
return convertSteamGameToCatalogueEntry(steamGame);
};

View File

@ -3,12 +3,13 @@ import { ipcMain } from "electron";
import "./catalogue/get-catalogue"; import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details"; import "./catalogue/get-game-shop-details";
import "./catalogue/get-games";
import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game"; import "./catalogue/get-random-game";
import "./catalogue/search-games"; import "./catalogue/search-games";
import "./catalogue/get-game-stats"; import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games"; import "./catalogue/get-trending-games";
import "./catalogue/get-publishers";
import "./catalogue/get-developers";
import "./hardware/get-disk-free-space"; import "./hardware/get-disk-free-space";
import "./library/add-game-to-library"; import "./library/add-game-to-library";
import "./library/create-game-shortcut"; import "./library/create-game-shortcut";
@ -40,8 +41,7 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates"; import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update"; import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-real-debrid";
import "./download-sources/delete-download-source"; import "./download-sources/put-download-source";
import "./download-sources/get-download-sources";
import "./auth/sign-out"; import "./auth/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";

View File

@ -13,6 +13,9 @@ const getComparedUnlockedAchievements = async (
where: { id: 1 }, where: { id: 1 },
}); });
const showHiddenAchievementsDescription =
userPreferences?.showHiddenAchievementsDescription || false;
return HydraApi.get<ComparedAchievements>( return HydraApi.get<ComparedAchievements>(
`/users/${userId}/games/achievements/compare`, `/users/${userId}/games/achievements/compare`,
{ {
@ -21,15 +24,35 @@ const getComparedUnlockedAchievements = async (
language: userPreferences?.language || "en", language: userPreferences?.language || "en",
} }
).then((achievements) => { ).then((achievements) => {
const sortedAchievements = achievements.achievements.sort((a, b) => { const sortedAchievements = achievements.achievements
if (a.targetStat.unlocked && !b.targetStat.unlocked) return -1; .sort((a, b) => {
if (!a.targetStat.unlocked && b.targetStat.unlocked) return 1; if (a.targetStat.unlocked && !b.targetStat.unlocked) return -1;
if (a.targetStat.unlocked && b.targetStat.unlocked) { if (!a.targetStat.unlocked && b.targetStat.unlocked) return 1;
return b.targetStat.unlockTime! - a.targetStat.unlockTime!; if (a.targetStat.unlocked && b.targetStat.unlocked) {
} return b.targetStat.unlockTime! - a.targetStat.unlockTime!;
}
return Number(a.hidden) - Number(b.hidden); return Number(a.hidden) - Number(b.hidden);
}); })
.map((achievement) => {
if (!achievement.hidden) return achievement;
if (!achievement.ownerStat) {
return {
...achievement,
description: "",
};
}
if (!showHiddenAchievementsDescription && achievement.hidden) {
return {
...achievement,
description: "",
};
}
return achievement;
});
return { return {
...achievements, ...achievements,

View File

@ -11,7 +11,7 @@ const getSteamGame = async (objectId: string) => {
}); });
return { return {
title: steamGame.name, title: steamGame.name as string,
iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon), iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon),
}; };
} catch (err) { } catch (err) {
@ -67,8 +67,25 @@ const getUser = async (
} }
} }
const friends = await Promise.all(
profile.friends.map(async (friend) => {
if (!friend.currentGame) return friend;
const currentGame = await getSteamGame(friend.currentGame.objectId);
return {
...friend,
currentGame: {
...friend.currentGame,
...currentGame,
},
};
})
);
return { return {
...profile, ...profile,
friends,
libraryGames, libraryGames,
recentGames, recentGames,
}; };

View File

@ -1,10 +1,8 @@
import { dataSource } from "./data-source"; import { dataSource } from "./data-source";
import { import {
DownloadQueue, DownloadQueue,
DownloadSource,
Game, Game,
GameShopCache, GameShopCache,
Repack,
UserPreferences, UserPreferences,
UserAuth, UserAuth,
GameAchievement, GameAchievement,
@ -13,16 +11,11 @@ import {
export const gameRepository = dataSource.getRepository(Game); export const gameRepository = dataSource.getRepository(Game);
export const repackRepository = dataSource.getRepository(Repack);
export const userPreferencesRepository = export const userPreferencesRepository =
dataSource.getRepository(UserPreferences); dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache); export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const downloadSourceRepository =
dataSource.getRepository(DownloadSource);
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue); export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth); export const userAuthRepository = dataSource.getRepository(UserAuth);

View File

@ -7,8 +7,9 @@ import { WindowManager } from "../window-manager";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements"; import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements";
import { Game } from "@main/entity"; import { Game } from "@main/entity";
import { achievementsLogger } from "../logger";
import { publishNewAchievementNotification } from "../notifications"; import { publishNewAchievementNotification } from "../notifications";
import { SubscriptionRequiredError } from "@shared";
import { achievementsLogger } from "../logger";
const saveAchievementsOnLocal = async ( const saveAchievementsOnLocal = async (
objectId: string, objectId: string,
@ -120,10 +121,14 @@ export const mergeAchievements = async (
} }
if (game.remoteId) { if (game.remoteId) {
await HydraApi.put("/profile/games/achievements", { await HydraApi.put(
id: game.remoteId, "/profile/games/achievements",
achievements: mergedLocalAchievements, {
}) id: game.remoteId,
achievements: mergedLocalAchievements,
},
{ needsSubscription: !newAchievements.length }
)
.then((response) => { .then((response) => {
return saveAchievementsOnLocal( return saveAchievementsOnLocal(
response.objectId, response.objectId,
@ -133,7 +138,13 @@ export const mergeAchievements = async (
); );
}) })
.catch((err) => { .catch((err) => {
achievementsLogger.error(err); if (err! instanceof SubscriptionRequiredError) {
achievementsLogger.log(
"Achievements not synchronized on API due to lack of subscription",
game.objectID,
game.title
);
}
return saveAchievementsOnLocal( return saveAchievementsOnLocal(
game.objectID, game.objectID,

View File

@ -23,7 +23,7 @@ interface HydraApiUserAuth {
authToken: string; authToken: string;
refreshToken: string; refreshToken: string;
expirationTimestamp: number; expirationTimestamp: number;
subscription: { expiresAt: Date | null } | null; subscription: { expiresAt: Date | string | null } | null;
} }
export class HydraApi { export class HydraApi {
@ -159,7 +159,11 @@ export class HydraApi {
config.method, config.method,
config.baseURL, config.baseURL,
config.url, config.url,
omit(config.headers, ["accessToken", "refreshToken"]), omit(config.headers, [
"accessToken",
"refreshToken",
"Authorization",
]),
Array.isArray(data) Array.isArray(data)
? data ? data
: omit(data, ["accessToken", "refreshToken"]) : omit(data, ["accessToken", "refreshToken"])
@ -182,8 +186,6 @@ export class HydraApi {
); );
} }
await getUserData();
const userAuth = await userAuthRepository.findOne({ const userAuth = await userAuthRepository.findOne({
where: { id: 1 }, where: { id: 1 },
relations: { subscription: true }, relations: { subscription: true },
@ -197,6 +199,14 @@ export class HydraApi {
? { expiresAt: userAuth.subscription?.expiresAt } ? { expiresAt: userAuth.subscription?.expiresAt }
: null, : null,
}; };
const updatedUserData = await getUserData();
this.userAuth.subscription = updatedUserData?.subscription
? {
expiresAt: updatedUserData.subscription.expiresAt,
}
: null;
} }
private static sendSignOutEvent() { private static sendSignOutEvent() {
@ -284,10 +294,8 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
} }
if (needsSubscription) { if (needsSubscription && !this.hasActiveSubscription()) {
if (!(await this.hasActiveSubscription())) { throw new SubscriptionRequiredError();
throw new SubscriptionRequiredError();
}
} }
} }

View File

@ -42,6 +42,7 @@ export const getUserData = () => {
}) })
.catch(async (err) => { .catch(async (err) => {
if (err instanceof UserNotLoggedInError) { if (err instanceof UserNotLoggedInError) {
logger.info("User is not logged in", err);
return null; return null;
} }
logger.error("Failed to get logged user"); logger.error("Failed to get logged user");
@ -58,6 +59,9 @@ export const getUserData = () => {
bio: "", bio: "",
email: null, email: null,
profileVisibility: "PUBLIC" as ProfileVisibility, profileVisibility: "PUBLIC" as ProfileVisibility,
quirks: {
backupsPerGameLimit: 0,
},
subscription: loggedUser.subscription subscription: loggedUser.subscription
? { ? {
id: loggedUser.subscription.subscriptionId, id: loggedUser.subscription.subscriptionId,

View File

@ -11,6 +11,7 @@ import type {
GameRunning, GameRunning,
FriendRequestAction, FriendRequestAction,
UpdateProfileRequest, UpdateProfileRequest,
CatalogueSearchPayload,
SeedingStatus, SeedingStatus,
GameAchievement, GameAchievement,
} from "@types"; } from "@types";
@ -54,7 +55,8 @@ contextBridge.exposeInMainWorld("electron", {
}, },
/* Catalogue */ /* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query), searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) =>
ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) => getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category), ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) => getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
@ -62,10 +64,6 @@ contextBridge.exposeInMainWorld("electron", {
getRandomGame: () => ipcRenderer.invoke("getRandomGame"), getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectId: string, shop: GameShop) => getHowLongToBeat: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getHowLongToBeat", objectId, shop), ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
getGames: (take?: number, skip?: number) =>
ipcRenderer.invoke("getGames", take, skip),
searchGameRepacks: (query: string) =>
ipcRenderer.invoke("searchGameRepacks", query),
getGameStats: (objectId: string, shop: GameShop) => getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop), ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"), getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
@ -96,9 +94,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("authenticateRealDebrid", apiToken), ipcRenderer.invoke("authenticateRealDebrid", apiToken),
/* Download sources */ /* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), putDownloadSource: (objectIds: string[]) =>
deleteDownloadSource: (id: number) => ipcRenderer.invoke("putDownloadSource", objectIds),
ipcRenderer.invoke("deleteDownloadSource", id),
/* Library */ /* Library */
addGameToLibrary: (objectId: string, title: string, shop: GameShop) => addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>

View File

@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
@ -7,6 +7,7 @@ import {
useAppSelector, useAppSelector,
useDownload, useDownload,
useLibrary, useLibrary,
useRepacks,
useToast, useToast,
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
@ -15,8 +16,6 @@ import * as styles from "./app.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { import {
setSearch,
clearSearch,
setUserPreferences, setUserPreferences,
toggleDraggingDisabled, toggleDraggingDisabled,
closeToast, closeToast,
@ -27,8 +26,9 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers"; import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context"; import { downloadSourcesTable } from "./dexie";
import { logger } from "./logger"; import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
export interface AppProps { export interface AppProps {
children: React.ReactNode; children: React.ReactNode;
@ -40,35 +40,31 @@ export function App() {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const downloadSourceMigrationLock = useRef(false); const { updateRepacks } = useRepacks();
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const { indexRepacks } = useContext(repacksContext);
const { const {
userDetails,
hasActiveSubscription,
isFriendsModalVisible, isFriendsModalVisible,
friendRequetsModalTab, friendRequetsModalTab,
friendModalUserId, friendModalUserId,
syncFriendRequests, syncFriendRequests,
hideFriendsModal, hideFriendsModal,
} = useUserDetails();
const {
userDetails,
hasActiveSubscription,
fetchUserDetails, fetchUserDetails,
updateUserDetails, updateUserDetails,
clearUserDetails, clearUserDetails,
} = useUserDetails(); } = useUserDetails();
const { hideHydraCloudModal, isHydraCloudModalVisible, hydraCloudFeature } =
useSubscription();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const search = useAppSelector((state) => state.search.value);
const draggingDisabled = useAppSelector( const draggingDisabled = useAppSelector(
(state) => state.window.draggingDisabled (state) => state.window.draggingDisabled
); );
@ -195,31 +191,6 @@ export function App() {
}; };
}, [onSignIn, updateLibrary, clearUserDetails]); }, [onSignIn, updateLibrary, clearUserDetails]);
const handleSearch = useCallback(
(query: string) => {
dispatch(setSearch(query));
if (query === "") {
navigate(-1);
return;
}
const searchParams = new URLSearchParams({
query,
});
navigate(`/search?${searchParams.toString()}`, {
replace: location.pathname.startsWith("/search"),
});
},
[dispatch, location.pathname, navigate]
);
const handleClear = useCallback(() => {
dispatch(clearSearch());
navigate(-1);
}, [dispatch, navigate]);
useEffect(() => { useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0; if (contentRef.current) contentRef.current.scrollTop = 0;
}, [location.pathname, location.search]); }, [location.pathname, location.search]);
@ -236,53 +207,31 @@ export function App() {
}, [dispatch, draggingDisabled]); }, [dispatch, draggingDisabled]);
useEffect(() => { useEffect(() => {
if (downloadSourceMigrationLock.current) return; updateRepacks();
downloadSourceMigrationLock.current = true; const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
window.electron.getDownloadSources().then(async (downloadSources) => { channel.onmessage = (event: MessageEvent<number>) => {
if (!downloadSources.length) { const newRepacksCount = event.data;
const id = crypto.randomUUID(); window.electron.publishNewRepacksNotification(newRepacksCount);
const channel = new BroadcastChannel(`download_sources:sync:${id}`); updateRepacks();
channel.onmessage = (event: MessageEvent<number>) => { downloadSourcesTable.toArray().then((downloadSources) => {
const newRepacksCount = event.data; downloadSources
window.electron.publishNewRepacksNotification(newRepacksCount); .filter((source) => !source.fingerprint)
}; .forEach((downloadSource) => {
window.electron
.putDownloadSource(downloadSource.objectIds)
.then(({ fingerprint }) => {
downloadSourcesTable.update(downloadSource.id, { fingerprint });
});
});
});
};
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
} }, [updateRepacks]);
for (const downloadSource of downloadSources) {
logger.info("Migrating download source", downloadSource.url);
const channel = new BroadcastChannel(
`download_sources:import:${downloadSource.url}`
);
await new Promise((resolve) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
downloadSource.url,
]);
channel.onmessage = () => {
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
resolve(true);
logger.info(
"Deleted download source from SQLite",
downloadSource.url
);
});
indexRepacks();
channel.close();
};
}).catch(() => channel.close());
}
downloadSourceMigrationLock.current = false;
});
}, [indexRepacks]);
const handleToastClose = useCallback(() => { const handleToastClose = useCallback(() => {
dispatch(closeToast()); dispatch(closeToast());
@ -308,6 +257,12 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
/> />
<HydraCloudModal
visible={isHydraCloudModalVisible}
onClose={hideHydraCloudModal}
feature={hydraCloudFeature}
/>
{userDetails && ( {userDetails && (
<UserFriendModal <UserFriendModal
visible={isFriendsModalVisible} visible={isFriendsModalVisible}
@ -321,11 +276,7 @@ export function App() {
<Sidebar /> <Sidebar />
<article className={styles.container}> <article className={styles.container}>
<Header <Header />
onSearch={handleSearch}
search={search}
onClear={handleClear}
/>
<section ref={contentRef} className={styles.content}> <section ref={contentRef} className={styles.content}>
<Outlet /> <Outlet />

View File

@ -0,0 +1,13 @@
<svg width="55" height="49" viewBox="0 0 55 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.8501 29.1176L19.9196 28.3235L20.6957 25.6764L20.437 24.8823L18.1088 25.9411L14.487 24.3528L10.0891 23.0293L5.69128 23.5587L3.10431 25.6764L2.58691 29.1176L4.1391 33.6177L5.69128 36.7942L8.53695 38.9118L10.8652 38.3824L13.9696 34.9412V31.5L12.9348 29.1176V32.2941L10.8652 34.9412H7.50216L4.91519 32.2941L5.69128 28.3235L9.57174 26.4705L13.9696 27.5294L17.8501 29.1176Z" fill="white"/>
<path d="M36.9585 29.1176L34.889 28.3235L34.1129 25.6764L34.3716 24.8823L36.6998 25.9411L40.3216 24.3528L44.7195 23.0293L49.1173 23.5587L51.7043 25.6764L52.2217 29.1176L50.6695 33.6177L49.1173 36.7942L46.2716 38.9118L43.9434 38.3824L40.839 34.9412V31.5L41.8738 29.1176V32.2941L43.9434 34.9412H47.3064L49.8934 32.2941L49.1173 28.3235L45.2369 26.4705L40.839 27.5294L36.9585 29.1176Z" fill="white"/>
<path d="M40.3873 19.4005L38.8784 19.9071L38.7685 19.0593L38.5553 17.7049L39.811 14.777L41.6564 11.5721L44.483 9.44001L47.1023 9.23162L49.2244 10.9371L50.7089 14.4032L51.4925 17.1031L50.9665 19.9071L49.3381 20.8916L45.7182 20.6206L43.8957 18.6282L43.2332 16.675L44.9154 18.5142L47.5156 18.8992L49.4627 17.0344L49.5586 14.0673L47.0064 12.1987L43.7784 13.2776L41.7929 16.3293L40.3873 19.4005Z" fill="white"/>
<path d="M14.0238 19.4005L15.5327 19.9071L15.6426 19.0593L15.8559 17.7049L14.6001 14.777L12.7548 11.5721L9.92812 9.44001L7.30879 9.23162L5.18676 10.9371L3.70221 14.4032L2.91861 17.1031L3.44468 19.9071L5.07308 20.8916L8.69292 20.6206L10.5154 18.6282L11.178 16.675L9.4957 18.5142L6.89555 18.8992L4.94841 17.0344L4.8525 14.0673L7.4047 12.1987L10.6327 13.2776L12.6182 16.3293L14.0238 19.4005Z" fill="white"/>
<path d="M19.9494 36.4343L22.554 34.3372L21.9876 34.0884L20.9528 31.9707L19.9494 32.5001L17.5898 34.0884L15.3904 37.377L14.744 40.4414L15.2615 43.0885L17.0724 45.4709L20.9528 47.3238L23.6932 46.5297L25.435 44.7653L26.0028 42.9192L25.6093 39.0508L25.0919 37.7913L23.6932 37.0002L24.3158 39.105L24.3158 42.0296L22.3597 43.9181L19.9494 43.9181L18.0225 42.0296L17.4949 39.105L19.9494 36.4343Z" fill="white"/>
<path d="M35.0955 36.4343L32.4909 34.3372L33.0573 34.0884L34.0921 31.9707L35.0955 32.5001L37.4552 34.0884L39.6545 37.377L40.3009 40.4414L39.7834 43.0885L37.9725 45.4709L34.0921 47.3238L31.3518 46.5297L29.6099 44.7653L29.0421 42.9192L29.4356 39.0508L29.953 37.7913L31.3518 37.0002L30.7291 39.105L30.7291 42.0296L32.6852 43.9181L35.0955 43.9181L37.0224 42.0296L37.55 39.105L35.0955 36.4343Z" fill="white"/>
<path d="M18.8447 8.70593V5.79413L20.1382 5H22.9839L27.3817 5.79413L31.7796 5H34.6252L35.9187 5.79413V8.70593L38.7644 11.353L37.2122 15.0589L37.9883 20.8825L35.9187 23.0002L33.3317 23.7943L32.8144 26.1767L33.3317 28.8238L32.8144 30.9415L31.7796 33.0591L30.2274 33.8533H27.3817H24.536L22.9839 33.0591L21.9491 30.9415L21.4317 28.8238L21.9491 26.1767L21.4317 23.7943L18.8447 23.0002L16.7751 20.8825L17.5512 15.0589L15.999 11.353L18.8447 8.70593Z" fill="white"/>
<path d="M15.5205 6.88232L16.2966 8.73528L17.5901 7.67645V4.76465L15.5205 6.88232Z" fill="white"/>
<path d="M39.2861 6.88232L38.51 8.73528L37.2166 7.67645V4.76465L39.2861 6.88232Z" fill="white"/>
<path d="M18.3667 2.11767L18.6254 4.23534L19.4015 3.70593H20.9537L20.4363 2.11767L17.0732 0L18.3667 2.11767Z" fill="white"/>
<path d="M35.6997 2.11767L35.441 4.23534L34.6649 3.70593H33.1127L33.6301 2.11767L36.9932 0L35.6997 2.11767Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -7,9 +7,5 @@ export interface BadgeProps {
} }
export function Badge({ children }: BadgeProps) { export function Badge({ children }: BadgeProps) {
return ( return <div className="badge">{children}</div>;
<div className="badge">
<span>{children}</span>
</div>
);
} }

View File

@ -1,6 +1,7 @@
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const checkboxField = style({ export const checkboxField = style({
display: "flex", display: "flex",
@ -10,19 +11,31 @@ export const checkboxField = style({
cursor: "pointer", cursor: "pointer",
}); });
export const checkbox = style({ export const checkbox = recipe({
width: "20px", base: {
height: "20px", width: "20px",
borderRadius: "4px", height: "20px",
backgroundColor: vars.color.darkBackground, borderRadius: "4px",
display: "flex", backgroundColor: vars.color.darkBackground,
justifyContent: "center", display: "flex",
alignItems: "center", justifyContent: "center",
position: "relative", alignItems: "center",
transition: "all ease 0.2s", position: "relative",
border: `solid 1px ${vars.color.border}`, transition: "all ease 0.2s",
":hover": { border: `solid 1px ${vars.color.border}`,
borderColor: "rgba(255, 255, 255, 0.5)", minWidth: "20px",
minHeight: "20px",
color: vars.color.darkBackground,
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
variants: {
checked: {
true: {
backgroundColor: vars.color.muted,
},
},
}, },
}); });
@ -38,4 +51,7 @@ export const checkboxInput = style({
export const checkboxLabel = style({ export const checkboxLabel = style({
cursor: "pointer", cursor: "pointer",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}); });

View File

@ -15,7 +15,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
return ( return (
<div className={styles.checkboxField}> <div className={styles.checkboxField}>
<div className={styles.checkbox}> <div className={styles.checkbox({ checked: props.checked })}>
<input <input
id={id} id={id}
type="checkbox" type="checkbox"

View File

@ -1,21 +1,21 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { CatalogueEntry, GameRepack, GameStats } from "@types"; import type { GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css"; import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge"; import { Badge } from "../badge/badge";
import { useCallback, useContext, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { useFormat } from "@renderer/hooks"; import { useFormat, useRepacks } from "@renderer/hooks";
import { repacksContext } from "@renderer/context"; import { steamUrlBuilder } from "@shared";
export interface GameCardProps export interface GameCardProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>, React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement HTMLButtonElement
> { > {
game: CatalogueEntry; game: any;
} }
const shopIcon = { const shopIcon = {
@ -26,20 +26,12 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card"); const { t } = useTranslation("game_card");
const [stats, setStats] = useState<GameStats | null>(null); const [stats, setStats] = useState<GameStats | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext); const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(game.title).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, isIndexingRepacks, searchRepacks]);
const uniqueRepackers = Array.from( const uniqueRepackers = Array.from(
new Set(repacks.map(({ repacker }) => repacker)) new Set(repacks.map((repack) => repack.repacker))
); );
const handleHover = useCallback(() => { const handleHover = useCallback(() => {
@ -61,7 +53,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
> >
<div className={styles.backdrop}> <div className={styles.backdrop}>
<img <img
src={game.cover} src={steamUrlBuilder.library(game.objectId)}
alt={game.title} alt={game.title}
className={styles.cover} className={styles.cover}
loading="lazy" loading="lazy"

View File

@ -6,14 +6,8 @@ import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import { useAppDispatch, useAppSelector } from "@renderer/hooks"; import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import * as styles from "./header.css"; import * as styles from "./header.css";
import { clearSearch } from "@renderer/features";
import { AutoUpdateSubHeader } from "./auto-update-sub-header"; import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters } from "@renderer/features";
export interface HeaderProps {
onSearch: (query: string) => void;
onClear: () => void;
search?: string;
}
const pathTitle: Record<string, string> = { const pathTitle: Record<string, string> = {
"/": "home", "/": "home",
@ -22,7 +16,7 @@ const pathTitle: Record<string, string> = {
"/settings": "settings", "/settings": "settings",
}; };
export function Header({ onSearch, onClear, search }: HeaderProps) { export function Header() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
@ -31,6 +25,11 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
const { headerTitle, draggingDisabled } = useAppSelector( const { headerTitle, draggingDisabled } = useAppSelector(
(state) => state.window (state) => state.window
); );
const searchValue = useAppSelector(
(state) => state.catalogueSearch.filters.title
);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
@ -46,12 +45,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
return t(pathTitle[location.pathname]); return t(pathTitle[location.pathname]);
}, [location.pathname, headerTitle, t]); }, [location.pathname, headerTitle, t]);
useEffect(() => {
if (search && !location.pathname.startsWith("/search")) {
dispatch(clearSearch());
}
}, [location.pathname, search, dispatch]);
const focusInput = () => { const focusInput = () => {
setIsFocused(true); setIsFocused(true);
inputRef.current?.focus(); inputRef.current?.focus();
@ -65,6 +58,20 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
navigate(-1); navigate(-1);
}; };
const handleSearch = (value: string) => {
dispatch(setFilters({ title: value }));
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");
}
};
useEffect(() => {
if (!location.pathname.startsWith("/catalogue") && searchValue) {
dispatch(setFilters({ title: "" }));
}
}, [location.pathname, searchValue, dispatch]);
return ( return (
<> <>
<header <header
@ -109,17 +116,17 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
type="text" type="text"
name="search" name="search"
placeholder={t("search")} placeholder={t("search")}
value={search} value={searchValue}
className={styles.searchInput} className={styles.searchInput}
onChange={(event) => onSearch(event.target.value)} onChange={(event) => handleSearch(event.target.value)}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={handleBlur} onBlur={handleBlur}
/> />
{search && ( {searchValue && (
<button <button
type="button" type="button"
onClick={onClear} onClick={() => dispatch(setFilters({ title: "" }))}
className={styles.actionButton} className={styles.actionButton}
> >
<XIcon /> <XIcon />

View File

@ -1,7 +1,6 @@
import { import {
createContext, createContext,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
@ -14,12 +13,12 @@ import {
useAppDispatch, useAppDispatch,
useAppSelector, useAppSelector,
useDownload, useDownload,
useRepacks,
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
import type { import type {
Game, Game,
GameRepack,
GameShop, GameShop,
GameStats, GameStats,
ShopDetails, ShopDetails,
@ -29,7 +28,6 @@ import type {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GameDetailsContext } from "./game-details.context.types"; import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared"; import { SteamContentDescriptor } from "@shared";
import { repacksContext } from "../repacks/repacks.context";
export const gameDetailsContext = createContext<GameDetailsContext>({ export const gameDetailsContext = createContext<GameDetailsContext>({
game: null, game: null,
@ -53,7 +51,6 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
setShowGameOptionsModal: () => {}, setShowGameOptionsModal: () => {},
setShowRepacksModal: () => {}, setShowRepacksModal: () => {},
setHasNSFWContentBlocked: () => {}, setHasNSFWContentBlocked: () => {},
handleClickOpenCheckout: () => {},
}); });
const { Provider } = gameDetailsContext; const { Provider } = gameDetailsContext;
@ -88,17 +85,8 @@ export function GameDetailsContextProvider({
const [showRepacksModal, setShowRepacksModal] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [repacks, setRepacks] = useState<GameRepack[]>([]); const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(objectId);
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(gameTitle).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
const { i18n } = useTranslation("game_details"); const { i18n } = useTranslation("game_details");
@ -111,11 +99,6 @@ export function GameDetailsContextProvider({
(state) => state.userPreferences.value (state) => state.userPreferences.value
); );
const handleClickOpenCheckout = () => {
// TODO: show modal before redirecting to checkout page
window.electron.openCheckout();
};
const updateGame = useCallback(async () => { const updateGame = useCallback(async () => {
return window.electron return window.electron
.getGameByObjectId(objectId!) .getGameByObjectId(objectId!)
@ -290,7 +273,6 @@ export function GameDetailsContextProvider({
updateGame, updateGame,
setShowRepacksModal, setShowRepacksModal,
setShowGameOptionsModal, setShowGameOptionsModal,
handleClickOpenCheckout,
}} }}
> >
{children} {children}

View File

@ -29,5 +29,4 @@ export interface GameDetailsContext {
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>; setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>; setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>; setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
handleClickOpenCheckout: () => void;
} }

View File

@ -1,5 +1,4 @@
export * from "./game-details/game-details.context"; export * from "./game-details/game-details.context";
export * from "./settings/settings.context"; export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context"; export * from "./user-profile/user-profile.context";
export * from "./repacks/repacks.context";
export * from "./cloud-sync/cloud-sync.context"; export * from "./cloud-sync/cloud-sync.context";

View File

@ -1,67 +0,0 @@
import type { GameRepack } from "@types";
import { createContext, useCallback, useEffect, useState } from "react";
import { repacksWorker } from "@renderer/workers";
export interface RepacksContext {
searchRepacks: (query: string) => Promise<GameRepack[]>;
indexRepacks: () => void;
isIndexingRepacks: boolean;
}
export const repacksContext = createContext<RepacksContext>({
searchRepacks: async () => [] as GameRepack[],
indexRepacks: () => {},
isIndexingRepacks: false,
});
const { Provider } = repacksContext;
export const { Consumer: RepacksContextConsumer } = repacksContext;
export interface RepacksContextProps {
children: React.ReactNode;
}
export function RepacksContextProvider({ children }: RepacksContextProps) {
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
const searchRepacks = useCallback(async (query: string) => {
return new Promise<GameRepack[]>((resolve) => {
const channelId = crypto.randomUUID();
repacksWorker.postMessage([channelId, query]);
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
resolve(event.data);
channel.close();
};
return [];
});
}, []);
const indexRepacks = useCallback(() => {
setIsIndexingRepacks(true);
repacksWorker.postMessage("INDEX_REPACKS");
repacksWorker.onmessage = () => {
setIsIndexingRepacks(false);
};
}, []);
useEffect(() => {
indexRepacks();
}, [indexRepacks]);
return (
<Provider
value={{
searchRepacks,
indexRepacks,
isIndexingRepacks,
}}
>
{children}
</Provider>
);
}

View File

@ -1,10 +1,8 @@
import type { CatalogueCategory } from "@shared"; import type { CatalogueCategory } from "@shared";
import type { import type {
AppUpdaterEvent, AppUpdaterEvent,
CatalogueEntry,
Game, Game,
LibraryGame, LibraryGame,
GameRepack,
GameShop, GameShop,
HowLongToBeatCategory, HowLongToBeatCategory,
ShopDetails, ShopDetails,
@ -14,7 +12,6 @@ import type {
UserPreferences, UserPreferences,
StartGameDownloadPayload, StartGameDownloadPayload,
RealDebridUser, RealDebridUser,
DownloadSource,
UserProfile, UserProfile,
FriendRequest, FriendRequest,
FriendRequestAction, FriendRequestAction,
@ -31,6 +28,7 @@ import type {
LudusaviBackup, LudusaviBackup,
UserAchievement, UserAchievement,
ComparedAchievements, ComparedAchievements,
CatalogueSearchPayload,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -58,8 +56,12 @@ declare global {
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer; onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
/* Catalogue */ /* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>; searchGames: (
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>; payload: CatalogueSearchPayload,
take: number,
skip: number
) => Promise<{ edges: any[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
getGameShopDetails: ( getGameShopDetails: (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
@ -70,8 +72,6 @@ declare global {
objectId: string, objectId: string,
shop: GameShop shop: GameShop
) => Promise<HowLongToBeatCategory[] | null>; ) => Promise<HowLongToBeatCategory[] | null>;
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>; getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>; getTrendingGames: () => Promise<TrendingGame[]>;
onUpdateAchievements: ( onUpdateAchievements: (
@ -79,6 +79,8 @@ declare global {
shop: GameShop, shop: GameShop,
cb: (achievements: GameAchievement[]) => void cb: (achievements: GameAchievement[]) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
getPublishers: () => Promise<string[]>;
getDevelopers: () => Promise<string[]>;
/* Library */ /* Library */
addGameToLibrary: ( addGameToLibrary: (
@ -125,8 +127,9 @@ declare global {
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>; authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
/* Download sources */ /* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>; putDownloadSource: (
deleteDownloadSource: (id: number) => Promise<void>; objectIds: string[]
) => Promise<{ fingerprint: string }>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>; getDiskFreeSpace: (path: string) => Promise<DiskSpace>;

View File

@ -21,11 +21,10 @@ export interface CatalogueCache {
export const db = new Dexie("Hydra"); export const db = new Dexie("Hydra");
db.version(5).stores({ db.version(8).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`, repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`, downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`, howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
}); });
export const downloadSourcesTable = db.table("downloadSources"); export const downloadSourcesTable = db.table("downloadSources");
@ -34,6 +33,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
"howLongToBeatEntries" "howLongToBeatEntries"
); );
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
db.open(); db.open();

View File

@ -0,0 +1,67 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { CatalogueSearchPayload } from "@types";
export interface CatalogueSearchState {
filters: CatalogueSearchPayload;
page: number;
steamUserTags: Record<string, Record<string, number>>;
steamGenres: Record<string, string[]>;
}
const initialState: CatalogueSearchState = {
filters: {
title: "",
downloadSourceFingerprints: [],
tags: [],
publishers: [],
genres: [],
developers: [],
},
steamUserTags: {},
steamGenres: {},
page: 1,
};
export const catalogueSearchSlice = createSlice({
name: "catalogueSearch",
initialState,
reducers: {
setFilters: (
state,
action: PayloadAction<Partial<CatalogueSearchPayload>>
) => {
state.filters = { ...state.filters, ...action.payload };
state.page = initialState.page;
},
clearFilters: (state) => {
state.filters = initialState.filters;
state.page = initialState.page;
},
setPage: (state, action: PayloadAction<number>) => {
state.page = action.payload;
},
clearPage: (state) => {
state.page = initialState.page;
},
setTags: (
state,
action: PayloadAction<Record<string, Record<string, number>>>
) => {
state.steamUserTags = action.payload;
},
setGenres: (state, action: PayloadAction<Record<string, string[]>>) => {
state.steamGenres = action.payload;
},
},
});
export const {
setFilters,
clearFilters,
setPage,
clearPage,
setTags,
setGenres,
} = catalogueSearchSlice.actions;

View File

@ -1,4 +1,3 @@
export * from "./search-slice";
export * from "./library-slice"; export * from "./library-slice";
export * from "./use-preferences-slice"; export * from "./use-preferences-slice";
export * from "./download-slice"; export * from "./download-slice";
@ -6,3 +5,6 @@ export * from "./window-slice";
export * from "./toast-slice"; export * from "./toast-slice";
export * from "./user-details-slice"; export * from "./user-details-slice";
export * from "./running-game-slice"; export * from "./running-game-slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./catalogue-search";

View File

@ -0,0 +1,24 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { GameRepack } from "@types";
export interface RepacksState {
value: GameRepack[];
}
const initialState: RepacksState = {
value: [],
};
export const repacksSlice = createSlice({
name: "repacks",
initialState,
reducers: {
setRepacks: (state, action: PayloadAction<RepacksState["value"]>) => {
state.value = action.payload;
},
},
});
export const { setRepacks } = repacksSlice.actions;

View File

@ -1,25 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
export interface SearchState {
value: string;
}
const initialState: SearchState = {
value: "",
};
export const searchSlice = createSlice({
name: "search",
initialState,
reducers: {
setSearch: (state, action: PayloadAction<string>) => {
state.value = action.payload;
},
clearSearch: (state) => {
state.value = "";
},
},
});
export const { setSearch, clearSearch } = searchSlice.actions;

View File

@ -0,0 +1,32 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { HydraCloudFeature } from "@types";
export interface SubscriptionState {
isHydraCloudModalVisible: boolean;
feature: HydraCloudFeature | "";
}
const initialState: SubscriptionState = {
isHydraCloudModalVisible: false,
feature: "",
};
export const subscriptionSlice = createSlice({
name: "subscription",
initialState,
reducers: {
setHydraCloudModalVisible: (
state,
action: PayloadAction<HydraCloudFeature>
) => {
state.isHydraCloudModalVisible = true;
state.feature = action.payload;
},
setHydraCloudModalHidden: (state) => {
state.isHydraCloudModalVisible = false;
},
},
});
export const { setHydraCloudModalVisible, setHydraCloudModalHidden } =
subscriptionSlice.actions;

View File

@ -5,3 +5,4 @@ export * from "./use-toast";
export * from "./redux"; export * from "./redux";
export * from "./use-user-details"; export * from "./use-user-details";
export * from "./use-format"; export * from "./use-format";
export * from "./use-repacks";

View File

@ -0,0 +1,53 @@
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
});
export function useCatalogue() {
const dispatch = useAppDispatch();
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
dispatch(setTags(response.data));
});
}, [dispatch]);
const getSteamGenres = useCallback(() => {
externalResourcesInstance.get("/steam-genres.json").then((response) => {
dispatch(setGenres(response.data));
});
}, [dispatch]);
const getSteamPublishers = useCallback(() => {
externalResourcesInstance.get("/steam-publishers.json").then((response) => {
setSteamPublishers(response.data);
});
}, []);
const getSteamDevelopers = useCallback(() => {
externalResourcesInstance.get("/steam-developers.json").then((response) => {
setSteamDevelopers(response.data);
});
}, []);
useEffect(() => {
getSteamUserTags();
getSteamGenres();
getSteamPublishers();
getSteamDevelopers();
}, [
getSteamUserTags,
getSteamGenres,
getSteamPublishers,
getSteamDevelopers,
]);
return { steamPublishers, steamDevelopers };
}

View File

@ -10,5 +10,5 @@ export function useFormat() {
}); });
}, [i18n.language]); }, [i18n.language]);
return { numberFormatter }; return { numberFormatter, formatNumber: numberFormatter.format };
} }

View File

@ -0,0 +1,34 @@
import { repacksTable } from "@renderer/dexie";
import { setRepacks } from "@renderer/features";
import { useCallback } from "react";
import { RootState } from "@renderer/store";
import { useSelector } from "react-redux";
import { useAppDispatch } from "./redux";
export function useRepacks() {
const dispatch = useAppDispatch();
const repacks = useSelector((state: RootState) => state.repacks.value);
const getRepacksForObjectId = useCallback(
(objectId: string) => {
return repacks.filter((repack) => repack.objectIds.includes(objectId));
},
[repacks]
);
const updateRepacks = useCallback(() => {
repacksTable.toArray().then((repacks) => {
dispatch(
setRepacks(
JSON.parse(
JSON.stringify(
repacks.filter((repack) => Array.isArray(repack.objectIds))
)
)
)
);
});
}, [dispatch]);
return { getRepacksForObjectId, updateRepacks };
}

View File

@ -0,0 +1,33 @@
import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import {
setHydraCloudModalVisible,
setHydraCloudModalHidden,
} from "@renderer/features";
import { HydraCloudFeature } from "@types";
export function useSubscription() {
const dispatch = useAppDispatch();
const { isHydraCloudModalVisible, feature } = useAppSelector(
(state) => state.subscription
);
const showHydraCloudModal = useCallback(
(feature: HydraCloudFeature) => {
dispatch(setHydraCloudModalVisible(feature));
},
[dispatch]
);
const hideHydraCloudModal = useCallback(() => {
dispatch(setHydraCloudModalHidden());
}, [dispatch]);
return {
isHydraCloudModalVisible,
hydraCloudFeature: feature,
showHydraCloudModal,
hideHydraCloudModal,
};
}

View File

@ -18,7 +18,6 @@ import { store } from "./store";
import resources from "@locales"; import resources from "@locales";
import { RepacksContextProvider } from "./context";
import { SuspenseWrapper } from "./components"; import { SuspenseWrapper } from "./components";
import { logger } from "./logger"; import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies"; import { addCookieInterceptor } from "./cookies";
@ -28,7 +27,6 @@ const GameDetails = React.lazy(
() => import("./pages/game-details/game-details") () => import("./pages/game-details/game-details")
); );
const Downloads = React.lazy(() => import("./pages/downloads/downloads")); const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
const SearchResults = React.lazy(() => import("./pages/home/search-results"));
const Settings = React.lazy(() => import("./pages/settings/settings")); const Settings = React.lazy(() => import("./pages/settings/settings"));
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue")); const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
const Profile = React.lazy(() => import("./pages/profile/profile")); const Profile = React.lazy(() => import("./pages/profile/profile"));
@ -64,43 +62,37 @@ i18n
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<Provider store={store}> <Provider store={store}>
<RepacksContextProvider> <HashRouter>
<HashRouter> <Routes>
<Routes> <Route element={<App />}>
<Route element={<App />}> <Route path="/" element={<SuspenseWrapper Component={Home} />} />
<Route path="/" element={<SuspenseWrapper Component={Home} />} /> <Route
<Route path="/catalogue"
path="/catalogue" element={<SuspenseWrapper Component={Catalogue} />}
element={<SuspenseWrapper Component={Catalogue} />} />
/> <Route
<Route path="/downloads"
path="/downloads" element={<SuspenseWrapper Component={Downloads} />}
element={<SuspenseWrapper Component={Downloads} />} />
/> <Route
<Route path="/game/:shop/:objectId"
path="/game/:shop/:objectId" element={<SuspenseWrapper Component={GameDetails} />}
element={<SuspenseWrapper Component={GameDetails} />} />
/> <Route
<Route path="/settings"
path="/search" element={<SuspenseWrapper Component={Settings} />}
element={<SuspenseWrapper Component={SearchResults} />} />
/> <Route
<Route path="/profile/:userId"
path="/settings" element={<SuspenseWrapper Component={Profile} />}
element={<SuspenseWrapper Component={Settings} />} />
/> <Route
<Route path="/achievements"
path="/profile/:userId" element={<SuspenseWrapper Component={Achievements} />}
element={<SuspenseWrapper Component={Profile} />} />
/> </Route>
<Route </Routes>
path="/achievements" </HashRouter>
element={<SuspenseWrapper Component={Achievements} />}
/>
</Route>
</Routes>
</HashRouter>
</RepacksContextProvider>
</Provider> </Provider>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -0,0 +1,90 @@
import { useDate } from "@renderer/hooks";
import type { UserAchievement } from "@types";
import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css";
import { EyeClosedIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { vars } from "@renderer/theme.css";
interface AchievementListProps {
achievements: UserAchievement[];
}
export function AchievementList({ achievements }: AchievementListProps) {
const { t } = useTranslation("achievement");
const { showHydraCloudModal } = useSubscription();
const { formatDateTime } = useDate();
return (
<ul className={styles.list}>
{achievements.map((achievement, index) => (
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
<img
className={styles.listItemImage({
unlocked: achievement.unlocked,
})}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div style={{ flex: 1 }}>
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
{achievement.hidden && (
<span
style={{ display: "flex" }}
title={t("hidden_achievement_tooltip")}
>
<EyeClosedIcon size={12} />
</span>
)}
{achievement.displayName}
</h4>
<p>{achievement.description}</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{achievement.points != undefined ? (
<div
style={{ display: "flex", alignItems: "center", gap: "4px" }}
title={t("achievement_earn_points", {
points: achievement.points,
})}
>
<HydraIcon width={20} height={20} />
<p style={{ fontSize: "1.1em" }}>{achievement.points}</p>
</div>
) : (
<button
onClick={() => showHydraCloudModal("achievements")}
style={{
display: "flex",
alignItems: "center",
gap: "4px",
cursor: "pointer",
color: vars.color.warning,
}}
title={t("achievement_earn_points", {
points: "???",
})}
>
<HydraIcon width={20} height={20} />
<p style={{ fontSize: "1.1em" }}>???</p>
</button>
)}
{achievement.unlockTime && (
<div
title={t("unlocked_at", {
date: formatDateTime(achievement.unlockTime),
})}
style={{ whiteSpace: "nowrap", gap: "4px", display: "flex" }}
>
<small>{formatDateTime(achievement.unlockTime)}</small>
</div>
)}
</div>
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,71 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const panel = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
backgroundColor: vars.color.background,
display: "flex",
flexDirection: "column",
alignItems: "start",
justifyContent: "space-between",
borderBottom: `solid 1px ${vars.color.border}`,
});
export const content = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadDetailsRow = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: vars.color.body,
alignItems: "center",
});
export const downloadsLink = style({
color: vars.color.body,
textDecoration: "underline",
});
export const progressBar = recipe({
base: {
position: "absolute",
bottom: "0",
left: "0",
width: "100%",
height: "3px",
transition: "all ease 0.2s",
"::-webkit-progress-bar": {
backgroundColor: "transparent",
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
},
variants: {
disabled: {
true: {
opacity: vars.opacity.disabled,
},
},
},
});
export const link = style({
textAlign: "start",
color: vars.color.body,
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});

View File

@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { UserAchievement } from "@types";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { useUserDetails } from "@renderer/hooks";
import { vars } from "@renderer/theme.css";
import * as styles from "./achievement-panel.css";
export interface AchievementPanelProps {
achievements: UserAchievement[];
}
export function AchievementPanel({ achievements }: AchievementPanelProps) {
const { t } = useTranslation("achievement");
const { hasActiveSubscription } = useUserDetails();
const { showHydraCloudModal } = useSubscription();
const achievementsPointsTotal = achievements.reduce(
(acc, achievement) => acc + (achievement.points ?? 0),
0
);
const achievementsPointsEarnedSum = achievements.reduce(
(acc, achievement) =>
acc + (achievement.unlocked ? (achievement.points ?? 0) : 0),
0
);
if (!hasActiveSubscription) {
return (
<div className={styles.panel}>
<div className={styles.content}>
{t("earned_points")} <HydraIcon width={20} height={20} />
??? / ???
</div>
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className={styles.link}
>
<small style={{ color: vars.color.warning }}>
{t("how_to_earn_achievements_points")}
</small>
</button>
</div>
);
}
return (
<div className={styles.panel}>
<div className={styles.content}>
{t("earned_points")} <HydraIcon width={20} height={20} />
{achievementsPointsEarnedSum} / {achievementsPointsTotal}
</div>
</div>
);
}

View File

@ -1,9 +1,8 @@
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks"; import { useAppDispatch, useUserDetails } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css";
import { import {
buildGameDetailsPath, buildGameDetailsPath,
formatDownloadProgress, formatDownloadProgress,
@ -11,11 +10,16 @@ import {
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react"; import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import type { ComparedAchievements, UserAchievement } from "@types"; import type { ComparedAchievements } from "@types";
import { average } from "color.js"; import { average } from "color.js";
import Color from "color"; import Color from "color";
import { Link } from "@renderer/components"; import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list"; import { ComparedAchievementList } from "./compared-achievement-list";
import * as styles from "./achievements.css";
import { AchievementList } from "./achievement-list";
import { AchievementPanel } from "./achievement-panel";
import { ComparedAchievementPanel } from "./compared-achievement-panel";
import { useSubscription } from "@renderer/hooks/use-subscription";
interface UserInfo { interface UserInfo {
id: string; id: string;
@ -30,10 +34,6 @@ interface AchievementsContentProps {
comparedAchievements: ComparedAchievements | null; comparedAchievements: ComparedAchievements | null;
} }
interface AchievementListProps {
achievements: UserAchievement[];
}
interface AchievementSummaryProps { interface AchievementSummaryProps {
user: UserInfo; user: UserInfo;
isComparison?: boolean; isComparison?: boolean;
@ -42,7 +42,7 @@ interface AchievementSummaryProps {
function AchievementSummary({ user, isComparison }: AchievementSummaryProps) { function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
const { t } = useTranslation("achievement"); const { t } = useTranslation("achievement");
const { userDetails, hasActiveSubscription } = useUserDetails(); const { userDetails, hasActiveSubscription } = useUserDetails();
const { handleClickOpenCheckout } = useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription();
const getProfileImage = ( const getProfileImage = (
user: Pick<UserInfo, "profileImageUrl" | "displayName"> user: Pick<UserInfo, "profileImageUrl" | "displayName">
@ -93,7 +93,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
<h3> <h3>
<button <button
className={styles.subscriptionRequiredButton} className={styles.subscriptionRequiredButton}
onClick={handleClickOpenCheckout} onClick={() => showHydraCloudModal("achievements")}
> >
{t("subscription_needed")} {t("subscription_needed")}
</button> </button>
@ -171,38 +171,6 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
); );
} }
function AchievementList({ achievements }: AchievementListProps) {
const { t } = useTranslation("achievement");
const { formatDateTime } = useDate();
return (
<ul className={styles.list}>
{achievements.map((achievement, index) => (
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
<img
className={styles.listItemImage({
unlocked: achievement.unlocked,
})}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div style={{ flex: 1 }}>
<h4>{achievement.displayName}</h4>
<p>{achievement.description}</p>
</div>
{achievement.unlockTime && (
<div style={{ whiteSpace: "nowrap" }}>
<small>{t("unlocked_at")}</small>
<p>{formatDateTime(achievement.unlockTime)}</p>
</div>
)}
</li>
))}
</ul>
);
}
export function AchievementsContent({ export function AchievementsContent({
otherUser, otherUser,
comparedAchievements, comparedAchievements,
@ -355,9 +323,15 @@ export function AchievementsContent({
)} )}
{otherUser ? ( {otherUser ? (
<ComparedAchievementList achievements={comparedAchievements!} /> <>
<ComparedAchievementPanel achievements={comparedAchievements!} />
<ComparedAchievementList achievements={comparedAchievements!} />
</>
) : ( ) : (
<AchievementList achievements={achievements!} /> <>
<AchievementPanel achievements={achievements!} />
<AchievementList achievements={achievements!} />
</>
)} )}
</section> </section>
</div> </div>

View File

@ -1,8 +1,13 @@
import type { ComparedAchievements } from "@types"; import type { ComparedAchievements } from "@types";
import * as styles from "./achievements.css"; import * as styles from "./achievements.css";
import { CheckCircleIcon, LockIcon } from "@primer/octicons-react"; import {
CheckCircleIcon,
EyeClosedIcon,
LockIcon,
} from "@primer/octicons-react";
import { useDate } from "@renderer/hooks"; import { useDate } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export interface ComparedAchievementListProps { export interface ComparedAchievementListProps {
achievements: ComparedAchievements; achievements: ComparedAchievements;
@ -11,6 +16,7 @@ export interface ComparedAchievementListProps {
export function ComparedAchievementList({ export function ComparedAchievementList({
achievements, achievements,
}: ComparedAchievementListProps) { }: ComparedAchievementListProps) {
const { t } = useTranslation("achievement");
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
return ( return (
@ -43,7 +49,17 @@ export function ComparedAchievementList({
loading="lazy" loading="lazy"
/> />
<div> <div>
<h4>{achievement.displayName}</h4> <h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
{achievement.hidden && (
<span
style={{ display: "flex" }}
title={t("hidden_achievement_tooltip")}
>
<EyeClosedIcon size={12} />
</span>
)}
{achievement.displayName}
</h4>
<p>{achievement.description}</p> <p>{achievement.description}</p>
</div> </div>
</div> </div>
@ -58,11 +74,9 @@ export function ComparedAchievementList({
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
justifyContent: "center", justifyContent: "center",
}} }}
title={formatDateTime(achievement.ownerStat.unlockTime!)}
> >
<CheckCircleIcon /> <CheckCircleIcon />
<small>
{formatDateTime(achievement.ownerStat.unlockTime!)}
</small>
</div> </div>
) : ( ) : (
<div <div
@ -86,11 +100,9 @@ export function ComparedAchievementList({
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
justifyContent: "center", justifyContent: "center",
}} }}
title={formatDateTime(achievement.targetStat.unlockTime!)}
> >
<CheckCircleIcon /> <CheckCircleIcon />
<small>
{formatDateTime(achievement.targetStat.unlockTime!)}
</small>
</div> </div>
) : ( ) : (
<div <div

View File

@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import * as styles from "./achievement-panel.css";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { ComparedAchievements } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useUserDetails } from "@renderer/hooks";
export interface ComparedAchievementPanelProps {
achievements: ComparedAchievements;
}
export function ComparedAchievementPanel({
achievements,
}: ComparedAchievementPanelProps) {
const { t } = useTranslation("achievement");
const { hasActiveSubscription } = useUserDetails();
return (
<div
className={styles.panel}
style={{
display: "grid",
gridTemplateColumns: hasActiveSubscription ? "3fr 1fr 1fr" : "3fr 2fr",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{t("available_points")} <HydraIcon width={20} height={20} />{" "}
{achievements.achievementsPointsTotal}
</div>
{hasActiveSubscription && (
<div className={styles.content}>
<HydraIcon width={20} height={20} />
{achievements.owner.achievementsPointsEarnedSum ?? 0}
</div>
)}
<div className={styles.content}>
<HydraIcon width={20} height={20} />
{achievements.target.achievementsPointsEarnedSum}
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
@use "../../scss/globals.scss";
.catalogue {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
width: 100%;
padding: 16px;
scroll-behavior: smooth;
&__filters-container {
width: 270px;
min-width: 270px;
max-width: 270px;
background-color: globals.$dark-background-color;
border-radius: 4px;
padding: 16px;
border: 1px solid globals.$border-color;
align-self: flex-start;
}
}

View File

@ -1,114 +1,362 @@
import { Button, GameCard } from "@renderer/components"; import type { DownloadSource } from "@types";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { useTranslation } from "react-i18next";
import type { CatalogueEntry } from "@types"; import {
useAppDispatch,
useAppSelector,
useFormat,
useRepacks,
} from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import { clearSearch } from "@renderer/features"; import "./catalogue.scss";
import { useAppDispatch } from "@renderer/hooks";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "../home/home.css";
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { downloadSourcesTable } from "@renderer/dexie";
import { FilterSection } from "./filter-section";
import { setFilters, setPage } from "@renderer/features";
import { useTranslation } from "react-i18next";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Pagination } from "./pagination";
import { useCatalogue } from "@renderer/hooks/use-catalogue";
import { GameItem } from "./game-item";
import { FilterItem } from "./filter-item";
const filterCategoryColors = {
genres: "hsl(262deg 50% 47%)",
tags: "hsl(95deg 50% 20%)",
downloadSourceFingerprints: "hsl(27deg 50% 40%)",
developers: "hsl(340deg 50% 46%)",
publishers: "hsl(200deg 50% 30%)",
};
const PAGE_SIZE = 20;
export default function Catalogue() { export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null);
const cataloguePageRef = useRef<HTMLDivElement>(null);
const { steamDevelopers, steamPublishers } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
(state) => state.catalogueSearch
);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<any[]>([]);
const [itemsCount, setItemsCount] = useState(0);
const { formatNumber } = useFormat();
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useTranslation("catalogue"); const { t, i18n } = useTranslation("catalogue");
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]); const { getRepacksForObjectId } = useRepacks();
const [isLoading, setIsLoading] = useState(false);
const contentRef = useRef<HTMLElement>(null);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const skip = Number(searchParams.get("skip") ?? 0);
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(buildGameDetailsPath(game));
};
useEffect(() => { useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0; setResults([]);
setIsLoading(true); setIsLoading(true);
setSearchResults([]); abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
window.electron window.electron
.getGames(24, skip) .searchGames(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)
.then((results) => { .then((response) => {
return new Promise((resolve) => { if (abortController.signal.aborted) {
setTimeout(() => { return;
setSearchResults(results); }
resolve(null);
}, 500); setResults(response.edges);
}); setItemsCount(response.count);
})
.finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [dispatch, skip, searchParams]); }, [filters, page, dispatch]);
const handleNextPage = () => { useEffect(() => {
const params = new URLSearchParams({ downloadSourcesTable.toArray().then((sources) => {
skip: String(skip + 24), setDownloadSources(sources.filter((source) => !!source.fingerprint));
}); });
}, [getRepacksForObjectId]);
navigate(`/catalogue?${params.toString()}`); const language = i18n.language.split("-")[0];
};
const steamGenresMapping = useMemo<Record<string, string>>(() => {
if (!steamGenres[language]) return {};
return steamGenres[language].reduce((prev, genre, index) => {
prev[genre] = steamGenres["en"][index];
return prev;
}, {});
}, [steamGenres, language]);
const steamGenresFilterItems = useMemo(() => {
return Object.entries(steamGenresMapping)
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => ({
label: key,
value: value,
checked: filters.genres.includes(value),
}));
}, [steamGenresMapping, filters.genres]);
const steamUserTagsFilterItems = useMemo(() => {
if (!steamUserTags[language]) return [];
return Object.entries(steamUserTags[language])
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => ({
label: key,
value: value,
checked: filters.tags.includes(value),
}));
}, [steamUserTags, filters.tags, language]);
const groupedFilters = useMemo(() => {
return [
...filters.genres.map((genre) => ({
label: Object.keys(steamGenresMapping).find(
(key) => steamGenresMapping[key] === genre
) as string,
orbColor: filterCategoryColors.genres,
key: "genres",
value: genre,
})),
...filters.tags.map((tag) => ({
label: Object.keys(steamUserTags[language]).find(
(key) => steamUserTags[language][key] === tag
),
orbColor: filterCategoryColors.tags,
key: "tags",
value: tag,
})),
...filters.downloadSourceFingerprints.map((fingerprint) => ({
label: downloadSources.find(
(source) => source.fingerprint === fingerprint
)?.name as string,
orbColor: filterCategoryColors.downloadSourceFingerprints,
key: "downloadSourceFingerprints",
value: fingerprint,
})),
...filters.developers.map((developer) => ({
label: developer,
orbColor: filterCategoryColors.developers,
key: "developers",
value: developer,
})),
...filters.publishers.map((publisher) => ({
label: publisher,
orbColor: filterCategoryColors.publishers,
key: "publishers",
value: publisher,
})),
];
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
const filterSections = useMemo(() => {
return [
{
title: t("genres"),
items: steamGenresFilterItems,
key: "genres",
},
{
title: t("tags"),
items: steamUserTagsFilterItems,
key: "tags",
},
{
title: t("download_sources"),
items: downloadSources.map((source) => ({
label: source.name,
value: source.fingerprint,
checked: filters.downloadSourceFingerprints.includes(
source.fingerprint
),
})),
key: "downloadSourceFingerprints",
},
{
title: t("developers"),
items: steamDevelopers.map((developer) => ({
label: developer,
value: developer,
checked: filters.developers.includes(developer),
})),
key: "developers",
},
{
title: t("publishers"),
items: steamPublishers.map((publisher) => ({
label: publisher,
value: publisher,
checked: filters.publishers.includes(publisher),
})),
key: "publishers",
},
];
}, [
downloadSources,
filters.developers,
filters.downloadSourceFingerprints,
filters.publishers,
steamDevelopers,
steamGenresFilterItems,
steamPublishers,
steamUserTagsFilterItems,
t,
]);
return ( return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444"> <div className="catalogue" ref={cataloguePageRef}>
<section <div
style={{ style={{
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 4}px`,
display: "flex", display: "flex",
width: "100%", gap: 8,
justifyContent: "space-between",
alignItems: "center", alignItems: "center",
borderBottom: `1px solid ${vars.color.border}`, justifyContent: "space-between",
}} }}
> >
<Button <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
onClick={() => navigate(-1)} <ul
theme="outline" style={{
disabled={skip === 0 || isLoading} display: "flex",
> gap: 8,
<ArrowLeftIcon /> flexWrap: "wrap",
{t("previous_page")} listStyle: "none",
</Button> margin: 0,
padding: 0,
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}> }}
{t("next_page")} >
<ArrowRightIcon /> {groupedFilters.map((filter) => (
</Button> <li key={`${filter.key}-${filter.value}`}>
</section> <FilterItem
filter={filter.label ?? ""}
<section ref={contentRef} className={styles.content}> orbColor={filter.orbColor}
<section className={styles.cards}> onRemove={() => {
{isLoading && dispatch(
Array.from({ length: 12 }).map((_, index) => ( setFilters({
<Skeleton key={index} className={styles.cardSkeleton} /> [filter.key]: filters[filter.key].filter(
(item) => item !== filter.value
),
})
);
}}
/>
</li>
))} ))}
</ul>
</div>
</div>
{!isLoading && searchResults.length > 0 && ( <div
<> style={{
{searchResults.map((game) => ( display: "flex",
<GameCard gap: SPACING_UNIT * 2,
key={game.objectId} justifyContent: "space-between",
game={game} }}
onClick={() => handleGameClick(game)} >
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: 8,
}}
>
{isLoading ? (
<SkeletonTheme
baseColor={vars.color.darkBackground}
highlightColor={vars.color.background}
>
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton
key={i}
style={{
height: 105,
borderRadius: 4,
border: `solid 1px ${vars.color.border}`,
}}
/> />
))} ))}
</> </SkeletonTheme>
) : (
results.map((game) => <GameItem key={game.id} game={game} />)
)} )}
</section>
</section> <div
</SkeletonTheme> style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 16,
}}
>
<span style={{ fontSize: 12 }}>
{t("result_count", {
resultCount: formatNumber(itemsCount),
})}
</span>
<Pagination
page={page}
totalPages={Math.ceil(itemsCount / PAGE_SIZE)}
onPageChange={(page) => {
dispatch(setPage(page));
if (cataloguePageRef.current) {
cataloguePageRef.current.scrollTop = 0;
}
}}
/>
</div>
</div>
<div className="catalogue__filters-container">
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{filterSections.map((section) => (
<FilterSection
key={section.key}
title={section.title}
onClear={() => dispatch(setFilters({ [section.key]: [] }))}
color={filterCategoryColors[section.key]}
onSelect={(value) => {
if (filters[section.key].includes(value)) {
dispatch(
setFilters({
[section.key]: filters[
section.key as
| "genres"
| "tags"
| "downloadSourceFingerprints"
| "developers"
| "publishers"
].filter((item) => item !== value),
})
);
} else {
dispatch(
setFilters({
[section.key]: [...filters[section.key], value],
})
);
}
}}
items={section.items}
/>
))}
</div>
</div>
</div>
</div>
); );
} }

View File

@ -0,0 +1,50 @@
import { vars } from "@renderer/theme.css";
import { XIcon } from "@primer/octicons-react";
interface FilterItemProps {
filter: string;
orbColor: string;
onRemove: () => void;
}
export function FilterItem({ filter, orbColor, onRemove }: FilterItemProps) {
return (
<div
style={{
display: "flex",
alignItems: "center",
color: vars.color.body,
backgroundColor: vars.color.darkBackground,
padding: "6px 12px",
borderRadius: 4,
border: `solid 1px ${vars.color.border}`,
fontSize: 12,
}}
>
<div
style={{
width: 10,
height: 10,
backgroundColor: orbColor,
borderRadius: "50%",
marginRight: 8,
}}
/>
{filter}
<button
type="button"
onClick={onRemove}
style={{
color: vars.color.body,
marginLeft: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
<XIcon size={13} />
</button>
</div>
);
}

View File

@ -0,0 +1,136 @@
import { CheckboxField, TextField } from "@renderer/components";
import { useFormat } from "@renderer/hooks";
import { useCallback, useMemo, useState } from "react";
import List from "rc-virtual-list";
import { vars } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export interface FilterSectionProps {
title: string;
items: {
label: string;
value: string | number;
checked: boolean;
}[];
onSelect: (value: string | number) => void;
color: string;
onClear: () => void;
}
export function FilterSection({
title,
items,
color,
onSelect,
onClear,
}: FilterSectionProps) {
const [search, setSearch] = useState("");
const { t } = useTranslation("catalogue");
const filteredItems = useMemo(() => {
if (search.length > 0) {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}
return items;
}, [items, search]);
const selectedItemsCount = useMemo(() => {
return items.filter((item) => item.checked).length;
}, [items]);
const onSearch = useCallback((value: string) => {
setSearch(value);
}, []);
const { formatNumber } = useFormat();
if (!items.length) {
return null;
}
return (
<div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div
style={{
width: 10,
height: 10,
backgroundColor: color,
borderRadius: "50%",
}}
/>
<h3
style={{
fontSize: 16,
fontWeight: 500,
}}
>
{title}
</h3>
</div>
{selectedItemsCount > 0 ? (
<button
type="button"
style={{
fontSize: 12,
marginBottom: 12,
display: "block",
color: vars.color.body,
cursor: "pointer",
textDecoration: "underline",
}}
onClick={onClear}
>
{t("clear_filters", {
filterCount: formatNumber(selectedItemsCount),
})}
</button>
) : (
<span style={{ fontSize: 12, marginBottom: 12, display: "block" }}>
{t("filter_count", {
filterCount: formatNumber(items.length),
})}
</span>
)}
<TextField
placeholder={t("search")}
onChange={(e) => onSearch(e.target.value)}
value={search}
containerProps={{ style: { marginBottom: 16 } }}
theme="dark"
/>
<List
data={filteredItems}
height={28 * (filteredItems.length > 10 ? 10 : filteredItems.length)}
itemHeight={28}
itemKey="value"
styles={{
verticalScrollBar: {
backgroundColor: "rgba(255, 255, 255, 0.03)",
},
verticalScrollBarThumb: {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
},
}}
>
{(item) => (
<div key={item.value} style={{ height: 28, maxHeight: 28 }}>
<CheckboxField
label={item.label}
checked={item.checked}
onChange={() => onSelect(item.value)}
/>
</div>
)}
</List>
</div>
);
}

View File

@ -0,0 +1,48 @@
@use "../../scss/globals.scss";
.game-item {
background-color: globals.$dark-background-color;
width: 100%;
color: #fff;
display: flex;
align-items: center;
overflow: hidden;
position: relative;
border-radius: 4px;
border: 1px solid globals.$border-color;
cursor: pointer;
gap: calc(globals.$spacing-unit * 2);
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&__cover {
width: 200px;
height: 100%;
object-fit: cover;
border-right: 1px solid globals.$border-color;
}
&__details {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: calc(globals.$spacing-unit * 2) 0;
}
&__genres {
color: globals.$body-color;
font-size: 12px;
text-align: left;
margin-bottom: 4px;
}
&__repackers {
display: flex;
gap: globals.$spacing-unit;
flex-wrap: wrap;
}
}

View File

@ -0,0 +1,71 @@
import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useAppSelector, useRepacks } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import "./game-item.scss";
import { useTranslation } from "react-i18next";
export interface GameItemProps {
game: any;
}
export function GameItem({ game }: GameItemProps) {
const navigate = useNavigate();
const { i18n } = useTranslation();
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const language = i18n.language.split("-")[0];
const uniqueRepackers = useMemo(() => {
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
}, [repacks]);
const genres = useMemo(() => {
return game.genres?.map((genre) => {
const index = steamGenres["en"].findIndex(
(steamGenre) => steamGenre === genre
);
if (steamGenres[language] && steamGenres[language][index]) {
return steamGenres[language][index];
}
return genre;
});
}, [game.genres, language, steamGenres]);
return (
<button
type="button"
className="game-item"
onClick={() => navigate(buildGameDetailsPath(game))}
>
<img
className="game-item__cover"
src={steamUrlBuilder.library(game.objectId)}
alt={game.title}
loading="lazy"
/>
<div className="game-item__details">
<span>{game.title}</span>
<span className="game-item__genres">{genres.join(", ")}</span>
<div className="game-item__repackers">
{uniqueRepackers.map((repacker) => (
<Badge key={repacker}>{repacker}</Badge>
))}
</div>
</div>
</button>
);
}

View File

@ -0,0 +1,128 @@
import { Button } from "@renderer/components/button/button";
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks/use-format";
interface PaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({
page,
totalPages,
onPageChange,
}: PaginationProps) {
const { formatNumber } = useFormat();
if (totalPages <= 1) return null;
// Number of visible pages
const visiblePages = 3;
// Calculate the start and end of the visible range
let startPage = Math.max(1, page - 1); // Shift range slightly back
let endPage = startPage + visiblePages - 1;
// Adjust the range if we're near the start or end
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - visiblePages + 1);
}
return (
<div
style={{
display: "flex",
gap: 4,
}}
>
{/* Previous Button */}
<Button
theme="outline"
onClick={() => onPageChange(page - 1)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === 1}
>
<ChevronLeftIcon />
</Button>
{page > 2 && (
<>
{/* initial page */}
<Button
theme="outline"
onClick={() => onPageChange(1)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === 1}
>
{1}
</Button>
{/* ellipsis */}
<div
style={{
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div>
</>
)}
{/* Page Buttons */}
{Array.from(
{ length: endPage - startPage + 1 },
(_, i) => startPage + i
).map((pageNumber) => (
<Button
theme={page === pageNumber ? "primary" : "outline"}
key={pageNumber}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
onClick={() => onPageChange(pageNumber)}
>
{formatNumber(pageNumber)}
</Button>
))}
{page < totalPages - 1 && (
<>
{/* ellipsis */}
<div
style={{
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div>
{/* last page */}
<Button
theme="outline"
onClick={() => onPageChange(totalPages)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === totalPages}
>
{formatNumber(totalPages)}
</Button>
</>
)}
{/* Next Button */}
<Button
theme="outline"
onClick={() => onPageChange(page + 1)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === totalPages}
>
<ChevronRightIcon />
</Button>
</div>
);
}

View File

@ -14,7 +14,7 @@ import {
TrashIcon, TrashIcon,
UploadIcon, UploadIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { useToast } from "@renderer/hooks"; import { useAppSelector, useToast } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AxiosProgressEvent } from "axios"; import { AxiosProgressEvent } from "axios";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
@ -145,6 +145,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const disableActions = uploadingBackup || restoringBackup || deletingArtifact; const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
const userDetails = useAppSelector((state) => state.userDetails.userDetails);
const backupsPerGameLimit = userDetails?.quirks.backupsPerGameLimit ?? 0;
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@ -181,7 +184,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
disabled={ disabled={
disableActions || disableActions ||
!backupPreview?.overall.totalGames || !backupPreview?.overall.totalGames ||
artifacts.length >= 2 artifacts.length >= backupsPerGameLimit
} }
> >
<UploadIcon /> <UploadIcon />
@ -199,7 +202,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
}} }}
> >
<h2>{t("backups")}</h2> <h2>{t("backups")}</h2>
<small>{artifacts.length} / 2</small> <small>
{artifacts.length} / {backupsPerGameLimit}
</small>
</div> </div>
</div> </div>

View File

@ -14,6 +14,7 @@ import { steamUrlBuilder } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails } from "@renderer/hooks"; import { useUserDetails } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
const HERO_ANIMATION_THRESHOLD = 25; const HERO_ANIMATION_THRESHOLD = 25;
@ -31,9 +32,10 @@ export function GameDetailsContent() {
gameColor, gameColor,
setGameColor, setGameColor,
hasNSFWContentBlocked, hasNSFWContentBlocked,
handleClickOpenCheckout,
} = useContext(gameDetailsContext); } = useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails(); const { userDetails, hasActiveSubscription } = useUserDetails();
const { setShowCloudSyncModal, getGameArtifacts } = const { setShowCloudSyncModal, getGameArtifacts } =
@ -104,7 +106,7 @@ export function GameDetailsContent() {
} }
if (!hasActiveSubscription) { if (!hasActiveSubscription) {
handleClickOpenCheckout(); showHydraCloudModal("backup");
return; return;
} }

View File

@ -8,7 +8,7 @@ import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useToast } from "@renderer/hooks"; import { useAppSelector, useToast } from "@renderer/hooks";
@ -159,16 +159,6 @@ export function DownloadSettingsModal({
</Button> </Button>
))} ))}
</div> </div>
{selectedDownloader != null &&
selectedDownloader !== Downloader.Torrent && (
<p style={{ marginTop: `${SPACING_UNIT}px` }}>
<span style={{ color: vars.color.warning }}>
{t("warning")}
</span>{" "}
{t("hydra_needs_to_remain_open")}
</p>
)}
</div> </div>
<div <div

View File

@ -21,6 +21,7 @@ import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section"; import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers"; import { buildGameAchievementPath } from "@renderer/helpers";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { useSubscription } from "@renderer/hooks/use-subscription";
const fakeAchievements: UserAchievement[] = [ const fakeAchievements: UserAchievement[] = [
{ {
@ -67,15 +68,10 @@ export function Sidebar() {
const [activeRequirement, setActiveRequirement] = const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum"); useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { const { gameTitle, shopDetails, objectId, shop, stats, achievements } =
gameTitle, useContext(gameDetailsContext);
shopDetails,
objectId, const { showHydraCloudModal } = useSubscription();
shop,
stats,
achievements,
handleClickOpenCheckout,
} = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
@ -179,7 +175,7 @@ export function Sidebar() {
{!hasActiveSubscription && ( {!hasActiveSubscription && (
<button <button
className={styles.subscriptionRequiredButton} className={styles.subscriptionRequiredButton}
onClick={handleClickOpenCheckout} onClick={() => showHydraCloudModal("achievements")}
> >
<CloudOfflineIcon size={16} /> <CloudOfflineIcon size={16} />
<span>{t("achievements_not_sync")}</span> <span>{t("achievements_not_sync")}</span>

View File

@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components"; import { Button, GameCard, Hero } from "@renderer/components";
import type { Steam250Game, CatalogueEntry } from "@types"; import type { Steam250Game } from "@types";
import flameIconStatic from "@renderer/assets/icons/flame-static.png"; import flameIconStatic from "@renderer/assets/icons/flame-static.png";
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif"; import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
@ -15,14 +15,6 @@ import * as styles from "./home.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared"; import { CatalogueCategory } from "@shared";
import { catalogueCacheTable, db } from "@renderer/dexie";
import { add } from "date-fns";
const categoryCacheDurationInSeconds = {
[CatalogueCategory.Hot]: 60 * 60 * 2,
[CatalogueCategory.Weekly]: 60 * 60 * 24,
[CatalogueCategory.Achievements]: 60 * 60 * 24,
};
export default function Home() { export default function Home() {
const { t } = useTranslation("home"); const { t } = useTranslation("home");
@ -36,9 +28,7 @@ export default function Home() {
CatalogueCategory.Hot CatalogueCategory.Hot
); );
const [catalogue, setCatalogue] = useState< const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({
Record<CatalogueCategory, CatalogueEntry[]>
>({
[CatalogueCategory.Hot]: [], [CatalogueCategory.Hot]: [],
[CatalogueCategory.Weekly]: [], [CatalogueCategory.Weekly]: [],
[CatalogueCategory.Achievements]: [], [CatalogueCategory.Achievements]: [],
@ -46,37 +36,11 @@ export default function Home() {
const getCatalogue = useCallback(async (category: CatalogueCategory) => { const getCatalogue = useCallback(async (category: CatalogueCategory) => {
try { try {
const catalogueCache = await catalogueCacheTable
.where("expiresAt")
.above(new Date())
.and((cache) => cache.category === category)
.first();
setCurrentCatalogueCategory(category); setCurrentCatalogueCategory(category);
setIsLoading(true); setIsLoading(true);
if (catalogueCache)
return setCatalogue((prev) => ({
...prev,
[category]: catalogueCache.games,
}));
const catalogue = await window.electron.getCatalogue(category); const catalogue = await window.electron.getCatalogue(category);
db.transaction("rw", catalogueCacheTable, async () => {
await catalogueCacheTable.where("category").equals(category).delete();
await catalogueCacheTable.add({
category,
games: catalogue,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: add(new Date(), {
seconds: categoryCacheDurationInSeconds[category],
}),
});
});
setCatalogue((prev) => ({ ...prev, [category]: catalogue })); setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@ -1,131 +0,0 @@
import { GameCard } from "@renderer/components";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import type { CatalogueEntry } from "@types";
import type { DebouncedFunc } from "lodash";
import { debounce } from "lodash";
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
import { clearSearch, setSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "./home.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import { vars } from "@renderer/theme.css";
export default function SearchResults() {
const dispatch = useAppDispatch();
const { t } = useTranslation("home");
const [searchParams] = useSearchParams();
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [showTypingMessage, setShowTypingMessage] = useState(false);
const debouncedFunc = useRef<DebouncedFunc<() => void> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const navigate = useNavigate();
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(buildGameDetailsPath(game));
};
useEffect(() => {
dispatch(setSearch(searchParams.get("query") ?? ""));
}, [dispatch, searchParams]);
useEffect(() => {
setIsLoading(true);
if (debouncedFunc.current) debouncedFunc.current.cancel();
if (abortControllerRef.current) abortControllerRef.current.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
debouncedFunc.current = debounce(() => {
const query = searchParams.get("query") ?? "";
if (query.length < 3) {
setIsLoading(false);
setShowTypingMessage(true);
setSearchResults([]);
return;
}
setShowTypingMessage(false);
window.electron
.searchGames(query)
.then((results) => {
if (abortController.signal.aborted) return;
setSearchResults(results);
setIsLoading(false);
})
.catch(() => {
setIsLoading(false);
});
}, 500);
debouncedFunc.current();
}, [searchParams, dispatch]);
const noResultsContent = () => {
if (isLoading) return null;
if (showTypingMessage) {
return (
<div className={styles.noResults}>
<SearchIcon size={56} />
<p>{t("start_typing")}</p>
</div>
);
}
if (searchResults.length === 0) {
return (
<div className={styles.noResults}>
<InboxIcon size={56} />
<p>{t("no_results")}</p>
</div>
);
}
return null;
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
<section className={styles.cards}>
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))}
{!isLoading && searchResults.length > 0 && (
<>
{searchResults.map((game) => (
<GameCard
key={game.objectId}
game={game}
onClick={() => handleGameClick(game)}
/>
))}
</>
)}
</section>
{noResultsContent()}
</section>
</SkeletonTheme>
);
}

View File

@ -2,7 +2,7 @@ import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks"; import { useFormat } from "@renderer/hooks";
import { useContext } from "react"; import { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./profile-content.css"; import * as styles from "./profile-content.css";
import { Avatar, Link } from "@renderer/components"; import { Avatar, Link } from "@renderer/components";
@ -13,6 +13,21 @@ export function FriendsBox() {
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
return (
<img
alt={game.title}
width={16}
style={{ borderRadius: 4 }}
src={game.iconUrl}
/>
);
}
return <SteamLogo width={16} height={16} />;
};
if (!userProfile?.friends.length) return null; if (!userProfile?.friends.length) return null;
return ( return (
@ -27,7 +42,14 @@ export function FriendsBox() {
<div className={styles.box}> <div className={styles.box}>
<ul className={styles.list}> <ul className={styles.list}>
{userProfile?.friends.map((friend) => ( {userProfile?.friends.map((friend) => (
<li key={friend.id}> <li
key={friend.id}
title={
friend.currentGame
? t("playing", { game: friend.currentGame.title })
: undefined
}
>
<Link to={`/profile/${friend.id}`} className={styles.listItem}> <Link to={`/profile/${friend.id}`} className={styles.listItem}>
<Avatar <Avatar
size={32} size={32}
@ -35,7 +57,19 @@ export function FriendsBox() {
alt={friend.displayName} alt={friend.displayName}
/> />
<span className={styles.friendName}>{friend.displayName}</span> <div
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span className={styles.friendName}>
{friend.displayName}
</span>
{friend.currentGame && (
<div style={{ display: "flex", gap: 4 }}>
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</Link> </Link>
</li> </li>
))} ))}

View File

@ -105,6 +105,22 @@ export const listItem = style({
}, },
}); });
export const statsListItem = style({
display: "flex",
flexDirection: "column",
transition: "all ease 0.1s",
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
textDecoration: "none",
},
});
export const gamesGrid = style({ export const gamesGrid = style({
listStyle: "none", listStyle: "none",
margin: "0", margin: "0",
@ -203,3 +219,12 @@ export const achievementsProgressBar = style({
borderRadius: "4px", borderRadius: "4px",
}, },
}); });
export const link = style({
textAlign: "start",
color: vars.color.body,
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});

View File

@ -21,6 +21,8 @@ import {
formatDownloadProgress, formatDownloadProgress,
} from "@renderer/helpers"; } from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { UserStatsBox } from "./user-stats-box";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
export function ProfileContent() { export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext); const { userProfile, isMe, userStats } = useContext(userProfileContext);
@ -135,6 +137,7 @@ export function ProfileContent() {
position: "relative", position: "relative",
display: "flex", display: "flex",
}} }}
title={game.title}
className={styles.game} className={styles.game}
> >
<button <button
@ -155,7 +158,7 @@ export function ProfileContent() {
height: "100%", height: "100%",
width: "100%", width: "100%",
background: background:
"linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%)", "linear-gradient(0deg, rgba(0, 0, 0, 0.75) 25%, transparent 100%)",
padding: 8, padding: 8,
}} }}
> >
@ -185,6 +188,22 @@ export function ProfileContent() {
flexDirection: "column", flexDirection: "column",
}} }}
> >
{game.achievementsPointsEarnedSum > 0 && (
<div
style={{
display: "flex",
justifyContent: "start",
gap: 8,
marginBottom: 4,
color: vars.color.muted,
}}
>
<HydraIcon width={16} height={16} />
{numberFormatter.format(
game.achievementsPointsEarnedSum
)}
</div>
)}
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -249,6 +268,7 @@ export function ProfileContent() {
{shouldShowRightContent && ( {shouldShowRightContent && (
<div className={styles.rightContent}> <div className={styles.rightContent}>
<UserStatsBox />
<RecentGamesBox /> <RecentGamesBox />
<FriendsBox /> <FriendsBox />

View File

@ -0,0 +1,125 @@
import * as styles from "./profile-content.css";
import { useCallback, useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { useFormat } from "@renderer/hooks";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { vars } from "@renderer/theme.css";
export function UserStatsBox() {
const { showHydraCloudModal } = useSubscription();
const { userStats, isMe } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const formatPlayTime = useCallback(
(playTimeInSeconds: number) => {
const seconds = playTimeInSeconds;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
if (!userStats) return null;
return (
<div>
<div className={styles.sectionHeader}>
<h2>{t("stats")}</h2>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>
{t("achievements_unlocked")}
</h3>
{userStats.unlockedAchievementSum !== undefined ? (
<div
style={{ display: "flex", justifyContent: "space-between" }}
>
<p className={styles.listItemDescription}>
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
{t("achievements")}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements")}
className={styles.link}
>
<small style={{ color: vars.color.warning }}>
{t("show_achievements_on_profile")}
</small>
</button>
)}
</li>
)}
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>{t("earned_points")}</h3>
{userStats.achievementsPointsEarnedSum !== undefined ? (
<div
style={{ display: "flex", justifyContent: "space-between" }}
>
<p className={styles.listItemDescription}>
<HydraIcon width={20} height={20} />
{numberFormatter.format(
userStats.achievementsPointsEarnedSum.value
)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile:
userStats.achievementsPointsEarnedSum.topPercentile,
})}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className={styles.link}
>
<small style={{ color: vars.color.warning }}>
{t("show_points_on_profile")}
</small>
</button>
)}
</li>
)}
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>{t("total_play_time")}</h3>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p className={styles.listItemDescription}>
<ClockIcon />
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
})}
</p>
</div>
</li>
</ul>
</div>
</div>
);
}

View File

@ -100,17 +100,30 @@ export function AddDownloadSourceModal({
} }
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]); }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => { const putDownloadSource = async () => {
setIsLoading(true); const downloadSource = await downloadSourcesTable.where({ url }).first();
if (!downloadSource) return;
window.electron
.putDownloadSource(downloadSource.objectIds)
.then(({ fingerprint }) => {
downloadSourcesTable.update(downloadSource.id, { fingerprint });
});
};
const handleAddDownloadSource = async () => {
if (validationResult) { if (validationResult) {
setIsLoading(true);
const channel = new BroadcastChannel(`download_sources:import:${url}`); const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]); downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = () => { channel.onmessage = async () => {
setIsLoading(false); setIsLoading(false);
putDownloadSource();
onClose(); onClose();
onAddDownloadSource(); onAddDownloadSource();
channel.close(); channel.close();

View File

@ -42,3 +42,17 @@ export const downloadSourcesHeader = style({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}); });
export const navigateToCatalogueButton = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
textDecoration: "underline",
cursor: "pointer",
":disabled": {
cursor: "default",
textDecoration: "none",
},
});

View File

@ -7,12 +7,14 @@ import * as styles from "./settings-download-sources.css";
import type { DownloadSource } from "@types"; import type { DownloadSource } from "@types";
import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react"; import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal"; import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks"; import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css"; import { settingsContext } from "@renderer/context";
import { repacksContext, settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie"; import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers"; import { downloadSourcesWorker } from "@renderer/workers";
import { useNavigate } from "react-router-dom";
import { clearFilters } from "@renderer/features";
import { setFilters } from "@renderer/features";
export function SettingsDownloadSources() { export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] = const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
@ -28,7 +30,11 @@ export function SettingsDownloadSources() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const { indexRepacks } = useContext(repacksContext); const dispatch = useAppDispatch();
const navigate = useNavigate();
const { updateRepacks } = useRepacks();
const getDownloadSources = async () => { const getDownloadSources = async () => {
await downloadSourcesTable await downloadSourcesTable
@ -57,16 +63,16 @@ export function SettingsDownloadSources() {
showSuccessToast(t("removed_download_source")); showSuccessToast(t("removed_download_source"));
getDownloadSources(); getDownloadSources();
indexRepacks();
setIsRemovingDownloadSource(false); setIsRemovingDownloadSource(false);
channel.close(); channel.close();
updateRepacks();
}; };
}; };
const handleAddDownloadSource = async () => { const handleAddDownloadSource = async () => {
indexRepacks();
await getDownloadSources(); await getDownloadSources();
showSuccessToast(t("added_download_source")); showSuccessToast(t("added_download_source"));
updateRepacks();
}; };
const syncDownloadSources = async () => { const syncDownloadSources = async () => {
@ -82,6 +88,7 @@ export function SettingsDownloadSources() {
getDownloadSources(); getDownloadSources();
setIsSyncingDownloadSources(false); setIsSyncingDownloadSources(false);
channel.close(); channel.close();
updateRepacks();
}; };
}; };
@ -95,6 +102,13 @@ export function SettingsDownloadSources() {
setShowAddDownloadSourceModal(false); setShowAddDownloadSourceModal(false);
}; };
const navigateToCatalogue = (fingerprint: string) => {
dispatch(clearFilters());
dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] }));
navigate("/catalogue");
};
return ( return (
<> <>
<AddDownloadSourceModal <AddDownloadSourceModal
@ -146,12 +160,11 @@ export function SettingsDownloadSources() {
<Badge>{statusTitle[downloadSource.status]}</Badge> <Badge>{statusTitle[downloadSource.status]}</Badge>
</div> </div>
<div <button
style={{ type="button"
display: "flex", className={styles.navigateToCatalogueButton}
alignItems: "center", disabled={!downloadSource.fingerprint}
gap: `${SPACING_UNIT}px`, onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
}}
> >
<small> <small>
{t("download_count", { {t("download_count", {
@ -160,7 +173,7 @@ export function SettingsDownloadSources() {
downloadSource.downloadCount.toLocaleString(), downloadSource.downloadCount.toLocaleString(),
})} })}
</small> </small>
</div> </button>
</div> </div>
<TextField <TextField

View File

@ -0,0 +1,79 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const friendListContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
height: "54px",
minHeight: "54px",
transition: "all ease 0.2s",
position: "relative",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendListButton = style({
display: "flex",
alignItems: "center",
position: "absolute",
cursor: "pointer",
height: "100%",
width: "100%",
flexDirection: "row",
color: vars.color.body,
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
padding: `0 ${SPACING_UNIT}px`,
});
export const friendRequestItem = style({
color: vars.color.body,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const acceptRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.success,
},
});
export const cancelRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.danger,
},
});
export const friendCodeButton = style({
color: vars.color.body,
cursor: "pointer",
display: "flex",
gap: `${SPACING_UNIT / 2}px`,
alignItems: "center",
transition: "all ease 0.2s",
":hover": {
color: vars.color.muted,
},
});

View File

@ -0,0 +1,38 @@
import { Button, Modal } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export interface HydraCloudModalProps {
feature: string;
visible: boolean;
onClose: () => void;
}
export const HydraCloudModal = ({
feature,
visible,
onClose,
}: HydraCloudModalProps) => {
const { t } = useTranslation("hydra_cloud");
const handleClickOpenCheckout = () => {
window.electron.openCheckout();
};
return (
<Modal visible={visible} title={t("hydra_cloud")} onClose={onClose}>
<div
data-hydra-cloud-feature={feature}
style={{
display: "flex",
width: "500px",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{t("hydra_cloud_feature_found")}
<Button onClick={handleClickOpenCheckout}>{t("learn_more")}</Button>
</div>
</Modal>
);
};

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