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 { HydraApi } from "@main/services";
const PAGE_SIZE = 12;
const searchGames = async (
_event: Electron.IpcMainInvokeEvent,
payload: CatalogueSearchPayload,
page: number
take: number,
skip: number
) => {
return HydraApi.post(
"/catalogue/search",
{ ...payload, take: page * PAGE_SIZE, skip: (page - 1) * PAGE_SIZE },
{ ...payload, take, skip },
{ needsAuth: false }
);
};

View File

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

View File

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

View File

@ -1,5 +1,5 @@
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 { 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 (
<>
<header

View File

@ -58,7 +58,8 @@ declare global {
/* Catalogue */
searchGames: (
payload: CatalogueSearchPayload,
page: number
take: number,
skip: number
) => Promise<{ edges: any[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
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(() => {
repacksTable.toArray().then((repacks) => {
dispatch(
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
setRepacks(
JSON.parse(
JSON.stringify(
repacks.filter((repack) => Array.isArray(repack.objectIds))
)
)
)
);
});
}, [dispatch]);

View File

@ -1,17 +1,5 @@
@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 {
display: flex;
flex-direction: column;
@ -19,25 +7,6 @@
width: 100%;
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 {
width: 270px;
min-width: 270px;

View File

@ -1,23 +1,25 @@
import { Badge, Button } from "@renderer/components";
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 { XIcon } from "@primer/octicons-react";
import "./catalogue.scss";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
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 { setFilters } from "@renderer/features";
import { useTranslation } from "react-i18next";
import axios from "axios";
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 = {
genres: "hsl(262deg 50% 47%)",
@ -27,21 +29,23 @@ const filterCategoryColors = {
publishers: "hsl(200deg 50% 30%)",
};
const PAGE_SIZE = 20;
export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null);
const [steamUserTags, setSteamUserTags] = useState<any>({});
const navigate = useNavigate();
const { steamGenres, steamUserTags, steamDevelopers, steamPublishers } =
useCatalogue();
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [publishers, setPublishers] = useState<string[]>([]);
const [developers, setDevelopers] = useState<string[]>([]);
const [results, setResults] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [itemsCount, setItemsCount] = useState(0);
const { formatNumber } = useFormat();
const { filters } = useAppSelector((state) => state.catalogueSearch);
@ -60,61 +64,159 @@ export default function Catalogue() {
abortControllerRef.current = abortController;
window.electron
.searchGames(filters, page)
.searchGames(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)
.then((response) => {
if (abortController.signal.aborted) {
return;
}
setResults(response.edges);
setTotalPages(Math.ceil(response.count / 12));
})
.finally(() => {
setItemsCount(response.count);
setIsLoading(false);
});
}, [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(() => {
downloadSourcesTable.toArray().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint));
});
}, [getRepacksForObjectId]);
useEffect(() => {
axios
.get(
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-user-tags.json`
)
.then((response) => {
const language = i18n.language.split("-")[0];
const language = i18n.language.split("-")[0];
if (response.data[language]) {
setSteamUserTags(response.data[language]);
} else {
setSteamUserTags(response.data["en"]);
}
});
}, [i18n.language]);
const steamGenresMapping = useMemo<Record<string, string>>(() => {
if (!steamGenres[language]) return {};
return steamGenres[language].reduce((prev, genre, index) => {
prev[genre] = steamGenres["en"][index];
return prev;
}, {});
}, [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 (
<div className="catalogue">
@ -127,65 +229,35 @@ export default function Catalogue() {
}}
>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{filters.genres.map((genre) => (
<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
<ul
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,
gap: 8,
flexWrap: "wrap",
listStyle: "none",
margin: 0,
padding: 0,
}}
>
<div
style={{
width: 10,
height: 10,
backgroundColor: filterCategoryColors.genres,
borderRadius: "50%",
marginRight: 8,
}}
/>
Action
<button
type="button"
style={{
color: vars.color.body,
marginLeft: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
<XIcon size={13} />
</button>
</li>
{groupedFilters.map((filter) => (
<li key={filter.label}>
<FilterItem
filter={filter.label}
orbColor={filter.orbColor}
onRemove={() => {
dispatch(
setFilters({
[filter.key]: filters[filter.key].filter(
(item) => item !== filter.value
),
})
);
}}
/>
</li>
))}
</ul>
</div>
{/* <Button theme="outline">
<XIcon />
Clear filters
</Button> */}
</div>
<div
@ -208,7 +280,7 @@ export default function Catalogue() {
baseColor={vars.color.darkBackground}
highlightColor={vars.color.background}
>
{Array.from({ length: 24 }).map((_, i) => (
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton
key={i}
style={{
@ -220,236 +292,60 @@ export default function Catalogue() {
))}
</SkeletonTheme>
) : (
gamesWithRepacks.map((game, i) => (
<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>
))
results.map((game) => <GameItem key={game.id} game={game} />)
)}
{totalPages > 1 && (
<div style={{ display: "flex", gap: 8 }}>
{Array.from({ length: 3 }).map((_, i) => (
<Button theme="outline" key={i} onClick={() => setPage(i + 1)}>
{i + 1}
</Button>
))}
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 16,
}}
>
<span>{formatNumber(itemsCount)} resultados</span>
<Pagination
page={page}
totalPages={Math.ceil(itemsCount / PAGE_SIZE)}
onPageChange={setPage}
/>
</div>
</div>
<div className="catalogue__filters-container">
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<FilterSection
title={t("genres")}
onClear={() => dispatch(setFilters({ genres: [] }))}
color={filterCategoryColors.genres}
onSelect={(value) => {
if (filters.genres.includes(value)) {
dispatch(
setFilters({
genres: filters.genres.filter((genre) => genre !== value),
})
);
} else {
dispatch(setFilters({ genres: [...filters.genres, value] }));
}
}}
items={[
"Action",
"Strategy",
"RPG",
"Casual",
"Racing",
"Sports",
"Indie",
"Adventure",
"Simulation",
"Massively Multiplayer",
"Free to Play",
"Accounting",
"Animation & Modeling",
"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),
}))}
/>
{filterSections.map((section) => (
<FilterSection
key={section.key}
title={section.title}
onClear={() => dispatch(setFilters({ [section.key]: [] }))}
color={filterCategoryColors[section.key]}
onSelect={(value) => {
if (filters[section.key].includes(value)) {
dispatch(
setFilters({
[section.key]: filters[
section.key as
| "genres"
| "tags"
| "downloadSourceFingerprints"
| "developers"
| "publishers"
].filter((item) => item !== value),
})
);
} else {
dispatch(
setFilters({
[section.key]: [...filters[section.key], value],
})
);
}
}}
items={section.items}
/>
))}
</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,
UploadIcon,
} from "@primer/octicons-react";
import { useToast } from "@renderer/hooks";
import { useAppSelector, useToast } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { AxiosProgressEvent } from "axios";
import { formatDownloadProgress } from "@renderer/helpers";
@ -145,6 +145,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
const userDetails = useAppSelector((state) => state.userDetails.userDetails);
const backupsPerGameLimit = userDetails?.quirks.backupsPerGameLimit ?? 0;
return (
<Modal
visible={visible}
@ -181,7 +184,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
disabled={
disableActions ||
!backupPreview?.overall.totalGames ||
artifacts.length >= 2
artifacts.length >= backupsPerGameLimit
}
>
<UploadIcon />
@ -199,7 +202,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
}}
>
<h2>{t("backups")}</h2>
<small>{artifacts.length} / 2</small>
<small>
{artifacts.length} / {backupsPerGameLimit}
</small>
</div>
</div>

View File

@ -42,3 +42,17 @@ export const downloadSourcesHeader = style({
justifyContent: "space-between",
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 { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
@ -163,14 +162,8 @@ export function SettingsDownloadSources() {
<button
type="button"
style={{
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
textDecoration: "underline",
cursor: "pointer",
}}
className={styles.navigateToCatalogueButton}
disabled={!downloadSource.fingerprint}
onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
>
<small>

View File

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

View File

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

View File

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