Merge branch 'feat/new-catalogue' into feat/achievements-points

This commit is contained in:
Zamitto 2024-12-23 16:19:20 -03:00
commit 54886a2c5a
19 changed files with 568 additions and 387 deletions

View File

@ -2,16 +2,15 @@ import type { CatalogueSearchPayload } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
const PAGE_SIZE = 12;
const searchGames = async ( const searchGames = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
payload: CatalogueSearchPayload, payload: CatalogueSearchPayload,
page: number take: number,
skip: number
) => { ) => {
return HydraApi.post( return HydraApi.post(
"/catalogue/search", "/catalogue/search",
{ ...payload, take: page * PAGE_SIZE, skip: (page - 1) * PAGE_SIZE }, { ...payload, take, skip },
{ needsAuth: false } { needsAuth: false }
); );
}; };

View File

@ -59,6 +59,9 @@ export const getUserData = () => {
bio: "", bio: "",
email: null, email: null,
profileVisibility: "PUBLIC" as ProfileVisibility, profileVisibility: "PUBLIC" as ProfileVisibility,
quirks: {
backupsPerGameLimit: 0,
},
subscription: loggedUser.subscription subscription: loggedUser.subscription
? { ? {
id: loggedUser.subscription.subscriptionId, id: loggedUser.subscription.subscriptionId,

View File

@ -55,8 +55,8 @@ contextBridge.exposeInMainWorld("electron", {
}, },
/* Catalogue */ /* Catalogue */
searchGames: (payload: CatalogueSearchPayload, page: number) => searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) =>
ipcRenderer.invoke("searchGames", payload, page), ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) => getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category), ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) => getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
@ -83,8 +83,6 @@ contextBridge.exposeInMainWorld("electron", {
listener listener
); );
}, },
getPublishers: () => ipcRenderer.invoke("getPublishers"),
getDevelopers: () => ipcRenderer.invoke("getDevelopers"),
/* User preferences */ /* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"), getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),

View File

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
@ -66,6 +66,12 @@ export function Header() {
} }
}; };
useEffect(() => {
if (!location.pathname.startsWith("/catalogue")) {
dispatch(setFilters({ title: "" }));
}
}, [location.pathname, dispatch]);
return ( return (
<> <>
<header <header

View File

@ -58,7 +58,8 @@ declare global {
/* Catalogue */ /* Catalogue */
searchGames: ( searchGames: (
payload: CatalogueSearchPayload, payload: CatalogueSearchPayload,
page: number take: number,
skip: number
) => Promise<{ edges: any[]; count: number }>; ) => Promise<{ edges: any[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<any[]>; getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
getGameShopDetails: ( getGameShopDetails: (

View File

@ -0,0 +1,54 @@
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
});
export function useCatalogue() {
const [steamGenres, setSteamGenres] = useState<Record<string, string[]>>({});
const [steamUserTags, setSteamUserTags] = useState<
Record<string, Record<string, number>>
>({});
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
setSteamUserTags(response.data);
});
}, []);
const getSteamGenres = useCallback(() => {
externalResourcesInstance.get("/steam-genres.json").then((response) => {
setSteamGenres(response.data);
});
}, []);
const getSteamPublishers = useCallback(() => {
externalResourcesInstance.get("/steam-publishers.json").then((response) => {
setSteamPublishers(response.data);
});
}, []);
const getSteamDevelopers = useCallback(() => {
externalResourcesInstance.get("/steam-developers.json").then((response) => {
setSteamDevelopers(response.data);
});
}, []);
useEffect(() => {
getSteamUserTags();
getSteamGenres();
getSteamPublishers();
getSteamDevelopers();
}, [
getSteamUserTags,
getSteamGenres,
getSteamPublishers,
getSteamDevelopers,
]);
return { steamGenres, steamUserTags, steamPublishers, steamDevelopers };
}

View File

@ -19,7 +19,13 @@ export function useRepacks() {
const updateRepacks = useCallback(() => { const updateRepacks = useCallback(() => {
repacksTable.toArray().then((repacks) => { repacksTable.toArray().then((repacks) => {
dispatch( dispatch(
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds))) setRepacks(
JSON.parse(
JSON.stringify(
repacks.filter((repack) => Array.isArray(repack.objectIds))
)
)
)
); );
}); });
}, [dispatch]); }, [dispatch]);

View File

@ -1,17 +1,5 @@
@use "../../scss/globals.scss"; @use "../../scss/globals.scss";
@keyframes gradientBorder {
0% {
border-image-source: linear-gradient(0deg, #16b195 50%, #3e62c0 100%);
}
50% {
border-image-source: linear-gradient(90deg, #3e62c0 50%, #16b195 100%);
}
100% {
border-image-source: linear-gradient(180deg, #16b195 50%, #3e62c0 100%);
}
}
.catalogue { .catalogue {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -19,25 +7,6 @@
width: 100%; width: 100%;
padding: 16px; padding: 16px;
&__game-item {
background-color: globals.$dark-background-color;
width: 100%;
color: #fff;
display: flex;
align-items: center;
overflow: hidden;
position: relative;
border-radius: 4px;
border: 1px solid globals.$border-color;
cursor: pointer;
gap: 12px;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
&__filters-container { &__filters-container {
width: 270px; width: 270px;
min-width: 270px; min-width: 270px;

View File

@ -1,23 +1,25 @@
import { Badge, Button } from "@renderer/components";
import type { DownloadSource } from "@types"; import type { DownloadSource } from "@types";
import { useAppDispatch, useAppSelector, useRepacks } from "@renderer/hooks"; import {
useAppDispatch,
useAppSelector,
useFormat,
useRepacks,
} from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { XIcon } from "@primer/octicons-react";
import "./catalogue.scss"; import "./catalogue.scss";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { downloadSourcesTable } from "@renderer/dexie"; import { downloadSourcesTable } from "@renderer/dexie";
import { steamUrlBuilder } from "@shared";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useNavigate } from "react-router-dom";
import { FilterSection } from "./filter-section"; import { FilterSection } from "./filter-section";
import { setFilters } from "@renderer/features"; import { setFilters } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import axios from "axios";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Pagination } from "./pagination";
import { useCatalogue } from "@renderer/hooks/use-catalogue";
import { GameItem } from "./game-item";
import { FilterItem } from "./filter-item";
const filterCategoryColors = { const filterCategoryColors = {
genres: "hsl(262deg 50% 47%)", genres: "hsl(262deg 50% 47%)",
@ -27,21 +29,23 @@ const filterCategoryColors = {
publishers: "hsl(200deg 50% 30%)", publishers: "hsl(200deg 50% 30%)",
}; };
const PAGE_SIZE = 20;
export default function Catalogue() { export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const [steamUserTags, setSteamUserTags] = useState<any>({}); const { steamGenres, steamUserTags, steamDevelopers, steamPublishers } =
useCatalogue();
const navigate = useNavigate();
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]); const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [publishers, setPublishers] = useState<string[]>([]);
const [developers, setDevelopers] = useState<string[]>([]);
const [results, setResults] = useState<any[]>([]); const [results, setResults] = useState<any[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [itemsCount, setItemsCount] = useState(0);
const { formatNumber } = useFormat();
const { filters } = useAppSelector((state) => state.catalogueSearch); const { filters } = useAppSelector((state) => state.catalogueSearch);
@ -60,61 +64,159 @@ export default function Catalogue() {
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
window.electron window.electron
.searchGames(filters, page) .searchGames(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)
.then((response) => { .then((response) => {
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
return; return;
} }
setResults(response.edges); setResults(response.edges);
setTotalPages(Math.ceil(response.count / 12)); setItemsCount(response.count);
})
.finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [filters, page, dispatch]); }, [filters, page, dispatch]);
useEffect(() => {
window.electron.getDevelopers().then((developers) => {
setDevelopers(developers);
});
window.electron.getPublishers().then((publishers) => {
setPublishers(publishers);
});
}, []);
const gamesWithRepacks = useMemo(() => {
return results.map((game) => {
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = Array.from(
new Set(repacks.map((repack) => repack.repacker))
);
return { ...game, repacks: uniqueRepackers };
});
}, [results, getRepacksForObjectId]);
useEffect(() => { useEffect(() => {
downloadSourcesTable.toArray().then((sources) => { downloadSourcesTable.toArray().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint)); setDownloadSources(sources.filter((source) => !!source.fingerprint));
}); });
}, [getRepacksForObjectId]); }, [getRepacksForObjectId]);
useEffect(() => { const language = i18n.language.split("-")[0];
axios
.get(
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-user-tags.json`
)
.then((response) => {
const language = i18n.language.split("-")[0];
if (response.data[language]) { const steamGenresMapping = useMemo<Record<string, string>>(() => {
setSteamUserTags(response.data[language]); if (!steamGenres[language]) return {};
} else {
setSteamUserTags(response.data["en"]); return steamGenres[language].reduce((prev, genre, index) => {
} prev[genre] = steamGenres["en"][index];
}); return prev;
}, [i18n.language]); }, {});
}, [steamGenres, language]);
const steamGenresFilterItems = useMemo(() => {
return Object.entries(steamGenresMapping)
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => ({
label: key,
value: value,
checked: filters.genres.includes(value),
}));
}, [steamGenresMapping, filters.genres]);
useEffect(() => {
setPage(1);
}, [filters]);
const steamUserTagsFilterItems = useMemo(() => {
if (!steamUserTags[language]) return [];
return Object.entries(steamUserTags[language])
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => ({
label: key,
value: value.toString(),
checked: filters.tags.includes(value),
}));
}, [steamUserTags, filters.tags, language]);
const groupedFilters = useMemo(() => {
return [
...filters.genres.map((genre) => ({
label: Object.keys(steamGenresMapping).find(
(key) => steamGenresMapping[key] === genre
) as string,
orbColor: filterCategoryColors.genres,
key: "genres",
value: genre,
})),
...filters.tags.map((tag) => ({
label: Object.keys(steamUserTags[language]).find(
(key) => steamUserTags[language][key] === tag
) as string,
orbColor: filterCategoryColors.tags,
key: "tags",
value: tag,
})),
...filters.downloadSourceFingerprints.map((fingerprint) => ({
label: downloadSources.find(
(source) => source.fingerprint === fingerprint
)?.name as string,
orbColor: filterCategoryColors.downloadSourceFingerprints,
key: "downloadSourceFingerprints",
value: fingerprint,
})),
...filters.developers.map((developer) => ({
label: developer,
orbColor: filterCategoryColors.developers,
key: "developers",
value: developer,
})),
...filters.publishers.map((publisher) => ({
label: publisher,
orbColor: filterCategoryColors.publishers,
key: "publishers",
value: publisher,
})),
];
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
const filterSections = useMemo(() => {
return [
{
title: t("genres"),
items: steamGenresFilterItems,
key: "genres",
},
{
title: t("tags"),
items: steamUserTagsFilterItems,
key: "tags",
},
{
title: t("download_sources"),
items: downloadSources.map((source) => ({
label: source.name,
value: source.fingerprint,
checked: filters.downloadSourceFingerprints.includes(
source.fingerprint
),
})),
key: "downloadSourceFingerprints",
},
{
title: t("developers"),
items: steamDevelopers.map((developer) => ({
label: developer,
value: developer,
checked: filters.developers.includes(developer),
})),
key: "developers",
},
{
title: t("publishers"),
items: steamPublishers.map((publisher) => ({
label: publisher,
value: publisher,
checked: filters.publishers.includes(publisher),
})),
key: "publishers",
},
];
}, [
downloadSources,
filters.developers,
filters.downloadSourceFingerprints,
filters.publishers,
steamDevelopers,
steamGenresFilterItems,
steamPublishers,
steamUserTagsFilterItems,
t,
]);
return ( return (
<div className="catalogue"> <div className="catalogue">
@ -127,65 +229,35 @@ export default function Catalogue() {
}} }}
> >
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{filters.genres.map((genre) => ( <ul
<Badge key={genre}>
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
<div
style={{
width: 10,
height: 10,
backgroundColor: filterCategoryColors.genres,
borderRadius: "50%",
}}
/>
{genre}
</div>
</Badge>
))}
<li
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", gap: 8,
color: vars.color.body, flexWrap: "wrap",
backgroundColor: vars.color.darkBackground, listStyle: "none",
padding: "6px 12px", margin: 0,
borderRadius: 4, padding: 0,
border: `solid 1px ${vars.color.border}`,
fontSize: 12,
}} }}
> >
<div {groupedFilters.map((filter) => (
style={{ <li key={filter.label}>
width: 10, <FilterItem
height: 10, filter={filter.label}
backgroundColor: filterCategoryColors.genres, orbColor={filter.orbColor}
borderRadius: "50%", onRemove={() => {
marginRight: 8, dispatch(
}} setFilters({
/> [filter.key]: filters[filter.key].filter(
Action (item) => item !== filter.value
<button ),
type="button" })
style={{ );
color: vars.color.body, }}
marginLeft: 4, />
display: "flex", </li>
alignItems: "center", ))}
justifyContent: "center", </ul>
cursor: "pointer",
}}
>
<XIcon size={13} />
</button>
</li>
</div> </div>
{/* <Button theme="outline">
<XIcon />
Clear filters
</Button> */}
</div> </div>
<div <div
@ -208,7 +280,7 @@ export default function Catalogue() {
baseColor={vars.color.darkBackground} baseColor={vars.color.darkBackground}
highlightColor={vars.color.background} highlightColor={vars.color.background}
> >
{Array.from({ length: 24 }).map((_, i) => ( {Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton <Skeleton
key={i} key={i}
style={{ style={{
@ -220,236 +292,60 @@ export default function Catalogue() {
))} ))}
</SkeletonTheme> </SkeletonTheme>
) : ( ) : (
gamesWithRepacks.map((game, i) => ( results.map((game) => <GameItem key={game.id} game={game} />)
<button
type="button"
key={i}
className="catalogue__game-item"
onClick={() => navigate(buildGameDetailsPath(game))}
>
<img
style={{
width: 200,
height: "100%",
objectFit: "cover",
}}
src={steamUrlBuilder.library(game.objectId)}
alt={game.title}
loading="lazy"
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
padding: "16px 0",
}}
>
<span>{game.title}</span>
<span
style={{
color: vars.color.body,
marginBottom: 4,
fontSize: 12,
}}
>
{game.genres?.join(", ")}
</span>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{game.repacks.map((repack) => (
<Badge key={repack}>{repack}</Badge>
))}
</div>
</div>
</button>
))
)} )}
{totalPages > 1 && ( <div
<div style={{ display: "flex", gap: 8 }}> style={{
{Array.from({ length: 3 }).map((_, i) => ( display: "flex",
<Button theme="outline" key={i} onClick={() => setPage(i + 1)}> alignItems: "center",
{i + 1} justifyContent: "space-between",
</Button> marginTop: 16,
))} }}
</div> >
)} <span>{formatNumber(itemsCount)} resultados</span>
<Pagination
page={page}
totalPages={Math.ceil(itemsCount / PAGE_SIZE)}
onPageChange={setPage}
/>
</div>
</div> </div>
<div className="catalogue__filters-container"> <div className="catalogue__filters-container">
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<FilterSection {filterSections.map((section) => (
title={t("genres")} <FilterSection
onClear={() => dispatch(setFilters({ genres: [] }))} key={section.key}
color={filterCategoryColors.genres} title={section.title}
onSelect={(value) => { onClear={() => dispatch(setFilters({ [section.key]: [] }))}
if (filters.genres.includes(value)) { color={filterCategoryColors[section.key]}
dispatch( onSelect={(value) => {
setFilters({ if (filters[section.key].includes(value)) {
genres: filters.genres.filter((genre) => genre !== value), dispatch(
}) setFilters({
); [section.key]: filters[
} else { section.key as
dispatch(setFilters({ genres: [...filters.genres, value] })); | "genres"
} | "tags"
}} | "downloadSourceFingerprints"
items={[ | "developers"
"Action", | "publishers"
"Strategy", ].filter((item) => item !== value),
"RPG", })
"Casual", );
"Racing", } else {
"Sports", dispatch(
"Indie", setFilters({
"Adventure", [section.key]: [...filters[section.key], value],
"Simulation", })
"Massively Multiplayer", );
"Free to Play", }
"Accounting", }}
"Animation & Modeling", items={section.items}
"Audio Production", />
"Design & Illustration", ))}
"Education",
"Photo Editing",
"Software Training",
"Utilities",
"Video Production",
"Web Publishing",
"Game Development",
"Early Access",
"Sexual Content",
"Nudity",
"Violent",
"Gore",
"Documentary",
"Tutorial",
]
.sort()
.map((genre) => ({
label: genre,
value: genre,
checked: filters.genres.includes(genre),
}))}
/>
<FilterSection
title={t("tags")}
color={filterCategoryColors.tags}
onClear={() => dispatch(setFilters({ tags: [] }))}
onSelect={(value) => {
if (filters.tags.includes(Number(value))) {
dispatch(
setFilters({
tags: filters.tags.filter((tag) => tag !== Number(value)),
})
);
} else {
dispatch(
setFilters({ tags: [...filters.tags, Number(value)] })
);
}
}}
items={
Object.entries(steamUserTags)
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => ({
label: key,
value: value,
checked: filters.tags.includes(value as number),
})) as any
}
/>
<FilterSection
title={t("download_sources")}
color={filterCategoryColors.downloadSourceFingerprints}
onClear={() =>
dispatch(setFilters({ downloadSourceFingerprints: [] }))
}
onSelect={(value) => {
if (filters.downloadSourceFingerprints.includes(value)) {
dispatch(
setFilters({
downloadSourceFingerprints:
filters.downloadSourceFingerprints.filter(
(fingerprint) => fingerprint !== value
),
})
);
} else {
dispatch(
setFilters({
downloadSourceFingerprints: [
...filters.downloadSourceFingerprints,
value,
],
})
);
}
}}
items={downloadSources.map((downloadSource) => ({
label: downloadSource.name,
value: downloadSource.fingerprint,
checked: filters.downloadSourceFingerprints.includes(
downloadSource.fingerprint
),
}))}
/>
<FilterSection
title={t("developers")}
color={filterCategoryColors.developers}
onClear={() => dispatch(setFilters({ developers: [] }))}
onSelect={(value) => {
if (filters.developers.includes(value)) {
dispatch(
setFilters({
developers: filters.developers.filter(
(developer) => developer !== value
),
})
);
} else {
dispatch(
setFilters({ developers: [...filters.developers, value] })
);
}
}}
items={developers.map((developer) => ({
label: developer,
value: developer,
checked: filters.developers.includes(developer),
}))}
/>
<FilterSection
title={t("publishers")}
color={filterCategoryColors.publishers}
onClear={() => dispatch(setFilters({ publishers: [] }))}
onSelect={(value) => {
if (filters.publishers.includes(value)) {
dispatch(
setFilters({
publishers: filters.publishers.filter(
(publisher) => publisher !== value
),
})
);
} else {
dispatch(
setFilters({ publishers: [...filters.publishers, value] })
);
}
}}
items={publishers.map((publisher) => ({
label: publisher,
value: publisher,
checked: filters.publishers.includes(publisher),
}))}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,50 @@
import { vars } from "@renderer/theme.css";
import { XIcon } from "@primer/octicons-react";
interface FilterItemProps {
filter: string;
orbColor: string;
onRemove: () => void;
}
export function FilterItem({ filter, orbColor, onRemove }: FilterItemProps) {
return (
<div
style={{
display: "flex",
alignItems: "center",
color: vars.color.body,
backgroundColor: vars.color.darkBackground,
padding: "6px 12px",
borderRadius: 4,
border: `solid 1px ${vars.color.border}`,
fontSize: 12,
}}
>
<div
style={{
width: 10,
height: 10,
backgroundColor: orbColor,
borderRadius: "50%",
marginRight: 8,
}}
/>
{filter}
<button
type="button"
onClick={onRemove}
style={{
color: vars.color.body,
marginLeft: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
<XIcon size={13} />
</button>
</div>
);
}

View File

@ -0,0 +1,48 @@
@use "../../scss/globals.scss";
.game-item {
background-color: globals.$dark-background-color;
width: 100%;
color: #fff;
display: flex;
align-items: center;
overflow: hidden;
position: relative;
border-radius: 4px;
border: 1px solid globals.$border-color;
cursor: pointer;
gap: calc(globals.$spacing-unit * 2);
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&__cover {
width: 200px;
height: 100%;
object-fit: cover;
border-right: 1px solid globals.$border-color;
}
&__details {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: calc(globals.$spacing-unit * 2) 0;
}
&__genres {
color: globals.$body-color;
font-size: 12px;
text-align: left;
margin-bottom: 4px;
}
&__repackers {
display: flex;
gap: globals.$spacing-unit;
flex-wrap: wrap;
}
}

View File

@ -0,0 +1,50 @@
import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useRepacks } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import "./game-item.scss";
export interface GameItemProps {
game: any;
}
export function GameItem({ game }: GameItemProps) {
const navigate = useNavigate();
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = useMemo(() => {
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
}, [repacks]);
return (
<button
type="button"
className="game-item"
onClick={() => navigate(buildGameDetailsPath(game))}
>
<img
className="game-item__cover"
src={steamUrlBuilder.library(game.objectId)}
alt={game.title}
loading="lazy"
/>
<div className="game-item__details">
<span>{game.title}</span>
<span className="game-item__genres">{game.genres?.join(", ")}</span>
<div className="game-item__repackers">
{uniqueRepackers.map((repacker) => (
<Badge key={repacker}>{repacker}</Badge>
))}
</div>
</div>
</button>
);
}

View File

@ -0,0 +1,83 @@
import { Button } from "@renderer/components/button/button";
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
interface PaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({
page,
totalPages,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null;
// Number of visible pages
const visiblePages = 3;
// Calculate the start and end of the visible range
let startPage = Math.max(1, page - 1); // Shift range slightly back
let endPage = startPage + visiblePages - 1;
// Adjust the range if we're near the start or end
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - visiblePages + 1);
}
return (
<div
style={{
display: "flex",
gap: 4,
}}
>
{/* Previous Button */}
<Button
theme="outline"
onClick={() => onPageChange(page - 1)}
style={{ width: 40 }}
disabled={page === 1}
>
<ChevronLeftIcon />
</Button>
<div
style={{
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div>
{/* Page Buttons */}
{Array.from(
{ length: endPage - startPage + 1 },
(_, i) => startPage + i
).map((pageNumber) => (
<Button
theme={page === pageNumber ? "primary" : "outline"}
key={pageNumber}
onClick={() => onPageChange(pageNumber)}
>
{pageNumber}
</Button>
))}
{/* Next Button */}
<Button
theme="outline"
onClick={() => onPageChange(page + 1)}
style={{ width: 40 }}
disabled={page === totalPages}
>
<ChevronRightIcon />
</Button>
</div>
);
}

View File

@ -14,7 +14,7 @@ import {
TrashIcon, TrashIcon,
UploadIcon, UploadIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { useToast } from "@renderer/hooks"; import { useAppSelector, useToast } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AxiosProgressEvent } from "axios"; import { AxiosProgressEvent } from "axios";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
@ -145,6 +145,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const disableActions = uploadingBackup || restoringBackup || deletingArtifact; const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
const userDetails = useAppSelector((state) => state.userDetails.userDetails);
const backupsPerGameLimit = userDetails?.quirks.backupsPerGameLimit ?? 0;
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@ -181,7 +184,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
disabled={ disabled={
disableActions || disableActions ||
!backupPreview?.overall.totalGames || !backupPreview?.overall.totalGames ||
artifacts.length >= 2 artifacts.length >= backupsPerGameLimit
} }
> >
<UploadIcon /> <UploadIcon />
@ -199,7 +202,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
}} }}
> >
<h2>{t("backups")}</h2> <h2>{t("backups")}</h2>
<small>{artifacts.length} / 2</small> <small>
{artifacts.length} / {backupsPerGameLimit}
</small>
</div> </div>
</div> </div>

View File

@ -42,3 +42,17 @@ export const downloadSourcesHeader = style({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}); });
export const navigateToCatalogueButton = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
textDecoration: "underline",
cursor: "pointer",
":disabled": {
cursor: "default",
textDecoration: "none",
},
});

View File

@ -9,7 +9,6 @@ import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal"; import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks"; import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared"; import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context"; import { settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie"; import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers"; import { downloadSourcesWorker } from "@renderer/workers";
@ -163,14 +162,8 @@ export function SettingsDownloadSources() {
<button <button
type="button" type="button"
style={{ className={styles.navigateToCatalogueButton}
display: "flex", disabled={!downloadSource.fingerprint}
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
textDecoration: "underline",
cursor: "pointer",
}}
onClick={() => navigateToCatalogue(downloadSource.fingerprint)} onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
> >
<small> <small>

View File

@ -4,7 +4,7 @@ $dark-background-color: #151515;
$muted-color: #c0c1c7; $muted-color: #c0c1c7;
$body-color: #8e919b; $body-color: #8e919b;
$border-color: #424244; $border-color: rgba(255, 255, 255, 0.15);
$success-color: #1c9749; $success-color: #1c9749;
$danger-color: #801d1e; $danger-color: #801d1e;
$warning-color: #ffc107; $warning-color: #ffc107;

View File

@ -8,7 +8,7 @@ export const vars = createGlobalTheme(":root", {
darkBackground: "#151515", darkBackground: "#151515",
muted: "#c0c1c7", muted: "#c0c1c7",
body: "#8e919b", body: "#8e919b",
border: "#424244", border: "rgba(255, 255, 255, 0.15)",
success: "#1c9749", success: "#1c9749",
danger: "#e11d48", danger: "#e11d48",
warning: "#ffc107", warning: "#ffc107",

View File

@ -267,6 +267,9 @@ export interface UserDetails {
profileVisibility: ProfileVisibility; profileVisibility: ProfileVisibility;
bio: string; bio: string;
subscription: Subscription | null; subscription: Subscription | null;
quirks: {
backupsPerGameLimit: number;
};
} }
export interface UserProfile { export interface UserProfile {
@ -284,6 +287,9 @@ export interface UserProfile {
currentGame: UserProfileCurrentGame | null; currentGame: UserProfileCurrentGame | null;
bio: string; bio: string;
hasActiveSubscription: boolean; hasActiveSubscription: boolean;
quirks: {
backupsPerGameLimit: number;
};
} }
export interface UpdateProfileRequest { export interface UpdateProfileRequest {