diff --git a/package.json b/package.json index ee039574..e2fec5ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.3", + "version": "3.7.4", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -75,6 +75,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", + "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.4.0", "react-redux": "^9.1.1", "react-router-dom": "^6.22.3", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 060b891f..5084a4a0 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "settings": "Settings", "my_library": "My library", @@ -92,8 +93,10 @@ }, "header": { "search": "Search games", + "search_library": "Search library", "home": "Home", "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "search_results": "Search results", "settings": "Settings", @@ -556,6 +559,15 @@ "platinum": "Platinum", "hidden": "Hidden", "test_notification": "Test notification", + "achievement_sound_volume": "Achievement sound volume", + "select_achievement_sound": "Select achievement sound", + "change_achievement_sound": "Change achievement sound", + "remove_achievement_sound": "Remove achievement sound", + "preview_sound": "Preview sound", + "select": "Select", + "preview": "Preview", + "remove": "Remove", + "no_sound_file_selected": "No sound file selected", "notification_preview": "Achievement Notification Preview", "enable_friend_start_game_notifications": "When a friend starts playing a game", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", @@ -694,7 +706,31 @@ "game_added_to_pinned": "Game added to pinned", "karma": "Karma", "karma_count": "karma", - "karma_description": "Earned from positive likes on reviews" + "karma_description": "Earned from positive likes on reviews", + "user_reviews": "Reviews", + "delete_review": "Delete Review", + "loading_reviews": "Loading reviews..." + }, + "library": { + "library": "Library", + "play": "Play", + "download": "Download", + "downloading": "Downloading", + "game": "game", + "games": "games", + "grid_view": "Grid view", + "compact_view": "Compact view", + "large_view": "Large view", + "no_games_title": "Your library is empty", + "no_games_description": "Add games from the catalogue or download them to get started", + "amount_hours": "{{amount}} hours", + "amount_minutes": "{{amount}} minutes", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "This playtime has been manually updated", + "all_games": "All Games", + "recently_played": "Recently Played", + "favorites": "Favorites" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index adf25e33..12e7e7fe 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catálogo", + "library": "Librería", "downloads": "Descargas", "settings": "Ajustes", "my_library": "Mi Librería", @@ -325,6 +326,7 @@ "maybe_later": "Tal vez después", "no_repacks_found": "Sin fuentes encontradas para este juego", "no_reviews_yet": "Sin reseñas aún", + "review_played_for": "Jugado por", "properties": "Propiedades", "rating": "Calificación", "rating_count": "Calificación", @@ -541,6 +543,12 @@ "platinum": "Platino", "hidden": "Oculto", "test_notification": "Probar notificación", + "achievement_sound_volume": "Volumen del sonido de logro", + "select_achievement_sound": "Seleccionar sonido de logro", + "select": "Seleccionar", + "preview": "Vista previa", + "remove": "Remover", + "no_sound_file_selected": "No se seleccionó ningún archivo de sonido", "notification_preview": "Probar notificación de logro", "debrid": "Debrid", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", @@ -681,7 +689,11 @@ "karma_count": "karma", "karma_description": "Conseguido por me gustas positivos en reseñas", "sort_by": "Filtrar por:", - "game_added_to_pinned": "Juego añadido a fijados" + "game_added_to_pinned": "Juego añadido a fijados", + "user_reviews": "Reseñas", + "loading_reviews": "Cargando reseñas...", + "no_reviews": "Sin reseñas aún", + "delete_review": "Eliminar reseña" }, "achievement": { "achievement_unlocked": "Logro desbloqueado", @@ -711,5 +723,26 @@ "hydra_cloud_feature_found": "¡Acabas de descubrir una característica de Hydra Cloud!", "learn_more": "Descubrir más", "debrid_description": "Descargas hasta x4 veces más rápidas con Nimbus" + }, + "library": { + "library": "Librería", + "play": "Jugar", + "download": "Descargar", + "downloading": "Descargando", + "game": "juego", + "games": "juegos", + "grid_view": "Vista de cuadrícula", + "compact_view": "Vista compacta", + "large_view": "Vista grande", + "no_games_title": "Tu librería está vacía", + "no_games_description": "Agregá juegos del catálogo o descargalos para comenzar", + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "Este tiempo de juego ha sido modificado manualmente", + "all_games": "Todos los Juegos", + "recently_played": "Jugados Recientemente", + "favorites": "Favoritos" } } diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 42743a64..fc0f4332 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catálogo", + "library": "Biblioteca", "downloads": "Downloads", "settings": "Ajustes", "my_library": "Biblioteca", @@ -317,6 +318,7 @@ "sort_lowest_score": "Menor Nota", "sort_most_voted": "Mais Votadas", "no_reviews_yet": "Ainda não há avaliações", + "review_played_for": "Jogado por", "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!", "rating": "Avaliação", "rating_stats": "Avaliação", @@ -540,6 +542,12 @@ "platinum": "Platina", "hidden": "Oculta", "test_notification": "Testar notificação", + "achievement_sound_volume": "Volume do som de conquista", + "select_achievement_sound": "Selecionar som de conquista", + "select": "Selecionar", + "preview": "Reproduzir", + "remove": "Remover", + "no_sound_file_selected": "Nenhum arquivo de som selecionado", "notification_preview": "Prévia da Notificação de Conquistas", "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", @@ -696,7 +704,11 @@ "karma": "Karma", "karma_count": "karma", "karma_description": "Ganho a partir de curtidas positivas em avaliações", - "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente" + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "user_reviews": "Avaliações", + "loading_reviews": "Carregando avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Excluir avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", @@ -726,5 +738,26 @@ "hydra_cloud_feature_found": "Você descobriu uma funcionalidade Hydra Cloud!", "learn_more": "Saiba mais", "debrid_description": "Baixe até 4x mais rápido com Nimbus" + }, + "library": { + "library": "Biblioteca", + "play": "Jogar", + "download": "Baixar", + "downloading": "Baixando", + "game": "jogo", + "games": "jogos", + "grid_view": "Visualização em grade", + "compact_view": "Visualização compacta", + "large_view": "Visualização grande", + "no_games_title": "Sua biblioteca está vazia", + "no_games_description": "Adicione jogos do catálogo ou baixe-os para começar", + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "all_games": "Todos os Jogos", + "recently_played": "Jogados Recentemente", + "favorites": "Favoritos" } } diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 6c1963cc..c8e4586d 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -183,7 +183,8 @@ "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", "review_from_blocked_user": "Avaliação de utilizador bloqueado", "show": "Mostrar", - "hide": "Ocultar" + "hide": "Ocultar", + "review_played_for": "Jogado por" }, "activation": { "title": "Ativação", @@ -469,7 +470,11 @@ "achievements_unlocked": "Conquistas desbloqueadas", "earned_points": "Pontos ganhos", "show_achievements_on_profile": "Mostre as suas conquistas no perfil", - "show_points_on_profile": "Mostre os seus pontos ganhos no perfil" + "show_points_on_profile": "Mostre os seus pontos ganhos no perfil", + "user_reviews": "Avaliações", + "loading_reviews": "A carregar avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Eliminar avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 6f4d4b92..c9527af8 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Каталог", + "library": "Библиотека", "downloads": "Загрузки", "settings": "Настройки", "my_library": "Библиотека", @@ -227,6 +228,7 @@ "write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "sort_newest": "Сначала новые", "no_reviews_yet": "Пока нет отзывов", + "review_played_for": "Играли", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "sort_oldest": "Сначала старые", "sort_highest_score": "Высший балл", @@ -554,6 +556,12 @@ "platinum": "Платиновый", "hidden": "Скрытый", "test_notification": "Тестовое уведомление", + "achievement_sound_volume": "Громкость звука достижения", + "select_achievement_sound": "Выбрать звук достижения", + "select": "Выбрать", + "preview": "Предпросмотр", + "remove": "Удалить", + "no_sound_file_selected": "Файл звука не выбран", "notification_preview": "Предварительный просмотр уведомления о достижении", "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", @@ -692,7 +700,11 @@ "game_added_to_pinned": "Игра добавлена в закрепленные", "karma": "Карма", "karma_count": "карма", - "karma_description": "Заработана положительными оценками отзывов" + "karma_description": "Заработана положительными оценками отзывов", + "user_reviews": "Отзывы", + "loading_reviews": "Загрузка отзывов...", + "no_reviews": "Пока нет отзывов", + "delete_review": "Удалить отзыв" }, "achievement": { "achievement_unlocked": "Достижение разблокировано", @@ -722,5 +734,26 @@ "hydra_cloud_feature_found": "Вы только что открыли для себя функцию Hydra Cloud!", "learn_more": "Подробнее", "debrid_description": "Скачивайте в 4 раза быстрее с Nimbus" + }, + "library": { + "library": "Библиотека", + "play": "Играть", + "download": "Скачать", + "downloading": "Скачивание", + "game": "игра", + "games": "игры", + "grid_view": "Вид сетки", + "compact_view": "Компактный вид", + "large_view": "Большой вид", + "no_games_title": "Ваша библиотека пуста", + "no_games_description": "Добавьте игры из каталога или скачайте их, чтобы начать", + "amount_hours": "{{amount}} часов", + "amount_minutes": "{{amount}} минут", + "amount_hours_short": "{{amount}}ч", + "amount_minutes_short": "{{amount}}м", + "manual_playtime_tooltip": "Время игры было обновлено вручную", + "all_games": "Все игры", + "recently_played": "Недавно сыгранные", + "favorites": "Избранное" } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 7cdd0c92..bfc353d9 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -27,7 +27,68 @@ "friends": "好友", "favorites": "收藏", "need_help": "需要帮助?", - "playable_button_title": "仅显示现在可以游玩的游戏" + "playable_button_title": "仅显示现在可以游玩的游戏", + "add_custom_game_tooltip": "添加自定义游戏", + "cancel": "取消", + "confirm": "确认", + "custom_game_modal": "添加自定义游戏", + "custom_game_modal_add": "添加游戏", + "custom_game_modal_adding": "正在添加游戏...", + "custom_game_modal_browse": "浏览", + "custom_game_modal_cancel": "取消", + "custom_game_modal_description": "通过选择可执行文件将自定义游戏添加到您的库中", + "custom_game_modal_enter_title": "输入标题", + "custom_game_modal_executable": "可执行文件", + "custom_game_modal_executable_path": "可执行文件路径", + "custom_game_modal_failed": "添加自定义游戏失败", + "custom_game_modal_select_executable": "选择可执行文件", + "custom_game_modal_success": "自定义游戏添加成功", + "custom_game_modal_title": "标题", + "decky_plugin_installation_error": "安装 Decky 插件出错: {{error}}", + "decky_plugin_installation_failed": "Decky 插件安装失败: {{error}}", + "decky_plugin_installed": "Decky 插件 v{{version}} 安装成功", + "decky_plugin_installed_version": "Decky 插件 (v{{version}})", + "edit_game_modal": "自定义资源", + "edit_game_modal_assets": "资源", + "edit_game_modal_browse": "浏览", + "edit_game_modal_cancel": "取消", + "edit_game_modal_description": "自定义游戏资源和详情", + "edit_game_modal_drop_hero_image_here": "拖放主图像到此处", + "edit_game_modal_drop_icon_image_here": "拖放图标到此处", + "edit_game_modal_drop_logo_image_here": "拖放Logo到此处", + "edit_game_modal_drop_to_replace_hero": "拖放以替换主图像", + "edit_game_modal_drop_to_replace_icon": "拖放以替换图标", + "edit_game_modal_drop_to_replace_logo": "拖放以替换Logo", + "edit_game_modal_enter_title": "输入标题", + "edit_game_modal_failed": "资源更新失败", + "edit_game_modal_fill_required": "请填写所有必填项", + "edit_game_modal_hero": "库主图", + "edit_game_modal_hero_preview": "库主图预览", + "edit_game_modal_hero_resolution": "推荐分辨率: 1920x620px", + "edit_game_modal_icon": "图标", + "edit_game_modal_icon_preview": "图标预览", + "edit_game_modal_icon_resolution": "推荐分辨率: 256x256px", + "edit_game_modal_image": "图片", + "edit_game_modal_image_filter": "图片", + "edit_game_modal_image_preview": "图片预览", + "edit_game_modal_logo": "Logo", + "edit_game_modal_logo_preview": "Logo预览", + "edit_game_modal_logo_resolution": "推荐分辨率: 640x360px", + "edit_game_modal_select_hero": "选择库主图", + "edit_game_modal_select_icon": "选择图标", + "edit_game_modal_select_image": "选择图片", + "edit_game_modal_select_logo": "选择Logo", + "edit_game_modal_success": "资源更新成功", + "edit_game_modal_title": "标题", + "edit_game_modal_update": "更新", + "edit_game_modal_updating": "正在更新...", + "install_decky_plugin": "安装 Decky 插件", + "install_decky_plugin_message": "这将下载并安装 Hydra 的 Decky Loader 插件。可能需要提升权限。继续吗?", + "install_decky_plugin_title": "安装 Hydra Decky 插件", + "show_playable_only_tooltip": "仅显示可游玩", + "update_decky_plugin": "更新 Decky 插件", + "update_decky_plugin_message": "有新版本的 Hydra Decky 插件可用。现在要更新吗?", + "update_decky_plugin_title": "更新 Hydra Decky 插件" }, "header": { "search": "搜索游戏", @@ -218,7 +279,93 @@ "reset_achievements_title": "您确定吗?", "save_changes": "保存更改", "unfreeze_backup": "取消固定", - "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改" + "you_might_need_to_restart_steam": "您可能需要重启Steam才能看到更改", + "add_to_favorites": "添加到收藏", + "already_in_library": "已在游戏库中", + "audio": "音频", + "backup_failed": "备份失败", + "be_first_to_review": "成为第一个分享游戏感受的人!", + "caption": "标题", + "create_shortcut_simple": "创建快捷方式", + "currency_country": "zh", + "currency_symbol": "¥", + "delete_review": "删除评价", + "delete_review_modal_cancel_button": "取消", + "delete_review_modal_delete_button": "删除", + "delete_review_modal_description": "此操作无法撤销。", + "delete_review_modal_title": "确定要删除您的评价吗?", + "edit_game_modal_button": "自定义游戏资源", + "failed_remove_files": "文件删除失败", + "failed_remove_from_library": "移出游戏库失败", + "failed_update_favorites": "收藏更新失败", + "files_removed_success": "文件已成功删除", + "filter_by_source": "按来源筛选", + "game_added_to_pinned": "游戏已添加到置顶", + "game_details": "游戏详情", + "game_removed_from_library": "游戏已从库中移除", + "game_removed_from_pinned": "游戏已从置顶移除", + "hide": "隐藏", + "hide_original": "隐藏原文", + "historical_keyshop": "历史密钥商店", + "historical_retail": "历史零售", + "keyshop_price": "密钥商店价格", + "language": "语言", + "leave_a_review": "留下评价", + "load_more_reviews": "加载更多评价", + "loading_more_reviews": "正在加载更多评价...", + "loading_reviews": "正在加载评价...", + "manual_playtime_tooltip": "该游戏时长已手动更新", + "manual_playtime_warning": "您的游戏时长将被标记为手动更新,且无法撤销。", + "maybe_later": "以后再说", + "no_prices_found": "未找到价格信息", + "no_repacks_found": "未找到该游戏的下载来源", + "no_reviews_yet": "暂无评价", + "prices": "价格", + "properties": "属性", + "rating": "评分", + "rating_count": "评分数", + "rating_negative": "差评", + "rating_neutral": "中性", + "rating_positive": "好评", + "rating_stats": "评分统计", + "rating_very_negative": "极差", + "rating_very_positive": "极好", + "remove_from_favorites": "移出收藏", + "remove_review": "移除评价", + "retail_price": "零售价格", + "review_cannot_be_empty": "评价内容不能为空。", + "review_deleted_successfully": "评价已成功删除。", + "review_deletion_failed": "评价删除失败,请重试。", + "review_from_blocked_user": "来自被屏蔽用户的评价", + "review_played_for": "已游玩", + "review_submission_failed": "评价提交失败,请重试。", + "review_submitted_successfully": "评价提交成功!", + "reviews": "评价", + "show": "显示", + "show_less": "收起", + "show_more": "展开", + "show_original": "显示原文", + "show_original_translated_from": "显示原文(由{{language}}翻译)", + "show_translation": "显示翻译", + "sort_highest_score": "最高分", + "sort_lowest_score": "最低分", + "sort_most_voted": "最多投票", + "sort_newest": "最新", + "sort_oldest": "最旧", + "submit_review": "提交", + "submitting": "正在提交...", + "update_game_playtime": "更新游戏时长", + "update_playtime": "更新时长", + "update_playtime_description": "手动更新 {{game}} 的游玩时长", + "update_playtime_error": "游戏时长更新失败", + "update_playtime_success": "游戏时长已成功更新", + "update_playtime_title": "更新游戏时长", + "view_all_prices": "点击查看所有价格", + "vote_failed": "投票失败,请重试。", + "would_you_recommend_this_game": "您想为此游戏留下评价吗?", + "write_review_placeholder": "分享您对本游戏的看法...", + "yes": "是", + "you_seemed_to_enjoy_this_game": "您似乎很喜欢这款游戏" }, "activation": { "title": "激活 Hydra", @@ -394,7 +541,24 @@ "update_email": "更新邮箱", "update_password": "更新密码", "variation": "变体", - "web_store": "网络商店" + "web_store": "网络商店", + "adding": "添加中…", + "autoplay_trailers_on_game_page": "在游戏页面自动播放预告片", + "debrid": "Debrid下载服务", + "debrid_description": "Debrid服务是一种高级不限速下载器,可让您以最快的网速下载托管在各类网盘上的文件,仅受您的网络速度限制。", + "download_source_already_exists": "该下载源URL已存在。", + "download_source_failed": "出错", + "download_source_matched": "已更新", + "download_source_matching": "正在更新", + "download_source_no_information": "暂无信息", + "download_source_pending_matching": "即将更新", + "download_sources_synced_successfully": "所有下载源已同步", + "enable_steam_achievements": "启用Steam成就搜索", + "failed_add_download_source": "添加下载源失败,请重试。", + "hide_to_tray_on_game_start": "启动游戏时隐藏到托盘", + "hydra_cloud": "Hydra Cloud", + "importing": "导入中…", + "removed_all_download_sources": "已移除所有下载源" }, "notifications": { "download_complete": "下载完成", @@ -421,7 +585,8 @@ "game_card": { "no_downloads": "无可用下载选项", "available_one": "可用", - "available_other": "可用" + "available_other": "可用", + "calculating": "正在计算" }, "binary_not_found_modal": { "title": "程序未安装", @@ -515,7 +680,23 @@ "show_achievements_on_profile": "在您的个人资料上显示成就", "show_points_on_profile": "在您的个人资料上显示获得的积分", "stats": "统计", - "top_percentile": "前 {{percentile}}%" + "top_percentile": "前 {{percentile}}%", + "achievements_earned": "已获得成就", + "amount_hours_short": "{{amount}}小时", + "amount_minutes_short": "{{amount}}分钟", + "delete_review": "删除评价", + "game_added_to_pinned": "游戏已添加到置顶", + "game_removed_from_pinned": "游戏已从置顶移除", + "karma": "业力", + "karma_count": "业力值", + "karma_description": "通过评论获得的点赞", + "loading_reviews": "正在加载评价...", + "manual_playtime_tooltip": "该游戏时长已手动更新", + "pinned": "已置顶", + "played_recently": "最近游玩", + "playtime": "游戏时长", + "sort_by": "排序方式:", + "user_reviews": "用户评价" }, "achievement": { "achievement_unlocked": "成就已解锁", diff --git a/src/main/constants.ts b/src/main/constants.ts index 82b99b2a..3c4c10e5 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); +export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes"); + export const MAIN_LOOP_INTERVAL = 2000; +export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15; + export const DECKY_PLUGINS_LOCATION = path.join( SystemPath.getPath("home"), "homebrew", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index d75f8255..2720d3ce 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -18,6 +18,7 @@ import "./library/close-game"; import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; import "./library/get-library"; +import "./library/refresh-library-assets"; import "./library/extract-game-download"; import "./library/clear-new-download-options"; import "./library/open-game"; @@ -94,6 +95,11 @@ import "./themes/get-custom-theme-by-id"; import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; +import "./themes/copy-theme-achievement-sound"; +import "./themes/remove-theme-achievement-sound"; +import "./themes/get-theme-sound-path"; +import "./themes/get-theme-sound-data-url"; +import "./themes/import-theme-sound-from-store"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 6314f83d..f62c60e7 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -4,6 +4,7 @@ import { downloadsSublevel, gamesShopAssetsSublevel, gamesSublevel, + gameAchievementsSublevel, } from "@main/level"; const getLibrary = async (): Promise => { @@ -18,14 +19,32 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + let unlockedAchievementCount = 0; + let achievementCount = 0; + + try { + const achievements = await gameAchievementsSublevel.get(key); + if (achievements) { + achievementCount = achievements.achievements.length; + unlockedAchievementCount = + achievements.unlockedAchievements.length; + } + } catch { + // No achievements data for this game + } + return { id: key, ...game, download: download ?? null, + unlockedAchievementCount, + achievementCount, + // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, - // Ensure compatibility with LibraryGame type - libraryHeroImageUrl: - game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + // Preserve custom image URLs from game if they exist + customIconUrl: game.customIconUrl, + customLogoImageUrl: game.customLogoImageUrl, + customHeroImageUrl: game.customHeroImageUrl, } as LibraryGame; }) ); diff --git a/src/main/events/library/refresh-library-assets.ts b/src/main/events/library/refresh-library-assets.ts new file mode 100644 index 00000000..d8578f1b --- /dev/null +++ b/src/main/events/library/refresh-library-assets.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { mergeWithRemoteGames } from "@main/services"; + +const refreshLibraryAssets = async () => { + await mergeWithRemoteGames(); +}; + +registerEvent("refreshLibraryAssets", refreshLibraryAssets); diff --git a/src/main/events/themes/copy-theme-achievement-sound.ts b/src/main/events/themes/copy-theme-achievement-sound.ts new file mode 100644 index 00000000..a52e6269 --- /dev/null +++ b/src/main/events/themes/copy-theme-achievement-sound.ts @@ -0,0 +1,40 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; + +const copyThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + sourcePath: string +): Promise => { + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error("Source file does not exist"); + } + + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId, theme.name); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const fileExtension = path.extname(sourcePath); + const destinationPath = path.join(themeDir, `achievement${fileExtension}`); + + await fs.promises.copyFile(sourcePath, destinationPath); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + originalSoundPath: sourcePath, + updatedAt: new Date(), + }); +}; + +registerEvent("copyThemeAchievementSound", copyThemeAchievementSound); diff --git a/src/main/events/themes/get-theme-sound-data-url.ts b/src/main/events/themes/get-theme-sound-data-url.ts new file mode 100644 index 00000000..a93538dd --- /dev/null +++ b/src/main/events/themes/get-theme-sound-data-url.ts @@ -0,0 +1,40 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import fs from "node:fs"; +import path from "node:path"; +import { logger } from "@main/services"; + +const getThemeSoundDataUrl = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + try { + const theme = await themesSublevel.get(themeId); + const soundPath = getThemeSoundPath(themeId, theme?.name); + + if (!soundPath || !fs.existsSync(soundPath)) { + return null; + } + + const buffer = await fs.promises.readFile(soundPath); + const ext = path.extname(soundPath).toLowerCase().slice(1); + + const mimeTypes: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + m4a: "audio/mp4", + }; + + const mimeType = mimeTypes[ext] || "audio/mpeg"; + const base64 = buffer.toString("base64"); + + return `data:${mimeType};base64,${base64}`; + } catch (error) { + logger.error("Failed to get theme sound data URL", error); + return null; + } +}; + +registerEvent("getThemeSoundDataUrl", getThemeSoundDataUrl); diff --git a/src/main/events/themes/get-theme-sound-path.ts b/src/main/events/themes/get-theme-sound-path.ts new file mode 100644 index 00000000..11658c6a --- /dev/null +++ b/src/main/events/themes/get-theme-sound-path.ts @@ -0,0 +1,13 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; + +const getThemeSoundPathEvent = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + const theme = await themesSublevel.get(themeId); + return getThemeSoundPath(themeId, theme?.name); +}; + +registerEvent("getThemeSoundPath", getThemeSoundPathEvent); diff --git a/src/main/events/themes/import-theme-sound-from-store.ts b/src/main/events/themes/import-theme-sound-from-store.ts new file mode 100644 index 00000000..66da6cb3 --- /dev/null +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -0,0 +1,60 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import axios from "axios"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import { logger } from "@main/services"; + +const importThemeSoundFromStore = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + themeName: string, + storeUrl: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + try { + const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`; + + const response = await axios.get(soundUrl, { + responseType: "arraybuffer", + timeout: 10000, + }); + + const themeDir = getThemePath(themeId, theme.name); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const destinationPath = path.join(themeDir, `achievement.${format}`); + await fs.promises.writeFile(destinationPath, response.data); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + updatedAt: new Date(), + }); + + logger.log(`Successfully imported sound for theme ${themeName}`); + return; + } catch (error) { + logger.error( + `Failed to import ${format} sound for theme ${themeName}`, + error + ); + continue; + } + } + + logger.log(`No sound file found for theme ${themeName} in store`); +}; + +registerEvent("importThemeSoundFromStore", importThemeSoundFromStore); diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts new file mode 100644 index 00000000..a8603426 --- /dev/null +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -0,0 +1,48 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import { THEMES_PATH } from "@main/constants"; +import path from "node:path"; + +const removeThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId, theme.name); + const legacyThemeDir = path.join(THEMES_PATH, themeId); + + const removeFromDir = async (dir: string) => { + if (!fs.existsSync(dir)) { + return; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + await fs.promises.unlink(soundPath); + } + } + }; + + await removeFromDir(themeDir); + if (themeDir !== legacyThemeDir) { + await removeFromDir(legacyThemeDir); + } + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: false, + originalSoundPath: undefined, + updatedAt: new Date(), + }); +}; + +registerEvent("removeThemeAchievementSound", removeThemeAchievementSound); diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 2da49a1c..664dbd78 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -2,6 +2,8 @@ import axios from "axios"; import { JSDOM } from "jsdom"; import UserAgent from "user-agents"; import path from "node:path"; +import fs from "node:fs"; +import { THEMES_PATH } from "@main/constants"; export const getFileBuffer = async (url: string) => fetch(url, { method: "GET" }).then((response) => @@ -31,9 +33,64 @@ export const isPortableVersion = () => { }; export const normalizePath = (str: string) => - path.posix.normalize(str).replace(/\\/g, "/"); + path.posix.normalize(str).replaceAll("\\", "/"); export const addTrailingSlash = (str: string) => str.endsWith("/") ? str : `${str}/`; +const sanitizeFolderName = (name: string): string => { + return name + .toLowerCase() + .replaceAll(/[^a-z0-9-_\s]/g, "") + .replaceAll(/\s+/g, "-") + .replaceAll(/-+/g, "-") + .replaceAll(/(^-|-$)/g, ""); +}; + +export const getThemePath = (themeId: string, themeName?: string): string => { + if (themeName) { + const sanitizedName = sanitizeFolderName(themeName); + if (sanitizedName) { + return path.join(THEMES_PATH, sanitizedName); + } + } + return path.join(THEMES_PATH, themeId); +}; + +export const getThemeSoundPath = ( + themeId: string, + themeName?: string +): string | null => { + const themeDir = getThemePath(themeId, themeName); + const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null; + + const checkDir = (dir: string): string | null => { + if (!fs.existsSync(dir)) { + return null; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + return soundPath; + } + } + + return null; + }; + + const soundPath = checkDir(themeDir); + if (soundPath) { + return soundPath; + } + + if (legacyThemeDir) { + return checkDir(legacyThemeDir); + } + + return null; +}; + export * from "./reg-parser"; diff --git a/src/main/main.ts b/src/main/main.ts index c6d54bc7..1cadcebd 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -17,6 +17,7 @@ import { Lock, DeckyPlugin, DownloadSourcesChecker, + WSClient, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -60,6 +61,7 @@ export const loadState = async () => { // Check for new download options on startup DownloadSourcesChecker.checkForChanges(); + WSClient.connect(); }); const downloads = await downloadsSublevel diff --git a/src/main/services/hosters/datanodes.ts b/src/main/services/hosters/datanodes.ts index 29708322..4cfb5242 100644 --- a/src/main/services/hosters/datanodes.ts +++ b/src/main/services/hosters/datanodes.ts @@ -1,6 +1,7 @@ import axios, { AxiosResponse } from "axios"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; +import { logger } from "@main/services"; export class DatanodesApi { private static readonly jar = new CookieJar(); @@ -20,51 +21,42 @@ export class DatanodesApi { await this.jar.setCookie("lang=english;", "https://datanodes.to"); - const payload = new URLSearchParams({ - op: "download2", - id: fileCode, - method_free: "Free Download >>", - dl: "1", - }); + const formData = new FormData(); + formData.append("op", "download2"); + formData.append("id", fileCode); + formData.append("rand", ""); + formData.append("referer", "https://datanodes.to/download"); + formData.append("method_free", "Free Download >>"); + formData.append("method_premium", ""); + formData.append("__dl", "1"); const response: AxiosResponse = await this.session.post( "https://datanodes.to/download", - payload, + formData, { headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0", + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + priority: "u=1, i", + "sec-ch-ua": + '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", Referer: "https://datanodes.to/download", - Origin: "https://datanodes.to", - "Content-Type": "application/x-www-form-urlencoded", }, - maxRedirects: 0, - validateStatus: (status: number) => status === 302 || status < 400, } ); - if (response.status === 302) { - return response.headers["location"]; - } - if (typeof response.data === "object" && response.data.url) { return decodeURIComponent(response.data.url); } - const htmlContent = String(response.data); - if (!htmlContent) { - throw new Error("Empty response received"); - } - - const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/; - const downloadLinkMatch = downloadLinkRegex.exec(htmlContent); - if (downloadLinkMatch) { - return downloadLinkMatch[1]; - } - throw new Error("Failed to get the download link"); } catch (error) { - console.error("Error fetching download URL:", error); + logger.error("Error fetching download URL:", error); throw error; } } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index e7e93268..a5a78e4a 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data"; import { db } from "@main/level"; import { levelKeys } from "@main/level/sublevels"; import type { Auth, User } from "@types"; +import { WSClient } from "./ws"; export interface HydraApiOptions { needsAuth?: boolean; @@ -103,8 +104,8 @@ export class HydraApi { await clearGamesRemoteIds(); uploadGamesBatch(); - // WSClient.close(); - // WSClient.connect(); + WSClient.close(); + WSClient.connect(); const { syncDownloadSourcesFromApi } = await import("./user"); syncDownloadSourcesFromApi(); diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index c00e4961..92cd66d8 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -60,13 +60,20 @@ export const mergeWithRemoteGames = async () => { const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); + // Construct coverImageUrl if not provided by backend (Steam games use predictable pattern) + const coverImageUrl = + game.coverImageUrl || + (game.shop === "steam" + ? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg` + : null); + await gamesShopAssetsSublevel.put(gameKey, { updatedAt: Date.now(), ...localGameShopAsset, shop: game.shop, objectId: game.objectId, title: localGame?.title || game.title, // Preserve local title if it exists - coverImageUrl: game.coverImageUrl, + coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, logoImageUrl: game.logoImageUrl, diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index d28c3cd7..b8ff480c 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -11,9 +11,10 @@ import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; import { WindowManager } from "../window-manager"; import type { Game, UserPreferences, UserProfile } from "@types"; -import { db, levelKeys } from "@main/level"; +import { db, levelKeys, themesSublevel } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; +import { getThemeSoundPath } from "@main/helpers"; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -40,6 +41,27 @@ async function downloadImage(url: string | null) { }); } +async function getAchievementSoundPath(): Promise { + try { + const allThemes = await themesSublevel.values().all(); + const activeTheme = allThemes.find((theme) => theme.isActive); + + if (activeTheme?.hasCustomSound) { + const themeSoundPath = getThemeSoundPath( + activeTheme.id, + activeTheme.name + ); + if (themeSoundPath) { + return themeSoundPath; + } + } + } catch (error) { + logger.error("Failed to get theme sound path", error); + } + + return achievementSoundPath; +} + export const publishDownloadCompleteNotification = async (game: Game) => { const userPreferences = await db.get( levelKeys.userPreferences, @@ -145,7 +167,8 @@ export const publishCombinedNewAchievementNotification = async ( if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; @@ -205,6 +228,7 @@ export const publishNewAchievementNotification = async (info: { if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; diff --git a/src/main/services/steam-250.ts b/src/main/services/steam-250.ts index 5652b0d3..826e528f 100644 --- a/src/main/services/steam-250.ts +++ b/src/main/services/steam-250.ts @@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => { if (!steamGameUrl) return null; return { - title: $title.textContent, + title: $title.getAttribute("data-title") || "", objectId: steamGameUrl.split("/").pop(), } as Steam250Game; }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 69cbb3d4..a2965532 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -202,6 +202,7 @@ contextBridge.exposeInMainWorld("electron", { verifyExecutablePathInUse: (executablePath: string) => ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), + refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), openGameInstaller: (shop: GameShop, objectId: string) => ipcRenderer.invoke("openGameInstaller", shop, objectId), openGameInstallerPath: (shop: GameShop, objectId: string) => @@ -576,6 +577,25 @@ contextBridge.exposeInMainWorld("electron", { getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"), toggleCustomTheme: (themeId: string, isActive: boolean) => ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), + copyThemeAchievementSound: (themeId: string, sourcePath: string) => + ipcRenderer.invoke("copyThemeAchievementSound", themeId, sourcePath), + removeThemeAchievementSound: (themeId: string) => + ipcRenderer.invoke("removeThemeAchievementSound", themeId), + getThemeSoundPath: (themeId: string) => + ipcRenderer.invoke("getThemeSoundPath", themeId), + getThemeSoundDataUrl: (themeId: string) => + ipcRenderer.invoke("getThemeSoundDataUrl", themeId), + importThemeSoundFromStore: ( + themeId: string, + themeName: string, + storeUrl: string + ) => + ipcRenderer.invoke( + "importThemeSoundFromStore", + themeId, + themeName, + storeUrl + ), /* Editor */ openEditorWindow: (themeId: string) => diff --git a/src/renderer/src/app.scss b/src/renderer/src/app.scss index 4c5374e8..0d992553 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -90,6 +90,7 @@ img { progress[value] { -webkit-appearance: none; + appearance: none; } .container { diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 274e95db..391e9c03 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef } from "react"; -import achievementSound from "@renderer/assets/audio/achievement.wav"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -26,7 +25,12 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; -import { injectCustomCss, removeCustomCss } from "./helpers"; +import { + injectCustomCss, + removeCustomCss, + getAchievementSoundUrl, + getAchievementSoundVolume, +} from "./helpers"; import "./app.scss"; export interface AppProps { @@ -220,9 +224,11 @@ export function App() { return () => unsubscribe(); }, [loadAndApplyTheme]); - const playAudio = useCallback(() => { - const audio = new Audio(achievementSound); - audio.volume = 0.2; + const playAudio = useCallback(async () => { + const soundUrl = await getAchievementSoundUrl(); + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; audio.play(); }, []); @@ -283,7 +289,11 @@ export function App() {
-
+
diff --git a/src/renderer/src/components/game-context-menu/game-context-menu.tsx b/src/renderer/src/components/game-context-menu/game-context-menu.tsx index 694012b7..782857a9 100644 --- a/src/renderer/src/components/game-context-menu/game-context-menu.tsx +++ b/src/renderer/src/components/game-context-menu/game-context-menu.tsx @@ -70,8 +70,10 @@ export function GameContextMenu({ onClick: () => { if (isGameRunning) { void handleCloseGame(); - } else { + } else if (canPlay) { void handlePlayGame(); + } else { + handleOpenDownloadOptions(); } }, disabled: isDeleting, diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 6f97729f..d3164ced 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -7,12 +7,13 @@ import { useAppDispatch, useAppSelector } from "@renderer/hooks"; import "./header.scss"; import { AutoUpdateSubHeader } from "./auto-update-sub-header"; -import { setFilters } from "@renderer/features"; +import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import cn from "classnames"; const pathTitle: Record = { "/": "home", "/catalogue": "catalogue", + "/library": "library", "/downloads": "downloads", "/settings": "settings", }; @@ -27,10 +28,20 @@ export function Header() { (state) => state.window ); - const searchValue = useAppSelector( + const catalogueSearchValue = useAppSelector( (state) => state.catalogueSearch.filters.title ); + const librarySearchValue = useAppSelector( + (state) => state.library.searchQuery + ); + + const isOnLibraryPage = location.pathname.startsWith("/library"); + + const searchValue = isOnLibraryPage + ? librarySearchValue + : catalogueSearchValue; + const dispatch = useAppDispatch(); const [isFocused, setIsFocused] = useState(false); @@ -41,6 +52,8 @@ export function Header() { if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle; if (location.pathname.startsWith("/profile")) return headerTitle; + if (location.pathname.startsWith("/library")) + return headerTitle || t("library"); if (location.pathname.startsWith("/search")) return t("search_results"); return t(pathTitle[location.pathname]); @@ -60,18 +73,29 @@ export function Header() { }; const handleSearch = (value: string) => { - dispatch(setFilters({ title: value.slice(0, 255) })); + if (isOnLibraryPage) { + dispatch(setLibrarySearchQuery(value.slice(0, 255))); + } else { + dispatch(setFilters({ title: value.slice(0, 255) })); + if (!location.pathname.startsWith("/catalogue")) { + navigate("/catalogue"); + } + } + }; - if (!location.pathname.startsWith("/catalogue")) { - navigate("/catalogue"); + const handleClearSearch = () => { + if (isOnLibraryPage) { + dispatch(setLibrarySearchQuery("")); + } else { + dispatch(setFilters({ title: "" })); } }; useEffect(() => { - if (!location.pathname.startsWith("/catalogue") && searchValue) { + if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) { dispatch(setFilters({ title: "" })); } - }, [location.pathname, searchValue, dispatch]); + }, [location.pathname, catalogueSearchValue, dispatch]); return ( <> @@ -120,7 +144,7 @@ export function Header() { ref={inputRef} type="text" name="search" - placeholder={t("search")} + placeholder={isOnLibraryPage ? t("search_library") : t("search")} value={searchValue} className="header__search-input" onChange={(event) => handleSearch(event.target.value)} @@ -131,7 +155,7 @@ export function Header() { {searchValue && ( -
+
+
-
-
+
- {Boolean( - review.playTimeInSeconds && review.playTimeInSeconds > 0 - ) && ( -
- - - {t("review_played_for")}{" "} - {formatPlayTime(review.playTimeInSeconds || 0)} - -
- )} + {review.user.displayName || "Anonymous"} +
-
-
- {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true, })}
+
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {t("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
+
void; + allGamesCount: number; + recentlyPlayedCount: number; + favoritesCount: number; +} + +export function FilterOptions({ + filterBy, + onFilterChange, + allGamesCount, + recentlyPlayedCount, + favoritesCount, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
+
+ + {filterBy === "all" && ( + + )} +
+
+ + {filterBy === "recently_played" && ( + + )} +
+
+ + {filterBy === "favorites" && ( + + )} +
+
+ ); +} diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss new file mode 100644 index 00000000..8ac59112 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -0,0 +1,209 @@ +@use "../../scss/globals.scss"; + +.library-game-card-large { + width: 100%; + height: 300px; + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all ease 0.2s; + cursor: pointer; + display: flex; + align-items: center; + text-align: left; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + + &:hover { + transform: scale(1.01); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + &__background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: 0; + } + + &__gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%); + z-index: 1; + } + + &__overlay { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: calc(globals.$spacing-unit * 1.5); + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: calc(globals.$spacing-unit); + } + + &__logo-container { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + } + + &__logo { + max-height: 120px; + max-width: 400px; + width: auto; + height: auto; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); + } + + &__title { + font-size: 28px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9); + } + + &__info-bar { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + justify-content: flex-end; + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.95); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + font-size: 12px; + } + + &__playtime-text { + font-weight: 500; + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__achievements { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 auto; + min-width: 0; + } + + &__achievement-header { + display: flex; + align-items: center; + justify-content: space-between; + } + &__achievements-gap { + display: flex; + align-items: center; + gap: 6px; + } + + &__achievement-trophy { + color: #fff; + flex-shrink: 0; + } + + &__achievement-progress { + width: 100%; + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; + overflow: hidden; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } + } + + &__achievement-bar { + height: 100%; + background-color: globals.$muted-color; + border-radius: 4px; + transition: width 0.3s ease; + } + + &__achievement-count { + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + } + + &__achievement-percentage { + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + } +} diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx new file mode 100644 index 00000000..42b4ab72 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -0,0 +1,132 @@ +import { LibraryGame } from "@types"; +import { useGameCard } from "@renderer/hooks"; +import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import { memo, useMemo } from "react"; +import "./library-game-card-large.scss"; + +interface LibraryGameCardLargeProps { + game: LibraryGame; + onContextMenu: ( + game: LibraryGame, + position: { x: number; y: number } + ) => void; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ + game, + onContextMenu, +}: Readonly) { + const { formatPlayTime, handleCardClick, handleContextMenuClick } = + useGameCard(game, onContextMenu); + + const backgroundImage = useMemo( + () => + getImageWithCustomPriority( + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl + ), + [game.libraryHeroImageUrl, game.libraryImageUrl, game.iconUrl] + ); + + const backgroundStyle = useMemo( + () => ({ backgroundImage: `url(${backgroundImage})` }), + [backgroundImage] + ); + + const achievementBarStyle = useMemo( + () => ({ + width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`, + }), + [game.unlockedAchievementCount, game.achievementCount] + ); + + const logoImage = game.logoImageUrl; + + return ( + + ); +}); diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss new file mode 100644 index 00000000..ab9a9f2a --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -0,0 +1,241 @@ +@use "../../scss/globals.scss"; + +.library-game-card { + &__wrapper { + cursor: pointer; + transition: all ease 0.2s; + box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); + width: 100%; + aspect-ratio: 3 / 4; + position: relative; + border: none; + background: none; + padding: 0; + border-radius: 4px; + overflow: hidden; + display: block; + container-type: inline-size; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover { + transform: scale(1.02); + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + } + + &__overlay { + position: absolute; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + height: 100%; + width: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%); + padding: 8px; + z-index: 2; + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.8); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &-long { + display: inline; + font-size: 12px; + } + + &-short { + display: none; + font-size: 12px; + } + + // When the card is narrow (less than 140px), show short format + @container (max-width: 140px) { + &-long { + display: none; + } + + &-short { + display: inline; + } + } + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__achievements { + display: flex; + flex-direction: column; + opacity: 1; + transform: translateY(0); + transition: all ease 0.2s; + pointer-events: auto; + width: 100%; + } + + &__achievement-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + color: globals.$muted-color; + overflow: hidden; + height: 18px; + } + + &__achievements-gap { + display: flex; + align-items: center; + gap: 8px; + } + + &__achievement-trophy { + color: #fff; + flex-shrink: 0; + } + + &__achievement-progress { + width: 100%; + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 4px; + overflow: hidden; + + &::-webkit-progress-bar { + background-color: rgba(255, 255, 255, 0.15); + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } + } + + &__achievement-bar { + height: 100%; + background-color: globals.$muted-color; + border-radius: 4px; + transition: width 0.3s ease; + position: relative; + } + + &__achievement-count { + white-space: nowrap; + } + + &__achievement-percentage { + white-space: nowrap; + } + + &__action-button { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.2); + border-radius: 4px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.9); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.8); + border-color: rgba(255, 255, 255, 0.4); + transform: scale(1.1); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); + } + + &:active { + transform: scale(0.95); + } + } + + &__wrapper:hover &__action-button { + opacity: 1; + transform: scale(1); + } + + &__game-image { + object-fit: cover; + border-radius: 4px; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + display: block; + top: 0; + left: 0; + z-index: 0; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Responsive sizing for compact grid cells */ +.library__games-grid--compact .library-game-card__wrapper { + width: 100%; + height: auto; + aspect-ratio: 215 / 320; +} diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx new file mode 100644 index 00000000..e6f2e713 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -0,0 +1,107 @@ +import { LibraryGame } from "@types"; +import { useGameCard } from "@renderer/hooks"; +import { memo } from "react"; +import { ClockIcon, AlertFillIcon, TrophyIcon } from "@primer/octicons-react"; +import "./library-game-card.scss"; + +interface LibraryGameCardProps { + game: LibraryGame; + onMouseEnter: () => void; + onMouseLeave: () => void; + onContextMenu: ( + game: LibraryGame, + position: { x: number; y: number } + ) => void; + onShowTooltip?: (gameId: string) => void; + onHideTooltip?: () => void; +} + +export const LibraryGameCard = memo(function LibraryGameCard({ + game, + onMouseEnter, + onMouseLeave, + onContextMenu, +}: Readonly) { + const { formatPlayTime, handleCardClick, handleContextMenuClick } = + useGameCard(game, onContextMenu); + + const coverImage = + game.coverImageUrl ?? + game.libraryImageUrl ?? + game.libraryHeroImageUrl ?? + game.iconUrl ?? + undefined; + + return ( + + ); +}); diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss new file mode 100644 index 00000000..ffc68b83 --- /dev/null +++ b/src/renderer/src/pages/library/library.scss @@ -0,0 +1,208 @@ +@use "../../scss/globals.scss"; + +.library { + &__content { + padding: calc(globals.$spacing-unit * 2); + height: 100%; + width: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 3); + align-items: flex-start; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + &__page-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + width: 100%; + } + + &__page-title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + } + + &__controls-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + position: relative; + } + + &__controls-left { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex: 1; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + margin-right: calc(globals.$spacing-unit * 2); + } + + &__controls-right { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__header-controls { + display: flex; + flex-direction: column; + align-items: end; + gap: calc(globals.$spacing-unit * 1); + &__left { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + } + } + &__header-title { + font-size: 20px; + font-weight: 700; + } + &__filter-label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + } + + &__separator { + width: 100%; + height: 1px; + background: rgba(255, 255, 255, 0.1); + border: none; + margin: 0; + } + + &__count { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 8px 16px; + } + + &__count-label { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + } + + &__count-number { + color: rgba(255, 255, 255, 0.9); + font-size: 13px; + font-weight: 600; + } + + &__no-games { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + gap: globals.$spacing-unit; + padding: calc(globals.$spacing-unit * 4); + } + + &__telescope-icon { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__games-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Grid view - larger cards + &--grid { + grid-template-columns: repeat(2, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(4, 1fr); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(5, 1fr); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(6, 1fr); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(8, 1fr); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(12, 1fr); + } + } + + // Compact view - smaller cards with responsive design + &--compact { + grid-template-columns: repeat(3, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(5, 1fr); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(7, 1fr); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(9, 1fr); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(12, 1fr); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(14, 1fr); + } + } + } + + &__games-list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Large view - 2 columns grid + &--large { + display: grid; + grid-template-columns: repeat(1, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(2, 1fr); + } + } + } +} diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx new file mode 100644 index 00000000..86afb549 --- /dev/null +++ b/src/renderer/src/pages/library/library.tsx @@ -0,0 +1,224 @@ +import { useEffect, useMemo, useState, useCallback } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useLibrary, useAppDispatch, useAppSelector } from "@renderer/hooks"; +import { setHeaderTitle } from "@renderer/features"; +import { TelescopeIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { LibraryGame } from "@types"; +import { GameContextMenu } from "@renderer/components"; +import { LibraryGameCard } from "./library-game-card"; +import { LibraryGameCardLarge } from "./library-game-card-large"; +import { ViewOptions, ViewMode } from "./view-options"; +import { FilterOptions, FilterOption } from "./filter-options"; +import "./library.scss"; + +export default function Library() { + const { library, updateLibrary } = useLibrary(); + type ElectronAPI = { + refreshLibraryAssets?: () => Promise; + onLibraryBatchComplete?: (cb: () => void) => () => void; + }; + + const [viewMode, setViewMode] = useState("compact"); + const [filterBy, setFilterBy] = useState("all"); + const [contextMenu, setContextMenu] = useState<{ + game: LibraryGame | null; + visible: boolean; + position: { x: number; y: number }; + }>({ game: null, visible: false, position: { x: 0, y: 0 } }); + + const searchQuery = useAppSelector((state) => state.library.searchQuery); + const dispatch = useAppDispatch(); + const { t } = useTranslation("library"); + + useEffect(() => { + dispatch(setHeaderTitle(t("library"))); + const electron = (globalThis as unknown as { electron?: ElectronAPI }) + .electron; + let unsubscribe: () => void = () => undefined; + if (electron?.refreshLibraryAssets) { + electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); + if (electron.onLibraryBatchComplete) { + unsubscribe = electron.onLibraryBatchComplete(() => { + updateLibrary(); + }); + } + } else { + updateLibrary(); + } + + return () => { + unsubscribe(); + }; + }, [dispatch, t, updateLibrary]); + + const handleOnMouseEnterGameCard = useCallback(() => { + // Optional: pause animations if needed + }, []); + + const handleOnMouseLeaveGameCard = useCallback(() => { + // Optional: resume animations if needed + }, []); + + const handleOpenContextMenu = useCallback( + (game: LibraryGame, position: { x: number; y: number }) => { + setContextMenu({ game, visible: true, position }); + }, + [] + ); + + const handleCloseContextMenu = useCallback(() => { + setContextMenu({ game: null, visible: false, position: { x: 0, y: 0 } }); + }, []); + + const filteredLibrary = useMemo(() => { + let filtered; + + switch (filterBy) { + case "recently_played": + filtered = library.filter((game) => game.lastTimePlayed !== null); + break; + case "favorites": + filtered = library.filter((game) => game.favorite); + break; + case "all": + default: + filtered = library; + } + + if (!searchQuery.trim()) return filtered; + + const queryLower = searchQuery.toLowerCase(); + return filtered.filter((game) => { + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + return queryIndex === queryLower.length; + }); + }, [library, filterBy, searchQuery]); + + const sortedLibrary = filteredLibrary; + + const filterCounts = useMemo(() => { + const allGamesCount = library.length; + let recentlyPlayedCount = 0; + let favoritesCount = 0; + + for (const game of library) { + if (game.lastTimePlayed !== null) recentlyPlayedCount++; + if (game.favorite) favoritesCount++; + } + + return { + allGamesCount, + recentlyPlayedCount, + favoritesCount, + }; + }, [library]); + + const hasGames = library.length > 0; + + return ( +
+ {hasGames && ( +
+
+
+ +
+ +
+ +
+
+
+ )} + + {!hasGames && ( +
+
+ +
+

{t("no_games_title")}

+

{t("no_games_description")}

+
+ )} + + {hasGames && ( + + {viewMode === "large" && ( + + {sortedLibrary.map((game) => ( + + ))} + + )} + + {viewMode !== "large" && ( + + {sortedLibrary.map((game) => ( +
  • + +
  • + ))} +
    + )} +
    + )} + + {contextMenu.game && ( + + )} +
    + ); +} diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss new file mode 100644 index 00000000..13307864 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.scss @@ -0,0 +1,55 @@ +@use "../../scss/globals.scss"; + +.library-view-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; + } + + &__options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + white-space: nowrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + border: none; + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); + } + + &.active { + color: rgba(0, 0, 0, 0.9); + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; + } + } + } +} diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx new file mode 100644 index 00000000..905fac58 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.tsx @@ -0,0 +1,45 @@ +import { AppsIcon, RowsIcon, SquareIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import "./view-options.scss"; + +export type ViewMode = "grid" | "compact" | "large"; + +interface ViewOptionsProps { + viewMode: ViewMode; + onViewModeChange: (viewMode: ViewMode) => void; +} + +export function ViewOptions({ + viewMode, + onViewModeChange, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
    +
    + + + +
    +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/library-tab.tsx b/src/renderer/src/pages/profile/profile-content/library-tab.tsx new file mode 100644 index 00000000..1bc78c05 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/library-tab.tsx @@ -0,0 +1,178 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { TelescopeIcon } from "@primer/octicons-react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { useFormat } from "@renderer/hooks"; +import type { UserGame } from "@types"; +import { SortOptions } from "./sort-options"; +import { UserLibraryGameCard } from "./user-library-game-card"; +import "./profile-content.scss"; + +type SortOption = "playtime" | "achievementCount" | "playedRecently"; + +interface LibraryTabProps { + sortBy: SortOption; + onSortChange: (sortBy: SortOption) => void; + pinnedGames: UserGame[]; + libraryGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; + statsIndex: number; + userStats: { libraryCount: number } | null; + animatedGameIdsRef: React.MutableRefObject>; + onLoadMore: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; + isMe: boolean; +} + +export function LibraryTab({ + sortBy, + onSortChange, + pinnedGames, + libraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, + statsIndex, + userStats, + animatedGameIdsRef, + onLoadMore, + onMouseEnter, + onMouseLeave, + isMe, +}: Readonly) { + const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const hasGames = libraryGames.length > 0; + const hasPinnedGames = pinnedGames.length > 0; + const hasAnyGames = hasGames || hasPinnedGames; + + return ( + + {hasAnyGames && ( + + )} + + {!hasAnyGames && ( +
    +
    + +
    +

    {t("no_recent_activity_title")}

    + {isMe &&

    {t("no_recent_activity_description")}

    } +
    + )} + + {hasAnyGames && ( +
    + {hasPinnedGames && ( +
    +
    +
    +

    {t("pinned")}

    + + {pinnedGames.length} + +
    +
    + +
      + {pinnedGames?.map((game) => ( +
    • + +
    • + ))} +
    +
    + )} + + {hasGames && ( +
    +
    +
    +

    {t("library")}

    + {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )} +
    +
    + + +
      + {libraryGames?.map((game, index) => { + const hasAnimated = animatedGameIdsRef.current.has( + game.objectId + ); + const isNewGame = !hasAnimated && !isLoadingLibraryGames; + + return ( + { + if (isNewGame) { + animatedGameIdsRef.current.add(game.objectId); + } + }} + > + + + ); + })} +
    +
    +
    + )} +
    + )} +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index c3c71d9a..958fe52d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -101,6 +101,11 @@ gap: calc(globals.$spacing-unit); margin-bottom: calc(globals.$spacing-unit * 2); border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } + + &__tab-wrapper { + position: relative; } &__tab { @@ -111,19 +116,40 @@ cursor: pointer; font-size: 14px; font-weight: 500; - border-bottom: 2px solid transparent; - transition: all ease 0.2s; - - &:hover { - color: rgba(255, 255, 255, 0.8); - } + transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); &--active { color: white; - border-bottom-color: #c9aa71; } } + &__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 9px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 1; + } + + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + &__games-grid { list-style: none; margin: 0; @@ -175,5 +201,245 @@ backdrop-filter: blur(10px); } } + + &__tab-panels { + display: block; + } + } +} + +// Reviews minimal styles +.user-reviews__loading { + padding: calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.8); + text-align: center; + display: flex; + justify-content: center; + align-items: center; +} + +.user-reviews__empty { + text-align: center; + padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.6); +} + +.user-reviews__list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 4); +} + +.user-reviews__review-item { + border-radius: 8px; +} + +.user-reviews__review-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.user-reviews__review-header-bottom { + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-meta-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__review-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: calc(globals.$spacing-unit * 1.5); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-game { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__game-icon { + width: 24px; + height: 24px; + object-fit: cover; +} + +.user-reviews__game-info { + display: flex; + flex-direction: column; +} + +.user-reviews__game-details { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__game-title { + background: none; + border: none; + color: rgba(255, 255, 255, 0.9); + font-weight: 600; + cursor: pointer; + text-align: left; + + &--clickable:hover { + text-decoration: underline; + } +} + +.user-reviews__review-date { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; +} + +.user-reviews__review-score-stars { + display: flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + font-size: 11px; + font-weight: 500; +} + +.user-reviews__review-star { + color: rgba(255, 255, 255, 0.7); + transition: color 0.2s ease; + + &--filled { + color: rgba(255, 255, 255, 0.7); + + svg { + fill: currentColor; + } + } +} + +.user-reviews__review-score-text { + font-weight: 500; +} + +.user-reviews__review-playtime { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 500; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.user-reviews__review-content { + color: rgba(255, 255, 255, 0.85); + line-height: 1.5; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + max-width: 100%; +} + +.user-reviews__review-translation-toggle { + display: inline-flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1); + margin-top: calc(globals.$spacing-unit * 1.5); + padding: 0; + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + transition: all 0.2s ease; + + &:hover { + text-decoration: underline; + color: rgba(255, 255, 255, 0.9); + } +} + +.user-reviews__review-actions { + margin-top: calc(globals.$spacing-unit * 2); + padding-top: calc(globals.$spacing-unit); + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-votes { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__vote-button { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 6px 12px; + color: #ccc; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: #ffffff; + } + + &--active { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); + + svg { + fill: white; + } + } +} + +.user-reviews__delete-review-button { + display: flex; + align-items: center; + gap: 6px; + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + border-radius: 6px; + padding: 6px 10px; + color: #f44336; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(244, 67, 54, 0.2); + border-color: rgba(244, 67, 54, 0.4); + color: #ff7961; } } diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index d2f1f074..8176bace 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,29 +1,82 @@ import { userProfileContext } from "@renderer/context"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; -import { useAppDispatch, useFormat } from "@renderer/hooks"; +import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; -import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserStatsBox } from "./user-stats-box"; import { UserKarmaBox } from "./user-karma-box"; -import { UserLibraryGameCard } from "./user-library-game-card"; -import { SortOptions } from "./sort-options"; -import { useSectionCollapse } from "@renderer/hooks/use-section-collapse"; -import { motion, AnimatePresence } from "framer-motion"; -import { - sectionVariants, - chevronVariants, - GAME_STATS_ANIMATION_DURATION_IN_MS, -} from "./profile-animations"; +import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; +import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { ProfileTabs } from "./profile-tabs"; +import { LibraryTab } from "./library-tab"; +import { ReviewsTab } from "./reviews-tab"; +import { AnimatePresence } from "framer-motion"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface UserReviewsResponse { + totalCount: number; + reviews: UserReview[]; +} + +const getRatingText = (score: number, t: (key: string) => string): string => { + switch (score) { + case 1: + return t("rating_very_negative"); + case 2: + return t("rating_negative"); + case 3: + return t("rating_neutral"); + case 4: + return t("rating_positive"); + case 5: + return t("rating_very_positive"); + default: + return ""; + } +}; + export function ProfileContent() { const { userProfile, @@ -32,16 +85,43 @@ export function ProfileContent() { libraryGames, pinnedGames, getUserLibraryGames, + loadMoreLibraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, } = useContext(userProfileContext); + const { userDetails } = useUserDetails(); const [statsIndex, setStatsIndex] = useState(0); const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [sortBy, setSortBy] = useState("playedRecently"); const statsAnimation = useRef(-1); - const { toggleSection, isPinnedCollapsed } = useSectionCollapse(); + + const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); + + // User reviews state + const [reviews, setReviews] = useState([]); + const [reviewsTotalCount, setReviewsTotalCount] = useState(0); + const [isLoadingReviews, setIsLoadingReviews] = useState(false); + const [votingReviews, setVotingReviews] = useState>(new Set()); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [reviewToDelete, setReviewToDelete] = useState(null); const dispatch = useAppDispatch(); const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const formatPlayTime = (playTimeInSeconds: number) => { + const minutes = playTimeInSeconds / 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) }); + }; useEffect(() => { dispatch(setHeaderTitle("")); @@ -53,10 +133,201 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { - getUserLibraryGames(sortBy); + // When sortBy changes, clear animated games so all games animate in + if (currentSortByRef.current !== sortBy) { + animatedGameIdsRef.current.clear(); + currentSortByRef.current = sortBy; + } + getUserLibraryGames(sortBy, true); } }, [sortBy, getUserLibraryGames, userProfile]); + const animatedGameIdsRef = useRef>(new Set()); + const currentSortByRef = useRef(sortBy); + + const handleLoadMore = useCallback(() => { + if ( + activeTab === "library" && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); + } + }, [ + activeTab, + hasMoreLibraryGames, + isLoadingLibraryGames, + loadMoreLibraryGames, + sortBy, + ]); + + // Clear reviews state and reset tab when switching users + useEffect(() => { + setReviews([]); + setReviewsTotalCount(0); + setIsLoadingReviews(false); + setActiveTab("library"); + }, [userProfile?.id]); + + useEffect(() => { + if (userProfile?.id) { + fetchUserReviews(); + } + }, [userProfile?.id]); + + const fetchUserReviews = async () => { + if (!userProfile?.id) return; + + setIsLoadingReviews(true); + try { + const response = await window.electron.hydraApi.get( + `/users/${userProfile.id}/reviews`, + { needsAuth: true } + ); + setReviews(response.reviews); + setReviewsTotalCount(response.totalCount); + } catch (error) { + // Error handling for fetching reviews + } finally { + setIsLoadingReviews(false); + } + }; + + const handleDeleteReview = async (reviewId: string) => { + try { + const reviewToDeleteObj = reviews.find( + (review) => review.id === reviewId + ); + if (!reviewToDeleteObj) return; + + await window.electron.hydraApi.delete( + `/games/${reviewToDeleteObj.game.shop}/${reviewToDeleteObj.game.objectId}/reviews/${reviewId}` + ); + // Remove the review from the local state + setReviews((prev) => prev.filter((review) => review.id !== reviewId)); + setReviewsTotalCount((prev) => prev - 1); + } catch (error) { + console.error("Failed to delete review:", error); + } + }; + + const handleDeleteClick = (reviewId: string) => { + setReviewToDelete(reviewId); + setDeleteModalVisible(true); + }; + + const handleDeleteConfirm = () => { + if (reviewToDelete) { + handleDeleteReview(reviewToDelete); + setReviewToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalVisible(false); + setReviewToDelete(null); + }; + + const handleVoteReview = async (reviewId: string, isUpvote: boolean) => { + if (votingReviews.has(reviewId)) return; + + setVotingReviews((prev) => new Set(prev).add(reviewId)); + + const review = reviews.find((r) => r.id === reviewId); + if (!review) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } + + const wasUpvoted = review.hasUpvoted; + const wasDownvoted = review.hasDownvoted; + + // Optimistic update + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + + let newUpvotes = r.upvotes; + let newDownvotes = r.downvotes; + let newHasUpvoted = r.hasUpvoted; + let newHasDownvoted = r.hasDownvoted; + + if (isUpvote) { + if (wasUpvoted) { + // Remove upvote + newUpvotes--; + newHasUpvoted = false; + } else { + // Add upvote + newUpvotes++; + newHasUpvoted = true; + if (wasDownvoted) { + // Remove downvote if it was downvoted + newDownvotes--; + newHasDownvoted = false; + } + } + } else if (wasDownvoted) { + // Remove downvote + newDownvotes--; + newHasDownvoted = false; + } else { + // Add downvote + newDownvotes++; + newHasDownvoted = true; + if (wasUpvoted) { + // Remove upvote if it was upvoted + newUpvotes--; + newHasUpvoted = false; + } + } + + return { + ...r, + upvotes: newUpvotes, + downvotes: newDownvotes, + hasUpvoted: newHasUpvoted, + hasDownvoted: newHasDownvoted, + }; + }) + ); + + try { + const endpoint = isUpvote ? "upvote" : "downvote"; + await window.electron.hydraApi.put( + `/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}` + ); + } catch (error) { + console.error("Failed to vote on review:", error); + + // Rollback optimistic update on error + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + return { + ...r, + upvotes: review.upvotes, + downvotes: review.downvotes, + hasUpvoted: review.hasUpvoted, + hasDownvoted: review.hasDownvoted, + }; + }) + ); + } finally { + setTimeout(() => { + setVotingReviews((prev) => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + }, 500); + } + }; + const handleOnMouseEnterGameCard = () => { setIsAnimationRunning(false); }; @@ -86,8 +357,6 @@ export function ProfileContent() { }; }, [setStatsIndex, isAnimationRunning]); - const { numberFormatter } = useFormat(); - const usersAreFriends = useMemo(() => { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -113,112 +382,46 @@ export function ProfileContent() { return (
    - {hasAnyGames && ( - - )} + - {!hasAnyGames && ( -
    -
    - -
    -

    {t("no_recent_activity_title")}

    - {isMe &&

    {t("no_recent_activity_description")}

    } -
    - )} - - {hasAnyGames && ( -
    - {hasPinnedGames && ( -
    -
    -
    - -

    {t("pinned")}

    - - {pinnedGames.length} - -
    -
    - - - {!isPinnedCollapsed && ( - -
      - {pinnedGames?.map((game) => ( -
    • - -
    • - ))} -
    -
    - )} -
    -
    +
    + + {activeTab === "library" && ( + )} - {hasGames && ( -
    -
    -
    -

    {t("library")}

    - {userStats && ( - - {numberFormatter.format(userStats.libraryCount)} - - )} -
    -
    - -
      - {libraryGames?.map((game) => ( -
    • - -
    • - ))} -
    -
    + {activeTab === "reviews" && ( + )} -
    - )} + +
    {shouldShowRightContent && ( @@ -230,6 +433,12 @@ export function ProfileContent() {
    )} + + ); }, [ @@ -242,9 +451,15 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - isPinnedCollapsed, - toggleSection, + sortBy, + activeTab, + // ensure reviews UI updates correctly + reviews, + reviewsTotalCount, + isLoadingReviews, + votingReviews, + deleteModalVisible, ]); return ( diff --git a/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx new file mode 100644 index 00000000..bea569e7 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx @@ -0,0 +1,252 @@ +import { motion, AnimatePresence } from "framer-motion"; +import { useNavigate } from "react-router-dom"; +import { ClockIcon } from "@primer/octicons-react"; +import { Star, ThumbsUp, ThumbsDown, TrashIcon, Languages } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import type { GameShop } from "@types"; +import { sanitizeHtml } from "@shared"; +import { useDate } from "@renderer/hooks"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ProfileReviewItemProps { + review: UserReview; + isOwnReview: boolean; + isVoting: boolean; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ProfileReviewItem({ + review, + isOwnReview, + isVoting, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const navigate = useNavigate(); + const { formatDistance } = useDate(); + const { t } = useTranslation("user_profile"); + const { t: tGameDetails, i18n } = useTranslation("game_details"); + const [showOriginal, setShowOriginal] = useState(false); + + const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || ""; + + const isDifferentLanguage = + getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language); + + const needsTranslation = + !isOwnReview && isDifferentLanguage && review.translations[i18n.language]; + + const getLanguageName = (languageCode: string | null) => { + if (!languageCode) return ""; + try { + const displayNames = new Intl.DisplayNames([i18n.language], { + type: "language", + }); + return displayNames.of(languageCode) || languageCode.toUpperCase(); + } catch { + return languageCode.toUpperCase(); + } + }; + + const displayContent = needsTranslation + ? review.translations[i18n.language] + : review.reviewHtml; + + return ( + +
    +
    +
    +
    +
    + {review.game.title} + +
    +
    +
    +
    + {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
    +
    +
    +
    +
    + + + {review.score}/5 + +
    + {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
    + + + {tGameDetails("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
    + )} +
    +
    +
    + +
    +
    + {needsTranslation && ( + <> + + {showOriginal && ( +
    + )} + + )} +
    + +
    +
    + onVote(review.id, true)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + + + onVote(review.id, false)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
    + + {isOwnReview && ( + + )} +
    + + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx new file mode 100644 index 00000000..bc76f40c --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -0,0 +1,67 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import "./profile-content.scss"; + +interface ProfileTabsProps { + activeTab: "library" | "reviews"; + reviewsTotalCount: number; + onTabChange: (tab: "library" | "reviews") => void; +} + +export function ProfileTabs({ + activeTab, + reviewsTotalCount, + onTabChange, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( +
    +
    + + {activeTab === "library" && ( + + )} +
    +
    + + {activeTab === "reviews" && ( + + )} +
    +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx new file mode 100644 index 00000000..afcc417b --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx @@ -0,0 +1,96 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; +import { ProfileReviewItem } from "./profile-review-item"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ReviewsTabProps { + reviews: UserReview[]; + isLoadingReviews: boolean; + votingReviews: Set; + userDetailsId?: string; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ReviewsTab({ + reviews, + isLoadingReviews, + votingReviews, + userDetailsId, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( + + {isLoadingReviews && ( +
    {t("loading_reviews")}
    + )} + {!isLoadingReviews && reviews.length === 0 && ( +
    +

    {t("no_reviews", "No reviews yet")}

    +
    + )} + {!isLoadingReviews && reviews.length > 0 && ( +
    + {reviews.map((review) => { + const isOwnReview = userDetailsId === review.user.id; + + return ( + + ); + })} +
    + )} +
    + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index 61640536..76bd25a9 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -36,6 +36,7 @@ box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); width: 100%; position: relative; + overflow: hidden; &:before { content: ""; @@ -193,8 +194,28 @@ border-radius: 4px; width: 100%; height: 100%; - min-width: 100%; - min-height: 100%; + display: block; + } + + &__cover-placeholder { + position: relative; + width: 100%; + padding-bottom: 150%; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + border-radius: 4px; + color: rgba(255, 255, 255, 0.3); + + svg { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } } &__achievements-progress { diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index 72b48a8c..81db6334 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -2,7 +2,7 @@ import { UserGame } from "@types"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import { useFormat, useToast } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { useCallback, useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { buildGameAchievementPath, buildGameDetailsPath, @@ -15,6 +15,7 @@ import { AlertFillIcon, PinIcon, PinSlashIcon, + ImageIcon, } from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { Tooltip } from "react-tooltip"; @@ -44,6 +45,11 @@ export function UserLibraryGameCard({ const navigate = useNavigate(); const [isTooltipHovered, setIsTooltipHovered] = useState(false); const [isPinning, setIsPinning] = useState(false); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + setImageError(false); + }, [game.coverImageUrl]); const getStatsItemCount = useCallback(() => { let statsCount = 1; @@ -233,11 +239,18 @@ export function UserLibraryGameCard({ )}
    - {game.title} + {imageError || !game.coverImageUrl ? ( +
    + +
    + ) : ( + {game.title} setImageError(true)} + /> + )} (); + useEffect(() => { window.electron.getDefaultDownloadsPath().then((path) => { setDefaultDownloadsPath(path); @@ -81,6 +91,9 @@ export function SettingsGeneral() { return () => { clearInterval(interval); + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } }; }, []); @@ -110,6 +123,9 @@ export function SettingsGeneral() { userPreferences.achievementCustomNotificationsEnabled ?? true, achievementCustomNotificationPosition: userPreferences.achievementCustomNotificationPosition ?? "top-left", + achievementSoundVolume: Math.round( + (userPreferences.achievementSoundVolume ?? 0.15) * 100 + ), friendRequestNotificationsEnabled: userPreferences.friendRequestNotificationsEnabled ?? false, friendStartGameNotificationsEnabled: @@ -148,6 +164,21 @@ export function SettingsGeneral() { await updateUserPreferences(values); }; + const handleVolumeChange = useCallback( + (newVolume: number) => { + setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume })); + + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } + + volumeUpdateTimeoutRef.current = setTimeout(() => { + updateUserPreferences({ achievementSoundVolume: newVolume / 100 }); + }, 300); + }, + [updateUserPreferences] + ); + const handleChangeAchievementCustomNotificationPosition = async ( event: React.ChangeEvent ) => { @@ -309,6 +340,39 @@ export function SettingsGeneral() { )} + {form.achievementNotificationsEnabled && ( +
    + +
    + + { + const volumePercent = parseInt(e.target.value, 10); + if (!isNaN(volumePercent)) { + handleVolumeChange(volumePercent); + } + }} + className="settings-general__volume-slider" + style={ + { + "--volume-percent": `${form.achievementSoundVolume}%`, + } as React.CSSProperties + } + /> + + {form.achievementSoundVolume}% + +
    +
    + )} +

    {t("common_redist")}

    diff --git a/src/renderer/src/pages/theme-editor/theme-editor.scss b/src/renderer/src/pages/theme-editor/theme-editor.scss index 38061c88..486f694c 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.scss +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -47,6 +47,8 @@ position: relative; border: 1px solid globals.$muted-color; border-radius: 2px; + flex: 1; + min-width: 0; } &__footer { @@ -80,7 +82,7 @@ } &__info { - padding: 16px; + padding: 8px; p { font-size: 16px; @@ -93,12 +95,39 @@ &__notification-preview { padding-top: 12px; display: flex; - flex-direction: row; - align-items: center; + flex-direction: column; gap: 16px; &__select-variation { flex: inherit; } } + + &__notification-preview-controls { + display: flex; + flex-direction: column; + gap: 16px; + flex-shrink: 0; + } + + &__notification-controls { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + + &__sound-actions { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } + + &__sound-actions-row { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } } diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 9df3e9f4..3f0be9cf 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -3,11 +3,16 @@ import "./theme-editor.scss"; import Editor from "@monaco-editor/react"; import { AchievementCustomNotificationPosition, Theme } from "@types"; import { useSearchParams } from "react-router-dom"; -import { Button, SelectField } from "@renderer/components"; -import { CheckIcon } from "@primer/octicons-react"; +import { Button, SelectField, TextField } from "@renderer/components"; +import { + CheckIcon, + UploadIcon, + TrashIcon, + PlayIcon, +} from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import cn from "classnames"; -import { injectCustomCss } from "@renderer/helpers"; +import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; @@ -27,6 +32,7 @@ export default function ThemeEditor() { const [theme, setTheme] = useState(null); const [code, setCode] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [soundPath, setSoundPath] = useState(""); const [isClosingNotifications, setIsClosingNotifications] = useState(false); @@ -62,6 +68,9 @@ export default function ThemeEditor() { if (loadedTheme) { setTheme(loadedTheme); setCode(loadedTheme.code); + if (loadedTheme.originalSoundPath) { + setSoundPath(loadedTheme.originalSoundPath); + } if (shadowRootRef) { injectCustomCss(loadedTheme.code, shadowRootRef); } @@ -107,6 +116,73 @@ export default function ThemeEditor() { } }; + const handleSelectSound = useCallback(async () => { + if (!theme) return; + + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: "Audio", + extensions: ["wav", "mp3", "ogg", "m4a"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + const originalPath = filePaths[0]; + await window.electron.copyThemeAchievementSound(theme.id, originalPath); + const updatedTheme = await window.electron.getCustomThemeById(theme.id); + if (updatedTheme) { + setTheme(updatedTheme); + if (updatedTheme.originalSoundPath) { + setSoundPath(updatedTheme.originalSoundPath); + } + } + } + }, [theme]); + + const handleRemoveSound = useCallback(async () => { + if (!theme) return; + + await window.electron.removeThemeAchievementSound(theme.id); + const updatedTheme = await window.electron.getCustomThemeById(theme.id); + if (updatedTheme) { + setTheme(updatedTheme); + } + setSoundPath(""); + }, [theme]); + + const handlePreviewSound = useCallback(async () => { + if (!theme) return; + + let soundUrl: string; + + if (theme.hasCustomSound) { + const themeSoundUrl = await window.electron.getThemeSoundDataUrl( + theme.id + ); + if (themeSoundUrl) { + soundUrl = themeSoundUrl; + } else { + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; + soundUrl = defaultSound; + } + } else { + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; + soundUrl = defaultSound; + } + + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; + audio.play(); + }, [theme]); + const achievementCustomNotificationPositionOptions = useMemo(() => { return [ "top-left", @@ -164,35 +240,66 @@ export default function ThemeEditor() {

    - { - return { - key: variation, - value: variation, - label: t(variation), - }; - } - )} - onChange={(value) => - setNotificationVariation( - value.target.value as keyof typeof notificationVariations - ) +
    +
    + { + return { + key: variation, + value: variation, + label: t(variation), + }; + } + )} + onChange={(value) => + setNotificationVariation( + value.target.value as keyof typeof notificationVariations + ) + } + /> + + + setNotificationAlignment( + e.target.value as AchievementCustomNotificationPosition + ) + } + options={achievementCustomNotificationPositionOptions} + /> +
    +
    + + + + {t("select")} + } /> - - setNotificationAlignment( - e.target.value as AchievementCustomNotificationPosition - ) - } - options={achievementCustomNotificationPositionOptions} - /> + {theme?.hasCustomSound && ( +
    + + +
    + )}
    diff --git a/src/types/index.ts b/src/types/index.ts index efcc7905..d47156f5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -363,6 +363,8 @@ export type LibraryGame = Game & Partial & { id: string; download: Download | null; + unlockedAchievementCount?: number; + achievementCount?: number; }; export type UserGameDetails = ShopAssets & { diff --git a/src/types/level.types.ts b/src/types/level.types.ts index ff602ac9..0600e82e 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -114,6 +114,7 @@ export interface UserPreferences { achievementNotificationsEnabled?: boolean; achievementCustomNotificationsEnabled?: boolean; achievementCustomNotificationPosition?: AchievementCustomNotificationPosition; + achievementSoundVolume?: number; friendRequestNotificationsEnabled?: boolean; friendStartGameNotificationsEnabled?: boolean; showDownloadSpeedInMegabytes?: boolean; diff --git a/src/types/theme.types.ts b/src/types/theme.types.ts index abba8fc1..80976ec0 100644 --- a/src/types/theme.types.ts +++ b/src/types/theme.types.ts @@ -5,6 +5,8 @@ export interface Theme { authorName?: string; isActive: boolean; code: string; + hasCustomSound?: boolean; + originalSoundPath?: string; createdAt: Date; updatedAt: Date; } diff --git a/yarn.lock b/yarn.lock index 6fb80492..da346e42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7538,6 +7538,13 @@ react-i18next@^14.1.0: "@babel/runtime" "^7.23.9" html-parse-stringify "^3.0.1" +react-infinite-scroll-component@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f" + integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ== + dependencies: + throttle-debounce "^2.1.0" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -8540,6 +8547,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +throttle-debounce@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" + integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== + "through@>=2.2.7 <3": version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"