Compare commits

..

40 Commits

Author SHA1 Message Date
Chubby Granny Chaser
3472e90858 Merge branch 'main' into fix/library-ui 2025-11-10 22:53:52 +00:00
Chubby Granny Chaser
46df34e8a5 feat: improving library 2025-11-10 22:20:44 +00:00
Chubby Granny Chaser
272b047ded Merge pull request #1844 from expload233/main
Add new translation for Chinese
2025-11-10 21:51:44 +00:00
Chubby Granny Chaser
dfba38aeed Merge branch 'main' into main 2025-11-10 21:51:20 +00:00
Moyasee
65e2bb38a0 ci: formatting 2025-11-08 19:26:13 +02:00
Moyasee
5f09321728 Merge branch 'fix/library-ui' of https://github.com/hydralauncher/hydra into fix/library-ui 2025-11-08 19:25:29 +02:00
Moyasee
011559b499 fix: removed VirtualList component from large view 2025-11-08 19:24:43 +02:00
Moyase
c890b0fd56 Merge branch 'main' into fix/library-ui 2025-11-08 14:52:35 +02:00
Moyasee
cf48627a8d fix: extracter ternary operation 2025-11-08 14:51:10 +02:00
Moyasee
196413ee28 fix: duplicated lines 2025-11-08 14:48:42 +02:00
Moyasee
c3a4990a50 ci: performance optimizing in library 2025-11-08 14:28:54 +02:00
Chubby Granny Chaser
fa4c11e458 Merge pull request #1849 from hydralauncher/fix/surprise-me-button
Fix: surprise me button functionality
2025-11-08 08:18:13 +00:00
Chubby Granny Chaser
50b0a82204 feat: improving styles on randomizer button 2025-11-08 08:17:19 +00:00
Moyasee
6e6e0f7bb7 fix: duplicate next suggestion styling removal 2025-11-07 13:35:50 +02:00
Moyasee
893802be55 fix: next suggestion and title not being showed 2025-11-07 13:27:24 +02:00
Moyasee
3bef0c9269 feat: library ui changes and searchbar removal 2025-11-06 18:26:56 +02:00
Moyase
754e9c14b8 Merge pull request #1821 from iam-sahil/feat/library
Feat/library
2025-11-06 17:22:58 +02:00
ctrlcat0x
5e653be4c3 fix: add error logging in handleActionClick for better debugging 2025-11-06 19:11:20 +05:30
ctrlcat0x
cedf7e6e37 style: improve color contrast in various components and update prop types to readonly 2025-11-06 19:03:23 +05:30
Moyase
518a0e1cf4 Merge branch 'main' into feat/library 2025-11-06 14:57:22 +02:00
expload233
5f56a3d517 add translation 2025-11-04 15:47:44 +08:00
expload
2359c4cc5e Merge branch 'hydralauncher:main' into main 2025-11-04 15:31:03 +08:00
expload233
66bb5221c1 fix lint 2025-11-04 15:24:42 +08:00
Chubby Granny Chaser
7fa50dc5a7 feat: adding ws client back 2025-11-03 12:02:52 +00:00
expload233
088feaffc2 add missing translation for zh-CN 2025-11-03 19:02:11 +08:00
expload233
aa6b595b18 Fill in the missing entries 2025-11-03 18:53:02 +08:00
Chubby Granny Chaser
f49fea3032 ci: bump version
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-02 21:17:34 +00:00
Chubby Granny Chaser
595d39986d Merge pull request #1843 from hydralauncher/fix/fixing-datanodes
fix: fixing datanodes
2025-11-02 21:15:53 +00:00
Chubby Granny Chaser
e7a437e839 Merge branch 'main' into feat/library 2025-11-02 20:23:44 +00:00
ctrlcat0x
f5470b29c0 style: adjust hover effects and dimensions for game cards; refine context menu actions 2025-10-23 10:58:31 +05:30
ctrlcat0x
a0a967aacd style: update compact view styles for game cards; adjust grid layout and add button order 2025-10-22 18:28:24 +05:30
ctrlcat0x
e19102ea66 style: update active state styles for filter and view options; adjust achievement progress bar styles 2025-10-22 16:12:12 +05:30
ctrlcat0x
107b61f663 style: update active state colors for filter and view options 2025-10-22 14:46:25 +05:30
ctrlcat0x
811a6ad955 refactor: remove unused imports and download logic from LibraryGameCard 2025-10-22 14:42:47 +05:30
ctrlcat0x
6fb8bbf744 he commit 2025-10-22 14:29:55 +05:30
ctrlcat0x
459017a4a6 Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/game-library 2025-10-22 14:28:00 +05:30
Sahil Rana
d6ff8f670e Merge branch 'main' into feat/game-library 2025-10-22 14:26:09 +05:30
ctrlcat0x
33e0d50966 feat: add achievements tracking to game library
- Updated `get-library.ts` to include unlocked and total achievement counts for each game.
- Removed `library-game-card-detailed.tsx` and its associated styles as part of the refactor.
- Enhanced `library-game-card-large.tsx` to display achievements with progress bars.
- Modified `library-game-card.scss` and `library-game-card-large.scss` to style the achievements section.
- Introduced a new `search-bar` component for filtering the game library.
- Implemented fuzzy search functionality in the library view.
- Updated `view-options` to improve UI consistency.
- Added achievement-related properties to the `LibraryGame` type in `index.ts`.
- Created a new `copilot-instructions.md` for project guidelines.
2025-10-22 14:24:04 +05:30
Sahil Rana
361073d3f8 Merge branch 'main' into feat/game-library 2025-10-20 23:51:13 +05:30
ctrlcat0x
d168e20385 feat(library): implement large game card and enhance library UI
- Added `LibraryGameCardLarge` component for displaying games in a larger format with improved styling and animations.
- Introduced SCSS styles for the large game card, including hover effects and gradient overlays.
- Updated `LibraryGameCard` component to support mouse enter and leave events for better interaction.
- Enhanced the library view options with new styles and functionality for switching between grid, compact, and large views.
- Improved overall layout and responsiveness of the library page, ensuring a better user experience across different screen sizes.
- Added tooltips for playtime information and context menus for game actions.
2025-10-20 23:43:47 +05:30
35 changed files with 1860 additions and 38 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.7.3",
"version": "3.7.4",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",

View File

@@ -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",
@@ -698,6 +701,27 @@
"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",
"user_achievements": "{{displayName}}'s Achievements",

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"settings": "Ajustes",
"my_library": "Mi Librería",
@@ -716,5 +717,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"
}
}

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads",
"settings": "Ajustes",
"my_library": "Biblioteca",
@@ -731,5 +732,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"
}
}

View File

@@ -13,6 +13,7 @@
},
"sidebar": {
"catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки",
"settings": "Настройки",
"my_library": "Библиотека",
@@ -727,5 +728,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": "Избранное"
}
}

View File

@@ -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": "成就已解锁",

View File

@@ -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/open-game";
import "./library/open-game-executable-path";

View File

@@ -4,6 +4,7 @@ import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
gameAchievementsSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => {
@@ -18,14 +19,32 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
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;
})
);

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { mergeWithRemoteGames } from "@main/services";
const refreshLibraryAssets = async () => {
await mergeWithRemoteGames();
};
registerEvent("refreshLibraryAssets", refreshLibraryAssets);

View File

@@ -16,6 +16,7 @@ import {
Ludusavi,
Lock,
DeckyPlugin,
WSClient,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -56,7 +57,7 @@ export const loadState = async () => {
const { syncDownloadSourcesFromApi } = await import("./services/user");
void syncDownloadSourcesFromApi();
// WSClient.connect();
WSClient.connect();
});
const downloads = await downloadsSublevel

View File

@@ -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();

View File

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

View File

@@ -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;
})

View File

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

View File

@@ -90,6 +90,7 @@ img {
progress[value] {
-webkit-appearance: none;
appearance: none;
}
.container {

View File

@@ -70,8 +70,10 @@ export function GameContextMenu({
onClick: () => {
if (isGameRunning) {
void handleCloseGame();
} else {
} else if (canPlay) {
void handlePlayGame();
} else {
handleOpenDownloadOptions();
}
},
disabled: isDeleting,

View File

@@ -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<string, string> = {
"/": "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 && (
<button
type="button"
onClick={() => dispatch(setFilters({ title: "" }))}
onClick={handleClearSearch}
className="header__action-button"
>
<XIcon />

View File

@@ -3,6 +3,7 @@ import {
DownloadIcon,
GearIcon,
HomeIcon,
BookIcon,
} from "@primer/octicons-react";
export const routes = [
@@ -16,6 +17,11 @@ export const routes = [
nameKey: "catalogue",
render: () => <AppsIcon />,
},
{
path: "/library",
nameKey: "library",
render: () => <BookIcon />,
},
{
path: "/downloads",
nameKey: "downloads",

View File

@@ -159,6 +159,7 @@ declare global {
) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;

View File

@@ -5,10 +5,12 @@ import type { LibraryGame } from "@types";
export interface LibraryState {
value: LibraryGame[];
searchQuery: string;
}
const initialState: LibraryState = {
value: [],
searchQuery: "",
};
export const librarySlice = createSlice({
@@ -18,7 +20,10 @@ export const librarySlice = createSlice({
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
state.value = action.payload;
},
setLibrarySearchQuery: (state, action: PayloadAction<string>) => {
state.searchQuery = action.payload;
},
},
});
export const { setLibrary } = librarySlice.actions;
export const { setLibrary, setLibrarySearchQuery } = librarySlice.actions;

View File

@@ -6,3 +6,4 @@ export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
export * from "./use-feature";
export * from "./use-game-card";

View File

@@ -0,0 +1,66 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useFormat } from "./use-format";
import { useTranslation } from "react-i18next";
import { buildGameDetailsPath } from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { LibraryGame } from "@types";
export function useGameCard(
game: LibraryGame,
onContextMenu: (game: LibraryGame, position: { x: number; y: number }) => void
) {
const { t } = useTranslation("library");
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const formatPlayTime = useCallback(
(playTimeInMilliseconds = 0, isShort = false) => {
const minutes = playTimeInMilliseconds / 60000;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t(isShort ? "amount_minutes_short" : "amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
const hoursKey = isShort ? "amount_hours_short" : "amount_hours";
const hoursAmount = isShort
? Math.floor(hours)
: numberFormatter.format(hours);
return t(hoursKey, { amount: hoursAmount });
},
[numberFormatter, t]
);
const handleCardClick = useCallback(() => {
navigate(buildGameDetailsPath(game));
}, [navigate, game]);
const handleContextMenuClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(game, { x: e.clientX, y: e.clientY });
},
[game, onContextMenu]
);
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
onContextMenu(game, { x: rect.right, y: rect.bottom });
},
[game, onContextMenu]
);
return {
formatPlayTime,
handleCardClick,
handleContextMenuClick,
handleMenuButtonClick,
};
}

View File

@@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log;
@@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route element={<App />}>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/library" element={<Library />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />

View File

@@ -231,44 +231,50 @@ $hero-height: 350px;
}
&__randomizer-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
position: fixed;
bottom: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 2);
z-index: 100;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
box-shadow:
0px 0px 10px 0px rgba(0, 0, 0, 0.8),
0px 2px 8px 0px rgba(255, 255, 255, 0.1);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
overflow: visible;
&:active {
opacity: 0.9;
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(255, 255, 255, 0.12);
color: globals.$body-color;
}
}
&__stars-icon-container {
width: 20px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__stars-icon {
width: 26px;
width: 70px;
position: absolute;
top: -3px;
top: -28px;
left: -27px;
}
}

View File

@@ -0,0 +1,55 @@
@use "../../scss/globals.scss";
.library-filter-options {
&__tabs {
display: flex;
gap: calc(globals.$spacing-unit);
position: relative;
}
&__tab-wrapper {
position: relative;
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&--active {
color: white;
}
}
&__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;
}
}

View File

@@ -0,0 +1,103 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import "./filter-options.scss";
export type FilterOption = "all" | "recently_played" | "favorites";
interface FilterOptionsProps {
filterBy: FilterOption;
onFilterChange: (filterBy: FilterOption) => void;
allGamesCount: number;
recentlyPlayedCount: number;
favoritesCount: number;
}
export function FilterOptions({
filterBy,
onFilterChange,
allGamesCount,
recentlyPlayedCount,
favoritesCount,
}: Readonly<FilterOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-filter-options__tabs">
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "all" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("all")}
>
{t("all_games")}
{allGamesCount > 0 && (
<span className="library-filter-options__tab-badge">
{allGamesCount}
</span>
)}
</button>
{filterBy === "all" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "recently_played" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("recently_played")}
>
{t("recently_played")}
{recentlyPlayedCount > 0 && (
<span className="library-filter-options__tab-badge">
{recentlyPlayedCount}
</span>
)}
</button>
{filterBy === "recently_played" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="library-filter-options__tab-wrapper">
<button
type="button"
className={`library-filter-options__tab ${filterBy === "favorites" ? "library-filter-options__tab--active" : ""}`}
onClick={() => onFilterChange("favorites")}
>
{t("favorites")}
{favoritesCount > 0 && (
<span className="library-filter-options__tab-badge">
{favoritesCount}
</span>
)}
</button>
{filterBy === "favorites" && (
<motion.div
className="library-filter-options__tab-underline"
layoutId="library-tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
@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;
}
}

View File

@@ -0,0 +1,139 @@
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<LibraryGameCardLargeProps>) {
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 (
<button
type="button"
className="library-game-card-large"
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div
className="library-game-card-large__background"
style={backgroundStyle}
/>
<div className="library-game-card-large__gradient" />
<div className="library-game-card-large__overlay">
<div className="library-game-card-large__top-section">
<div className="library-game-card-large__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card-large__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card-large__playtime-text">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
</div>
</div>
<div className="library-game-card-large__logo-container">
{logoImage ? (
<img
src={logoImage}
alt={game.title}
className="library-game-card-large__logo"
/>
) : (
<h3 className="library-game-card-large__title">{game.title}</h3>
)}
</div>
<div className="library-game-card-large__info-bar">
{/* Achievements section */}
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card-large__achievements">
<div className="library-game-card-large__achievement-header">
<div className="library-game-card-large__achievements-gap">
<TrophyIcon
size={14}
className="library-game-card-large__achievement-trophy"
/>
<span className="library-game-card-large__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card-large__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card-large__achievement-progress">
<div
className="library-game-card-large__achievement-bar"
style={achievementBarStyle}
/>
</div>
</div>
)}
</div>
</div>
</button>
);
});

View File

@@ -0,0 +1,243 @@
@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;
}

View File

@@ -0,0 +1,114 @@
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<LibraryGameCardProps>) {
const {
formatPlayTime,
handleCardClick,
handleContextMenuClick,
} = useGameCard(game, onContextMenu);
const coverImage =
game.coverImageUrl ??
game.libraryImageUrl ??
game.libraryHeroImageUrl ??
game.iconUrl ??
undefined;
return (
<button
type="button"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="library-game-card__wrapper"
title={game.title}
onClick={handleCardClick}
onContextMenu={handleContextMenuClick}
>
<div className="library-game-card__overlay">
<div className="library-game-card__top-section">
<div className="library-game-card__playtime">
{game.hasManuallyUpdatedPlaytime ? (
<AlertFillIcon
size={11}
className="library-game-card__manual-playtime"
/>
) : (
<ClockIcon size={11} />
)}
<span className="library-game-card__playtime-long">
{formatPlayTime(game.playTimeInMilliseconds)}
</span>
<span className="library-game-card__playtime-short">
{formatPlayTime(game.playTimeInMilliseconds, true)}
</span>
</div>
</div>
{(game.achievementCount ?? 0) > 0 && (
<div className="library-game-card__achievements">
<div className="library-game-card__achievement-header">
<div className="library-game-card__achievements-gap">
<TrophyIcon
size={13}
className="library-game-card__achievement-trophy"
/>
<span className="library-game-card__achievement-count">
{game.unlockedAchievementCount ?? 0} /{" "}
{game.achievementCount ?? 0}
</span>
</div>
<span className="library-game-card__achievement-percentage">
{Math.round(
((game.unlockedAchievementCount ?? 0) /
(game.achievementCount ?? 1)) *
100
)}
%
</span>
</div>
<div className="library-game-card__achievement-progress">
<div
className="library-game-card__achievement-bar"
style={{
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
}}
/>
</div>
</div>
)}
</div>
<img
src={coverImage ?? undefined}
alt={game.title}
className="library-game-card__game-image"
loading="lazy"
/>
</button>
);
});

View File

@@ -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);
}
}
}
}

View File

@@ -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<unknown>;
onLibraryBatchComplete?: (cb: () => void) => () => void;
};
const [viewMode, setViewMode] = useState<ViewMode>("compact");
const [filterBy, setFilterBy] = useState<FilterOption>("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 (
<section className="library__content">
{hasGames && (
<div className="library__page-header">
<div className="library__controls-row">
<div className="library__controls-left">
<FilterOptions
filterBy={filterBy}
onFilterChange={setFilterBy}
allGamesCount={filterCounts.allGamesCount}
recentlyPlayedCount={filterCounts.recentlyPlayedCount}
favoritesCount={filterCounts.favoritesCount}
/>
</div>
<div className="library__controls-right">
<ViewOptions viewMode={viewMode} onViewModeChange={setViewMode} />
</div>
</div>
</div>
)}
{!hasGames && (
<div className="library__no-games">
<div className="library__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_games_title")}</h2>
<p>{t("no_games_description")}</p>
</div>
)}
{hasGames && (
<AnimatePresence mode="wait">
{viewMode === "large" && (
<motion.div
key={`${filterBy}-large`}
className="library__games-list library__games-list--large"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
<LibraryGameCardLarge
key={`${game.shop}-${game.objectId}`}
game={game}
onContextMenu={handleOpenContextMenu}
/>
))}
</motion.div>
)}
{viewMode !== "large" && (
<motion.ul
key={`${filterBy}-${viewMode}`}
className={`library__games-grid library__games-grid--${viewMode}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{sortedLibrary.map((game) => (
<li
key={`${game.shop}-${game.objectId}`}
style={{ listStyle: "none" }}
>
<LibraryGameCard
game={game}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
onContextMenu={handleOpenContextMenu}
/>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
)}
{contextMenu.game && (
<GameContextMenu
game={contextMenu.game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
)}
</section>
);
}

View File

@@ -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;
}
}
}
}

View File

@@ -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<ViewOptionsProps>) {
const { t } = useTranslation("library");
return (
<div className="library-view-options__container">
<div className="library-view-options__options">
<button
className={`library-view-options__option ${viewMode === "compact" ? "active" : ""}`}
onClick={() => onViewModeChange("compact")}
title={t("compact_view")}
>
<SquareIcon size={16} />
</button>
<button
className={`library-view-options__option ${viewMode === "grid" ? "active" : ""}`}
onClick={() => onViewModeChange("grid")}
title={t("grid_view")}
>
<AppsIcon size={16} />
</button>
<button
className={`library-view-options__option ${viewMode === "large" ? "active" : ""}`}
onClick={() => onViewModeChange("large")}
title={t("large_view")}
>
<RowsIcon size={16} />
</button>
</div>
</div>
);
}

View File

@@ -362,6 +362,8 @@ export type LibraryGame = Game &
Partial<ShopAssets> & {
id: string;
download: Download | null;
unlockedAchievementCount?: number;
achievementCount?: number;
};
export type UserGameDetails = ShopAssets & {