mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-02 16:23:48 +03:00
feat: adding remove function to filter tags
This commit is contained in:
parent
4476b1b216
commit
b5a9beb481
@ -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 }
|
||||
);
|
||||
};
|
||||
|
@ -37,8 +37,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) =>
|
||||
@ -65,8 +65,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
listener
|
||||
);
|
||||
},
|
||||
getPublishers: () => ipcRenderer.invoke("getPublishers"),
|
||||
getDevelopers: () => ipcRenderer.invoke("getDevelopers"),
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||
|
@ -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
|
||||
|
3
src/renderer/src/declaration.d.ts
vendored
3
src/renderer/src/declaration.d.ts
vendored
@ -51,7 +51,8 @@ declare global {
|
||||
/* Catalogue */
|
||||
searchGames: (
|
||||
payload: CatalogueSearchPayload,
|
||||
page: number
|
||||
take: number,
|
||||
skip: number
|
||||
) => Promise<{ edges: any[]; count: number }>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
|
||||
getGameShopDetails: (
|
||||
|
54
src/renderer/src/hooks/use-catalogue.ts
Normal file
54
src/renderer/src/hooks/use-catalogue.ts
Normal 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 };
|
||||
}
|
@ -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]);
|
||||
|
@ -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;
|
||||
|
@ -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,155 @@ 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]);
|
||||
|
||||
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 +225,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
|
||||
@ -220,236 +288,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>
|
||||
|
50
src/renderer/src/pages/catalogue/filter-item.tsx
Normal file
50
src/renderer/src/pages/catalogue/filter-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
src/renderer/src/pages/catalogue/game-item.scss
Normal file
48
src/renderer/src/pages/catalogue/game-item.scss
Normal 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;
|
||||
}
|
||||
}
|
50
src/renderer/src/pages/catalogue/game-item.tsx
Normal file
50
src/renderer/src/pages/catalogue/game-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
83
src/renderer/src/pages/catalogue/pagination.tsx
Normal file
83
src/renderer/src/pages/catalogue/pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
||||
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
@ -163,14 +163,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>
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -243,6 +243,9 @@ export interface UserDetails {
|
||||
profileVisibility: ProfileVisibility;
|
||||
bio: string;
|
||||
subscription: Subscription | null;
|
||||
quirks: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
@ -260,6 +263,9 @@ export interface UserProfile {
|
||||
currentGame: UserProfileCurrentGame | null;
|
||||
bio: string;
|
||||
hasActiveSubscription: boolean;
|
||||
quirks: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
|
Loading…
Reference in New Issue
Block a user