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 {
|
||||
DownloadQueue,
|
||||
DownloadSource,
|
||||
Game,
|
||||
GameShopCache,
|
||||
Repack,
|
||||
UserPreferences,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
@ -17,12 +15,10 @@ export const dataSource = new DataSource({
|
||||
type: "better-sqlite3",
|
||||
entities: [
|
||||
Game,
|
||||
Repack,
|
||||
UserAuth,
|
||||
UserPreferences,
|
||||
UserSubscription,
|
||||
GameShopCache,
|
||||
DownloadSource,
|
||||
DownloadQueue,
|
||||
GameAchievement,
|
||||
],
|
||||
|
@ -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,
|
||||
UpdateDateColumn,
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
} from "typeorm";
|
||||
import { Repack } from "./repack.entity";
|
||||
|
||||
import type { GameShop, GameStatus } from "@types";
|
||||
import { Downloader } from "@shared";
|
||||
@ -72,13 +70,6 @@ export class Game {
|
||||
@Column("text", { nullable: true })
|
||||
uri: string | null;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@OneToOne("Repack", "game", { nullable: true })
|
||||
@JoinColumn()
|
||||
repack: Repack;
|
||||
|
||||
@OneToOne("DownloadQueue", "game")
|
||||
downloadQueue: DownloadQueue;
|
||||
|
||||
|
@ -1,10 +1,8 @@
|
||||
export * from "./game.entity";
|
||||
export * from "./repack.entity";
|
||||
export * from "./user-auth.entity";
|
||||
export * from "./user-preferences.entity";
|
||||
export * from "./user-subscription.entity";
|
||||
export * from "./game-shop-cache.entity";
|
||||
export * from "./game.entity";
|
||||
export * from "./game-achievements.entity";
|
||||
export * from "./download-source.entity";
|
||||
export * from "./download-queue.entity";
|
||||
|
@ -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 { HydraApi } from "@main/services";
|
||||
import { CatalogueCategory, steamUrlBuilder } from "@shared";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { CatalogueCategory } from "@shared";
|
||||
|
||||
const getCatalogue = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
@ -14,26 +11,11 @@ const getCatalogue = async (
|
||||
skip: "0",
|
||||
});
|
||||
|
||||
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
|
||||
return HydraApi.get(
|
||||
`/catalogue/${category}?${params.toString()}`,
|
||||
{},
|
||||
{ needsAuth: false }
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
response.map(async (game) => {
|
||||
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
return {
|
||||
title: steamGame.name,
|
||||
shop: game.shop,
|
||||
cover: steamUrlBuilder.library(game.objectId),
|
||||
objectId: game.objectId,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("getCatalogue", getCatalogue);
|
||||
|
@ -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 { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
||||
import type { CatalogueEntry } from "@types";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const searchGamesEvent = async (
|
||||
const searchGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
query: string
|
||||
): Promise<CatalogueEntry[]> => {
|
||||
const games = await HydraApi.get<
|
||||
{ objectId: string; title: string; shop: string }[]
|
||||
>("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false });
|
||||
|
||||
return games.map((game) => {
|
||||
return convertSteamGameToCatalogueEntry({
|
||||
id: Number(game.objectId),
|
||||
name: game.title,
|
||||
clientIcon: null,
|
||||
});
|
||||
});
|
||||
payload: CatalogueSearchPayload
|
||||
) => {
|
||||
return HydraApi.post(
|
||||
"/catalogue/search",
|
||||
{ ...payload, take: 12, skip: 0 },
|
||||
{ needsAuth: false }
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("searchGames", searchGamesEvent);
|
||||
registerEvent("searchGames", searchGames);
|
||||
|
@ -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-game-shop-details";
|
||||
import "./catalogue/get-games";
|
||||
import "./catalogue/get-how-long-to-beat";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/search-games";
|
||||
@ -38,8 +37,7 @@ import "./user-preferences/auto-launch";
|
||||
import "./autoupdater/check-for-updates";
|
||||
import "./autoupdater/restart-and-install-update";
|
||||
import "./user-preferences/authenticate-real-debrid";
|
||||
import "./download-sources/delete-download-source";
|
||||
import "./download-sources/get-download-sources";
|
||||
import "./download-sources/put-download-source";
|
||||
import "./auth/sign-out";
|
||||
import "./auth/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { dataSource } from "./data-source";
|
||||
import {
|
||||
DownloadQueue,
|
||||
DownloadSource,
|
||||
Game,
|
||||
GameShopCache,
|
||||
Repack,
|
||||
UserPreferences,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
@ -13,16 +11,11 @@ import {
|
||||
|
||||
export const gameRepository = dataSource.getRepository(Game);
|
||||
|
||||
export const repackRepository = dataSource.getRepository(Repack);
|
||||
|
||||
export const userPreferencesRepository =
|
||||
dataSource.getRepository(UserPreferences);
|
||||
|
||||
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
|
||||
|
||||
export const downloadSourceRepository =
|
||||
dataSource.getRepository(DownloadSource);
|
||||
|
||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||
|
||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||
|
@ -11,6 +11,7 @@ import type {
|
||||
GameRunning,
|
||||
FriendRequestAction,
|
||||
UpdateProfileRequest,
|
||||
CatalogueSearchPayload,
|
||||
} from "@types";
|
||||
import type { CatalogueCategory } from "@shared";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
@ -36,7 +37,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
},
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
||||
searchGames: (payload: CatalogueSearchPayload) =>
|
||||
ipcRenderer.invoke("searchGames", payload),
|
||||
getCatalogue: (category: CatalogueCategory) =>
|
||||
ipcRenderer.invoke("getCatalogue", category),
|
||||
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
||||
@ -44,10 +46,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||
getHowLongToBeat: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
|
||||
getGames: (take?: number, skip?: number) =>
|
||||
ipcRenderer.invoke("getGames", take, skip),
|
||||
searchGameRepacks: (query: string) =>
|
||||
ipcRenderer.invoke("searchGameRepacks", query),
|
||||
getGameStats: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
||||
@ -78,9 +76,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
||||
|
||||
/* Download sources */
|
||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||
deleteDownloadSource: (id: number) =>
|
||||
ipcRenderer.invoke("deleteDownloadSource", id),
|
||||
putDownloadSource: (objectIds: string[]) =>
|
||||
ipcRenderer.invoke("putDownloadSource", objectIds),
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useRepacks,
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
@ -15,8 +16,6 @@ import * as styles from "./app.css";
|
||||
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
setSearch,
|
||||
clearSearch,
|
||||
setUserPreferences,
|
||||
toggleDraggingDisabled,
|
||||
closeToast,
|
||||
@ -27,8 +26,6 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { downloadSourcesWorker } from "./workers";
|
||||
import { repacksContext } from "./context";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@ -42,9 +39,9 @@ export function App() {
|
||||
|
||||
const downloadSourceMigrationLock = useRef(false);
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const {
|
||||
isFriendsModalVisible,
|
||||
@ -67,8 +64,6 @@ export function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const search = useAppSelector((state) => state.search.value);
|
||||
|
||||
const draggingDisabled = useAppSelector(
|
||||
(state) => state.window.draggingDisabled
|
||||
);
|
||||
@ -187,31 +182,6 @@ export function App() {
|
||||
};
|
||||
}, [onSignIn, updateLibrary, clearUserDetails]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
dispatch(setSearch(query));
|
||||
|
||||
if (query === "") {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
query,
|
||||
});
|
||||
|
||||
navigate(`/search?${searchParams.toString()}`, {
|
||||
replace: location.pathname.startsWith("/search"),
|
||||
});
|
||||
},
|
||||
[dispatch, location.pathname, navigate]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
dispatch(clearSearch());
|
||||
navigate(-1);
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||
}, [location.pathname, location.search]);
|
||||
@ -232,49 +202,19 @@ export function App() {
|
||||
|
||||
downloadSourceMigrationLock.current = true;
|
||||
|
||||
window.electron.getDownloadSources().then(async (downloadSources) => {
|
||||
if (!downloadSources.length) {
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
updateRepacks();
|
||||
|
||||
channel.onmessage = (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
};
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
}
|
||||
channel.onmessage = (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
updateRepacks();
|
||||
};
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
logger.info("Migrating download source", downloadSource.url);
|
||||
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:import:${downloadSource.url}`
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
downloadSourcesWorker.postMessage([
|
||||
"IMPORT_DOWNLOAD_SOURCE",
|
||||
downloadSource.url,
|
||||
]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
|
||||
resolve(true);
|
||||
logger.info(
|
||||
"Deleted download source from SQLite",
|
||||
downloadSource.url
|
||||
);
|
||||
});
|
||||
|
||||
indexRepacks();
|
||||
channel.close();
|
||||
};
|
||||
}).catch(() => channel.close());
|
||||
}
|
||||
|
||||
downloadSourceMigrationLock.current = false;
|
||||
});
|
||||
}, [indexRepacks]);
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
}, [updateRepacks]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
dispatch(closeToast());
|
||||
@ -313,11 +253,7 @@ export function App() {
|
||||
<Sidebar />
|
||||
|
||||
<article className={styles.container}>
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
search={search}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
<Header />
|
||||
|
||||
<section ref={contentRef} className={styles.content}>
|
||||
<Outlet />
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const checkboxField = style({
|
||||
display: "flex",
|
||||
@ -10,19 +11,30 @@ export const checkboxField = style({
|
||||
cursor: "pointer",
|
||||
});
|
||||
|
||||
export const checkbox = style({
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
transition: "all ease 0.2s",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
":hover": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
export const checkbox = recipe({
|
||||
base: {
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
transition: "all ease 0.2s",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
minWidth: "20px",
|
||||
minHeight: "20px",
|
||||
":hover": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
checked: {
|
||||
true: {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,7 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxField}>
|
||||
<div className={styles.checkbox}>
|
||||
<div className={styles.checkbox({ checked: props.checked })}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
|
||||
import type { GameStats } from "@types";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
|
||||
import * as styles from "./game-card.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { repacksContext } from "@renderer/context";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> {
|
||||
game: CatalogueEntry;
|
||||
game: any;
|
||||
}
|
||||
|
||||
const shopIcon = {
|
||||
@ -26,20 +26,12 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
const { t } = useTranslation("game_card");
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndexingRepacks) {
|
||||
searchRepacks(game.title).then((repacks) => {
|
||||
setRepacks(repacks);
|
||||
});
|
||||
}
|
||||
}, [game, isIndexingRepacks, searchRepacks]);
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
const repacks = getRepacksForObjectId(game.objectId);
|
||||
|
||||
const uniqueRepackers = Array.from(
|
||||
new Set(repacks.map(({ repacker }) => repacker))
|
||||
new Set(repacks.map((repack) => repack.repacker))
|
||||
);
|
||||
|
||||
const handleHover = useCallback(() => {
|
||||
@ -61,7 +53,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
>
|
||||
<div className={styles.backdrop}>
|
||||
<img
|
||||
src={game.cover}
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
className={styles.cover}
|
||||
loading="lazy"
|
||||
|
@ -74,30 +74,9 @@ export const search = recipe({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInput = style({
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
outline: "none",
|
||||
color: "#DADBE1",
|
||||
cursor: "default",
|
||||
fontFamily: "inherit",
|
||||
textOverflow: "ellipsis",
|
||||
":focus": {
|
||||
cursor: "text",
|
||||
},
|
||||
});
|
||||
|
||||
export const actionButton = style({
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
transition: "all ease 0.2s",
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
":hover": {
|
||||
color: "#DADBE1",
|
||||
},
|
||||
});
|
||||
export const searchButton = style({
|
||||
WebkitAppRegion: "no-drag",
|
||||
} as ComplexStyleRule);
|
||||
|
||||
export const section = style({
|
||||
display: "flex",
|
||||
|
@ -1,19 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import { ArrowLeftIcon, SearchIcon } from "@primer/octicons-react";
|
||||
|
||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
|
||||
import * as styles from "./header.css";
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||
|
||||
export interface HeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
onClear: () => void;
|
||||
search?: string;
|
||||
}
|
||||
import { Button } from "../button/button";
|
||||
|
||||
const pathTitle: Record<string, string> = {
|
||||
"/": "home",
|
||||
@ -22,18 +16,13 @@ const pathTitle: Record<string, string> = {
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
export function Header() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||
(state) => state.window
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
@ -46,21 +35,6 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
return t(pathTitle[location.pathname]);
|
||||
}, [location.pathname, headerTitle, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search && !location.pathname.startsWith("/search")) {
|
||||
dispatch(clearSearch());
|
||||
}
|
||||
}, [location.pathname, search, dispatch]);
|
||||
|
||||
const focusInput = () => {
|
||||
setIsFocused(true);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
@ -95,37 +69,14 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.search({ focused: isFocused })}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionButton}
|
||||
onClick={focusInput}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
className={styles.searchInput}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
theme="outline"
|
||||
className={styles.searchButton}
|
||||
onClick={() => navigate("/catalogue?search=true")}
|
||||
>
|
||||
<SearchIcon />
|
||||
{t("search")}
|
||||
</Button>
|
||||
</section>
|
||||
</header>
|
||||
<AutoUpdateSubHeader />
|
||||
|
@ -1,7 +1,6 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@ -14,12 +13,12 @@ import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useRepacks,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
ShopDetails,
|
||||
@ -29,7 +28,6 @@ import type {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GameDetailsContext } from "./game-details.context.types";
|
||||
import { SteamContentDescriptor } from "@shared";
|
||||
import { repacksContext } from "../repacks/repacks.context";
|
||||
|
||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
game: null,
|
||||
@ -88,17 +86,8 @@ export function GameDetailsContextProvider({
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIndexingRepacks) {
|
||||
searchRepacks(gameTitle).then((repacks) => {
|
||||
setRepacks(repacks);
|
||||
});
|
||||
}
|
||||
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
const repacks = getRepacksForObjectId(objectId);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
export * from "./game-details/game-details.context";
|
||||
export * from "./settings/settings.context";
|
||||
export * from "./user-profile/user-profile.context";
|
||||
export * from "./repacks/repacks.context";
|
||||
export * from "./cloud-sync/cloud-sync.context";
|
||||
|
@ -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 {
|
||||
AppUpdaterEvent,
|
||||
CatalogueEntry,
|
||||
Game,
|
||||
LibraryGame,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
@ -13,7 +11,6 @@ import type {
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
UserProfile,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
@ -30,6 +27,7 @@ import type {
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
ComparedAchievements,
|
||||
CatalogueSearchPayload,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
@ -51,8 +49,8 @@ declare global {
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
||||
searchGames: (payload: CatalogueSearchPayload) => Promise<any[]>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<any[]>;
|
||||
getGameShopDetails: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@ -63,8 +61,6 @@ declare global {
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
|
||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||
onUpdateAchievements: (
|
||||
@ -118,8 +114,9 @@ declare global {
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
|
||||
/* Download sources */
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
deleteDownloadSource: (id: number) => Promise<void>;
|
||||
putDownloadSource: (
|
||||
objectIds: string[]
|
||||
) => Promise<{ fingerprint: string }>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
|
@ -21,11 +21,10 @@ export interface CatalogueCache {
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(5).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
||||
db.version(8).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
|
||||
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
||||
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
|
||||
});
|
||||
|
||||
export const downloadSourcesTable = db.table("downloadSources");
|
||||
@ -34,6 +33,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
|
||||
"howLongToBeatEntries"
|
||||
);
|
||||
|
||||
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
|
||||
|
||||
db.open();
|
||||
|
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 "./use-preferences-slice";
|
||||
export * from "./download-slice";
|
||||
@ -6,3 +5,5 @@ export * from "./window-slice";
|
||||
export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./running-game-slice";
|
||||
export * from "./repacks-slice";
|
||||
export * from "./catalogue-search";
|
||||
|
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 "./use-user-details";
|
||||
export * from "./use-format";
|
||||
export * from "./use-repacks";
|
||||
|
@ -10,5 +10,5 @@ export function useFormat() {
|
||||
});
|
||||
}, [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 { RepacksContextProvider } from "./context";
|
||||
import { SuspenseWrapper } from "./components";
|
||||
import { logger } from "./logger";
|
||||
import { addCookieInterceptor } from "./cookies";
|
||||
@ -28,7 +27,6 @@ const GameDetails = React.lazy(
|
||||
() => import("./pages/game-details/game-details")
|
||||
);
|
||||
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
|
||||
const SearchResults = React.lazy(() => import("./pages/home/search-results"));
|
||||
const Settings = React.lazy(() => import("./pages/settings/settings"));
|
||||
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
|
||||
const Profile = React.lazy(() => import("./pages/profile/profile"));
|
||||
@ -64,43 +62,37 @@ i18n
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<RepacksContextProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
|
||||
<Route
|
||||
path="/catalogue"
|
||||
element={<SuspenseWrapper Component={Catalogue} />}
|
||||
/>
|
||||
<Route
|
||||
path="/downloads"
|
||||
element={<SuspenseWrapper Component={Downloads} />}
|
||||
/>
|
||||
<Route
|
||||
path="/game/:shop/:objectId"
|
||||
element={<SuspenseWrapper Component={GameDetails} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={<SuspenseWrapper Component={SearchResults} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SuspenseWrapper Component={Settings} />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={<SuspenseWrapper Component={Profile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/achievements"
|
||||
element={<SuspenseWrapper Component={Achievements} />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</RepacksContextProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
|
||||
<Route
|
||||
path="/catalogue"
|
||||
element={<SuspenseWrapper Component={Catalogue} />}
|
||||
/>
|
||||
<Route
|
||||
path="/downloads"
|
||||
element={<SuspenseWrapper Component={Downloads} />}
|
||||
/>
|
||||
<Route
|
||||
path="/game/:shop/:objectId"
|
||||
element={<SuspenseWrapper Component={GameDetails} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SuspenseWrapper Component={Settings} />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={<SuspenseWrapper Component={Profile} />}
|
||||
/>
|
||||
<Route
|
||||
path="/achievements"
|
||||
element={<SuspenseWrapper Component={Achievements} />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
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 Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge, Button } from "@renderer/components";
|
||||
|
||||
import type { CatalogueEntry } from "@types";
|
||||
import type { DownloadSource } from "@types";
|
||||
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import * as styles from "../home/home.css";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import cn from "classnames";
|
||||
|
||||
import { useAppDispatch, useAppSelector, useRepacks } from "@renderer/hooks";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LightBulbIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
|
||||
import "./catalogue.scss";
|
||||
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { FilterSection } from "./filter-section";
|
||||
import { setSearch } from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Catalogue() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const search = searchParams.get("search");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [games, setGames] = useState<any[]>([]);
|
||||
|
||||
const filters = useAppSelector((state) => state.catalogueSearch.value);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation("catalogue");
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const skip = Number(searchParams.get("skip") ?? 0);
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(buildGameDetailsPath(game));
|
||||
};
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||
setIsLoading(true);
|
||||
setSearchResults([]);
|
||||
setGames([]);
|
||||
|
||||
window.electron
|
||||
.getGames(24, skip)
|
||||
.then((results) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
setSearchResults(results);
|
||||
resolve(null);
|
||||
}, 500);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [dispatch, skip, searchParams]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
const params = new URLSearchParams({
|
||||
skip: String(skip + 24),
|
||||
window.electron.searchGames(filters).then((games) => {
|
||||
setGames(games);
|
||||
});
|
||||
}, [filters]);
|
||||
|
||||
navigate(`/catalogue?${params.toString()}`);
|
||||
};
|
||||
const gamesWithRepacks = useMemo(() => {
|
||||
return games.map((game) => {
|
||||
const repacks = getRepacksForObjectId(game.objectId);
|
||||
const uniqueRepackers = Array.from(
|
||||
new Set(repacks.map((repack) => repack.repacker))
|
||||
);
|
||||
return { ...game, repacks: uniqueRepackers };
|
||||
});
|
||||
}, [games, getRepacksForObjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
setDownloadSources(sources);
|
||||
});
|
||||
}, [getRepacksForObjectId]);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
setFocused(true);
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const onSearch = useCallback(
|
||||
(value: string) => {
|
||||
dispatch(setSearch({ title: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
focusInput();
|
||||
}
|
||||
}, [search, focusInput]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section
|
||||
<div className="catalogue">
|
||||
<div
|
||||
style={{
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 4}px`,
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderBottom: `1px solid ${vars.color.border}`,
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
theme="outline"
|
||||
disabled={skip === 0 || isLoading}
|
||||
<div
|
||||
className={cn("catalogue__search-container", {
|
||||
["catalogue__search-container--focused"]: focused,
|
||||
})}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
{t("previous_page")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="catalogue__search-icon-button"
|
||||
onClick={focusInput}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
|
||||
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}>
|
||||
{t("next_page")}
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</section>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
value={filters.title}
|
||||
className="catalogue__search-input"
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
/>
|
||||
|
||||
<section ref={contentRef} className={styles.content}>
|
||||
<section className={styles.cards}>
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))}
|
||||
|
||||
{!isLoading && searchResults.length > 0 && (
|
||||
<>
|
||||
{searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectId}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{filters.title && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(setSearch({ title: "" }))}
|
||||
className="catalogue__search-clear-button"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{filters.downloadSourceFingerprints.map((fingerprint) => (
|
||||
<Badge key={fingerprint}>
|
||||
{
|
||||
downloadSources.find(
|
||||
(source) => source.fingerprint === fingerprint
|
||||
)!.name
|
||||
}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* <Button theme="outline">
|
||||
<XIcon />
|
||||
Clear filters
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: SPACING_UNIT * 2,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{gamesWithRepacks.map((game, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className="catalogue__game-item"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
width: 200,
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 4,
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
<span>{game.title}</span>
|
||||
<span
|
||||
style={{
|
||||
color: vars.color.body,
|
||||
marginBottom: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{game.genres.join(", ")}
|
||||
</span>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{game.repacks.map((repack) => (
|
||||
<Badge key={repack}>{repack}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button theme="outline">1</Button>
|
||||
<Button theme="outline">2</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="catalogue__filters-container">
|
||||
<Button
|
||||
style={{ width: "100%", marginBottom: 16 }}
|
||||
theme="outline"
|
||||
className="catalogue__ai-recommendations-button"
|
||||
>
|
||||
<span className="catalogue__ai-recommendations-button-text">
|
||||
<LightBulbIcon size={14} />
|
||||
Recomendações por AI
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<FilterSection
|
||||
title="Genres"
|
||||
onSelect={(value) => {
|
||||
if (filters.genres.includes(value)) {
|
||||
dispatch(
|
||||
setSearch({
|
||||
genres: filters.genres.filter((genre) => genre !== value),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(setSearch({ genres: [...filters.genres, value] }));
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
"Action",
|
||||
"Strategy",
|
||||
"RPG",
|
||||
"Casual",
|
||||
"Racing",
|
||||
"Sports",
|
||||
"Indie",
|
||||
"Adventure",
|
||||
"Simulation",
|
||||
"Massively Multiplayer",
|
||||
"Free to Play",
|
||||
"Accounting",
|
||||
"Animation & Modeling",
|
||||
"Audio Production",
|
||||
"Design & Illustration",
|
||||
"Education",
|
||||
"Photo Editing",
|
||||
"Software Training",
|
||||
"Utilities",
|
||||
"Video Production",
|
||||
"Web Publishing",
|
||||
"Game Development",
|
||||
"Early Access",
|
||||
"Sexual Content",
|
||||
"Nudity",
|
||||
"Violent",
|
||||
"Gore",
|
||||
"Documentary",
|
||||
"Tutorial",
|
||||
].map((genre) => ({
|
||||
label: genre,
|
||||
value: genre,
|
||||
checked: filters.genres.includes(genre),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="User tags"
|
||||
onSelect={(value) => {
|
||||
if (filters.tags.includes(value)) {
|
||||
dispatch(
|
||||
setSearch({
|
||||
tags: filters.tags.filter((tag) => tag !== value),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(setSearch({ tags: [...filters.tags, value] }));
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
"Action",
|
||||
"Strategy",
|
||||
"RPG",
|
||||
"Casual",
|
||||
"Racing",
|
||||
"Sports",
|
||||
"Indie",
|
||||
"Adventure",
|
||||
"Simulation",
|
||||
"Massively Multiplayer",
|
||||
"Free to Play",
|
||||
"Accounting",
|
||||
"Animation & Modeling",
|
||||
"Audio Production",
|
||||
"Design & Illustration",
|
||||
"Education",
|
||||
"Photo Editing",
|
||||
"Software Training",
|
||||
"Utilities",
|
||||
"Video Production",
|
||||
"Web Publishing",
|
||||
"Game Development",
|
||||
"Early Access",
|
||||
"Sexual Content",
|
||||
"Nudity",
|
||||
"Violent",
|
||||
"Gore",
|
||||
"Documentary",
|
||||
"Tutorial",
|
||||
].map((genre) => ({
|
||||
label: genre,
|
||||
value: genre,
|
||||
checked: filters.tags.includes(genre),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Download sources"
|
||||
onSelect={(value) => {
|
||||
if (filters.downloadSourceFingerprints.includes(value)) {
|
||||
dispatch(
|
||||
setSearch({
|
||||
downloadSourceFingerprints:
|
||||
filters.downloadSourceFingerprints.filter(
|
||||
(fingerprint) => fingerprint !== value
|
||||
),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
setSearch({
|
||||
downloadSourceFingerprints: [
|
||||
...filters.downloadSourceFingerprints,
|
||||
value,
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={downloadSources.map((downloadSource) => ({
|
||||
label: `${downloadSource.name} (${downloadSource.objectIds.length})`,
|
||||
value: downloadSource.fingerprint,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
downloadSource.fingerprint
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
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 { Button, GameCard, Hero } from "@renderer/components";
|
||||
import type { Steam250Game, CatalogueEntry } from "@types";
|
||||
import type { Steam250Game } from "@types";
|
||||
|
||||
import flameIconStatic from "@renderer/assets/icons/flame-static.png";
|
||||
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
|
||||
@ -15,14 +15,6 @@ import * as styles from "./home.css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { CatalogueCategory } from "@shared";
|
||||
import { catalogueCacheTable, db } from "@renderer/dexie";
|
||||
import { add } from "date-fns";
|
||||
|
||||
const categoryCacheDurationInSeconds = {
|
||||
[CatalogueCategory.Hot]: 60 * 60 * 2,
|
||||
[CatalogueCategory.Weekly]: 60 * 60 * 24,
|
||||
[CatalogueCategory.Achievements]: 60 * 60 * 24,
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation("home");
|
||||
@ -36,9 +28,7 @@ export default function Home() {
|
||||
CatalogueCategory.Hot
|
||||
);
|
||||
|
||||
const [catalogue, setCatalogue] = useState<
|
||||
Record<CatalogueCategory, CatalogueEntry[]>
|
||||
>({
|
||||
const [catalogue, setCatalogue] = useState<Record<CatalogueCategory, any[]>>({
|
||||
[CatalogueCategory.Hot]: [],
|
||||
[CatalogueCategory.Weekly]: [],
|
||||
[CatalogueCategory.Achievements]: [],
|
||||
@ -46,37 +36,11 @@ export default function Home() {
|
||||
|
||||
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
|
||||
try {
|
||||
const catalogueCache = await catalogueCacheTable
|
||||
.where("expiresAt")
|
||||
.above(new Date())
|
||||
.and((cache) => cache.category === category)
|
||||
.first();
|
||||
|
||||
setCurrentCatalogueCategory(category);
|
||||
setIsLoading(true);
|
||||
|
||||
if (catalogueCache)
|
||||
return setCatalogue((prev) => ({
|
||||
...prev,
|
||||
[category]: catalogueCache.games,
|
||||
}));
|
||||
|
||||
const catalogue = await window.electron.getCatalogue(category);
|
||||
|
||||
db.transaction("rw", catalogueCacheTable, async () => {
|
||||
await catalogueCacheTable.where("category").equals(category).delete();
|
||||
|
||||
await catalogueCacheTable.add({
|
||||
category,
|
||||
games: catalogue,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
expiresAt: add(new Date(), {
|
||||
seconds: categoryCacheDurationInSeconds[category],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
@ -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]);
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
setIsLoading(true);
|
||||
const putDownloadSource = async () => {
|
||||
const downloadSource = await downloadSourcesTable.where({ url }).first();
|
||||
if (!downloadSource) return;
|
||||
|
||||
window.electron
|
||||
.putDownloadSource(downloadSource.objectIds)
|
||||
.then(({ fingerprint }) => {
|
||||
downloadSourcesTable.update(downloadSource.id, { fingerprint });
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
if (validationResult) {
|
||||
setIsLoading(true);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${url}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
channel.onmessage = async () => {
|
||||
setIsLoading(false);
|
||||
|
||||
putDownloadSource();
|
||||
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
channel.close();
|
||||
|
@ -7,10 +7,10 @@ import * as styles from "./settings-download-sources.css";
|
||||
import type { DownloadSource } from "@types";
|
||||
import { NoEntryIcon, PlusCircleIcon, SyncIcon } from "@primer/octicons-react";
|
||||
import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { useRepacks, useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { repacksContext, settingsContext } from "@renderer/context";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
|
||||
@ -28,7 +28,7 @@ export function SettingsDownloadSources() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
await downloadSourcesTable
|
||||
@ -57,16 +57,16 @@ export function SettingsDownloadSources() {
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
|
||||
getDownloadSources();
|
||||
indexRepacks();
|
||||
setIsRemovingDownloadSource(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
indexRepacks();
|
||||
await getDownloadSources();
|
||||
showSuccessToast(t("added_download_source"));
|
||||
updateRepacks();
|
||||
};
|
||||
|
||||
const syncDownloadSources = async () => {
|
||||
@ -82,6 +82,7 @@ export function SettingsDownloadSources() {
|
||||
getDownloadSources();
|
||||
setIsSyncingDownloadSources(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -6,7 +6,7 @@ $body-color: #8e919b;
|
||||
|
||||
$border-color: #424244;
|
||||
$success-color: #1c9749;
|
||||
$danger-color: #e11d48;
|
||||
$danger-color: #801d1e;
|
||||
$warning-color: #ffc107;
|
||||
|
||||
$disabled-opacity: 0.5;
|
||||
|
@ -3,16 +3,16 @@ import {
|
||||
downloadSlice,
|
||||
windowSlice,
|
||||
librarySlice,
|
||||
searchSlice,
|
||||
userPreferencesSlice,
|
||||
toastSlice,
|
||||
userDetailsSlice,
|
||||
gameRunningSlice,
|
||||
repacksSlice,
|
||||
catalogueSearchSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
search: searchSlice.reducer,
|
||||
window: windowSlice.reducer,
|
||||
library: librarySlice.reducer,
|
||||
userPreferences: userPreferencesSlice.reducer,
|
||||
@ -20,6 +20,8 @@ export const store = configureStore({
|
||||
toast: toastSlice.reducer,
|
||||
userDetails: userDetailsSlice.reducer,
|
||||
gameRunning: gameRunningSlice.reducer,
|
||||
repacks: repacksSlice.reducer,
|
||||
catalogueSearch: catalogueSearchSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -2,7 +2,10 @@ import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
|
||||
|
||||
import { z } from "zod";
|
||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { DownloadSourceStatus, formatName, pipe } from "@shared";
|
||||
import { GameRepack } from "@types";
|
||||
|
||||
const formatRepackName = pipe((name) => name.replace("[DL]", ""), formatName);
|
||||
|
||||
export const downloadSourceSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
@ -22,6 +25,64 @@ type Payload =
|
||||
| ["VALIDATE_DOWNLOAD_SOURCE", string]
|
||||
| ["SYNC_DOWNLOAD_SOURCES", string];
|
||||
|
||||
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
|
||||
|
||||
const addNewDownloads = async (
|
||||
downloadSource: { id: number; name: string },
|
||||
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
|
||||
steamGames: SteamGamesByLetter
|
||||
) => {
|
||||
const now = new Date();
|
||||
|
||||
const results = [] as (Omit<GameRepack, "id"> & {
|
||||
downloadSourceId: number;
|
||||
})[];
|
||||
|
||||
const objectIdsOnSource = new Set<string>();
|
||||
|
||||
for (const download of downloads) {
|
||||
const formattedTitle = formatRepackName(download.title);
|
||||
const [firstLetter] = formattedTitle;
|
||||
const games = steamGames[firstLetter] || [];
|
||||
|
||||
const gamesInSteam = games.filter((game) =>
|
||||
formattedTitle.startsWith(game.name)
|
||||
);
|
||||
|
||||
if (gamesInSteam.length === 0) continue;
|
||||
|
||||
for (const game of gamesInSteam) {
|
||||
objectIdsOnSource.add(String(game.id));
|
||||
}
|
||||
|
||||
results.push({
|
||||
objectIds: gamesInSteam.map((game) => String(game.id)),
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: downloadSource.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await repacksTable.bulkAdd(results);
|
||||
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
objectIds: Array.from(objectIdsOnSource),
|
||||
});
|
||||
};
|
||||
|
||||
const getSteamGames = async () => {
|
||||
const response = await axios.get<SteamGamesByLetter>(
|
||||
"https://assets.hydralauncher.gg/steam-games-by-letter.json"
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const [type, data] = event.data;
|
||||
|
||||
@ -55,6 +116,8 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const response =
|
||||
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
||||
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
const now = new Date();
|
||||
|
||||
@ -70,18 +133,11 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
|
||||
const downloadSource = await downloadSourcesTable.get(id);
|
||||
|
||||
const repacks = response.data.downloads.map((download) => ({
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: response.data.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource!.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
await repacksTable.bulkAdd(repacks);
|
||||
await addNewDownloads(
|
||||
downloadSource,
|
||||
response.data.downloads,
|
||||
steamGames
|
||||
);
|
||||
});
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${data}`);
|
||||
@ -110,6 +166,8 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
await db.transaction(
|
||||
"rw",
|
||||
repacksTable,
|
||||
@ -121,29 +179,16 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const repacks = source.downloads.filter(
|
||||
(download) =>
|
||||
!existingRepacks.some(
|
||||
(repack) => repack.title === download.title
|
||||
)
|
||||
);
|
||||
|
||||
const repacks = source.downloads
|
||||
.filter(
|
||||
(download) =>
|
||||
!existingRepacks.some(
|
||||
(repack) => repack.title === download.title
|
||||
)
|
||||
)
|
||||
.map((download) => ({
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: source.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
await addNewDownloads(downloadSource, repacks, steamGames);
|
||||
|
||||
newRepacksCount += repacks.length;
|
||||
|
||||
await repacksTable.bulkAdd(repacks);
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
|
@ -1,5 +1,3 @@
|
||||
import RepacksWorker from "./repacks.worker?worker";
|
||||
import DownloadSourcesWorker from "./download-sources.worker?worker";
|
||||
|
||||
export const repacksWorker = new RepacksWorker();
|
||||
export const downloadSourcesWorker = new DownloadSourcesWorker();
|
||||
|
@ -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 {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* @deprecated Use uris instead
|
||||
*/
|
||||
magnet: string;
|
||||
uris: string[];
|
||||
repacker: string;
|
||||
fileSize: string | null;
|
||||
objectIds: string[];
|
||||
uploadDate: Date | string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@ -77,15 +74,6 @@ export interface TorrentFile {
|
||||
length: number;
|
||||
}
|
||||
|
||||
/* Used by the catalogue */
|
||||
export interface CatalogueEntry {
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
title: string;
|
||||
/* Epic Games covers cannot be guessed with objectID */
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export interface UserGame {
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
@ -301,6 +289,7 @@ export interface DownloadSource {
|
||||
repackCount: number;
|
||||
status: DownloadSourceStatus;
|
||||
downloadCount: number;
|
||||
fingerprint: string;
|
||||
etag: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@ -374,6 +363,15 @@ export interface ComparedAchievements {
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CatalogueSearchPayload {
|
||||
title: string;
|
||||
downloadSourceFingerprints: string[];
|
||||
tags: number[];
|
||||
publishers: string[];
|
||||
genres: string[];
|
||||
developers: string[];
|
||||
}
|
||||
|
||||
export * from "./steam.types";
|
||||
export * from "./real-debrid.types";
|
||||
export * from "./ludusavi.types";
|
||||
|
Loading…
Reference in New Issue
Block a user