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 & {