diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 71631f91..8624cf08 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -93,6 +93,7 @@ }, "header": { "search": "Search games", + "search_library": "Search library", "home": "Home", "catalogue": "Catalogue", "library": "Library", @@ -727,9 +728,8 @@ "amount_minutes_short": "{{amount}}m", "manual_playtime_tooltip": "This playtime has been manually updated", "all_games": "All Games", - "favourited_games": "Favourited", - "new_games": "New Games", - "top_10": "Top 10" + "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 12c19d01..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", @@ -722,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 90346b22..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", @@ -737,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/ru/translation.json b/src/locales/ru/translation.json index f210d96f..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": "Библиотека", @@ -733,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/renderer/src/app.scss b/src/renderer/src/app.scss index ed7b9aa8..0d992553 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -5,7 +5,7 @@ } ::-webkit-scrollbar { - width: 4px; + width: 9px; background-color: globals.$dark-background-color; } @@ -90,6 +90,7 @@ img { progress[value] { -webkit-appearance: none; + appearance: none; } .container { diff --git a/src/renderer/src/components/header/header.scss b/src/renderer/src/components/header/header.scss index cd25d8e2..f0c72ce0 100644 --- a/src/renderer/src/components/header/header.scss +++ b/src/renderer/src/components/header/header.scss @@ -24,7 +24,7 @@ background-color: globals.$background-color; display: inline-flex; transition: all ease 0.2s; - width: 300px; + width: 200px; align-items: center; border-radius: 8px; border: solid 1px globals.$border-color; @@ -35,7 +35,7 @@ } &--focused { - width: 350px; + width: 250px; border-color: #dadbe1; } } diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 0f452bf2..d3164ced 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -7,7 +7,7 @@ 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 = { @@ -28,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); @@ -63,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 ( <> @@ -123,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)} @@ -134,7 +155,7 @@ export function Header() { {searchValue && ( - - - +
+
+ + {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 index 3fceac03..a06de7e9 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -11,7 +11,6 @@ cursor: pointer; display: flex; align-items: center; - padding: 0; text-align: left; &:before { @@ -25,7 +24,7 @@ 35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, - rgba(255, 255, 255, 0.15) 74%, + rgba(255, 255, 255, 0.15) 64%, rgba(255, 255, 255, 0.1) 100% ); transition: all ease 0.3s; @@ -63,12 +62,7 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient( - 0deg, - rgba(0, 0, 0, 0.1) 0%, - rgba(0, 0, 0, 0.2) 50%, - rgba(0, 0, 0, 0.3) 100% - ); + background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%); z-index: 1; } @@ -80,7 +74,7 @@ display: flex; flex-direction: column; justify-content: space-between; - padding: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 1.5); } &__top-section { @@ -90,36 +84,6 @@ gap: calc(globals.$spacing-unit); } - &__menu-button { - align-self: flex-start; - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: solid 1px rgba(255, 255, 255, 0.15); - border-radius: 4px; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all ease 0.2s; - color: rgba(255, 255, 255, 0.95); - padding: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - opacity: 0; - transform: scale(0.9); - - &:hover { - background: rgba(0, 0, 0, 0.6); - border-color: rgba(255, 255, 255, 0.25); - transform: scale(1.05); - } - - &:active { - transform: scale(0.95); - } - } &__logo-container { flex: 1; @@ -168,9 +132,9 @@ display: flex; align-items: center; gap: 6px; - padding: 8px 12px; + padding: 6px 12px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - font-size: 14px; + font-size: 12px; } &__playtime-text { @@ -185,7 +149,6 @@ display: flex; flex-direction: column; gap: 6px; - padding: 6px 12px; flex: 1 1 auto; min-width: 0; } @@ -193,7 +156,6 @@ &__achievement-header { display: flex; align-items: center; - gap: 8px; justify-content: space-between; } &__achievements-gap { @@ -241,55 +203,9 @@ } &__achievement-percentage { - font-size: 12px; + font-size: 14px; color: rgba(255, 255, 255, 0.85); white-space: nowrap; } - &__action-button { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 20px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - cursor: pointer; - font-size: 14px; - font-weight: 600; - transition: all ease 0.2s; - flex: 0 0 auto; - - &:hover { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.3); - transform: scale(1.05); - } - - &:active { - transform: scale(0.98); - } - } - - &:hover &__menu-button { - opacity: 1; - transform: scale(1); - } - - &__action-icon--downloading { - animation: pulse 1.5s ease-in-out infinite; - } -} - -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } } diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 5628fe10..c2bf6a3b 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -1,25 +1,19 @@ import { LibraryGame } from "@types"; -import { useDownload, useFormat } from "@renderer/hooks"; -import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath } from "@renderer/helpers"; +import { useGameCard } from "@renderer/hooks"; import { - PlayIcon, - DownloadIcon, ClockIcon, AlertFillIcon, - ThreeBarsIcon, TrophyIcon, - XIcon, } from "@primer/octicons-react"; -import { useTranslation } from "react-i18next"; -import { useCallback, useState } from "react"; -import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions"; -import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { GameContextMenu } from "@renderer/components"; +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 = ( @@ -30,250 +24,116 @@ const getImageWithCustomPriority = ( return customUrl || originalUrl || fallbackUrl || ""; }; -export function LibraryGameCardLarge({ +export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({ game, + onContextMenu, }: Readonly) { - const { t } = useTranslation("library"); - const { numberFormatter } = useFormat(); - const navigate = useNavigate(); - const { lastPacket } = useDownload(); - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - }>({ visible: false, position: { x: 0, y: 0 } }); - - const isGameDownloading = - game?.download?.status === "active" && lastPacket?.gameId === game?.id; - - 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 = () => { - navigate(buildGameDetailsPath(game)); - }; - const { - handlePlayGame, - handleOpenDownloadOptions, - handleCloseGame, - isGameRunning, - } = useGameActions(game); + formatPlayTime, + handleCardClick, + handleContextMenuClick, + } = useGameCard(game, onContextMenu); - const handleActionClick = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (isGameRunning) { - try { - await handleCloseGame(); - } catch (e) { - console.error(e); - } - return; - } - try { - await handlePlayGame(); - } catch (err) { - console.error(err); - try { - handleOpenDownloadOptions(); - } catch (e) { - console.error(e); - } - } - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setContextMenu({ - visible: true, - position: { x: e.clientX, y: e.clientY }, - }); - }; - - const handleMenuButtonClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setContextMenu({ - visible: true, - position: { - x: e.currentTarget.getBoundingClientRect().right, - y: e.currentTarget.getBoundingClientRect().bottom, - }, - }); - }; - - const handleCloseContextMenu = () => { - setContextMenu({ visible: false, position: { x: 0, y: 0 } }); - }; - - // Use libraryHeroImageUrl as background, fallback to libraryImageUrl - const backgroundImage = getImageWithCustomPriority( - game.libraryHeroImageUrl, - game.libraryImageUrl, - game.iconUrl + 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] ); - // For logo, check if logoImageUrl exists (similar to game details page) const logoImage = game.logoImageUrl; return ( - <> - - - -
- {logoImage ? ( - {game.title} +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + ) : ( -

{game.title}

+ )} -
- -
- {/* Achievements section */} - {(game.achievementCount ?? 0) > 0 && ( -
-
-
- - - {game.unlockedAchievementCount ?? 0} /{" "} - {game.achievementCount ?? 0} - -
- - {Math.round( - ((game.unlockedAchievementCount ?? 0) / - (game.achievementCount ?? 1)) * - 100 - )} - % - -
-
-
-
-
- )} - - + + {formatPlayTime(game.playTimeInMilliseconds)} +
- - - + +
+ {logoImage ? ( + {game.title} + ) : ( +

{game.title}

+ )} +
+ +
+ {/* Achievements section */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )} +
+
+ ); -} +}); diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index aa957d12..8643d23c 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -54,7 +54,7 @@ justify-content: space-between; height: 100%; width: 100%; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 5%, transparent 100%); + background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%); padding: 8px; z-index: 2; } @@ -109,25 +109,26 @@ &__achievements { display: flex; flex-direction: column; - gap: 4px; - padding: 6px 8px; - opacity: 0; - transform: translateY(8px); + opacity: 1; + transform: translateY(0); transition: all ease 0.2s; - pointer-events: none; + pointer-events: auto; width: 100%; } - &__achievements-gap { - display: flex; - align-items: center; - gap: 6px; - } &__achievement-header { display: flex; - align-items: center; - gap: 6px; 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 { @@ -136,16 +137,15 @@ } &__achievement-progress { - margin-top: 8px; width: 100%; height: 4px; transition: all ease 0.2s; - background-color: rgba(255, 255, 255, 0.08); + background-color: rgba(255, 255, 255, 0.15); border-radius: 4px; overflow: hidden; &::-webkit-progress-bar { - background-color: transparent; + background-color: rgba(255, 255, 255, 0.15); border-radius: 4px; } @@ -164,15 +164,10 @@ } &__achievement-count { - font-size: 12px; - font-weight: 500; - color: rgba(255, 255, 255, 0.9); white-space: nowrap; } &__achievement-percentage { - font-size: 11px; - color: rgba(255, 255, 255, 0.7); white-space: nowrap; } @@ -209,53 +204,12 @@ } } - &__menu-button { - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: solid 1px rgba(255, 255, 255, 0.15); - border-radius: 4px; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all ease 0.2s; - color: rgba(255, 255, 255, 0.8); - padding: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - opacity: 0; - transform: scale(0.9); - - &:hover { - background: rgba(0, 0, 0, 0.6); - border-color: rgba(255, 255, 255, 0.25); - transform: scale(1.05); - } - - &:active { - transform: scale(0.95); - } - } - - &__wrapper:hover &__action-button, - &__wrapper:hover &__menu-button { + &__wrapper:hover &__action-button { opacity: 1; transform: scale(1); } - &__wrapper:hover &__achievements { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - &__action-icon { - &--downloading { - animation: pulse 1.5s ease-in-out infinite; - } - } &__game-image { object-fit: cover; @@ -281,9 +235,9 @@ } } -/* Force fixed size for compact grid cells so cards render at 220x320 */ +/* Responsive sizing for compact grid cells */ .library__games-grid--compact .library-game-card__wrapper { - width: 215px; - height: 320px; - aspect-ratio: unset; + 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 index a9f2aba2..39cce681 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -1,89 +1,36 @@ import { LibraryGame } from "@types"; -import { useFormat } from "@renderer/hooks"; -import { useNavigate } from "react-router-dom"; -import { useCallback, useState } from "react"; -import { buildGameDetailsPath } from "@renderer/helpers"; +import { useGameCard } from "@renderer/hooks"; +import { memo } from "react"; import { ClockIcon, AlertFillIcon, - ThreeBarsIcon, TrophyIcon, } from "@primer/octicons-react"; -import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { Tooltip } from "react-tooltip"; -import { useTranslation } from "react-i18next"; -import { GameContextMenu } from "@renderer/components"; 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 function LibraryGameCard({ +export const LibraryGameCard = memo(function LibraryGameCard({ game, onMouseEnter, onMouseLeave, + onContextMenu, }: Readonly) { - const { t } = useTranslation("library"); - const { numberFormatter } = useFormat(); - const navigate = useNavigate(); - const [isTooltipHovered, setIsTooltipHovered] = useState(false); - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - }>({ visible: false, position: { x: 0, y: 0 } }); - - 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 = () => { - navigate(buildGameDetailsPath(game)); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setContextMenu({ - visible: true, - position: { x: e.clientX, y: e.clientY }, - }); - }; - - const handleMenuButtonClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setContextMenu({ - visible: true, - position: { - x: e.currentTarget.getBoundingClientRect().right, - y: e.currentTarget.getBoundingClientRect().bottom, - }, - }); - }; - - const handleCloseContextMenu = () => { - setContextMenu({ visible: false, position: { x: 0, y: 0 } }); - }; + const { + formatPlayTime, + handleCardClick, + handleContextMenuClick, + } = useGameCard(game, onContextMenu); const coverImage = game.coverImageUrl ?? @@ -93,110 +40,75 @@ export function LibraryGameCard({ undefined; return ( - <> - + - setIsTooltipHovered(true)} - afterHide={() => setIsTooltipHovered(false)} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )} +
+ + {game.title} - - + ); -} +}); diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss index 9b660a45..ffc68b83 100644 --- a/src/renderer/src/pages/library/library.scss +++ b/src/renderer/src/pages/library/library.scss @@ -2,7 +2,7 @@ .library { &__content { - padding: calc(globals.$spacing-unit * 3); + padding: calc(globals.$spacing-unit * 2); height: 100%; width: 100%; overflow-y: auto; @@ -38,13 +38,17 @@ align-items: center; justify-content: space-between; width: 100%; - gap: calc(globals.$spacing-unit * 2); + 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 { @@ -159,31 +163,28 @@ } } - // Compact view - smaller cards + // Compact view - smaller cards with responsive design &--compact { - grid-template-columns: repeat(auto-fill, 215px); - grid-auto-rows: 320px; - justify-content: start; + grid-template-columns: repeat(3, 1fr); @container #{globals.$app-container} (min-width: 900px) { - grid-template-columns: repeat(auto-fill, 215px); + grid-template-columns: repeat(5, 1fr); } @container #{globals.$app-container} (min-width: 1300px) { - grid-template-columns: repeat(auto-fill, 215px); + grid-template-columns: repeat(7, 1fr); } - /* keep same pattern for very large screens */ @container #{globals.$app-container} (min-width: 2000px) { - grid-template-columns: repeat(auto-fill, 215px); + grid-template-columns: repeat(9, 1fr); } @container #{globals.$app-container} (min-width: 2600px) { - grid-template-columns: repeat(auto-fill, 215px); + grid-template-columns: repeat(12, 1fr); } @container #{globals.$app-container} (min-width: 3000px) { - grid-template-columns: repeat(auto-fill, 210px); + grid-template-columns: repeat(14, 1fr); } } } diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 1cafc870..86afb549 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -1,14 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; -import { useLibrary, useAppDispatch } from "@renderer/hooks"; +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"; -// detailed view removed — keep file if needed later import { LibraryGameCardLarge } from "./library-game-card-large"; import { ViewOptions, ViewMode } from "./view-options"; import { FilterOptions, FilterOption } from "./filter-options"; -import { SearchBar } from "./search-bar"; import "./library.scss"; export default function Library() { @@ -20,7 +21,13 @@ export default function Library() { const [viewMode, setViewMode] = useState("compact"); const [filterBy, setFilterBy] = useState("all"); - const [searchQuery, setSearchQuery] = useState(""); + 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"); @@ -48,35 +55,35 @@ export default function Library() { }; }, [dispatch, t, updateLibrary]); - const handleOnMouseEnterGameCard = () => { + const handleOnMouseEnterGameCard = useCallback(() => { // Optional: pause animations if needed - }; + }, []); - const handleOnMouseLeaveGameCard = () => { + 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 "favourited": + case "recently_played": + filtered = library.filter((game) => game.lastTimePlayed !== null); + break; + case "favorites": filtered = library.filter((game) => game.favorite); break; - case "new": - filtered = library.filter( - (game) => (game.playTimeInMilliseconds || 0) === 0 - ); - break; - case "top10": - filtered = library - .slice() - .sort( - (a, b) => - (b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0) - ) - .slice(0, 10); - break; case "all": default: filtered = library; @@ -103,16 +110,24 @@ export default function Library() { }); }, [library, filterBy, searchQuery]); - // No sorting for now — rely on filteredLibrary const sortedLibrary = filteredLibrary; - // Calculate counts for filters - const allGamesCount = library.length; - const favouritedCount = library.filter((game) => game.favorite).length; - const newGamesCount = library.filter( - (game) => (game.playTimeInMilliseconds || 0) === 0 - ).length; - const top10Count = Math.min(10, library.length); + 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; @@ -125,15 +140,13 @@ export default function Library() {
-
@@ -150,32 +163,61 @@ export default function Library() { )} - {hasGames && viewMode === "large" && ( -
- {sortedLibrary.map((game) => ( - - ))} -
+ {hasGames && ( + + {viewMode === "large" && ( + + {sortedLibrary.map((game) => ( + + ))} + + )} + + {viewMode !== "large" && ( + + {sortedLibrary.map((game) => ( +
  • + +
  • + ))} +
    + )} +
    )} - {hasGames && viewMode !== "large" && ( - + {contextMenu.game && ( + )} ); diff --git a/src/renderer/src/pages/library/search-bar.scss b/src/renderer/src/pages/library/search-bar.scss deleted file mode 100644 index 6b09c683..00000000 --- a/src/renderer/src/pages/library/search-bar.scss +++ /dev/null @@ -1,75 +0,0 @@ -.search-bar { - display: flex; - align-items: center; - - &__container { - height: 32px; - display: flex; - align-items: center; - gap: 8px; - padding: 4px 12px; - background-color: transparent; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 6px; - transition: all 0.2s ease; - width: 250px; - - &:focus-within { - width: 300px; - background: rgba(255, 255, 255, 0.02); - border-color: rgba(255, 255, 255, 0.2); - } - } - - &__icon { - color: rgba(255, 255, 255, 0.75); - flex-shrink: 0; - transition: color 0.2s ease; - } - - &__input { - flex: 1; - background: transparent; - border: none; - outline: none; - color: rgba(255, 255, 255, 0.9); - font-size: 14px; - font-family: inherit; - min-width: 0; - - &::placeholder { - color: rgba(255, 255, 255, 0.6); - } - - &:focus ~ .search-bar__icon { - color: rgba(255, 255, 255, 0.7); - } - } - - &__clear { - flex-shrink: 0; - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.65); - font-size: 18px; - font-weight: bold; - cursor: pointer; - padding: 0; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 3px; - transition: all 0.2s ease; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.8); - } - - &:active { - background: rgba(255, 255, 255, 0.15); - } - } -} diff --git a/src/renderer/src/pages/library/search-bar.tsx b/src/renderer/src/pages/library/search-bar.tsx deleted file mode 100644 index 5a2da667..00000000 --- a/src/renderer/src/pages/library/search-bar.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { SearchIcon } from "@primer/octicons-react"; -import { FC, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import "./search-bar.scss"; - -interface SearchBarProps { - value: string; - onChange: (value: string) => void; -} - -export const SearchBar: FC = ({ value, onChange }) => { - const { t } = useTranslation(); - const inputRef = useRef(null); - - const handleClear = () => { - onChange(""); - inputRef.current?.focus(); - }; - - return ( -
    -
    - - onChange(e.target.value)} - /> - {value && ( - - )} -
    -
    - ); -}; diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss index 77bfc10e..13307864 100644 --- a/src/renderer/src/pages/library/view-options.scss +++ b/src/renderer/src/pages/library/view-options.scss @@ -26,7 +26,7 @@ display: flex; align-items: center; gap: calc(globals.$spacing-unit); - padding: 8px 10px; + padding: 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.04); border: none; @@ -38,8 +38,8 @@ white-space: nowrap; &:hover { - color: rgba(255, 255, 255, 0.95); - background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); } &.active {