Merge pull request #1313 from hydralauncher/feat/new-catalogue
Feat/new catalogue
@ -26,4 +26,9 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
BIN
build/icon.ico
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 108 KiB |
BIN
build/icon.png
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 604 KiB |
@ -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",
|
||||
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 7.3 KiB |
@ -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;
|
||||
|
@ -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": "الحفظ السحابي",
|
||||
|
@ -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": "Запазване в облака",
|
||||
|
@ -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ó",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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": "Көрсету аты",
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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": "Открыть источники",
|
||||
|
@ -231,7 +231,7 @@
|
||||
"sign_out_modal_text": "Ваша бібліотека пов'язана з поточним обліковим записом. При виході з системи ваша бібліотека буде недоступною, і прогрес не буде збережено. Продовжити вихід?",
|
||||
"sign_out_modal_title": "Ви впевнені?",
|
||||
"successfully_signed_out": "Успішний вихід з акаунту",
|
||||
"total_play_time": "Всього зіграно: {{amount}}",
|
||||
"total_play_time": "Всього зіграно",
|
||||
"try_again": "Будь ласка, попробуйте ще раз"
|
||||
}
|
||||
}
|
||||
|
@ -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": "云存档",
|
||||
|
@ -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,
|
||||
],
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
@ -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);
|
||||
|
10
src/main/events/catalogue/get-developers.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<string[]>(`/catalogue/developers`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getDevelopers", getDevelopers);
|
@ -1,29 +0,0 @@
|
||||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
const getGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
take = 12,
|
||||
skip = 0
|
||||
): Promise<CatalogueEntry[]> => {
|
||||
const searchParams = new URLSearchParams({
|
||||
take: take.toString(),
|
||||
skip: skip.toString(),
|
||||
});
|
||||
|
||||
const games = await HydraApi.get<CatalogueEntry[]>(
|
||||
`/games/catalogue?${searchParams.toString()}`,
|
||||
undefined,
|
||||
{ needsAuth: false }
|
||||
);
|
||||
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
cover: steamUrlBuilder.library(game.objectId),
|
||||
}));
|
||||
};
|
||||
|
||||
registerEvent("getGames", getGames);
|
10
src/main/events/catalogue/get-publishers.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<string[]>(`/catalogue/publishers`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getPublishers", getPublishers);
|
@ -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<CatalogueEntry[]> => {
|
||||
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);
|
||||
|
@ -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);
|
@ -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);
|
17
src/main/events/download-sources/put-download-source.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const putDownloadSource = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectIds: string[]
|
||||
) => {
|
||||
return HydraApi.put<{ fingerprint: string }>(
|
||||
"/download-sources",
|
||||
{
|
||||
objectIds,
|
||||
},
|
||||
{ needsAuth: false }
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("putDownloadSource", putDownloadSource);
|
@ -1,31 +0,0 @@
|
||||
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
|
||||
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
export interface SearchGamesArgs {
|
||||
query?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
export const convertSteamGameToCatalogueEntry = (
|
||||
game: SteamGame
|
||||
): CatalogueEntry => ({
|
||||
objectId: String(game.id),
|
||||
title: game.name,
|
||||
shop: "steam" as GameShop,
|
||||
cover: steamUrlBuilder.library(String(game.id)),
|
||||
});
|
||||
|
||||
export const getSteamGameById = async (
|
||||
objectId: string
|
||||
): Promise<CatalogueEntry | null> => {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
if (!steamGame) return null;
|
||||
|
||||
return convertSteamGameToCatalogueEntry(steamGame);
|
||||
};
|
@ -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";
|
||||
|
@ -13,6 +13,9 @@ const getComparedUnlockedAchievements = async (
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const showHiddenAchievementsDescription =
|
||||
userPreferences?.showHiddenAchievementsDescription || false;
|
||||
|
||||
return HydraApi.get<ComparedAchievements>(
|
||||
`/users/${userId}/games/achievements/compare`,
|
||||
{
|
||||
@ -21,7 +24,8 @@ const getComparedUnlockedAchievements = async (
|
||||
language: userPreferences?.language || "en",
|
||||
}
|
||||
).then((achievements) => {
|
||||
const sortedAchievements = achievements.achievements.sort((a, b) => {
|
||||
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) {
|
||||
@ -29,6 +33,25 @@ const getComparedUnlockedAchievements = async (
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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", {
|
||||
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,
|
||||
|
@ -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,12 +294,10 @@ export class HydraApi {
|
||||
await this.revalidateAccessTokenIfExpired();
|
||||
}
|
||||
|
||||
if (needsSubscription) {
|
||||
if (!(await this.hasActiveSubscription())) {
|
||||
if (needsSubscription && !this.hasActiveSubscription()) {
|
||||
throw new SubscriptionRequiredError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async get<T = any>(
|
||||
url: string,
|
||||
|
@ -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,
|
||||
|
@ -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) =>
|
||||
|
@ -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;
|
||||
updateRepacks();
|
||||
|
||||
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<number>) => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
downloadSourceMigrationLock.current = false;
|
||||
});
|
||||
}, [indexRepacks]);
|
||||
}, [updateRepacks]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
dispatch(closeToast());
|
||||
@ -308,6 +257,12 @@ export function App() {
|
||||
onClose={handleToastClose}
|
||||
/>
|
||||
|
||||
<HydraCloudModal
|
||||
visible={isHydraCloudModalVisible}
|
||||
onClose={hideHydraCloudModal}
|
||||
feature={hydraCloudFeature}
|
||||
/>
|
||||
|
||||
{userDetails && (
|
||||
<UserFriendModal
|
||||
visible={isFriendsModalVisible}
|
||||
@ -321,11 +276,7 @@ export function App() {
|
||||
<Sidebar />
|
||||
|
||||
<article className={styles.container}>
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
search={search}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
<Header />
|
||||
|
||||
<section ref={contentRef} className={styles.content}>
|
||||
<Outlet />
|
||||
|
13
src/renderer/src/assets/icons/hydra.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg width="55" height="49" viewBox="0 0 55 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.8501 29.1176L19.9196 28.3235L20.6957 25.6764L20.437 24.8823L18.1088 25.9411L14.487 24.3528L10.0891 23.0293L5.69128 23.5587L3.10431 25.6764L2.58691 29.1176L4.1391 33.6177L5.69128 36.7942L8.53695 38.9118L10.8652 38.3824L13.9696 34.9412V31.5L12.9348 29.1176V32.2941L10.8652 34.9412H7.50216L4.91519 32.2941L5.69128 28.3235L9.57174 26.4705L13.9696 27.5294L17.8501 29.1176Z" fill="white"/>
|
||||
<path d="M36.9585 29.1176L34.889 28.3235L34.1129 25.6764L34.3716 24.8823L36.6998 25.9411L40.3216 24.3528L44.7195 23.0293L49.1173 23.5587L51.7043 25.6764L52.2217 29.1176L50.6695 33.6177L49.1173 36.7942L46.2716 38.9118L43.9434 38.3824L40.839 34.9412V31.5L41.8738 29.1176V32.2941L43.9434 34.9412H47.3064L49.8934 32.2941L49.1173 28.3235L45.2369 26.4705L40.839 27.5294L36.9585 29.1176Z" fill="white"/>
|
||||
<path d="M40.3873 19.4005L38.8784 19.9071L38.7685 19.0593L38.5553 17.7049L39.811 14.777L41.6564 11.5721L44.483 9.44001L47.1023 9.23162L49.2244 10.9371L50.7089 14.4032L51.4925 17.1031L50.9665 19.9071L49.3381 20.8916L45.7182 20.6206L43.8957 18.6282L43.2332 16.675L44.9154 18.5142L47.5156 18.8992L49.4627 17.0344L49.5586 14.0673L47.0064 12.1987L43.7784 13.2776L41.7929 16.3293L40.3873 19.4005Z" fill="white"/>
|
||||
<path d="M14.0238 19.4005L15.5327 19.9071L15.6426 19.0593L15.8559 17.7049L14.6001 14.777L12.7548 11.5721L9.92812 9.44001L7.30879 9.23162L5.18676 10.9371L3.70221 14.4032L2.91861 17.1031L3.44468 19.9071L5.07308 20.8916L8.69292 20.6206L10.5154 18.6282L11.178 16.675L9.4957 18.5142L6.89555 18.8992L4.94841 17.0344L4.8525 14.0673L7.4047 12.1987L10.6327 13.2776L12.6182 16.3293L14.0238 19.4005Z" fill="white"/>
|
||||
<path d="M19.9494 36.4343L22.554 34.3372L21.9876 34.0884L20.9528 31.9707L19.9494 32.5001L17.5898 34.0884L15.3904 37.377L14.744 40.4414L15.2615 43.0885L17.0724 45.4709L20.9528 47.3238L23.6932 46.5297L25.435 44.7653L26.0028 42.9192L25.6093 39.0508L25.0919 37.7913L23.6932 37.0002L24.3158 39.105L24.3158 42.0296L22.3597 43.9181L19.9494 43.9181L18.0225 42.0296L17.4949 39.105L19.9494 36.4343Z" fill="white"/>
|
||||
<path d="M35.0955 36.4343L32.4909 34.3372L33.0573 34.0884L34.0921 31.9707L35.0955 32.5001L37.4552 34.0884L39.6545 37.377L40.3009 40.4414L39.7834 43.0885L37.9725 45.4709L34.0921 47.3238L31.3518 46.5297L29.6099 44.7653L29.0421 42.9192L29.4356 39.0508L29.953 37.7913L31.3518 37.0002L30.7291 39.105L30.7291 42.0296L32.6852 43.9181L35.0955 43.9181L37.0224 42.0296L37.55 39.105L35.0955 36.4343Z" fill="white"/>
|
||||
<path d="M18.8447 8.70593V5.79413L20.1382 5H22.9839L27.3817 5.79413L31.7796 5H34.6252L35.9187 5.79413V8.70593L38.7644 11.353L37.2122 15.0589L37.9883 20.8825L35.9187 23.0002L33.3317 23.7943L32.8144 26.1767L33.3317 28.8238L32.8144 30.9415L31.7796 33.0591L30.2274 33.8533H27.3817H24.536L22.9839 33.0591L21.9491 30.9415L21.4317 28.8238L21.9491 26.1767L21.4317 23.7943L18.8447 23.0002L16.7751 20.8825L17.5512 15.0589L15.999 11.353L18.8447 8.70593Z" fill="white"/>
|
||||
<path d="M15.5205 6.88232L16.2966 8.73528L17.5901 7.67645V4.76465L15.5205 6.88232Z" fill="white"/>
|
||||
<path d="M39.2861 6.88232L38.51 8.73528L37.2166 7.67645V4.76465L39.2861 6.88232Z" fill="white"/>
|
||||
<path d="M18.3667 2.11767L18.6254 4.23534L19.4015 3.70593H20.9537L20.4363 2.11767L17.0732 0L18.3667 2.11767Z" fill="white"/>
|
||||
<path d="M35.6997 2.11767L35.441 4.23534L34.6649 3.70593H33.1127L33.6301 2.11767L36.9932 0L35.6997 2.11767Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.3 KiB |
@ -7,9 +7,5 @@ export interface BadgeProps {
|
||||
}
|
||||
|
||||
export function Badge({ children }: BadgeProps) {
|
||||
return (
|
||||
<div className="badge">
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
return <div className="badge">{children}</div>;
|
||||
}
|
||||
|
@ -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,7 +11,8 @@ export const checkboxField = style({
|
||||
cursor: "pointer",
|
||||
});
|
||||
|
||||
export const checkbox = style({
|
||||
export const checkbox = recipe({
|
||||
base: {
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "4px",
|
||||
@ -21,9 +23,20 @@ export const checkbox = style({
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const checkboxInput = style({
|
||||
@ -38,4 +51,7 @@ export const checkboxInput = style({
|
||||
|
||||
export const checkboxLabel = style({
|
||||
cursor: "pointer",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
@ -15,7 +15,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxField}>
|
||||
<div className={styles.checkbox}>
|
||||
<div className={styles.checkbox({ checked: props.checked })}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
|
||||
import type { GameStats } from "@types";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
|
||||
import * as styles from "./game-card.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { repacksContext } from "@renderer/context";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
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<GameStats | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
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) {
|
||||
>
|
||||
<div className={styles.backdrop}>
|
||||
<img
|
||||
src={game.cover}
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
className={styles.cover}
|
||||
loading="lazy"
|
||||
|
@ -6,14 +6,8 @@ import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./header.css";
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||
|
||||
export interface HeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
onClear: () => void;
|
||||
search?: string;
|
||||
}
|
||||
import { setFilters } from "@renderer/features";
|
||||
|
||||
const pathTitle: Record<string, string> = {
|
||||
"/": "home",
|
||||
@ -22,7 +16,7 @@ const pathTitle: Record<string, string> = {
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
export function Header() {
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<header
|
||||
@ -109,17 +116,17 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
value={searchValue}
|
||||
className={styles.searchInput}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onChange={(event) => handleSearch(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{search && (
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
onClick={() => dispatch(setFilters({ title: "" }))}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<XIcon />
|
||||
|
@ -1,7 +1,6 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@ -14,12 +13,12 @@ import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useRepacks,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
ShopDetails,
|
||||
@ -29,7 +28,6 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GameDetailsContext } from "./game-details.context.types";
|
||||
import { SteamContentDescriptor } from "@shared";
|
||||
import { repacksContext } from "../repacks/repacks.context";
|
||||
|
||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
game: null,
|
||||
@ -53,7 +51,6 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
setShowGameOptionsModal: () => {},
|
||||
setShowRepacksModal: () => {},
|
||||
setHasNSFWContentBlocked: () => {},
|
||||
handleClickOpenCheckout: () => {},
|
||||
});
|
||||
|
||||
const { Provider } = gameDetailsContext;
|
||||
@ -88,17 +85,8 @@ export function GameDetailsContextProvider({
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndexingRepacks) {
|
||||
searchRepacks(gameTitle).then((repacks) => {
|
||||
setRepacks(repacks);
|
||||
});
|
||||
}
|
||||
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
const repacks = getRepacksForObjectId(objectId);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
|
||||
@ -111,11 +99,6 @@ export function GameDetailsContextProvider({
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const handleClickOpenCheckout = () => {
|
||||
// TODO: show modal before redirecting to checkout page
|
||||
window.electron.openCheckout();
|
||||
};
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectId(objectId!)
|
||||
@ -290,7 +273,6 @@ export function GameDetailsContextProvider({
|
||||
updateGame,
|
||||
setShowRepacksModal,
|
||||
setShowGameOptionsModal,
|
||||
handleClickOpenCheckout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
@ -29,5 +29,4 @@ export interface GameDetailsContext {
|
||||
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleClickOpenCheckout: () => void;
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
export * from "./game-details/game-details.context";
|
||||
export * from "./settings/settings.context";
|
||||
export * from "./user-profile/user-profile.context";
|
||||
export * from "./repacks/repacks.context";
|
||||
export * from "./cloud-sync/cloud-sync.context";
|
||||
|
@ -1,67 +0,0 @@
|
||||
import type { GameRepack } from "@types";
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { repacksWorker } from "@renderer/workers";
|
||||
|
||||
export interface RepacksContext {
|
||||
searchRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
indexRepacks: () => void;
|
||||
isIndexingRepacks: boolean;
|
||||
}
|
||||
|
||||
export const repacksContext = createContext<RepacksContext>({
|
||||
searchRepacks: async () => [] as GameRepack[],
|
||||
indexRepacks: () => {},
|
||||
isIndexingRepacks: false,
|
||||
});
|
||||
|
||||
const { Provider } = repacksContext;
|
||||
export const { Consumer: RepacksContextConsumer } = repacksContext;
|
||||
|
||||
export interface RepacksContextProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RepacksContextProvider({ children }: RepacksContextProps) {
|
||||
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
|
||||
|
||||
const searchRepacks = useCallback(async (query: string) => {
|
||||
return new Promise<GameRepack[]>((resolve) => {
|
||||
const channelId = crypto.randomUUID();
|
||||
repacksWorker.postMessage([channelId, query]);
|
||||
|
||||
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
|
||||
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
|
||||
resolve(event.data);
|
||||
channel.close();
|
||||
};
|
||||
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const indexRepacks = useCallback(() => {
|
||||
setIsIndexingRepacks(true);
|
||||
repacksWorker.postMessage("INDEX_REPACKS");
|
||||
|
||||
repacksWorker.onmessage = () => {
|
||||
setIsIndexingRepacks(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
indexRepacks();
|
||||
}, [indexRepacks]);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
searchRepacks,
|
||||
indexRepacks,
|
||||
isIndexingRepacks,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
21
src/renderer/src/declaration.d.ts
vendored
@ -1,10 +1,8 @@
|
||||
import type { CatalogueCategory } from "@shared";
|
||||
import type {
|
||||
AppUpdaterEvent,
|
||||
CatalogueEntry,
|
||||
Game,
|
||||
LibraryGame,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
@ -14,7 +12,6 @@ import type {
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
UserProfile,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
@ -31,6 +28,7 @@ import type {
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
ComparedAchievements,
|
||||
CatalogueSearchPayload,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
@ -58,8 +56,12 @@ declare global {
|
||||
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
||||
searchGames: (
|
||||
payload: CatalogueSearchPayload,
|
||||
take: number,
|
||||
skip: number
|
||||
) => Promise<{ edges: any[]; count: number }>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
|
||||
getGameShopDetails: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@ -70,8 +72,6 @@ declare global {
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
|
||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||
onUpdateAchievements: (
|
||||
@ -79,6 +79,8 @@ declare global {
|
||||
shop: GameShop,
|
||||
cb: (achievements: GameAchievement[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
getPublishers: () => Promise<string[]>;
|
||||
getDevelopers: () => Promise<string[]>;
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (
|
||||
@ -125,8 +127,9 @@ declare global {
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
|
||||
/* Download sources */
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
deleteDownloadSource: (id: number) => Promise<void>;
|
||||
putDownloadSource: (
|
||||
objectIds: string[]
|
||||
) => Promise<{ fingerprint: string }>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
|
@ -21,11 +21,10 @@ export interface CatalogueCache {
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(5).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
||||
db.version(8).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
|
||||
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
||||
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
|
||||
});
|
||||
|
||||
export const downloadSourcesTable = db.table("downloadSources");
|
||||
@ -34,6 +33,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
|
||||
"howLongToBeatEntries"
|
||||
);
|
||||
|
||||
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
|
||||
|
||||
db.open();
|
||||
|
67
src/renderer/src/features/catalogue-search.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
import type { CatalogueSearchPayload } from "@types";
|
||||
|
||||
export interface CatalogueSearchState {
|
||||
filters: CatalogueSearchPayload;
|
||||
page: number;
|
||||
steamUserTags: Record<string, Record<string, number>>;
|
||||
steamGenres: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const initialState: CatalogueSearchState = {
|
||||
filters: {
|
||||
title: "",
|
||||
downloadSourceFingerprints: [],
|
||||
tags: [],
|
||||
publishers: [],
|
||||
genres: [],
|
||||
developers: [],
|
||||
},
|
||||
steamUserTags: {},
|
||||
steamGenres: {},
|
||||
page: 1,
|
||||
};
|
||||
|
||||
export const catalogueSearchSlice = createSlice({
|
||||
name: "catalogueSearch",
|
||||
initialState,
|
||||
reducers: {
|
||||
setFilters: (
|
||||
state,
|
||||
action: PayloadAction<Partial<CatalogueSearchPayload>>
|
||||
) => {
|
||||
state.filters = { ...state.filters, ...action.payload };
|
||||
state.page = initialState.page;
|
||||
},
|
||||
clearFilters: (state) => {
|
||||
state.filters = initialState.filters;
|
||||
state.page = initialState.page;
|
||||
},
|
||||
setPage: (state, action: PayloadAction<number>) => {
|
||||
state.page = action.payload;
|
||||
},
|
||||
clearPage: (state) => {
|
||||
state.page = initialState.page;
|
||||
},
|
||||
setTags: (
|
||||
state,
|
||||
action: PayloadAction<Record<string, Record<string, number>>>
|
||||
) => {
|
||||
state.steamUserTags = action.payload;
|
||||
},
|
||||
setGenres: (state, action: PayloadAction<Record<string, string[]>>) => {
|
||||
state.steamGenres = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setFilters,
|
||||
clearFilters,
|
||||
setPage,
|
||||
clearPage,
|
||||
setTags,
|
||||
setGenres,
|
||||
} = catalogueSearchSlice.actions;
|
@ -1,4 +1,3 @@
|
||||
export * from "./search-slice";
|
||||
export * from "./library-slice";
|
||||
export * from "./use-preferences-slice";
|
||||
export * from "./download-slice";
|
||||
@ -6,3 +5,6 @@ export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./running-game-slice";
|
||||
export * from "./subscription-slice";
|
||||
export * from "./repacks-slice";
|
||||
export * from "./catalogue-search";
|
||||
|
24
src/renderer/src/features/repacks-slice.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
export interface RepacksState {
|
||||
value: GameRepack[];
|
||||
}
|
||||
|
||||
const initialState: RepacksState = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
export const repacksSlice = createSlice({
|
||||
name: "repacks",
|
||||
initialState,
|
||||
reducers: {
|
||||
setRepacks: (state, action: PayloadAction<RepacksState["value"]>) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setRepacks } = repacksSlice.actions;
|
@ -1,25 +0,0 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface SearchState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const initialState: SearchState = {
|
||||
value: "",
|
||||
};
|
||||
|
||||
export const searchSlice = createSlice({
|
||||
name: "search",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
clearSearch: (state) => {
|
||||
state.value = "";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setSearch, clearSearch } = searchSlice.actions;
|
32
src/renderer/src/features/subscription-slice.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { HydraCloudFeature } from "@types";
|
||||
|
||||
export interface SubscriptionState {
|
||||
isHydraCloudModalVisible: boolean;
|
||||
feature: HydraCloudFeature | "";
|
||||
}
|
||||
|
||||
const initialState: SubscriptionState = {
|
||||
isHydraCloudModalVisible: false,
|
||||
feature: "",
|
||||
};
|
||||
|
||||
export const subscriptionSlice = createSlice({
|
||||
name: "subscription",
|
||||
initialState,
|
||||
reducers: {
|
||||
setHydraCloudModalVisible: (
|
||||
state,
|
||||
action: PayloadAction<HydraCloudFeature>
|
||||
) => {
|
||||
state.isHydraCloudModalVisible = true;
|
||||
state.feature = action.payload;
|
||||
},
|
||||
setHydraCloudModalHidden: (state) => {
|
||||
state.isHydraCloudModalVisible = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setHydraCloudModalVisible, setHydraCloudModalHidden } =
|
||||
subscriptionSlice.actions;
|
@ -5,3 +5,4 @@ export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
export * from "./use-format";
|
||||
export * from "./use-repacks";
|
||||
|
53
src/renderer/src/hooks/use-catalogue.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAppDispatch } from "./redux";
|
||||
import { setGenres, setTags } from "@renderer/features";
|
||||
|
||||
export const externalResourcesInstance = axios.create({
|
||||
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
|
||||
});
|
||||
|
||||
export function useCatalogue() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
|
||||
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
|
||||
|
||||
const getSteamUserTags = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
|
||||
dispatch(setTags(response.data));
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const getSteamGenres = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-genres.json").then((response) => {
|
||||
dispatch(setGenres(response.data));
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const getSteamPublishers = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-publishers.json").then((response) => {
|
||||
setSteamPublishers(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getSteamDevelopers = useCallback(() => {
|
||||
externalResourcesInstance.get("/steam-developers.json").then((response) => {
|
||||
setSteamDevelopers(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSteamUserTags();
|
||||
getSteamGenres();
|
||||
getSteamPublishers();
|
||||
getSteamDevelopers();
|
||||
}, [
|
||||
getSteamUserTags,
|
||||
getSteamGenres,
|
||||
getSteamPublishers,
|
||||
getSteamDevelopers,
|
||||
]);
|
||||
|
||||
return { steamPublishers, steamDevelopers };
|
||||
}
|
@ -10,5 +10,5 @@ export function useFormat() {
|
||||
});
|
||||
}, [i18n.language]);
|
||||
|
||||
return { numberFormatter };
|
||||
return { numberFormatter, formatNumber: numberFormatter.format };
|
||||
}
|
||||
|
34
src/renderer/src/hooks/use-repacks.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { repacksTable } from "@renderer/dexie";
|
||||
import { setRepacks } from "@renderer/features";
|
||||
import { useCallback } from "react";
|
||||
import { RootState } from "@renderer/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useAppDispatch } from "./redux";
|
||||
|
||||
export function useRepacks() {
|
||||
const dispatch = useAppDispatch();
|
||||
const repacks = useSelector((state: RootState) => state.repacks.value);
|
||||
|
||||
const getRepacksForObjectId = useCallback(
|
||||
(objectId: string) => {
|
||||
return repacks.filter((repack) => repack.objectIds.includes(objectId));
|
||||
},
|
||||
[repacks]
|
||||
);
|
||||
|
||||
const updateRepacks = useCallback(() => {
|
||||
repacksTable.toArray().then((repacks) => {
|
||||
dispatch(
|
||||
setRepacks(
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
repacks.filter((repack) => Array.isArray(repack.objectIds))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
return { getRepacksForObjectId, updateRepacks };
|
||||
}
|
33
src/renderer/src/hooks/use-subscription.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import {
|
||||
setHydraCloudModalVisible,
|
||||
setHydraCloudModalHidden,
|
||||
} from "@renderer/features";
|
||||
import { HydraCloudFeature } from "@types";
|
||||
|
||||
export function useSubscription() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { isHydraCloudModalVisible, feature } = useAppSelector(
|
||||
(state) => state.subscription
|
||||
);
|
||||
|
||||
const showHydraCloudModal = useCallback(
|
||||
(feature: HydraCloudFeature) => {
|
||||
dispatch(setHydraCloudModalVisible(feature));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const hideHydraCloudModal = useCallback(() => {
|
||||
dispatch(setHydraCloudModalHidden());
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
isHydraCloudModalVisible,
|
||||
hydraCloudFeature: feature,
|
||||
showHydraCloudModal,
|
||||
hideHydraCloudModal,
|
||||
};
|
||||
}
|
@ -18,7 +18,6 @@ import { store } from "./store";
|
||||
|
||||
import resources from "@locales";
|
||||
|
||||
import { RepacksContextProvider } from "./context";
|
||||
import { SuspenseWrapper } from "./components";
|
||||
import { logger } from "./logger";
|
||||
import { addCookieInterceptor } from "./cookies";
|
||||
@ -28,7 +27,6 @@ const GameDetails = React.lazy(
|
||||
() => import("./pages/game-details/game-details")
|
||||
);
|
||||
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
|
||||
const SearchResults = React.lazy(() => import("./pages/home/search-results"));
|
||||
const Settings = React.lazy(() => import("./pages/settings/settings"));
|
||||
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
|
||||
const Profile = React.lazy(() => import("./pages/profile/profile"));
|
||||
@ -64,7 +62,6 @@ i18n
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<RepacksContextProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
@ -81,10 +78,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
path="/game/:shop/:objectId"
|
||||
element={<SuspenseWrapper Component={GameDetails} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={<SuspenseWrapper Component={SearchResults} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SuspenseWrapper Component={Settings} />}
|
||||
@ -100,7 +93,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</RepacksContextProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
90
src/renderer/src/pages/achievements/achievement-list.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import type { UserAchievement } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievements.css";
|
||||
import { EyeClosedIcon } from "@primer/octicons-react";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
|
||||
interface AchievementListProps {
|
||||
achievements: UserAchievement[];
|
||||
}
|
||||
|
||||
export function AchievementList({ achievements }: AchievementListProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{achievements.map((achievement, index) => (
|
||||
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{achievement.hidden && (
|
||||
<span
|
||||
style={{ display: "flex" }}
|
||||
title={t("hidden_achievement_tooltip")}
|
||||
>
|
||||
<EyeClosedIcon size={12} />
|
||||
</span>
|
||||
)}
|
||||
{achievement.displayName}
|
||||
</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{achievement.points != undefined ? (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "4px" }}
|
||||
title={t("achievement_earn_points", {
|
||||
points: achievement.points,
|
||||
})}
|
||||
>
|
||||
<HydraIcon width={20} height={20} />
|
||||
<p style={{ fontSize: "1.1em" }}>{achievement.points}</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
cursor: "pointer",
|
||||
color: vars.color.warning,
|
||||
}}
|
||||
title={t("achievement_earn_points", {
|
||||
points: "???",
|
||||
})}
|
||||
>
|
||||
<HydraIcon width={20} height={20} />
|
||||
<p style={{ fontSize: "1.1em" }}>???</p>
|
||||
</button>
|
||||
)}
|
||||
{achievement.unlockTime && (
|
||||
<div
|
||||
title={t("unlocked_at", {
|
||||
date: formatDateTime(achievement.unlockTime),
|
||||
})}
|
||||
style={{ whiteSpace: "nowrap", gap: "4px", display: "flex" }}
|
||||
>
|
||||
<small>{formatDateTime(achievement.unlockTime)}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
71
src/renderer/src/pages/achievements/achievement-panel.css.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const panel = style({
|
||||
width: "100%",
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
|
||||
backgroundColor: vars.color.background,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "start",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `solid 1px ${vars.color.border}`,
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "center",
|
||||
});
|
||||
|
||||
export const actions = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadDetailsRow = style({
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
display: "flex",
|
||||
color: vars.color.body,
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const downloadsLink = style({
|
||||
color: vars.color.body,
|
||||
textDecoration: "underline",
|
||||
});
|
||||
|
||||
export const progressBar = recipe({
|
||||
base: {
|
||||
position: "absolute",
|
||||
bottom: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "3px",
|
||||
transition: "all ease 0.2s",
|
||||
"::-webkit-progress-bar": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
"::-webkit-progress-value": {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const link = style({
|
||||
textAlign: "start",
|
||||
color: vars.color.body,
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
57
src/renderer/src/pages/achievements/achievement-panel.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { UserAchievement } from "@types";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import * as styles from "./achievement-panel.css";
|
||||
|
||||
export interface AchievementPanelProps {
|
||||
achievements: UserAchievement[];
|
||||
}
|
||||
|
||||
export function AchievementPanel({ achievements }: AchievementPanelProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { hasActiveSubscription } = useUserDetails();
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const achievementsPointsTotal = achievements.reduce(
|
||||
(acc, achievement) => acc + (achievement.points ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
const achievementsPointsEarnedSum = achievements.reduce(
|
||||
(acc, achievement) =>
|
||||
acc + (achievement.unlocked ? (achievement.points ?? 0) : 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.content}>
|
||||
{t("earned_points")} <HydraIcon width={20} height={20} />
|
||||
??? / ???
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showHydraCloudModal("achievements-points")}
|
||||
className={styles.link}
|
||||
>
|
||||
<small style={{ color: vars.color.warning }}>
|
||||
{t("how_to_earn_achievements_points")}
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.content}>
|
||||
{t("earned_points")} <HydraIcon width={20} height={20} />
|
||||
{achievementsPointsEarnedSum} / {achievementsPointsTotal}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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<UserInfo, "profileImageUrl" | "displayName">
|
||||
@ -93,7 +93,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||
<h3>
|
||||
<button
|
||||
className={styles.subscriptionRequiredButton}
|
||||
onClick={handleClickOpenCheckout}
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
>
|
||||
{t("subscription_needed")}
|
||||
</button>
|
||||
@ -171,38 +171,6 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementList({ achievements }: AchievementListProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{achievements.map((achievement, index) => (
|
||||
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
|
||||
<img
|
||||
className={styles.listItemImage({
|
||||
unlocked: achievement.unlocked,
|
||||
})}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4>{achievement.displayName}</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
{achievement.unlockTime && (
|
||||
<div style={{ whiteSpace: "nowrap" }}>
|
||||
<small>{t("unlocked_at")}</small>
|
||||
<p>{formatDateTime(achievement.unlockTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function AchievementsContent({
|
||||
otherUser,
|
||||
comparedAchievements,
|
||||
@ -355,9 +323,15 @@ export function AchievementsContent({
|
||||
)}
|
||||
|
||||
{otherUser ? (
|
||||
<>
|
||||
<ComparedAchievementPanel achievements={comparedAchievements!} />
|
||||
<ComparedAchievementList achievements={comparedAchievements!} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AchievementPanel achievements={achievements!} />
|
||||
<AchievementList achievements={achievements!} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
@ -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"
|
||||
/>
|
||||
<div>
|
||||
<h4>{achievement.displayName}</h4>
|
||||
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{achievement.hidden && (
|
||||
<span
|
||||
style={{ display: "flex" }}
|
||||
title={t("hidden_achievement_tooltip")}
|
||||
>
|
||||
<EyeClosedIcon size={12} />
|
||||
</span>
|
||||
)}
|
||||
{achievement.displayName}
|
||||
</h4>
|
||||
<p>{achievement.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,11 +74,9 @@ export function ComparedAchievementList({
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
title={formatDateTime(achievement.ownerStat.unlockTime!)}
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<small>
|
||||
{formatDateTime(achievement.ownerStat.unlockTime!)}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@ -86,11 +100,9 @@ export function ComparedAchievementList({
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
title={formatDateTime(achievement.targetStat.unlockTime!)}
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<small>
|
||||
{formatDateTime(achievement.targetStat.unlockTime!)}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievement-panel.css";
|
||||
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { ComparedAchievements } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
|
||||
export interface ComparedAchievementPanelProps {
|
||||
achievements: ComparedAchievements;
|
||||
}
|
||||
|
||||
export function ComparedAchievementPanel({
|
||||
achievements,
|
||||
}: ComparedAchievementPanelProps) {
|
||||
const { t } = useTranslation("achievement");
|
||||
const { hasActiveSubscription } = useUserDetails();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: hasActiveSubscription ? "3fr 1fr 1fr" : "3fr 2fr",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{t("available_points")} <HydraIcon width={20} height={20} />{" "}
|
||||
{achievements.achievementsPointsTotal}
|
||||
</div>
|
||||
{hasActiveSubscription && (
|
||||
<div className={styles.content}>
|
||||
<HydraIcon width={20} height={20} />
|
||||
{achievements.owner.achievementsPointsEarnedSum ?? 0}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<HydraIcon width={20} height={20} />
|
||||
{achievements.target.achievementsPointsEarnedSum}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
22
src/renderer/src/pages/catalogue/catalogue.scss
Normal file
@ -0,0 +1,22 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.catalogue {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&__filters-container {
|
||||
width: 270px;
|
||||
min-width: 270px;
|
||||
max-width: 270px;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
border: 1px solid globals.$border-color;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
@ -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 abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cataloguePageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { steamDevelopers, steamPublishers } = useCatalogue();
|
||||
|
||||
const { steamGenres, steamUserTags } = useAppSelector(
|
||||
(state) => state.catalogueSearch
|
||||
);
|
||||
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
|
||||
const [itemsCount, setItemsCount] = useState(0);
|
||||
|
||||
const { formatNumber } = useFormat();
|
||||
|
||||
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation("catalogue");
|
||||
const { t, i18n } = useTranslation("catalogue");
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const skip = Number(searchParams.get("skip") ?? 0);
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(buildGameDetailsPath(game));
|
||||
};
|
||||
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]);
|
||||
|
||||
navigate(`/catalogue?${params.toString()}`);
|
||||
};
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
const steamGenresMapping = useMemo<Record<string, string>>(() => {
|
||||
if (!steamGenres[language]) return {};
|
||||
|
||||
return steamGenres[language].reduce((prev, genre, index) => {
|
||||
prev[genre] = steamGenres["en"][index];
|
||||
return prev;
|
||||
}, {});
|
||||
}, [steamGenres, language]);
|
||||
|
||||
const steamGenresFilterItems = useMemo(() => {
|
||||
return Object.entries(steamGenresMapping)
|
||||
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
.map(([key, value]) => ({
|
||||
label: key,
|
||||
value: value,
|
||||
checked: filters.genres.includes(value),
|
||||
}));
|
||||
}, [steamGenresMapping, filters.genres]);
|
||||
|
||||
const steamUserTagsFilterItems = useMemo(() => {
|
||||
if (!steamUserTags[language]) return [];
|
||||
|
||||
return Object.entries(steamUserTags[language])
|
||||
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
.map(([key, value]) => ({
|
||||
label: key,
|
||||
value: value,
|
||||
checked: filters.tags.includes(value),
|
||||
}));
|
||||
}, [steamUserTags, filters.tags, language]);
|
||||
|
||||
const groupedFilters = useMemo(() => {
|
||||
return [
|
||||
...filters.genres.map((genre) => ({
|
||||
label: Object.keys(steamGenresMapping).find(
|
||||
(key) => steamGenresMapping[key] === genre
|
||||
) as string,
|
||||
orbColor: filterCategoryColors.genres,
|
||||
key: "genres",
|
||||
value: genre,
|
||||
})),
|
||||
|
||||
...filters.tags.map((tag) => ({
|
||||
label: Object.keys(steamUserTags[language]).find(
|
||||
(key) => steamUserTags[language][key] === tag
|
||||
),
|
||||
orbColor: filterCategoryColors.tags,
|
||||
key: "tags",
|
||||
value: tag,
|
||||
})),
|
||||
|
||||
...filters.downloadSourceFingerprints.map((fingerprint) => ({
|
||||
label: downloadSources.find(
|
||||
(source) => source.fingerprint === fingerprint
|
||||
)?.name as string,
|
||||
orbColor: filterCategoryColors.downloadSourceFingerprints,
|
||||
key: "downloadSourceFingerprints",
|
||||
value: fingerprint,
|
||||
})),
|
||||
|
||||
...filters.developers.map((developer) => ({
|
||||
label: developer,
|
||||
orbColor: filterCategoryColors.developers,
|
||||
key: "developers",
|
||||
value: developer,
|
||||
})),
|
||||
|
||||
...filters.publishers.map((publisher) => ({
|
||||
label: publisher,
|
||||
orbColor: filterCategoryColors.publishers,
|
||||
key: "publishers",
|
||||
value: publisher,
|
||||
})),
|
||||
];
|
||||
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
|
||||
|
||||
const filterSections = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: t("genres"),
|
||||
items: steamGenresFilterItems,
|
||||
key: "genres",
|
||||
},
|
||||
{
|
||||
title: t("tags"),
|
||||
items: steamUserTagsFilterItems,
|
||||
key: "tags",
|
||||
},
|
||||
{
|
||||
title: t("download_sources"),
|
||||
items: downloadSources.map((source) => ({
|
||||
label: source.name,
|
||||
value: source.fingerprint,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
source.fingerprint
|
||||
),
|
||||
})),
|
||||
key: "downloadSourceFingerprints",
|
||||
},
|
||||
{
|
||||
title: t("developers"),
|
||||
items: steamDevelopers.map((developer) => ({
|
||||
label: developer,
|
||||
value: developer,
|
||||
checked: filters.developers.includes(developer),
|
||||
})),
|
||||
key: "developers",
|
||||
},
|
||||
{
|
||||
title: t("publishers"),
|
||||
items: steamPublishers.map((publisher) => ({
|
||||
label: publisher,
|
||||
value: publisher,
|
||||
checked: filters.publishers.includes(publisher),
|
||||
})),
|
||||
key: "publishers",
|
||||
},
|
||||
];
|
||||
}, [
|
||||
downloadSources,
|
||||
filters.developers,
|
||||
filters.downloadSourceFingerprints,
|
||||
filters.publishers,
|
||||
steamDevelopers,
|
||||
steamGenresFilterItems,
|
||||
steamPublishers,
|
||||
steamUserTagsFilterItems,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section
|
||||
<div className="catalogue" ref={cataloguePageRef}>
|
||||
<div
|
||||
style={{
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 4}px`,
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
borderBottom: `1px solid ${vars.color.border}`,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
theme="outline"
|
||||
disabled={skip === 0 || isLoading}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<ul
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
{t("previous_page")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}>
|
||||
{t("next_page")}
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section ref={contentRef} className={styles.content}>
|
||||
<section className={styles.cards}>
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
{groupedFilters.map((filter) => (
|
||||
<li key={`${filter.key}-${filter.value}`}>
|
||||
<FilterItem
|
||||
filter={filter.label ?? ""}
|
||||
orbColor={filter.orbColor}
|
||||
onRemove={() => {
|
||||
dispatch(
|
||||
setFilters({
|
||||
[filter.key]: filters[filter.key].filter(
|
||||
(item) => item !== filter.value
|
||||
),
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && searchResults.length > 0 && (
|
||||
<>
|
||||
{searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectId}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: SPACING_UNIT * 2,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.darkBackground}
|
||||
highlightColor={vars.color.background}
|
||||
>
|
||||
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
style={{
|
||||
height: 105,
|
||||
borderRadius: 4,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
) : (
|
||||
results.map((game) => <GameItem key={game.id} game={game} />)
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{t("result_count", {
|
||||
resultCount: formatNumber(itemsCount),
|
||||
})}
|
||||
</span>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(itemsCount / PAGE_SIZE)}
|
||||
onPageChange={(page) => {
|
||||
dispatch(setPage(page));
|
||||
if (cataloguePageRef.current) {
|
||||
cataloguePageRef.current.scrollTop = 0;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="catalogue__filters-container">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{filterSections.map((section) => (
|
||||
<FilterSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
onClear={() => dispatch(setFilters({ [section.key]: [] }))}
|
||||
color={filterCategoryColors[section.key]}
|
||||
onSelect={(value) => {
|
||||
if (filters[section.key].includes(value)) {
|
||||
dispatch(
|
||||
setFilters({
|
||||
[section.key]: filters[
|
||||
section.key as
|
||||
| "genres"
|
||||
| "tags"
|
||||
| "downloadSourceFingerprints"
|
||||
| "developers"
|
||||
| "publishers"
|
||||
].filter((item) => item !== value),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
setFilters({
|
||||
[section.key]: [...filters[section.key], value],
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={section.items}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
50
src/renderer/src/pages/catalogue/filter-item.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
|
||||
interface FilterItemProps {
|
||||
filter: string;
|
||||
orbColor: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function FilterItem({ filter, orbColor, onRemove }: FilterItemProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: vars.color.body,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 4,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: orbColor,
|
||||
borderRadius: "50%",
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
style={{
|
||||
color: vars.color.body,
|
||||
marginLeft: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
136
src/renderer/src/pages/catalogue/filter-section.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { CheckboxField, TextField } from "@renderer/components";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import List from "rc-virtual-list";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface FilterSectionProps {
|
||||
title: string;
|
||||
items: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
checked: boolean;
|
||||
}[];
|
||||
onSelect: (value: string | number) => void;
|
||||
color: string;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function FilterSection({
|
||||
title,
|
||||
items,
|
||||
color,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: FilterSectionProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const { t } = useTranslation("catalogue");
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (search.length > 0) {
|
||||
return items.filter((item) =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [items, search]);
|
||||
|
||||
const selectedItemsCount = useMemo(() => {
|
||||
return items.filter((item) => item.checked).length;
|
||||
}, [items]);
|
||||
|
||||
const onSearch = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
}, []);
|
||||
|
||||
const { formatNumber } = useFormat();
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: color,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{selectedItemsCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
display: "block",
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
onClick={onClear}
|
||||
>
|
||||
{t("clear_filters", {
|
||||
filterCount: formatNumber(selectedItemsCount),
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, marginBottom: 12, display: "block" }}>
|
||||
{t("filter_count", {
|
||||
filterCount: formatNumber(items.length),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
value={search}
|
||||
containerProps={{ style: { marginBottom: 16 } }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
<List
|
||||
data={filteredItems}
|
||||
height={28 * (filteredItems.length > 10 ? 10 : filteredItems.length)}
|
||||
itemHeight={28}
|
||||
itemKey="value"
|
||||
styles={{
|
||||
verticalScrollBar: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.03)",
|
||||
},
|
||||
verticalScrollBarThumb: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "24px",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<div key={item.value} style={{ height: 28, maxHeight: 28 }}>
|
||||
<CheckboxField
|
||||
label={item.label}
|
||||
checked={item.checked}
|
||||
onChange={() => onSelect(item.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}
|
48
src/renderer/src/pages/catalogue/game-item.scss
Normal file
@ -0,0 +1,48 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.game-item {
|
||||
background-color: globals.$dark-background-color;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
border: 1px solid globals.$border-color;
|
||||
cursor: pointer;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&__cover {
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-right: 1px solid globals.$border-color;
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: calc(globals.$spacing-unit * 2) 0;
|
||||
}
|
||||
|
||||
&__genres {
|
||||
color: globals.$body-color;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__repackers {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
71
src/renderer/src/pages/catalogue/game-item.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { Badge } from "@renderer/components";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { useAppSelector, useRepacks } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import "./game-item.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface GameItemProps {
|
||||
game: any;
|
||||
}
|
||||
|
||||
export function GameItem({ game }: GameItemProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
const repacks = getRepacksForObjectId(game.objectId);
|
||||
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
const uniqueRepackers = useMemo(() => {
|
||||
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
|
||||
}, [repacks]);
|
||||
|
||||
const genres = useMemo(() => {
|
||||
return game.genres?.map((genre) => {
|
||||
const index = steamGenres["en"].findIndex(
|
||||
(steamGenre) => steamGenre === genre
|
||||
);
|
||||
|
||||
if (steamGenres[language] && steamGenres[language][index]) {
|
||||
return steamGenres[language][index];
|
||||
}
|
||||
|
||||
return genre;
|
||||
});
|
||||
}, [game.genres, language, steamGenres]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="game-item"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
<img
|
||||
className="game-item__cover"
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div className="game-item__details">
|
||||
<span>{game.title}</span>
|
||||
<span className="game-item__genres">{genres.join(", ")}</span>
|
||||
|
||||
<div className="game-item__repackers">
|
||||
{uniqueRepackers.map((repacker) => (
|
||||
<Badge key={repacker}>{repacker}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
128
src/renderer/src/pages/catalogue/pagination.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { Button } from "@renderer/components/button/button";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||
import { useFormat } from "@renderer/hooks/use-format";
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
const { formatNumber } = useFormat();
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
// Number of visible pages
|
||||
const visiblePages = 3;
|
||||
|
||||
// Calculate the start and end of the visible range
|
||||
let startPage = Math.max(1, page - 1); // Shift range slightly back
|
||||
let endPage = startPage + visiblePages - 1;
|
||||
|
||||
// Adjust the range if we're near the start or end
|
||||
if (endPage > totalPages) {
|
||||
endPage = totalPages;
|
||||
startPage = Math.max(1, endPage - visiblePages + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{/* Previous Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
|
||||
{page > 2 && (
|
||||
<>
|
||||
{/* initial page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(1)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === 1}
|
||||
>
|
||||
{1}
|
||||
</Button>
|
||||
|
||||
{/* ellipsis */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>...</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page Buttons */}
|
||||
{Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, i) => startPage + i
|
||||
).map((pageNumber) => (
|
||||
<Button
|
||||
theme={page === pageNumber ? "primary" : "outline"}
|
||||
key={pageNumber}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
onClick={() => onPageChange(pageNumber)}
|
||||
>
|
||||
{formatNumber(pageNumber)}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{page < totalPages - 1 && (
|
||||
<>
|
||||
{/* ellipsis */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>...</span>
|
||||
</div>
|
||||
|
||||
{/* last page */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
{formatNumber(totalPages)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@ -181,7 +184,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
disabled={
|
||||
disableActions ||
|
||||
!backupPreview?.overall.totalGames ||
|
||||
artifacts.length >= 2
|
||||
artifacts.length >= backupsPerGameLimit
|
||||
}
|
||||
>
|
||||
<UploadIcon />
|
||||
@ -199,7 +202,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
}}
|
||||
>
|
||||
<h2>{t("backups")}</h2>
|
||||
<small>{artifacts.length} / 2</small>
|
||||
<small>
|
||||
{artifacts.length} / {backupsPerGameLimit}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedDownloader != null &&
|
||||
selectedDownloader !== Downloader.Torrent && (
|
||||
<p style={{ marginTop: `${SPACING_UNIT}px` }}>
|
||||
<span style={{ color: vars.color.warning }}>
|
||||
{t("warning")}
|
||||
</span>{" "}
|
||||
{t("hydra_needs_to_remain_open")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -21,6 +21,7 @@ import { howLongToBeatEntriesTable } from "@renderer/dexie";
|
||||
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||
import { buildGameAchievementPath } from "@renderer/helpers";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
|
||||
const fakeAchievements: UserAchievement[] = [
|
||||
{
|
||||
@ -67,15 +68,10 @@ export function Sidebar() {
|
||||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("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 && (
|
||||
<button
|
||||
className={styles.subscriptionRequiredButton}
|
||||
onClick={handleClickOpenCheckout}
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
>
|
||||
<CloudOfflineIcon size={16} />
|
||||
<span>{t("achievements_not_sync")}</span>
|
||||
|
@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import { Button, GameCard, Hero } from "@renderer/components";
|
||||
import type { Steam250Game, CatalogueEntry } from "@types";
|
||||
import type { Steam250Game } from "@types";
|
||||
|
||||
import flameIconStatic from "@renderer/assets/icons/flame-static.png";
|
||||
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
|
||||
@ -15,14 +15,6 @@ import * as styles from "./home.css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { CatalogueCategory } from "@shared";
|
||||
import { catalogueCacheTable, db } from "@renderer/dexie";
|
||||
import { add } from "date-fns";
|
||||
|
||||
const categoryCacheDurationInSeconds = {
|
||||
[CatalogueCategory.Hot]: 60 * 60 * 2,
|
||||
[CatalogueCategory.Weekly]: 60 * 60 * 24,
|
||||
[CatalogueCategory.Achievements]: 60 * 60 * 24,
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation("home");
|
||||
@ -36,9 +28,7 @@ export default function Home() {
|
||||
CatalogueCategory.Hot
|
||||
);
|
||||
|
||||
const [catalogue, setCatalogue] = useState<
|
||||
Record<CatalogueCategory, CatalogueEntry[]>
|
||||
>({
|
||||
const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({
|
||||
[CatalogueCategory.Hot]: [],
|
||||
[CatalogueCategory.Weekly]: [],
|
||||
[CatalogueCategory.Achievements]: [],
|
||||
@ -46,37 +36,11 @@ export default function Home() {
|
||||
|
||||
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
|
||||
try {
|
||||
const catalogueCache = await catalogueCacheTable
|
||||
.where("expiresAt")
|
||||
.above(new Date())
|
||||
.and((cache) => cache.category === category)
|
||||
.first();
|
||||
|
||||
setCurrentCatalogueCategory(category);
|
||||
setIsLoading(true);
|
||||
|
||||
if (catalogueCache)
|
||||
return setCatalogue((prev) => ({
|
||||
...prev,
|
||||
[category]: catalogueCache.games,
|
||||
}));
|
||||
|
||||
const catalogue = await window.electron.getCatalogue(category);
|
||||
|
||||
db.transaction("rw", catalogueCacheTable, async () => {
|
||||
await catalogueCacheTable.where("category").equals(category).delete();
|
||||
|
||||
await catalogueCacheTable.add({
|
||||
category,
|
||||
games: catalogue,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
expiresAt: add(new Date(), {
|
||||
seconds: categoryCacheDurationInSeconds[category],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
@ -1,131 +0,0 @@
|
||||
import { GameCard } from "@renderer/components";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
import type { DebouncedFunc } from "lodash";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
|
||||
import { clearSearch, setSearch } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import * as styles from "./home.css";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
|
||||
import { vars } from "@renderer/theme.css";
|
||||
|
||||
export default function SearchResults() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation("home");
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTypingMessage, setShowTypingMessage] = useState(false);
|
||||
|
||||
const debouncedFunc = useRef<DebouncedFunc<() => void> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(buildGameDetailsPath(game));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setSearch(searchParams.get("query") ?? ""));
|
||||
}, [dispatch, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort();
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
debouncedFunc.current = debounce(() => {
|
||||
const query = searchParams.get("query") ?? "";
|
||||
|
||||
if (query.length < 3) {
|
||||
setIsLoading(false);
|
||||
setShowTypingMessage(true);
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowTypingMessage(false);
|
||||
window.electron
|
||||
.searchGames(query)
|
||||
.then((results) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
setSearchResults(results);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
debouncedFunc.current();
|
||||
}, [searchParams, dispatch]);
|
||||
|
||||
const noResultsContent = () => {
|
||||
if (isLoading) return null;
|
||||
|
||||
if (showTypingMessage) {
|
||||
return (
|
||||
<div className={styles.noResults}>
|
||||
<SearchIcon size={56} />
|
||||
|
||||
<p>{t("start_typing")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
return (
|
||||
<div className={styles.noResults}>
|
||||
<InboxIcon size={56} />
|
||||
|
||||
<p>{t("no_results")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section className={styles.content}>
|
||||
<section className={styles.cards}>
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))}
|
||||
|
||||
{!isLoading && searchResults.length > 0 && (
|
||||
<>
|
||||
{searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectId}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{noResultsContent()}
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
@ -2,7 +2,7 @@ import { userProfileContext } from "@renderer/context";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import * as styles from "./profile-content.css";
|
||||
import { Avatar, Link } from "@renderer/components";
|
||||
|
||||
@ -13,6 +13,21 @@ export function FriendsBox() {
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
|
||||
if (game.iconUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={game.title}
|
||||
width={16}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={game.iconUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SteamLogo width={16} height={16} />;
|
||||
};
|
||||
|
||||
if (!userProfile?.friends.length) return null;
|
||||
|
||||
return (
|
||||
@ -27,7 +42,14 @@ export function FriendsBox() {
|
||||
<div className={styles.box}>
|
||||
<ul className={styles.list}>
|
||||
{userProfile?.friends.map((friend) => (
|
||||
<li key={friend.id}>
|
||||
<li
|
||||
key={friend.id}
|
||||
title={
|
||||
friend.currentGame
|
||||
? t("playing", { game: friend.currentGame.title })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link to={`/profile/${friend.id}`} className={styles.listItem}>
|
||||
<Avatar
|
||||
size={32}
|
||||
@ -35,7 +57,19 @@ export function FriendsBox() {
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
|
||||
<span className={styles.friendName}>{friend.displayName}</span>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<span className={styles.friendName}>
|
||||
{friend.displayName}
|
||||
</span>
|
||||
{friend.currentGame && (
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{getGameImage(friend.currentGame)}
|
||||
<small>{friend.currentGame.title}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
@ -105,6 +105,22 @@ export const listItem = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const statsListItem = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "all ease 0.1s",
|
||||
color: vars.color.muted,
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
textDecoration: "none",
|
||||
},
|
||||
});
|
||||
|
||||
export const gamesGrid = style({
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
@ -203,3 +219,12 @@ export const achievementsProgressBar = style({
|
||||
borderRadius: "4px",
|
||||
},
|
||||
});
|
||||
|
||||
export const link = style({
|
||||
textAlign: "start",
|
||||
color: vars.color.body,
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
|
@ -21,6 +21,8 @@ import {
|
||||
formatDownloadProgress,
|
||||
} from "@renderer/helpers";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { UserStatsBox } from "./user-stats-box";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
|
||||
export function ProfileContent() {
|
||||
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
||||
@ -135,6 +137,7 @@ export function ProfileContent() {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
}}
|
||||
title={game.title}
|
||||
className={styles.game}
|
||||
>
|
||||
<button
|
||||
@ -155,7 +158,7 @@ export function ProfileContent() {
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
background:
|
||||
"linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%)",
|
||||
"linear-gradient(0deg, rgba(0, 0, 0, 0.75) 25%, transparent 100%)",
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
@ -185,6 +188,22 @@ export function ProfileContent() {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{game.achievementsPointsEarnedSum > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
gap: 8,
|
||||
marginBottom: 4,
|
||||
color: vars.color.muted,
|
||||
}}
|
||||
>
|
||||
<HydraIcon width={16} height={16} />
|
||||
{numberFormatter.format(
|
||||
game.achievementsPointsEarnedSum
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@ -249,6 +268,7 @@ export function ProfileContent() {
|
||||
|
||||
{shouldShowRightContent && (
|
||||
<div className={styles.rightContent}>
|
||||
<UserStatsBox />
|
||||
<RecentGamesBox />
|
||||
<FriendsBox />
|
||||
|
||||
|
@ -0,0 +1,125 @@
|
||||
import * as styles from "./profile-content.css";
|
||||
import { useCallback, useContext } from "react";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
|
||||
export function UserStatsBox() {
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
const { userStats, isMe } = useContext(userProfileContext);
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInSeconds: number) => {
|
||||
const seconds = playTimeInSeconds;
|
||||
const minutes = seconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
},
|
||||
[numberFormatter, t]
|
||||
);
|
||||
|
||||
if (!userStats) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>{t("stats")}</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.box}>
|
||||
<ul className={styles.list}>
|
||||
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
|
||||
<li className={styles.statsListItem}>
|
||||
<h3 className={styles.listItemTitle}>
|
||||
{t("achievements_unlocked")}
|
||||
</h3>
|
||||
{userStats.unlockedAchievementSum !== undefined ? (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<p className={styles.listItemDescription}>
|
||||
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
|
||||
{t("achievements")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showHydraCloudModal("achievements")}
|
||||
className={styles.link}
|
||||
>
|
||||
<small style={{ color: vars.color.warning }}>
|
||||
{t("show_achievements_on_profile")}
|
||||
</small>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
|
||||
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
|
||||
<li className={styles.statsListItem}>
|
||||
<h3 className={styles.listItemTitle}>{t("earned_points")}</h3>
|
||||
{userStats.achievementsPointsEarnedSum !== undefined ? (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<p className={styles.listItemDescription}>
|
||||
<HydraIcon width={20} height={20} />
|
||||
{numberFormatter.format(
|
||||
userStats.achievementsPointsEarnedSum.value
|
||||
)}
|
||||
</p>
|
||||
<p title={t("ranking_updated_weekly")}>
|
||||
{t("top_percentile", {
|
||||
percentile:
|
||||
userStats.achievementsPointsEarnedSum.topPercentile,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showHydraCloudModal("achievements-points")}
|
||||
className={styles.link}
|
||||
>
|
||||
<small style={{ color: vars.color.warning }}>
|
||||
{t("show_points_on_profile")}
|
||||
</small>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
|
||||
<li className={styles.statsListItem}>
|
||||
<h3 className={styles.listItemTitle}>{t("total_play_time")}</h3>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<p className={styles.listItemDescription}>
|
||||
<ClockIcon />
|
||||
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
|
||||
</p>
|
||||
<p title={t("ranking_updated_weekly")}>
|
||||
{t("top_percentile", {
|
||||
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -100,17 +100,30 @@ export function AddDownloadSourceModal({
|
||||
}
|
||||
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
||||
|
||||
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);
|
||||
|
||||
if (validationResult) {
|
||||
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();
|
||||
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
@ -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 (
|
||||
<>
|
||||
<AddDownloadSourceModal
|
||||
@ -146,12 +160,11 @@ export function SettingsDownloadSources() {
|
||||
<Badge>{statusTitle[downloadSource.status]}</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navigateToCatalogueButton}
|
||||
disabled={!downloadSource.fingerprint}
|
||||
onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
|
||||
>
|
||||
<small>
|
||||
{t("download_count", {
|
||||
@ -160,7 +173,7 @@ export function SettingsDownloadSources() {
|
||||
downloadSource.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
|
@ -0,0 +1,79 @@
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const friendListDisplayName = style({
|
||||
fontWeight: "bold",
|
||||
fontSize: vars.size.body,
|
||||
textAlign: "left",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const friendListContainer = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
alignItems: "center",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
width: "100%",
|
||||
height: "54px",
|
||||
minHeight: "54px",
|
||||
transition: "all ease 0.2s",
|
||||
position: "relative",
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const friendListButton = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
position: "absolute",
|
||||
cursor: "pointer",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
color: vars.color.body,
|
||||
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
||||
padding: `0 ${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const friendRequestItem = style({
|
||||
color: vars.color.body,
|
||||
":hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
});
|
||||
|
||||
export const acceptRequestButton = style({
|
||||
cursor: "pointer",
|
||||
color: vars.color.body,
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
":hover": {
|
||||
color: vars.color.success,
|
||||
},
|
||||
});
|
||||
|
||||
export const cancelRequestButton = style({
|
||||
cursor: "pointer",
|
||||
color: vars.color.body,
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
":hover": {
|
||||
color: vars.color.danger,
|
||||
},
|
||||
});
|
||||
|
||||
export const friendCodeButton = style({
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
alignItems: "center",
|
||||
transition: "all ease 0.2s",
|
||||
":hover": {
|
||||
color: vars.color.muted,
|
||||
},
|
||||
});
|
@ -0,0 +1,38 @@
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface HydraCloudModalProps {
|
||||
feature: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const HydraCloudModal = ({
|
||||
feature,
|
||||
visible,
|
||||
onClose,
|
||||
}: HydraCloudModalProps) => {
|
||||
const { t } = useTranslation("hydra_cloud");
|
||||
|
||||
const handleClickOpenCheckout = () => {
|
||||
window.electron.openCheckout();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={t("hydra_cloud")} onClose={onClose}>
|
||||
<div
|
||||
data-hydra-cloud-feature={feature}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "500px",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{t("hydra_cloud_feature_found")}
|
||||
<Button onClick={handleClickOpenCheckout}>{t("learn_more")}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|