feat: adding source search

This commit is contained in:
Chubby Granny Chaser 2024-12-20 17:07:51 +00:00
parent 1e99dae9de
commit 109c12064d
No known key found for this signature in database
49 changed files with 2010 additions and 958 deletions

View File

@ -26,4 +26,9 @@ module.exports = {
},
],
},
settings: {
react: {
version: "detect",
},
},
};

View File

@ -1,10 +1,8 @@
import { DataSource } from "typeorm";
import {
DownloadQueue,
DownloadSource,
Game,
GameShopCache,
Repack,
UserPreferences,
UserAuth,
GameAchievement,
@ -17,12 +15,10 @@ export const dataSource = new DataSource({
type: "better-sqlite3",
entities: [
Game,
Repack,
UserAuth,
UserPreferences,
UserSubscription,
GameShopCache,
DownloadSource,
DownloadQueue,
GameAchievement,
],

View File

@ -1,41 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from "typeorm";
import type { Repack } from "./repack.entity";
import { DownloadSourceStatus } from "@shared";
@Entity("download_source")
export class DownloadSource {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true, unique: true })
url: string;
@Column("text")
name: string;
@Column("text", { nullable: true })
etag: string | null;
@Column("int", { default: 0 })
downloadCount: number;
@Column("text", { default: DownloadSourceStatus.UpToDate })
status: DownloadSourceStatus;
@OneToMany("Repack", "downloadSource", { cascade: true })
repacks: Repack[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -5,9 +5,7 @@ import {
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import { Repack } from "./repack.entity";
import type { GameShop, GameStatus } from "@types";
import { Downloader } from "@shared";
@ -72,13 +70,6 @@ export class Game {
@Column("text", { nullable: true })
uri: string | null;
/**
* @deprecated
*/
@OneToOne("Repack", "game", { nullable: true })
@JoinColumn()
repack: Repack;
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;

View File

@ -1,10 +1,8 @@
export * from "./game.entity";
export * from "./repack.entity";
export * from "./user-auth.entity";
export * from "./user-preferences.entity";
export * from "./user-subscription.entity";
export * from "./game-shop-cache.entity";
export * from "./game.entity";
export * from "./game-achievements.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity";

View File

@ -1,45 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
} from "typeorm";
import { DownloadSource } from "./download-source.entity";
@Entity("repack")
export class Repack {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
title: string;
/**
* @deprecated Use uris instead
*/
@Column("text", { unique: true })
magnet: string;
@Column("text")
repacker: string;
@Column("text")
fileSize: string;
@Column("datetime")
uploadDate: Date | string;
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
downloadSource: DownloadSource;
@Column("text", { default: "[]" })
uris: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -1,9 +1,6 @@
import type { GameShop } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { CatalogueCategory, steamUrlBuilder } from "@shared";
import { steamGamesWorker } from "@main/workers";
import { CatalogueCategory } from "@shared";
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
@ -14,26 +11,11 @@ const getCatalogue = async (
skip: "0",
});
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
return HydraApi.get(
`/catalogue/${category}?${params.toString()}`,
{},
{ needsAuth: false }
);
return Promise.all(
response.map(async (game) => {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
return {
title: steamGame.name,
shop: game.shop,
cover: steamUrlBuilder.library(game.objectId),
objectId: game.objectId,
};
})
);
};
registerEvent("getCatalogue", getCatalogue);

View File

@ -1,29 +0,0 @@
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { steamUrlBuilder } from "@shared";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take = 12,
skip = 0
): Promise<CatalogueEntry[]> => {
const searchParams = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
});
const games = await HydraApi.get<CatalogueEntry[]>(
`/games/catalogue?${searchParams.toString()}`,
undefined,
{ needsAuth: false }
);
return games.map((game) => ({
...game,
cover: steamUrlBuilder.library(game.objectId),
}));
};
registerEvent("getGames", getGames);

View File

@ -1,23 +1,16 @@
import type { CatalogueSearchPayload } from "@types";
import { registerEvent } from "../register-event";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import type { CatalogueEntry } from "@types";
import { HydraApi } from "@main/services";
const searchGamesEvent = async (
const searchGames = async (
_event: Electron.IpcMainInvokeEvent,
query: string
): Promise<CatalogueEntry[]> => {
const games = await HydraApi.get<
{ objectId: string; title: string; shop: string }[]
>("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false });
return games.map((game) => {
return convertSteamGameToCatalogueEntry({
id: Number(game.objectId),
name: game.title,
clientIcon: null,
});
});
payload: CatalogueSearchPayload
) => {
return HydraApi.post(
"/catalogue/search",
{ ...payload, take: 12, skip: 0 },
{ needsAuth: false }
);
};
registerEvent("searchGames", searchGamesEvent);
registerEvent("searchGames", searchGames);

View File

@ -1,9 +0,0 @@
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => knexClient("download_source").where({ id }).delete();
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@ -1,7 +0,0 @@
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
knexClient.select("*").from("download_source");
registerEvent("getDownloadSources", getDownloadSources);

View File

@ -0,0 +1,13 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const putDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
objectIds: string[]
) => {
return HydraApi.put<{ fingerprint: string }>("/download-sources", {
objectIds,
});
};
registerEvent("putDownloadSource", putDownloadSource);

View File

@ -1,31 +0,0 @@
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { steamGamesWorker } from "@main/workers";
import { steamUrlBuilder } from "@shared";
export interface SearchGamesArgs {
query?: string;
take?: number;
skip?: number;
}
export const convertSteamGameToCatalogueEntry = (
game: SteamGame
): CatalogueEntry => ({
objectId: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: steamUrlBuilder.library(String(game.id)),
});
export const getSteamGameById = async (
objectId: string
): Promise<CatalogueEntry | null> => {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
if (!steamGame) return null;
return convertSteamGameToCatalogueEntry(steamGame);
};

View File

@ -3,7 +3,6 @@ import { ipcMain } from "electron";
import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details";
import "./catalogue/get-games";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
@ -38,8 +37,7 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./download-sources/delete-download-source";
import "./download-sources/get-download-sources";
import "./download-sources/put-download-source";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";

View File

@ -1,10 +1,8 @@
import { dataSource } from "./data-source";
import {
DownloadQueue,
DownloadSource,
Game,
GameShopCache,
Repack,
UserPreferences,
UserAuth,
GameAchievement,
@ -13,16 +11,11 @@ import {
export const gameRepository = dataSource.getRepository(Game);
export const repackRepository = dataSource.getRepository(Repack);
export const userPreferencesRepository =
dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const downloadSourceRepository =
dataSource.getRepository(DownloadSource);
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth);

View File

@ -11,6 +11,7 @@ import type {
GameRunning,
FriendRequestAction,
UpdateProfileRequest,
CatalogueSearchPayload,
} from "@types";
import type { CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
@ -36,7 +37,8 @@ contextBridge.exposeInMainWorld("electron", {
},
/* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
searchGames: (payload: CatalogueSearchPayload) =>
ipcRenderer.invoke("searchGames", payload),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
@ -44,10 +46,6 @@ contextBridge.exposeInMainWorld("electron", {
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
getGames: (take?: number, skip?: number) =>
ipcRenderer.invoke("getGames", take, skip),
searchGameRepacks: (query: string) =>
ipcRenderer.invoke("searchGameRepacks", query),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
@ -78,9 +76,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
/* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
putDownloadSource: (objectIds: string[]) =>
ipcRenderer.invoke("putDownloadSource", objectIds),
/* Library */
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>

View File

@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
@ -7,6 +7,7 @@ import {
useAppSelector,
useDownload,
useLibrary,
useRepacks,
useToast,
useUserDetails,
} from "@renderer/hooks";
@ -15,8 +16,6 @@ import * as styles from "./app.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
setSearch,
clearSearch,
setUserPreferences,
toggleDraggingDisabled,
closeToast,
@ -27,8 +26,6 @@ import {
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context";
import { logger } from "./logger";
export interface AppProps {
children: React.ReactNode;
@ -42,9 +39,9 @@ export function App() {
const downloadSourceMigrationLock = useRef(false);
const { clearDownload, setLastPacket } = useDownload();
const { updateRepacks } = useRepacks();
const { indexRepacks } = useContext(repacksContext);
const { clearDownload, setLastPacket } = useDownload();
const {
isFriendsModalVisible,
@ -67,8 +64,6 @@ export function App() {
const navigate = useNavigate();
const location = useLocation();
const search = useAppSelector((state) => state.search.value);
const draggingDisabled = useAppSelector(
(state) => state.window.draggingDisabled
);
@ -187,31 +182,6 @@ export function App() {
};
}, [onSignIn, updateLibrary, clearUserDetails]);
const handleSearch = useCallback(
(query: string) => {
dispatch(setSearch(query));
if (query === "") {
navigate(-1);
return;
}
const searchParams = new URLSearchParams({
query,
});
navigate(`/search?${searchParams.toString()}`, {
replace: location.pathname.startsWith("/search"),
});
},
[dispatch, location.pathname, navigate]
);
const handleClear = useCallback(() => {
dispatch(clearSearch());
navigate(-1);
}, [dispatch, navigate]);
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
}, [location.pathname, location.search]);
@ -232,49 +202,19 @@ export function App() {
downloadSourceMigrationLock.current = true;
window.electron.getDownloadSources().then(async (downloadSources) => {
if (!downloadSources.length) {
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
updateRepacks();
channel.onmessage = (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
};
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}
channel.onmessage = (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
updateRepacks();
};
for (const downloadSource of downloadSources) {
logger.info("Migrating download source", downloadSource.url);
const channel = new BroadcastChannel(
`download_sources:import:${downloadSource.url}`
);
await new Promise((resolve) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
downloadSource.url,
]);
channel.onmessage = () => {
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
resolve(true);
logger.info(
"Deleted download source from SQLite",
downloadSource.url
);
});
indexRepacks();
channel.close();
};
}).catch(() => channel.close());
}
downloadSourceMigrationLock.current = false;
});
}, [indexRepacks]);
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
@ -313,11 +253,7 @@ export function App() {
<Sidebar />
<article className={styles.container}>
<Header
onSearch={handleSearch}
search={search}
onClear={handleClear}
/>
<Header />
<section ref={contentRef} className={styles.content}>
<Outlet />

View File

@ -1,6 +1,7 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const checkboxField = style({
display: "flex",
@ -10,19 +11,30 @@ export const checkboxField = style({
cursor: "pointer",
});
export const checkbox = style({
width: "20px",
height: "20px",
borderRadius: "4px",
backgroundColor: vars.color.darkBackground,
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.border}`,
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
export const checkbox = recipe({
base: {
width: "20px",
height: "20px",
borderRadius: "4px",
backgroundColor: vars.color.darkBackground,
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.border}`,
minWidth: "20px",
minHeight: "20px",
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
variants: {
checked: {
true: {
backgroundColor: vars.color.muted,
},
},
},
});

View File

@ -15,7 +15,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
return (
<div className={styles.checkboxField}>
<div className={styles.checkbox}>
<div className={styles.checkbox({ checked: props.checked })}>
<input
id={id}
type="checkbox"

View File

@ -1,21 +1,21 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
import type { GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { useCallback, useContext, useEffect, useState } from "react";
import { useFormat } from "@renderer/hooks";
import { repacksContext } from "@renderer/context";
import { useCallback, useState } from "react";
import { useFormat, useRepacks } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
export interface GameCardProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
game: CatalogueEntry;
game: any;
}
const shopIcon = {
@ -26,20 +26,12 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const [stats, setStats] = useState<GameStats | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(game.title).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, isIndexingRepacks, searchRepacks]);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = Array.from(
new Set(repacks.map(({ repacker }) => repacker))
new Set(repacks.map((repack) => repack.repacker))
);
const handleHover = useCallback(() => {
@ -61,7 +53,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
>
<div className={styles.backdrop}>
<img
src={game.cover}
src={steamUrlBuilder.library(game.objectId)}
alt={game.title}
className={styles.cover}
loading="lazy"

View File

@ -74,30 +74,9 @@ export const search = recipe({
},
});
export const searchInput = style({
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
":focus": {
cursor: "text",
},
});
export const actionButton = style({
color: "inherit",
cursor: "pointer",
transition: "all ease 0.2s",
padding: `${SPACING_UNIT}px`,
":hover": {
color: "#DADBE1",
},
});
export const searchButton = style({
WebkitAppRegion: "no-drag",
} as ComplexStyleRule);
export const section = style({
display: "flex",

View File

@ -1,19 +1,13 @@
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import { ArrowLeftIcon, SearchIcon } from "@primer/octicons-react";
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import { useAppSelector } from "@renderer/hooks";
import * as styles from "./header.css";
import { clearSearch } from "@renderer/features";
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
export interface HeaderProps {
onSearch: (query: string) => void;
onClear: () => void;
search?: string;
}
import { Button } from "../button/button";
const pathTitle: Record<string, string> = {
"/": "home",
@ -22,18 +16,13 @@ const pathTitle: Record<string, string> = {
"/settings": "settings",
};
export function Header({ onSearch, onClear, search }: HeaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
export function Header() {
const navigate = useNavigate();
const location = useLocation();
const { headerTitle, draggingDisabled } = useAppSelector(
(state) => state.window
);
const dispatch = useAppDispatch();
const [isFocused, setIsFocused] = useState(false);
const { t } = useTranslation("header");
@ -46,21 +35,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
return t(pathTitle[location.pathname]);
}, [location.pathname, headerTitle, t]);
useEffect(() => {
if (search && !location.pathname.startsWith("/search")) {
dispatch(clearSearch());
}
}, [location.pathname, search, dispatch]);
const focusInput = () => {
setIsFocused(true);
inputRef.current?.focus();
};
const handleBlur = () => {
setIsFocused(false);
};
const handleBackButtonClick = () => {
navigate(-1);
};
@ -95,37 +69,14 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
</section>
<section className={styles.section}>
<div className={styles.search({ focused: isFocused })}>
<button
type="button"
className={styles.actionButton}
onClick={focusInput}
>
<SearchIcon />
</button>
<input
ref={inputRef}
type="text"
name="search"
placeholder={t("search")}
value={search}
className={styles.searchInput}
onChange={(event) => onSearch(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
/>
{search && (
<button
type="button"
onClick={onClear}
className={styles.actionButton}
>
<XIcon />
</button>
)}
</div>
<Button
theme="outline"
className={styles.searchButton}
onClick={() => navigate("/catalogue?search=true")}
>
<SearchIcon />
{t("search")}
</Button>
</section>
</header>
<AutoUpdateSubHeader />

View File

@ -1,7 +1,6 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
@ -14,12 +13,12 @@ import {
useAppDispatch,
useAppSelector,
useDownload,
useRepacks,
useUserDetails,
} from "@renderer/hooks";
import type {
Game,
GameRepack,
GameShop,
GameStats,
ShopDetails,
@ -29,7 +28,6 @@ import type {
import { useTranslation } from "react-i18next";
import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
import { repacksContext } from "../repacks/repacks.context";
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
@ -88,17 +86,8 @@ export function GameDetailsContextProvider({
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(gameTitle).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(objectId);
const { i18n } = useTranslation("game_details");

View File

@ -1,5 +1,4 @@
export * from "./game-details/game-details.context";
export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context";
export * from "./repacks/repacks.context";
export * from "./cloud-sync/cloud-sync.context";

View File

@ -1,67 +0,0 @@
import type { GameRepack } from "@types";
import { createContext, useCallback, useEffect, useState } from "react";
import { repacksWorker } from "@renderer/workers";
export interface RepacksContext {
searchRepacks: (query: string) => Promise<GameRepack[]>;
indexRepacks: () => void;
isIndexingRepacks: boolean;
}
export const repacksContext = createContext<RepacksContext>({
searchRepacks: async () => [] as GameRepack[],
indexRepacks: () => {},
isIndexingRepacks: false,
});
const { Provider } = repacksContext;
export const { Consumer: RepacksContextConsumer } = repacksContext;
export interface RepacksContextProps {
children: React.ReactNode;
}
export function RepacksContextProvider({ children }: RepacksContextProps) {
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
const searchRepacks = useCallback(async (query: string) => {
return new Promise<GameRepack[]>((resolve) => {
const channelId = crypto.randomUUID();
repacksWorker.postMessage([channelId, query]);
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
resolve(event.data);
channel.close();
};
return [];
});
}, []);
const indexRepacks = useCallback(() => {
setIsIndexingRepacks(true);
repacksWorker.postMessage("INDEX_REPACKS");
repacksWorker.onmessage = () => {
setIsIndexingRepacks(false);
};
}, []);
useEffect(() => {
indexRepacks();
}, [indexRepacks]);
return (
<Provider
value={{
searchRepacks,
indexRepacks,
isIndexingRepacks,
}}
>
{children}
</Provider>
);
}

View File

@ -1,10 +1,8 @@
import type { CatalogueCategory } from "@shared";
import type {
AppUpdaterEvent,
CatalogueEntry,
Game,
LibraryGame,
GameRepack,
GameShop,
HowLongToBeatCategory,
ShopDetails,
@ -13,7 +11,6 @@ import type {
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
DownloadSource,
UserProfile,
FriendRequest,
FriendRequestAction,
@ -30,6 +27,7 @@ import type {
LudusaviBackup,
UserAchievement,
ComparedAchievements,
CatalogueSearchPayload,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type { DiskSpace } from "check-disk-space";
@ -51,8 +49,8 @@ declare global {
) => () => Electron.IpcRenderer;
/* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
searchGames: (payload: CatalogueSearchPayload) => Promise<any[]>;
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
getGameShopDetails: (
objectId: string,
shop: GameShop,
@ -63,8 +61,6 @@ declare global {
objectId: string,
shop: GameShop
) => Promise<HowLongToBeatCategory[] | null>;
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
onUpdateAchievements: (
@ -118,8 +114,9 @@ declare global {
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
deleteDownloadSource: (id: number) => Promise<void>;
putDownloadSource: (
objectIds: string[]
) => Promise<{ fingerprint: string }>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;

View File

@ -21,11 +21,10 @@ export interface CatalogueCache {
export const db = new Dexie("Hydra");
db.version(5).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
db.version(8).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
@ -34,6 +33,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
"howLongToBeatEntries"
);
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
db.open();

View File

@ -0,0 +1,37 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { CatalogueSearchPayload } from "@types";
export interface CatalogueSearchState {
value: CatalogueSearchPayload;
}
const initialState: CatalogueSearchState = {
value: {
title: "",
downloadSourceFingerprints: [],
tags: [],
publishers: [],
genres: [],
developers: [],
},
};
export const catalogueSearchSlice = createSlice({
name: "catalogueSearch",
initialState,
reducers: {
setSearch: (
state,
action: PayloadAction<Partial<CatalogueSearchPayload>>
) => {
state.value = { ...state.value, ...action.payload };
},
clearSearch: (state) => {
state.value = initialState.value;
},
},
});
export const { setSearch } = catalogueSearchSlice.actions;

View File

@ -1,4 +1,3 @@
export * from "./search-slice";
export * from "./library-slice";
export * from "./use-preferences-slice";
export * from "./download-slice";
@ -6,3 +5,5 @@ export * from "./window-slice";
export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./running-game-slice";
export * from "./repacks-slice";
export * from "./catalogue-search";

View File

@ -0,0 +1,24 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { GameRepack } from "@types";
export interface RepacksState {
value: GameRepack[];
}
const initialState: RepacksState = {
value: [],
};
export const repacksSlice = createSlice({
name: "repacks",
initialState,
reducers: {
setRepacks: (state, action: PayloadAction<RepacksState["value"]>) => {
state.value = action.payload;
},
},
});
export const { setRepacks } = repacksSlice.actions;

View File

@ -1,25 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
export interface SearchState {
value: string;
}
const initialState: SearchState = {
value: "",
};
export const searchSlice = createSlice({
name: "search",
initialState,
reducers: {
setSearch: (state, action: PayloadAction<string>) => {
state.value = action.payload;
},
clearSearch: (state) => {
state.value = "";
},
},
});
export const { setSearch, clearSearch } = searchSlice.actions;

View File

@ -5,3 +5,4 @@ export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
export * from "./use-repacks";

View File

@ -10,5 +10,5 @@ export function useFormat() {
});
}, [i18n.language]);
return { numberFormatter };
return { numberFormatter, formatNumber: numberFormatter.format };
}

View File

@ -0,0 +1,26 @@
import { repacksTable } from "@renderer/dexie";
import { setRepacks } from "@renderer/features";
import { useCallback } from "react";
import { RootState } from "@renderer/store";
import { useSelector } from "react-redux";
import { useAppDispatch } from "./redux";
export function useRepacks() {
const dispatch = useAppDispatch();
const repacks = useSelector((state: RootState) => state.repacks.value);
const getRepacksForObjectId = useCallback(
(objectId: string) => {
return repacks.filter((repack) => repack.objectIds.includes(objectId));
},
[repacks]
);
const updateRepacks = useCallback(() => {
repacksTable.toArray().then((repacks) => {
dispatch(setRepacks(repacks));
});
}, [dispatch]);
return { getRepacksForObjectId, updateRepacks };
}

View File

@ -18,7 +18,6 @@ import { store } from "./store";
import resources from "@locales";
import { RepacksContextProvider } from "./context";
import { SuspenseWrapper } from "./components";
import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies";
@ -28,7 +27,6 @@ const GameDetails = React.lazy(
() => import("./pages/game-details/game-details")
);
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
const SearchResults = React.lazy(() => import("./pages/home/search-results"));
const Settings = React.lazy(() => import("./pages/settings/settings"));
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
const Profile = React.lazy(() => import("./pages/profile/profile"));
@ -64,43 +62,37 @@ i18n
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<RepacksContextProvider>
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
<Route
path="/catalogue"
element={<SuspenseWrapper Component={Catalogue} />}
/>
<Route
path="/downloads"
element={<SuspenseWrapper Component={Downloads} />}
/>
<Route
path="/game/:shop/:objectId"
element={<SuspenseWrapper Component={GameDetails} />}
/>
<Route
path="/search"
element={<SuspenseWrapper Component={SearchResults} />}
/>
<Route
path="/settings"
element={<SuspenseWrapper Component={Settings} />}
/>
<Route
path="/profile/:userId"
element={<SuspenseWrapper Component={Profile} />}
/>
<Route
path="/achievements"
element={<SuspenseWrapper Component={Achievements} />}
/>
</Route>
</Routes>
</HashRouter>
</RepacksContextProvider>
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
<Route
path="/catalogue"
element={<SuspenseWrapper Component={Catalogue} />}
/>
<Route
path="/downloads"
element={<SuspenseWrapper Component={Downloads} />}
/>
<Route
path="/game/:shop/:objectId"
element={<SuspenseWrapper Component={GameDetails} />}
/>
<Route
path="/settings"
element={<SuspenseWrapper Component={Settings} />}
/>
<Route
path="/profile/:userId"
element={<SuspenseWrapper Component={Profile} />}
/>
<Route
path="/achievements"
element={<SuspenseWrapper Component={Achievements} />}
/>
</Route>
</Routes>
</HashRouter>
</Provider>
</React.StrictMode>
);

View File

@ -0,0 +1,114 @@
@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;
gap: calc(globals.$spacing-unit * 2);
width: 100%;
padding: 16px;
&__search-container {
background-color: globals.$dark-background-color;
display: inline-flex;
transition: all ease 0.2s;
width: 200px;
align-items: center;
border-radius: 8px;
border: 1px solid globals.$border-color;
height: 40px;
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&--focused {
width: 250px;
border-color: #dadbe1;
}
}
&__search-icon-button {
color: inherit;
cursor: pointer;
transition: all ease 0.2s;
padding: 8px;
&:hover {
color: #dadbe1;
}
}
&__search-clear-button {
color: inherit;
cursor: pointer;
transition: all ease 0.2s;
padding: 8px;
&:hover {
color: #dadbe1;
}
}
&__search-input {
background-color: transparent;
border: none;
width: 100%;
height: 100%;
outline: none;
color: #dadbe1;
cursor: default;
font-family: inherit;
text-overflow: ellipsis;
}
&__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: 250px;
min-width: 250px;
max-width: 250px;
background-color: globals.$dark-background-color;
border-radius: 4px;
padding: 16px;
border: 1px solid globals.$border-color;
}
&__ai-recommendations-button-text {
font-weight: 400;
color: #fff;
background: linear-gradient(0deg, #16b195 50%, #3e62c0 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: bold;
}
}

View File

@ -1,114 +1,374 @@
import { Button, GameCard } from "@renderer/components";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { useTranslation } from "react-i18next";
import { Badge, Button } from "@renderer/components";
import type { CatalogueEntry } from "@types";
import type { DownloadSource } from "@types";
import { clearSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "../home/home.css";
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import cn from "classnames";
import { useAppDispatch, useAppSelector, useRepacks } from "@renderer/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LightBulbIcon, SearchIcon, 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, useSearchParams } from "react-router-dom";
import { FilterSection } from "./filter-section";
import { setSearch } from "@renderer/features";
import { useTranslation } from "react-i18next";
export default function Catalogue() {
const inputRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState(false);
const [searchParams] = useSearchParams();
const search = searchParams.get("search");
const navigate = useNavigate();
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [games, setGames] = useState<any[]>([]);
const filters = useAppSelector((state) => state.catalogueSearch.value);
const dispatch = useAppDispatch();
const { t } = useTranslation("catalogue");
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const contentRef = useRef<HTMLElement>(null);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const skip = Number(searchParams.get("skip") ?? 0);
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(buildGameDetailsPath(game));
};
const { getRepacksForObjectId } = useRepacks();
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
setIsLoading(true);
setSearchResults([]);
setGames([]);
window.electron
.getGames(24, skip)
.then((results) => {
return new Promise((resolve) => {
setTimeout(() => {
setSearchResults(results);
resolve(null);
}, 500);
});
})
.finally(() => {
setIsLoading(false);
});
}, [dispatch, skip, searchParams]);
const handleNextPage = () => {
const params = new URLSearchParams({
skip: String(skip + 24),
window.electron.searchGames(filters).then((games) => {
setGames(games);
});
}, [filters]);
navigate(`/catalogue?${params.toString()}`);
};
const gamesWithRepacks = useMemo(() => {
return games.map((game) => {
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = Array.from(
new Set(repacks.map((repack) => repack.repacker))
);
return { ...game, repacks: uniqueRepackers };
});
}, [games, getRepacksForObjectId]);
useEffect(() => {
downloadSourcesTable.toArray().then((sources) => {
setDownloadSources(sources);
});
}, [getRepacksForObjectId]);
const focusInput = useCallback(() => {
setFocused(true);
inputRef.current?.focus();
}, []);
const onSearch = useCallback(
(value: string) => {
dispatch(setSearch({ title: value }));
},
[dispatch]
);
useEffect(() => {
if (search) {
focusInput();
}
}, [search, focusInput]);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section
<div className="catalogue">
<div
style={{
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 4}px`,
display: "flex",
width: "100%",
justifyContent: "space-between",
alignItems: "center",
borderBottom: `1px solid ${vars.color.border}`,
flexDirection: "column",
alignItems: "flex-start",
}}
>
<Button
onClick={() => navigate(-1)}
theme="outline"
disabled={skip === 0 || isLoading}
<div
className={cn("catalogue__search-container", {
["catalogue__search-container--focused"]: focused,
})}
>
<ArrowLeftIcon />
{t("previous_page")}
</Button>
<button
type="button"
className="catalogue__search-icon-button"
onClick={focusInput}
>
<SearchIcon />
</button>
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}>
{t("next_page")}
<ArrowRightIcon />
</Button>
</section>
<input
ref={inputRef}
type="text"
name="search"
placeholder={t("search")}
value={filters.title}
className="catalogue__search-input"
onChange={(event) => onSearch(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
<section ref={contentRef} className={styles.content}>
<section className={styles.cards}>
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))}
{!isLoading && searchResults.length > 0 && (
<>
{searchResults.map((game) => (
<GameCard
key={game.objectId}
game={game}
onClick={() => handleGameClick(game)}
/>
))}
</>
{filters.title && (
<button
type="button"
onClick={() => dispatch(setSearch({ title: "" }))}
className="catalogue__search-clear-button"
>
<XIcon />
</button>
)}
</section>
</section>
</SkeletonTheme>
</div>
</div>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{filters.downloadSourceFingerprints.map((fingerprint) => (
<Badge key={fingerprint}>
{
downloadSources.find(
(source) => source.fingerprint === fingerprint
)!.name
}
</Badge>
))}
</div>
{/* <Button theme="outline">
<XIcon />
Clear filters
</Button> */}
</div>
<div
style={{
display: "flex",
gap: SPACING_UNIT * 2,
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: 8,
}}
>
{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>
))}
<div style={{ display: "flex", gap: 8 }}>
<Button theme="outline">1</Button>
<Button theme="outline">2</Button>
</div>
</div>
<div className="catalogue__filters-container">
<Button
style={{ width: "100%", marginBottom: 16 }}
theme="outline"
className="catalogue__ai-recommendations-button"
>
<span className="catalogue__ai-recommendations-button-text">
<LightBulbIcon size={14} />
Recomendações por AI
</span>
</Button>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<FilterSection
title="Genres"
onSelect={(value) => {
if (filters.genres.includes(value)) {
dispatch(
setSearch({
genres: filters.genres.filter((genre) => genre !== value),
})
);
} else {
dispatch(setSearch({ 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",
].map((genre) => ({
label: genre,
value: genre,
checked: filters.genres.includes(genre),
}))}
/>
<FilterSection
title="User tags"
onSelect={(value) => {
if (filters.tags.includes(value)) {
dispatch(
setSearch({
tags: filters.tags.filter((tag) => tag !== value),
})
);
} else {
dispatch(setSearch({ tags: [...filters.tags, 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",
].map((genre) => ({
label: genre,
value: genre,
checked: filters.tags.includes(genre),
}))}
/>
<FilterSection
title="Download sources"
onSelect={(value) => {
if (filters.downloadSourceFingerprints.includes(value)) {
dispatch(
setSearch({
downloadSourceFingerprints:
filters.downloadSourceFingerprints.filter(
(fingerprint) => fingerprint !== value
),
})
);
} else {
dispatch(
setSearch({
downloadSourceFingerprints: [
...filters.downloadSourceFingerprints,
value,
],
})
);
}
}}
items={downloadSources.map((downloadSource) => ({
label: `${downloadSource.name} (${downloadSource.objectIds.length})`,
value: downloadSource.fingerprint,
checked: filters.downloadSourceFingerprints.includes(
downloadSource.fingerprint
),
}))}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,85 @@
import { CheckboxField, TextField } from "@renderer/components";
import { useFormat } from "@renderer/hooks";
import { useCallback, useMemo, useState } from "react";
export interface FilterSectionProps {
title: string;
items: {
label: string;
value: string;
checked: boolean;
}[];
onSelect: (value: string) => void;
}
export function FilterSection({ title, items, onSelect }: FilterSectionProps) {
const [search, setSearch] = useState("");
const filteredItems = useMemo(() => {
if (items.length > 10 && search.length > 0) {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}
return items.slice(0, 10);
}, [items, search]);
const onSearch = useCallback((value: string) => {
setSearch(value);
}, []);
const { formatNumber } = useFormat();
return (
<div>
<h3
style={{
fontSize: 16,
fontWeight: 500,
color: "#fff",
}}
>
{title}
</h3>
<span style={{ fontSize: 12, marginBottom: 12, display: "block" }}>
{formatNumber(items.length)} disponíveis
</span>
<TextField
placeholder="Search..."
onChange={(e) => onSearch(e.target.value)}
value={search}
containerProps={{ style: { marginBottom: 16 } }}
theme="dark"
/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{filteredItems.map((item) => (
<div key={item.value}>
<CheckboxField
label={item.label}
checked={item.checked}
onChange={() => onSelect(item.value)}
/>
</div>
))}
<button
type="button"
style={{
color: "#fff",
fontSize: 14,
textAlign: "left",
marginTop: 8,
textDecoration: "underline",
cursor: "pointer",
}}
>
View more ({items.length - filteredItems.length})
</button>
</div>
</div>
);
}

View File

@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components";
import type { Steam250Game, CatalogueEntry } from "@types";
import type { Steam250Game } from "@types";
import flameIconStatic from "@renderer/assets/icons/flame-static.png";
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
@ -15,14 +15,6 @@ import * as styles from "./home.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared";
import { catalogueCacheTable, db } from "@renderer/dexie";
import { add } from "date-fns";
const categoryCacheDurationInSeconds = {
[CatalogueCategory.Hot]: 60 * 60 * 2,
[CatalogueCategory.Weekly]: 60 * 60 * 24,
[CatalogueCategory.Achievements]: 60 * 60 * 24,
};
export default function Home() {
const { t } = useTranslation("home");
@ -36,9 +28,7 @@ export default function Home() {
CatalogueCategory.Hot
);
const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, CatalogueEntry[]>
>({
const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({
[CatalogueCategory.Hot]: [],
[CatalogueCategory.Weekly]: [],
[CatalogueCategory.Achievements]: [],
@ -46,37 +36,11 @@ export default function Home() {
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
try {
const catalogueCache = await catalogueCacheTable
.where("expiresAt")
.above(new Date())
.and((cache) => cache.category === category)
.first();
setCurrentCatalogueCategory(category);
setIsLoading(true);
if (catalogueCache)
return setCatalogue((prev) => ({
...prev,
[category]: catalogueCache.games,
}));
const catalogue = await window.electron.getCatalogue(category);
db.transaction("rw", catalogueCacheTable, async () => {
await catalogueCacheTable.where("category").equals(category).delete();
await catalogueCacheTable.add({
category,
games: catalogue,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: add(new Date(), {
seconds: categoryCacheDurationInSeconds[category],
}),
});
});
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
} finally {
setIsLoading(false);

View File

@ -1,131 +0,0 @@
import { GameCard } from "@renderer/components";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import type { CatalogueEntry } from "@types";
import type { DebouncedFunc } from "lodash";
import { debounce } from "lodash";
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
import { clearSearch, setSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "./home.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import { vars } from "@renderer/theme.css";
export default function SearchResults() {
const dispatch = useAppDispatch();
const { t } = useTranslation("home");
const [searchParams] = useSearchParams();
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [showTypingMessage, setShowTypingMessage] = useState(false);
const debouncedFunc = useRef<DebouncedFunc<() => void> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const navigate = useNavigate();
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(buildGameDetailsPath(game));
};
useEffect(() => {
dispatch(setSearch(searchParams.get("query") ?? ""));
}, [dispatch, searchParams]);
useEffect(() => {
setIsLoading(true);
if (debouncedFunc.current) debouncedFunc.current.cancel();
if (abortControllerRef.current) abortControllerRef.current.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
debouncedFunc.current = debounce(() => {
const query = searchParams.get("query") ?? "";
if (query.length < 3) {
setIsLoading(false);
setShowTypingMessage(true);
setSearchResults([]);
return;
}
setShowTypingMessage(false);
window.electron
.searchGames(query)
.then((results) => {
if (abortController.signal.aborted) return;
setSearchResults(results);
setIsLoading(false);
})
.catch(() => {
setIsLoading(false);
});
}, 500);
debouncedFunc.current();
}, [searchParams, dispatch]);
const noResultsContent = () => {
if (isLoading) return null;
if (showTypingMessage) {
return (
<div className={styles.noResults}>
<SearchIcon size={56} />
<p>{t("start_typing")}</p>
</div>
);
}
if (searchResults.length === 0) {
return (
<div className={styles.noResults}>
<InboxIcon size={56} />
<p>{t("no_results")}</p>
</div>
);
}
return null;
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
<section className={styles.cards}>
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))}
{!isLoading && searchResults.length > 0 && (
<>
{searchResults.map((game) => (
<GameCard
key={game.objectId}
game={game}
onClick={() => handleGameClick(game)}
/>
))}
</>
)}
</section>
{noResultsContent()}
</section>
</SkeletonTheme>
);
}

View File

@ -100,17 +100,30 @@ export function AddDownloadSourceModal({
}
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
setIsLoading(true);
const putDownloadSource = async () => {
const downloadSource = await downloadSourcesTable.where({ url }).first();
if (!downloadSource) return;
window.electron
.putDownloadSource(downloadSource.objectIds)
.then(({ fingerprint }) => {
downloadSourcesTable.update(downloadSource.id, { fingerprint });
});
};
const handleAddDownloadSource = async () => {
if (validationResult) {
setIsLoading(true);
const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = () => {
channel.onmessage = async () => {
setIsLoading(false);
putDownloadSource();
onClose();
onAddDownloadSource();
channel.close();

View File

@ -7,10 +7,10 @@ import * as styles from "./settings-download-sources.css";
import type { DownloadSource } from "@types";
import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks";
import { useRepacks, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { repacksContext, settingsContext } from "@renderer/context";
import { settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
@ -28,7 +28,7 @@ export function SettingsDownloadSources() {
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast();
const { indexRepacks } = useContext(repacksContext);
const { updateRepacks } = useRepacks();
const getDownloadSources = async () => {
await downloadSourcesTable
@ -57,16 +57,16 @@ export function SettingsDownloadSources() {
showSuccessToast(t("removed_download_source"));
getDownloadSources();
indexRepacks();
setIsRemovingDownloadSource(false);
channel.close();
updateRepacks();
};
};
const handleAddDownloadSource = async () => {
indexRepacks();
await getDownloadSources();
showSuccessToast(t("added_download_source"));
updateRepacks();
};
const syncDownloadSources = async () => {
@ -82,6 +82,7 @@ export function SettingsDownloadSources() {
getDownloadSources();
setIsSyncingDownloadSources(false);
channel.close();
updateRepacks();
};
};

View File

@ -6,7 +6,7 @@ $body-color: #8e919b;
$border-color: #424244;
$success-color: #1c9749;
$danger-color: #e11d48;
$danger-color: #801d1e;
$warning-color: #ffc107;
$disabled-opacity: 0.5;

View File

@ -3,16 +3,16 @@ import {
downloadSlice,
windowSlice,
librarySlice,
searchSlice,
userPreferencesSlice,
toastSlice,
userDetailsSlice,
gameRunningSlice,
repacksSlice,
catalogueSearchSlice,
} from "@renderer/features";
export const store = configureStore({
reducer: {
search: searchSlice.reducer,
window: windowSlice.reducer,
library: librarySlice.reducer,
userPreferences: userPreferencesSlice.reducer,
@ -20,6 +20,8 @@ export const store = configureStore({
toast: toastSlice.reducer,
userDetails: userDetailsSlice.reducer,
gameRunning: gameRunningSlice.reducer,
repacks: repacksSlice.reducer,
catalogueSearch: catalogueSearchSlice.reducer,
},
});

View File

@ -2,7 +2,10 @@ import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus } from "@shared";
import { DownloadSourceStatus, formatName, pipe } from "@shared";
import { GameRepack } from "@types";
const formatRepackName = pipe((name) => name.replace("[DL]", ""), formatName);
export const downloadSourceSchema = z.object({
name: z.string().max(255),
@ -22,6 +25,64 @@ type Payload =
| ["VALIDATE_DOWNLOAD_SOURCE", string]
| ["SYNC_DOWNLOAD_SOURCES", string];
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
const addNewDownloads = async (
downloadSource: { id: number; name: string },
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
steamGames: SteamGamesByLetter
) => {
const now = new Date();
const results = [] as (Omit<GameRepack, "id"> & {
downloadSourceId: number;
})[];
const objectIdsOnSource = new Set<string>();
for (const download of downloads) {
const formattedTitle = formatRepackName(download.title);
const [firstLetter] = formattedTitle;
const games = steamGames[firstLetter] || [];
const gamesInSteam = games.filter((game) =>
formattedTitle.startsWith(game.name)
);
if (gamesInSteam.length === 0) continue;
for (const game of gamesInSteam) {
objectIdsOnSource.add(String(game.id));
}
results.push({
objectIds: gamesInSteam.map((game) => String(game.id)),
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
});
}
await repacksTable.bulkAdd(results);
await downloadSourcesTable.update(downloadSource.id, {
objectIds: Array.from(objectIdsOnSource),
});
};
const getSteamGames = async () => {
const response = await axios.get<SteamGamesByLetter>(
"https://assets.hydralauncher.gg/steam-games-by-letter.json"
);
return response.data;
};
self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data;
@ -55,6 +116,8 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
const steamGames = await getSteamGames();
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date();
@ -70,18 +133,11 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
const downloadSource = await downloadSourcesTable.get(id);
const repacks = response.data.downloads.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: response.data.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource!.id,
createdAt: now,
updatedAt: now,
}));
await repacksTable.bulkAdd(repacks);
await addNewDownloads(
downloadSource,
response.data.downloads,
steamGames
);
});
const channel = new BroadcastChannel(`download_sources:import:${data}`);
@ -110,6 +166,8 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
const source = downloadSourceSchema.parse(response.data);
const steamGames = await getSteamGames();
await db.transaction(
"rw",
repacksTable,
@ -121,29 +179,16 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
status: DownloadSourceStatus.UpToDate,
});
const now = new Date();
const repacks = source.downloads.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
);
const repacks = source.downloads
.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
)
.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: source.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
}));
await addNewDownloads(downloadSource, repacks, steamGames);
newRepacksCount += repacks.length;
await repacksTable.bulkAdd(repacks);
}
);
} catch (err: unknown) {

View File

@ -1,5 +1,3 @@
import RepacksWorker from "./repacks.worker?worker";
import DownloadSourcesWorker from "./download-sources.worker?worker";
export const repacksWorker = new RepacksWorker();
export const downloadSourcesWorker = new DownloadSourcesWorker();

View File

@ -1,51 +0,0 @@
import { repacksTable } from "@renderer/dexie";
import { formatName } from "@shared";
import type { GameRepack } from "@types";
import flexSearch from "flexsearch";
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
uris: string;
}
const state = {
repacks: [] as SerializedGameRepack[],
index: null as flexSearch.Index | null,
};
self.onmessage = async (
event: MessageEvent<[string, string] | "INDEX_REPACKS">
) => {
if (event.data === "INDEX_REPACKS") {
state.index = new flexSearch.Index();
repacksTable
.toCollection()
.sortBy("uploadDate")
.then((results) => {
state.repacks = results.reverse();
for (let i = 0; i < state.repacks.length; i++) {
const repack = state.repacks[i];
const formattedTitle = formatName(repack.title);
state.index!.add(i, formattedTitle);
}
self.postMessage("INDEXING_COMPLETE");
});
} else {
const [requestId, query] = event.data;
const results = state.index!.search(formatName(query)).map((index) => {
const repack = state.repacks.at(index as number) as SerializedGameRepack;
return {
...repack,
uris: [...repack.uris, repack.magnet].filter(Boolean),
};
});
const channel = new BroadcastChannel(`repacks:search:${requestId}`);
channel.postMessage(results);
}
};

View File

@ -16,13 +16,10 @@ export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
export interface GameRepack {
id: number;
title: string;
/**
* @deprecated Use uris instead
*/
magnet: string;
uris: string[];
repacker: string;
fileSize: string | null;
objectIds: string[];
uploadDate: Date | string | null;
createdAt: Date;
updatedAt: Date;
@ -77,15 +74,6 @@ export interface TorrentFile {
length: number;
}
/* Used by the catalogue */
export interface CatalogueEntry {
objectId: string;
shop: GameShop;
title: string;
/* Epic Games covers cannot be guessed with objectID */
cover: string;
}
export interface UserGame {
objectId: string;
shop: GameShop;
@ -301,6 +289,7 @@ export interface DownloadSource {
repackCount: number;
status: DownloadSourceStatus;
downloadCount: number;
fingerprint: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
@ -374,6 +363,15 @@ export interface ComparedAchievements {
}[];
}
export interface CatalogueSearchPayload {
title: string;
downloadSourceFingerprints: string[];
tags: number[];
publishers: string[];
genres: string[];
developers: string[];
}
export * from "./steam.types";
export * from "./real-debrid.types";
export * from "./ludusavi.types";

1111
yarn.lock

File diff suppressed because it is too large Load Diff