diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 46bdb28c..97c1e42a 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", @@ -678,6 +680,28 @@ "karma_count": "karma", "karma_description": "Earned from positive likes on 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 8d21aa11..a533de1a 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..8922802b 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -22,10 +22,12 @@ const getLibrary = async (): Promise => { id: key, ...game, download: download ?? null, + // 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 f7ea2744..33d3e3b5 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 da914b92..2b8816c9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -208,6 +208,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/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 e61f3954..328d7f64 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 9f882aed..fc5a4b97 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -161,6 +161,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..e58e285b --- /dev/null +++ b/src/renderer/src/pages/library/filter-options.scss @@ -0,0 +1,58 @@ +@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); + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; /* prevent label and count from wrapping */ + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); + } + + &.active { + color: #c9aa71; + background: rgba(201, 170, 113, 0.15); + + .library-filter-options__count { + background: rgba(201, 170, 113, 0.25); + color: #c9aa71; + } + } + } + + &__label { + font-weight: 500; + white-space: nowrap; + } + + &__count { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); + 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..07c935d9 --- /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, +}: FilterOptionsProps) { + const { t } = useTranslation("library"); + + return ( +
+ + + + +
+ ); +} diff --git a/src/renderer/src/pages/library/library-game-card-detailed.scss b/src/renderer/src/pages/library/library-game-card-detailed.scss new file mode 100644 index 00000000..0038e918 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-detailed.scss @@ -0,0 +1,217 @@ +@use "../../scss/globals.scss"; + +.library-game-card-detailed { + width: 100%; + height: 350px; + 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.05); + transform: translateY(-2px); + 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: top; + background-repeat: no-repeat; + z-index: 0; + } + + &__gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.3) 50%, + rgba(0, 0, 0, 0.5) 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 * 3); + } + + &__menu-button { + align-self: flex-end; + 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.8); + padding: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + + &: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: 140px; + max-width: 450px; + width: auto; + height: auto; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); + } + + &__title { + font-size: 32px; + 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; + justify-content: space-between; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 2); + } + + &__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: 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; + } + + &__action-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all ease 0.2s; + flex-shrink: 0; + + &: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); + } + } + + &__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-detailed.tsx b/src/renderer/src/pages/library/library-game-card-detailed.tsx new file mode 100644 index 00000000..d07dad28 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-detailed.tsx @@ -0,0 +1,209 @@ +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, +} from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { useCallback, useState } from "react"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { GameContextMenu } from "@renderer/components"; +import "./library-game-card-detailed.scss"; + +interface LibraryGameCardDetailedProps { + game: LibraryGame; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export function LibraryGameCardDetailed({ + game, +}: LibraryGameCardDetailedProps) { + 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 handleActionClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (game.executablePath) { + window.electron.openGame( + game.shop, + game.objectId, + game.executablePath, + game.launchOptions + ); + } else { + 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 } }); + }; + + // 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} +

+ )} +
+ +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + + ) : ( + + )} + + {formatPlayTime(game.playTimeInMilliseconds)} + +
+ + +
+ + + + + ); +} 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..86a5a792 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -0,0 +1,227 @@ +@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.05); + transform: translateY(-2px); + 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( + 90deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.3) 50%, + rgba(0, 0, 0, 0.5) 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.5); + } + + &__menu-button { + align-self: flex-end; + 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.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); + } + } + + &__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; + justify-content: space-between; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 2); + } + + &__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: 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; + } + + &__action-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + 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.9); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all ease 0.2s; + flex-shrink: 0; + opacity: 0; + transform: translateY(10px); + + &: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, + &:hover &__action-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..49d93048 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -0,0 +1,205 @@ +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, +} from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { useCallback, useState } from "react"; +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 }: LibraryGameCardLargeProps) { + 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 handleActionClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (game.executablePath) { + window.electron.openGame( + game.shop, + game.objectId, + game.executablePath, + game.launchOptions + ); + } else { + 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 } }); + }; + + // 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}

+ )} +
+ +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + + ) : ( + + )} + + {formatPlayTime(game.playTimeInMilliseconds)} + +
+ + +
+ + + + + ); +} 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..30adacec --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -0,0 +1,206 @@ +@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.05); + } + + &: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.2) 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; + } + + &__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: 50%; + 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: 32px; + height: 32px; + 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); + } + + &__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; + } +} 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..e18df1b0 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -0,0 +1,212 @@ +import { LibraryGame } from "@types"; +import { useFormat, useDownload } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { useCallback, useState } from "react"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { + ClockIcon, + PlayIcon, + DownloadIcon, + AlertFillIcon, + ThreeBarsIcon, +} 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, +}: LibraryGameCardProps) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + const [isTooltipHovered, setIsTooltipHovered] = useState(false); + 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 handleActionClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (game.executablePath) { + // Game is installed, launch it + window.electron.openGame( + game.shop, + game.objectId, + game.executablePath, + game.launchOptions + ); + } else { + // Game is not installed, navigate to download options + 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 ( + <> + + + + {/* Action button - Play or Download */} + + + + {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..1e8038d9 --- /dev/null +++ b/src/renderer/src/pages/library/library.scss @@ -0,0 +1,204 @@ +@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(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(16, 1fr); + } + } + } + + &__games-list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Large view - 2 columns grid + &--large { + display: grid; + grid-template-columns: repeat(1, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(2, 1fr); + } + } + } +} diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx new file mode 100644 index 00000000..0014f5a4 --- /dev/null +++ b/src/renderer/src/pages/library/library.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLibrary, useAppDispatch, useUserDetails } 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 "./library.scss"; + +export default function Library() { + const { library, updateLibrary } = useLibrary(); + + const [viewMode, setViewMode] = useState("grid"); + const [filterBy, setFilterBy] = useState("all"); + const dispatch = useAppDispatch(); + const { t } = useTranslation("library"); + const { userDetails, fetchUserDetails } = useUserDetails(); + + useEffect(() => { + dispatch(setHeaderTitle(t("library"))); + + // Refresh library assets from cloud, then update library display + window.electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); // Fallback to local cache on error + + // Listen for library sync completion to refresh cover images + const unsubscribe = window.electron.onLibraryBatchComplete(() => { + updateLibrary(); + }); + + return () => { + unsubscribe(); + }; + }, [dispatch, t, updateLibrary]); + + // Ensure we have the current user details available + useEffect(() => { + fetchUserDetails().catch(() => { + /* ignore errors - fallback to local state */ + }); + }, [fetchUserDetails]); + + const handleOnMouseEnterGameCard = () => { + // Optional: pause animations if needed + }; + + const handleOnMouseLeaveGameCard = () => { + // Optional: resume animations if needed + }; + + const filteredLibrary = useMemo(() => { + switch (filterBy) { + case "favourited": + return library.filter((game) => game.favorite); + case "new": + return library.filter( + (game) => (game.playTimeInMilliseconds || 0) === 0 + ); + case "top10": + return library + .slice() + .sort( + (a, b) => + (b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0) + ) + .slice(0, 10); + case "all": + default: + return library; + } + }, [library, filterBy]); + + // 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 && ( + <> +
+

+ {`${t("Welcome", { defaultValue: "Welcome" })} ${ + userDetails?.displayName || "John Doe" + }`} +

+ +
+
+ +
+ +
+ +
+
+
+ + )} + + {!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/view-options.scss b/src/renderer/src/pages/library/view-options.scss new file mode 100644 index 00000000..a9b4e197 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.scss @@ -0,0 +1,50 @@ +@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.8); + 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 16px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + font-size: 13px; + 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: #c9aa71; + background: rgba(201, 170, 113, 0.15); + } + } +} 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..3ea19cee --- /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 }: ViewOptionsProps) { + const { t } = useTranslation("library"); + + return ( +
+
+ + + +
+
+ ); +}