Merge branch 'main' into feat/context_game_menu

This commit is contained in:
Carlos Eduardo Mariano Garcia Pereira
2025-10-01 00:34:48 -03:00
committed by GitHub
59 changed files with 1698 additions and 637 deletions

View File

@@ -119,14 +119,17 @@ declare global {
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => Promise<Game>;
updateCustomGame: (
shop: GameShop,
objectId: string,
title: string,
iconUrl?: string,
logoImageUrl?: string,
libraryHeroImageUrl?: string
) => Promise<Game>;
updateCustomGame: (params: {
shop: GameShop;
objectId: string;
title: string;
iconUrl?: string;
logoImageUrl?: string;
libraryHeroImageUrl?: string;
originalIconPath?: string;
originalLogoPath?: string;
originalHeroPath?: string;
}) => Promise<Game>;
copyCustomGameAsset: (
sourcePath: string,
assetType: "icon" | "logo" | "hero"
@@ -135,14 +138,17 @@ declare global {
deletedCount: number;
errors: string[];
}>;
updateGameCustomAssets: (
shop: GameShop,
objectId: string,
title: string,
customIconUrl?: string | null,
customLogoImageUrl?: string | null,
customHeroImageUrl?: string | null
) => Promise<Game>;
updateGameCustomAssets: (params: {
shop: GameShop;
objectId: string;
title: string;
customIconUrl?: string | null;
customLogoImageUrl?: string | null;
customHeroImageUrl?: string | null;
customOriginalIconPath?: string | null;
customOriginalLogoPath?: string | null;
customOriginalHeroPath?: string | null;
}) => Promise<Game>;
createGameShortcut: (
shop: GameShop,
objectId: string,

View File

@@ -1,8 +1,7 @@
@use "../../../scss/globals.scss";
.description-header {
width: calc(100% - calc(globals.$spacing-unit * 2));
margin: calc(globals.$spacing-unit * 1) auto;
width: 100%;
padding: calc(globals.$spacing-unit * 1.5);
display: flex;
justify-content: space-between;
@@ -10,8 +9,9 @@
background-color: globals.$background-color;
height: 72px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.03);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-bottom: calc(globals.$spacing-unit * 1);
&__info {
display: flex;

View File

@@ -7,6 +7,15 @@
display: flex;
flex-direction: column;
align-items: center;
max-height: 80vh;
@media (min-width: 1024px) {
max-height: 70vh;
}
@media (min-width: 1280px) {
max-height: 60vh;
}
}
&__viewport {
@@ -16,8 +25,19 @@
overflow: hidden;
border-radius: 8px;
@media (min-width: 1024px) {
width: 80%;
max-height: 400px;
}
@media (min-width: 1280px) {
width: 60%;
max-height: 500px;
}
@media (min-width: 1536px) {
width: 50%;
max-height: 600px;
}
}
@@ -52,10 +72,18 @@
overflow-y: hidden;
gap: calc(globals.$spacing-unit / 2);
@media (min-width: 1024px) {
width: 80%;
}
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
&::-webkit-scrollbar-thumb {
width: 20%;
}
@@ -79,6 +107,19 @@
border: solid 1px globals.$border-color;
overflow: hidden;
position: relative;
aspect-ratio: 16/9;
@media (min-width: 1024px) {
width: 15%;
}
@media (min-width: 1280px) {
width: 12%;
}
@media (min-width: 1536px) {
width: 10%;
}
&:hover {
opacity: 0.8;

View File

@@ -43,6 +43,29 @@ export function GameDetailsContent() {
const $images = Array.from(document.querySelectorAll("img"));
$images.forEach(($image) => {
$image.loading = "lazy";
// Remove any inline width/height styles that might cause overflow
$image.removeAttribute("width");
$image.removeAttribute("height");
$image.removeAttribute("style");
// Set max-width to prevent overflow
$image.style.maxWidth = "100%";
$image.style.width = "auto";
$image.style.height = "auto";
$image.style.boxSizing = "border-box";
});
// Handle videos the same way
const $videos = Array.from(document.querySelectorAll("video"));
$videos.forEach(($video) => {
// Remove any inline width/height styles that might cause overflow
$video.removeAttribute("width");
$video.removeAttribute("height");
$video.removeAttribute("style");
// Set max-width to prevent overflow
$video.style.maxWidth = "100%";
$video.style.width = "auto";
$video.style.height = "auto";
$video.style.boxSizing = "border-box";
});
return document.body.outerHTML;
@@ -168,14 +191,16 @@ export function GameDetailsContent() {
{renderGameLogo()}
<div className="game-details__hero-buttons game-details__hero-buttons--right">
<button
type="button"
className="game-details__edit-custom-game-button"
onClick={handleEditGameClick}
title={t("edit_game_modal_button")}
>
<PencilIcon size={16} />
</button>
{game && (
<button
type="button"
className="game-details__edit-custom-game-button"
onClick={handleEditGameClick}
title={t("edit_game_modal_button")}
>
<PencilIcon size={16} />
</button>
)}
{game?.shop !== "custom" && (
<button
@@ -217,13 +242,15 @@ export function GameDetailsContent() {
</div>
</section>
<EditGameModal
visible={showEditGameModal}
onClose={() => setShowEditGameModal(false)}
game={game}
shopDetails={shopDetails}
onGameUpdated={handleGameUpdated}
/>
{game && (
<EditGameModal
visible={showEditGameModal}
onClose={() => setShowEditGameModal(false)}
game={game}
shopDetails={shopDetails}
onGameUpdated={handleGameUpdated}
/>
)}
</div>
);
}

View File

@@ -182,44 +182,49 @@ $hero-height: 300px;
globals.$background-color 50%,
globals.$dark-background-color 100%
);
padding: calc(globals.$spacing-unit * 1.5);
gap: calc(globals.$spacing-unit * 1.5);
}
&__description-content {
width: 100%;
height: 100%;
min-height: 100%;
min-width: 0;
flex: 1;
overflow-x: hidden;
}
&__description {
user-select: text;
line-height: 22px;
font-size: globals.$body-font-size;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
}
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
}
overflow-x: auto;
min-height: auto;
@media (min-width: 1280px) {
width: 60%;
}
img {
@media (min-width: 1536px) {
width: 50%;
}
img,
video {
border-radius: 5px;
margin-top: globals.$spacing-unit;
margin-bottom: calc(globals.$spacing-unit * 3);
display: block;
width: 100%;
height: auto;
object-fit: cover;
display: block !important;
max-width: 100% !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
box-sizing: border-box !important;
word-wrap: break-word;
overflow-wrap: break-word;
}
a {
@@ -247,12 +252,17 @@ $hero-height: 300px;
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 80%;
}
@media (min-width: 1280px) {
width: 60%;
line-height: 22px;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__randomizer-button {

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ImageIcon, XIcon } from "@primer/octicons-react";
import { Modal, TextField, Button } from "@renderer/components";
import { useToast } from "@renderer/hooks";
import { generateRandomGradient } from "@renderer/helpers";
import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types";
import "./edit-game-modal.scss";
@@ -29,16 +30,34 @@ export function EditGameModal({
const { showSuccessToast, showErrorToast } = useToast();
const [gameName, setGameName] = useState("");
const [iconPath, setIconPath] = useState("");
const [logoPath, setLogoPath] = useState("");
const [heroPath, setHeroPath] = useState("");
const [assetPaths, setAssetPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [assetDisplayPaths, setAssetDisplayPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [originalAssetPaths, setOriginalAssetPaths] = useState({
icon: "",
logo: "",
hero: "",
});
const [removedAssets, setRemovedAssets] = useState({
icon: false,
logo: false,
hero: false,
});
const [defaultUrls, setDefaultUrls] = useState({
icon: null as string | null,
logo: null as string | null,
hero: null as string | null,
});
const [isUpdating, setIsUpdating] = useState(false);
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
const [defaultIconUrl, setDefaultIconUrl] = useState<string | null>(null);
const [defaultLogoUrl, setDefaultLogoUrl] = useState<string | null>(null);
const [defaultHeroUrl, setDefaultHeroUrl] = useState<string | null>(null);
const isCustomGame = (game: LibraryGame | Game): boolean => {
return game.shop === "custom";
};
@@ -48,26 +67,58 @@ export function EditGameModal({
};
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
setIconPath(extractLocalPath(game.iconUrl));
setLogoPath(extractLocalPath(game.logoImageUrl));
setHeroPath(extractLocalPath(game.libraryHeroImageUrl));
setAssetPaths({
icon: extractLocalPath(game.iconUrl),
logo: extractLocalPath(game.logoImageUrl),
hero: extractLocalPath(game.libraryHeroImageUrl),
});
setAssetDisplayPaths({
icon: extractLocalPath(game.iconUrl),
logo: extractLocalPath(game.logoImageUrl),
hero: extractLocalPath(game.libraryHeroImageUrl),
});
setOriginalAssetPaths({
icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl),
logo:
(game as any).originalLogoPath || extractLocalPath(game.logoImageUrl),
hero:
(game as any).originalHeroPath ||
extractLocalPath(game.libraryHeroImageUrl),
});
}, []);
const setNonCustomGameAssets = useCallback(
(game: LibraryGame) => {
setIconPath(extractLocalPath(game.customIconUrl));
setLogoPath(extractLocalPath(game.customLogoImageUrl));
setHeroPath(extractLocalPath(game.customHeroImageUrl));
setAssetPaths({
icon: extractLocalPath(game.customIconUrl),
logo: extractLocalPath(game.customLogoImageUrl),
hero: extractLocalPath(game.customHeroImageUrl),
});
setAssetDisplayPaths({
icon: extractLocalPath(game.customIconUrl),
logo: extractLocalPath(game.customLogoImageUrl),
hero: extractLocalPath(game.customHeroImageUrl),
});
setOriginalAssetPaths({
icon:
(game as any).customOriginalIconPath ||
extractLocalPath(game.customIconUrl),
logo:
(game as any).customOriginalLogoPath ||
extractLocalPath(game.customLogoImageUrl),
hero:
(game as any).customOriginalHeroPath ||
extractLocalPath(game.customHeroImageUrl),
});
setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null);
setDefaultLogoUrl(
shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null
);
setDefaultHeroUrl(
shopDetails?.assets?.libraryHeroImageUrl ||
setDefaultUrls({
icon: shopDetails?.assets?.iconUrl || game.iconUrl || null,
logo: shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null,
hero:
shopDetails?.assets?.libraryHeroImageUrl ||
game.libraryHeroImageUrl ||
null
);
null,
});
},
[shopDetails]
);
@@ -93,38 +144,38 @@ export function EditGameModal({
};
const getAssetPath = (assetType: AssetType): string => {
switch (assetType) {
case "icon":
return iconPath;
case "logo":
return logoPath;
case "hero":
return heroPath;
}
return assetPaths[assetType];
};
const getAssetDisplayPath = (assetType: AssetType): string => {
// Use original path if available, otherwise fall back to display path
return originalAssetPaths[assetType] || assetDisplayPaths[assetType];
};
const setAssetPath = (assetType: AssetType, path: string): void => {
switch (assetType) {
case "icon":
setIconPath(path);
break;
case "logo":
setLogoPath(path);
break;
case "hero":
setHeroPath(path);
break;
}
setAssetPaths((prev) => ({ ...prev, [assetType]: path }));
};
const setAssetDisplayPath = (assetType: AssetType, path: string): void => {
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path }));
};
const getDefaultUrl = (assetType: AssetType): string | null => {
return defaultUrls[assetType];
};
const getOriginalAssetUrl = (assetType: AssetType): string | null => {
if (!game || !isCustomGame(game)) return null;
switch (assetType) {
case "icon":
return defaultIconUrl;
return game.iconUrl;
case "logo":
return defaultLogoUrl;
return game.logoImageUrl;
case "hero":
return defaultHeroUrl;
return game.libraryHeroImageUrl;
default:
return null;
}
};
@@ -140,23 +191,68 @@ export function EditGameModal({
});
if (filePaths && filePaths.length > 0) {
const originalPath = filePaths[0];
try {
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
filePaths[0],
originalPath,
assetType
);
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
setAssetDisplayPath(assetType, originalPath);
// Store the original path for display purposes
setOriginalAssetPaths((prev) => ({
...prev,
[assetType]: originalPath,
}));
// Clear the removed flag when a new asset is selected
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
} catch (error) {
console.error(`Failed to copy ${assetType} asset:`, error);
setAssetPath(assetType, filePaths[0]);
setAssetPath(assetType, originalPath);
setAssetDisplayPath(assetType, originalPath);
setOriginalAssetPaths((prev) => ({
...prev,
[assetType]: originalPath,
}));
// Clear the removed flag when a new asset is selected
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
}
}
};
const handleRestoreDefault = (assetType: AssetType) => {
setAssetPath(assetType, "");
if (game && isCustomGame(game)) {
// For custom games, mark asset as removed and clear paths
setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
setAssetPath(assetType, "");
setAssetDisplayPath(assetType, "");
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
} else {
// For non-custom games, clear custom assets (restore to shop defaults)
setAssetPath(assetType, "");
setAssetDisplayPath(assetType, "");
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
}
};
const getOriginalTitle = (): string => {
if (!game) return "";
// For non-custom games, the original title is from shopDetails assets
return shopDetails?.assets?.title || game.title || "";
};
const handleRestoreDefaultTitle = () => {
const originalTitle = getOriginalTitle();
setGameName(originalTitle);
};
const isTitleChanged = useMemo((): boolean => {
if (!game || isCustomGame(game)) return false;
const originalTitle = getOriginalTitle();
return gameName.trim() !== originalTitle.trim();
}, [game, gameName, shopDetails]);
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
const handleDragOver = (e: React.DragEvent) => {
@@ -232,6 +328,7 @@ export function EditGameModal({
const assetPath = copiedAssetUrl.replace("local:", "");
setAssetPath(assetType, assetPath);
setAssetDisplayPath(assetType, filePath);
showSuccessToast(
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
@@ -267,11 +364,38 @@ export function EditGameModal({
// Helper function to prepare custom game assets
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl;
const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl;
const libraryHeroImageUrl = heroPath
? `local:${heroPath}`
: game.libraryHeroImageUrl;
// For custom games, check if asset was explicitly removed
let iconUrl;
if (removedAssets.icon) {
iconUrl = null;
} else if (assetPaths.icon) {
iconUrl = `local:${assetPaths.icon}`;
} else {
iconUrl = game.iconUrl;
}
let logoImageUrl;
if (removedAssets.logo) {
logoImageUrl = null;
} else if (assetPaths.logo) {
logoImageUrl = `local:${assetPaths.logo}`;
} else {
logoImageUrl = game.logoImageUrl;
}
// For hero image, if removed, restore to the original gradient or keep the original
let libraryHeroImageUrl;
if (removedAssets.hero) {
// If the original hero was a gradient (data URL), keep it, otherwise generate a new one
const originalHero = game.libraryHeroImageUrl;
libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml")
? originalHero
: generateRandomGradient();
} else {
libraryHeroImageUrl = assetPaths.hero
? `local:${assetPaths.hero}`
: game.libraryHeroImageUrl;
}
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
};
@@ -279,9 +403,9 @@ export function EditGameModal({
// Helper function to prepare non-custom game assets
const prepareNonCustomGameAssets = () => {
return {
customIconUrl: iconPath ? `local:${iconPath}` : null,
customLogoImageUrl: logoPath ? `local:${logoPath}` : null,
customHeroImageUrl: heroPath ? `local:${heroPath}` : null,
customIconUrl: assetPaths.icon ? `local:${assetPaths.icon}` : null,
customLogoImageUrl: assetPaths.logo ? `local:${assetPaths.logo}` : null,
customHeroImageUrl: assetPaths.hero ? `local:${assetPaths.hero}` : null,
};
};
@@ -290,14 +414,17 @@ export function EditGameModal({
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
prepareCustomGameAssets(game);
return window.electron.updateCustomGame(
game.shop,
game.objectId,
gameName.trim(),
iconUrl || undefined,
logoImageUrl || undefined,
libraryHeroImageUrl || undefined
);
return window.electron.updateCustomGame({
shop: game.shop,
objectId: game.objectId,
title: gameName.trim(),
iconUrl: iconUrl || undefined,
logoImageUrl: logoImageUrl || undefined,
libraryHeroImageUrl: libraryHeroImageUrl || undefined,
originalIconPath: originalAssetPaths.icon || undefined,
originalLogoPath: originalAssetPaths.logo || undefined,
originalHeroPath: originalAssetPaths.hero || undefined,
});
};
// Helper function to update non-custom game
@@ -305,14 +432,17 @@ export function EditGameModal({
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
prepareNonCustomGameAssets();
return window.electron.updateGameCustomAssets(
game.shop,
game.objectId,
gameName.trim(),
return window.electron.updateGameCustomAssets({
shop: game.shop,
objectId: game.objectId,
title: gameName.trim(),
customIconUrl,
customLogoImageUrl,
customHeroImageUrl
);
customHeroImageUrl,
customOriginalIconPath: originalAssetPaths.icon || undefined,
customOriginalLogoPath: originalAssetPaths.logo || undefined,
customOriginalHeroPath: originalAssetPaths.hero || undefined,
});
};
const handleUpdateGame = async () => {
@@ -343,19 +473,31 @@ export function EditGameModal({
};
// Helper function to reset form to initial state
const resetFormToInitialState = (game: LibraryGame | Game) => {
setGameName(game.title || "");
const resetFormToInitialState = useCallback(
(game: LibraryGame | Game) => {
setGameName(game.title || "");
if (isCustomGame(game)) {
setCustomGameAssets(game);
// Clear default URLs for custom games
setDefaultIconUrl(null);
setDefaultLogoUrl(null);
setDefaultHeroUrl(null);
} else {
setNonCustomGameAssets(game as LibraryGame);
}
};
// Reset removed assets state
setRemovedAssets({
icon: false,
logo: false,
hero: false,
});
if (isCustomGame(game)) {
setCustomGameAssets(game);
// Clear default URLs for custom games
setDefaultUrls({
icon: null,
logo: null,
hero: null,
});
} else {
setNonCustomGameAssets(game as LibraryGame);
}
},
[setCustomGameAssets, setNonCustomGameAssets]
);
const handleClose = () => {
if (!isUpdating && game) {
@@ -378,6 +520,7 @@ export function EditGameModal({
const renderImageSection = (assetType: AssetType) => {
const assetPath = getAssetPath(assetType);
const assetDisplayPath = getAssetDisplayPath(assetType);
const defaultUrl = getDefaultUrl(assetType);
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
const isDragOver = dragOverTarget === assetType;
@@ -390,7 +533,7 @@ export function EditGameModal({
<div className="edit-game-modal__image-section">
<TextField
placeholder={t(`edit_game_modal_select_${assetType}`)}
value={assetPath}
value={assetDisplayPath}
readOnly
theme="dark"
rightContent={
@@ -404,17 +547,19 @@ export function EditGameModal({
<ImageIcon />
{t("edit_game_modal_browse")}
</Button>
{game && !isCustomGame(game) && assetPath && (
<Button
type="button"
theme="outline"
onClick={() => handleRestoreDefault(assetType)}
disabled={isUpdating}
title={`Remove ${assetType}`}
>
<XIcon />
</Button>
)}
{game &&
(assetPath ||
(isCustomGame(game) && getOriginalAssetUrl(assetType))) && (
<Button
type="button"
theme="outline"
onClick={() => handleRestoreDefault(assetType)}
disabled={isUpdating}
title={`Remove ${assetType}`}
>
<XIcon />
</Button>
)}
</div>
}
/>
@@ -442,7 +587,7 @@ export function EditGameModal({
/>
{isDragOver && (
<div className="edit-game-modal__drop-overlay">
<span>Drop to replace {assetType}</span>
<span>{t(`edit_game_modal_drop_to_replace_${assetType}`)}</span>
</div>
)}
</button>
@@ -465,7 +610,7 @@ export function EditGameModal({
>
<div className="edit-game-modal__drop-zone-content">
<ImageIcon />
<span>Drop {assetType} image here</span>
<span>{t(`edit_game_modal_drop_${assetType}_image_here`)}</span>
</div>
</button>
)}
@@ -489,6 +634,19 @@ export function EditGameModal({
onChange={handleGameNameChange}
theme="dark"
disabled={isUpdating}
rightContent={
isTitleChanged && (
<Button
type="button"
theme="outline"
onClick={handleRestoreDefaultTitle}
disabled={isUpdating}
title="Restore default title"
>
<XIcon />
</Button>
)
}
/>
<div className="edit-game-modal__asset-selector">

View File

@@ -2,12 +2,33 @@
.repacks-modal {
&__filter-container {
transition: all 0.3s ease;
}
&__filter-top {
margin-bottom: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: row;
gap: calc(globals.$spacing-unit * 1);
align-items: center;
}
&__filter-toggle {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
font-size: globals.$small-font-size;
font-weight: 600;
color: var(--color-text-secondary);
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 1.5);
border-radius: 6px;
transition: background-color 0.2s ease;
white-space: nowrap;
}
&__repacks {
display: flex;
gap: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 2);
flex-direction: column;
}
@@ -16,7 +37,7 @@
text-align: left;
flex-direction: column;
align-items: flex-start;
gap: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 1);
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 2);
}
@@ -29,4 +50,106 @@
&__repack-info {
font-size: globals.$small-font-size;
}
&__no-results {
width: 100%;
padding: calc(globals.$spacing-unit * 4) 0;
text-align: center;
color: globals.$muted-color;
font-size: globals.$small-font-size;
display: flex;
align-items: center;
justify-content: center;
}
&__no-results-content {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
max-width: 480px;
width: 100%;
}
&__no-results-text {
color: globals.$muted-color;
font-size: globals.$small-font-size;
text-align: center;
}
&__no-results-button {
display: flex;
justify-content: center;
width: 100%;
}
&__download-sources {
padding: 0;
background-color: var(--color-background-light);
border-radius: 8px;
margin-bottom: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 1);
max-height: 0;
overflow: hidden;
transition:
max-height 0.3s ease,
padding 0.3s ease;
&--open {
max-height: 280px;
}
}
&__filter-label {
display: none;
font-size: globals.$small-font-size;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--color-text-secondary);
width: 100%;
}
&__source-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: calc(globals.$spacing-unit * 1);
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
align-items: start;
padding: calc(globals.$spacing-unit * 0.5) calc(globals.$spacing-unit * 0.5)
calc(globals.$spacing-unit * 0.5) 0;
}
&__source-item {
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
background: var(--color-surface, rgba(0, 0, 0, 0.03));
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
display: flex;
align-items: center;
min-height: calc(globals.$spacing-unit * 5);
box-sizing: border-box;
width: 100%;
transition: border-color 0.2s ease;
&:hover {
border-color: rgba(255, 255, 255, 0.2);
}
}
&__source-item :global(.checkbox-field) {
width: 100%;
min-width: 0;
}
&__source-item :global(.checkbox-field__label) {
white-space: normal;
overflow: visible;
text-overflow: unset;
display: block;
font-size: 0.85rem;
width: 100%;
word-break: break-word;
}
}

View File

@@ -1,5 +1,11 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
PlusCircleIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@primer/octicons-react";
import {
Badge,
@@ -7,7 +13,10 @@ import {
DebridBadge,
Modal,
TextField,
CheckboxField,
} from "@renderer/components";
import { downloadSourcesTable } from "@renderer/dexie";
import type { DownloadSource } from "@types";
import type { GameRepack } from "@types";
import { DownloadSettingsModal } from "./download-settings-modal";
@@ -36,6 +45,11 @@ export function RepacksModal({
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [selectedFingerprints, setSelectedFingerprints] = useState<string[]>(
[]
);
const [filterTerm, setFilterTerm] = useState("");
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
{}
@@ -46,6 +60,7 @@ export function RepacksModal({
const { t } = useTranslation("game_details");
const { formatDate } = useDate();
const navigate = useNavigate();
const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") {
@@ -90,8 +105,37 @@ export function RepacksModal({
}, [repacks, hashesInDebrid]);
useEffect(() => {
setFilteredRepacks(sortedRepacks);
}, [sortedRepacks, visible, game]);
downloadSourcesTable.toArray().then((sources) => {
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
const filteredSources = sources.filter(
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
);
setDownloadSources(filteredSources);
});
}, [sortedRepacks]);
useEffect(() => {
const term = filterTerm.trim().toLowerCase();
const byTerm = sortedRepacks.filter((repack) => {
if (!term) return true;
const lowerTitle = repack.title.toLowerCase();
const lowerRepacker = repack.repacker.toLowerCase();
return lowerTitle.includes(term) || lowerRepacker.includes(term);
});
const bySource = byTerm.filter((repack) => {
if (selectedFingerprints.length === 0) return true;
return downloadSources.some(
(src) =>
selectedFingerprints.includes(src.fingerprint) &&
src.name === repack.repacker
);
});
setFilteredRepacks(bySource);
}, [sortedRepacks, filterTerm, selectedFingerprints, downloadSources]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
@@ -99,17 +143,14 @@ export function RepacksModal({
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
const term = event.target.value.toLocaleLowerCase();
setFilterTerm(event.target.value);
};
setFilteredRepacks(
sortedRepacks.filter((repack) => {
const lowerCaseTitle = repack.title.toLowerCase();
const lowerCaseRepacker = repack.repacker.toLowerCase();
return [lowerCaseTitle, lowerCaseRepacker].some((value) =>
value.includes(term)
);
})
const toggleFingerprint = (fingerprint: string) => {
setSelectedFingerprints((prev) =>
prev.includes(fingerprint)
? prev.filter((f) => f !== fingerprint)
: [...prev, fingerprint]
);
};
@@ -118,6 +159,8 @@ export function RepacksModal({
return repack.uris.some((uri) => uri.includes(game.download!.uri));
};
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
return (
<>
<DownloadSettingsModal
@@ -133,38 +176,103 @@ export function RepacksModal({
description={t("repacks_modal_description")}
onClose={onClose}
>
<div className="repacks-modal__filter-container">
<TextField placeholder={t("filter")} onChange={handleFilter} />
<div
className={`repacks-modal__filter-container ${isFilterDrawerOpen ? "repacks-modal__filter-container--drawer-open" : ""}`}
>
<div className="repacks-modal__filter-top">
<TextField placeholder={t("filter")} onChange={handleFilter} />
{downloadSources.length > 0 && (
<Button
type="button"
theme="outline"
onClick={() => setIsFilterDrawerOpen(!isFilterDrawerOpen)}
className="repacks-modal__filter-toggle"
>
{t("filter_by_source")}
{isFilterDrawerOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</Button>
)}
</div>
<div
className={`repacks-modal__download-sources ${isFilterDrawerOpen ? "repacks-modal__download-sources--open" : ""}`}
>
<div className="repacks-modal__source-grid">
{downloadSources.map((source) => {
const label = source.name || source.url;
const truncatedLabel =
label.length > 16 ? label.substring(0, 16) + "..." : label;
return (
<div
key={source.fingerprint}
className="repacks-modal__source-item"
>
<CheckboxField
label={truncatedLabel}
checked={selectedFingerprints.includes(
source.fingerprint
)}
onChange={() => toggleFingerprint(source.fingerprint)}
/>
</div>
);
})}
</div>
</div>
</div>
<div className="repacks-modal__repacks">
{filteredRepacks.map((repack) => {
const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
{filteredRepacks.length === 0 ? (
<div className="repacks-modal__no-results">
<div className="repacks-modal__no-results-content">
<div className="repacks-modal__no-results-text">
{t("no_repacks_found")}
</div>
<div className="repacks-modal__no-results-button">
<Button
type="button"
theme="primary"
onClick={() => {
onClose();
navigate("/settings?tab=2");
}}
>
<PlusCircleIcon />
{t("add_download_source", { ns: "settings" })}
</Button>
</div>
</div>
</div>
) : (
filteredRepacks.map((repack) => {
const isLastDownloadedOption =
checkIfLastDownloadedOption(repack);
return (
<Button
key={repack.id}
theme="dark"
onClick={() => handleRepackClick(repack)}
className="repacks-modal__repack-button"
>
<p className="repacks-modal__repack-title">{repack.title}</p>
return (
<Button
key={repack.id}
theme="dark"
onClick={() => handleRepackClick(repack)}
className="repacks-modal__repack-button"
>
<p className="repacks-modal__repack-title">{repack.title}</p>
{isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge>
)}
{isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge>
)}
<p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p>
<p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p>
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
<DebridBadge />
)}
</Button>
);
})}
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
<DebridBadge />
)}
</Button>
);
})
)}
</div>
</Modal>
</>

View File

@@ -1,6 +1,12 @@
@use "../../../scss/globals.scss";
.sidebar-section {
background-color: globals.$background-color;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
&__button {
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
display: flex;
@@ -15,7 +21,7 @@
font-weight: bold;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
background-color: rgba(255, 255, 255, 0.1);
}
&:active {
@@ -34,6 +40,7 @@
&__content {
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
background-color: globals.$dark-background-color;
position: relative;
}
}

View File

@@ -25,7 +25,7 @@ export function HowLongToBeatSection({
return `${value} ${t(durationTranslation[unit])}`;
};
if (!howLongToBeatData && !isLoading) return null;
if (!howLongToBeatData || !isLoading) return null;
return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">

View File

@@ -1,11 +1,12 @@
@use "../../../scss/globals.scss";
.content-sidebar {
border-left: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
height: 100%;
flex-shrink: 0;
width: 280px;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
@media (min-width: 1024px) {
width: 320px;

View File

@@ -76,6 +76,15 @@
width: 24px;
height: 24px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
&__title-flame-icon {
width: 32px;
height: 32px;
object-fit: contain;
}
&__title {

View File

@@ -158,7 +158,7 @@ export default function Home() {
<img
src={flameIconAnimated}
alt="Flame animation"
className="home__flame-icon"
className="home__title-flame-icon"
/>
</div>
)}

View File

@@ -5,7 +5,6 @@ import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { UserGame } from "@types";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
@@ -17,8 +16,6 @@ import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
import { motion, AnimatePresence } from "framer-motion";
import {
sectionVariants,
gameCardVariants,
gameGridVariants,
chevronVariants,
GAME_STATS_ANIMATION_DURATION_IN_MS,
} from "./profile-animations";
@@ -38,8 +35,6 @@ export function ProfileContent() {
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const [prevLibraryGames, setPrevLibraryGames] = useState<UserGame[]>([]);
const [prevPinnedGames, setPrevPinnedGames] = useState<UserGame[]>([]);
const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
@@ -92,27 +87,6 @@ export function ProfileContent() {
const { numberFormatter } = useFormat();
const gamesHaveChanged = (
current: UserGame[],
previous: UserGame[]
): boolean => {
if (current.length !== previous.length) return true;
return current.some(
(game, index) => game.objectId !== previous[index]?.objectId
);
};
const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames);
const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames);
useEffect(() => {
setPrevLibraryGames(libraryGames);
}, [libraryGames]);
useEffect(() => {
setPrevPinnedGames(pinnedGames);
}, [pinnedGames]);
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
@@ -192,57 +166,22 @@ export function ProfileContent() {
exit="collapsed"
layout
>
<motion.ul
className="profile-content__games-grid"
variants={
shouldAnimatePinned ? gameGridVariants : undefined
}
initial={shouldAnimatePinned ? "hidden" : undefined}
animate={shouldAnimatePinned ? "visible" : undefined}
exit={shouldAnimatePinned ? "exit" : undefined}
key={
shouldAnimatePinned
? `pinned-${sortBy}`
: `pinned-static`
}
>
{shouldAnimatePinned ? (
<AnimatePresence mode="wait">
{pinnedGames?.map((game, index) => (
<motion.li
key={game.objectId}
variants={gameCardVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ delay: index * 0.1 }}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</motion.li>
))}
</AnimatePresence>
) : (
pinnedGames?.map((game) => (
<li
key={game.objectId}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</li>
))
)}
</motion.ul>
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li
key={game.objectId}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
@@ -262,54 +201,19 @@ export function ProfileContent() {
</div>
</div>
<motion.ul
className="profile-content__games-grid"
variants={
shouldAnimateLibrary ? gameGridVariants : undefined
}
initial={shouldAnimateLibrary ? "hidden" : undefined}
animate={shouldAnimateLibrary ? "visible" : undefined}
exit={shouldAnimateLibrary ? "exit" : undefined}
key={
shouldAnimateLibrary
? `library-${sortBy}`
: `library-static`
}
>
{shouldAnimateLibrary ? (
<AnimatePresence mode="wait">
{libraryGames?.map((game, index) => (
<motion.li
key={game.objectId}
variants={gameCardVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ delay: index * 0.1 }}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</motion.li>
))}
</AnimatePresence>
) : (
libraryGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
/>
</li>
))
)}
</motion.ul>
<ul className="profile-content__games-grid">
{libraryGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</div>
)}
</div>
@@ -338,8 +242,6 @@ export function ProfileContent() {
pinnedGames,
isPinnedCollapsed,
toggleSection,
shouldAnimateLibrary,
shouldAnimatePinned,
sortBy,
]);

View File

@@ -8,6 +8,7 @@
display: flex;
transition: all ease 0.2s;
cursor: grab;
container-type: inline-size;
&:hover {
transform: scale(1.05);
@@ -86,32 +87,10 @@
top: 8px;
right: 8px;
display: flex;
gap: 6px;
gap: 4px;
z-index: 2;
}
&__favorite-icon {
color: rgba(255, 255, 255, 0.8);
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: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all ease 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
}
&__pin-button {
color: rgba(255, 255, 255, 0.8);
background: rgba(0, 0, 0, 0.4);
@@ -154,11 +133,25 @@
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all ease 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
&-long {
display: inline;
font-size: 12px;
}
&-short {
display: none;
font-size: 12px;
}
// When the card is narrow (less than 180px), show short format
@container (max-width: 140px) {
&-long {
display: none;
}
&-short {
display: inline;
}
}
}
&__manual-playtime {

View File

@@ -13,7 +13,6 @@ import {
ClockIcon,
TrophyIcon,
AlertFillIcon,
HeartFillIcon,
PinIcon,
PinSlashIcon,
} from "@primer/octicons-react";
@@ -27,6 +26,7 @@ interface UserLibraryGameCardProps {
statIndex: number;
onMouseEnter: () => void;
onMouseLeave: () => void;
sortBy?: string;
}
export function UserLibraryGameCard({
@@ -34,6 +34,7 @@ export function UserLibraryGameCard({
statIndex,
onMouseEnter,
onMouseLeave,
sortBy,
}: UserLibraryGameCardProps) {
const { userProfile, isMe, getUserLibraryGames } =
useContext(userProfileContext);
@@ -79,17 +80,22 @@ export function UserLibraryGameCard({
};
const formatPlayTime = useCallback(
(playTimeInSeconds = 0) => {
(playTimeInSeconds = 0, isShort = false) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
return t(isShort ? "amount_minutes_short" : "amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
const hoursKey = isShort ? "amount_hours_short" : "amount_hours";
const hoursAmount = isShort
? Math.floor(hours)
: numberFormatter.format(hours);
return t(hoursKey, { amount: hoursAmount });
},
[numberFormatter, t]
);
@@ -104,7 +110,7 @@ export function UserLibraryGameCard({
!game.isPinned
);
await getUserLibraryGames();
await getUserLibraryGames(sortBy);
if (game.isPinned) {
showSuccessToast(t("game_removed_from_pinned"));
@@ -130,33 +136,26 @@ export function UserLibraryGameCard({
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div className="user-library-game__overlay">
{(game.isFavorite || isMe) && (
{isMe && (
<div className="user-library-game__actions-container">
{game.isFavorite && (
<div className="user-library-game__favorite-icon">
<HeartFillIcon size={12} />
</div>
)}
{isMe && (
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
)}
<button
type="button"
className="user-library-game__pin-button"
onClick={(e) => {
e.stopPropagation();
toggleGamePinned();
}}
disabled={isPinning}
>
{game.isPinned ? (
<PinSlashIcon size={12} />
) : (
<PinIcon size={12} />
)}
</button>
</div>
)}
<small
<div
className="user-library-game__playtime"
data-tooltip-place="top"
data-tooltip-content={
@@ -174,8 +173,13 @@ export function UserLibraryGameCard({
) : (
<ClockIcon size={11} />
)}
{formatPlayTime(game.playTimeInSeconds)}
</small>
<span className="user-library-game__playtime-long">
{formatPlayTime(game.playTimeInSeconds)}
</span>
<span className="user-library-game__playtime-short">
{formatPlayTime(game.playTimeInSeconds, true)}
</span>
</div>
{userProfile?.hasActiveSubscription &&
game.achievementCount > 0 && (