From d168e203855e3592885721c30ab355ec99a54177 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Mon, 20 Oct 2025 23:43:47 +0530 Subject: [PATCH 01/95] feat(library): implement large game card and enhance library UI - Added `LibraryGameCardLarge` component for displaying games in a larger format with improved styling and animations. - Introduced SCSS styles for the large game card, including hover effects and gradient overlays. - Updated `LibraryGameCard` component to support mouse enter and leave events for better interaction. - Enhanced the library view options with new styles and functionality for switching between grid, compact, and large views. - Improved overall layout and responsiveness of the library page, ensuring a better user experience across different screen sizes. - Added tooltips for playtime information and context menus for game actions. --- src/locales/en/translation.json | 24 ++ src/main/events/index.ts | 1 + src/main/events/library/get-library.ts | 8 +- .../events/library/refresh-library-assets.ts | 8 + .../library-sync/merge-with-remote-games.ts | 9 +- src/preload/index.ts | 1 + src/renderer/src/app.scss | 2 +- .../src/components/header/header.scss | 4 +- src/renderer/src/components/header/header.tsx | 3 + .../src/components/sidebar/routes.tsx | 6 + src/renderer/src/declaration.d.ts | 1 + src/renderer/src/main.tsx | 2 + .../src/pages/library/filter-options.scss | 58 +++++ .../src/pages/library/filter-options.tsx | 61 +++++ .../library/library-game-card-detailed.scss | 217 +++++++++++++++++ .../library/library-game-card-detailed.tsx | 209 ++++++++++++++++ .../library/library-game-card-large.scss | 227 ++++++++++++++++++ .../pages/library/library-game-card-large.tsx | 205 ++++++++++++++++ .../src/pages/library/library-game-card.scss | 206 ++++++++++++++++ .../src/pages/library/library-game-card.tsx | 212 ++++++++++++++++ src/renderer/src/pages/library/library.scss | 204 ++++++++++++++++ src/renderer/src/pages/library/library.tsx | 164 +++++++++++++ .../src/pages/library/view-options.scss | 50 ++++ .../src/pages/library/view-options.tsx | 45 ++++ 24 files changed, 1920 insertions(+), 7 deletions(-) create mode 100644 src/main/events/library/refresh-library-assets.ts create mode 100644 src/renderer/src/pages/library/filter-options.scss create mode 100644 src/renderer/src/pages/library/filter-options.tsx create mode 100644 src/renderer/src/pages/library/library-game-card-detailed.scss create mode 100644 src/renderer/src/pages/library/library-game-card-detailed.tsx create mode 100644 src/renderer/src/pages/library/library-game-card-large.scss create mode 100644 src/renderer/src/pages/library/library-game-card-large.tsx create mode 100644 src/renderer/src/pages/library/library-game-card.scss create mode 100644 src/renderer/src/pages/library/library-game-card.tsx create mode 100644 src/renderer/src/pages/library/library.scss create mode 100644 src/renderer/src/pages/library/library.tsx create mode 100644 src/renderer/src/pages/library/view-options.scss create mode 100644 src/renderer/src/pages/library/view-options.tsx 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 ( +
+
+ + + +
+
+ ); +} From 33e0d509668c4eb24dd09593c332324c6380b032 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 14:24:04 +0530 Subject: [PATCH 02/95] feat: add achievements tracking to game library - Updated `get-library.ts` to include unlocked and total achievement counts for each game. - Removed `library-game-card-detailed.tsx` and its associated styles as part of the refactor. - Enhanced `library-game-card-large.tsx` to display achievements with progress bars. - Modified `library-game-card.scss` and `library-game-card-large.scss` to style the achievements section. - Introduced a new `search-bar` component for filtering the game library. - Implemented fuzzy search functionality in the library view. - Updated `view-options` to improve UI consistency. - Added achievement-related properties to the `LibraryGame` type in `index.ts`. - Created a new `copilot-instructions.md` for project guidelines. --- src/main/events/library/get-library.ts | 17 ++ .../library/library-game-card-detailed.scss | 217 ------------------ .../library/library-game-card-detailed.tsx | 209 ----------------- .../library/library-game-card-large.scss | 75 +++++- .../pages/library/library-game-card-large.tsx | 78 +++++-- .../src/pages/library/library-game-card.scss | 67 +++++- .../src/pages/library/library-game-card.tsx | 59 +++-- src/renderer/src/pages/library/library.tsx | 46 +++- .../src/pages/library/search-bar.scss | 75 ++++++ src/renderer/src/pages/library/search-bar.tsx | 44 ++++ .../src/pages/library/view-options.scss | 4 +- .../src/pages/library/view-options.tsx | 3 - src/types/index.ts | 2 + 13 files changed, 405 insertions(+), 491 deletions(-) delete mode 100644 src/renderer/src/pages/library/library-game-card-detailed.scss delete mode 100644 src/renderer/src/pages/library/library-game-card-detailed.tsx create mode 100644 src/renderer/src/pages/library/search-bar.scss create mode 100644 src/renderer/src/pages/library/search-bar.tsx diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 8922802b..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,10 +19,26 @@ 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, // Preserve custom image URLs from game if they exist diff --git a/src/renderer/src/pages/library/library-game-card-detailed.scss b/src/renderer/src/pages/library/library-game-card-detailed.scss deleted file mode 100644 index 0038e918..00000000 --- a/src/renderer/src/pages/library/library-game-card-detailed.scss +++ /dev/null @@ -1,217 +0,0 @@ -@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 deleted file mode 100644 index d07dad28..00000000 --- a/src/renderer/src/pages/library/library-game-card-detailed.tsx +++ /dev/null @@ -1,209 +0,0 @@ -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 index 86a5a792..10e8beb5 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -65,9 +65,9 @@ right: 0; bottom: 0; background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.1) 0%, - rgba(0, 0, 0, 0.3) 50%, + 0deg, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 0.2) 50%, rgba(0, 0, 0, 0.5) 100% ); z-index: 1; @@ -81,11 +81,18 @@ display: flex; flex-direction: column; justify-content: space-between; - padding: calc(globals.$spacing-unit * 2.5); + 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-end; + align-self: flex-start; background: rgba(0, 0, 0, 0.3); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); @@ -175,6 +182,59 @@ color: globals.$warning-color; } + &__achievements { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 12px; + width: 100%; + } + + &__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: #ffd700; + flex-shrink: 0; + } + + &__achievement-progress { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; + } + + &__achievement-bar { + height: 100%; + background: linear-gradient(90deg, #ffd700, #ffed4e); + border-radius: 4px; + transition: width 0.3s ease; + } + + &__achievement-count { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + } + + &__achievement-percentage { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + } + &__action-button { display: flex; align-items: center; @@ -191,8 +251,6 @@ font-weight: 600; transition: all ease 0.2s; flex-shrink: 0; - opacity: 0; - transform: translateY(10px); &:hover { background: rgba(255, 255, 255, 0.15); @@ -205,8 +263,7 @@ } } - &:hover &__menu-button, - &:hover &__action-button { + &:hover &__menu-button { opacity: 1; transform: scale(1); } 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 49d93048..cc1c659d 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -8,6 +8,7 @@ import { ClockIcon, AlertFillIcon, ThreeBarsIcon, + TrophyIcon, } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { useCallback, useState } from "react"; @@ -130,14 +131,29 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) {
- +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + + ) : ( + + )} + + {formatPlayTime(game.playTimeInMilliseconds)} + +
+ +
{logoImage ? ( @@ -152,19 +168,39 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) {
-
- {game.hasManuallyUpdatedPlaytime ? ( - - ) : ( - - )} - - {formatPlayTime(game.playTimeInMilliseconds)} - -
+ {/* Achievements section */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )}
- {/* Action button - Play or Download */} - + {/* Achievements section - shown on hover */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )}
("grid"); const [filterBy, setFilterBy] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); const dispatch = useAppDispatch(); const { t } = useTranslation("library"); const { userDetails, fetchUserDetails } = useUserDetails(); @@ -53,27 +55,60 @@ export default function Library() { // Optional: resume animations if needed }; + // Simple fuzzy search function + const fuzzySearch = (query: string, items: typeof library) => { + if (!query.trim()) return items; + + const queryLower = query.toLowerCase(); + return items.filter((game) => { + const titleLower = game.title.toLowerCase(); + let matches = 0; + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + matches++; + queryIndex++; + } + } + + return queryIndex === queryLower.length; + }); + }; + const filteredLibrary = useMemo(() => { + let filtered; + switch (filterBy) { case "favourited": - return library.filter((game) => game.favorite); + filtered = library.filter((game) => game.favorite); + break; case "new": - return library.filter( + filtered = library.filter( (game) => (game.playTimeInMilliseconds || 0) === 0 ); + break; case "top10": - return library + filtered = library .slice() .sort( (a, b) => (b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0) ) .slice(0, 10); + break; case "all": default: - return library; + filtered = library; } - }, [library, filterBy]); + + // Apply search filter + return fuzzySearch(searchQuery, filtered); + }, [library, filterBy, searchQuery]); // No sorting for now — rely on filteredLibrary const sortedLibrary = filteredLibrary; @@ -112,6 +147,7 @@ export default function Library() {
+ 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 a9b4e197..3f49851c 100644 --- a/src/renderer/src/pages/library/view-options.scss +++ b/src/renderer/src/pages/library/view-options.scss @@ -26,13 +26,13 @@ display: flex; align-items: center; gap: calc(globals.$spacing-unit); - padding: 8px 16px; + padding: 8px 10px; 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-size: 14px; font-weight: 500; transition: all ease 0.2s; white-space: nowrap; diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx index 3ea19cee..788eefa9 100644 --- a/src/renderer/src/pages/library/view-options.tsx +++ b/src/renderer/src/pages/library/view-options.tsx @@ -21,7 +21,6 @@ export function ViewOptions({ viewMode, onViewModeChange }: ViewOptionsProps) { title={t("grid_view")} > - {t("Grid View")}
diff --git a/src/types/index.ts b/src/types/index.ts index 63b18645..f714a938 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -372,6 +372,8 @@ export type LibraryGame = Game & Partial & { id: string; download: Download | null; + unlockedAchievementCount?: number; + achievementCount?: number; }; export type UserGameDetails = ShopAssets & { From 811a6ad9557ba74505d58f0948aaf71269b0058a Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 14:42:47 +0530 Subject: [PATCH 03/95] refactor: remove unused imports and download logic from LibraryGameCard --- .../src/pages/library/library-game-card.tsx | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index 0eecc4a6..be0a5a73 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -1,5 +1,5 @@ import { LibraryGame } from "@types"; -import { useFormat, useDownload } from "@renderer/hooks"; +import { useFormat } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; import { useCallback, useState } from "react"; import { buildGameDetailsPath } from "@renderer/helpers"; @@ -8,8 +8,6 @@ import { AlertFillIcon, ThreeBarsIcon, TrophyIcon, - PlayIcon, - DownloadIcon, } from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { Tooltip } from "react-tooltip"; @@ -32,15 +30,11 @@ export function LibraryGameCard({ 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; @@ -66,23 +60,6 @@ export function LibraryGameCard({ 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(); From 107b61f663fd4a8a7b8e8a2f6f7188bfada34ac1 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 14:46:25 +0530 Subject: [PATCH 04/95] style: update active state colors for filter and view options --- src/renderer/src/pages/library/filter-options.scss | 10 ++++++---- src/renderer/src/pages/library/view-options.scss | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss index e58e285b..377527a1 100644 --- a/src/renderer/src/pages/library/filter-options.scss +++ b/src/renderer/src/pages/library/filter-options.scss @@ -29,12 +29,14 @@ } &.active { - color: #c9aa71; - background: rgba(201, 170, 113, 0.15); + &.active { + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.15); + } .library-filter-options__count { - background: rgba(201, 170, 113, 0.25); - color: #c9aa71; + background: rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.95); } } } diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss index 3f49851c..6815f625 100644 --- a/src/renderer/src/pages/library/view-options.scss +++ b/src/renderer/src/pages/library/view-options.scss @@ -43,8 +43,8 @@ } &.active { - color: #c9aa71; - background: rgba(201, 170, 113, 0.15); + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.15); } } } From e19102ea66d8adad2ab94f73cfacc7faf3c4fbc2 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 16:12:12 +0530 Subject: [PATCH 05/95] style: update active state styles for filter and view options; adjust achievement progress bar styles --- .../src/pages/library/filter-options.scss | 14 ++++++---- .../library/library-game-card-large.scss | 23 +++++++++++----- .../src/pages/library/library-game-card.scss | 27 ++++++++++++++----- src/renderer/src/pages/library/library.tsx | 16 +---------- .../src/pages/library/view-options.scss | 9 +++++-- 5 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss index 377527a1..cc899d56 100644 --- a/src/renderer/src/pages/library/filter-options.scss +++ b/src/renderer/src/pages/library/filter-options.scss @@ -22,6 +22,7 @@ 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); @@ -29,14 +30,17 @@ } &.active { - &.active { - color: rgba(255, 255, 255, 0.95); - background: rgba(255, 255, 255, 0.15); + color: #000; + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; } .library-filter-options__count { - background: rgba(255, 255, 255, 0.25); - color: rgba(255, 255, 255, 0.95); + background: #ebebeb; + color: rgba(0, 0, 0, 0.9); } } } 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 10e8beb5..0be1e907 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -203,28 +203,39 @@ } &__achievement-trophy { - color: #ffd700; + color: #fff; flex-shrink: 0; } &__achievement-progress { width: 100%; - height: 6px; - background: rgba(255, 255, 255, 0.1); + 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: linear-gradient(90deg, #ffd700, #ffed4e); + background-color: globals.$muted-color; border-radius: 4px; transition: width 0.3s ease; } &__achievement-count { font-size: 14px; - font-weight: 600; + font-weight: 500; color: rgba(255, 255, 255, 0.9); white-space: nowrap; } @@ -239,7 +250,7 @@ display: flex; align-items: center; gap: 8px; - padding: 12px 24px; + padding: 10px 20px; border-radius: 6px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index ae4db0c8..b763ae2c 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -131,28 +131,41 @@ } &__achievement-trophy { - color: #ffd700; + color: #fff; flex-shrink: 0; } &__achievement-progress { + margin-top: 8px; width: 100%; - height: 6px; - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; + 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: linear-gradient(90deg, #ffd700, #ffed4e); - border-radius: 3px; + background-color: globals.$muted-color; + border-radius: 4px; transition: width 0.3s ease; + position: relative; } &__achievement-count { font-size: 12px; - font-weight: 600; + font-weight: 500; color: rgba(255, 255, 255, 0.9); white-space: nowrap; } diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 1c6baf17..748aba4f 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { useLibrary, useAppDispatch, useUserDetails } from "@renderer/hooks"; +import { useLibrary, useAppDispatch } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; @@ -19,7 +19,6 @@ export default function Library() { const [searchQuery, setSearchQuery] = useState(""); const dispatch = useAppDispatch(); const { t } = useTranslation("library"); - const { userDetails, fetchUserDetails } = useUserDetails(); useEffect(() => { dispatch(setHeaderTitle(t("library"))); @@ -40,13 +39,6 @@ export default function Library() { }; }, [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 }; @@ -128,12 +120,6 @@ export default function Library() { {hasGames && ( <>
-

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

-
Date: Wed, 22 Oct 2025 18:28:24 +0530 Subject: [PATCH 06/95] style: update compact view styles for game cards; adjust grid layout and add button order --- .../src/pages/library/library-game-card.scss | 7 +++++++ src/renderer/src/pages/library/library.scss | 15 +++++++++------ src/renderer/src/pages/library/library.tsx | 2 +- src/renderer/src/pages/library/view-options.tsx | 14 +++++++------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index b763ae2c..d6bf8c6d 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -280,3 +280,10 @@ 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.scss b/src/renderer/src/pages/library/library.scss index 1e8038d9..9b660a45 100644 --- a/src/renderer/src/pages/library/library.scss +++ b/src/renderer/src/pages/library/library.scss @@ -161,26 +161,29 @@ // Compact view - smaller cards &--compact { - grid-template-columns: repeat(3, 1fr); + 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(5, 1fr); + grid-template-columns: repeat(auto-fill, 215px); } @container #{globals.$app-container} (min-width: 1300px) { - grid-template-columns: repeat(7, 1fr); + 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(9, 1fr); + grid-template-columns: repeat(auto-fill, 215px); } @container #{globals.$app-container} (min-width: 2600px) { - grid-template-columns: repeat(12, 1fr); + grid-template-columns: repeat(auto-fill, 215px); } @container #{globals.$app-container} (min-width: 3000px) { - grid-template-columns: repeat(16, 1fr); + grid-template-columns: repeat(auto-fill, 210px); } } } diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 748aba4f..2d58bb08 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -14,7 +14,7 @@ import "./library.scss"; export default function Library() { const { library, updateLibrary } = useLibrary(); - const [viewMode, setViewMode] = useState("grid"); + const [viewMode, setViewMode] = useState("compact"); const [filterBy, setFilterBy] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); const dispatch = useAppDispatch(); diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx index 788eefa9..662bab65 100644 --- a/src/renderer/src/pages/library/view-options.tsx +++ b/src/renderer/src/pages/library/view-options.tsx @@ -15,13 +15,6 @@ export function ViewOptions({ viewMode, onViewModeChange }: ViewOptionsProps) { return (
- + + +
- {!hasAnyGames && ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe &&

{t("no_recent_activity_description")}

} -
- )} +
+ +
+ {t("loading_reviews")} +
) : reviews.length === 0 ? (

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

@@ -461,22 +470,35 @@ export function ProfileContent() {
- {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true })} + {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
{Array.from({ length: 5 }, (_, index) => ( -
+
handleVoteReview(review.id, true)} + onClick={() => + handleVoteReview(review.id, true) + } disabled={votingReviews.has(review.id)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -518,7 +544,9 @@ export function ProfileContent() { handleVoteReview(review.id, false)} + onClick={() => + handleVoteReview(review.id, false) + } disabled={votingReviews.has(review.id)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} From f5470b29c065bf110c5fcefd408ae3ca5ed86330 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Thu, 23 Oct 2025 10:58:31 +0530 Subject: [PATCH 09/95] style: adjust hover effects and dimensions for game cards; refine context menu actions --- .../game-context-menu/game-context-menu.tsx | 4 +- .../library/library-game-card-large.scss | 16 ++++---- .../pages/library/library-game-card-large.tsx | 39 ++++++++++++++----- .../src/pages/library/library-game-card.scss | 6 +-- 4 files changed, 44 insertions(+), 21 deletions(-) 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/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss index 0be1e907..700c7d0b 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -40,8 +40,7 @@ } &:hover { - transform: scale(1.05); - transform: translateY(-2px); + transform: scale(1.01); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); border-color: rgba(255, 255, 255, 0.1); } @@ -66,9 +65,9 @@ bottom: 0; background: linear-gradient( 0deg, - rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.2) 50%, - rgba(0, 0, 0, 0.5) 100% + rgba(0, 0, 0, 0.3) 100% ); z-index: 1; } @@ -154,9 +153,9 @@ &__info-bar { display: flex; - justify-content: space-between; - align-items: flex-end; + align-items: center; gap: calc(globals.$spacing-unit * 2); + justify-content: flex-end; } &__playtime { @@ -187,7 +186,8 @@ flex-direction: column; gap: 6px; padding: 6px 12px; - width: 100%; + flex: 1 1 auto; + min-width: 0; } &__achievement-header { @@ -261,7 +261,7 @@ font-size: 14px; font-weight: 600; transition: all ease 0.2s; - flex-shrink: 0; + flex: 0 0 auto; &:hover { background: rgba(255, 255, 255, 0.15); 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 cc1c659d..3c9285e9 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -9,9 +9,11 @@ import { 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"; @@ -66,18 +68,32 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) { navigate(buildGameDetailsPath(game)); }; + const { + handlePlayGame, + handleOpenDownloadOptions, + handleCloseGame, + isGameRunning, + } = useGameActions(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)); + if (isGameRunning) { + try { + await handleCloseGame(); + } catch (e) { + void e; + } + return; + } + try { + await handlePlayGame(); + } catch (err) { + try { + handleOpenDownloadOptions(); + } catch (e) { + void e; + } } }; @@ -215,6 +231,11 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) { /> {t("downloading")} + ) : isGameRunning ? ( + <> + + {t("close")} + ) : game.executablePath ? ( <> diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index d6bf8c6d..aa957d12 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -37,7 +37,7 @@ } &:hover { - transform: scale(1.05); + transform: scale(1.02); } &:hover::before { @@ -215,8 +215,8 @@ -webkit-backdrop-filter: blur(8px); border: solid 1px rgba(255, 255, 255, 0.15); border-radius: 4px; - width: 32px; - height: 32px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; From acf8f340dd171c64b819c95c37045acf1fd314ae Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 10:33:29 +0300 Subject: [PATCH 10/95] ci: review message ui change and fix loading reviews positioning --- .../profile-content/profile-content.scss | 22 +++++++- .../profile-content/profile-content.tsx | 51 ++++++++++--------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 4cdea61b..21acfa47 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -182,6 +182,10 @@ .user-reviews__loading { padding: calc(globals.$spacing-unit * 2); color: rgba(255, 255, 255, 0.8); + text-align: center; + display: flex; + justify-content: center; + align-items: center; } .user-reviews__empty { @@ -208,6 +212,14 @@ margin-bottom: calc(globals.$spacing-unit * 1.5); } +.user-reviews__review-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: calc(globals.$spacing-unit * 1.5); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + .user-reviews__review-game { display: flex; align-items: center; @@ -215,8 +227,8 @@ } .user-reviews__game-icon { - width: 40px; - height: 40px; + width: 24px; + height: 24px; border-radius: 8px; object-fit: cover; } @@ -227,6 +239,12 @@ gap: calc(globals.$spacing-unit * 0.25); } +.user-reviews__game-details { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); +} + .user-reviews__game-title { background: none; border: none; diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index d245aa5e..97d9b1a9 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -461,29 +461,12 @@ export function ProfileContent() { transition={{ duration: 0.3 }} >
-
- {review.game.title} -
- -
- {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
-
+
+ {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
@@ -517,6 +500,28 @@ export function ProfileContent() { }} /> +
+
+
+
+ {review.game.title} + +
+
+
+
+
Date: Thu, 23 Oct 2025 10:34:15 +0300 Subject: [PATCH 11/95] ci: formatting --- .../src/pages/profile/profile-content/profile-content.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 97d9b1a9..b37f7517 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -512,7 +512,9 @@ export function ProfileContent() {
{/* render reviews content unconditionally */} - {isLoadingReviews ? ( -
- {t("loading_reviews")} -
- ) : reviews.length === 0 ? ( -
-

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

-
- ) : ( -
- {reviews.map((review) => { + {(() => { + if (isLoadingReviews) { + return ( +
+ {t("loading_reviews")} +
+ ); + } + + if (reviews.length === 0) { + return ( +
+

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

+
+ ); + } + + return ( +
+ {reviews.map((review) => { const isOwnReview = userDetails?.id === review.user.id; return ( @@ -586,7 +595,8 @@ export function ProfileContent() { ); })}
- )} + ); + })()}
From f5399774316da2250c7049c3049f2900406f4e58 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 11:53:35 +0300 Subject: [PATCH 15/95] fix: refactoring functions to prevent nesting more than 4 lvls --- .../profile-content/profile-content.tsx | 294 +++++++++--------- 1 file changed, 143 insertions(+), 151 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 679556db..f5511a1e 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -437,166 +437,158 @@ export function ProfileContent() {
{/* render reviews content unconditionally */} - {(() => { - if (isLoadingReviews) { - return ( -
- {t("loading_reviews")} -
- ); - } - - if (reviews.length === 0) { - return ( -
-

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

-
- ); - } - - return ( -
- {reviews.map((review) => { - const isOwnReview = userDetails?.id === review.user.id; + {isLoadingReviews && ( +
+ {t("loading_reviews")} +
+ )} + {!isLoadingReviews && reviews.length === 0 && ( +
+

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

+
+ )} + {!isLoadingReviews && reviews.length > 0 && ( +
+ {reviews.map((review) => { + const isOwnReview = userDetails?.id === review.user.id; - return ( - -
-
- {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
+ return ( + +
+
+ {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )} +
-
- {Array.from({ length: 5 }, (_, index) => ( -
- -
- ))} -
-
- -
- -
-
-
-
- {review.game.title} - + +
+ ))} +
+
+ +
+ +
+
+
+
+ {review.game.title} + +
-
-
-
- - handleVoteReview(review.id, true) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.upvotes} - - - +
+
+ + handleVoteReview(review.id, true) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + - - handleVoteReview(review.id, false) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.downvotes} - - - + + handleVoteReview(review.id, false) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
+ + {isOwnReview && ( + + )}
- - {isOwnReview && ( - - )} -
- - ); - })} -
- ); - })()} + + ); + })} +
+ )}
From d21ec52814e17d657360bfba75443aa1cc3bae45 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 12:06:23 +0300 Subject: [PATCH 16/95] ci: deleted comments --- .../profile-content/profile-content.tsx | 264 +++++++++--------- 1 file changed, 131 insertions(+), 133 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index f5511a1e..dd09ed0b 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -26,8 +26,7 @@ import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; import type { GameShop } from "@types"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { - // removed: sectionVariants, - // removed: chevronVariants, + GAME_STATS_ANIMATION_DURATION_IN_MS, } from "./profile-animations"; import "./profile-content.scss"; @@ -450,145 +449,145 @@ export function ProfileContent() { {!isLoadingReviews && reviews.length > 0 && (
{reviews.map((review) => { - const isOwnReview = userDetails?.id === review.user.id; + const isOwnReview = userDetails?.id === review.user.id; - return ( - -
-
- {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
- -
- {Array.from({ length: 5 }, (_, index) => ( -
- -
- ))} -
+ return ( + +
+
+ {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
-
+
+ {Array.from({ length: 5 }, (_, index) => ( +
+ +
+ ))} +
+
-
-
-
-
- {review.game.title} - -
+
+ +
+
+
+
+ {review.game.title} +
+
-
-
- - handleVoteReview(review.id, true) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.upvotes} - - - +
+
+ + handleVoteReview(review.id, true) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + - - handleVoteReview(review.id, false) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.downvotes} - - - -
- - {isOwnReview && ( - - )} + + handleVoteReview(review.id, false) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + +
- - ); - })} -
- )} + + {isOwnReview && ( + + )} +
+ + ); + })} +
+ )}
@@ -621,8 +620,7 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - // removed isPinnedCollapsed, - // removed toggleSection, + sortBy, activeTab, ]); From daf9751cf6ebf1532c600f86be3b9dde2ab05931 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 14:27:03 +0300 Subject: [PATCH 17/95] ci: import formatting --- .../src/pages/profile/profile-content/profile-content.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index dd09ed0b..cd833c6c 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -25,10 +25,7 @@ import { buildGameDetailsPath } from "@renderer/helpers"; import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; import type { GameShop } from "@types"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; -import { - - GAME_STATS_ANIMATION_DURATION_IN_MS, -} from "./profile-animations"; +import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; From cc95deb709c78d0d9621ca8ad37ea0280873381b Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 14:40:02 +0300 Subject: [PATCH 18/95] fix: proreply reseting user reviews on profile changing --- .../src/pages/profile/profile-content/profile-content.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index cd833c6c..9955640b 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -108,6 +108,14 @@ export function ProfileContent() { } }, [sortBy, getUserLibraryGames, userProfile]); + // Clear reviews state and reset tab when switching users + useEffect(() => { + setReviews([]); + setReviewsTotalCount(0); + setIsLoadingReviews(false); + setActiveTab("library"); + }, [userProfile?.id]); + useEffect(() => { if (userProfile?.id) { fetchUserReviews(); From 81a77411ccd325692395d79f188fee5f6d2911df Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 16:54:18 +0300 Subject: [PATCH 19/95] ci: fix gap between game image and game name in reviews --- .../src/pages/profile/profile-content/profile-content.scss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 45a7f119..15b9de6d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -230,27 +230,24 @@ .user-reviews__review-game { display: flex; - align-items: center; gap: calc(globals.$spacing-unit); } .user-reviews__game-icon { width: 24px; height: 24px; - border-radius: 8px; object-fit: cover; } .user-reviews__game-info { display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit * 0.25); } .user-reviews__game-details { display: flex; align-items: center; - gap: calc(globals.$spacing-unit * 0.5); + gap: calc(globals.$spacing-unit * 0.75); } .user-reviews__game-title { From 29e1713824856a4f455dd06361214b5fc69786ab Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 20:06:37 +0300 Subject: [PATCH 20/95] fix: upvote/downvote button arent being disabled after click --- .../profile-content/profile-content.tsx | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 9955640b..44af9c1d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -181,7 +181,14 @@ export function ProfileContent() { setVotingReviews((prev) => new Set(prev).add(reviewId)); const review = reviews.find((r) => r.id === reviewId); - if (!review) return; + if (!review) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } const wasUpvoted = review.hasUpvoted; const wasDownvoted = review.hasDownvoted; @@ -258,11 +265,13 @@ export function ProfileContent() { }) ); } finally { - setVotingReviews((prev) => { - const newSet = new Set(prev); - newSet.delete(reviewId); - return newSet; - }); + setTimeout(() => { + setVotingReviews((prev) => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + }, 500); } }; @@ -536,6 +545,10 @@ export function ProfileContent() { handleVoteReview(review.id, true) } disabled={votingReviews.has(review.id)} + style={{ + opacity: votingReviews.has(review.id) ? 0.5 : 1, + cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > @@ -559,6 +572,10 @@ export function ProfileContent() { handleVoteReview(review.id, false) } disabled={votingReviews.has(review.id)} + style={{ + opacity: votingReviews.has(review.id) ? 0.5 : 1, + cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > @@ -628,6 +645,12 @@ export function ProfileContent() { sortBy, activeTab, + // ensure reviews UI updates correctly + reviews, + reviewsTotalCount, + isLoadingReviews, + votingReviews, + deleteModalVisible, ]); return ( From 8de6c92d28ef63d26217d85a82880292d5d90c90 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 24 Oct 2025 08:19:55 +0300 Subject: [PATCH 21/95] ci: formatting --- .../profile/profile-content/profile-content.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 44af9c1d..e284cb88 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -546,8 +546,12 @@ export function ProfileContent() { } disabled={votingReviews.has(review.id)} style={{ - opacity: votingReviews.has(review.id) ? 0.5 : 1, - cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + opacity: votingReviews.has(review.id) + ? 0.5 + : 1, + cursor: votingReviews.has(review.id) + ? "not-allowed" + : "pointer", }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -573,8 +577,12 @@ export function ProfileContent() { } disabled={votingReviews.has(review.id)} style={{ - opacity: votingReviews.has(review.id) ? 0.5 : 1, - cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + opacity: votingReviews.has(review.id) + ? 0.5 + : 1, + cursor: votingReviews.has(review.id) + ? "not-allowed" + : "pointer", }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} From 101bc3546086123c80dce7279bac273407992a86 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:21:31 +0200 Subject: [PATCH 22/95] feat: sidebar badge on new game download option --- .../downloadSourcesCheckTimestamp.ts | 16 +++ src/main/level/sublevels/index.ts | 1 + src/main/level/sublevels/keys.ts | 1 + src/main/main.ts | 4 + src/main/services/download-sources-checker.ts | 127 ++++++++++++++++++ src/main/services/hydra-api.ts | 32 +++++ src/main/services/index.ts | 1 + src/preload/index.ts | 11 ++ src/renderer/src/app.tsx | 4 + .../components/sidebar/sidebar-game-item.tsx | 7 + .../src/components/sidebar/sidebar.scss | 20 +++ src/renderer/src/declaration.d.ts | 5 + src/renderer/src/features/library-slice.ts | 20 ++- src/renderer/src/hooks/index.ts | 1 + .../hooks/use-download-options-listener.ts | 19 +++ src/types/level.types.ts | 1 + 16 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/main/level/sublevels/downloadSourcesCheckTimestamp.ts create mode 100644 src/main/services/download-sources-checker.ts create mode 100644 src/renderer/src/hooks/use-download-options-listener.ts diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts new file mode 100644 index 00000000..13dbf682 --- /dev/null +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -0,0 +1,16 @@ +import { levelKeys } from "./keys"; +import { db } from "../level"; + +export const getLastDownloadSourcesCheck = async (): Promise => { + try { + const timestamp = await db.get(levelKeys.lastDownloadSourcesCheck); + return timestamp; + } catch (error) { + // Key doesn't exist yet + return null; + } +}; + +export const updateLastDownloadSourcesCheck = async (timestamp: string): Promise => { + await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); +}; \ No newline at end of file diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 3619ae26..4575bbc4 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -7,3 +7,4 @@ export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; export * from "./download-sources"; +export * from "./downloadSourcesCheckTimestamp"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index a28690b2..536f9dca 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,4 +18,5 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", + lastDownloadSourcesCheck: "lastDownloadSourcesCheck", }; diff --git a/src/main/main.ts b/src/main/main.ts index ffb8f8a9..8e1264d7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,6 +16,7 @@ import { Ludusavi, Lock, DeckyPlugin, + DownloadSourcesChecker, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -56,6 +57,9 @@ export const loadState = async () => { const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); + + // Check for new download options on startup + void DownloadSourcesChecker.checkForChanges(); // WSClient.connect(); }); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts new file mode 100644 index 00000000..f22f12fc --- /dev/null +++ b/src/main/services/download-sources-checker.ts @@ -0,0 +1,127 @@ +import { HydraApi } from "./hydra-api"; +import { gamesSublevel, getLastDownloadSourcesCheck, updateLastDownloadSourcesCheck, downloadSourcesSublevel } from "@main/level"; +import { logger } from "./logger"; +import { WindowManager } from "./window-manager"; +import type { Game } from "@types"; + +interface DownloadSourcesChangeResponse { + shop: string; + objectId: string; + newDownloadOptionsCount: number; + downloadSourceIds: string[]; +} + +export class DownloadSourcesChecker { + static async checkForChanges(): Promise { + logger.info("DownloadSourcesChecker.checkForChanges() called"); + + try { + // Get all installed games (excluding custom games) + const installedGames = await gamesSublevel.values().all(); + const nonCustomGames = installedGames.filter((game: Game) => game.shop !== 'custom'); + logger.info(`Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games`); + + if (nonCustomGames.length === 0) { + logger.info("No non-custom games found, skipping download sources check"); + return; + } + + // Get download sources + const downloadSources = await downloadSourcesSublevel.values().all(); + const downloadSourceIds = downloadSources.map(source => source.id); + logger.info(`Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(', ')}`); + + if (downloadSourceIds.length === 0) { + logger.info("No download sources found, skipping download sources check"); + return; + } + + // Get last check timestamp or use a default (24 hours ago) + const lastCheck = await getLastDownloadSourcesCheck(); + const since = lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + logger.info(`Last check: ${lastCheck}, using since: ${since}`); + + // Clear any previously stored new download option counts so badges don't persist across restarts + const previouslyFlaggedGames = nonCustomGames.filter( + (game: Game) => (game as Game).newDownloadOptionsCount && (game as Game).newDownloadOptionsCount! > 0 + ); + + const clearedPayload: { gameId: string; count: number }[] = []; + if (previouslyFlaggedGames.length > 0) { + logger.info(`Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games`); + for (const game of previouslyFlaggedGames) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: undefined, + }); + clearedPayload.push({ gameId: `${game.shop}:${game.objectId}`, count: 0 }); + } + } + + // Prepare games array for API call (excluding custom games) + const games = nonCustomGames.map((game: Game) => ({ + shop: game.shop, + objectId: game.objectId + })); + + logger.info(`Checking download sources changes for ${games.length} non-custom games since ${since}`); + logger.info(`Making API call to HydraApi.checkDownloadSourcesChanges with:`, { + downloadSourceIds, + gamesCount: games.length, + since + }); + + // Call the API + const response = await HydraApi.checkDownloadSourcesChanges( + downloadSourceIds, + games, + since + ); + + logger.info("API call completed, response:", response); + + // Update the last check timestamp + await updateLastDownloadSourcesCheck(new Date().toISOString()); + + // Process the response and store newDownloadOptionsCount for games with new options + if (response && Array.isArray(response)) { + const gamesWithNewOptions: { gameId: string; count: number }[] = []; + + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find(g => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + ); + + if (game) { + // Store the new download options count in the game data + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount + }); + + logger.info(`Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options`); + } + } + } + + // Send IPC event to renderer to clear stale badges and set fresh counts from response + const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; + if (eventPayload.length > 0 && WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-new-download-options", eventPayload); + } + + logger.info(`Found new download options for ${gamesWithNewOptions.length} games`); + } + + logger.info("Download sources check completed successfully"); + } catch (error) { + logger.error("Failed to check download sources changes:", error); + } + } +} \ No newline at end of file diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 12090df3..4f7091db 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -399,4 +399,36 @@ export class HydraApi { .then((response) => response.data) .catch(this.handleUnauthorizedError); } + + static async checkDownloadSourcesChanges( + downloadSourceIds: string[], + games: Array<{ shop: string; objectId: string }>, + since: string + ) { + logger.info("HydraApi.checkDownloadSourcesChanges called with:", { + downloadSourceIds, + gamesCount: games.length, + since, + isLoggedIn: this.isLoggedIn() + }); + + try { + const result = await this.post>("/download-sources/changes", { + downloadSourceIds, + games, + since, + }, { needsAuth: true }); + + logger.info("HydraApi.checkDownloadSourcesChanges completed successfully:", result); + return result; + } catch (error) { + logger.error("HydraApi.checkDownloadSourcesChanges failed:", error); + throw error; + } + } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index da4e6848..a3891dc6 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -19,3 +19,4 @@ export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; export * from "./user"; +export * from "./download-sources-checker"; diff --git a/src/preload/index.ts b/src/preload/index.ts index f89ec4db..a1a0d959 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -580,6 +580,17 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-custom-theme-updated", listener); }, + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + gamesWithNewOptions: { gameId: string; count: number }[] + ) => cb(gamesWithNewOptions); + ipcRenderer.on("on-new-download-options", listener); + return () => + ipcRenderer.removeListener("on-new-download-options", listener); + }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 168a4435..274e95db 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -10,6 +10,7 @@ import { useToast, useUserDetails, } from "@renderer/hooks"; +import { useDownloadOptionsListener } from "@renderer/hooks/use-download-options-listener"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { @@ -36,6 +37,9 @@ export function App() { const contentRef = useRef(null); const { updateLibrary, library } = useLibrary(); + // Listen for new download options updates + useDownloadOptionsListener(); + const { t } = useTranslation("app"); const { clearDownload, setLastPacket } = useDownload(); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 356aa913..add7e081 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -80,6 +80,13 @@ export function SidebarGameItem({ {getGameTitle(game)} + + {game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && ( + +
+
+
{game.newDownloadOptionsCount}
+
+ )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 49f6e007..9068e2b5 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -115,6 +115,26 @@ background-size: cover; } + &__game-badge { + background: rgba(255, 255, 255, 0.1);; + color: #fff; + font-size: 10px; + font-weight: bold; + padding: 4px 6px; + border-radius: 6px; + display: flex; + margin-left: auto; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + gap: calc(globals.$spacing-unit * 0.35); + } + + &__game-badge-plus, + &__game-badge-count { + display: flex; + align-items: center; + justify-content: center; + } + &__section-header { display: flex; justify-content: space-between; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index fa4ab3d6..30b5c67a 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -414,6 +414,11 @@ declare global { openEditorWindow: (themeId: string) => Promise; onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer; closeEditorWindow: (themeId?: string) => Promise; + + /* Download Options */ + onNewDownloadOptions: ( + cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void + ) => () => Electron.IpcRenderer; } interface Window { diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index 6c95aa79..e92e6a25 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -18,7 +18,25 @@ export const librarySlice = createSlice({ setLibrary: (state, action: PayloadAction) => { state.value = action.payload; }, + updateGameNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string; count: number }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = action.payload.count; + } + }, + clearNewDownloadOptions: ( + state, + action: PayloadAction<{ gameId: string }> + ) => { + const game = state.value.find((g) => g.id === action.payload.gameId); + if (game) { + game.newDownloadOptionsCount = undefined; + } + }, }, }); -export const { setLibrary } = librarySlice.actions; +export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions } = librarySlice.actions; diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 73733e2b..2d2ee02f 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -6,3 +6,4 @@ export * from "./redux"; export * from "./use-user-details"; export * from "./use-format"; export * from "./use-feature"; +export * from "./use-download-options-listener"; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts new file mode 100644 index 00000000..f0268335 --- /dev/null +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useAppDispatch } from "./redux"; +import { updateGameNewDownloadOptions } from "@renderer/features"; + +export function useDownloadOptionsListener() { + const dispatch = useAppDispatch(); + + useEffect(() => { + const unsubscribe = window.electron.onNewDownloadOptions( + (gamesWithNewOptions) => { + gamesWithNewOptions.forEach(({ gameId, count }) => { + dispatch(updateGameNewDownloadOptions({ gameId, count })); + }); + } + ); + + return unsubscribe; + }, [dispatch]); +} \ No newline at end of file diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 1df55b9e..ff602ac9 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -59,6 +59,7 @@ export interface Game { pinnedDate?: Date | null; automaticCloudSync?: boolean; hasManuallyUpdatedPlaytime?: boolean; + newDownloadOptionsCount?: number; } export interface Download { From 4dd3c9de76e0765a3b001c01577bbf2cf56d7a33 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:26:22 +0200 Subject: [PATCH 23/95] fix: formatting --- .../downloadSourcesCheckTimestamp.ts | 6 +- src/main/main.ts | 2 +- src/main/services/download-sources-checker.ts | 106 ++++++++++++------ src/main/services/hydra-api.ts | 35 +++--- .../components/sidebar/sidebar-game-item.tsx | 4 +- .../src/components/sidebar/sidebar.scss | 2 +- src/renderer/src/features/library-slice.ts | 6 +- .../hooks/use-download-options-listener.ts | 2 +- 8 files changed, 108 insertions(+), 55 deletions(-) diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index 13dbf682..f7071932 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -11,6 +11,8 @@ export const getLastDownloadSourcesCheck = async (): Promise => { } }; -export const updateLastDownloadSourcesCheck = async (timestamp: string): Promise => { +export const updateLastDownloadSourcesCheck = async ( + timestamp: string +): Promise => { await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); -}; \ No newline at end of file +}; diff --git a/src/main/main.ts b/src/main/main.ts index 8e1264d7..50173390 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -57,7 +57,7 @@ export const loadState = async () => { const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); - + // Check for new download options on startup void DownloadSourcesChecker.checkForChanges(); // WSClient.connect(); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index f22f12fc..d2ccb531 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -1,5 +1,10 @@ import { HydraApi } from "./hydra-api"; -import { gamesSublevel, getLastDownloadSourcesCheck, updateLastDownloadSourcesCheck, downloadSourcesSublevel } from "@main/level"; +import { + gamesSublevel, + getLastDownloadSourcesCheck, + updateLastDownloadSourcesCheck, + downloadSourcesSublevel, +} from "@main/level"; import { logger } from "./logger"; import { WindowManager } from "./window-manager"; import type { Game } from "@types"; @@ -14,62 +19,85 @@ interface DownloadSourcesChangeResponse { export class DownloadSourcesChecker { static async checkForChanges(): Promise { logger.info("DownloadSourcesChecker.checkForChanges() called"); - + try { // Get all installed games (excluding custom games) const installedGames = await gamesSublevel.values().all(); - const nonCustomGames = installedGames.filter((game: Game) => game.shop !== 'custom'); - logger.info(`Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games`); - + const nonCustomGames = installedGames.filter( + (game: Game) => game.shop !== "custom" + ); + logger.info( + `Found ${installedGames.length} total games, ${nonCustomGames.length} non-custom games` + ); + if (nonCustomGames.length === 0) { - logger.info("No non-custom games found, skipping download sources check"); + logger.info( + "No non-custom games found, skipping download sources check" + ); return; } // Get download sources const downloadSources = await downloadSourcesSublevel.values().all(); - const downloadSourceIds = downloadSources.map(source => source.id); - logger.info(`Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(', ')}`); + const downloadSourceIds = downloadSources.map((source) => source.id); + logger.info( + `Found ${downloadSourceIds.length} download sources: ${downloadSourceIds.join(", ")}` + ); if (downloadSourceIds.length === 0) { - logger.info("No download sources found, skipping download sources check"); + logger.info( + "No download sources found, skipping download sources check" + ); return; } // Get last check timestamp or use a default (24 hours ago) const lastCheck = await getLastDownloadSourcesCheck(); - const since = lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const since = + lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); logger.info(`Last check: ${lastCheck}, using since: ${since}`); // Clear any previously stored new download option counts so badges don't persist across restarts const previouslyFlaggedGames = nonCustomGames.filter( - (game: Game) => (game as Game).newDownloadOptionsCount && (game as Game).newDownloadOptionsCount! > 0 + (game: Game) => + (game as Game).newDownloadOptionsCount && + (game as Game).newDownloadOptionsCount! > 0 ); const clearedPayload: { gameId: string; count: number }[] = []; if (previouslyFlaggedGames.length > 0) { - logger.info(`Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games`); + logger.info( + `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` + ); for (const game of previouslyFlaggedGames) { await gamesSublevel.put(`${game.shop}:${game.objectId}`, { ...game, newDownloadOptionsCount: undefined, }); - clearedPayload.push({ gameId: `${game.shop}:${game.objectId}`, count: 0 }); + clearedPayload.push({ + gameId: `${game.shop}:${game.objectId}`, + count: 0, + }); } } // Prepare games array for API call (excluding custom games) const games = nonCustomGames.map((game: Game) => ({ shop: game.shop, - objectId: game.objectId + objectId: game.objectId, })); - logger.info(`Checking download sources changes for ${games.length} non-custom games since ${since}`); - logger.info(`Making API call to HydraApi.checkDownloadSourcesChanges with:`, { - downloadSourceIds, - gamesCount: games.length, - since - }); + logger.info( + `Checking download sources changes for ${games.length} non-custom games since ${since}` + ); + logger.info( + `Making API call to HydraApi.checkDownloadSourcesChanges with:`, + { + downloadSourceIds, + gamesCount: games.length, + since, + } + ); // Call the API const response = await HydraApi.checkDownloadSourcesChanges( @@ -77,7 +105,7 @@ export class DownloadSourcesChecker { games, since ); - + logger.info("API call completed, response:", response); // Update the last check timestamp @@ -86,37 +114,45 @@ export class DownloadSourcesChecker { // Process the response and store newDownloadOptionsCount for games with new options if (response && Array.isArray(response)) { const gamesWithNewOptions: { gameId: string; count: number }[] = []; - + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { if (gameUpdate.newDownloadOptionsCount > 0) { - const game = nonCustomGames.find(g => - g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + const game = nonCustomGames.find( + (g) => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId ); - + if (game) { // Store the new download options count in the game data await gamesSublevel.put(`${game.shop}:${game.objectId}`, { ...game, - newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, }); - + gamesWithNewOptions.push({ gameId: `${game.shop}:${game.objectId}`, - count: gameUpdate.newDownloadOptionsCount + count: gameUpdate.newDownloadOptionsCount, }); - - logger.info(`Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options`); + + logger.info( + `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` + ); } } } - + // Send IPC event to renderer to clear stale badges and set fresh counts from response const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; if (eventPayload.length > 0 && WindowManager.mainWindow) { - WindowManager.mainWindow.webContents.send("on-new-download-options", eventPayload); + WindowManager.mainWindow.webContents.send( + "on-new-download-options", + eventPayload + ); } - - logger.info(`Found new download options for ${gamesWithNewOptions.length} games`); + + logger.info( + `Found new download options for ${gamesWithNewOptions.length} games` + ); } logger.info("Download sources check completed successfully"); @@ -124,4 +160,4 @@ export class DownloadSourcesChecker { logger.error("Failed to check download sources changes:", error); } } -} \ No newline at end of file +} diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 4f7091db..e7e93268 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -409,22 +409,31 @@ export class HydraApi { downloadSourceIds, gamesCount: games.length, since, - isLoggedIn: this.isLoggedIn() + isLoggedIn: this.isLoggedIn(), }); try { - const result = await this.post>("/download-sources/changes", { - downloadSourceIds, - games, - since, - }, { needsAuth: true }); - - logger.info("HydraApi.checkDownloadSourcesChanges completed successfully:", result); + const result = await this.post< + Array<{ + shop: string; + objectId: string; + newDownloadOptionsCount: number; + downloadSourceIds: string[]; + }> + >( + "/download-sources/changes", + { + downloadSourceIds, + games, + since, + }, + { needsAuth: true } + ); + + logger.info( + "HydraApi.checkDownloadSourcesChanges completed successfully:", + result + ); return result; } catch (error) { logger.error("HydraApi.checkDownloadSourcesChanges failed:", error); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index add7e081..59698935 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -84,7 +84,9 @@ export function SidebarGameItem({ {game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && (
+
-
{game.newDownloadOptionsCount}
+
+ {game.newDownloadOptionsCount} +
)} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 9068e2b5..fdb3872e 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -116,7 +116,7 @@ } &__game-badge { - background: rgba(255, 255, 255, 0.1);; + background: rgba(255, 255, 255, 0.1); color: #fff; font-size: 10px; font-weight: bold; diff --git a/src/renderer/src/features/library-slice.ts b/src/renderer/src/features/library-slice.ts index e92e6a25..9c399dbe 100644 --- a/src/renderer/src/features/library-slice.ts +++ b/src/renderer/src/features/library-slice.ts @@ -39,4 +39,8 @@ export const librarySlice = createSlice({ }, }); -export const { setLibrary, updateGameNewDownloadOptions, clearNewDownloadOptions } = librarySlice.actions; +export const { + setLibrary, + updateGameNewDownloadOptions, + clearNewDownloadOptions, +} = librarySlice.actions; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts index f0268335..34cc7bf7 100644 --- a/src/renderer/src/hooks/use-download-options-listener.ts +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -16,4 +16,4 @@ export function useDownloadOptionsListener() { return unsubscribe; }, [dispatch]); -} \ No newline at end of file +} From efab242c745a8fa615f11c53a316fb1f8f1d21b6 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 31 Oct 2025 23:17:06 +0200 Subject: [PATCH 24/95] ci: showing new badge in repack-modal --- src/locales/en/translation.json | 1 + .../get-download-sources-check-baseline.ts | 13 ++ .../get-download-sources-since-value.ts | 13 ++ src/main/events/index.ts | 2 + .../downloadSourcesCheckTimestamp.ts | 51 ++++- src/main/level/sublevels/keys.ts | 3 +- src/main/services/download-sources-checker.ts | 180 +++++++++++------- src/preload/index.ts | 4 + src/renderer/src/declaration.d.ts | 2 + .../hooks/use-download-options-listener.ts | 4 +- .../game-details/modals/repacks-modal.scss | 15 ++ .../game-details/modals/repacks-modal.tsx | 48 ++++- src/types/index.ts | 1 + 13 files changed, 253 insertions(+), 84 deletions(-) create mode 100644 src/main/events/download-sources/get-download-sources-check-baseline.ts create mode 100644 src/main/events/download-sources/get-download-sources-since-value.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 668f1547..7710066d 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -194,6 +194,7 @@ "download_in_progress": "Download in progress", "download_paused": "Download paused", "last_downloaded_option": "Last downloaded option", + "new_download_option": "New", "create_steam_shortcut": "Create Steam shortcut", "create_shortcut_success": "Shortcut created successfully", "you_might_need_to_restart_steam": "You might need to restart Steam to see the changes", diff --git a/src/main/events/download-sources/get-download-sources-check-baseline.ts b/src/main/events/download-sources/get-download-sources-check-baseline.ts new file mode 100644 index 00000000..2f3ab377 --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-check-baseline.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesCheckBaseline } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesCheckBaselineHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesCheckBaseline(); +}; + +registerEvent( + "getDownloadSourcesCheckBaseline", + getDownloadSourcesCheckBaselineHandler +); diff --git a/src/main/events/download-sources/get-download-sources-since-value.ts b/src/main/events/download-sources/get-download-sources-since-value.ts new file mode 100644 index 00000000..cbd06faf --- /dev/null +++ b/src/main/events/download-sources/get-download-sources-since-value.ts @@ -0,0 +1,13 @@ +import { getDownloadSourcesSinceValue } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getDownloadSourcesSinceValueHandler = async ( + _event: Electron.IpcMainInvokeEvent +) => { + return await getDownloadSourcesSinceValue(); +}; + +registerEvent( + "getDownloadSourcesSinceValue", + getDownloadSourcesSinceValueHandler +); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 0ab5499a..162b08d7 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -64,6 +64,8 @@ import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-torbox"; import "./download-sources/add-download-source"; import "./download-sources/sync-download-sources"; +import "./download-sources/get-download-sources-check-baseline"; +import "./download-sources/get-download-sources-since-value"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index f7071932..4b60b962 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -1,18 +1,59 @@ import { levelKeys } from "./keys"; import { db } from "../level"; +import { logger } from "@main/services"; -export const getLastDownloadSourcesCheck = async (): Promise => { +// Gets when we last started the app (for next API call's 'since') +export const getDownloadSourcesCheckBaseline = async (): Promise< + string | null +> => { try { - const timestamp = await db.get(levelKeys.lastDownloadSourcesCheck); + const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); return timestamp; } catch (error) { - // Key doesn't exist yet + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources check baseline not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources check baseline", + error + ); + } return null; } }; -export const updateLastDownloadSourcesCheck = async ( +// Updates to current time (when app starts) +export const updateDownloadSourcesCheckBaseline = async ( timestamp: string ): Promise => { - await db.put(levelKeys.lastDownloadSourcesCheck, timestamp); + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp); +}; + +// Gets the 'since' value the API used in the last check (for modal comparison) +export const getDownloadSourcesSinceValue = async (): Promise< + string | null +> => { + try { + const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); + return timestamp; + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + logger.debug("Download sources since value not found, returning null"); + } else { + logger.error( + "Unexpected error while getting download sources since value", + error + ); + } + return null; + } +}; + +// Saves the 'since' value we used in the API call (for modal to compare against) +export const updateDownloadSourcesSinceValue = async ( + timestamp: string +): Promise => { + const utcTimestamp = new Date(timestamp).toISOString(); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); }; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 536f9dca..89c33f8d 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,5 +18,6 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", - lastDownloadSourcesCheck: "lastDownloadSourcesCheck", + downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app + downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison) }; diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index d2ccb531..f8b853a7 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -1,8 +1,9 @@ import { HydraApi } from "./hydra-api"; import { gamesSublevel, - getLastDownloadSourcesCheck, - updateLastDownloadSourcesCheck, + getDownloadSourcesCheckBaseline, + updateDownloadSourcesCheckBaseline, + updateDownloadSourcesSinceValue, downloadSourcesSublevel, } from "@main/level"; import { logger } from "./logger"; @@ -17,6 +18,89 @@ interface DownloadSourcesChangeResponse { } export class DownloadSourcesChecker { + private static async clearStaleBadges( + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + const previouslyFlaggedGames = nonCustomGames.filter( + (game: Game) => + game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 + ); + + const clearedPayload: { gameId: string; count: number }[] = []; + if (previouslyFlaggedGames.length > 0) { + logger.info( + `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` + ); + for (const game of previouslyFlaggedGames) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: undefined, + }); + clearedPayload.push({ + gameId: `${game.shop}:${game.objectId}`, + count: 0, + }); + } + } + + return clearedPayload; + } + + private static async processApiResponse( + response: unknown, + nonCustomGames: Game[] + ): Promise<{ gameId: string; count: number }[]> { + if (!response || !Array.isArray(response)) { + return []; + } + + const gamesWithNewOptions: { gameId: string; count: number }[] = []; + + for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { + if (gameUpdate.newDownloadOptionsCount > 0) { + const game = nonCustomGames.find( + (g) => + g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId + ); + + if (game) { + await gamesSublevel.put(`${game.shop}:${game.objectId}`, { + ...game, + newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, + }); + + gamesWithNewOptions.push({ + gameId: `${game.shop}:${game.objectId}`, + count: gameUpdate.newDownloadOptionsCount, + }); + + logger.info( + `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` + ); + } + } + } + + return gamesWithNewOptions; + } + + private static sendNewDownloadOptionsEvent( + clearedPayload: { gameId: string; count: number }[], + gamesWithNewOptions: { gameId: string; count: number }[] + ): void { + const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; + if (eventPayload.length > 0 && WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send( + "on-new-download-options", + eventPayload + ); + } + + logger.info( + `Found new download options for ${gamesWithNewOptions.length} games` + ); + } + static async checkForChanges(): Promise { logger.info("DownloadSourcesChecker.checkForChanges() called"); @@ -51,35 +135,16 @@ export class DownloadSourcesChecker { return; } - // Get last check timestamp or use a default (24 hours ago) - const lastCheck = await getLastDownloadSourcesCheck(); + // Get when we LAST started the app (for this check's 'since' parameter) + const previousBaseline = await getDownloadSourcesCheckBaseline(); const since = - lastCheck || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - logger.info(`Last check: ${lastCheck}, using since: ${since}`); + previousBaseline || + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + logger.info(`Using since: ${since} (from last app start)`); // Clear any previously stored new download option counts so badges don't persist across restarts - const previouslyFlaggedGames = nonCustomGames.filter( - (game: Game) => - (game as Game).newDownloadOptionsCount && - (game as Game).newDownloadOptionsCount! > 0 - ); - - const clearedPayload: { gameId: string; count: number }[] = []; - if (previouslyFlaggedGames.length > 0) { - logger.info( - `Clearing stale newDownloadOptionsCount for ${previouslyFlaggedGames.length} games` - ); - for (const game of previouslyFlaggedGames) { - await gamesSublevel.put(`${game.shop}:${game.objectId}`, { - ...game, - newDownloadOptionsCount: undefined, - }); - clearedPayload.push({ - gameId: `${game.shop}:${game.objectId}`, - count: 0, - }); - } - } + const clearedPayload = await this.clearStaleBadges(nonCustomGames); // Prepare games array for API call (excluding custom games) const games = nonCustomGames.map((game: Game) => ({ @@ -108,52 +173,25 @@ export class DownloadSourcesChecker { logger.info("API call completed, response:", response); - // Update the last check timestamp - await updateLastDownloadSourcesCheck(new Date().toISOString()); + // Save the 'since' value we just used (for modal to compare against) + await updateDownloadSourcesSinceValue(since); + logger.info(`Saved 'since' value: ${since} (for modal comparison)`); + + // Update baseline to NOW (for next app start's 'since') + const now = new Date().toISOString(); + await updateDownloadSourcesCheckBaseline(now); + logger.info( + `Updated baseline to: ${now} (will be 'since' on next app start)` + ); // Process the response and store newDownloadOptionsCount for games with new options - if (response && Array.isArray(response)) { - const gamesWithNewOptions: { gameId: string; count: number }[] = []; + const gamesWithNewOptions = await this.processApiResponse( + response, + nonCustomGames + ); - for (const gameUpdate of response as DownloadSourcesChangeResponse[]) { - if (gameUpdate.newDownloadOptionsCount > 0) { - const game = nonCustomGames.find( - (g) => - g.shop === gameUpdate.shop && g.objectId === gameUpdate.objectId - ); - - if (game) { - // Store the new download options count in the game data - await gamesSublevel.put(`${game.shop}:${game.objectId}`, { - ...game, - newDownloadOptionsCount: gameUpdate.newDownloadOptionsCount, - }); - - gamesWithNewOptions.push({ - gameId: `${game.shop}:${game.objectId}`, - count: gameUpdate.newDownloadOptionsCount, - }); - - logger.info( - `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` - ); - } - } - } - - // Send IPC event to renderer to clear stale badges and set fresh counts from response - const eventPayload = [...clearedPayload, ...gamesWithNewOptions]; - if (eventPayload.length > 0 && WindowManager.mainWindow) { - WindowManager.mainWindow.webContents.send( - "on-new-download-options", - eventPayload - ); - } - - logger.info( - `Found new download options for ${gamesWithNewOptions.length} games` - ); - } + // Send IPC event to renderer to clear stale badges and set fresh counts from response + this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); logger.info("Download sources check completed successfully"); } catch (error) { diff --git a/src/preload/index.ts b/src/preload/index.ts index a1a0d959..3a879d70 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,10 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), + getDownloadSourcesCheckBaseline: () => + ipcRenderer.invoke("getDownloadSourcesCheckBaseline"), + getDownloadSourcesSinceValue: () => + ipcRenderer.invoke("getDownloadSourcesSinceValue"), /* Library */ toggleAutomaticCloudSync: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 30b5c67a..b5d2492d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -214,6 +214,8 @@ declare global { ) => Promise; getDownloadSources: () => Promise; syncDownloadSources: () => Promise; + getDownloadSourcesCheckBaseline: () => Promise; + getDownloadSourcesSinceValue: () => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; diff --git a/src/renderer/src/hooks/use-download-options-listener.ts b/src/renderer/src/hooks/use-download-options-listener.ts index 34cc7bf7..dfc9b3b4 100644 --- a/src/renderer/src/hooks/use-download-options-listener.ts +++ b/src/renderer/src/hooks/use-download-options-listener.ts @@ -8,9 +8,9 @@ export function useDownloadOptionsListener() { useEffect(() => { const unsubscribe = window.electron.onNewDownloadOptions( (gamesWithNewOptions) => { - gamesWithNewOptions.forEach(({ gameId, count }) => { + for (const { gameId, count } of gamesWithNewOptions) { dispatch(updateGameNewDownloadOptions({ gameId, count })); - }); + } } ); diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index ba9778fd..9bffc676 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -45,12 +45,27 @@ &__repack-title { color: globals.$muted-color; word-break: break-word; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1); } &__repack-info { font-size: globals.$small-font-size; } + &__new-badge { + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + flex-shrink: 0; + } + &__no-results { width: 100%; padding: calc(globals.$spacing-unit * 4) 0; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 306e8647..1627d8e9 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,8 +15,7 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import type { DownloadSource } from "@types"; -import type { GameRepack } from "@types"; +import type { DownloadSource, GameRepack } from "@types"; import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; @@ -53,6 +52,10 @@ export function RepacksModal({ const [hashesInDebrid, setHashesInDebrid] = useState>( {} ); + const [lastCheckTimestamp, setLastCheckTimestamp] = useState( + null + ); + const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true); const { game, repacks } = useContext(gameDetailsContext); @@ -66,8 +69,8 @@ export function RepacksModal({ return null; } - const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i; - const match = magnet.match(hashRegex); + const hashRegex = /xt=urn:btih:([a-f0-9]+)/i; + const match = hashRegex.exec(magnet); return match ? match[1].toLowerCase() : null; }; @@ -97,6 +100,21 @@ export function RepacksModal({ fetchDownloadSources(); }, []); + useEffect(() => { + const fetchLastCheckTimestamp = async () => { + setIsLoadingTimestamp(true); + + const timestamp = await window.electron.getDownloadSourcesSinceValue(); + + setLastCheckTimestamp(timestamp); + setIsLoadingTimestamp(false); + }; + + if (visible) { + fetchLastCheckTimestamp(); + } + }, [visible, repacks]); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -158,6 +176,19 @@ export function RepacksModal({ return repack.uris.some((uri) => uri.includes(game.download!.uri)); }; + const isNewRepack = (repack: GameRepack): boolean => { + // Don't show badge while loading timestamp + if (isLoadingTimestamp) return false; + + if (!lastCheckTimestamp || !repack.createdAt) { + return false; + } + + const lastCheckUtc = new Date(lastCheckTimestamp).toISOString(); + + return repack.createdAt > lastCheckUtc; + }; + const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); useEffect(() => { @@ -273,7 +304,14 @@ export function RepacksModal({ onClick={() => handleRepackClick(repack)} className="repacks-modal__repack-button" > -

{repack.title}

+

+ {repack.title} + {isNewRepack(repack) && ( + + {t("new_download_option")} + + )} +

{isLastDownloadedOption && ( {t("last_downloaded_option")} diff --git a/src/types/index.ts b/src/types/index.ts index 4b13c496..f435bc60 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,6 +23,7 @@ export interface GameRepack { uploadDate: string | null; downloadSourceId: string; downloadSourceName: string; + createdAt: string; } export interface DownloadSource { From 5067cf163e5bf65122858ce1446a2edb57d15fa1 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 2 Nov 2025 18:22:37 +0200 Subject: [PATCH 25/95] feat: added new badge to repacks-modal, set up badge clearing --- src/main/events/index.ts | 1 + .../library/clear-new-download-options.ts | 27 ++++++++++++++++ src/main/services/download-sources-checker.ts | 13 -------- src/preload/index.ts | 2 ++ src/renderer/src/declaration.d.ts | 4 +++ .../game-details/modals/repacks-modal.scss | 10 +++--- .../game-details/modals/repacks-modal.tsx | 31 +++++++++++++++++-- 7 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 src/main/events/library/clear-new-download-options.ts diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 162b08d7..d75f8255 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -19,6 +19,7 @@ import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; import "./library/get-library"; import "./library/extract-game-download"; +import "./library/clear-new-download-options"; import "./library/open-game"; import "./library/open-game-executable-path"; import "./library/open-game-installer"; diff --git a/src/main/events/library/clear-new-download-options.ts b/src/main/events/library/clear-new-download-options.ts new file mode 100644 index 00000000..55ebfd8f --- /dev/null +++ b/src/main/events/library/clear-new-download-options.ts @@ -0,0 +1,27 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { logger } from "@main/services"; +import type { GameShop } from "@types"; + +const clearNewDownloadOptions = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string +) => { + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + if (!game) return; + + try { + await gamesSublevel.put(gameKey, { + ...game, + newDownloadOptionsCount: undefined, + }); + logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`); + } catch (error) { + logger.error(`Failed to clear newDownloadOptionsCount: ${error}`); + } +}; + +registerEvent("clearNewDownloadOptions", clearNewDownloadOptions); diff --git a/src/main/services/download-sources-checker.ts b/src/main/services/download-sources-checker.ts index f8b853a7..928e3d52 100644 --- a/src/main/services/download-sources-checker.ts +++ b/src/main/services/download-sources-checker.ts @@ -73,10 +73,6 @@ export class DownloadSourcesChecker { gameId: `${game.shop}:${game.objectId}`, count: gameUpdate.newDownloadOptionsCount, }); - - logger.info( - `Game ${game.title} has ${gameUpdate.newDownloadOptionsCount} new download options` - ); } } } @@ -121,7 +117,6 @@ export class DownloadSourcesChecker { return; } - // Get download sources const downloadSources = await downloadSourcesSublevel.values().all(); const downloadSourceIds = downloadSources.map((source) => source.id); logger.info( @@ -135,7 +130,6 @@ export class DownloadSourcesChecker { return; } - // Get when we LAST started the app (for this check's 'since' parameter) const previousBaseline = await getDownloadSourcesCheckBaseline(); const since = previousBaseline || @@ -143,10 +137,8 @@ export class DownloadSourcesChecker { logger.info(`Using since: ${since} (from last app start)`); - // Clear any previously stored new download option counts so badges don't persist across restarts const clearedPayload = await this.clearStaleBadges(nonCustomGames); - // Prepare games array for API call (excluding custom games) const games = nonCustomGames.map((game: Game) => ({ shop: game.shop, objectId: game.objectId, @@ -164,7 +156,6 @@ export class DownloadSourcesChecker { } ); - // Call the API const response = await HydraApi.checkDownloadSourcesChanges( downloadSourceIds, games, @@ -173,24 +164,20 @@ export class DownloadSourcesChecker { logger.info("API call completed, response:", response); - // Save the 'since' value we just used (for modal to compare against) await updateDownloadSourcesSinceValue(since); logger.info(`Saved 'since' value: ${since} (for modal comparison)`); - // Update baseline to NOW (for next app start's 'since') const now = new Date().toISOString(); await updateDownloadSourcesCheckBaseline(now); logger.info( `Updated baseline to: ${now} (will be 'since' on next app start)` ); - // Process the response and store newDownloadOptionsCount for games with new options const gamesWithNewOptions = await this.processApiResponse( response, nonCustomGames ); - // Send IPC event to renderer to clear stale badges and set fresh counts from response this.sendNewDownloadOptionsEvent(clearedPayload, gamesWithNewOptions); logger.info("Download sources check completed successfully"); diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a879d70..69cbb3d4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -183,6 +183,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("addGameToFavorites", shop, objectId), removeGameFromFavorites: (shop: GameShop, objectId: string) => ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), + clearNewDownloadOptions: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId), toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), updateLaunchOptions: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index b5d2492d..54b1be51 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -142,6 +142,10 @@ declare global { shop: GameShop, objectId: string ) => Promise; + clearNewDownloadOptions: ( + shop: GameShop, + objectId: string + ) => Promise; toggleGamePin: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.scss b/src/renderer/src/pages/game-details/modals/repacks-modal.scss index 9bffc676..3f78c82c 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.scss +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.scss @@ -55,15 +55,15 @@ } &__new-badge { - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.7); - padding: 4px 8px; + background-color: rgba(34, 197, 94, 0.15); + color: rgb(187, 247, 208); + padding: 2px 6px; border-radius: 6px; - font-size: 12px; + font-size: 10px; font-weight: 600; - min-width: 24px; text-align: center; flex-shrink: 0; + border: 1px solid rgba(34, 197, 94, 0.5); } &__no-results { diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 1627d8e9..fa9b04a1 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -21,7 +21,8 @@ import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; import { Downloader } from "@shared"; import { orderBy } from "lodash-es"; -import { useDate, useFeature } from "@renderer/hooks"; +import { useDate, useFeature, useAppDispatch } from "@renderer/hooks"; +import { clearNewDownloadOptions } from "@renderer/features"; import "./repacks-modal.scss"; export interface RepacksModalProps { @@ -56,6 +57,9 @@ export function RepacksModal({ null ); const [isLoadingTimestamp, setIsLoadingTimestamp] = useState(true); + const [viewedRepackIds, setViewedRepackIds] = useState>( + new Set() + ); const { game, repacks } = useContext(gameDetailsContext); @@ -63,14 +67,15 @@ export function RepacksModal({ const { formatDate } = useDate(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); const getHashFromMagnet = (magnet: string) => { if (!magnet || typeof magnet !== "string") { return null; } - const hashRegex = /xt=urn:btih:([a-f0-9]+)/i; - const match = hashRegex.exec(magnet); + const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i; + const match = magnet.match(hashRegex); return match ? match[1].toLowerCase() : null; }; @@ -115,6 +120,21 @@ export function RepacksModal({ } }, [visible, repacks]); + useEffect(() => { + if ( + visible && + game?.newDownloadOptionsCount && + game.newDownloadOptionsCount > 0 + ) { + // Clear the badge in the database + globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); + + // Clear the badge in Redux store + const gameId = `${game.shop}:${game.objectId}`; + dispatch(clearNewDownloadOptions({ gameId })); + } + }, [visible, game, dispatch]); + const sortedRepacks = useMemo(() => { return orderBy( repacks, @@ -157,6 +177,8 @@ export function RepacksModal({ const handleRepackClick = (repack: GameRepack) => { setRepack(repack); setShowSelectFolderModal(true); + // Mark this repack as viewed to hide the "NEW" badge + setViewedRepackIds((prev) => new Set(prev).add(repack.id)); }; const handleFilter: React.ChangeEventHandler = (event) => { @@ -180,6 +202,9 @@ export function RepacksModal({ // Don't show badge while loading timestamp if (isLoadingTimestamp) return false; + // Don't show badge if user has already clicked this repack in current session + if (viewedRepackIds.has(repack.id)) return false; + if (!lastCheckTimestamp || !repack.createdAt) { return false; } From 87d35da9fcbbed07dc155397f5737ae4bc79d475 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 2 Nov 2025 18:24:10 +0200 Subject: [PATCH 26/95] fix: deleted comments --- src/renderer/src/pages/game-details/modals/repacks-modal.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index fa9b04a1..91013da0 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -126,10 +126,8 @@ export function RepacksModal({ game?.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 ) { - // Clear the badge in the database globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); - // Clear the badge in Redux store const gameId = `${game.shop}:${game.objectId}`; dispatch(clearNewDownloadOptions({ gameId })); } @@ -177,7 +175,6 @@ export function RepacksModal({ const handleRepackClick = (repack: GameRepack) => { setRepack(repack); setShowSelectFolderModal(true); - // Mark this repack as viewed to hide the "NEW" badge setViewedRepackIds((prev) => new Set(prev).add(repack.id)); }; @@ -199,10 +196,8 @@ export function RepacksModal({ }; const isNewRepack = (repack: GameRepack): boolean => { - // Don't show badge while loading timestamp if (isLoadingTimestamp) return false; - // Don't show badge if user has already clicked this repack in current session if (viewedRepackIds.has(repack.id)) return false; if (!lastCheckTimestamp || !repack.createdAt) { From 6f6b7d49ac120fa2bed6055f8e8f3a45a551e325 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 2 Nov 2025 18:47:26 +0200 Subject: [PATCH 27/95] fix: removed void and converted conditional to boolean --- src/main/main.ts | 2 +- src/renderer/src/components/sidebar/sidebar-game-item.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 50173390..6fc2b216 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -59,7 +59,7 @@ export const loadState = async () => { void syncDownloadSourcesFromApi(); // Check for new download options on startup - void DownloadSourcesChecker.checkForChanges(); + DownloadSourcesChecker.checkForChanges(); // WSClient.connect(); }); diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 59698935..ee16d418 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -81,7 +81,7 @@ export function SidebarGameItem({ {getGameTitle(game)} - {game.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 && ( + {(game.newDownloadOptionsCount ?? 0) > 0 && (
+
From bf387aef3f240febd3e4b6d66c3d78be698c983f Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 2 Nov 2025 17:30:45 +0000 Subject: [PATCH 28/95] feat: improving animations --- .../user-profile/user-profile.context.tsx | 76 +- .../profile-content/profile-content.scss | 27 +- .../profile-content/profile-content.tsx | 681 +++++++++++------- .../user-library-game-card.scss | 25 +- .../user-library-game-card.tsx | 25 +- 5 files changed, 542 insertions(+), 292 deletions(-) diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 87e2a669..9f3a861d 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -14,12 +14,15 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; - getUserLibraryGames: (sortBy?: string) => Promise; + getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise; + loadMoreLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; libraryGames: UserGame[]; pinnedGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -30,12 +33,15 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, - getUserLibraryGames: async (_sortBy?: string) => {}, + getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {}, + loadMoreLibraryGames: async (_sortBy?: string) => false, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], libraryGames: [], pinnedGames: [], + hasMoreLibraryGames: false, + isLoadingLibraryGames: false, }); const { Provider } = userProfileContext; @@ -62,6 +68,9 @@ export function UserProfileContextProvider({ DEFAULT_USER_PROFILE_BACKGROUND ); const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); + const [libraryPage, setLibraryPage] = useState(0); + const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true); + const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false); const isMe = userDetails?.id === userProfile?.id; @@ -93,7 +102,13 @@ export function UserProfileContextProvider({ }, [userId]); const getUserLibraryGames = useCallback( - async (sortBy?: string) => { + async (sortBy?: string, reset = true) => { + if (reset) { + setLibraryPage(0); + setHasMoreLibraryGames(true); + setIsLoadingLibraryGames(true); + } + try { const params = new URLSearchParams(); params.append("take", "12"); @@ -115,18 +130,68 @@ export function UserProfileContextProvider({ if (response) { setLibraryGames(response.library); setPinnedGames(response.pinnedGames); + setHasMoreLibraryGames(response.library.length === 12); } else { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); } } catch (error) { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); + } finally { + setIsLoadingLibraryGames(false); } }, [userId] ); + const loadMoreLibraryGames = useCallback( + async (sortBy?: string): Promise => { + if (isLoadingLibraryGames || !hasMoreLibraryGames) { + return false; + } + + setIsLoadingLibraryGames(true); + try { + const nextPage = libraryPage + 1; + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", String(nextPage * 12)); + if (sortBy) { + params.append("sortBy", sortBy); + } + + const queryString = params.toString(); + const url = queryString + ? `/users/${userId}/library?${queryString}` + : `/users/${userId}/library`; + + const response = await window.electron.hydraApi.get<{ + library: UserGame[]; + pinnedGames: UserGame[]; + }>(url); + + if (response && response.library.length > 0) { + setLibraryGames((prev) => [...prev, ...response.library]); + setLibraryPage(nextPage); + setHasMoreLibraryGames(response.library.length === 12); + return true; + } else { + setHasMoreLibraryGames(false); + return false; + } + } catch (error) { + setHasMoreLibraryGames(false); + return false; + } finally { + setIsLoadingLibraryGames(false); + } + }, + [userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames] + ); + const getUserProfile = useCallback(async () => { getUserStats(); getUserLibraryGames(); @@ -164,6 +229,8 @@ export function UserProfileContextProvider({ setLibraryGames([]); setPinnedGames([]); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); + setLibraryPage(0); + setHasMoreLibraryGames(true); getUserProfile(); getBadges(); @@ -177,12 +244,15 @@ export function UserProfileContextProvider({ isMe, getUserProfile, getUserLibraryGames, + loadMoreLibraryGames, setSelectedBackgroundImage, backgroundImage: getBackgroundImageUrl(), userStats, badges, libraryGames, pinnedGames, + hasMoreLibraryGames, + isLoadingLibraryGames, }} > {children} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 15b9de6d..ffdf6a45 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -101,6 +101,11 @@ gap: calc(globals.$spacing-unit); margin-bottom: calc(globals.$spacing-unit * 2); border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } + + &__tab-wrapper { + position: relative; } &__tab { @@ -111,19 +116,22 @@ cursor: pointer; font-size: 14px; font-weight: 500; - border-bottom: 2px solid transparent; - transition: all ease 0.2s; - - &:hover { - color: rgba(255, 255, 255, 0.8); - } + transition: color ease 0.2s; &--active { color: white; - border-bottom-color: white; } } + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + &__games-grid { list-style: none; margin: 0; @@ -179,10 +187,6 @@ &__tab-panels { display: block; } - - &__tab-panel[hidden] { - display: none; - } } } @@ -210,7 +214,6 @@ .user-reviews__review-item { border-radius: 8px; - padding: calc(globals.$spacing-unit * 2); } .user-reviews__review-header { diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index e284cb88..749c7588 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -26,6 +26,8 @@ import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; import type { GameShop } from "@types"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; @@ -71,6 +73,9 @@ export function ProfileContent() { libraryGames, pinnedGames, getUserLibraryGames, + loadMoreLibraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, } = useContext(userProfileContext); const { userDetails } = useUserDetails(); const { formatDistance } = useDate(); @@ -104,10 +109,69 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { - getUserLibraryGames(sortBy); + getUserLibraryGames(sortBy, true); } }, [sortBy, getUserLibraryGames, userProfile]); + const loadMoreRef = useRef(null); + const observerRef = useRef(null); + + useEffect(() => { + if (activeTab !== "library" || !hasMoreLibraryGames) { + return; + } + + // Clean up previous observer + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + // Use setTimeout to ensure the DOM element is available after render + const timeoutId = setTimeout(() => { + const currentRef = loadMoreRef.current; + if (!currentRef) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if ( + entry?.isIntersecting && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); + } + }, + { + root: null, + rootMargin: "200px", + threshold: 0.1, + } + ); + + observerRef.current = observer; + observer.observe(currentRef); + }, 100); + + return () => { + clearTimeout(timeoutId); + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, [ + activeTab, + hasMoreLibraryGames, + isLoadingLibraryGames, + loadMoreLibraryGames, + sortBy, + libraryGames.length, + ]); + // Clear reviews state and reset tab when switching users useEffect(() => { setReviews([]); @@ -332,294 +396,373 @@ export function ProfileContent() {
- - +
+ + {activeTab === "library" && ( + + )} +
+
+ + {activeTab === "reviews" && ( + + )} +
-