mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-20 01:33:56 +00:00
Compare commits
22 Commits
fix/fixing
...
fix/surpri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b0a82204 | ||
|
|
6e6e0f7bb7 | ||
|
|
893802be55 | ||
|
|
754e9c14b8 | ||
|
|
5e653be4c3 | ||
|
|
cedf7e6e37 | ||
|
|
518a0e1cf4 | ||
|
|
7fa50dc5a7 | ||
|
|
f49fea3032 | ||
|
|
595d39986d | ||
|
|
e7a437e839 | ||
|
|
f5470b29c0 | ||
|
|
a0a967aacd | ||
|
|
e19102ea66 | ||
|
|
107b61f663 | ||
|
|
811a6ad955 | ||
|
|
6fb8bbf744 | ||
|
|
459017a4a6 | ||
|
|
d6ff8f670e | ||
|
|
33e0d50966 | ||
|
|
361073d3f8 | ||
|
|
d168e20385 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hydralauncher",
|
"name": "hydralauncher",
|
||||||
"version": "3.7.3",
|
"version": "3.7.4",
|
||||||
"description": "Hydra",
|
"description": "Hydra",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Los Broxas",
|
"author": "Los Broxas",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"catalogue": "Catalogue",
|
"catalogue": "Catalogue",
|
||||||
|
"library": "Library",
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"my_library": "My library",
|
"my_library": "My library",
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
"search": "Search games",
|
"search": "Search games",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"catalogue": "Catalogue",
|
"catalogue": "Catalogue",
|
||||||
|
"library": "Library",
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"search_results": "Search results",
|
"search_results": "Search results",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -698,6 +700,28 @@
|
|||||||
"delete_review": "Delete Review",
|
"delete_review": "Delete Review",
|
||||||
"loading_reviews": "Loading reviews..."
|
"loading_reviews": "Loading reviews..."
|
||||||
},
|
},
|
||||||
|
"library": {
|
||||||
|
"library": "Library",
|
||||||
|
"play": "Play",
|
||||||
|
"download": "Download",
|
||||||
|
"downloading": "Downloading",
|
||||||
|
"game": "game",
|
||||||
|
"games": "games",
|
||||||
|
"grid_view": "Grid view",
|
||||||
|
"compact_view": "Compact view",
|
||||||
|
"large_view": "Large view",
|
||||||
|
"no_games_title": "Your library is empty",
|
||||||
|
"no_games_description": "Add games from the catalogue or download them to get started",
|
||||||
|
"amount_hours": "{{amount}} hours",
|
||||||
|
"amount_minutes": "{{amount}} minutes",
|
||||||
|
"amount_hours_short": "{{amount}}h",
|
||||||
|
"amount_minutes_short": "{{amount}}m",
|
||||||
|
"manual_playtime_tooltip": "This playtime has been manually updated",
|
||||||
|
"all_games": "All Games",
|
||||||
|
"favourited_games": "Favourited",
|
||||||
|
"new_games": "New Games",
|
||||||
|
"top_10": "Top 10"
|
||||||
|
},
|
||||||
"achievement": {
|
"achievement": {
|
||||||
"achievement_unlocked": "Achievement unlocked",
|
"achievement_unlocked": "Achievement unlocked",
|
||||||
"user_achievements": "{{displayName}}'s Achievements",
|
"user_achievements": "{{displayName}}'s Achievements",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import "./library/close-game";
|
|||||||
import "./library/delete-game-folder";
|
import "./library/delete-game-folder";
|
||||||
import "./library/get-game-by-object-id";
|
import "./library/get-game-by-object-id";
|
||||||
import "./library/get-library";
|
import "./library/get-library";
|
||||||
|
import "./library/refresh-library-assets";
|
||||||
import "./library/extract-game-download";
|
import "./library/extract-game-download";
|
||||||
import "./library/open-game";
|
import "./library/open-game";
|
||||||
import "./library/open-game-executable-path";
|
import "./library/open-game-executable-path";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
downloadsSublevel,
|
downloadsSublevel,
|
||||||
gamesShopAssetsSublevel,
|
gamesShopAssetsSublevel,
|
||||||
gamesSublevel,
|
gamesSublevel,
|
||||||
|
gameAchievementsSublevel,
|
||||||
} from "@main/level";
|
} from "@main/level";
|
||||||
|
|
||||||
const getLibrary = async (): Promise<LibraryGame[]> => {
|
const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||||
@@ -18,14 +19,32 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
|||||||
const download = await downloadsSublevel.get(key);
|
const download = await downloadsSublevel.get(key);
|
||||||
const gameAssets = await gamesShopAssetsSublevel.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 {
|
return {
|
||||||
id: key,
|
id: key,
|
||||||
...game,
|
...game,
|
||||||
download: download ?? null,
|
download: download ?? null,
|
||||||
|
unlockedAchievementCount,
|
||||||
|
achievementCount,
|
||||||
|
// Spread gameAssets last to ensure all image URLs are properly set
|
||||||
...gameAssets,
|
...gameAssets,
|
||||||
// Ensure compatibility with LibraryGame type
|
// Preserve custom image URLs from game if they exist
|
||||||
libraryHeroImageUrl:
|
customIconUrl: game.customIconUrl,
|
||||||
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
|
customLogoImageUrl: game.customLogoImageUrl,
|
||||||
|
customHeroImageUrl: game.customHeroImageUrl,
|
||||||
} as LibraryGame;
|
} as LibraryGame;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
8
src/main/events/library/refresh-library-assets.ts
Normal file
8
src/main/events/library/refresh-library-assets.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { mergeWithRemoteGames } from "@main/services";
|
||||||
|
|
||||||
|
const refreshLibraryAssets = async () => {
|
||||||
|
await mergeWithRemoteGames();
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("refreshLibraryAssets", refreshLibraryAssets);
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Ludusavi,
|
Ludusavi,
|
||||||
Lock,
|
Lock,
|
||||||
DeckyPlugin,
|
DeckyPlugin,
|
||||||
|
WSClient,
|
||||||
} from "@main/services";
|
} from "@main/services";
|
||||||
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export const loadState = async () => {
|
|||||||
|
|
||||||
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
const { syncDownloadSourcesFromApi } = await import("./services/user");
|
||||||
void syncDownloadSourcesFromApi();
|
void syncDownloadSourcesFromApi();
|
||||||
// WSClient.connect();
|
WSClient.connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloads = await downloadsSublevel
|
const downloads = await downloadsSublevel
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
|
|||||||
import { db } from "@main/level";
|
import { db } from "@main/level";
|
||||||
import { levelKeys } from "@main/level/sublevels";
|
import { levelKeys } from "@main/level/sublevels";
|
||||||
import type { Auth, User } from "@types";
|
import type { Auth, User } from "@types";
|
||||||
|
import { WSClient } from "./ws";
|
||||||
|
|
||||||
export interface HydraApiOptions {
|
export interface HydraApiOptions {
|
||||||
needsAuth?: boolean;
|
needsAuth?: boolean;
|
||||||
@@ -103,8 +104,8 @@ export class HydraApi {
|
|||||||
await clearGamesRemoteIds();
|
await clearGamesRemoteIds();
|
||||||
uploadGamesBatch();
|
uploadGamesBatch();
|
||||||
|
|
||||||
// WSClient.close();
|
WSClient.close();
|
||||||
// WSClient.connect();
|
WSClient.connect();
|
||||||
|
|
||||||
const { syncDownloadSourcesFromApi } = await import("./user");
|
const { syncDownloadSourcesFromApi } = await import("./user");
|
||||||
syncDownloadSourcesFromApi();
|
syncDownloadSourcesFromApi();
|
||||||
|
|||||||
@@ -60,13 +60,20 @@ export const mergeWithRemoteGames = async () => {
|
|||||||
|
|
||||||
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
|
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, {
|
await gamesShopAssetsSublevel.put(gameKey, {
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
...localGameShopAsset,
|
...localGameShopAsset,
|
||||||
shop: game.shop,
|
shop: game.shop,
|
||||||
objectId: game.objectId,
|
objectId: game.objectId,
|
||||||
title: localGame?.title || game.title, // Preserve local title if it exists
|
title: localGame?.title || game.title, // Preserve local title if it exists
|
||||||
coverImageUrl: game.coverImageUrl,
|
coverImageUrl,
|
||||||
libraryHeroImageUrl: game.libraryHeroImageUrl,
|
libraryHeroImageUrl: game.libraryHeroImageUrl,
|
||||||
libraryImageUrl: game.libraryImageUrl,
|
libraryImageUrl: game.libraryImageUrl,
|
||||||
logoImageUrl: game.logoImageUrl,
|
logoImageUrl: game.logoImageUrl,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => {
|
|||||||
if (!steamGameUrl) return null;
|
if (!steamGameUrl) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: $title.textContent,
|
title: $title.getAttribute("data-title") || "",
|
||||||
objectId: steamGameUrl.split("/").pop(),
|
objectId: steamGameUrl.split("/").pop(),
|
||||||
} as Steam250Game;
|
} as Steam250Game;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
verifyExecutablePathInUse: (executablePath: string) =>
|
verifyExecutablePathInUse: (executablePath: string) =>
|
||||||
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
|
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
|
||||||
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
||||||
|
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
||||||
openGameInstaller: (shop: GameShop, objectId: string) =>
|
openGameInstaller: (shop: GameShop, objectId: string) =>
|
||||||
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
||||||
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 9px;
|
width: 4px;
|
||||||
background-color: globals.$dark-background-color;
|
background-color: globals.$dark-background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,10 @@ export function GameContextMenu({
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (isGameRunning) {
|
if (isGameRunning) {
|
||||||
void handleCloseGame();
|
void handleCloseGame();
|
||||||
} else {
|
} else if (canPlay) {
|
||||||
void handlePlayGame();
|
void handlePlayGame();
|
||||||
|
} else {
|
||||||
|
handleOpenDownloadOptions();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
disabled: isDeleting,
|
disabled: isDeleting,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
background-color: globals.$background-color;
|
background-color: globals.$background-color;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
transition: all ease 0.2s;
|
transition: all ease 0.2s;
|
||||||
width: 200px;
|
width: 300px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: solid 1px globals.$border-color;
|
border: solid 1px globals.$border-color;
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--focused {
|
&--focused {
|
||||||
width: 250px;
|
width: 350px;
|
||||||
border-color: #dadbe1;
|
border-color: #dadbe1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import cn from "classnames";
|
|||||||
const pathTitle: Record<string, string> = {
|
const pathTitle: Record<string, string> = {
|
||||||
"/": "home",
|
"/": "home",
|
||||||
"/catalogue": "catalogue",
|
"/catalogue": "catalogue",
|
||||||
|
"/library": "library",
|
||||||
"/downloads": "downloads",
|
"/downloads": "downloads",
|
||||||
"/settings": "settings",
|
"/settings": "settings",
|
||||||
};
|
};
|
||||||
@@ -41,6 +42,8 @@ export function Header() {
|
|||||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/profile")) 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");
|
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||||
|
|
||||||
return t(pathTitle[location.pathname]);
|
return t(pathTitle[location.pathname]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
GearIcon,
|
GearIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
|
BookIcon,
|
||||||
} from "@primer/octicons-react";
|
} from "@primer/octicons-react";
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
@@ -16,6 +17,11 @@ export const routes = [
|
|||||||
nameKey: "catalogue",
|
nameKey: "catalogue",
|
||||||
render: () => <AppsIcon />,
|
render: () => <AppsIcon />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/library",
|
||||||
|
nameKey: "library",
|
||||||
|
render: () => <BookIcon />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/downloads",
|
path: "/downloads",
|
||||||
nameKey: "downloads",
|
nameKey: "downloads",
|
||||||
|
|||||||
1
src/renderer/src/declaration.d.ts
vendored
1
src/renderer/src/declaration.d.ts
vendored
@@ -159,6 +159,7 @@ declare global {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
|
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
|
||||||
getLibrary: () => Promise<LibraryGame[]>;
|
getLibrary: () => Promise<LibraryGame[]>;
|
||||||
|
refreshLibraryAssets: () => Promise<void>;
|
||||||
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||||
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||||
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings";
|
|||||||
import Profile from "./pages/profile/profile";
|
import Profile from "./pages/profile/profile";
|
||||||
import Achievements from "./pages/achievements/achievements";
|
import Achievements from "./pages/achievements/achievements";
|
||||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||||
|
import Library from "./pages/library/library";
|
||||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||||
|
|
||||||
console.log = logger.log;
|
console.log = logger.log;
|
||||||
@@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route element={<App />}>
|
<Route element={<App />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/catalogue" element={<Catalogue />} />
|
<Route path="/catalogue" element={<Catalogue />} />
|
||||||
|
<Route path="/library" element={<Library />} />
|
||||||
<Route path="/downloads" element={<Downloads />} />
|
<Route path="/downloads" element={<Downloads />} />
|
||||||
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
|
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|||||||
@@ -231,44 +231,50 @@ $hero-height: 350px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__randomizer-button {
|
&__randomizer-button {
|
||||||
padding: calc(globals.$spacing-unit * 1.5);
|
position: fixed;
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
bottom: 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(255, 255, 255, 0.08);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all ease 0.2s;
|
transition: all ease 0.2s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
min-width: 40px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: globals.$spacing-unit;
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
border: solid 1px globals.$border-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);
|
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
&:active {
|
&:disabled {
|
||||||
opacity: 0.9;
|
opacity: globals.$disabled-opacity;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
color: globals.$body-color;
|
color: globals.$body-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__stars-icon-container {
|
&__stars-icon-container {
|
||||||
width: 20px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__stars-icon {
|
&__stars-icon {
|
||||||
width: 26px;
|
width: 70px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -28px;
|
||||||
|
left: -27px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/renderer/src/pages/library/filter-options.scss
Normal file
63
src/renderer/src/pages/library/filter-options.scss
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.library-filter-options {
|
||||||
|
&__container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
white-space: nowrap; /* prevent label and count from wrapping */
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #000;
|
||||||
|
background: #fff;
|
||||||
|
svg,
|
||||||
|
svg * {
|
||||||
|
fill: currentColor;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-filter-options__count {
|
||||||
|
background: #ebebeb;
|
||||||
|
color: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/renderer/src/pages/library/filter-options.tsx
Normal file
61
src/renderer/src/pages/library/filter-options.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./filter-options.scss";
|
||||||
|
|
||||||
|
export type FilterOption = "all" | "favourited" | "new" | "top10";
|
||||||
|
|
||||||
|
interface FilterOptionsProps {
|
||||||
|
filterBy: FilterOption;
|
||||||
|
onFilterChange: (filterBy: FilterOption) => void;
|
||||||
|
allGamesCount: number;
|
||||||
|
favouritedCount: number;
|
||||||
|
newGamesCount: number;
|
||||||
|
top10Count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterOptions({
|
||||||
|
filterBy,
|
||||||
|
onFilterChange,
|
||||||
|
allGamesCount,
|
||||||
|
favouritedCount,
|
||||||
|
newGamesCount,
|
||||||
|
top10Count,
|
||||||
|
}: Readonly<FilterOptionsProps>) {
|
||||||
|
const { t } = useTranslation("library");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="library-filter-options__container">
|
||||||
|
<button
|
||||||
|
className={`library-filter-options__option ${filterBy === "all" ? "active" : ""}`}
|
||||||
|
onClick={() => onFilterChange("all")}
|
||||||
|
>
|
||||||
|
<span className="library-filter-options__label">{t("all_games")}</span>
|
||||||
|
<span className="library-filter-options__count">{allGamesCount}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`library-filter-options__option ${filterBy === "favourited" ? "active" : ""}`}
|
||||||
|
onClick={() => onFilterChange("favourited")}
|
||||||
|
>
|
||||||
|
<span className="library-filter-options__label">
|
||||||
|
{t("Favourite Games")}
|
||||||
|
</span>
|
||||||
|
<span className="library-filter-options__count">{favouritedCount}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`library-filter-options__option ${filterBy === "new" ? "active" : ""}`}
|
||||||
|
onClick={() => onFilterChange("new")}
|
||||||
|
>
|
||||||
|
<span className="library-filter-options__label">{t("new_games")}</span>
|
||||||
|
<span className="library-filter-options__count">{newGamesCount}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`library-filter-options__option ${filterBy === "top10" ? "active" : ""}`}
|
||||||
|
onClick={() => onFilterChange("top10")}
|
||||||
|
>
|
||||||
|
<span className="library-filter-options__label">
|
||||||
|
{t("Most Played")}
|
||||||
|
</span>
|
||||||
|
<span className="library-filter-options__count">{top10Count}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
src/renderer/src/pages/library/library-game-card-large.scss
Normal file
295
src/renderer/src/pages/library/library-game-card-large.scss
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.library-game-card-large {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 172%;
|
||||||
|
position: absolute;
|
||||||
|
background: linear-gradient(
|
||||||
|
35deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0%,
|
||||||
|
rgba(0, 0, 0, 0.07) 51.5%,
|
||||||
|
rgba(255, 255, 255, 0.15) 74%,
|
||||||
|
rgba(255, 255, 255, 0.1) 100%
|
||||||
|
);
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
transform: translateY(-36%);
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.01);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__background {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__gradient {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0%,
|
||||||
|
rgba(0, 0, 0, 0.2) 50%,
|
||||||
|
rgba(0, 0, 0, 0.3) 100%
|
||||||
|
);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__overlay {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: calc(globals.$spacing-unit * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__top-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__menu-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
max-height: 120px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__playtime {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__playtime-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__manual-playtime {
|
||||||
|
color: globals.$warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievements {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
&__achievements-gap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-trophy {
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::-webkit-progress-bar {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-progress-value {
|
||||||
|
background-color: globals.$muted-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: globals.$muted-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-percentage {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover &__menu-button {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-icon--downloading {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/renderer/src/pages/library/library-game-card-large.tsx
Normal file
279
src/renderer/src/pages/library/library-game-card-large.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { LibraryGame } from "@types";
|
||||||
|
import { useDownload, useFormat } from "@renderer/hooks";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
import {
|
||||||
|
PlayIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
ClockIcon,
|
||||||
|
AlertFillIcon,
|
||||||
|
ThreeBarsIcon,
|
||||||
|
TrophyIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions";
|
||||||
|
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
|
import { GameContextMenu } from "@renderer/components";
|
||||||
|
import "./library-game-card-large.scss";
|
||||||
|
|
||||||
|
interface LibraryGameCardLargeProps {
|
||||||
|
game: LibraryGame;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getImageWithCustomPriority = (
|
||||||
|
customUrl: string | null | undefined,
|
||||||
|
originalUrl: string | null | undefined,
|
||||||
|
fallbackUrl?: string | null | undefined
|
||||||
|
) => {
|
||||||
|
return customUrl || originalUrl || fallbackUrl || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LibraryGameCardLarge({
|
||||||
|
game,
|
||||||
|
}: Readonly<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 {
|
||||||
|
handlePlayGame,
|
||||||
|
handleOpenDownloadOptions,
|
||||||
|
handleCloseGame,
|
||||||
|
isGameRunning,
|
||||||
|
} = useGameActions(game);
|
||||||
|
|
||||||
|
const handleActionClick = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (isGameRunning) {
|
||||||
|
try {
|
||||||
|
await handleCloseGame();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await handlePlayGame();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
try {
|
||||||
|
handleOpenDownloadOptions();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
position: { x: e.clientX, y: e.clientY },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuButtonClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
position: {
|
||||||
|
x: e.currentTarget.getBoundingClientRect().right,
|
||||||
|
y: e.currentTarget.getBoundingClientRect().bottom,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseContextMenu = () => {
|
||||||
|
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use libraryHeroImageUrl as background, fallback to libraryImageUrl
|
||||||
|
const backgroundImage = getImageWithCustomPriority(
|
||||||
|
game.libraryHeroImageUrl,
|
||||||
|
game.libraryImageUrl,
|
||||||
|
game.iconUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
// For logo, check if logoImageUrl exists (similar to game details page)
|
||||||
|
const logoImage = game.logoImageUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="library-game-card-large"
|
||||||
|
onClick={handleCardClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="library-game-card-large__background"
|
||||||
|
style={{ backgroundImage: `url(${backgroundImage})` }}
|
||||||
|
/>
|
||||||
|
<div className="library-game-card-large__gradient" />
|
||||||
|
|
||||||
|
<div className="library-game-card-large__overlay">
|
||||||
|
<div className="library-game-card-large__top-section">
|
||||||
|
<div className="library-game-card-large__playtime">
|
||||||
|
{game.hasManuallyUpdatedPlaytime ? (
|
||||||
|
<AlertFillIcon
|
||||||
|
size={11}
|
||||||
|
className="library-game-card-large__manual-playtime"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ClockIcon size={11} />
|
||||||
|
)}
|
||||||
|
<span className="library-game-card-large__playtime-text">
|
||||||
|
{formatPlayTime(game.playTimeInMilliseconds)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="library-game-card-large__menu-button"
|
||||||
|
onClick={handleMenuButtonClick}
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<ThreeBarsIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="library-game-card-large__logo-container">
|
||||||
|
{logoImage ? (
|
||||||
|
<img
|
||||||
|
src={logoImage}
|
||||||
|
alt={game.title}
|
||||||
|
className="library-game-card-large__logo"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h3 className="library-game-card-large__title">{game.title}</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="library-game-card-large__info-bar">
|
||||||
|
{/* Achievements section */}
|
||||||
|
{(game.achievementCount ?? 0) > 0 && (
|
||||||
|
<div className="library-game-card-large__achievements">
|
||||||
|
<div className="library-game-card-large__achievement-header">
|
||||||
|
<div className="library-game-card-large__achievements-gap">
|
||||||
|
<TrophyIcon
|
||||||
|
size={14}
|
||||||
|
className="library-game-card-large__achievement-trophy"
|
||||||
|
/>
|
||||||
|
<span className="library-game-card-large__achievement-count">
|
||||||
|
{game.unlockedAchievementCount ?? 0} /{" "}
|
||||||
|
{game.achievementCount ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="library-game-card-large__achievement-percentage">
|
||||||
|
{Math.round(
|
||||||
|
((game.unlockedAchievementCount ?? 0) /
|
||||||
|
(game.achievementCount ?? 1)) *
|
||||||
|
100
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="library-game-card-large__achievement-progress">
|
||||||
|
<div
|
||||||
|
className="library-game-card-large__achievement-bar"
|
||||||
|
style={{
|
||||||
|
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="library-game-card-large__action-button"
|
||||||
|
onClick={handleActionClick}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
if (isGameDownloading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DownloadIcon
|
||||||
|
size={16}
|
||||||
|
className="library-game-card-large__action-icon--downloading"
|
||||||
|
/>
|
||||||
|
{t("downloading")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGameRunning) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<XIcon size={16} />
|
||||||
|
{t("close")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.executablePath) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PlayIcon size={16} />
|
||||||
|
{t("play")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DownloadIcon size={16} />
|
||||||
|
{t("download")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<GameContextMenu
|
||||||
|
game={game}
|
||||||
|
visible={contextMenu.visible}
|
||||||
|
position={contextMenu.position}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
src/renderer/src/pages/library/library-game-card.scss
Normal file
289
src/renderer/src/pages/library/library-game-card.scss
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.library-game-card {
|
||||||
|
&__wrapper {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5);
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: block;
|
||||||
|
container-type: inline-size;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 172%;
|
||||||
|
position: absolute;
|
||||||
|
background: linear-gradient(
|
||||||
|
35deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0%,
|
||||||
|
rgba(0, 0, 0, 0.07) 51.5%,
|
||||||
|
rgba(255, 255, 255, 0.15) 64%,
|
||||||
|
rgba(255, 255, 255, 0.1) 100%
|
||||||
|
);
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
transform: translateY(-36%);
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__overlay {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 5%, transparent 100%);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__top-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__playtime {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
|
||||||
|
&-long {
|
||||||
|
display: inline;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-short {
|
||||||
|
display: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the card is narrow (less than 140px), show short format
|
||||||
|
@container (max-width: 140px) {
|
||||||
|
&-long {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-short {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__manual-playtime {
|
||||||
|
color: globals.$warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievements {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
&__achievements-gap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-trophy {
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-progress {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::-webkit-progress-bar {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-progress-value {
|
||||||
|
background-color: globals.$muted-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: globals.$muted-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__achievement-percentage {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: solid 1px rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__menu-button {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wrapper:hover &__action-button,
|
||||||
|
&__wrapper:hover &__menu-button {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wrapper:hover &__achievements {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-icon {
|
||||||
|
&--downloading {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__game-image {
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
display: block;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force fixed size for compact grid cells so cards render at 220x320 */
|
||||||
|
.library__games-grid--compact .library-game-card__wrapper {
|
||||||
|
width: 215px;
|
||||||
|
height: 320px;
|
||||||
|
aspect-ratio: unset;
|
||||||
|
}
|
||||||
202
src/renderer/src/pages/library/library-game-card.tsx
Normal file
202
src/renderer/src/pages/library/library-game-card.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { LibraryGame } from "@types";
|
||||||
|
import { useFormat } from "@renderer/hooks";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
AlertFillIcon,
|
||||||
|
ThreeBarsIcon,
|
||||||
|
TrophyIcon,
|
||||||
|
} from "@primer/octicons-react";
|
||||||
|
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { GameContextMenu } from "@renderer/components";
|
||||||
|
import "./library-game-card.scss";
|
||||||
|
|
||||||
|
interface LibraryGameCardProps {
|
||||||
|
game: LibraryGame;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryGameCard({
|
||||||
|
game,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
}: Readonly<LibraryGameCardProps>) {
|
||||||
|
const { t } = useTranslation("library");
|
||||||
|
const { numberFormatter } = useFormat();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
}>({ visible: false, position: { x: 0, y: 0 } });
|
||||||
|
|
||||||
|
const formatPlayTime = useCallback(
|
||||||
|
(playTimeInMilliseconds = 0, isShort = false) => {
|
||||||
|
const minutes = playTimeInMilliseconds / 60000;
|
||||||
|
|
||||||
|
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||||
|
return t(isShort ? "amount_minutes_short" : "amount_minutes", {
|
||||||
|
amount: minutes.toFixed(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = minutes / 60;
|
||||||
|
const hoursKey = isShort ? "amount_hours_short" : "amount_hours";
|
||||||
|
const hoursAmount = isShort
|
||||||
|
? Math.floor(hours)
|
||||||
|
: numberFormatter.format(hours);
|
||||||
|
|
||||||
|
return t(hoursKey, { amount: hoursAmount });
|
||||||
|
},
|
||||||
|
[numberFormatter, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
navigate(buildGameDetailsPath(game));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
position: { x: e.clientX, y: e.clientY },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuButtonClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
position: {
|
||||||
|
x: e.currentTarget.getBoundingClientRect().right,
|
||||||
|
y: e.currentTarget.getBoundingClientRect().bottom,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseContextMenu = () => {
|
||||||
|
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const coverImage =
|
||||||
|
game.coverImageUrl ??
|
||||||
|
game.libraryImageUrl ??
|
||||||
|
game.libraryHeroImageUrl ??
|
||||||
|
game.iconUrl ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
className="library-game-card__wrapper"
|
||||||
|
title={isTooltipHovered ? undefined : game.title}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
<div className="library-game-card__overlay">
|
||||||
|
<div className="library-game-card__top-section">
|
||||||
|
<div
|
||||||
|
className="library-game-card__playtime"
|
||||||
|
data-tooltip-place="top"
|
||||||
|
data-tooltip-content={
|
||||||
|
game.hasManuallyUpdatedPlaytime
|
||||||
|
? t("manual_playtime_tooltip")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
data-tooltip-id={game.objectId}
|
||||||
|
>
|
||||||
|
{game.hasManuallyUpdatedPlaytime ? (
|
||||||
|
<AlertFillIcon
|
||||||
|
size={11}
|
||||||
|
className="library-game-card__manual-playtime"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ClockIcon size={11} />
|
||||||
|
)}
|
||||||
|
<span className="library-game-card__playtime-long">
|
||||||
|
{formatPlayTime(game.playTimeInMilliseconds)}
|
||||||
|
</span>
|
||||||
|
<span className="library-game-card__playtime-short">
|
||||||
|
{formatPlayTime(game.playTimeInMilliseconds, true)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="library-game-card__menu-button"
|
||||||
|
onClick={handleMenuButtonClick}
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<ThreeBarsIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Achievements section - shown on hover */}
|
||||||
|
{(game.achievementCount ?? 0) > 0 && (
|
||||||
|
<div className="library-game-card__achievements">
|
||||||
|
<div className="library-game-card__achievement-header">
|
||||||
|
<div className="library-game-card__achievements-gap">
|
||||||
|
<TrophyIcon
|
||||||
|
size={14}
|
||||||
|
className="library-game-card__achievement-trophy"
|
||||||
|
/>
|
||||||
|
<span className="library-game-card__achievement-count">
|
||||||
|
{game.unlockedAchievementCount ?? 0} /{" "}
|
||||||
|
{game.achievementCount ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="library-game-card__achievement-percentage">
|
||||||
|
{Math.round(
|
||||||
|
((game.unlockedAchievementCount ?? 0) /
|
||||||
|
(game.achievementCount ?? 1)) *
|
||||||
|
100
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="library-game-card__achievement-progress">
|
||||||
|
<div
|
||||||
|
className="library-game-card__achievement-bar"
|
||||||
|
style={{
|
||||||
|
width: `${((game.unlockedAchievementCount ?? 0) / (game.achievementCount ?? 1)) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={coverImage ?? undefined}
|
||||||
|
alt={game.title}
|
||||||
|
className="library-game-card__game-image"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<Tooltip
|
||||||
|
id={game.objectId}
|
||||||
|
style={{
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
openOnClick={false}
|
||||||
|
afterShow={() => setIsTooltipHovered(true)}
|
||||||
|
afterHide={() => setIsTooltipHovered(false)}
|
||||||
|
/>
|
||||||
|
<GameContextMenu
|
||||||
|
game={game}
|
||||||
|
visible={contextMenu.visible}
|
||||||
|
position={contextMenu.position}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/renderer/src/pages/library/library.scss
Normal file
207
src/renderer/src/pages/library/library.scss
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.library {
|
||||||
|
&__content {
|
||||||
|
padding: calc(globals.$spacing-unit * 3);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 3);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 1.5);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__controls-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__controls-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: end;
|
||||||
|
gap: calc(globals.$spacing-unit * 1);
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__header-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
&__filter-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__separator {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count-label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count-number {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no-games {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: globals.$spacing-unit;
|
||||||
|
padding: calc(globals.$spacing-unit * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__telescope-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__games-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Grid view - larger cards
|
||||||
|
&--grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 900px) {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 1300px) {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 2000px) {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 2600px) {
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 3000px) {
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact view - smaller cards
|
||||||
|
&--compact {
|
||||||
|
grid-template-columns: repeat(auto-fill, 215px);
|
||||||
|
grid-auto-rows: 320px;
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 900px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, 215px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 1300px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, 215px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* keep same pattern for very large screens */
|
||||||
|
@container #{globals.$app-container} (min-width: 2000px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, 215px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 2600px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, 215px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 3000px) {
|
||||||
|
grid-template-columns: repeat(auto-fill, 210px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__games-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(globals.$spacing-unit * 2);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Large view - 2 columns grid
|
||||||
|
&--large {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
|
||||||
|
@container #{globals.$app-container} (min-width: 900px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/renderer/src/pages/library/library.tsx
Normal file
182
src/renderer/src/pages/library/library.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLibrary, useAppDispatch } from "@renderer/hooks";
|
||||||
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
|
import { TelescopeIcon } from "@primer/octicons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LibraryGameCard } from "./library-game-card";
|
||||||
|
// detailed view removed — keep file if needed later
|
||||||
|
import { LibraryGameCardLarge } from "./library-game-card-large";
|
||||||
|
import { ViewOptions, ViewMode } from "./view-options";
|
||||||
|
import { FilterOptions, FilterOption } from "./filter-options";
|
||||||
|
import { SearchBar } from "./search-bar";
|
||||||
|
import "./library.scss";
|
||||||
|
|
||||||
|
export default function Library() {
|
||||||
|
const { library, updateLibrary } = useLibrary();
|
||||||
|
type ElectronAPI = {
|
||||||
|
refreshLibraryAssets?: () => Promise<unknown>;
|
||||||
|
onLibraryBatchComplete?: (cb: () => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("compact");
|
||||||
|
const [filterBy, setFilterBy] = useState<FilterOption>("all");
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation("library");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setHeaderTitle(t("library")));
|
||||||
|
const electron = (globalThis as unknown as { electron?: ElectronAPI })
|
||||||
|
.electron;
|
||||||
|
let unsubscribe: () => void = () => undefined;
|
||||||
|
if (electron?.refreshLibraryAssets) {
|
||||||
|
electron
|
||||||
|
.refreshLibraryAssets()
|
||||||
|
.then(() => updateLibrary())
|
||||||
|
.catch(() => updateLibrary());
|
||||||
|
if (electron.onLibraryBatchComplete) {
|
||||||
|
unsubscribe = electron.onLibraryBatchComplete(() => {
|
||||||
|
updateLibrary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [dispatch, t, updateLibrary]);
|
||||||
|
|
||||||
|
const handleOnMouseEnterGameCard = () => {
|
||||||
|
// Optional: pause animations if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnMouseLeaveGameCard = () => {
|
||||||
|
// Optional: resume animations if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLibrary = useMemo(() => {
|
||||||
|
let filtered;
|
||||||
|
|
||||||
|
switch (filterBy) {
|
||||||
|
case "favourited":
|
||||||
|
filtered = library.filter((game) => game.favorite);
|
||||||
|
break;
|
||||||
|
case "new":
|
||||||
|
filtered = library.filter(
|
||||||
|
(game) => (game.playTimeInMilliseconds || 0) === 0
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "top10":
|
||||||
|
filtered = library
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0)
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
|
break;
|
||||||
|
case "all":
|
||||||
|
default:
|
||||||
|
filtered = library;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchQuery.trim()) return filtered;
|
||||||
|
|
||||||
|
const queryLower = searchQuery.toLowerCase();
|
||||||
|
return filtered.filter((game) => {
|
||||||
|
const titleLower = game.title.toLowerCase();
|
||||||
|
let queryIndex = 0;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < titleLower.length && queryIndex < queryLower.length;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
if (titleLower[i] === queryLower[queryIndex]) {
|
||||||
|
queryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryIndex === queryLower.length;
|
||||||
|
});
|
||||||
|
}, [library, filterBy, searchQuery]);
|
||||||
|
|
||||||
|
// No sorting for now — rely on filteredLibrary
|
||||||
|
const sortedLibrary = filteredLibrary;
|
||||||
|
|
||||||
|
// Calculate counts for filters
|
||||||
|
const allGamesCount = library.length;
|
||||||
|
const favouritedCount = library.filter((game) => game.favorite).length;
|
||||||
|
const newGamesCount = library.filter(
|
||||||
|
(game) => (game.playTimeInMilliseconds || 0) === 0
|
||||||
|
).length;
|
||||||
|
const top10Count = Math.min(10, library.length);
|
||||||
|
|
||||||
|
const hasGames = library.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="library__content">
|
||||||
|
{hasGames && (
|
||||||
|
<div className="library__page-header">
|
||||||
|
<div className="library__controls-row">
|
||||||
|
<div className="library__controls-left">
|
||||||
|
<FilterOptions
|
||||||
|
filterBy={filterBy}
|
||||||
|
onFilterChange={setFilterBy}
|
||||||
|
allGamesCount={allGamesCount}
|
||||||
|
favouritedCount={favouritedCount}
|
||||||
|
newGamesCount={newGamesCount}
|
||||||
|
top10Count={top10Count}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="library__controls-right">
|
||||||
|
<SearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||||
|
<ViewOptions viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasGames && (
|
||||||
|
<div className="library__no-games">
|
||||||
|
<div className="library__telescope-icon">
|
||||||
|
<TelescopeIcon size={24} />
|
||||||
|
</div>
|
||||||
|
<h2>{t("no_games_title")}</h2>
|
||||||
|
<p>{t("no_games_description")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasGames && viewMode === "large" && (
|
||||||
|
<div className="library__games-list library__games-list--large">
|
||||||
|
{sortedLibrary.map((game) => (
|
||||||
|
<LibraryGameCardLarge
|
||||||
|
key={`${game.shop}-${game.objectId}`}
|
||||||
|
game={game}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasGames && viewMode !== "large" && (
|
||||||
|
<ul className={`library__games-grid library__games-grid--${viewMode}`}>
|
||||||
|
{sortedLibrary.map((game) => (
|
||||||
|
<li
|
||||||
|
key={`${game.shop}-${game.objectId}`}
|
||||||
|
style={{ listStyle: "none" }}
|
||||||
|
>
|
||||||
|
<LibraryGameCard
|
||||||
|
game={game}
|
||||||
|
onMouseEnter={handleOnMouseEnterGameCard}
|
||||||
|
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/renderer/src/pages/library/search-bar.scss
Normal file
75
src/renderer/src/pages/library/search-bar.scss
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 250px;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
width: 300px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus ~ .search-bar__icon {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/renderer/src/pages/library/search-bar.tsx
Normal file
44
src/renderer/src/pages/library/search-bar.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { SearchIcon } from "@primer/octicons-react";
|
||||||
|
import { FC, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./search-bar.scss";
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchBar: FC<SearchBarProps> = ({ value, onChange }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-bar">
|
||||||
|
<div className="search-bar__container">
|
||||||
|
<SearchIcon size={16} className="search-bar__icon" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="search-bar__input"
|
||||||
|
placeholder={t("Search library", { defaultValue: "Search library" })}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
className="search-bar__clear"
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
src/renderer/src/pages/library/view-options.scss
Normal file
55
src/renderer/src/pages/library/view-options.scss
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@use "../../scss/globals.scss";
|
||||||
|
|
||||||
|
.library-view-options {
|
||||||
|
&__container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(globals.$spacing-unit);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: rgba(0, 0, 0, 0.9);
|
||||||
|
background: #fff;
|
||||||
|
svg,
|
||||||
|
svg * {
|
||||||
|
fill: currentColor;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/renderer/src/pages/library/view-options.tsx
Normal file
45
src/renderer/src/pages/library/view-options.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { AppsIcon, RowsIcon, SquareIcon } from "@primer/octicons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./view-options.scss";
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "compact" | "large";
|
||||||
|
|
||||||
|
interface ViewOptionsProps {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (viewMode: ViewMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewOptions({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
}: Readonly<ViewOptionsProps>) {
|
||||||
|
const { t } = useTranslation("library");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="library-view-options__container">
|
||||||
|
<div className="library-view-options__options">
|
||||||
|
<button
|
||||||
|
className={`library-view-options__option ${viewMode === "compact" ? "active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("compact")}
|
||||||
|
title={t("compact_view")}
|
||||||
|
>
|
||||||
|
<SquareIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`library-view-options__option ${viewMode === "grid" ? "active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("grid")}
|
||||||
|
title={t("grid_view")}
|
||||||
|
>
|
||||||
|
<AppsIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`library-view-options__option ${viewMode === "large" ? "active" : ""}`}
|
||||||
|
onClick={() => onViewModeChange("large")}
|
||||||
|
title={t("large_view")}
|
||||||
|
>
|
||||||
|
<RowsIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -362,6 +362,8 @@ export type LibraryGame = Game &
|
|||||||
Partial<ShopAssets> & {
|
Partial<ShopAssets> & {
|
||||||
id: string;
|
id: string;
|
||||||
download: Download | null;
|
download: Download | null;
|
||||||
|
unlockedAchievementCount?: number;
|
||||||
|
achievementCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserGameDetails = ShopAssets & {
|
export type UserGameDetails = ShopAssets & {
|
||||||
|
|||||||
Reference in New Issue
Block a user