diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6ec93984c..6da066afe 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -26,4 +26,9 @@ module.exports = { }, ], }, + settings: { + react: { + version: "detect", + }, + }, }; diff --git a/build/icon.ico b/build/icon.ico index d8eefecd5..d8abc8b31 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index 865a96a2a..6eae42804 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/icons/512x512.png b/build/icons/512x512.png index 865a96a2a..6eae42804 100644 Binary files a/build/icons/512x512.png and b/build/icons/512x512.png differ diff --git a/build/installerSidebar.bmp b/build/installerSidebar.bmp index b5156d6df..f77a7ca86 100644 Binary files a/build/installerSidebar.bmp and b/build/installerSidebar.bmp differ diff --git a/package.json b/package.json index 848eac781..abfaf6b77 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "electron-log": "^5.2.4", "electron-updater": "^6.3.9", "file-type": "^19.6.0", - "flexsearch": "^0.7.43", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", "jsdom": "^24.0.0", @@ -64,6 +63,7 @@ "lodash-es": "^4.17.21", "parse-torrent": "^11.0.17", "piscina": "^4.7.0", + "rc-virtual-list": "^3.16.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", "react-loading-skeleton": "^3.4.0", diff --git a/resources/icon.png b/resources/icon.png index 865a96a2a..6eae42804 100644 Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/resources/tray-icon.png b/resources/tray-icon.png index 9254a8fb5..ee90c026a 100644 Binary files a/resources/tray-icon.png and b/resources/tray-icon.png differ diff --git a/scripts/upload-build.cjs b/scripts/upload-build.cjs index 9f19ca892..c00b565a2 100644 --- a/scripts/upload-build.cjs +++ b/scripts/upload-build.cjs @@ -20,7 +20,7 @@ const s3 = new S3Client({ const dist = path.resolve(__dirname, "..", "dist"); -const extensionsToUpload = [".deb", ".exe"]; +const extensionsToUpload = [".deb", ".exe", ".pacman"]; fs.readdir(dist, async (err, files) => { if (err) throw err; diff --git a/src/locales/ar/translation.json b/src/locales/ar/translation.json index 156c3da40..a4724b21e 100644 --- a/src/locales/ar/translation.json +++ b/src/locales/ar/translation.json @@ -309,7 +309,7 @@ "last_time_played": "لعبت آخر مرة {{period}}", "activity": "النشاط الأخير", "library": "مكتبة", - "total_play_time": "إجمالي وقت اللعب: {{amount}}", + "total_play_time": "إجمالي وقت اللعب", "no_recent_activity_title": "هممم... لا شيء هنا", "no_recent_activity_description": "لم تلعب أي مباراة مؤخرًا. ", "display_name": "اسم العرض", @@ -383,13 +383,13 @@ "achievement_unlocked": "تم فتح الإنجاز", "user_achievements": "{{displayName}}إنجازات", "your_achievements": "إنجازاتك", - "unlocked_at": "مقفلة في:", + "unlocked_at": "مقفلة في: {{date}}", "subscription_needed": "مطلوب اشتراك Hydra Cloud لرؤية هذا المحتوى", "new_achievements_unlocked": "مفتوح {{achievementCount}} انجازات جديدة من {{gameCount}} ألعاب", "achievement_progress": "{{unlockedCount}}/{{totalCount}} الإنجازات", "achievements_unlocked_for_game": "مفتوح {{achievementCount}} انجازات جديدة ل {{gameTitle}}" }, - "tour": { + "hydra_cloud": { "subscription_tour_title": "اشتراك Hydra كلاود", "subscribe_now": "اشترك الآن", "cloud_saving": "الحفظ السحابي", diff --git a/src/locales/bg/translation.json b/src/locales/bg/translation.json index a1a5306fa..3112bb08a 100644 --- a/src/locales/bg/translation.json +++ b/src/locales/bg/translation.json @@ -293,7 +293,7 @@ "last_time_played": "Последно играно {{period}}", "activity": "Скорошна активност", "library": "Библиотека", - "total_play_time": "Общо време за игра: {{amount}}", + "total_play_time": "Общо време за игра", "no_recent_activity_title": "Хмм… няма нищо тук", "no_recent_activity_description": "Не сте играли игри напоследък. Време е да промените това.!", "display_name": "Показване на името", @@ -362,13 +362,13 @@ "achievement_unlocked": "Постижението е отключено", "user_achievements": "Постиженията на {{displayName}} ", "your_achievements": "Вашите Постижения", - "unlocked_at": "Отключено на:", + "unlocked_at": "Отключено на: {{date}}", "subscription_needed": "Необходим е абонамент за Hydra Cloud, за да видите това съдържание", "new_achievements_unlocked": "Отключени {{achievementCount}} нови постижения от {{gameCount}} игра", "achievement_progress": "{{unlockedCount}}/{{totalCount}} постижения", "achievements_unlocked_for_game": "Отключени {{achievementCount}} нови постижения за {{gameTitle}}" }, - "tour": { + "hydra_cloud": { "subscription_tour_title": "Hydra Cloud Абонамент", "subscribe_now": "Абонирай се сега", "cloud_saving": "Запазване в облака", diff --git a/src/locales/ca/translation.json b/src/locales/ca/translation.json index 8b96486f1..acf4b3c7b 100644 --- a/src/locales/ca/translation.json +++ b/src/locales/ca/translation.json @@ -224,7 +224,7 @@ "last_time_played": "Última partida {{period}}", "activity": "Activitat recent", "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_description": "No has jugat a cap joc recentment. És el moment de canviar-ho!", "display_name": "Nom de visualització", diff --git a/src/locales/cs/translation.json b/src/locales/cs/translation.json index d839fa461..c12914446 100644 --- a/src/locales/cs/translation.json +++ b/src/locales/cs/translation.json @@ -293,7 +293,7 @@ "last_time_played": "Naposledy hráno {{period}}", "activity": "Nedávná aktivita", "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_description": "V poslední době si nehrál žádnout hru, můžeš to ale napravit!", "display_name": "Zobrazované jméno", @@ -362,13 +362,13 @@ "achievement_unlocked": "Achievement odemčen", "user_achievements": "Achievementy uživatele {{displayName}}", "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", "new_achievements_unlocked": "Odemčeno {{achievementCount}} nových achievementů z {{gameCount}} her", "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementů", "achievements_unlocked_for_game": "Odemčeno {{achievementCount}} nových achievementů pro {{gameTitle}}" }, - "tour": { + "hydra_cloud": { "subscription_tour_title": "Předplatné Hydra Cloud", "subscribe_now": "Připojit se", "cloud_saving": "Ukládání v cloudu", diff --git a/src/locales/da/translation.json b/src/locales/da/translation.json index 58087bfa2..711c81a34 100644 --- a/src/locales/da/translation.json +++ b/src/locales/da/translation.json @@ -251,7 +251,7 @@ "last_time_played": "Sidst spillet {{period}}", "activity": "Seneste aktivitet", "library": "Bibliotek", - "total_play_time": "Samlet spiltid: {{amount}}", + "total_play_time": "Samlet spiltid", "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å!", "display_name": "Brugernavn", diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 2852430f0..bf1eff601 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -224,7 +224,7 @@ "last_time_played": "Zuletzt gespielt {{period}}", "activity": "Letzte Aktivität", "library": "Bibliothek", - "total_play_time": "Gesamtspielzeit: {{amount}}", + "total_play_time": "Gesamtspielzeit", "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!", "display_name": "Anzeigename", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f74cd8e39..f9a683bf3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -46,8 +46,15 @@ "checking_files": "Checking {{title}} files… ({{percentage}} complete)" }, "catalogue": { - "next_page": "Next page", - "previous_page": "Previous page" + "search": "Filter…", + "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": { "open_download_options": "Open download options", @@ -163,7 +170,7 @@ "no_download_option_info": "No information available", "backup_deletion_failed": "Failed to delete backup", "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", "select_folder": "Select folder", "backup_from": "Backup from {{date}}", @@ -301,7 +308,7 @@ "last_time_played": "Last played {{period}}", "activity": "Recent Activity", "library": "Library", - "total_play_time": "Total playtime: {{amount}}", + "total_play_time": "Total playtime", "no_recent_activity_title": "Hmmm… nothing here", "no_recent_activity_description": "You haven't played any games recently. It's time to change that!", "display_name": "Display name", @@ -364,19 +371,34 @@ "your_friend_code": "Your friend code:", "upload_banner": "Upload 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_unlocked": "Achievement unlocked", "user_achievements": "{{displayName}}'s 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", "new_achievements_unlocked": "Unlocked {{achievementCount}} new achievements from {{gameCount}} games", "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", "subscribe_now": "Subscribe now", "cloud_saving": "Cloud saving", @@ -384,6 +406,9 @@ "animated_profile_picture": "Animated profile pictures", "premium_support": "Premium Support", "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" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index a766be1c0..b3dd42f8c 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -46,8 +46,15 @@ "checking_files": "Verificando archivos de {{title}}… ({{percentage}} completado)" }, "catalogue": { - "next_page": "Siguiente página", - "previous_page": "Pagina anterior" + "search": "Filtrar…", + "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": { "open_download_options": "Ver opciones de descargas", @@ -295,7 +302,7 @@ "last_time_played": "Última vez jugado: {{period}}", "activity": "Actividad reciente", "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_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!", "display_name": "Nombre en pantalla", @@ -358,19 +365,20 @@ "your_friend_code": "Tu código de amigo:", "upload_banner": "Subir un banner", "uploading_banner": "Subiendo banner…", - "background_image_updated": "Imagen de fondo actualizada" + "background_image_updated": "Imagen de fondo actualizada", + "playing": "Jugando {{game}}" }, "achievement": { "achievement_unlocked": "Logro desbloqueado", "user_achievements": "Logros de {{displayName}}", "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", "new_achievements_unlocked": "Desbloqueados {{achievementCount}} nuevos logros de {{gameCount}} juegos", "achievement_progress": "{{unlockedCount}}/{{totalCount}} logros", "achievements_unlocked_for_game": "Se han desbloqueado {{achievementCount}} nuevos logros de {{gameTitle}}" }, - "tour": { + "hydra_cloud": { "subscription_tour_title": "Suscripción Hydra Cloud", "subscribe_now": "Suscribirse ahora", "cloud_saving": "Guardado en la nube", diff --git a/src/locales/et/translation.json b/src/locales/et/translation.json index 9a01fd509..91b4a63a8 100644 --- a/src/locales/et/translation.json +++ b/src/locales/et/translation.json @@ -290,7 +290,7 @@ "last_time_played": "Viimati mängitud {{period}}", "activity": "Hiljutine aktiivsus", "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_description": "Sa pole hiljuti ühtegi mängu mänginud. On aeg seda muuta!", "display_name": "Kuvatav nimi", @@ -359,11 +359,11 @@ "achievement_unlocked": "Saavutus avatud", "user_achievements": "{{displayName}} saavutused", "your_achievements": "Sinu saavutused", - "unlocked_at": "Avatud:", + "unlocked_at": "Avatud: {{date}}", "subscription_needed": "Selle sisu nägemiseks on vaja Hydra Cloud tellimust", "new_achievements_unlocked": "Avatud {{achievementCount}} uut saavutust {{gameCount}} mängust" }, - "tour": { + "hydra_cloud": { "subscription_tour_title": "Hydra Cloud Tellimus", "subscribe_now": "Telli kohe", "cloud_saving": "Pilvesalvestus", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 90e32062c..ba4a06f19 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -224,7 +224,7 @@ "last_time_played": "Terakhir dimainkan {{period}}", "activity": "Aktivitas terbaru", "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_description": "Kamu belum main game baru-baru ini. Yuk, mulai main!", "display_name": "Nama tampilan", diff --git a/src/locales/kk/translation.json b/src/locales/kk/translation.json index a15f6418f..6d5d84044 100644 --- a/src/locales/kk/translation.json +++ b/src/locales/kk/translation.json @@ -220,7 +220,7 @@ "last_time_played": "Соңғы ойын {{period}}", "activity": "Соңғы әрекет", "library": "Кітапхана", - "total_play_time": "Барлығы ойнаған: {{amount}}", + "total_play_time": "Барлығы ойнаған", "no_recent_activity_title": "Хммм... Мұнда ештеңе жоқ", "no_recent_activity_description": "Сіз ұзақ уақыт бойы ештеңе ойнаған жоқсыз. Мұны өзгерту керек!", "display_name": "Көрсету аты", diff --git a/src/locales/nb/translation.json b/src/locales/nb/translation.json index 32c2943b7..5c5f68823 100644 --- a/src/locales/nb/translation.json +++ b/src/locales/nb/translation.json @@ -251,7 +251,7 @@ "last_time_played": "Sist spilt {{period}}", "activity": "Seneste aktivitet", "library": "Bibliotek", - "total_play_time": "Samlet spilltid: {{amount}}", + "total_play_time": "Samlet spilltid", "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å!", "display_name": "Brukernavn", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 348f67979..46f7e70f7 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -158,7 +158,7 @@ "no_download_option_info": "Sem informações disponíveis", "backup_deletion_failed": "Falha ao apagar backup", "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}}", "custom_backup_location_set": "Localização customizada selecionada", "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" }, "catalogue": { - "next_page": "Próxima página", - "previous_page": "Página anterior" + "search": "Filtrar…", + "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": { "close": "Botão de fechar" @@ -299,7 +306,7 @@ "last_time_played": "Última sessão {{period}}", "activity": "Atividades recentes", "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_description": "Parece que você não jogou nada recentemente. Que tal começar agora?", "display_name": "Nome de exibição", @@ -362,26 +369,43 @@ "your_friend_code": "Seu código de amigo:", "upload_banner": "Carregar 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_unlocked": "Conquista desbloqueada", "your_achievements": "Suas Conquistas", "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", "new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos", "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", + "hydra_cloud": "Hydra Cloud", "subscribe_now": "Inscreva-se agora", "cloud_achievements": "Salvamento de conquistas em nuvem", "animated_profile_picture": "Fotos de perfil animadas", "premium_support": "Suporte Premium", "show_and_compare_achievements": "Exiba e compare suas conquistas com outros usuários", "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" } } diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 36320dc8e..ce081b3f6 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -287,7 +287,7 @@ "last_time_played": "Última sessão {{period}}", "activity": "Atividade recente", "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_description": "Parece que não jogaste nada recentemente. Que tal começar agora?", "display_name": "Nome de apresentação", @@ -356,11 +356,11 @@ "achievement_unlocked": "Conquista desbloqueada", "your_achievements": "As tuas Conquistas", "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", "new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos" }, - "tour": { + "hydra_cloud": { "subscription_tour_title": "Subscrição Hydra Cloud", "subscribe_now": "Subscreve agora", "cloud_achievements": "Gravação de conquistas na nuvem", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 81b5c8908..741f5e16f 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -46,8 +46,15 @@ "checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)" }, "catalogue": { - "next_page": "Следующая страница", - "previous_page": "Предыдущая страница" + "search": "Фильтр…", + "developers": "Разработчики", + "genres": "Жанры", + "tags": "Маркеры", + "publishers": "Издательства", + "download_sources": "Источники загрузки", + "result_count": "{{resultCount}} результатов", + "filter_count": "{{filterCount}} доступных", + "clear_filters": "Очистить {{filterCount}} выбранных" }, "game_details": { "open_download_options": "Открыть источники", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 8550d4256..ed4b3d588 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -231,7 +231,7 @@ "sign_out_modal_text": "Ваша бібліотека пов'язана з поточним обліковим записом. При виході з системи ваша бібліотека буде недоступною, і прогрес не буде збережено. Продовжити вихід?", "sign_out_modal_title": "Ви впевнені?", "successfully_signed_out": "Успішний вихід з акаунту", - "total_play_time": "Всього зіграно: {{amount}}", + "total_play_time": "Всього зіграно", "try_again": "Будь ласка, попробуйте ще раз" } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 9bab7516d..664877fa8 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -290,7 +290,7 @@ "last_time_played": "上次游玩时间 {{period}}", "activity": "近期活动", "library": "库", - "total_play_time": "总游戏时长: {{amount}}", + "total_play_time": "总游戏时长", "no_recent_activity_title": "Emmm… 这里暂时啥都没有", "no_recent_activity_description": "你最近没玩过任何游戏。是时候做出改变了!", "display_name": "昵称", @@ -359,11 +359,11 @@ "achievement_unlocked": "成就已解锁", "user_achievements": "{{displayName}}的成就", "your_achievements": "你的成就", - "unlocked_at": "解锁于:", + "unlocked_at": "解锁于: {{date}}", "subscription_needed": "需要订阅 Hydra Cloud 才能看到此内容", "new_achievements_unlocked": "从 {{gameCount}} 游戏中解锁 {{achievementCount}} 新成就" }, - "tour": { + "hydra_cloud": { "subscription_tour_title": "Hydra 云订阅", "subscribe_now": "现在订购", "cloud_saving": "云存档", diff --git a/src/main/data-source.ts b/src/main/data-source.ts index 80a40f475..51c8522ee 100644 --- a/src/main/data-source.ts +++ b/src/main/data-source.ts @@ -1,10 +1,8 @@ import { DataSource } from "typeorm"; import { DownloadQueue, - DownloadSource, Game, GameShopCache, - Repack, UserPreferences, UserAuth, GameAchievement, @@ -17,12 +15,10 @@ export const dataSource = new DataSource({ type: "better-sqlite3", entities: [ Game, - Repack, UserAuth, UserPreferences, UserSubscription, GameShopCache, - DownloadSource, DownloadQueue, GameAchievement, ], diff --git a/src/main/entity/download-source.entity.ts b/src/main/entity/download-source.entity.ts deleted file mode 100644 index dc59bac4f..000000000 --- a/src/main/entity/download-source.entity.ts +++ /dev/null @@ -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; -} diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index baa6355c4..8dfc4fae4 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -5,9 +5,7 @@ import { CreateDateColumn, UpdateDateColumn, OneToOne, - JoinColumn, } from "typeorm"; -import { Repack } from "./repack.entity"; import type { GameShop, GameStatus } from "@types"; import { Downloader } from "@shared"; @@ -72,13 +70,6 @@ export class Game { @Column("text", { nullable: true }) uri: string | null; - /** - * @deprecated - */ - @OneToOne("Repack", "game", { nullable: true }) - @JoinColumn() - repack: Repack; - @OneToOne("DownloadQueue", "game") downloadQueue: DownloadQueue; diff --git a/src/main/entity/index.ts b/src/main/entity/index.ts index 5829e6a25..1625ac8ad 100644 --- a/src/main/entity/index.ts +++ b/src/main/entity/index.ts @@ -1,10 +1,8 @@ export * from "./game.entity"; -export * from "./repack.entity"; export * from "./user-auth.entity"; export * from "./user-preferences.entity"; export * from "./user-subscription.entity"; export * from "./game-shop-cache.entity"; export * from "./game.entity"; export * from "./game-achievements.entity"; -export * from "./download-source.entity"; export * from "./download-queue.entity"; diff --git a/src/main/entity/repack.entity.ts b/src/main/entity/repack.entity.ts deleted file mode 100644 index 36de2a7c9..000000000 --- a/src/main/entity/repack.entity.ts +++ /dev/null @@ -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; -} diff --git a/src/main/events/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts index a0542f259..2c2914aa2 100644 --- a/src/main/events/catalogue/get-catalogue.ts +++ b/src/main/events/catalogue/get-catalogue.ts @@ -1,9 +1,6 @@ -import type { GameShop } from "@types"; - import { registerEvent } from "../register-event"; import { HydraApi } from "@main/services"; -import { CatalogueCategory, steamUrlBuilder } from "@shared"; -import { steamGamesWorker } from "@main/workers"; +import { CatalogueCategory } from "@shared"; const getCatalogue = async ( _event: Electron.IpcMainInvokeEvent, @@ -14,26 +11,11 @@ const getCatalogue = async ( skip: "0", }); - const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>( + return HydraApi.get( `/catalogue/${category}?${params.toString()}`, {}, { 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); diff --git a/src/main/events/catalogue/get-developers.ts b/src/main/events/catalogue/get-developers.ts new file mode 100644 index 000000000..76ae566b5 --- /dev/null +++ b/src/main/events/catalogue/get-developers.ts @@ -0,0 +1,10 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; + +const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => { + return HydraApi.get(`/catalogue/developers`, null, { + needsAuth: false, + }); +}; + +registerEvent("getDevelopers", getDevelopers); diff --git a/src/main/events/catalogue/get-games.ts b/src/main/events/catalogue/get-games.ts deleted file mode 100644 index 3eb1f1357..000000000 --- a/src/main/events/catalogue/get-games.ts +++ /dev/null @@ -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 => { - const searchParams = new URLSearchParams({ - take: take.toString(), - skip: skip.toString(), - }); - - const games = await HydraApi.get( - `/games/catalogue?${searchParams.toString()}`, - undefined, - { needsAuth: false } - ); - - return games.map((game) => ({ - ...game, - cover: steamUrlBuilder.library(game.objectId), - })); -}; - -registerEvent("getGames", getGames); diff --git a/src/main/events/catalogue/get-publishers.ts b/src/main/events/catalogue/get-publishers.ts new file mode 100644 index 000000000..3b8fdc5fb --- /dev/null +++ b/src/main/events/catalogue/get-publishers.ts @@ -0,0 +1,10 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; + +const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => { + return HydraApi.get(`/catalogue/publishers`, null, { + needsAuth: false, + }); +}; + +registerEvent("getPublishers", getPublishers); diff --git a/src/main/events/catalogue/search-games.ts b/src/main/events/catalogue/search-games.ts index 4ce42fd84..8b22101dc 100644 --- a/src/main/events/catalogue/search-games.ts +++ b/src/main/events/catalogue/search-games.ts @@ -1,23 +1,18 @@ +import type { CatalogueSearchPayload } from "@types"; import { registerEvent } from "../register-event"; -import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; -import type { CatalogueEntry } from "@types"; import { HydraApi } from "@main/services"; -const searchGamesEvent = async ( +const searchGames = async ( _event: Electron.IpcMainInvokeEvent, - query: string -): Promise => { - const games = await HydraApi.get< - { objectId: string; title: string; shop: string }[] - >("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false }); - - return games.map((game) => { - return convertSteamGameToCatalogueEntry({ - id: Number(game.objectId), - name: game.title, - clientIcon: null, - }); - }); + payload: CatalogueSearchPayload, + take: number, + skip: number +) => { + return HydraApi.post( + "/catalogue/search", + { ...payload, take, skip }, + { needsAuth: false } + ); }; -registerEvent("searchGames", searchGamesEvent); +registerEvent("searchGames", searchGames); diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts deleted file mode 100644 index abfbf661f..000000000 --- a/src/main/events/download-sources/delete-download-source.ts +++ /dev/null @@ -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); diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts deleted file mode 100644 index 97f8a6d85..000000000 --- a/src/main/events/download-sources/get-download-sources.ts +++ /dev/null @@ -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); diff --git a/src/main/events/download-sources/put-download-source.ts b/src/main/events/download-sources/put-download-source.ts new file mode 100644 index 000000000..72297059e --- /dev/null +++ b/src/main/events/download-sources/put-download-source.ts @@ -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); diff --git a/src/main/events/helpers/search-games.ts b/src/main/events/helpers/search-games.ts deleted file mode 100644 index 74e0b6a89..000000000 --- a/src/main/events/helpers/search-games.ts +++ /dev/null @@ -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 => { - const steamGame = await steamGamesWorker.run(Number(objectId), { - name: "getById", - }); - - if (!steamGame) return null; - - return convertSteamGameToCatalogueEntry(steamGame); -}; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index fd89ba30d..86b149887 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -3,12 +3,13 @@ import { ipcMain } from "electron"; import "./catalogue/get-catalogue"; import "./catalogue/get-game-shop-details"; -import "./catalogue/get-games"; import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-random-game"; import "./catalogue/search-games"; import "./catalogue/get-game-stats"; import "./catalogue/get-trending-games"; +import "./catalogue/get-publishers"; +import "./catalogue/get-developers"; import "./hardware/get-disk-free-space"; import "./library/add-game-to-library"; import "./library/create-game-shortcut"; @@ -40,8 +41,7 @@ import "./user-preferences/auto-launch"; import "./autoupdater/check-for-updates"; import "./autoupdater/restart-and-install-update"; import "./user-preferences/authenticate-real-debrid"; -import "./download-sources/delete-download-source"; -import "./download-sources/get-download-sources"; +import "./download-sources/put-download-source"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; diff --git a/src/main/events/user/get-compared-unlocked-achievements.ts b/src/main/events/user/get-compared-unlocked-achievements.ts index 0c1171400..0b6652121 100644 --- a/src/main/events/user/get-compared-unlocked-achievements.ts +++ b/src/main/events/user/get-compared-unlocked-achievements.ts @@ -13,6 +13,9 @@ const getComparedUnlockedAchievements = async ( where: { id: 1 }, }); + const showHiddenAchievementsDescription = + userPreferences?.showHiddenAchievementsDescription || false; + return HydraApi.get( `/users/${userId}/games/achievements/compare`, { @@ -21,15 +24,35 @@ const getComparedUnlockedAchievements = async ( language: userPreferences?.language || "en", } ).then((achievements) => { - const sortedAchievements = achievements.achievements.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) { - return b.targetStat.unlockTime! - a.targetStat.unlockTime!; - } - - return Number(a.hidden) - Number(b.hidden); - }); + const sortedAchievements = achievements.achievements + .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) { + return b.targetStat.unlockTime! - a.targetStat.unlockTime!; + } + + 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 { ...achievements, diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index 6bbab9c45..f51b0456f 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -11,7 +11,7 @@ const getSteamGame = async (objectId: string) => { }); return { - title: steamGame.name, + title: steamGame.name as string, iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon), }; } 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 { ...profile, + friends, libraryGames, recentGames, }; diff --git a/src/main/repository.ts b/src/main/repository.ts index cf3ab143d..e0c4204e9 100644 --- a/src/main/repository.ts +++ b/src/main/repository.ts @@ -1,10 +1,8 @@ import { dataSource } from "./data-source"; import { DownloadQueue, - DownloadSource, Game, GameShopCache, - Repack, UserPreferences, UserAuth, GameAchievement, @@ -13,16 +11,11 @@ import { export const gameRepository = dataSource.getRepository(Game); -export const repackRepository = dataSource.getRepository(Repack); - export const userPreferencesRepository = dataSource.getRepository(UserPreferences); export const gameShopCacheRepository = dataSource.getRepository(GameShopCache); -export const downloadSourceRepository = - dataSource.getRepository(DownloadSource); - export const downloadQueueRepository = dataSource.getRepository(DownloadQueue); export const userAuthRepository = dataSource.getRepository(UserAuth); diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index 50780e95d..dd8c877dd 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -7,8 +7,9 @@ import { WindowManager } from "../window-manager"; import { HydraApi } from "../hydra-api"; import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements"; import { Game } from "@main/entity"; -import { achievementsLogger } from "../logger"; import { publishNewAchievementNotification } from "../notifications"; +import { SubscriptionRequiredError } from "@shared"; +import { achievementsLogger } from "../logger"; const saveAchievementsOnLocal = async ( objectId: string, @@ -120,10 +121,14 @@ export const mergeAchievements = async ( } if (game.remoteId) { - await HydraApi.put("/profile/games/achievements", { - id: game.remoteId, - achievements: mergedLocalAchievements, - }) + await HydraApi.put( + "/profile/games/achievements", + { + id: game.remoteId, + achievements: mergedLocalAchievements, + }, + { needsSubscription: !newAchievements.length } + ) .then((response) => { return saveAchievementsOnLocal( response.objectId, @@ -133,7 +138,13 @@ export const mergeAchievements = async ( ); }) .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( game.objectID, diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index bac1486a0..63dd9b16f 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -23,7 +23,7 @@ interface HydraApiUserAuth { authToken: string; refreshToken: string; expirationTimestamp: number; - subscription: { expiresAt: Date | null } | null; + subscription: { expiresAt: Date | string | null } | null; } export class HydraApi { @@ -159,7 +159,11 @@ export class HydraApi { config.method, config.baseURL, config.url, - omit(config.headers, ["accessToken", "refreshToken"]), + omit(config.headers, [ + "accessToken", + "refreshToken", + "Authorization", + ]), Array.isArray(data) ? data : omit(data, ["accessToken", "refreshToken"]) @@ -182,8 +186,6 @@ export class HydraApi { ); } - await getUserData(); - const userAuth = await userAuthRepository.findOne({ where: { id: 1 }, relations: { subscription: true }, @@ -197,6 +199,14 @@ export class HydraApi { ? { expiresAt: userAuth.subscription?.expiresAt } : null, }; + + const updatedUserData = await getUserData(); + + this.userAuth.subscription = updatedUserData?.subscription + ? { + expiresAt: updatedUserData.subscription.expiresAt, + } + : null; } private static sendSignOutEvent() { @@ -284,10 +294,8 @@ export class HydraApi { await this.revalidateAccessTokenIfExpired(); } - if (needsSubscription) { - if (!(await this.hasActiveSubscription())) { - throw new SubscriptionRequiredError(); - } + if (needsSubscription && !this.hasActiveSubscription()) { + throw new SubscriptionRequiredError(); } } diff --git a/src/main/services/user/get-user-data.ts b/src/main/services/user/get-user-data.ts index ff0128815..7e9244547 100644 --- a/src/main/services/user/get-user-data.ts +++ b/src/main/services/user/get-user-data.ts @@ -42,6 +42,7 @@ export const getUserData = () => { }) .catch(async (err) => { if (err instanceof UserNotLoggedInError) { + logger.info("User is not logged in", err); return null; } logger.error("Failed to get logged user"); @@ -58,6 +59,9 @@ export const getUserData = () => { bio: "", email: null, profileVisibility: "PUBLIC" as ProfileVisibility, + quirks: { + backupsPerGameLimit: 0, + }, subscription: loggedUser.subscription ? { id: loggedUser.subscription.subscriptionId, diff --git a/src/preload/index.ts b/src/preload/index.ts index 07c8c5989..e56e6797d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ import type { GameRunning, FriendRequestAction, UpdateProfileRequest, + CatalogueSearchPayload, SeedingStatus, GameAchievement, } from "@types"; @@ -54,7 +55,8 @@ contextBridge.exposeInMainWorld("electron", { }, /* Catalogue */ - searchGames: (query: string) => ipcRenderer.invoke("searchGames", query), + searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) => + ipcRenderer.invoke("searchGames", payload, take, skip), getCatalogue: (category: CatalogueCategory) => ipcRenderer.invoke("getCatalogue", category), getGameShopDetails: (objectId: string, shop: GameShop, language: string) => @@ -62,10 +64,6 @@ contextBridge.exposeInMainWorld("electron", { getRandomGame: () => ipcRenderer.invoke("getRandomGame"), getHowLongToBeat: (objectId: string, shop: GameShop) => 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) => ipcRenderer.invoke("getGameStats", objectId, shop), getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"), @@ -96,9 +94,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("authenticateRealDebrid", apiToken), /* Download sources */ - getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), - deleteDownloadSource: (id: number) => - ipcRenderer.invoke("deleteDownloadSource", id), + putDownloadSource: (objectIds: string[]) => + ipcRenderer.invoke("putDownloadSource", objectIds), /* Library */ addGameToLibrary: (objectId: string, title: string, shop: GameShop) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 6bd0acefe..5fefe90c2 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -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"; @@ -7,6 +7,7 @@ import { useAppSelector, useDownload, useLibrary, + useRepacks, useToast, useUserDetails, } from "@renderer/hooks"; @@ -15,8 +16,6 @@ import * as styles from "./app.css"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { - setSearch, - clearSearch, setUserPreferences, toggleDraggingDisabled, closeToast, @@ -27,8 +26,9 @@ import { import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { downloadSourcesWorker } from "./workers"; -import { repacksContext } from "./context"; -import { logger } from "./logger"; +import { downloadSourcesTable } from "./dexie"; +import { useSubscription } from "./hooks/use-subscription"; +import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; export interface AppProps { children: React.ReactNode; @@ -40,35 +40,31 @@ export function App() { const { t } = useTranslation("app"); - const downloadSourceMigrationLock = useRef(false); + const { updateRepacks } = useRepacks(); const { clearDownload, setLastPacket } = useDownload(); - const { indexRepacks } = useContext(repacksContext); - const { + userDetails, + hasActiveSubscription, isFriendsModalVisible, friendRequetsModalTab, friendModalUserId, syncFriendRequests, hideFriendsModal, - } = useUserDetails(); - - const { - userDetails, - hasActiveSubscription, fetchUserDetails, updateUserDetails, clearUserDetails, } = useUserDetails(); + const { hideHydraCloudModal, isHydraCloudModalVisible, hydraCloudFeature } = + useSubscription(); + const dispatch = useAppDispatch(); const navigate = useNavigate(); const location = useLocation(); - const search = useAppSelector((state) => state.search.value); - const draggingDisabled = useAppSelector( (state) => state.window.draggingDisabled ); @@ -195,31 +191,6 @@ export function App() { }; }, [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(() => { if (contentRef.current) contentRef.current.scrollTop = 0; }, [location.pathname, location.search]); @@ -236,53 +207,31 @@ export function App() { }, [dispatch, draggingDisabled]); useEffect(() => { - if (downloadSourceMigrationLock.current) return; - - downloadSourceMigrationLock.current = true; - - window.electron.getDownloadSources().then(async (downloadSources) => { - if (!downloadSources.length) { - const id = crypto.randomUUID(); - const channel = new BroadcastChannel(`download_sources:sync:${id}`); - - channel.onmessage = (event: MessageEvent) => { - const newRepacksCount = event.data; - window.electron.publishNewRepacksNotification(newRepacksCount); - }; - - downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); - } - - 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()); - } + updateRepacks(); + + const id = crypto.randomUUID(); + const channel = new BroadcastChannel(`download_sources:sync:${id}`); + + channel.onmessage = (event: MessageEvent) => { + const newRepacksCount = event.data; + window.electron.publishNewRepacksNotification(newRepacksCount); + updateRepacks(); + + downloadSourcesTable.toArray().then((downloadSources) => { + downloadSources + .filter((source) => !source.fingerprint) + .forEach((downloadSource) => { + window.electron + .putDownloadSource(downloadSource.objectIds) + .then(({ fingerprint }) => { + downloadSourcesTable.update(downloadSource.id, { fingerprint }); + }); + }); + }); + }; - downloadSourceMigrationLock.current = false; - }); - }, [indexRepacks]); + downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); + }, [updateRepacks]); const handleToastClose = useCallback(() => { dispatch(closeToast()); @@ -308,6 +257,12 @@ export function App() { onClose={handleToastClose} /> + + {userDetails && (
-
+
diff --git a/src/renderer/src/assets/icons/hydra.svg b/src/renderer/src/assets/icons/hydra.svg new file mode 100644 index 000000000..ce1e5cf0f --- /dev/null +++ b/src/renderer/src/assets/icons/hydra.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/renderer/src/components/badge/badge.tsx b/src/renderer/src/components/badge/badge.tsx index 752a33ba1..c4819ae38 100644 --- a/src/renderer/src/components/badge/badge.tsx +++ b/src/renderer/src/components/badge/badge.tsx @@ -7,9 +7,5 @@ export interface BadgeProps { } export function Badge({ children }: BadgeProps) { - return ( -
- {children} -
- ); + return
{children}
; } diff --git a/src/renderer/src/components/checkbox-field/checkbox-field.css.ts b/src/renderer/src/components/checkbox-field/checkbox-field.css.ts index 606b226ae..ce7aead84 100644 --- a/src/renderer/src/components/checkbox-field/checkbox-field.css.ts +++ b/src/renderer/src/components/checkbox-field/checkbox-field.css.ts @@ -1,6 +1,7 @@ import { style } from "@vanilla-extract/css"; import { SPACING_UNIT, vars } from "../../theme.css"; +import { recipe } from "@vanilla-extract/recipes"; export const checkboxField = style({ display: "flex", @@ -10,19 +11,31 @@ export const checkboxField = style({ cursor: "pointer", }); -export const checkbox = style({ - width: "20px", - height: "20px", - borderRadius: "4px", - backgroundColor: vars.color.darkBackground, - display: "flex", - justifyContent: "center", - alignItems: "center", - position: "relative", - transition: "all ease 0.2s", - border: `solid 1px ${vars.color.border}`, - ":hover": { - borderColor: "rgba(255, 255, 255, 0.5)", +export const checkbox = recipe({ + base: { + width: "20px", + height: "20px", + borderRadius: "4px", + backgroundColor: vars.color.darkBackground, + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative", + transition: "all ease 0.2s", + border: `solid 1px ${vars.color.border}`, + 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({ cursor: "pointer", + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", }); diff --git a/src/renderer/src/components/checkbox-field/checkbox-field.tsx b/src/renderer/src/components/checkbox-field/checkbox-field.tsx index bb81a9106..f40c05c25 100644 --- a/src/renderer/src/components/checkbox-field/checkbox-field.tsx +++ b/src/renderer/src/components/checkbox-field/checkbox-field.tsx @@ -15,7 +15,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) { return (
-
+
, HTMLButtonElement > { - game: CatalogueEntry; + game: any; } const shopIcon = { @@ -26,20 +26,12 @@ export function GameCard({ game, ...props }: GameCardProps) { const { t } = useTranslation("game_card"); const [stats, setStats] = useState(null); - const [repacks, setRepacks] = useState([]); - const { searchRepacks, isIndexingRepacks } = useContext(repacksContext); - - useEffect(() => { - if (!isIndexingRepacks) { - searchRepacks(game.title).then((repacks) => { - setRepacks(repacks); - }); - } - }, [game, isIndexingRepacks, searchRepacks]); + const { getRepacksForObjectId } = useRepacks(); + const repacks = getRepacksForObjectId(game.objectId); const uniqueRepackers = Array.from( - new Set(repacks.map(({ repacker }) => repacker)) + new Set(repacks.map((repack) => repack.repacker)) ); const handleHover = useCallback(() => { @@ -61,7 +53,7 @@ export function GameCard({ game, ...props }: GameCardProps) { >
{game.title} void; - onClear: () => void; - search?: string; -} +import { setFilters } from "@renderer/features"; const pathTitle: Record = { "/": "home", @@ -22,7 +16,7 @@ const pathTitle: Record = { "/settings": "settings", }; -export function Header({ onSearch, onClear, search }: HeaderProps) { +export function Header() { const inputRef = useRef(null); const navigate = useNavigate(); @@ -31,6 +25,11 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { const { headerTitle, draggingDisabled } = useAppSelector( (state) => state.window ); + + const searchValue = useAppSelector( + (state) => state.catalogueSearch.filters.title + ); + const dispatch = useAppDispatch(); const [isFocused, setIsFocused] = useState(false); @@ -46,12 +45,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { return t(pathTitle[location.pathname]); }, [location.pathname, headerTitle, t]); - useEffect(() => { - if (search && !location.pathname.startsWith("/search")) { - dispatch(clearSearch()); - } - }, [location.pathname, search, dispatch]); - const focusInput = () => { setIsFocused(true); inputRef.current?.focus(); @@ -65,6 +58,20 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { 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 ( <>
onSearch(event.target.value)} + onChange={(event) => handleSearch(event.target.value)} onFocus={() => setIsFocused(true)} onBlur={handleBlur} /> - {search && ( + {searchValue && ( + )} + {achievement.unlockTime && ( +
+ {formatDateTime(achievement.unlockTime)} +
+ )} +
+ + ))} + + ); +} diff --git a/src/renderer/src/pages/achievements/achievement-panel.css.ts b/src/renderer/src/pages/achievements/achievement-panel.css.ts new file mode 100644 index 000000000..f8daeab9d --- /dev/null +++ b/src/renderer/src/pages/achievements/achievement-panel.css.ts @@ -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", + }, +}); diff --git a/src/renderer/src/pages/achievements/achievement-panel.tsx b/src/renderer/src/pages/achievements/achievement-panel.tsx new file mode 100644 index 000000000..bda25a89e --- /dev/null +++ b/src/renderer/src/pages/achievements/achievement-panel.tsx @@ -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 ( +
+
+ {t("earned_points")} + ??? / ??? +
+ +
+ ); + } + + return ( +
+
+ {t("earned_points")} + {achievementsPointsEarnedSum} / {achievementsPointsTotal} +
+
+ ); +} diff --git a/src/renderer/src/pages/achievements/achievements-content.tsx b/src/renderer/src/pages/achievements/achievements-content.tsx index a7a9aaa8b..e7d47f34a 100644 --- a/src/renderer/src/pages/achievements/achievements-content.tsx +++ b/src/renderer/src/pages/achievements/achievements-content.tsx @@ -1,9 +1,8 @@ import { setHeaderTitle } from "@renderer/features"; -import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks"; +import { useAppDispatch, useUserDetails } from "@renderer/hooks"; import { steamUrlBuilder } from "@shared"; import { useContext, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import * as styles from "./achievements.css"; import { buildGameDetailsPath, formatDownloadProgress, @@ -11,11 +10,16 @@ import { import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react"; import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { gameDetailsContext } from "@renderer/context"; -import type { ComparedAchievements, UserAchievement } from "@types"; +import type { ComparedAchievements } from "@types"; import { average } from "color.js"; import Color from "color"; import { Link } from "@renderer/components"; 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 { id: string; @@ -30,10 +34,6 @@ interface AchievementsContentProps { comparedAchievements: ComparedAchievements | null; } -interface AchievementListProps { - achievements: UserAchievement[]; -} - interface AchievementSummaryProps { user: UserInfo; isComparison?: boolean; @@ -42,7 +42,7 @@ interface AchievementSummaryProps { function AchievementSummary({ user, isComparison }: AchievementSummaryProps) { const { t } = useTranslation("achievement"); const { userDetails, hasActiveSubscription } = useUserDetails(); - const { handleClickOpenCheckout } = useContext(gameDetailsContext); + const { showHydraCloudModal } = useSubscription(); const getProfileImage = ( user: Pick @@ -93,7 +93,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {

@@ -171,38 +171,6 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) { ); } -function AchievementList({ achievements }: AchievementListProps) { - const { t } = useTranslation("achievement"); - const { formatDateTime } = useDate(); - - return ( -
    - {achievements.map((achievement, index) => ( -
  • - {achievement.displayName} -
    -

    {achievement.displayName}

    -

    {achievement.description}

    -
    - {achievement.unlockTime && ( -
    - {t("unlocked_at")} -

    {formatDateTime(achievement.unlockTime)}

    -
    - )} -
  • - ))} -
- ); -} - export function AchievementsContent({ otherUser, comparedAchievements, @@ -355,9 +323,15 @@ export function AchievementsContent({ )} {otherUser ? ( - + <> + + + ) : ( - + <> + + + )}

diff --git a/src/renderer/src/pages/achievements/compared-achievement-list.tsx b/src/renderer/src/pages/achievements/compared-achievement-list.tsx index 21f119364..44aec686a 100644 --- a/src/renderer/src/pages/achievements/compared-achievement-list.tsx +++ b/src/renderer/src/pages/achievements/compared-achievement-list.tsx @@ -1,8 +1,13 @@ import type { ComparedAchievements } from "@types"; 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 { SPACING_UNIT } from "@renderer/theme.css"; +import { useTranslation } from "react-i18next"; export interface ComparedAchievementListProps { achievements: ComparedAchievements; @@ -11,6 +16,7 @@ export interface ComparedAchievementListProps { export function ComparedAchievementList({ achievements, }: ComparedAchievementListProps) { + const { t } = useTranslation("achievement"); const { formatDateTime } = useDate(); return ( @@ -43,7 +49,17 @@ export function ComparedAchievementList({ loading="lazy" />
-

{achievement.displayName}

+

+ {achievement.hidden && ( + + + + )} + {achievement.displayName} +

{achievement.description}

@@ -58,11 +74,9 @@ export function ComparedAchievementList({ gap: `${SPACING_UNIT}px`, justifyContent: "center", }} + title={formatDateTime(achievement.ownerStat.unlockTime!)} > - - {formatDateTime(achievement.ownerStat.unlockTime!)} - ) : (
- - {formatDateTime(achievement.targetStat.unlockTime!)} -
) : (
+
+ {t("available_points")} {" "} + {achievements.achievementsPointsTotal} +
+ {hasActiveSubscription && ( +
+ + {achievements.owner.achievementsPointsEarnedSum ?? 0} +
+ )} +
+ + {achievements.target.achievementsPointsEarnedSum} +
+
+ ); +} diff --git a/src/renderer/src/pages/catalogue/catalogue.scss b/src/renderer/src/pages/catalogue/catalogue.scss new file mode 100644 index 000000000..701652df6 --- /dev/null +++ b/src/renderer/src/pages/catalogue/catalogue.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index ff8f5c001..524b3c250 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -1,114 +1,362 @@ -import { Button, GameCard } from "@renderer/components"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import { useTranslation } from "react-i18next"; +import type { DownloadSource } from "@types"; -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 { 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 "./catalogue.scss"; 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() { - const dispatch = useAppDispatch(); + const abortControllerRef = useRef(null); + const cataloguePageRef = useRef(null); + + const { steamDevelopers, steamPublishers } = useCatalogue(); - const { t } = useTranslation("catalogue"); + const { steamGenres, steamUserTags } = useAppSelector( + (state) => state.catalogueSearch + ); - const [searchResults, setSearchResults] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [downloadSources, setDownloadSources] = useState([]); + const [isLoading, setIsLoading] = useState(true); - const contentRef = useRef(null); + const [results, setResults] = useState([]); - const navigate = useNavigate(); + const [itemsCount, setItemsCount] = useState(0); - const [searchParams] = useSearchParams(); - const skip = Number(searchParams.get("skip") ?? 0); + const { formatNumber } = useFormat(); - const handleGameClick = (game: CatalogueEntry) => { - dispatch(clearSearch()); - navigate(buildGameDetailsPath(game)); - }; + const { filters, page } = useAppSelector((state) => state.catalogueSearch); + + const dispatch = useAppDispatch(); + + const { t, i18n } = useTranslation("catalogue"); + + const { getRepacksForObjectId } = useRepacks(); useEffect(() => { - if (contentRef.current) contentRef.current.scrollTop = 0; + setResults([]); setIsLoading(true); - setSearchResults([]); + abortControllerRef.current?.abort(); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; window.electron - .getGames(24, skip) - .then((results) => { - return new Promise((resolve) => { - setTimeout(() => { - setSearchResults(results); - resolve(null); - }, 500); - }); - }) - .finally(() => { + .searchGames(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE) + .then((response) => { + if (abortController.signal.aborted) { + return; + } + + setResults(response.edges); + setItemsCount(response.count); setIsLoading(false); }); - }, [dispatch, skip, searchParams]); + }, [filters, page, dispatch]); - const handleNextPage = () => { - const params = new URLSearchParams({ - skip: String(skip + 24), + useEffect(() => { + downloadSourcesTable.toArray().then((sources) => { + setDownloadSources(sources.filter((source) => !!source.fingerprint)); }); + }, [getRepacksForObjectId]); + + const language = i18n.language.split("-")[0]; + + const steamGenresMapping = useMemo>(() => { + 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 []; - navigate(`/catalogue?${params.toString()}`); - }; + 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 ( - -
+
- - - -
- -
-
- {isLoading && - Array.from({ length: 12 }).map((_, index) => ( - +
+
    + {groupedFilters.map((filter) => ( +
  • + { + dispatch( + setFilters({ + [filter.key]: filters[filter.key].filter( + (item) => item !== filter.value + ), + }) + ); + }} + /> +
  • ))} +
+
+ - {!isLoading && searchResults.length > 0 && ( - <> - {searchResults.map((game) => ( - handleGameClick(game)} +
+
+ {isLoading ? ( + + {Array.from({ length: PAGE_SIZE }).map((_, i) => ( + ))} - + + ) : ( + results.map((game) => ) )} -
-
-
+ +
+ + {t("result_count", { + resultCount: formatNumber(itemsCount), + })} + + + { + dispatch(setPage(page)); + if (cataloguePageRef.current) { + cataloguePageRef.current.scrollTop = 0; + } + }} + /> +
+ + +
+
+ {filterSections.map((section) => ( + 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} + /> + ))} +
+
+ + ); } diff --git a/src/renderer/src/pages/catalogue/filter-item.tsx b/src/renderer/src/pages/catalogue/filter-item.tsx new file mode 100644 index 000000000..2413bee98 --- /dev/null +++ b/src/renderer/src/pages/catalogue/filter-item.tsx @@ -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 ( +
+
+ {filter} + +
+ ); +} diff --git a/src/renderer/src/pages/catalogue/filter-section.tsx b/src/renderer/src/pages/catalogue/filter-section.tsx new file mode 100644 index 000000000..0569ba9d1 --- /dev/null +++ b/src/renderer/src/pages/catalogue/filter-section.tsx @@ -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 ( +
+
+
+

+ {title} +

+
+ + {selectedItemsCount > 0 ? ( + + ) : ( + + {t("filter_count", { + filterCount: formatNumber(items.length), + })} + + )} + + onSearch(e.target.value)} + value={search} + containerProps={{ style: { marginBottom: 16 } }} + theme="dark" + /> + + 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) => ( +
+ onSelect(item.value)} + /> +
+ )} +
+
+ ); +} diff --git a/src/renderer/src/pages/catalogue/game-item.scss b/src/renderer/src/pages/catalogue/game-item.scss new file mode 100644 index 000000000..83d182f4c --- /dev/null +++ b/src/renderer/src/pages/catalogue/game-item.scss @@ -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; + } +} diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx new file mode 100644 index 000000000..8fb4c1b63 --- /dev/null +++ b/src/renderer/src/pages/catalogue/game-item.tsx @@ -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 ( + + ); +} diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx new file mode 100644 index 000000000..3669e902c --- /dev/null +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -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 ( +
+ {/* Previous Button */} + + + {page > 2 && ( + <> + {/* initial page */} + + + {/* ellipsis */} +
+ ... +
+ + )} + + {/* Page Buttons */} + {Array.from( + { length: endPage - startPage + 1 }, + (_, i) => startPage + i + ).map((pageNumber) => ( + + ))} + + {page < totalPages - 1 && ( + <> + {/* ellipsis */} +
+ ... +
+ + {/* last page */} + + + )} + + {/* Next Button */} + +
+ ); +} diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx index be5cfd4d7..b4665f15c 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx @@ -14,7 +14,7 @@ import { TrashIcon, UploadIcon, } from "@primer/octicons-react"; -import { useToast } from "@renderer/hooks"; +import { useAppSelector, useToast } from "@renderer/hooks"; import { useTranslation } from "react-i18next"; import { AxiosProgressEvent } from "axios"; import { formatDownloadProgress } from "@renderer/helpers"; @@ -145,6 +145,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { const disableActions = uploadingBackup || restoringBackup || deletingArtifact; + const userDetails = useAppSelector((state) => state.userDetails.userDetails); + const backupsPerGameLimit = userDetails?.quirks.backupsPerGameLimit ?? 0; + return ( = 2 + artifacts.length >= backupsPerGameLimit } > @@ -199,7 +202,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { }} >

{t("backups")}

- {artifacts.length} / 2 + + {artifacts.length} / {backupsPerGameLimit} +
diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 70ce165fa..12495231e 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -14,6 +14,7 @@ import { steamUrlBuilder } from "@shared"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; import { useUserDetails } from "@renderer/hooks"; +import { useSubscription } from "@renderer/hooks/use-subscription"; const HERO_ANIMATION_THRESHOLD = 25; @@ -31,9 +32,10 @@ export function GameDetailsContent() { gameColor, setGameColor, hasNSFWContentBlocked, - handleClickOpenCheckout, } = useContext(gameDetailsContext); + const { showHydraCloudModal } = useSubscription(); + const { userDetails, hasActiveSubscription } = useUserDetails(); const { setShowCloudSyncModal, getGameArtifacts } = @@ -104,7 +106,7 @@ export function GameDetailsContent() { } if (!hasActiveSubscription) { - handleClickOpenCheckout(); + showHydraCloudModal("backup"); return; } diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index 35982b19e..191d9ac16 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -8,7 +8,7 @@ import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; 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 { useAppSelector, useToast } from "@renderer/hooks"; @@ -159,16 +159,6 @@ export function DownloadSettingsModal({ ))} - - {selectedDownloader != null && - selectedDownloader !== Downloader.Torrent && ( -

- - {t("warning")} - {" "} - {t("hydra_needs_to_remain_open")} -

- )}
("minimum"); - const { - gameTitle, - shopDetails, - objectId, - shop, - stats, - achievements, - handleClickOpenCheckout, - } = useContext(gameDetailsContext); + const { gameTitle, shopDetails, objectId, shop, stats, achievements } = + useContext(gameDetailsContext); + + const { showHydraCloudModal } = useSubscription(); const { t } = useTranslation("game_details"); const { formatDateTime } = useDate(); @@ -179,7 +175,7 @@ export function Sidebar() { {!hasActiveSubscription && ( + )} + + )} + + {(isMe || userStats.achievementsPointsEarnedSum !== undefined) && ( +
  • +

    {t("earned_points")}

    + {userStats.achievementsPointsEarnedSum !== undefined ? ( +
    +

    + + {numberFormatter.format( + userStats.achievementsPointsEarnedSum.value + )} +

    +

    + {t("top_percentile", { + percentile: + userStats.achievementsPointsEarnedSum.topPercentile, + })} +

    +
    + ) : ( + + )} +
  • + )} + +
  • +

    {t("total_play_time")}

    +
    +

    + + {formatPlayTime(userStats.totalPlayTimeInSeconds.value)} +

    +

    + {t("top_percentile", { + percentile: userStats.totalPlayTimeInSeconds.topPercentile, + })} +

    +
    +
  • + +
    + + ); +} diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx index 5b05d5b8c..c2b94b7f5 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.tsx +++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx @@ -100,17 +100,30 @@ export function AddDownloadSourceModal({ } }, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]); - const handleAddDownloadSource = async () => { - setIsLoading(true); + const putDownloadSource = async () => { + 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) { + setIsLoading(true); + const channel = new BroadcastChannel(`download_sources:import:${url}`); downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]); - channel.onmessage = () => { + channel.onmessage = async () => { setIsLoading(false); + putDownloadSource(); + onClose(); onAddDownloadSource(); channel.close(); diff --git a/src/renderer/src/pages/settings/settings-download-sources.css.ts b/src/renderer/src/pages/settings/settings-download-sources.css.ts index 0e88631d2..caa93ce82 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.css.ts +++ b/src/renderer/src/pages/settings/settings-download-sources.css.ts @@ -42,3 +42,17 @@ export const downloadSourcesHeader = style({ justifyContent: "space-between", 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", + }, +}); diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index d2f45329f..96fb6f9fb 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -7,12 +7,14 @@ import * as styles from "./settings-download-sources.css"; import type { DownloadSource } from "@types"; import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react"; import { AddDownloadSourceModal } from "./add-download-source-modal"; -import { useToast } from "@renderer/hooks"; +import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks"; import { DownloadSourceStatus } from "@shared"; -import { SPACING_UNIT } from "@renderer/theme.css"; -import { repacksContext, settingsContext } from "@renderer/context"; +import { settingsContext } from "@renderer/context"; import { downloadSourcesTable } from "@renderer/dexie"; import { downloadSourcesWorker } from "@renderer/workers"; +import { useNavigate } from "react-router-dom"; +import { clearFilters } from "@renderer/features"; +import { setFilters } from "@renderer/features"; export function SettingsDownloadSources() { const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] = @@ -28,7 +30,11 @@ export function SettingsDownloadSources() { const { t } = useTranslation("settings"); const { showSuccessToast } = useToast(); - const { indexRepacks } = useContext(repacksContext); + const dispatch = useAppDispatch(); + + const navigate = useNavigate(); + + const { updateRepacks } = useRepacks(); const getDownloadSources = async () => { await downloadSourcesTable @@ -57,16 +63,16 @@ export function SettingsDownloadSources() { showSuccessToast(t("removed_download_source")); getDownloadSources(); - indexRepacks(); setIsRemovingDownloadSource(false); channel.close(); + updateRepacks(); }; }; const handleAddDownloadSource = async () => { - indexRepacks(); await getDownloadSources(); showSuccessToast(t("added_download_source")); + updateRepacks(); }; const syncDownloadSources = async () => { @@ -82,6 +88,7 @@ export function SettingsDownloadSources() { getDownloadSources(); setIsSyncingDownloadSources(false); channel.close(); + updateRepacks(); }; }; @@ -95,6 +102,13 @@ export function SettingsDownloadSources() { setShowAddDownloadSourceModal(false); }; + const navigateToCatalogue = (fingerprint: string) => { + dispatch(clearFilters()); + dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] })); + + navigate("/catalogue"); + }; + return ( <> {statusTitle[downloadSource.status]} -
    navigateToCatalogue(downloadSource.fingerprint)} > {t("download_count", { @@ -160,7 +173,7 @@ export function SettingsDownloadSources() { downloadSource.downloadCount.toLocaleString(), })} -
    + void; +} + +export const HydraCloudModal = ({ + feature, + visible, + onClose, +}: HydraCloudModalProps) => { + const { t } = useTranslation("hydra_cloud"); + + const handleClickOpenCheckout = () => { + window.electron.openCheckout(); + }; + + return ( + +
    + {t("hydra_cloud_feature_found")} + +
    +
    + ); +}; diff --git a/src/renderer/src/scss/globals.scss b/src/renderer/src/scss/globals.scss index 66478981c..cc01c197d 100644 --- a/src/renderer/src/scss/globals.scss +++ b/src/renderer/src/scss/globals.scss @@ -4,9 +4,9 @@ $dark-background-color: #151515; $muted-color: #c0c1c7; $body-color: #8e919b; -$border-color: #424244; +$border-color: rgba(255, 255, 255, 0.15); $success-color: #1c9749; -$danger-color: #e11d48; +$danger-color: #801d1e; $warning-color: #ffc107; $disabled-opacity: 0.5; diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 0f2bee9f8..e771f98ef 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -3,16 +3,17 @@ import { downloadSlice, windowSlice, librarySlice, - searchSlice, userPreferencesSlice, toastSlice, userDetailsSlice, gameRunningSlice, + subscriptionSlice, + repacksSlice, + catalogueSearchSlice, } from "@renderer/features"; export const store = configureStore({ reducer: { - search: searchSlice.reducer, window: windowSlice.reducer, library: librarySlice.reducer, userPreferences: userPreferencesSlice.reducer, @@ -20,6 +21,9 @@ export const store = configureStore({ toast: toastSlice.reducer, userDetails: userDetailsSlice.reducer, gameRunning: gameRunningSlice.reducer, + subscription: subscriptionSlice.reducer, + repacks: repacksSlice.reducer, + catalogueSearch: catalogueSearchSlice.reducer, }, }); diff --git a/src/renderer/src/theme.css.ts b/src/renderer/src/theme.css.ts index 4316ecd04..b9fbaf550 100644 --- a/src/renderer/src/theme.css.ts +++ b/src/renderer/src/theme.css.ts @@ -8,7 +8,7 @@ export const vars = createGlobalTheme(":root", { darkBackground: "#151515", muted: "#c0c1c7", body: "#8e919b", - border: "#424244", + border: "rgba(255, 255, 255, 0.15)", success: "#1c9749", danger: "#e11d48", warning: "#ffc107", diff --git a/src/renderer/src/workers/download-sources.worker.ts b/src/renderer/src/workers/download-sources.worker.ts index 29ab7d87f..893572e1d 100644 --- a/src/renderer/src/workers/download-sources.worker.ts +++ b/src/renderer/src/workers/download-sources.worker.ts @@ -2,7 +2,10 @@ import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie"; import { z } from "zod"; import axios, { AxiosError, AxiosHeaders } from "axios"; -import { DownloadSourceStatus } from "@shared"; +import { DownloadSourceStatus, formatName, pipe } from "@shared"; +import { GameRepack } from "@types"; + +const formatRepackName = pipe((name) => name.replace("[DL]", ""), formatName); export const downloadSourceSchema = z.object({ name: z.string().max(255), @@ -22,6 +25,95 @@ type Payload = | ["VALIDATE_DOWNLOAD_SOURCE", string] | ["SYNC_DOWNLOAD_SOURCES", string]; +export type SteamGamesByLetter = Record; + +const addNewDownloads = async ( + downloadSource: { id: number; name: string }, + downloads: z.infer["downloads"], + steamGames: SteamGamesByLetter +) => { + const now = new Date(); + + const results = [] as (Omit & { + downloadSourceId: number; + })[]; + + const objectIdsOnSource = new Set(); + + for (const download of downloads) { + const formattedTitle = formatRepackName(download.title); + const [firstLetter] = formattedTitle; + const games = steamGames[firstLetter] || []; + + const gamesInSteam = games.filter((game) => + formattedTitle.startsWith(game.name) + ); + + if (gamesInSteam.length === 0) continue; + + for (const game of gamesInSteam) { + objectIdsOnSource.add(String(game.id)); + } + + results.push({ + objectIds: gamesInSteam.map((game) => String(game.id)), + title: download.title, + uris: download.uris, + fileSize: download.fileSize, + repacker: downloadSource.name, + uploadDate: download.uploadDate, + downloadSourceId: downloadSource.id, + createdAt: now, + updatedAt: now, + }); + } + + await repacksTable.bulkAdd(results); + + await downloadSourcesTable.update(downloadSource.id, { + objectIds: Array.from(objectIdsOnSource), + }); +}; + +const getSteamGames = async () => { + const response = await axios.get( + `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` + ); + + return response.data; +}; + +const importDownloadSource = async (url: string) => { + const response = await axios.get>(url); + + const steamGames = await getSteamGames(); + + await db.transaction("rw", repacksTable, downloadSourcesTable, async () => { + const now = new Date(); + + const id = await downloadSourcesTable.add({ + url, + name: response.data.name, + etag: response.headers["etag"], + status: DownloadSourceStatus.UpToDate, + downloadCount: response.data.downloads.length, + createdAt: now, + updatedAt: now, + }); + + const downloadSource = await downloadSourcesTable.get(id); + + await addNewDownloads(downloadSource, response.data.downloads, steamGames); + }); +}; + +const deleteDownloadSource = async (id: number) => { + await db.transaction("rw", repacksTable, downloadSourcesTable, async () => { + await repacksTable.where({ downloadSourceId: id }).delete(); + await downloadSourcesTable.where({ id }).delete(); + }); +}; + self.onmessage = async (event: MessageEvent) => { const [type, data] = event.data; @@ -41,10 +133,7 @@ self.onmessage = async (event: MessageEvent) => { } if (type === "DELETE_DOWNLOAD_SOURCE") { - await db.transaction("rw", repacksTable, downloadSourcesTable, async () => { - await repacksTable.where({ downloadSourceId: data }).delete(); - await downloadSourcesTable.where({ id: data }).delete(); - }); + await deleteDownloadSource(data); const channel = new BroadcastChannel(`download_sources:delete:${data}`); @@ -52,37 +141,7 @@ self.onmessage = async (event: MessageEvent) => { } if (type === "IMPORT_DOWNLOAD_SOURCE") { - const response = - await axios.get>(data); - - await db.transaction("rw", repacksTable, downloadSourcesTable, async () => { - const now = new Date(); - - const id = await downloadSourcesTable.add({ - url: data, - name: response.data.name, - etag: response.headers["etag"], - status: DownloadSourceStatus.UpToDate, - downloadCount: response.data.downloads.length, - createdAt: now, - updatedAt: now, - }); - - const downloadSource = await downloadSourcesTable.get(id); - - const repacks = response.data.downloads.map((download) => ({ - title: download.title, - uris: download.uris, - fileSize: download.fileSize, - repacker: response.data.name, - uploadDate: download.uploadDate, - downloadSourceId: downloadSource!.id, - createdAt: now, - updatedAt: now, - })); - - await repacksTable.bulkAdd(repacks); - }); + await importDownloadSource(data); const channel = new BroadcastChannel(`download_sources:import:${data}`); channel.postMessage(true); @@ -96,64 +155,62 @@ self.onmessage = async (event: MessageEvent) => { const downloadSources = await downloadSourcesTable.toArray(); const existingRepacks = await repacksTable.toArray(); - for (const downloadSource of downloadSources) { - const headers = new AxiosHeaders(); - - if (downloadSource.etag) { - headers.set("If-None-Match", downloadSource.etag); - } - - try { - const response = await axios.get(downloadSource.url, { - headers, - }); - - const source = downloadSourceSchema.parse(response.data); - - await db.transaction( - "rw", - repacksTable, - downloadSourcesTable, - async () => { - await downloadSourcesTable.update(downloadSource.id, { - etag: response.headers["etag"], - downloadCount: source.downloads.length, - status: DownloadSourceStatus.UpToDate, - }); - - const now = new Date(); - - const repacks = source.downloads - .filter( + if (downloadSources.some((source) => !source.fingerprint)) { + await Promise.all( + downloadSources.map(async (source) => { + await deleteDownloadSource(source.id); + await importDownloadSource(source.url); + }) + ); + } else { + for (const downloadSource of downloadSources) { + const headers = new AxiosHeaders(); + + if (downloadSource.etag) { + headers.set("If-None-Match", downloadSource.etag); + } + + try { + const response = await axios.get(downloadSource.url, { + headers, + }); + + const source = downloadSourceSchema.parse(response.data); + + const steamGames = await getSteamGames(); + + await db.transaction( + "rw", + repacksTable, + downloadSourcesTable, + async () => { + await downloadSourcesTable.update(downloadSource.id, { + etag: response.headers["etag"], + downloadCount: source.downloads.length, + status: DownloadSourceStatus.UpToDate, + }); + + const repacks = source.downloads.filter( (download) => !existingRepacks.some( (repack) => repack.title === download.title ) - ) - .map((download) => ({ - title: download.title, - uris: download.uris, - fileSize: download.fileSize, - repacker: source.name, - uploadDate: download.uploadDate, - downloadSourceId: downloadSource.id, - createdAt: now, - updatedAt: now, - })); - - newRepacksCount += repacks.length; - - await repacksTable.bulkAdd(repacks); - } - ); - } catch (err: unknown) { - const isNotModified = (err as AxiosError).response?.status === 304; - - await downloadSourcesTable.update(downloadSource.id, { - status: isNotModified - ? DownloadSourceStatus.UpToDate - : DownloadSourceStatus.Errored, - }); + ); + + await addNewDownloads(downloadSource, repacks, steamGames); + + newRepacksCount += repacks.length; + } + ); + } catch (err: unknown) { + const isNotModified = (err as AxiosError).response?.status === 304; + + await downloadSourcesTable.update(downloadSource.id, { + status: isNotModified + ? DownloadSourceStatus.UpToDate + : DownloadSourceStatus.Errored, + }); + } } } diff --git a/src/renderer/src/workers/index.ts b/src/renderer/src/workers/index.ts index b8141a8f8..39367894f 100644 --- a/src/renderer/src/workers/index.ts +++ b/src/renderer/src/workers/index.ts @@ -1,5 +1,3 @@ -import RepacksWorker from "./repacks.worker?worker"; import DownloadSourcesWorker from "./download-sources.worker?worker"; -export const repacksWorker = new RepacksWorker(); export const downloadSourcesWorker = new DownloadSourcesWorker(); diff --git a/src/renderer/src/workers/repacks.worker.ts b/src/renderer/src/workers/repacks.worker.ts deleted file mode 100644 index c23945101..000000000 --- a/src/renderer/src/workers/repacks.worker.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { repacksTable } from "@renderer/dexie"; -import { formatName } from "@shared"; -import type { GameRepack } from "@types"; -import flexSearch from "flexsearch"; - -interface SerializedGameRepack extends Omit { - uris: string; -} - -const state = { - repacks: [] as SerializedGameRepack[], - index: null as flexSearch.Index | null, -}; - -self.onmessage = async ( - event: MessageEvent<[string, string] | "INDEX_REPACKS"> -) => { - if (event.data === "INDEX_REPACKS") { - state.index = new flexSearch.Index(); - - repacksTable - .toCollection() - .sortBy("uploadDate") - .then((results) => { - state.repacks = results.reverse(); - - for (let i = 0; i < state.repacks.length; i++) { - const repack = state.repacks[i]; - const formattedTitle = formatName(repack.title); - state.index!.add(i, formattedTitle); - } - - self.postMessage("INDEXING_COMPLETE"); - }); - } else { - const [requestId, query] = event.data; - - const results = state.index!.search(formatName(query)).map((index) => { - const repack = state.repacks.at(index as number) as SerializedGameRepack; - - return { - ...repack, - uris: [...repack.uris, repack.magnet].filter(Boolean), - }; - }); - - const channel = new BroadcastChannel(`repacks:search:${requestId}`); - - channel.postMessage(results); - } -}; diff --git a/src/types/index.ts b/src/types/index.ts index 4b09e17e3..e6ca334b6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,16 +14,18 @@ export type GameShop = "steam" | "epic"; export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL"; +export type HydraCloudFeature = + | "achievements" + | "backup" + | "achievements-points"; + export interface GameRepack { id: number; title: string; - /** - * @deprecated Use uris instead - */ - magnet: string; uris: string[]; repacker: string; fileSize: string | null; + objectIds: string[]; uploadDate: Date | string | null; createdAt: Date; updatedAt: Date; @@ -36,12 +38,14 @@ export interface AchievementData { icon: string; icongray: string; hidden: boolean; + points?: number; } export interface UserAchievement { name: string; hidden: boolean; displayName: string; + points?: number; description?: string; unlocked: boolean; unlockTime: number | null; @@ -78,15 +82,6 @@ export interface TorrentFile { length: number; } -/* Used by the catalogue */ -export interface CatalogueEntry { - objectId: string; - shop: GameShop; - title: string; - /* Epic Games covers cannot be guessed with objectID */ - cover: string; -} - export interface UserGame { objectId: string; shop: GameShop; @@ -97,6 +92,7 @@ export interface UserGame { lastTimePlayed: Date | null; unlockedAchievementCount: number; achievementCount: number; + achievementsPointsEarnedSum: number; } export interface DownloadQueue { @@ -206,6 +202,13 @@ export interface UserFriend { profileImageUrl: string | null; createdAt: string; updatedAt: string; + currentGame: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + sessionDurationInSeconds: number; + } | null; } export interface UserFriends { @@ -264,6 +267,9 @@ export interface UserDetails { profileVisibility: ProfileVisibility; bio: string; subscription: Subscription | null; + quirks: { + backupsPerGameLimit: number; + }; } export interface UserProfile { @@ -281,6 +287,9 @@ export interface UserProfile { currentGame: UserProfileCurrentGame | null; bio: string; hasActiveSubscription: boolean; + quirks: { + backupsPerGameLimit: number; + }; } export interface UpdateProfileRequest { @@ -310,7 +319,9 @@ export interface DownloadSource { url: string; repackCount: number; status: DownloadSourceStatus; + objectIds: string[]; downloadCount: number; + fingerprint: string; etag: string | null; createdAt: Date; updatedAt: Date; @@ -328,9 +339,17 @@ export interface TrendingGame { logo: string | null; } +export interface UserStatsPercentile { + value: number; + topPercentile: number; +} + export interface UserStats { libraryCount: number; friendsCount: number; + totalPlayTimeInSeconds: UserStatsPercentile; + achievementsPointsEarnedSum?: UserStatsPercentile; + unlockedAchievementSum?: number; } export interface UnlockedAchievement { @@ -358,15 +377,18 @@ export interface GameArtifact { } export interface ComparedAchievements { + achievementsPointsTotal: number; owner: { totalAchievementCount: number; unlockedAchievementCount: number; + achievementsPointsEarnedSum?: number; }; target: { displayName: string; profileImageUrl: string; totalAchievementCount: number; unlockedAchievementCount: number; + achievementsPointsEarnedSum: number; }; achievements: { hidden: boolean; @@ -384,6 +406,15 @@ export interface ComparedAchievements { }[]; } +export interface CatalogueSearchPayload { + title: string; + downloadSourceFingerprints: string[]; + tags: number[]; + publishers: string[]; + genres: string[]; + developers: string[]; +} + export * from "./steam.types"; export * from "./real-debrid.types"; export * from "./ludusavi.types"; diff --git a/yarn.lock b/yarn.lock index fb6bcfe87..c28ea3844 100644 --- a/yarn.lock +++ b/yarn.lock @@ -978,6 +978,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.15", "@babel/template@^7.24.0": version "7.24.0" resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz" @@ -4101,7 +4108,7 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -classnames@^2.5.1: +classnames@^2.2.1, classnames@^2.2.6, classnames@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== @@ -5394,11 +5401,6 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== -flexsearch@^0.7.43: - version "0.7.43" - resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.43.tgz#34f89b36278a466ce379c5bf6fb341965ed3f16c" - integrity sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg== - follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" @@ -7571,6 +7573,34 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +rc-resize-observer@^1.0.0: + version "1.4.3" + resolved "https://registry.yarnpkg.com/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz#4fd41fa561ba51362b5155a07c35d7c89a1ea569" + integrity sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ== + dependencies: + "@babel/runtime" "^7.20.7" + classnames "^2.2.1" + rc-util "^5.44.1" + resize-observer-polyfill "^1.5.1" + +rc-util@^5.36.0, rc-util@^5.44.1: + version "5.44.2" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.44.2.tgz#6bc5db0e96ebdb515eb5977a7371887e5413a6f8" + integrity sha512-uGSk3hpPBLa3/0QAcKhCjgl4SFnhQCJDLvvpoLdbR6KgDuXrujG+dQaUeUvBJr2ZWak1O/9n+cYbJiWmmk95EQ== + dependencies: + "@babel/runtime" "^7.18.3" + react-is "^18.2.0" + +rc-virtual-list@^3.16.1: + version "3.16.1" + resolved "https://registry.yarnpkg.com/rc-virtual-list/-/rc-virtual-list-3.16.1.tgz#073d75cc0295497cdd9a35d6f5d1b71b4f35233e" + integrity sha512-algM5UsB7vrlPNr9lsZEH8s9KHkP8XbT/Y0qylyPkiM8mIOlSJLjBNADcmbYPEQCm4zW82mZRJuVHNzqqN0EAQ== + dependencies: + "@babel/runtime" "^7.20.0" + classnames "^2.2.6" + rc-resize-observer "^1.0.0" + rc-util "^5.36.0" + rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -7607,6 +7637,11 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + react-loading-skeleton@^3.4.0: version "3.5.0" resolved "https://registry.yarnpkg.com/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz#da2090355b4dedcad5c53cb3f0ed364e3a76d6ca" @@ -7784,6 +7819,11 @@ reselect@^5.1.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-alpn@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"