mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-01-23 05:24:55 +03:00
feat: adding source search
This commit is contained in:
parent
1e99dae9de
commit
109c12064d
@ -26,4 +26,9 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
import {
|
import {
|
||||||
DownloadQueue,
|
DownloadQueue,
|
||||||
DownloadSource,
|
|
||||||
Game,
|
Game,
|
||||||
GameShopCache,
|
GameShopCache,
|
||||||
Repack,
|
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
GameAchievement,
|
GameAchievement,
|
||||||
@ -17,12 +15,10 @@ export const dataSource = new DataSource({
|
|||||||
type: "better-sqlite3",
|
type: "better-sqlite3",
|
||||||
entities: [
|
entities: [
|
||||||
Game,
|
Game,
|
||||||
Repack,
|
|
||||||
UserAuth,
|
UserAuth,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserSubscription,
|
UserSubscription,
|
||||||
GameShopCache,
|
GameShopCache,
|
||||||
DownloadSource,
|
|
||||||
DownloadQueue,
|
DownloadQueue,
|
||||||
GameAchievement,
|
GameAchievement,
|
||||||
],
|
],
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -5,9 +5,7 @@ import {
|
|||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
JoinColumn,
|
|
||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
import { Repack } from "./repack.entity";
|
|
||||||
|
|
||||||
import type { GameShop, GameStatus } from "@types";
|
import type { GameShop, GameStatus } from "@types";
|
||||||
import { Downloader } from "@shared";
|
import { Downloader } from "@shared";
|
||||||
@ -72,13 +70,6 @@ export class Game {
|
|||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
uri: string | null;
|
uri: string | null;
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@OneToOne("Repack", "game", { nullable: true })
|
|
||||||
@JoinColumn()
|
|
||||||
repack: Repack;
|
|
||||||
|
|
||||||
@OneToOne("DownloadQueue", "game")
|
@OneToOne("DownloadQueue", "game")
|
||||||
downloadQueue: DownloadQueue;
|
downloadQueue: DownloadQueue;
|
||||||
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
export * from "./game.entity";
|
export * from "./game.entity";
|
||||||
export * from "./repack.entity";
|
|
||||||
export * from "./user-auth.entity";
|
export * from "./user-auth.entity";
|
||||||
export * from "./user-preferences.entity";
|
export * from "./user-preferences.entity";
|
||||||
export * from "./user-subscription.entity";
|
export * from "./user-subscription.entity";
|
||||||
export * from "./game-shop-cache.entity";
|
export * from "./game-shop-cache.entity";
|
||||||
export * from "./game.entity";
|
export * from "./game.entity";
|
||||||
export * from "./game-achievements.entity";
|
export * from "./game-achievements.entity";
|
||||||
export * from "./download-source.entity";
|
|
||||||
export * from "./download-queue.entity";
|
export * from "./download-queue.entity";
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -1,9 +1,6 @@
|
|||||||
import type { GameShop } from "@types";
|
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { CatalogueCategory, steamUrlBuilder } from "@shared";
|
import { CatalogueCategory } from "@shared";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
|
||||||
|
|
||||||
const getCatalogue = async (
|
const getCatalogue = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -14,26 +11,11 @@ const getCatalogue = async (
|
|||||||
skip: "0",
|
skip: "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
|
return HydraApi.get(
|
||||||
`/catalogue/${category}?${params.toString()}`,
|
`/catalogue/${category}?${params.toString()}`,
|
||||||
{},
|
{},
|
||||||
{ needsAuth: false }
|
{ 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);
|
registerEvent("getCatalogue", getCatalogue);
|
||||||
|
@ -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);
|
|
@ -1,23 +1,16 @@
|
|||||||
|
import type { CatalogueSearchPayload } from "@types";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
|
||||||
import type { CatalogueEntry } from "@types";
|
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
|
|
||||||
const searchGamesEvent = async (
|
const searchGames = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
query: string
|
payload: CatalogueSearchPayload
|
||||||
): Promise<CatalogueEntry[]> => {
|
) => {
|
||||||
const games = await HydraApi.get<
|
return HydraApi.post(
|
||||||
{ objectId: string; title: string; shop: string }[]
|
"/catalogue/search",
|
||||||
>("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false });
|
{ ...payload, take: 12, skip: 0 },
|
||||||
|
{ needsAuth: false }
|
||||||
return games.map((game) => {
|
);
|
||||||
return convertSteamGameToCatalogueEntry({
|
|
||||||
id: Number(game.objectId),
|
|
||||||
name: game.title,
|
|
||||||
clientIcon: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("searchGames", searchGamesEvent);
|
registerEvent("searchGames", searchGames);
|
||||||
|
@ -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);
|
|
@ -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);
|
|
13
src/main/events/download-sources/put-download-source.ts
Normal file
13
src/main/events/download-sources/put-download-source.ts
Normal 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);
|
@ -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);
|
|
||||||
};
|
|
@ -3,7 +3,6 @@ import { ipcMain } from "electron";
|
|||||||
|
|
||||||
import "./catalogue/get-catalogue";
|
import "./catalogue/get-catalogue";
|
||||||
import "./catalogue/get-game-shop-details";
|
import "./catalogue/get-game-shop-details";
|
||||||
import "./catalogue/get-games";
|
|
||||||
import "./catalogue/get-how-long-to-beat";
|
import "./catalogue/get-how-long-to-beat";
|
||||||
import "./catalogue/get-random-game";
|
import "./catalogue/get-random-game";
|
||||||
import "./catalogue/search-games";
|
import "./catalogue/search-games";
|
||||||
@ -38,8 +37,7 @@ import "./user-preferences/auto-launch";
|
|||||||
import "./autoupdater/check-for-updates";
|
import "./autoupdater/check-for-updates";
|
||||||
import "./autoupdater/restart-and-install-update";
|
import "./autoupdater/restart-and-install-update";
|
||||||
import "./user-preferences/authenticate-real-debrid";
|
import "./user-preferences/authenticate-real-debrid";
|
||||||
import "./download-sources/delete-download-source";
|
import "./download-sources/put-download-source";
|
||||||
import "./download-sources/get-download-sources";
|
|
||||||
import "./auth/sign-out";
|
import "./auth/sign-out";
|
||||||
import "./auth/open-auth-window";
|
import "./auth/open-auth-window";
|
||||||
import "./auth/get-session-hash";
|
import "./auth/get-session-hash";
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { dataSource } from "./data-source";
|
import { dataSource } from "./data-source";
|
||||||
import {
|
import {
|
||||||
DownloadQueue,
|
DownloadQueue,
|
||||||
DownloadSource,
|
|
||||||
Game,
|
Game,
|
||||||
GameShopCache,
|
GameShopCache,
|
||||||
Repack,
|
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
GameAchievement,
|
GameAchievement,
|
||||||
@ -13,16 +11,11 @@ import {
|
|||||||
|
|
||||||
export const gameRepository = dataSource.getRepository(Game);
|
export const gameRepository = dataSource.getRepository(Game);
|
||||||
|
|
||||||
export const repackRepository = dataSource.getRepository(Repack);
|
|
||||||
|
|
||||||
export const userPreferencesRepository =
|
export const userPreferencesRepository =
|
||||||
dataSource.getRepository(UserPreferences);
|
dataSource.getRepository(UserPreferences);
|
||||||
|
|
||||||
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
|
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
|
||||||
|
|
||||||
export const downloadSourceRepository =
|
|
||||||
dataSource.getRepository(DownloadSource);
|
|
||||||
|
|
||||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||||
|
|
||||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||||
|
@ -11,6 +11,7 @@ import type {
|
|||||||
GameRunning,
|
GameRunning,
|
||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
CatalogueSearchPayload,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { CatalogueCategory } from "@shared";
|
import type { CatalogueCategory } from "@shared";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
@ -36,7 +37,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
searchGames: (payload: CatalogueSearchPayload) =>
|
||||||
|
ipcRenderer.invoke("searchGames", payload),
|
||||||
getCatalogue: (category: CatalogueCategory) =>
|
getCatalogue: (category: CatalogueCategory) =>
|
||||||
ipcRenderer.invoke("getCatalogue", category),
|
ipcRenderer.invoke("getCatalogue", category),
|
||||||
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
||||||
@ -44,10 +46,6 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||||
getHowLongToBeat: (objectId: string, shop: GameShop) =>
|
getHowLongToBeat: (objectId: string, shop: GameShop) =>
|
||||||
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
|
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) =>
|
getGameStats: (objectId: string, shop: GameShop) =>
|
||||||
ipcRenderer.invoke("getGameStats", objectId, shop),
|
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
||||||
@ -78,9 +76,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
||||||
|
|
||||||
/* Download sources */
|
/* Download sources */
|
||||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
putDownloadSource: (objectIds: string[]) =>
|
||||||
deleteDownloadSource: (id: number) =>
|
ipcRenderer.invoke("putDownloadSource", objectIds),
|
||||||
ipcRenderer.invoke("deleteDownloadSource", id),
|
|
||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
|
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
|
||||||
|
@ -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";
|
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||||
|
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
useAppSelector,
|
useAppSelector,
|
||||||
useDownload,
|
useDownload,
|
||||||
useLibrary,
|
useLibrary,
|
||||||
|
useRepacks,
|
||||||
useToast,
|
useToast,
|
||||||
useUserDetails,
|
useUserDetails,
|
||||||
} from "@renderer/hooks";
|
} from "@renderer/hooks";
|
||||||
@ -15,8 +16,6 @@ import * as styles from "./app.css";
|
|||||||
|
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
setSearch,
|
|
||||||
clearSearch,
|
|
||||||
setUserPreferences,
|
setUserPreferences,
|
||||||
toggleDraggingDisabled,
|
toggleDraggingDisabled,
|
||||||
closeToast,
|
closeToast,
|
||||||
@ -27,8 +26,6 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||||
import { downloadSourcesWorker } from "./workers";
|
import { downloadSourcesWorker } from "./workers";
|
||||||
import { repacksContext } from "./context";
|
|
||||||
import { logger } from "./logger";
|
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -42,9 +39,9 @@ export function App() {
|
|||||||
|
|
||||||
const downloadSourceMigrationLock = useRef(false);
|
const downloadSourceMigrationLock = useRef(false);
|
||||||
|
|
||||||
const { clearDownload, setLastPacket } = useDownload();
|
const { updateRepacks } = useRepacks();
|
||||||
|
|
||||||
const { indexRepacks } = useContext(repacksContext);
|
const { clearDownload, setLastPacket } = useDownload();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isFriendsModalVisible,
|
isFriendsModalVisible,
|
||||||
@ -67,8 +64,6 @@ export function App() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const search = useAppSelector((state) => state.search.value);
|
|
||||||
|
|
||||||
const draggingDisabled = useAppSelector(
|
const draggingDisabled = useAppSelector(
|
||||||
(state) => state.window.draggingDisabled
|
(state) => state.window.draggingDisabled
|
||||||
);
|
);
|
||||||
@ -187,31 +182,6 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, [onSignIn, updateLibrary, clearUserDetails]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||||
}, [location.pathname, location.search]);
|
}, [location.pathname, location.search]);
|
||||||
@ -232,49 +202,19 @@ export function App() {
|
|||||||
|
|
||||||
downloadSourceMigrationLock.current = true;
|
downloadSourceMigrationLock.current = true;
|
||||||
|
|
||||||
window.electron.getDownloadSources().then(async (downloadSources) => {
|
updateRepacks();
|
||||||
if (!downloadSources.length) {
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||||
|
|
||||||
channel.onmessage = (event: MessageEvent<number>) => {
|
channel.onmessage = (event: MessageEvent<number>) => {
|
||||||
const newRepacksCount = event.data;
|
const newRepacksCount = event.data;
|
||||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||||
|
updateRepacks();
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||||
}
|
}, [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]);
|
|
||||||
|
|
||||||
const handleToastClose = useCallback(() => {
|
const handleToastClose = useCallback(() => {
|
||||||
dispatch(closeToast());
|
dispatch(closeToast());
|
||||||
@ -313,11 +253,7 @@ export function App() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
<article className={styles.container}>
|
<article className={styles.container}>
|
||||||
<Header
|
<Header />
|
||||||
onSearch={handleSearch}
|
|
||||||
search={search}
|
|
||||||
onClear={handleClear}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section ref={contentRef} className={styles.content}>
|
<section ref={contentRef} className={styles.content}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { style } from "@vanilla-extract/css";
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
import { recipe } from "@vanilla-extract/recipes";
|
||||||
|
|
||||||
export const checkboxField = style({
|
export const checkboxField = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -10,7 +11,8 @@ export const checkboxField = style({
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const checkbox = style({
|
export const checkbox = recipe({
|
||||||
|
base: {
|
||||||
width: "20px",
|
width: "20px",
|
||||||
height: "20px",
|
height: "20px",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
@ -21,9 +23,19 @@ export const checkbox = style({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
transition: "all ease 0.2s",
|
transition: "all ease 0.2s",
|
||||||
border: `solid 1px ${vars.color.border}`,
|
border: `solid 1px ${vars.color.border}`,
|
||||||
|
minWidth: "20px",
|
||||||
|
minHeight: "20px",
|
||||||
":hover": {
|
":hover": {
|
||||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
checked: {
|
||||||
|
true: {
|
||||||
|
backgroundColor: vars.color.muted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const checkboxInput = style({
|
export const checkboxInput = style({
|
||||||
|
@ -15,7 +15,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.checkboxField}>
|
<div className={styles.checkboxField}>
|
||||||
<div className={styles.checkbox}>
|
<div className={styles.checkbox({ checked: props.checked })}>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
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 SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
|
|
||||||
import * as styles from "./game-card.css";
|
import * as styles from "./game-card.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Badge } from "../badge/badge";
|
import { Badge } from "../badge/badge";
|
||||||
import { useCallback, useContext, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useFormat } from "@renderer/hooks";
|
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||||
import { repacksContext } from "@renderer/context";
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
|
||||||
export interface GameCardProps
|
export interface GameCardProps
|
||||||
extends React.DetailedHTMLProps<
|
extends React.DetailedHTMLProps<
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
> {
|
> {
|
||||||
game: CatalogueEntry;
|
game: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shopIcon = {
|
const shopIcon = {
|
||||||
@ -26,20 +26,12 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||||||
const { t } = useTranslation("game_card");
|
const { t } = useTranslation("game_card");
|
||||||
|
|
||||||
const [stats, setStats] = useState<GameStats | null>(null);
|
const [stats, setStats] = useState<GameStats | null>(null);
|
||||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
|
||||||
|
|
||||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
const { getRepacksForObjectId } = useRepacks();
|
||||||
|
const repacks = getRepacksForObjectId(game.objectId);
|
||||||
useEffect(() => {
|
|
||||||
if (!isIndexingRepacks) {
|
|
||||||
searchRepacks(game.title).then((repacks) => {
|
|
||||||
setRepacks(repacks);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [game, isIndexingRepacks, searchRepacks]);
|
|
||||||
|
|
||||||
const uniqueRepackers = Array.from(
|
const uniqueRepackers = Array.from(
|
||||||
new Set(repacks.map(({ repacker }) => repacker))
|
new Set(repacks.map((repack) => repack.repacker))
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHover = useCallback(() => {
|
const handleHover = useCallback(() => {
|
||||||
@ -61,7 +53,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||||||
>
|
>
|
||||||
<div className={styles.backdrop}>
|
<div className={styles.backdrop}>
|
||||||
<img
|
<img
|
||||||
src={game.cover}
|
src={steamUrlBuilder.library(game.objectId)}
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
className={styles.cover}
|
className={styles.cover}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
@ -74,30 +74,9 @@ export const search = recipe({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const searchInput = style({
|
export const searchButton = style({
|
||||||
backgroundColor: "transparent",
|
WebkitAppRegion: "no-drag",
|
||||||
border: "none",
|
} as ComplexStyleRule);
|
||||||
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 section = style({
|
export const section = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
@ -1,19 +1,13 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
import { ArrowLeftIcon, SearchIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
import { useAppSelector } from "@renderer/hooks";
|
||||||
|
|
||||||
import * as styles from "./header.css";
|
import * as styles from "./header.css";
|
||||||
import { clearSearch } from "@renderer/features";
|
|
||||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||||
|
import { Button } from "../button/button";
|
||||||
export interface HeaderProps {
|
|
||||||
onSearch: (query: string) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathTitle: Record<string, string> = {
|
const pathTitle: Record<string, string> = {
|
||||||
"/": "home",
|
"/": "home",
|
||||||
@ -22,18 +16,13 @@ const pathTitle: Record<string, string> = {
|
|||||||
"/settings": "settings",
|
"/settings": "settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
export function Header() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||||
(state) => state.window
|
(state) => state.window
|
||||||
);
|
);
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useTranslation("header");
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
@ -46,21 +35,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||||||
return t(pathTitle[location.pathname]);
|
return t(pathTitle[location.pathname]);
|
||||||
}, [location.pathname, headerTitle, t]);
|
}, [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 = () => {
|
const handleBackButtonClick = () => {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
};
|
};
|
||||||
@ -95,37 +69,14 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<div className={styles.search({ focused: isFocused })}>
|
<Button
|
||||||
<button
|
theme="outline"
|
||||||
type="button"
|
className={styles.searchButton}
|
||||||
className={styles.actionButton}
|
onClick={() => navigate("/catalogue?search=true")}
|
||||||
onClick={focusInput}
|
|
||||||
>
|
>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</button>
|
{t("search")}
|
||||||
|
</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>
|
|
||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
<AutoUpdateSubHeader />
|
<AutoUpdateSubHeader />
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
@ -14,12 +13,12 @@ import {
|
|||||||
useAppDispatch,
|
useAppDispatch,
|
||||||
useAppSelector,
|
useAppSelector,
|
||||||
useDownload,
|
useDownload,
|
||||||
|
useRepacks,
|
||||||
useUserDetails,
|
useUserDetails,
|
||||||
} from "@renderer/hooks";
|
} from "@renderer/hooks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Game,
|
Game,
|
||||||
GameRepack,
|
|
||||||
GameShop,
|
GameShop,
|
||||||
GameStats,
|
GameStats,
|
||||||
ShopDetails,
|
ShopDetails,
|
||||||
@ -29,7 +28,6 @@ import type {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GameDetailsContext } from "./game-details.context.types";
|
import { GameDetailsContext } from "./game-details.context.types";
|
||||||
import { SteamContentDescriptor } from "@shared";
|
import { SteamContentDescriptor } from "@shared";
|
||||||
import { repacksContext } from "../repacks/repacks.context";
|
|
||||||
|
|
||||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||||
game: null,
|
game: null,
|
||||||
@ -88,17 +86,8 @@ export function GameDetailsContextProvider({
|
|||||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||||
|
|
||||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
const { getRepacksForObjectId } = useRepacks();
|
||||||
|
const repacks = getRepacksForObjectId(objectId);
|
||||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isIndexingRepacks) {
|
|
||||||
searchRepacks(gameTitle).then((repacks) => {
|
|
||||||
setRepacks(repacks);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
|
|
||||||
|
|
||||||
const { i18n } = useTranslation("game_details");
|
const { i18n } = useTranslation("game_details");
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
export * from "./game-details/game-details.context";
|
export * from "./game-details/game-details.context";
|
||||||
export * from "./settings/settings.context";
|
export * from "./settings/settings.context";
|
||||||
export * from "./user-profile/user-profile.context";
|
export * from "./user-profile/user-profile.context";
|
||||||
export * from "./repacks/repacks.context";
|
|
||||||
export * from "./cloud-sync/cloud-sync.context";
|
export * from "./cloud-sync/cloud-sync.context";
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
15
src/renderer/src/declaration.d.ts
vendored
15
src/renderer/src/declaration.d.ts
vendored
@ -1,10 +1,8 @@
|
|||||||
import type { CatalogueCategory } from "@shared";
|
import type { CatalogueCategory } from "@shared";
|
||||||
import type {
|
import type {
|
||||||
AppUpdaterEvent,
|
AppUpdaterEvent,
|
||||||
CatalogueEntry,
|
|
||||||
Game,
|
Game,
|
||||||
LibraryGame,
|
LibraryGame,
|
||||||
GameRepack,
|
|
||||||
GameShop,
|
GameShop,
|
||||||
HowLongToBeatCategory,
|
HowLongToBeatCategory,
|
||||||
ShopDetails,
|
ShopDetails,
|
||||||
@ -13,7 +11,6 @@ import type {
|
|||||||
UserPreferences,
|
UserPreferences,
|
||||||
StartGameDownloadPayload,
|
StartGameDownloadPayload,
|
||||||
RealDebridUser,
|
RealDebridUser,
|
||||||
DownloadSource,
|
|
||||||
UserProfile,
|
UserProfile,
|
||||||
FriendRequest,
|
FriendRequest,
|
||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
@ -30,6 +27,7 @@ import type {
|
|||||||
LudusaviBackup,
|
LudusaviBackup,
|
||||||
UserAchievement,
|
UserAchievement,
|
||||||
ComparedAchievements,
|
ComparedAchievements,
|
||||||
|
CatalogueSearchPayload,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
@ -51,8 +49,8 @@ declare global {
|
|||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
|
|
||||||
/* Catalogue */
|
/* Catalogue */
|
||||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
searchGames: (payload: CatalogueSearchPayload) => Promise<any[]>;
|
||||||
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
|
||||||
getGameShopDetails: (
|
getGameShopDetails: (
|
||||||
objectId: string,
|
objectId: string,
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
@ -63,8 +61,6 @@ declare global {
|
|||||||
objectId: string,
|
objectId: string,
|
||||||
shop: GameShop
|
shop: GameShop
|
||||||
) => Promise<HowLongToBeatCategory[] | null>;
|
) => Promise<HowLongToBeatCategory[] | null>;
|
||||||
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
|
|
||||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
|
||||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||||
onUpdateAchievements: (
|
onUpdateAchievements: (
|
||||||
@ -118,8 +114,9 @@ declare global {
|
|||||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||||
|
|
||||||
/* Download sources */
|
/* Download sources */
|
||||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
putDownloadSource: (
|
||||||
deleteDownloadSource: (id: number) => Promise<void>;
|
objectIds: string[]
|
||||||
|
) => Promise<{ fingerprint: string }>;
|
||||||
|
|
||||||
/* Hardware */
|
/* Hardware */
|
||||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||||
|
@ -21,11 +21,10 @@ export interface CatalogueCache {
|
|||||||
|
|
||||||
export const db = new Dexie("Hydra");
|
export const db = new Dexie("Hydra");
|
||||||
|
|
||||||
db.version(5).stores({
|
db.version(8).stores({
|
||||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
|
||||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
|
||||||
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
||||||
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const downloadSourcesTable = db.table("downloadSources");
|
export const downloadSourcesTable = db.table("downloadSources");
|
||||||
@ -34,6 +33,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
|
|||||||
"howLongToBeatEntries"
|
"howLongToBeatEntries"
|
||||||
);
|
);
|
||||||
|
|
||||||
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
|
|
||||||
|
|
||||||
db.open();
|
db.open();
|
||||||
|
37
src/renderer/src/features/catalogue-search.ts
Normal file
37
src/renderer/src/features/catalogue-search.ts
Normal 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;
|
@ -1,4 +1,3 @@
|
|||||||
export * from "./search-slice";
|
|
||||||
export * from "./library-slice";
|
export * from "./library-slice";
|
||||||
export * from "./use-preferences-slice";
|
export * from "./use-preferences-slice";
|
||||||
export * from "./download-slice";
|
export * from "./download-slice";
|
||||||
@ -6,3 +5,5 @@ export * from "./window-slice";
|
|||||||
export * from "./toast-slice";
|
export * from "./toast-slice";
|
||||||
export * from "./user-details-slice";
|
export * from "./user-details-slice";
|
||||||
export * from "./running-game-slice";
|
export * from "./running-game-slice";
|
||||||
|
export * from "./repacks-slice";
|
||||||
|
export * from "./catalogue-search";
|
||||||
|
24
src/renderer/src/features/repacks-slice.ts
Normal file
24
src/renderer/src/features/repacks-slice.ts
Normal 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;
|
@ -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;
|
|
@ -5,3 +5,4 @@ export * from "./use-toast";
|
|||||||
export * from "./redux";
|
export * from "./redux";
|
||||||
export * from "./use-user-details";
|
export * from "./use-user-details";
|
||||||
export * from "./use-format";
|
export * from "./use-format";
|
||||||
|
export * from "./use-repacks";
|
||||||
|
@ -10,5 +10,5 @@ export function useFormat() {
|
|||||||
});
|
});
|
||||||
}, [i18n.language]);
|
}, [i18n.language]);
|
||||||
|
|
||||||
return { numberFormatter };
|
return { numberFormatter, formatNumber: numberFormatter.format };
|
||||||
}
|
}
|
||||||
|
26
src/renderer/src/hooks/use-repacks.ts
Normal file
26
src/renderer/src/hooks/use-repacks.ts
Normal 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 };
|
||||||
|
}
|
@ -18,7 +18,6 @@ import { store } from "./store";
|
|||||||
|
|
||||||
import resources from "@locales";
|
import resources from "@locales";
|
||||||
|
|
||||||
import { RepacksContextProvider } from "./context";
|
|
||||||
import { SuspenseWrapper } from "./components";
|
import { SuspenseWrapper } from "./components";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { addCookieInterceptor } from "./cookies";
|
import { addCookieInterceptor } from "./cookies";
|
||||||
@ -28,7 +27,6 @@ const GameDetails = React.lazy(
|
|||||||
() => import("./pages/game-details/game-details")
|
() => import("./pages/game-details/game-details")
|
||||||
);
|
);
|
||||||
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
|
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 Settings = React.lazy(() => import("./pages/settings/settings"));
|
||||||
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
|
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
|
||||||
const Profile = React.lazy(() => import("./pages/profile/profile"));
|
const Profile = React.lazy(() => import("./pages/profile/profile"));
|
||||||
@ -64,7 +62,6 @@ i18n
|
|||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<RepacksContextProvider>
|
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<App />}>
|
<Route element={<App />}>
|
||||||
@ -81,10 +78,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
path="/game/:shop/:objectId"
|
path="/game/:shop/:objectId"
|
||||||
element={<SuspenseWrapper Component={GameDetails} />}
|
element={<SuspenseWrapper Component={GameDetails} />}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/search"
|
|
||||||
element={<SuspenseWrapper Component={SearchResults} />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={<SuspenseWrapper Component={Settings} />}
|
element={<SuspenseWrapper Component={Settings} />}
|
||||||
@ -100,7 +93,6 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</RepacksContextProvider>
|
|
||||||
</Provider>
|
</Provider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
114
src/renderer/src/pages/catalogue/catalogue.scss
Normal file
114
src/renderer/src/pages/catalogue/catalogue.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,114 +1,374 @@
|
|||||||
import { Button, GameCard } from "@renderer/components";
|
import { Badge, Button } from "@renderer/components";
|
||||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import type { CatalogueEntry } from "@types";
|
import type { DownloadSource } from "@types";
|
||||||
|
|
||||||
import { clearSearch } from "@renderer/features";
|
import cn from "classnames";
|
||||||
import { useAppDispatch } from "@renderer/hooks";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useAppDispatch, useAppSelector, useRepacks } from "@renderer/hooks";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import * as styles from "../home/home.css";
|
import { LightBulbIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
|
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import "./catalogue.scss";
|
||||||
|
|
||||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
|
import { downloadSourcesTable } from "@renderer/dexie";
|
||||||
|
import { 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() {
|
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 dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { t } = useTranslation("catalogue");
|
const { t } = useTranslation("catalogue");
|
||||||
|
|
||||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
const { getRepacksForObjectId } = useRepacks();
|
||||||
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));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
setGames([]);
|
||||||
setIsLoading(true);
|
|
||||||
setSearchResults([]);
|
|
||||||
|
|
||||||
window.electron
|
window.electron.searchGames(filters).then((games) => {
|
||||||
.getGames(24, skip)
|
setGames(games);
|
||||||
.then((results) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
setSearchResults(results);
|
|
||||||
resolve(null);
|
|
||||||
}, 500);
|
|
||||||
});
|
});
|
||||||
})
|
}, [filters]);
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, [dispatch, skip, searchParams]);
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
const gamesWithRepacks = useMemo(() => {
|
||||||
const params = new URLSearchParams({
|
return games.map((game) => {
|
||||||
skip: String(skip + 24),
|
const repacks = getRepacksForObjectId(game.objectId);
|
||||||
|
const uniqueRepackers = Array.from(
|
||||||
|
new Set(repacks.map((repack) => repack.repacker))
|
||||||
|
);
|
||||||
|
return { ...game, repacks: uniqueRepackers };
|
||||||
});
|
});
|
||||||
|
}, [games, getRepacksForObjectId]);
|
||||||
|
|
||||||
navigate(`/catalogue?${params.toString()}`);
|
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 (
|
return (
|
||||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
<div className="catalogue">
|
||||||
<section
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 4}px`,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
width: "100%",
|
flexDirection: "column",
|
||||||
justifyContent: "space-between",
|
alignItems: "flex-start",
|
||||||
alignItems: "center",
|
|
||||||
borderBottom: `1px solid ${vars.color.border}`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<div
|
||||||
onClick={() => navigate(-1)}
|
className={cn("catalogue__search-container", {
|
||||||
theme="outline"
|
["catalogue__search-container--focused"]: focused,
|
||||||
disabled={skip === 0 || isLoading}
|
})}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon />
|
<button
|
||||||
{t("previous_page")}
|
type="button"
|
||||||
</Button>
|
className="catalogue__search-icon-button"
|
||||||
|
onClick={focusInput}
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}>
|
<input
|
||||||
{t("next_page")}
|
ref={inputRef}
|
||||||
<ArrowRightIcon />
|
type="text"
|
||||||
</Button>
|
name="search"
|
||||||
</section>
|
placeholder={t("search")}
|
||||||
|
value={filters.title}
|
||||||
<section ref={contentRef} className={styles.content}>
|
className="catalogue__search-input"
|
||||||
<section className={styles.cards}>
|
onChange={(event) => onSearch(event.target.value)}
|
||||||
{isLoading &&
|
onFocus={() => setFocused(true)}
|
||||||
Array.from({ length: 12 }).map((_, index) => (
|
onBlur={() => setFocused(false)}
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</SkeletonTheme>
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
85
src/renderer/src/pages/catalogue/filter-section.tsx
Normal file
85
src/renderer/src/pages/catalogue/filter-section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||||
|
|
||||||
import { Button, GameCard, Hero } from "@renderer/components";
|
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 flameIconStatic from "@renderer/assets/icons/flame-static.png";
|
||||||
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
|
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 { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
import { CatalogueCategory } from "@shared";
|
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() {
|
export default function Home() {
|
||||||
const { t } = useTranslation("home");
|
const { t } = useTranslation("home");
|
||||||
@ -36,9 +28,7 @@ export default function Home() {
|
|||||||
CatalogueCategory.Hot
|
CatalogueCategory.Hot
|
||||||
);
|
);
|
||||||
|
|
||||||
const [catalogue, setCatalogue] = useState<
|
const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({
|
||||||
Record<CatalogueCategory, CatalogueEntry[]>
|
|
||||||
>({
|
|
||||||
[CatalogueCategory.Hot]: [],
|
[CatalogueCategory.Hot]: [],
|
||||||
[CatalogueCategory.Weekly]: [],
|
[CatalogueCategory.Weekly]: [],
|
||||||
[CatalogueCategory.Achievements]: [],
|
[CatalogueCategory.Achievements]: [],
|
||||||
@ -46,37 +36,11 @@ export default function Home() {
|
|||||||
|
|
||||||
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
|
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
|
||||||
try {
|
try {
|
||||||
const catalogueCache = await catalogueCacheTable
|
|
||||||
.where("expiresAt")
|
|
||||||
.above(new Date())
|
|
||||||
.and((cache) => cache.category === category)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
setCurrentCatalogueCategory(category);
|
setCurrentCatalogueCategory(category);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (catalogueCache)
|
|
||||||
return setCatalogue((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[category]: catalogueCache.games,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const catalogue = await window.electron.getCatalogue(category);
|
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 }));
|
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -100,17 +100,30 @@ export function AddDownloadSourceModal({
|
|||||||
}
|
}
|
||||||
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
||||||
|
|
||||||
|
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 () => {
|
const handleAddDownloadSource = async () => {
|
||||||
|
if (validationResult) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (validationResult) {
|
|
||||||
const channel = new BroadcastChannel(`download_sources:import:${url}`);
|
const channel = new BroadcastChannel(`download_sources:import:${url}`);
|
||||||
|
|
||||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||||
|
|
||||||
channel.onmessage = () => {
|
channel.onmessage = async () => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
|
putDownloadSource();
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
onAddDownloadSource();
|
onAddDownloadSource();
|
||||||
channel.close();
|
channel.close();
|
||||||
|
@ -7,10 +7,10 @@ import * as styles from "./settings-download-sources.css";
|
|||||||
import type { DownloadSource } from "@types";
|
import type { DownloadSource } from "@types";
|
||||||
import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
|
import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
|
||||||
import { AddDownloadSourceModal } from "./add-download-source-modal";
|
import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||||
import { useToast } from "@renderer/hooks";
|
import { useRepacks, useToast } from "@renderer/hooks";
|
||||||
import { DownloadSourceStatus } from "@shared";
|
import { DownloadSourceStatus } from "@shared";
|
||||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||||
import { repacksContext, settingsContext } from "@renderer/context";
|
import { settingsContext } from "@renderer/context";
|
||||||
import { downloadSourcesTable } from "@renderer/dexie";
|
import { downloadSourcesTable } from "@renderer/dexie";
|
||||||
import { downloadSourcesWorker } from "@renderer/workers";
|
import { downloadSourcesWorker } from "@renderer/workers";
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ export function SettingsDownloadSources() {
|
|||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { showSuccessToast } = useToast();
|
const { showSuccessToast } = useToast();
|
||||||
|
|
||||||
const { indexRepacks } = useContext(repacksContext);
|
const { updateRepacks } = useRepacks();
|
||||||
|
|
||||||
const getDownloadSources = async () => {
|
const getDownloadSources = async () => {
|
||||||
await downloadSourcesTable
|
await downloadSourcesTable
|
||||||
@ -57,16 +57,16 @@ export function SettingsDownloadSources() {
|
|||||||
showSuccessToast(t("removed_download_source"));
|
showSuccessToast(t("removed_download_source"));
|
||||||
|
|
||||||
getDownloadSources();
|
getDownloadSources();
|
||||||
indexRepacks();
|
|
||||||
setIsRemovingDownloadSource(false);
|
setIsRemovingDownloadSource(false);
|
||||||
channel.close();
|
channel.close();
|
||||||
|
updateRepacks();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddDownloadSource = async () => {
|
const handleAddDownloadSource = async () => {
|
||||||
indexRepacks();
|
|
||||||
await getDownloadSources();
|
await getDownloadSources();
|
||||||
showSuccessToast(t("added_download_source"));
|
showSuccessToast(t("added_download_source"));
|
||||||
|
updateRepacks();
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncDownloadSources = async () => {
|
const syncDownloadSources = async () => {
|
||||||
@ -82,6 +82,7 @@ export function SettingsDownloadSources() {
|
|||||||
getDownloadSources();
|
getDownloadSources();
|
||||||
setIsSyncingDownloadSources(false);
|
setIsSyncingDownloadSources(false);
|
||||||
channel.close();
|
channel.close();
|
||||||
|
updateRepacks();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ $body-color: #8e919b;
|
|||||||
|
|
||||||
$border-color: #424244;
|
$border-color: #424244;
|
||||||
$success-color: #1c9749;
|
$success-color: #1c9749;
|
||||||
$danger-color: #e11d48;
|
$danger-color: #801d1e;
|
||||||
$warning-color: #ffc107;
|
$warning-color: #ffc107;
|
||||||
|
|
||||||
$disabled-opacity: 0.5;
|
$disabled-opacity: 0.5;
|
||||||
|
@ -3,16 +3,16 @@ import {
|
|||||||
downloadSlice,
|
downloadSlice,
|
||||||
windowSlice,
|
windowSlice,
|
||||||
librarySlice,
|
librarySlice,
|
||||||
searchSlice,
|
|
||||||
userPreferencesSlice,
|
userPreferencesSlice,
|
||||||
toastSlice,
|
toastSlice,
|
||||||
userDetailsSlice,
|
userDetailsSlice,
|
||||||
gameRunningSlice,
|
gameRunningSlice,
|
||||||
|
repacksSlice,
|
||||||
|
catalogueSearchSlice,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
search: searchSlice.reducer,
|
|
||||||
window: windowSlice.reducer,
|
window: windowSlice.reducer,
|
||||||
library: librarySlice.reducer,
|
library: librarySlice.reducer,
|
||||||
userPreferences: userPreferencesSlice.reducer,
|
userPreferences: userPreferencesSlice.reducer,
|
||||||
@ -20,6 +20,8 @@ export const store = configureStore({
|
|||||||
toast: toastSlice.reducer,
|
toast: toastSlice.reducer,
|
||||||
userDetails: userDetailsSlice.reducer,
|
userDetails: userDetailsSlice.reducer,
|
||||||
gameRunning: gameRunningSlice.reducer,
|
gameRunning: gameRunningSlice.reducer,
|
||||||
|
repacks: repacksSlice.reducer,
|
||||||
|
catalogueSearch: catalogueSearchSlice.reducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -2,7 +2,10 @@ import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
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({
|
export const downloadSourceSchema = z.object({
|
||||||
name: z.string().max(255),
|
name: z.string().max(255),
|
||||||
@ -22,6 +25,64 @@ type Payload =
|
|||||||
| ["VALIDATE_DOWNLOAD_SOURCE", string]
|
| ["VALIDATE_DOWNLOAD_SOURCE", string]
|
||||||
| ["SYNC_DOWNLOAD_SOURCES", 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>) => {
|
self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||||
const [type, data] = event.data;
|
const [type, data] = event.data;
|
||||||
|
|
||||||
@ -55,6 +116,8 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
|||||||
const response =
|
const response =
|
||||||
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
||||||
|
|
||||||
|
const steamGames = await getSteamGames();
|
||||||
|
|
||||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@ -70,18 +133,11 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
|||||||
|
|
||||||
const downloadSource = await downloadSourcesTable.get(id);
|
const downloadSource = await downloadSourcesTable.get(id);
|
||||||
|
|
||||||
const repacks = response.data.downloads.map((download) => ({
|
await addNewDownloads(
|
||||||
title: download.title,
|
downloadSource,
|
||||||
uris: download.uris,
|
response.data.downloads,
|
||||||
fileSize: download.fileSize,
|
steamGames
|
||||||
repacker: response.data.name,
|
);
|
||||||
uploadDate: download.uploadDate,
|
|
||||||
downloadSourceId: downloadSource!.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await repacksTable.bulkAdd(repacks);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const channel = new BroadcastChannel(`download_sources:import:${data}`);
|
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 source = downloadSourceSchema.parse(response.data);
|
||||||
|
|
||||||
|
const steamGames = await getSteamGames();
|
||||||
|
|
||||||
await db.transaction(
|
await db.transaction(
|
||||||
"rw",
|
"rw",
|
||||||
repacksTable,
|
repacksTable,
|
||||||
@ -121,29 +179,16 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
|||||||
status: DownloadSourceStatus.UpToDate,
|
status: DownloadSourceStatus.UpToDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const repacks = source.downloads.filter(
|
||||||
|
|
||||||
const repacks = source.downloads
|
|
||||||
.filter(
|
|
||||||
(download) =>
|
(download) =>
|
||||||
!existingRepacks.some(
|
!existingRepacks.some(
|
||||||
(repack) => repack.title === download.title
|
(repack) => repack.title === download.title
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.map((download) => ({
|
|
||||||
title: download.title,
|
await addNewDownloads(downloadSource, repacks, steamGames);
|
||||||
uris: download.uris,
|
|
||||||
fileSize: download.fileSize,
|
|
||||||
repacker: source.name,
|
|
||||||
uploadDate: download.uploadDate,
|
|
||||||
downloadSourceId: downloadSource.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}));
|
|
||||||
|
|
||||||
newRepacksCount += repacks.length;
|
newRepacksCount += repacks.length;
|
||||||
|
|
||||||
await repacksTable.bulkAdd(repacks);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import RepacksWorker from "./repacks.worker?worker";
|
|
||||||
import DownloadSourcesWorker from "./download-sources.worker?worker";
|
import DownloadSourcesWorker from "./download-sources.worker?worker";
|
||||||
|
|
||||||
export const repacksWorker = new RepacksWorker();
|
|
||||||
export const downloadSourcesWorker = new DownloadSourcesWorker();
|
export const downloadSourcesWorker = new DownloadSourcesWorker();
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
@ -16,13 +16,10 @@ export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
|
|||||||
export interface GameRepack {
|
export interface GameRepack {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
|
||||||
* @deprecated Use uris instead
|
|
||||||
*/
|
|
||||||
magnet: string;
|
|
||||||
uris: string[];
|
uris: string[];
|
||||||
repacker: string;
|
repacker: string;
|
||||||
fileSize: string | null;
|
fileSize: string | null;
|
||||||
|
objectIds: string[];
|
||||||
uploadDate: Date | string | null;
|
uploadDate: Date | string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@ -77,15 +74,6 @@ export interface TorrentFile {
|
|||||||
length: number;
|
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 {
|
export interface UserGame {
|
||||||
objectId: string;
|
objectId: string;
|
||||||
shop: GameShop;
|
shop: GameShop;
|
||||||
@ -301,6 +289,7 @@ export interface DownloadSource {
|
|||||||
repackCount: number;
|
repackCount: number;
|
||||||
status: DownloadSourceStatus;
|
status: DownloadSourceStatus;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
|
fingerprint: string;
|
||||||
etag: string | null;
|
etag: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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 "./steam.types";
|
||||||
export * from "./real-debrid.types";
|
export * from "./real-debrid.types";
|
||||||
export * from "./ludusavi.types";
|
export * from "./ludusavi.types";
|
||||||
|
Loading…
Reference in New Issue
Block a user