From d168e203855e3592885721c30ab355ec99a54177 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Mon, 20 Oct 2025 23:43:47 +0530 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 (
- +
diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index be0a5a73..a9f2aba2 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -25,7 +25,7 @@ export function LibraryGameCard({ game, onMouseEnter, onMouseLeave, -}: LibraryGameCardProps) { +}: Readonly) { const { t } = useTranslation("library"); const { numberFormatter } = useFormat(); const navigate = useNavigate(); diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 2d58bb08..1cafc870 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -13,6 +13,10 @@ import "./library.scss"; export default function Library() { const { library, updateLibrary } = useLibrary(); + type ElectronAPI = { + refreshLibraryAssets?: () => Promise; + onLibraryBatchComplete?: (cb: () => void) => () => void; + }; const [viewMode, setViewMode] = useState("compact"); const [filterBy, setFilterBy] = useState("all"); @@ -22,17 +26,22 @@ export default function Library() { 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(() => { + const electron = (globalThis as unknown as { electron?: ElectronAPI }) + .electron; + let unsubscribe: () => void = () => undefined; + if (electron?.refreshLibraryAssets) { + electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); + if (electron.onLibraryBatchComplete) { + unsubscribe = electron.onLibraryBatchComplete(() => { + updateLibrary(); + }); + } + } else { updateLibrary(); - }); + } return () => { unsubscribe(); @@ -47,31 +56,6 @@ 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; @@ -98,8 +82,25 @@ export default function Library() { filtered = library; } - // Apply search filter - return fuzzySearch(searchQuery, filtered); + if (!searchQuery.trim()) return filtered; + + const queryLower = searchQuery.toLowerCase(); + return filtered.filter((game) => { + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + return queryIndex === queryLower.length; + }); }, [library, filterBy, searchQuery]); // No sorting for now — rely on filteredLibrary @@ -118,30 +119,25 @@ export default function Library() { return (
{hasGames && ( - <> -
-
-
- -
+
+
+
+ +
-
- - -
+
+ +
- +
)} {!hasGames && ( diff --git a/src/renderer/src/pages/library/search-bar.scss b/src/renderer/src/pages/library/search-bar.scss index 7c487ec5..6b09c683 100644 --- a/src/renderer/src/pages/library/search-bar.scss +++ b/src/renderer/src/pages/library/search-bar.scss @@ -22,7 +22,7 @@ } &__icon { - color: rgba(255, 255, 255, 0.5); + color: rgba(255, 255, 255, 0.75); flex-shrink: 0; transition: color 0.2s ease; } @@ -38,7 +38,7 @@ min-width: 0; &::placeholder { - color: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.6); } &:focus ~ .search-bar__icon { @@ -50,7 +50,7 @@ flex-shrink: 0; background: transparent; border: none; - color: rgba(255, 255, 255, 0.5); + color: rgba(255, 255, 255, 0.65); font-size: 18px; font-weight: bold; cursor: pointer; diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss index b8cf1484..77bfc10e 100644 --- a/src/renderer/src/pages/library/view-options.scss +++ b/src/renderer/src/pages/library/view-options.scss @@ -10,7 +10,7 @@ &__label { font-size: 14px; font-weight: 600; - color: rgba(255, 255, 255, 0.8); + color: rgba(255, 255, 255, 0.95); white-space: nowrap; } @@ -30,7 +30,7 @@ border-radius: 6px; background: rgba(255, 255, 255, 0.04); border: none; - color: rgba(255, 255, 255, 0.6); + color: rgba(255, 255, 255, 0.9); cursor: pointer; font-size: 14px; font-weight: 500; diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx index 662bab65..905fac58 100644 --- a/src/renderer/src/pages/library/view-options.tsx +++ b/src/renderer/src/pages/library/view-options.tsx @@ -9,7 +9,10 @@ interface ViewOptionsProps { onViewModeChange: (viewMode: ViewMode) => void; } -export function ViewOptions({ viewMode, onViewModeChange }: ViewOptionsProps) { +export function ViewOptions({ + viewMode, + onViewModeChange, +}: Readonly) { const { t } = useTranslation("library"); return ( From 5e653be4c3fc51b557cb47c947ef5b03b1d1ea56 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Thu, 6 Nov 2025 19:11:20 +0530 Subject: [PATCH 09/12] fix: add error logging in handleActionClick for better debugging --- src/renderer/src/pages/library/library-game-card-large.tsx | 1 + 1 file changed, 1 insertion(+) 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 e56f0ae3..5628fe10 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -91,6 +91,7 @@ export function LibraryGameCardLarge({ try { await handlePlayGame(); } catch (err) { + console.error(err); try { handleOpenDownloadOptions(); } catch (e) { From 893802be559d90bd5a0ec24c13923e63cf18b019 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 7 Nov 2025 13:27:24 +0200 Subject: [PATCH 10/12] fix: next suggestion and title not being showed --- src/main/services/steam-250.ts | 2 +- .../src/pages/game-details/game-details.scss | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/services/steam-250.ts b/src/main/services/steam-250.ts index 5652b0d3..826e528f 100644 --- a/src/main/services/steam-250.ts +++ b/src/main/services/steam-250.ts @@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => { if (!steamGameUrl) return null; return { - title: $title.textContent, + title: $title.getAttribute("data-title") || "", objectId: steamGameUrl.split("/").pop(), } as Steam250Game; }) diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index f5f77a86..9fdc9485 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -130,6 +130,22 @@ } } + // Randomizer button styles + &__randomizer-button { + position: fixed; + bottom: calc(globals.$spacing-unit * 5); + right: calc(globals.$spacing-unit * 5); + z-index: 100; + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); + overflow: visible; + } + + &__stars-icon-container { + width: 70px; + height: 70px; + position: relative; + } + // Skeleton-specific styles &__skeleton { .react-loading-skeleton { From 6e6e0f7bb71f22404c0c7b2835c3eaff12232c43 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 7 Nov 2025 13:35:50 +0200 Subject: [PATCH 11/12] fix: duplicate next suggestion styling removal --- .../src/pages/game-details/game-details.scss | 16 ------------- src/renderer/src/pages/game-details/hero.scss | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index 9fdc9485..f5f77a86 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -130,22 +130,6 @@ } } - // Randomizer button styles - &__randomizer-button { - position: fixed; - bottom: calc(globals.$spacing-unit * 5); - right: calc(globals.$spacing-unit * 5); - z-index: 100; - padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); - overflow: visible; - } - - &__stars-icon-container { - width: 70px; - height: 70px; - position: relative; - } - // Skeleton-specific styles &__skeleton { .react-loading-skeleton { diff --git a/src/renderer/src/pages/game-details/hero.scss b/src/renderer/src/pages/game-details/hero.scss index 41264fe4..82f64337 100644 --- a/src/renderer/src/pages/game-details/hero.scss +++ b/src/renderer/src/pages/game-details/hero.scss @@ -231,26 +231,36 @@ $hero-height: 350px; } &__randomizer-button { - padding: calc(globals.$spacing-unit * 1.5); + position: fixed; + bottom: calc(globals.$spacing-unit * 5); + right: calc(globals.$spacing-unit * 5); + z-index: 100; + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); background-color: rgba(0, 0, 0, 0.6); backdrop-filter: blur(20px); border-radius: 8px; transition: all ease 0.2s; cursor: pointer; min-height: 40px; - min-width: 40px; display: flex; align-items: center; justify-content: center; + gap: globals.$spacing-unit; color: globals.$muted-color; border: solid 1px globals.$border-color; box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1); + overflow: visible; &:active { opacity: 0.9; } + &:disabled { + opacity: globals.$disabled-opacity; + cursor: not-allowed; + } + &:hover { background-color: rgba(0, 0, 0, 0.5); color: globals.$body-color; @@ -258,17 +268,15 @@ $hero-height: 350px; } &__stars-icon-container { - width: 20px; + width: 16px; height: 16px; - display: flex; - align-items: center; - justify-content: center; position: relative; } &__stars-icon { - width: 26px; + width: 70px; position: absolute; - top: -3px; + top: -28px; + left: -27px; } } From 50b0a8220455e7005365e261ef8b3bb138ccf270 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sat, 8 Nov 2025 08:17:19 +0000 Subject: [PATCH 12/12] feat: improving styles on randomizer button --- src/renderer/src/pages/game-details/hero.scss | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/pages/game-details/hero.scss b/src/renderer/src/pages/game-details/hero.scss index 82f64337..fd071eec 100644 --- a/src/renderer/src/pages/game-details/hero.scss +++ b/src/renderer/src/pages/game-details/hero.scss @@ -233,10 +233,10 @@ $hero-height: 350px; &__randomizer-button { position: fixed; bottom: calc(globals.$spacing-unit * 5); - right: calc(globals.$spacing-unit * 5); + right: calc(globals.$spacing-unit * 2); z-index: 100; padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2); - background-color: rgba(0, 0, 0, 0.6); + background-color: rgba(255, 255, 255, 0.08); backdrop-filter: blur(20px); border-radius: 8px; transition: all ease 0.2s; @@ -248,21 +248,19 @@ $hero-height: 350px; gap: globals.$spacing-unit; color: globals.$muted-color; border: solid 1px globals.$border-color; - box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); + box-shadow: + 0px 0px 10px 0px rgba(0, 0, 0, 0.8), + 0px 2px 8px 0px rgba(255, 255, 255, 0.1); animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1); overflow: visible; - &:active { - opacity: 0.9; - } - &:disabled { opacity: globals.$disabled-opacity; cursor: not-allowed; } &:hover { - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(255, 255, 255, 0.12); color: globals.$body-color; } }