mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-01-23 05:24:55 +03:00
Merge branch 'feat/new-catalogue' into feat/achievements-points
# Conflicts: # src/preload/index.ts # src/renderer/src/app.tsx # src/renderer/src/features/index.ts # src/renderer/src/store.ts # yarn.lock
This commit is contained in:
commit
cabddfb926
@ -26,4 +26,9 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -55,7 +55,6 @@
|
||||
"electron-log": "^5.2.4",
|
||||
"electron-updater": "^6.3.9",
|
||||
"file-type": "^19.6.0",
|
||||
"flexsearch": "^0.7.43",
|
||||
"i18next": "^23.11.2",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
"jsdom": "^24.0.0",
|
||||
@ -64,11 +63,13 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"parse-torrent": "^11.0.17",
|
||||
"piscina": "^4.7.0",
|
||||
"rc-virtual-list": "^3.16.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-loading-skeleton": "^3.4.0",
|
||||
"react-redux": "^9.1.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"sound-play": "^1.1.0",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"tar": "^7.4.3",
|
||||
|
@ -20,7 +20,7 @@ const s3 = new S3Client({
|
||||
|
||||
const dist = path.resolve(__dirname, "..", "dist");
|
||||
|
||||
const extensionsToUpload = [".deb", ".exe"];
|
||||
const extensionsToUpload = [".deb", ".exe", ".pacman"];
|
||||
|
||||
fs.readdir(dist, async (err, files) => {
|
||||
if (err) throw err;
|
||||
|
@ -284,8 +284,11 @@
|
||||
"instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo"
|
||||
},
|
||||
"catalogue": {
|
||||
"next_page": "Próxima página",
|
||||
"previous_page": "Página anterior"
|
||||
"search": "Pesquisar…",
|
||||
"developers": "Desenvolvedores",
|
||||
"genres": "Gêneros",
|
||||
"tags": "Tags",
|
||||
"download_sources": "Fontes de download"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Botão de fechar"
|
||||
|
@ -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);
|
||||
|
10
src/main/events/catalogue/get-developers.ts
Normal file
10
src/main/events/catalogue/get-developers.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<string[]>(`/catalogue/developers`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getDevelopers", getDevelopers);
|
@ -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);
|
10
src/main/events/catalogue/get-publishers.ts
Normal file
10
src/main/events/catalogue/get-publishers.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<string[]>(`/catalogue/publishers`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getPublishers", getPublishers);
|
@ -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: 24, 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);
|
17
src/main/events/download-sources/put-download-source.ts
Normal file
17
src/main/events/download-sources/put-download-source.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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,
|
||||
},
|
||||
{ needsAuth: false }
|
||||
);
|
||||
};
|
||||
|
||||
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,12 +3,13 @@ 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";
|
||||
import "./catalogue/get-game-stats";
|
||||
import "./catalogue/get-trending-games";
|
||||
import "./catalogue/get-publishers";
|
||||
import "./catalogue/get-developers";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/create-game-shortcut";
|
||||
@ -40,8 +41,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,
|
||||
SeedingStatus,
|
||||
GameAchievement,
|
||||
} from "@types";
|
||||
@ -54,7 +55,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) =>
|
||||
@ -62,10 +64,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"),
|
||||
@ -85,6 +83,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
listener
|
||||
);
|
||||
},
|
||||
getPublishers: () => ipcRenderer.invoke("getPublishers"),
|
||||
getDevelopers: () => ipcRenderer.invoke("getDevelopers"),
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||
@ -96,9 +96,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,
|
||||
@ -44,9 +43,9 @@ export function App() {
|
||||
|
||||
const downloadSourceMigrationLock = useRef(false);
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const {
|
||||
userDetails,
|
||||
@ -69,8 +68,6 @@ export function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const search = useAppSelector((state) => state.search.value);
|
||||
|
||||
const draggingDisabled = useAppSelector(
|
||||
(state) => state.window.draggingDisabled
|
||||
);
|
||||
@ -197,31 +194,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]);
|
||||
@ -242,49 +214,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());
|
||||
@ -328,11 +270,7 @@ export function App() {
|
||||
<Sidebar />
|
||||
|
||||
<article className={styles.container}>
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
search={search}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
<Header />
|
||||
|
||||
<section ref={contentRef} className={styles.content}>
|
||||
<Outlet />
|
||||
|
@ -7,9 +7,5 @@ export interface BadgeProps {
|
||||
}
|
||||
|
||||
export function Badge({ children }: BadgeProps) {
|
||||
return (
|
||||
<div className="badge">
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
return <div className="badge">{children}</div>;
|
||||
}
|
||||
|
@ -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,31 @@ 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",
|
||||
color: vars.color.darkBackground,
|
||||
":hover": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
checked: {
|
||||
true: {
|
||||
backgroundColor: vars.color.muted,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -38,4 +51,7 @@ export const checkboxInput = style({
|
||||
|
||||
export const checkboxLabel = style({
|
||||
cursor: "pointer",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
@ -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"
|
||||
|
@ -1,19 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
|
||||
import { useAppDispatch, 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 { setSearch } from "@renderer/features";
|
||||
|
||||
const pathTitle: Record<string, string> = {
|
||||
"/": "home",
|
||||
@ -22,7 +16,7 @@ const pathTitle: Record<string, string> = {
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
export function Header() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
@ -31,6 +25,11 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||
(state) => state.window
|
||||
);
|
||||
|
||||
const searchValue = useAppSelector(
|
||||
(state) => state.catalogueSearch.value.title
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@ -46,12 +45,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();
|
||||
@ -65,6 +58,14 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
dispatch(setSearch({ title: value }));
|
||||
|
||||
if (!location.pathname.startsWith("/catalogue")) {
|
||||
navigate("/catalogue");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@ -109,17 +110,17 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
value={searchValue}
|
||||
className={styles.searchInput}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onChange={(event) => handleSearch(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{search && (
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
onClick={() => dispatch(setSearch({ title: "" }))}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<XIcon />
|
||||
|
@ -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,
|
||||
@ -87,17 +85,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>
|
||||
);
|
||||
}
|
17
src/renderer/src/declaration.d.ts
vendored
17
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,
|
||||
@ -14,7 +12,6 @@ import type {
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
DownloadSource,
|
||||
UserProfile,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
@ -31,6 +28,7 @@ import type {
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
ComparedAchievements,
|
||||
CatalogueSearchPayload,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
@ -58,8 +56,8 @@ declare global {
|
||||
onHardDelete: (cb: () => void) => () => 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,
|
||||
@ -70,8 +68,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: (
|
||||
@ -79,6 +75,8 @@ declare global {
|
||||
shop: GameShop,
|
||||
cb: (achievements: GameAchievement[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
getPublishers: () => Promise<string[]>;
|
||||
getDevelopers: () => Promise<string[]>;
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (
|
||||
@ -125,8 +123,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, clearSearch } = catalogueSearchSlice.actions;
|
@ -1,4 +1,3 @@
|
||||
export * from "./search-slice";
|
||||
export * from "./library-slice";
|
||||
export * from "./use-preferences-slice";
|
||||
export * from "./download-slice";
|
||||
@ -7,3 +6,5 @@ export * from "./toast-slice";
|
||||
export * from "./user-details-slice";
|
||||
export * from "./running-game-slice";
|
||||
export * from "./subscription-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 };
|
||||
}
|
||||
|
28
src/renderer/src/hooks/use-repacks.ts
Normal file
28
src/renderer/src/hooks/use-repacks.ts
Normal file
@ -0,0 +1,28 @@
|
||||
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.filter((repack) => Array.isArray(repack.objectIds)))
|
||||
);
|
||||
});
|
||||
}, [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>
|
||||
);
|
||||
|
51
src/renderer/src/pages/catalogue/catalogue.scss
Normal file
51
src/renderer/src/pages/catalogue/catalogue.scss
Normal file
@ -0,0 +1,51 @@
|
||||
@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;
|
||||
|
||||
&__game-item {
|
||||
background-color: globals.$dark-background-color;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
border: 1px solid globals.$border-color;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
&__filters-container {
|
||||
width: 270px;
|
||||
min-width: 270px;
|
||||
max-width: 270px;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
border: 1px solid globals.$border-color;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
@ -1,114 +1,449 @@
|
||||
import { Button, GameCard } from "@renderer/components";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } 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 { useAppDispatch, useAppSelector, useRepacks } from "@renderer/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
|
||||
import "./catalogue.scss";
|
||||
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FilterSection } from "./filter-section";
|
||||
import { setSearch } from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
const filterCategoryColors = {
|
||||
genres: "hsl(262deg 50% 47%)",
|
||||
tags: "hsl(95deg 50% 20%)",
|
||||
downloadSourceFingerprints: "hsl(27deg 50% 40%)",
|
||||
developers: "hsl(340deg 50% 46%)",
|
||||
publishers: "hsl(200deg 50% 30%)",
|
||||
};
|
||||
|
||||
export default function Catalogue() {
|
||||
const dispatch = useAppDispatch();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { t } = useTranslation("catalogue");
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
const [steamUserTags, setSteamUserTags] = useState<any>({});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const skip = Number(searchParams.get("skip") ?? 0);
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [games, setGames] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [publishers, setPublishers] = useState<string[]>([]);
|
||||
const [developers, setDevelopers] = useState<string[]>([]);
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(buildGameDetailsPath(game));
|
||||
};
|
||||
const filters = useAppSelector((state) => state.catalogueSearch.value);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t, i18n } = useTranslation("catalogue");
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||
setGames([]);
|
||||
setIsLoading(true);
|
||||
setSearchResults([]);
|
||||
abortControllerRef.current?.abort();
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
window.electron
|
||||
.getGames(24, skip)
|
||||
.then((results) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
setSearchResults(results);
|
||||
resolve(null);
|
||||
}, 500);
|
||||
});
|
||||
.searchGames(filters)
|
||||
.then((games) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGames(games);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [dispatch, skip, searchParams]);
|
||||
}, [filters]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
const params = new URLSearchParams({
|
||||
skip: String(skip + 24),
|
||||
useEffect(() => {
|
||||
window.electron.getDevelopers().then((developers) => {
|
||||
setDevelopers(developers);
|
||||
});
|
||||
|
||||
navigate(`/catalogue?${params.toString()}`);
|
||||
};
|
||||
window.electron.getPublishers().then((publishers) => {
|
||||
setPublishers(publishers);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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.filter((source) => !!source.fingerprint));
|
||||
});
|
||||
}, [getRepacksForObjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(
|
||||
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-user-tags.json`
|
||||
)
|
||||
.then((response) => {
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
if (response.data[language]) {
|
||||
setSteamUserTags(response.data[language]);
|
||||
} else {
|
||||
setSteamUserTags(response.data["en"]);
|
||||
}
|
||||
});
|
||||
}, [i18n.language]);
|
||||
|
||||
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",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
borderBottom: `1px solid ${vars.color.border}`,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
theme="outline"
|
||||
disabled={skip === 0 || isLoading}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{filters.genres.map((genre) => (
|
||||
<Badge key={genre}>
|
||||
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: filterCategoryColors.genres,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
|
||||
{genre}
|
||||
</div>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
<li
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: vars.color.body,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 4,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: filterCategoryColors.genres,
|
||||
borderRadius: "50%",
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
Action
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
color: vars.color.body,
|
||||
marginLeft: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</li>
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
{t("previous_page")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleNextPage} theme="outline" disabled={isLoading}>
|
||||
{t("next_page")}
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<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)}
|
||||
{isLoading ? (
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.darkBackground}
|
||||
highlightColor={vars.color.background}
|
||||
>
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
style={{
|
||||
height: 105,
|
||||
borderRadius: 4,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</SkeletonTheme>
|
||||
) : (
|
||||
gamesWithRepacks.map((game, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className="catalogue__game-item"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
width: 200,
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
src={steamUrlBuilder.library(game.objectId)}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 4,
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
<span>{game.title}</span>
|
||||
<span
|
||||
style={{
|
||||
color: vars.color.body,
|
||||
marginBottom: 4,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{game.genres?.join(", ")}
|
||||
</span>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{game.repacks.map((repack) => (
|
||||
<Badge key={repack}>{repack}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
|
||||
{/* <div style={{ display: "flex", gap: 8 }}>
|
||||
<Button theme="outline">1</Button>
|
||||
<Button theme="outline">2</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="catalogue__filters-container">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<FilterSection
|
||||
title={t("genres")}
|
||||
onClear={() => dispatch(setSearch({ genres: [] }))}
|
||||
color={filterCategoryColors.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",
|
||||
]
|
||||
.sort()
|
||||
.map((genre) => ({
|
||||
label: genre,
|
||||
value: genre,
|
||||
checked: filters.genres.includes(genre),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title={t("tags")}
|
||||
color={filterCategoryColors.tags}
|
||||
onClear={() => dispatch(setSearch({ tags: [] }))}
|
||||
onSelect={(value) => {
|
||||
if (filters.tags.includes(Number(value))) {
|
||||
dispatch(
|
||||
setSearch({
|
||||
tags: filters.tags.filter((tag) => tag !== Number(value)),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
setSearch({ tags: [...filters.tags, Number(value)] })
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={
|
||||
Object.entries(steamUserTags)
|
||||
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
.map(([key, value]) => ({
|
||||
label: key,
|
||||
value: value,
|
||||
checked: filters.tags.includes(value as number),
|
||||
})) as any
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title={t("download_sources")}
|
||||
color={filterCategoryColors.downloadSourceFingerprints}
|
||||
onClear={() =>
|
||||
dispatch(setSearch({ downloadSourceFingerprints: [] }))
|
||||
}
|
||||
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,
|
||||
value: downloadSource.fingerprint,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
downloadSource.fingerprint
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title={t("developers")}
|
||||
color={filterCategoryColors.developers}
|
||||
onClear={() => dispatch(setSearch({ developers: [] }))}
|
||||
onSelect={(value) => {
|
||||
if (filters.developers.includes(value)) {
|
||||
dispatch(
|
||||
setSearch({
|
||||
developers: filters.developers.filter(
|
||||
(developer) => developer !== value
|
||||
),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
setSearch({ developers: [...filters.developers, value] })
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={developers.map((developer) => ({
|
||||
label: developer,
|
||||
value: developer,
|
||||
checked: filters.developers.includes(developer),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title={t("publishers")}
|
||||
color={filterCategoryColors.publishers}
|
||||
onClear={() => dispatch(setSearch({ publishers: [] }))}
|
||||
onSelect={(value) => {
|
||||
if (filters.publishers.includes(value)) {
|
||||
dispatch(
|
||||
setSearch({
|
||||
publishers: filters.publishers.filter(
|
||||
(publisher) => publisher !== value
|
||||
),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
setSearch({ publishers: [...filters.publishers, value] })
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={publishers.map((publisher) => ({
|
||||
label: publisher,
|
||||
value: publisher,
|
||||
checked: filters.publishers.includes(publisher),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
126
src/renderer/src/pages/catalogue/filter-section.tsx
Normal file
126
src/renderer/src/pages/catalogue/filter-section.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { CheckboxField, TextField } from "@renderer/components";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import List from "rc-virtual-list";
|
||||
|
||||
export interface FilterSectionProps {
|
||||
title: string;
|
||||
items: {
|
||||
label: string;
|
||||
value: string;
|
||||
checked: boolean;
|
||||
}[];
|
||||
onSelect: (value: string) => void;
|
||||
color: string;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function FilterSection({
|
||||
title,
|
||||
items,
|
||||
color,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: FilterSectionProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (search.length > 0) {
|
||||
return items.filter((item) =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [items, search]);
|
||||
|
||||
const selectedItemsCount = useMemo(() => {
|
||||
return items.filter((item) => item.checked).length;
|
||||
}, [items]);
|
||||
|
||||
const onSearch = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
}, []);
|
||||
|
||||
const { formatNumber } = useFormat();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: color,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{selectedItemsCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
display: "block",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
onClick={onClear}
|
||||
>
|
||||
Limpar {formatNumber(selectedItemsCount)} selecionados
|
||||
</button>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
|
||||
<List
|
||||
data={filteredItems}
|
||||
height={28 * (filteredItems.length > 10 ? 10 : filteredItems.length)}
|
||||
itemHeight={28}
|
||||
itemKey="value"
|
||||
styles={{
|
||||
verticalScrollBar: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.03)",
|
||||
},
|
||||
verticalScrollBarThumb: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "24px",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<div key={item.value} style={{ height: 28, maxHeight: 28 }}>
|
||||
<CheckboxField
|
||||
label={item.label}
|
||||
checked={item.checked}
|
||||
onChange={() => onSelect(item.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</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,12 +7,14 @@ 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 { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { repacksContext, settingsContext } from "@renderer/context";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
import { clearSearch, setSearch } from "@renderer/features";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function SettingsDownloadSources() {
|
||||
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
|
||||
@ -28,7 +30,11 @@ export function SettingsDownloadSources() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { indexRepacks } = useContext(repacksContext);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
await downloadSourcesTable
|
||||
@ -57,16 +63,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 +88,7 @@ export function SettingsDownloadSources() {
|
||||
getDownloadSources();
|
||||
setIsSyncingDownloadSources(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
};
|
||||
|
||||
@ -95,6 +102,13 @@ export function SettingsDownloadSources() {
|
||||
setShowAddDownloadSourceModal(false);
|
||||
};
|
||||
|
||||
const navigateToCatalogue = (fingerprint: string) => {
|
||||
dispatch(clearSearch());
|
||||
dispatch(setSearch({ downloadSourceFingerprints: [fingerprint] }));
|
||||
|
||||
navigate("/catalogue");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddDownloadSourceModal
|
||||
@ -146,12 +160,17 @@ export function SettingsDownloadSources() {
|
||||
<Badge>{statusTitle[downloadSource.status]}</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.muted,
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
|
||||
>
|
||||
<small>
|
||||
{t("download_count", {
|
||||
@ -160,7 +179,7 @@ export function SettingsDownloadSources() {
|
||||
downloadSource.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
|
@ -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,17 +3,17 @@ import {
|
||||
downloadSlice,
|
||||
windowSlice,
|
||||
librarySlice,
|
||||
searchSlice,
|
||||
userPreferencesSlice,
|
||||
toastSlice,
|
||||
userDetailsSlice,
|
||||
gameRunningSlice,
|
||||
subscriptionSlice,
|
||||
repacksSlice,
|
||||
catalogueSearchSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
search: searchSlice.reducer,
|
||||
window: windowSlice.reducer,
|
||||
library: librarySlice.reducer,
|
||||
userPreferences: userPreferencesSlice.reducer,
|
||||
@ -22,6 +22,8 @@ export const store = configureStore({
|
||||
userDetails: userDetailsSlice.reducer,
|
||||
gameRunning: gameRunningSlice.reducer,
|
||||
subscription: subscriptionSlice.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,95 @@ 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>(
|
||||
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const importDownloadSource = async (url: string) => {
|
||||
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
|
||||
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
const now = new Date();
|
||||
|
||||
const id = await downloadSourcesTable.add({
|
||||
url,
|
||||
name: response.data.name,
|
||||
etag: response.headers["etag"],
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
downloadCount: response.data.downloads.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const downloadSource = await downloadSourcesTable.get(id);
|
||||
|
||||
await addNewDownloads(downloadSource, response.data.downloads, steamGames);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteDownloadSource = async (id: number) => {
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
await repacksTable.where({ downloadSourceId: id }).delete();
|
||||
await downloadSourcesTable.where({ id }).delete();
|
||||
});
|
||||
};
|
||||
|
||||
self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const [type, data] = event.data;
|
||||
|
||||
@ -41,10 +133,7 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
}
|
||||
|
||||
if (type === "DELETE_DOWNLOAD_SOURCE") {
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
await repacksTable.where({ downloadSourceId: data }).delete();
|
||||
await downloadSourcesTable.where({ id: data }).delete();
|
||||
});
|
||||
await deleteDownloadSource(data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
|
||||
|
||||
@ -52,37 +141,7 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
}
|
||||
|
||||
if (type === "IMPORT_DOWNLOAD_SOURCE") {
|
||||
const response =
|
||||
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
||||
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
const now = new Date();
|
||||
|
||||
const id = await downloadSourcesTable.add({
|
||||
url: data,
|
||||
name: response.data.name,
|
||||
etag: response.headers["etag"],
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
downloadCount: response.data.downloads.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
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 importDownloadSource(data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${data}`);
|
||||
channel.postMessage(true);
|
||||
@ -97,6 +156,12 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const existingRepacks = await repacksTable.toArray();
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
if (!downloadSource.fingerprint) {
|
||||
await deleteDownloadSource(downloadSource.id);
|
||||
await importDownloadSource(downloadSource.url);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headers = new AxiosHeaders();
|
||||
|
||||
if (downloadSource.etag) {
|
||||
@ -110,6 +175,8 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
await db.transaction(
|
||||
"rw",
|
||||
repacksTable,
|
||||
@ -121,29 +188,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);
|
||||
}
|
||||
};
|
@ -17,13 +17,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;
|
||||
@ -80,15 +77,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;
|
||||
@ -320,7 +308,9 @@ export interface DownloadSource {
|
||||
url: string;
|
||||
repackCount: number;
|
||||
status: DownloadSourceStatus;
|
||||
objectIds: string[];
|
||||
downloadCount: number;
|
||||
fingerprint: string;
|
||||
etag: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@ -405,6 +395,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";
|
||||
|
94
yarn.lock
94
yarn.lock
@ -978,6 +978,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7":
|
||||
version "7.26.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1"
|
||||
integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.22.15", "@babel/template@^7.24.0":
|
||||
version "7.24.0"
|
||||
resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz"
|
||||
@ -4101,7 +4108,7 @@ ci-info@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
|
||||
integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==
|
||||
|
||||
classnames@^2.5.1:
|
||||
classnames@^2.2.1, classnames@^2.2.6, classnames@^2.5.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
|
||||
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
|
||||
@ -4173,6 +4180,11 @@ clone@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
|
||||
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
|
||||
|
||||
clsx@^1.0.4:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
|
||||
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
|
||||
|
||||
color-convert@^1.9.0:
|
||||
version "1.9.3"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||
@ -4638,6 +4650,14 @@ doctrine@^3.0.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
dom-helpers@^5.1.3:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
|
||||
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.8.7"
|
||||
csstype "^3.0.2"
|
||||
|
||||
dot-case@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
|
||||
@ -5394,11 +5414,6 @@ flatted@^3.2.9:
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a"
|
||||
integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==
|
||||
|
||||
flexsearch@^0.7.43:
|
||||
version "0.7.43"
|
||||
resolved "https://registry.yarnpkg.com/flexsearch/-/flexsearch-0.7.43.tgz#34f89b36278a466ce379c5bf6fb341965ed3f16c"
|
||||
integrity sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg==
|
||||
|
||||
follow-redirects@^1.15.6:
|
||||
version "1.15.9"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
|
||||
@ -5549,11 +5564,6 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@
|
||||
has-symbols "^1.0.3"
|
||||
hasown "^2.0.0"
|
||||
|
||||
get-nonce@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
|
||||
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
|
||||
|
||||
get-intrinsic@^1.2.5, get-intrinsic@^1.2.6:
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.6.tgz#43dd3dd0e7b49b82b2dfcad10dc824bf7fc265d5"
|
||||
@ -5570,6 +5580,11 @@ get-intrinsic@^1.2.5, get-intrinsic@^1.2.6:
|
||||
hasown "^2.0.2"
|
||||
math-intrinsics "^1.0.0"
|
||||
|
||||
get-nonce@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
|
||||
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
|
||||
|
||||
get-package-type@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
|
||||
@ -7519,7 +7534,7 @@ promise-retry@^2.0.1:
|
||||
err-code "^2.0.2"
|
||||
retry "^0.12.0"
|
||||
|
||||
prop-types@^15.8.1:
|
||||
prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@ -7571,6 +7586,34 @@ quick-lru@^5.1.1:
|
||||
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
|
||||
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
|
||||
|
||||
rc-resize-observer@^1.0.0:
|
||||
version "1.4.3"
|
||||
resolved "https://registry.yarnpkg.com/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz#4fd41fa561ba51362b5155a07c35d7c89a1ea569"
|
||||
integrity sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.7"
|
||||
classnames "^2.2.1"
|
||||
rc-util "^5.44.1"
|
||||
resize-observer-polyfill "^1.5.1"
|
||||
|
||||
rc-util@^5.36.0, rc-util@^5.44.1:
|
||||
version "5.44.2"
|
||||
resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.44.2.tgz#6bc5db0e96ebdb515eb5977a7371887e5413a6f8"
|
||||
integrity sha512-uGSk3hpPBLa3/0QAcKhCjgl4SFnhQCJDLvvpoLdbR6KgDuXrujG+dQaUeUvBJr2ZWak1O/9n+cYbJiWmmk95EQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.18.3"
|
||||
react-is "^18.2.0"
|
||||
|
||||
rc-virtual-list@^3.16.1:
|
||||
version "3.16.1"
|
||||
resolved "https://registry.yarnpkg.com/rc-virtual-list/-/rc-virtual-list-3.16.1.tgz#073d75cc0295497cdd9a35d6f5d1b71b4f35233e"
|
||||
integrity sha512-algM5UsB7vrlPNr9lsZEH8s9KHkP8XbT/Y0qylyPkiM8mIOlSJLjBNADcmbYPEQCm4zW82mZRJuVHNzqqN0EAQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.0"
|
||||
classnames "^2.2.6"
|
||||
rc-resize-observer "^1.0.0"
|
||||
rc-util "^5.36.0"
|
||||
|
||||
rc@^1.2.7:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
|
||||
@ -7607,6 +7650,16 @@ react-is@^16.13.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^18.2.0:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-lifecycles-compat@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
||||
|
||||
react-loading-skeleton@^3.4.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz#da2090355b4dedcad5c53cb3f0ed364e3a76d6ca"
|
||||
@ -7668,6 +7721,18 @@ react-style-singleton@^2.2.1:
|
||||
invariant "^2.2.4"
|
||||
tslib "^2.0.0"
|
||||
|
||||
react-virtualized@^9.22.5:
|
||||
version "9.22.5"
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.5.tgz#bfb96fed519de378b50d8c0064b92994b3b91620"
|
||||
integrity sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.7.2"
|
||||
clsx "^1.0.4"
|
||||
dom-helpers "^5.1.3"
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.7.2"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
|
||||
@ -7784,6 +7849,11 @@ reselect@^5.1.0:
|
||||
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e"
|
||||
integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==
|
||||
|
||||
resize-observer-polyfill@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
|
||||
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
|
||||
|
||||
resolve-alpn@^1.0.0:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"
|
||||
|
Loading…
Reference in New Issue
Block a user