diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2888a2e2..9989f153 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "settings": "Settings", "my_library": "My library", @@ -94,6 +95,7 @@ "search": "Search games", "home": "Home", "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "search_results": "Search results", "settings": "Settings", @@ -698,6 +700,28 @@ "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", + "favourited_games": "Favourited", + "new_games": "New Games", + "top_10": "Top 10" + }, "achievement": { "achievement_unlocked": "Achievement unlocked", "user_achievements": "{{displayName}}'s Achievements", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 0ab5499a..aaac89dd 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -18,6 +18,7 @@ import "./library/close-game"; import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; import "./library/get-library"; +import "./library/refresh-library-assets"; import "./library/extract-game-download"; import "./library/open-game"; import "./library/open-game-executable-path"; diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 6314f83d..f62c60e7 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -4,6 +4,7 @@ import { downloadsSublevel, gamesShopAssetsSublevel, gamesSublevel, + gameAchievementsSublevel, } from "@main/level"; const getLibrary = async (): Promise => { @@ -18,14 +19,32 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + let unlockedAchievementCount = 0; + let achievementCount = 0; + + try { + const achievements = await gameAchievementsSublevel.get(key); + if (achievements) { + achievementCount = achievements.achievements.length; + unlockedAchievementCount = + achievements.unlockedAchievements.length; + } + } catch { + // No achievements data for this game + } + return { id: key, ...game, download: download ?? null, + unlockedAchievementCount, + achievementCount, + // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, - // Ensure compatibility with LibraryGame type - libraryHeroImageUrl: - game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + // Preserve custom image URLs from game if they exist + customIconUrl: game.customIconUrl, + customLogoImageUrl: game.customLogoImageUrl, + customHeroImageUrl: game.customHeroImageUrl, } as LibraryGame; }) ); diff --git a/src/main/events/library/refresh-library-assets.ts b/src/main/events/library/refresh-library-assets.ts new file mode 100644 index 00000000..d8578f1b --- /dev/null +++ b/src/main/events/library/refresh-library-assets.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { mergeWithRemoteGames } from "@main/services"; + +const refreshLibraryAssets = async () => { + await mergeWithRemoteGames(); +}; + +registerEvent("refreshLibraryAssets", refreshLibraryAssets); diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index c00e4961..92cd66d8 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -60,13 +60,20 @@ export const mergeWithRemoteGames = async () => { const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); + // Construct coverImageUrl if not provided by backend (Steam games use predictable pattern) + const coverImageUrl = + game.coverImageUrl || + (game.shop === "steam" + ? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg` + : null); + await gamesShopAssetsSublevel.put(gameKey, { updatedAt: Date.now(), ...localGameShopAsset, shop: game.shop, objectId: game.objectId, title: localGame?.title || game.title, // Preserve local title if it exists - coverImageUrl: game.coverImageUrl, + coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, logoImageUrl: game.logoImageUrl, diff --git a/src/preload/index.ts b/src/preload/index.ts index f89ec4db..fc588a30 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => diff --git a/src/renderer/src/app.scss b/src/renderer/src/app.scss index 4c5374e8..ed7b9aa8 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -5,7 +5,7 @@ } ::-webkit-scrollbar { - width: 9px; + width: 4px; background-color: globals.$dark-background-color; } diff --git a/src/renderer/src/components/game-context-menu/game-context-menu.tsx b/src/renderer/src/components/game-context-menu/game-context-menu.tsx index 694012b7..782857a9 100644 --- a/src/renderer/src/components/game-context-menu/game-context-menu.tsx +++ b/src/renderer/src/components/game-context-menu/game-context-menu.tsx @@ -70,8 +70,10 @@ export function GameContextMenu({ onClick: () => { if (isGameRunning) { void handleCloseGame(); - } else { + } else if (canPlay) { void handlePlayGame(); + } else { + handleOpenDownloadOptions(); } }, disabled: isDeleting, diff --git a/src/renderer/src/components/header/header.scss b/src/renderer/src/components/header/header.scss index f0c72ce0..cd25d8e2 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: 200px; + width: 300px; align-items: center; border-radius: 8px; border: solid 1px globals.$border-color; @@ -35,7 +35,7 @@ } &--focused { - width: 250px; + width: 350px; border-color: #dadbe1; } } diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 6f97729f..0f452bf2 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -13,6 +13,7 @@ import cn from "classnames"; const pathTitle: Record = { "/": "home", "/catalogue": "catalogue", + "/library": "library", "/downloads": "downloads", "/settings": "settings", }; @@ -41,6 +42,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]); diff --git a/src/renderer/src/components/sidebar/routes.tsx b/src/renderer/src/components/sidebar/routes.tsx index 608718b6..f579329e 100644 --- a/src/renderer/src/components/sidebar/routes.tsx +++ b/src/renderer/src/components/sidebar/routes.tsx @@ -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: () => , }, + { + path: "/library", + nameKey: "library", + render: () => , + }, { path: "/downloads", nameKey: "downloads", diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index fa4ab3d6..65f2ce9e 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -159,6 +159,7 @@ declare global { ) => Promise; verifyExecutablePathInUse: (executablePath: string) => Promise; getLibrary: () => Promise; + refreshLibraryAssets: () => Promise; openGameInstaller: (shop: GameShop, objectId: string) => Promise; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise; diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a1b5f7d0..84c7f815 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( }> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss new file mode 100644 index 00000000..6f309662 --- /dev/null +++ b/src/renderer/src/pages/library/filter-options.scss @@ -0,0 +1,63 @@ +@use "../../scss/globals.scss"; + +.library-filter-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 8px 16px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; /* prevent label and count from wrapping */ + border: 1px solid rgba(0, 0, 0, 0.06); + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); + } + + &.active { + color: #000; + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; + } + + .library-filter-options__count { + background: #ebebeb; + color: rgba(0, 0, 0, 0.9); + } + } + } + + &__label { + font-weight: 500; + white-space: nowrap; + } + + &__count { + background: rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.95); + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + transition: all ease 0.2s; + } +} diff --git a/src/renderer/src/pages/library/filter-options.tsx b/src/renderer/src/pages/library/filter-options.tsx new file mode 100644 index 00000000..572ebd35 --- /dev/null +++ b/src/renderer/src/pages/library/filter-options.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import "./filter-options.scss"; + +export type FilterOption = "all" | "favourited" | "new" | "top10"; + +interface FilterOptionsProps { + filterBy: FilterOption; + onFilterChange: (filterBy: FilterOption) => void; + allGamesCount: number; + favouritedCount: number; + newGamesCount: number; + top10Count: number; +} + +export function FilterOptions({ + filterBy, + onFilterChange, + allGamesCount, + favouritedCount, + newGamesCount, + top10Count, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
+ + + + +
+ ); +} diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss new file mode 100644 index 00000000..3fceac03 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -0,0 +1,295 @@ +@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; + padding: 0; + 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) 74%, + 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.1) 0%, + rgba(0, 0, 0, 0.2) 50%, + rgba(0, 0, 0, 0.3) 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 * 2); + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + 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; + 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: 8px 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + font-size: 14px; + } + + &__playtime-text { + font-weight: 500; + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__achievements { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 12px; + flex: 1 1 auto; + min-width: 0; + } + + &__achievement-header { + display: flex; + align-items: center; + gap: 8px; + 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: 12px; + 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 new file mode 100644 index 00000000..5628fe10 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -0,0 +1,279 @@ +import { LibraryGame } from "@types"; +import { useDownload, useFormat } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { buildGameDetailsPath } from "@renderer/helpers"; +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 "./library-game-card-large.scss"; + +interface LibraryGameCardLargeProps { + game: LibraryGame; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export function LibraryGameCardLarge({ + game, +}: 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); + + 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 + ); + + // For logo, check if logoImageUrl exists (similar to game details page) + const logoImage = game.logoImageUrl; + + return ( + <> + + + +
+ {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 new file mode 100644 index 00000000..aa957d12 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -0,0 +1,289 @@ +@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.5) 5%, 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; + gap: 4px; + padding: 6px 8px; + opacity: 0; + transform: translateY(8px); + transition: all ease 0.2s; + pointer-events: none; + width: 100%; + } + &__achievements-gap { + display: flex; + align-items: center; + gap: 6px; + } + + &__achievement-header { + display: flex; + align-items: center; + gap: 6px; + justify-content: space-between; + } + + &__achievement-trophy { + color: #fff; + flex-shrink: 0; + } + + &__achievement-progress { + margin-top: 8px; + 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; + position: relative; + } + + &__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; + } + + &__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); + } + } + + &__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 { + 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; + 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; + } +} + +/* Force fixed size for compact grid cells so cards render at 220x320 */ +.library__games-grid--compact .library-game-card__wrapper { + width: 215px; + height: 320px; + aspect-ratio: unset; +} diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx new file mode 100644 index 00000000..a9f2aba2 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -0,0 +1,202 @@ +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 { + 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; +} + +export function LibraryGameCard({ + game, + onMouseEnter, + onMouseLeave, +}: 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 coverImage = + game.coverImageUrl ?? + game.libraryImageUrl ?? + game.libraryHeroImageUrl ?? + game.iconUrl ?? + undefined; + + return ( + <> + + + + {/* Achievements section - shown on hover */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )} +
+ + {game.title} + + setIsTooltipHovered(true)} + afterHide={() => setIsTooltipHovered(false)} + /> + + + ); +} diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss new file mode 100644 index 00000000..9b660a45 --- /dev/null +++ b/src/renderer/src/pages/library/library.scss @@ -0,0 +1,207 @@ +@use "../../scss/globals.scss"; + +.library { + &__content { + padding: calc(globals.$spacing-unit * 3); + 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%; + gap: calc(globals.$spacing-unit * 2); + } + + &__controls-left { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__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 + &--compact { + grid-template-columns: repeat(auto-fill, 215px); + grid-auto-rows: 320px; + justify-content: start; + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + /* keep same pattern for very large screens */ + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(auto-fill, 215px); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(auto-fill, 210px); + } + } + } + + &__games-list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Large view - 2 columns grid + &--large { + display: grid; + grid-template-columns: repeat(1, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(2, 1fr); + } + } + } +} diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx new file mode 100644 index 00000000..1cafc870 --- /dev/null +++ b/src/renderer/src/pages/library/library.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLibrary, useAppDispatch } from "@renderer/hooks"; +import { setHeaderTitle } from "@renderer/features"; +import { TelescopeIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +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() { + const { library, updateLibrary } = useLibrary(); + type ElectronAPI = { + refreshLibraryAssets?: () => Promise; + onLibraryBatchComplete?: (cb: () => void) => () => void; + }; + + const [viewMode, setViewMode] = useState("compact"); + const [filterBy, setFilterBy] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + 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 = () => { + // Optional: pause animations if needed + }; + + const handleOnMouseLeaveGameCard = () => { + // Optional: resume animations if needed + }; + + const filteredLibrary = useMemo(() => { + let filtered; + + switch (filterBy) { + case "favourited": + 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; + } + + 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]); + + // 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 hasGames = library.length > 0; + + return ( +
+ {hasGames && ( +
+
+
+ +
+ +
+ + +
+
+
+ )} + + {!hasGames && ( +
+
+ +
+

{t("no_games_title")}

+

{t("no_games_description")}

+
+ )} + + {hasGames && viewMode === "large" && ( +
+ {sortedLibrary.map((game) => ( + + ))} +
+ )} + + {hasGames && viewMode !== "large" && ( +
    + {sortedLibrary.map((game) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/library/search-bar.scss b/src/renderer/src/pages/library/search-bar.scss new file mode 100644 index 00000000..6b09c683 --- /dev/null +++ b/src/renderer/src/pages/library/search-bar.scss @@ -0,0 +1,75 @@ +.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 new file mode 100644 index 00000000..5a2da667 --- /dev/null +++ b/src/renderer/src/pages/library/search-bar.tsx @@ -0,0 +1,44 @@ +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 new file mode 100644 index 00000000..77bfc10e --- /dev/null +++ b/src/renderer/src/pages/library/view-options.scss @@ -0,0 +1,55 @@ +@use "../../scss/globals.scss"; + +.library-view-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; + } + + &__options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + white-space: nowrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 8px 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.95); + background: rgba(255, 255, 255, 0.06); + } + + &.active { + color: rgba(0, 0, 0, 0.9); + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; + } + } + } +} diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx new file mode 100644 index 00000000..905fac58 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.tsx @@ -0,0 +1,45 @@ +import { AppsIcon, RowsIcon, SquareIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import "./view-options.scss"; + +export type ViewMode = "grid" | "compact" | "large"; + +interface ViewOptionsProps { + viewMode: ViewMode; + onViewModeChange: (viewMode: ViewMode) => void; +} + +export function ViewOptions({ + viewMode, + onViewModeChange, +}: Readonly) { + const { t } = useTranslation("library"); + + return ( +
+
+ + + +
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 18331210..c8e03022 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -362,6 +362,8 @@ export type LibraryGame = Game & Partial & { id: string; download: Download | null; + unlockedAchievementCount?: number; + achievementCount?: number; }; export type UserGameDetails = ShopAssets & {