feat: adding change hero

This commit is contained in:
Chubby Granny Chaser 2024-10-05 02:21:41 +01:00
parent 586df616e8
commit 035e424a76
No known key found for this signature in database
48 changed files with 520 additions and 500 deletions

View file

@ -30,7 +30,7 @@ const getCatalogue = async (
title: steamGame.name,
shop: game.shop,
cover: steamUrlBuilder.library(game.objectId),
objectID: game.objectId,
objectId: game.objectId,
};
})
);

View file

@ -7,16 +7,16 @@ import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
const getLocalizedSteamAppDetails = async (
objectID: string,
objectId: string,
language: string
): Promise<ShopDetails | null> => {
if (language === "english") {
return getSteamAppDetails(objectID, language);
return getSteamAppDetails(objectId, language);
}
return getSteamAppDetails(objectID, language).then(
return getSteamAppDetails(objectId, language).then(
async (localizedAppDetails) => {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
@ -34,21 +34,21 @@ const getLocalizedSteamAppDetails = async (
const getGameShopDetails = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
shop: GameShop,
language: string
): Promise<ShopDetails | null> => {
if (shop === "steam") {
const cachedData = await gameShopCacheRepository.findOne({
where: { objectID, language },
where: { objectID: objectId, language },
});
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
(result) => {
if (result) {
gameShopCacheRepository.upsert(
{
objectID,
objectID: objectId,
shop: "steam",
language,
serializedData: JSON.stringify(result),
@ -68,7 +68,7 @@ const getGameShopDetails = async (
if (cachedGame) {
return {
...cachedGame,
objectID,
objectId,
} as ShopDetails;
}

View file

@ -1,28 +1,29 @@
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
import { HydraApi } from "@main/services";
import { steamUrlBuilder } from "@shared";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take = 12,
cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
const steamGames = await steamGamesWorker.run(
{ limit: take, offset: cursor },
{ name: "list" }
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 {
results: steamGames.map((steamGame) => ({
title: steamGame.name,
shop: "steam",
cover: steamUrlBuilder.library(steamGame.id),
objectID: steamGame.id,
})),
cursor: cursor + steamGames.length,
};
return games.map((game) => ({
...game,
cover: steamUrlBuilder.library(game.objectId),
}));
};
registerEvent("getGames", getGames);

View file

@ -6,14 +6,14 @@ import { gameShopCacheRepository } from "@main/repository";
const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
shop: GameShop,
title: string
): Promise<HowLongToBeatCategory[] | null> => {
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
const gameShopCache = await gameShopCacheRepository.findOne({
where: { objectID, shop },
where: { objectID: objectId, shop },
});
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
@ -23,7 +23,7 @@ const getHowLongToBeat = async (
return searchHowLongToBeatPromise.then(async (response) => {
const game = response.data.find(
(game) => game.profile_steam === Number(objectID)
(game) => game.profile_steam === Number(objectId)
);
if (!game) return null;
@ -31,7 +31,7 @@ const getHowLongToBeat = async (
gameShopCacheRepository.upsert(
{
objectID,
objectID: objectId,
shop,
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
},

View file

@ -4,6 +4,9 @@ import { registerEvent } from "../register-event";
const deleteGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string
) => HydraApi.delete<{ ok: boolean }>(`/games/artifacts/${gameArtifactId}`);
) =>
HydraApi.delete<{ ok: boolean }>(
`/profile/games/artifacts/${gameArtifactId}`
);
registerEvent("deleteGameArtifact", deleteGameArtifact);

View file

@ -1,6 +1,6 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import fs from "node:fs";
import AdmZip from "adm-zip";
import * as tar from "tar";
import { registerEvent } from "../register-event";
import axios from "axios";
import { app } from "electron";
@ -8,16 +8,54 @@ import path from "node:path";
import { backupsPath } from "@main/constants";
import type { GameShop } from "@types";
import YAML from "yaml";
export interface LudusaviBackup {
files: {
[key: string]: {
hash: string;
size: number;
};
};
}
const replaceLudusaviBackupWithCurrentUser = (
mappingPath: string,
backupHomeDir: string
) => {
const data = fs.readFileSync(mappingPath, "utf8");
const manifest = YAML.parse(data);
const currentHomeDir = app.getPath("home");
const backups = manifest.backups.map((backup: LudusaviBackup) => {
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
return {
...prev,
[key.replace(backupHomeDir, currentHomeDir)]: value,
};
}, {});
return {
...backup,
files,
};
});
fs.writeFileSync(mappingPath, YAML.stringify({ ...manifest, backups }));
};
const downloadGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
gameArtifactId: string
) => {
const { downloadUrl, objectKey } = await HydraApi.post<{
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
downloadUrl: string;
objectKey: string;
}>(`/games/artifacts/${gameArtifactId}/download`);
homeDir: string;
}>(`/profile/games/artifacts/${gameArtifactId}/download`);
const zipLocation = path.join(app.getPath("userData"), objectKey);
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
@ -42,20 +80,31 @@ const downloadGameArtifact = async (
});
writer.on("close", () => {
const zip = new AdmZip(zipLocation);
zip.extractAllToAsync(backupPath, true, true, (err) => {
if (err) {
logger.error("Failed to extract zip", err);
throw err;
}
tar
.x({
file: zipLocation,
cwd: backupPath,
})
.then(async () => {
const [game] = await Ludusavi.findGames(shop, objectId);
if (!game) throw new Error("Game not found in Ludusavi manifest");
Ludusavi.restoreBackup(backupPath).then(() => {
WindowManager.mainWindow?.webContents.send(
`on-backup-download-complete-${objectId}-${shop}`,
true
const mappingPath = path.join(
backupsPath,
`${shop}-${objectId}`,
game,
"mapping.yaml"
);
replaceLudusaviBackupWithCurrentUser(mappingPath, homeDir);
Ludusavi.restoreBackup(backupPath).then(() => {
WindowManager.mainWindow?.webContents.send(
`on-backup-download-complete-${objectId}-${shop}`,
true
);
});
});
});
});
};

View file

@ -12,7 +12,9 @@ const getGameArtifacts = async (
shop,
});
return HydraApi.get<GameArtifact[]>(`/games/artifacts?${params.toString()}`);
return HydraApi.get<GameArtifact[]>(
`/profile/games/artifacts?${params.toString()}`
);
};
registerEvent("getGameArtifacts", getGameArtifacts);

View file

@ -2,47 +2,31 @@ import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import archiver from "archiver";
import * as tar from "tar";
import crypto from "node:crypto";
import { GameShop } from "@types";
import axios from "axios";
import os from "node:os";
import { app } from "electron";
import { backupsPath } from "@main/constants";
import { app } from "electron";
const compressBackupToArtifact = async (
shop: GameShop,
objectId: string,
cb: (zipLocation: string) => void
) => {
const bundleBackup = async (shop: GameShop, objectId: string) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
await Ludusavi.backupGame(shop, objectId, backupPath);
const archive = archiver("zip", {
zlib: { level: 9 },
});
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.zip`);
const zipLocation = path.join(
app.getPath("userData"),
`${crypto.randomUUID()}.zip`
await tar.create(
{
gzip: false,
file: tarLocation,
cwd: backupPath,
},
["."]
);
const output = fs.createWriteStream(zipLocation);
output.on("close", () => {
cb(zipLocation);
});
output.on("error", (err) => {
logger.error("Failed to compress folder", err);
throw err;
});
archive.pipe(output);
archive.directory(backupPath, false);
archive.finalize();
return tarLocation;
};
const uploadSaveGame = async (
@ -50,49 +34,51 @@ const uploadSaveGame = async (
objectId: string,
shop: GameShop
) => {
compressBackupToArtifact(shop, objectId, (zipLocation) => {
fs.stat(zipLocation, async (err, stat) => {
const bundleLocation = await bundleBackup(shop, objectId);
fs.stat(bundleLocation, async (err, stat) => {
if (err) {
logger.error("Failed to get zip file stats", err);
throw err;
}
const { uploadUrl } = await HydraApi.post<{
id: string;
uploadUrl: string;
}>("/profile/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
homeDir: app.getPath("home"),
platform: os.platform(),
});
fs.readFile(bundleLocation, async (err, fileBuffer) => {
if (err) {
logger.error("Failed to get zip file stats", err);
logger.error("Failed to read zip file", err);
throw err;
}
const { uploadUrl } = await HydraApi.post<{
id: string;
uploadUrl: string;
}>("/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
await axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/tar",
},
onUploadProgress: (progressEvent) => {
console.log(progressEvent);
},
});
fs.readFile(zipLocation, async (err, fileBuffer) => {
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
fs.rm(bundleLocation, (err) => {
if (err) {
logger.error("Failed to read zip file", err);
logger.error("Failed to remove tar file", err);
throw err;
}
await axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/zip",
},
onUploadProgress: (progressEvent) => {
console.log(progressEvent);
},
});
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
fs.rm(zipLocation, (err) => {
if (err) {
logger.error("Failed to remove zip file", err);
throw err;
}
});
});
});
});

View file

@ -12,7 +12,7 @@ export interface SearchGamesArgs {
export const convertSteamGameToCatalogueEntry = (
game: SteamGame
): CatalogueEntry => ({
objectID: String(game.id),
objectId: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: steamUrlBuilder.library(String(game.id)),

View file

@ -10,14 +10,14 @@ import { steamUrlBuilder } from "@shared";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
title: string,
shop: GameShop
) => {
return gameRepository
.update(
{
objectID,
objectID: objectId,
},
{
shop,
@ -27,23 +27,25 @@ const addGameToLibrary = async (
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gameRepository.insert({
title,
iconUrl,
objectID,
objectID: objectId,
shop,
});
}
const game = await gameRepository.findOne({ where: { objectID } });
const game = await gameRepository.findOne({
where: { objectID: objectId },
});
createGame(game!).catch(() => {});
});

View file

@ -2,15 +2,15 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getGameByObjectID = async (
const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string
objectId: string
) =>
gameRepository.findOne({
where: {
objectID,
objectID: objectId,
isDeleted: false,
},
});
registerEvent("getGameByObjectID", getGameByObjectID);
registerEvent("getGameByObjectId", getGameByObjectId);

View file

@ -14,7 +14,7 @@ const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload
) => {
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
const { objectId, title, shop, downloadPath, downloader, uri } = payload;
return dataSource.transaction(async (transactionalEntityManager) => {
const gameRepository = transactionalEntityManager.getRepository(Game);
@ -23,7 +23,7 @@ const startGameDownload = async (
const game = await gameRepository.findOne({
where: {
objectID,
objectID: objectId,
shop,
},
});
@ -51,18 +51,18 @@ const startGameDownload = async (
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gameRepository.insert({
title,
iconUrl,
objectID,
objectID: objectId,
downloader,
shop,
status: "active",
@ -73,7 +73,7 @@ const startGameDownload = async (
const updatedGame = await gameRepository.findOne({
where: {
objectID,
objectID: objectId,
},
});

View file

@ -2,7 +2,7 @@ import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types";
import type { GameRunning } from "@types";
import { PythonInstance } from "./download";
import { Game } from "@main/entity";

View file

@ -17,7 +17,7 @@ export const requestSteam250 = async (path: string) => {
return {
title: $title.textContent,
objectID: steamGameUrl.split("/").pop(),
objectId: steamGameUrl.split("/").pop(),
} as Steam250Game;
})
.filter((game) => game != null);
@ -38,7 +38,7 @@ export const getSteam250List = async () => {
).flat();
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
if (item) map.set(item.objectID, item);
if (item) map.set(item.objectId, item);
return map;
}, new Map());

View file

@ -21,7 +21,7 @@ export interface SteamGridGameResponse {
}
export const getSteamGridData = async (
objectID: string,
objectId: string,
path: string,
shop: GameShop,
params: Record<string, string> = {}
@ -33,7 +33,7 @@ export const getSteamGridData = async (
}
const response = await axios.get(
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectId}?${searchParams.toString()}`,
{
headers: {
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
@ -59,10 +59,10 @@ export const getSteamGridGameById = async (
return response.data;
};
export const getSteamGameClientIcon = async (objectID: string) => {
export const getSteamGameClientIcon = async (objectId: string) => {
const {
data: { id: steamGridGameId },
} = await getSteamGridData(objectID, "games", "steam");
} = await getSteamGridData(objectId, "games", "steam");
const steamGridGame = await getSteamGridGameById(steamGridGameId);
return steamGridGame.data.platforms.steam.metadata.clienticon;

View file

@ -12,11 +12,11 @@ export interface SteamAppDetailsResponse {
}
export const getSteamAppDetails = async (
objectID: string,
objectId: string,
language: string
) => {
const searchParams = new URLSearchParams({
appids: objectID,
appids: objectId,
l: language,
});
@ -25,7 +25,7 @@ export const getSteamAppDetails = async (
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
)
.then((response) => {
if (response.data[objectID].success) return response.data[objectID].data;
if (response.data[objectId].success) return response.data[objectId].data;
return null;
})
.catch((err) => {

View file

@ -28,20 +28,18 @@ export const backupGame = ({
title,
backupPath,
preview = false,
winePrefix,
}: {
title: string;
backupPath: string;
preview?: boolean;
winePrefix?: string;
}) => {
const args = ["backup", title, "--api", "--force"];
if (preview) {
args.push("--preview");
}
if (backupPath) {
args.push("--path", backupPath);
}
if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath);
if (winePrefix) args.push("--wine-prefix", winePrefix);
const result = cp.execFileSync(binaryPath, args);
@ -59,3 +57,5 @@ export const restoreBackup = (backupPath: string) => {
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};
// --wine-prefix

View file

@ -38,13 +38,13 @@ contextBridge.exposeInMainWorld("electron", {
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
getGames: (take?: number, prevCursor?: number) =>
ipcRenderer.invoke("getGames", take, prevCursor),
getHowLongToBeat: (objectId: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectId, shop, title),
getGames: (take?: number, skip?: number) =>
ipcRenderer.invoke("getGames", take, skip),
searchGameRepacks: (query: string) =>
ipcRenderer.invoke("searchGameRepacks", query),
getGameStats: (objectId: string, shop: GameShop) =>
@ -65,8 +65,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("deleteDownloadSource", id),
/* Library */
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectId, title, shop),
createGameShortcut: (id: number) =>
ipcRenderer.invoke("createGameShortcut", id),
updateExecutablePath: (id: number, executablePath: string) =>
@ -88,8 +88,8 @@ contextBridge.exposeInMainWorld("electron", {
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
deleteGameFolder: (gameId: number) =>
ipcRenderer.invoke("deleteGameFolder", gameId),
getGameByObjectID: (objectID: string) =>
ipcRenderer.invoke("getGameByObjectID", objectID),
getGameByObjectId: (objectId: string) =>
ipcRenderer.invoke("getGameByObjectId", objectId),
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]

View file

@ -26,6 +26,10 @@ globalStyle("::-webkit-scrollbar-thumb", {
borderRadius: "24px",
});
globalStyle("::-webkit-scrollbar-thumb:hover", {
backgroundColor: "rgba(255, 255, 255, 0.16)",
});
globalStyle("html, body, #root, main", {
height: "100%",
});

View file

@ -44,7 +44,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectID, game.shop).then((stats) => {
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
setStats(stats);
});
}

View file

@ -140,7 +140,10 @@ export function Sidebar() {
event: React.MouseEvent,
game: LibraryGame
) => {
const path = buildGameDetailsPath(game);
const path = buildGameDetailsPath({
...game,
objectId: game.objectID,
});
if (path !== location.pathname) {
navigate(path);
}

View file

@ -31,7 +31,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
gameTitle: "",
isGameRunning: false,
isLoading: false,
objectID: undefined,
objectId: undefined,
gameColor: "",
showRepacksModal: false,
showGameOptionsModal: false,
@ -97,7 +97,7 @@ export function GameDetailsContextProvider({
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectID(objectId!)
.getGameByObjectId(objectId!)
.then((result) => setGame(result));
}, [setGame, objectId]);
@ -199,7 +199,7 @@ export function GameDetailsContextProvider({
gameTitle,
isGameRunning,
isLoading,
objectID: objectId,
objectId,
gameColor,
showGameOptionsModal,
showRepacksModal,

View file

@ -14,7 +14,7 @@ export interface GameDetailsContext {
gameTitle: string;
isGameRunning: boolean;
isLoading: boolean;
objectID: string | undefined;
objectId: string | undefined;
gameColor: string;
showRepacksModal: boolean;
showGameOptionsModal: boolean;

View file

@ -50,6 +50,7 @@ export function RepacksContextProvider({ children }: RepacksContextProps) {
}, []);
useEffect(() => {
console.log("CALLED");
indexRepacks();
}, [indexRepacks]);

View file

@ -51,27 +51,24 @@ declare global {
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
getGameShopDetails: (
objectID: string,
objectId: string,
shop: GameShop,
language: string
) => Promise<ShopDetails | null>;
getRandomGame: () => Promise<Steam250Game>;
getHowLongToBeat: (
objectID: string,
objectId: string,
shop: GameShop,
title: string
) => Promise<HowLongToBeatCategory[] | null>;
getGames: (
take?: number,
prevCursor?: number
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
/* Library */
addGameToLibrary: (
objectID: string,
objectId: string,
title: string,
shop: GameShop
) => Promise<void>;
@ -87,7 +84,7 @@ declare global {
removeGameFromLibrary: (gameId: number) => Promise<void>;
removeGame: (gameId: number) => Promise<void>;
deleteGameFolder: (gameId: number) => Promise<unknown>;
getGameByObjectID: (objectID: string) => Promise<Game | null>;
getGameByObjectId: (objectId: string) => Promise<Game | null>;
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]

View file

@ -27,11 +27,11 @@ export const getSteamLanguage = (language: string) => {
};
export const buildGameDetailsPath = (
game: { shop: GameShop; objectID: string; title: string },
game: { shop: GameShop; objectId: string; title: string },
params: Record<string, string> = {}
) => {
const searchParams = new URLSearchParams({ title: game.title, ...params });
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
return `/game/${game.shop}/${game.objectId}?${searchParams.toString()}`;
};
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>

View file

@ -64,7 +64,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/" Component={Home} />
<Route path="/catalogue" Component={Catalogue} />
<Route path="/downloads" Component={Downloads} />
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/game/:shop/:objectId" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />

View file

@ -24,12 +24,10 @@ export function Catalogue() {
const contentRef = useRef<HTMLElement>(null);
const cursorRef = useRef<number>(0);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const cursor = Number(searchParams.get("cursor") ?? 0);
const skip = Number(searchParams.get("skip") ?? 0);
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
@ -42,11 +40,10 @@ export function Catalogue() {
setSearchResults([]);
window.electron
.getGames(24, cursor)
.then(({ results, cursor }) => {
.getGames(24, skip)
.then((results) => {
return new Promise((resolve) => {
setTimeout(() => {
cursorRef.current = cursor;
setSearchResults(results);
resolve(null);
}, 500);
@ -55,11 +52,11 @@ export function Catalogue() {
.finally(() => {
setIsLoading(false);
});
}, [dispatch, cursor, searchParams]);
}, [dispatch, skip, searchParams]);
const handleNextPage = () => {
const params = new URLSearchParams({
cursor: cursorRef.current.toString(),
skip: String(skip + 24),
});
navigate(`/catalogue?${params.toString()}`);
@ -80,7 +77,7 @@ export function Catalogue() {
<Button
onClick={() => navigate(-1)}
theme="outline"
disabled={cursor === 0 || isLoading}
disabled={skip === 0 || isLoading}
>
<ArrowLeftIcon />
{t("previous_page")}
@ -103,7 +100,7 @@ export function Catalogue() {
<>
{searchResults.map((game) => (
<GameCard
key={game.objectID}
key={game.objectId}
game={game}
onClick={() => handleGameClick(game)}
/>

View file

@ -93,6 +93,7 @@ export const downloadRightContent = style({
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
background: "linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%)",
});
export const downloadActions = style({

View file

@ -227,7 +227,14 @@ export function DownloadGroup({
<button
type="button"
className={styles.downloadTitle}
onClick={() => navigate(buildGameDetailsPath(game))}
onClick={() =>
navigate(
buildGameDetailsPath({
...game,
objectId: game.objectID,
})
)
}
>
{game.title}
</button>

View file

@ -1,4 +1,4 @@
import { Modal, ModalProps, TextField } from "@renderer/components";
import { Modal, ModalProps } from "@renderer/components";
import { useContext, useMemo } from "react";
import { cloudSyncContext } from "@renderer/context";
@ -11,6 +11,8 @@ export function CloudSyncFilesModal({
}: CloudSyncFilesModalProps) {
const { backupPreview } = useContext(cloudSyncContext);
console.log(backupPreview);
const files = useMemo(() => {
if (!backupPreview) {
return [];
@ -51,22 +53,24 @@ export function CloudSyncFilesModal({
))}
</div> */}
<ul
style={{
margin: 0,
padding: 0,
listStyle: "none",
gap: 16,
display: "flex",
flexDirection: "column",
}}
>
{files.map((file) => (
<li key={file.path}>
<TextField value={file.path} readOnly />
</li>
))}
</ul>
<table>
<thead>
<tr>
<th style={{ textAlign: "left" }}>Arquivo</th>
<th style={{ textAlign: "left" }}>Hash</th>
<th style={{ textAlign: "left" }}>Tamanho</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.path}>
<td style={{ textAlign: "left" }}>{file.path}</td>
<td style={{ textAlign: "left" }}>{file.change}</td>
<td style={{ textAlign: "left" }}>{file.path}</td>
</tr>
))}
</tbody>
</table>
</Modal>
);
}

View file

@ -43,7 +43,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
setShowCloudSyncFilesModal,
} = useContext(cloudSyncContext);
const { objectID, shop, gameTitle } = useContext(gameDetailsContext);
const { objectId, shop, gameTitle } = useContext(gameDetailsContext);
const { showSuccessToast, showErrorToast } = useToast();
@ -63,13 +63,13 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
useEffect(() => {
gameBackupsTable
.where({ shop: shop, objectId: objectID })
.where({ shop: shop, objectId })
.last()
.then((lastBackup) => setLastBackup(lastBackup || null));
const removeBackupDownloadProgressListener =
window.electron.onBackupDownloadProgress(
objectID!,
objectId!,
shop,
(progressEvent) => {
setBackupDownloadProgress(progressEvent);
@ -79,7 +79,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
return () => {
removeBackupDownloadProgressListener();
};
}, [backupPreview, objectID, shop]);
}, [backupPreview, objectId, shop]);
const handleBackupInstallClick = async (artifactId: string) => {
setBackupDownloadProgress(null);

View file

@ -26,7 +26,7 @@ export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const {
objectID,
objectId,
shopDetails,
game,
gameColor,
@ -42,7 +42,7 @@ export function GameDetailsContent() {
const [backdropOpactiy, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => {
const output = await average(steamUrlBuilder.libraryHero(objectID!), {
const output = await average(steamUrlBuilder.libraryHero(objectId!), {
amount: 1,
format: "hex",
});
@ -56,7 +56,7 @@ export function GameDetailsContent() {
useEffect(() => {
setBackdropOpacity(1);
}, [objectID]);
}, [objectId]);
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT;
@ -90,7 +90,7 @@ export function GameDetailsContent() {
return (
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
<img
src={steamUrlBuilder.libraryHero(objectID!)}
src={steamUrlBuilder.libraryHero(objectId!)}
className={styles.heroImage}
alt={game?.title}
onLoad={handleHeroLoad}
@ -116,7 +116,7 @@ export function GameDetailsContent() {
>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
src={steamUrlBuilder.logo(objectId!)}
className={styles.gameLogo}
alt={game?.title}
/>

View file

@ -33,7 +33,7 @@ export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [randomizerLocked, setRandomizerLocked] = useState(false);
const { objectID, shop } = useParams();
const { objectId, shop } = useParams();
const [searchParams] = useSearchParams();
const fromRandomizer = searchParams.get("fromRandomizer");
@ -50,7 +50,7 @@ export function GameDetails() {
window.electron.getRandomGame().then((randomGame) => {
setRandomGame(randomGame);
});
}, [objectID]);
}, [objectId]);
const handleRandomizerClick = () => {
if (randomGame) {
@ -82,7 +82,7 @@ export function GameDetails() {
<GameDetailsContextProvider
gameTitle={gameTitle!}
shop={shop! as GameShop}
objectId={objectID!}
objectId={objectId!}
>
<GameDetailsContextConsumer>
{({
@ -105,7 +105,7 @@ export function GameDetails() {
) => {
await startDownload({
repackId: repack.id,
objectID: objectID!,
objectId: objectId!,
title: gameTitle,
downloader,
shop: shop as GameShop,
@ -125,7 +125,7 @@ export function GameDetails() {
return (
<CloudSyncContextProvider
objectId={objectID!}
objectId={objectId!}
shop={shop! as GameShop}
>
<CloudSyncContextConsumer>

View file

@ -18,7 +18,7 @@ export function HeroPanelActions() {
game,
repacks,
isGameRunning,
objectID,
objectId,
gameTitle,
setShowGameOptionsModal,
setShowRepacksModal,
@ -39,7 +39,7 @@ export function HeroPanelActions() {
setToggleLibraryGameDisabled(true);
try {
await window.electron.addGameToLibrary(objectID!, gameTitle, "steam");
await window.electron.addGameToLibrary(objectId!, gameTitle, "steam");
updateLibrary();
updateGame();

View file

@ -24,11 +24,11 @@ export function Sidebar() {
const { numberFormatter } = useFormat();
// useEffect(() => {
// if (objectID) {
// if (objectId) {
// setHowLongToBeat({ isLoading: true, data: null });
// window.electron
// .getHowLongToBeat(objectID, "steam", gameTitle)
// .getHowLongToBeat(objectId, "steam", gameTitle)
// .then((howLongToBeat) => {
// setHowLongToBeat({ isLoading: false, data: howLongToBeat });
// })
@ -36,7 +36,7 @@ export function Sidebar() {
// setHowLongToBeat({ isLoading: false, data: null });
// });
// }
// }, [objectID, gameTitle]);
// }, [objectId, gameTitle]);
return (
<aside className={styles.contentSidebar}>

View file

@ -186,7 +186,7 @@ export function Home() {
))
: catalogue[currentCatalogueCategory].map((result) => (
<GameCard
key={result.objectID}
key={result.objectId}
game={result}
onClick={() => navigate(buildGameDetailsPath(result))}
/>

View file

@ -115,7 +115,7 @@ export function SearchResults() {
<>
{searchResults.map((game) => (
<GameCard
key={game.objectID}
key={game.objectId}
game={game}
onClick={() => handleGameClick(game)}
/>

View file

@ -15,7 +15,7 @@ export const gameCover = style({
height: "172%",
position: "absolute",
background:
"linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%);",
"linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%)",
transition: "all ease 0.3s",
transform: "translateY(-36%)",
opacity: "0.5",
@ -189,3 +189,15 @@ export const defaultAvatarWrapper = style({
border: `solid 1px ${vars.color.border}`,
borderRadius: "4px",
});
export const achievementsProgressBar = style({
width: "100%",
height: "4px",
transition: "all ease 0.2s",
"::-webkit-progress-bar": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
});

View file

@ -7,7 +7,7 @@ import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { ClockIcon, TelescopeIcon } from "@primer/octicons-react";
import { ClockIcon, TelescopeIcon, TrophyIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile";
@ -15,7 +15,10 @@ import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserGame } from "@types";
import { buildGameDetailsPath } from "@renderer/helpers";
import {
buildGameDetailsPath,
formatDownloadProgress,
} from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
export function ProfileContent() {
@ -44,7 +47,7 @@ export function ProfileContent() {
const buildUserGameDetailsPath = (game: UserGame) =>
buildGameDetailsPath({
...game,
objectID: game.objectId,
objectId: game.objectId,
});
const formatPlayTime = useCallback(
@ -115,6 +118,7 @@ export function ProfileContent() {
borderRadius: 4,
overflow: "hidden",
position: "relative",
display: "flex",
}}
className={styles.game}
>
@ -126,23 +130,85 @@ export function ProfileContent() {
className={styles.gameCover}
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div style={{ position: "absolute", padding: 4 }}>
<div
style={{
position: "absolute",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "space-between",
height: "100%",
width: "100%",
background:
"linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%)",
padding: 8,
}}
>
<small
style={{
backgroundColor: vars.color.background,
color: vars.color.muted,
// border: `solid 1px ${vars.color.border}`,
border: `solid 1px ${vars.color.border}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 4px",
padding: "4px",
}}
>
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
<div
style={{
color: "white",
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<TrophyIcon size={13} />
<span>
{game.unlockedAchievementCount} /{" "}
{game.achievementCount}
</span>
</div>
<span>
{formatDownloadProgress(
game.unlockedAchievementCount /
game.achievementCount
)}
</span>
</div>
<progress
max={1}
value={
game.unlockedAchievementCount /
game.achievementCount
}
className={styles.achievementsProgressBar}
/>
</div>
</div>
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
@ -178,6 +244,7 @@ export function ProfileContent() {
userStats,
numberFormatter,
t,
formatPlayTime,
navigate,
]);

View file

@ -37,7 +37,7 @@ export function RecentGamesBox() {
const buildUserGameDetailsPath = (game: UserGame) =>
buildGameDetailsPath({
...game,
objectID: game.objectId,
objectId: game.objectId,
});
if (!userProfile?.recentGames.length) return null;

View file

@ -70,7 +70,7 @@ export const heroPanel = style({
export const userInformation = style({
display: "flex",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 6}px ${SPACING_UNIT * 3}px`,
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
});

View file

@ -1,4 +1,4 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import * as styles from "./profile-hero.css";
import { useCallback, useContext, useMemo, useState } from "react";
@ -10,6 +10,7 @@ import {
PersonAddIcon,
PersonIcon,
SignOutIcon,
UploadIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
@ -36,8 +37,7 @@ export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const { isMe, heroBackground, getUserProfile, userProfile } =
useContext(userProfileContext);
const { isMe, getUserProfile, userProfile } = useContext(userProfileContext);
const {
signOut,
updateFriendRequestState,
@ -48,6 +48,8 @@ export function ProfileHero() {
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [hero, setHero] = useState("");
const { t } = useTranslation("user_profile");
const { formatDistance } = useDate();
@ -124,6 +126,7 @@ export function ProfileHero() {
theme="outline"
onClick={() => setShowEditProfileModal(true)}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
>
<PencilIcon />
{t("edit_profile")}
@ -148,6 +151,7 @@ export function ProfileHero() {
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
>
<PersonAddIcon />
{t("add_friend")}
@ -198,6 +202,7 @@ export function ProfileHero() {
handleFriendAction(userProfile.relation!.BId, "CANCEL")
}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
>
<XCircleFillIcon /> {t("cancel_request")}
</Button>
@ -212,11 +217,12 @@ export function ProfileHero() {
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
>
<CheckCircleFillIcon /> {t("accept_request")}
</Button>
<Button
theme="outline"
theme="danger"
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "REFUSED")
}
@ -246,7 +252,6 @@ export function ProfileHero() {
if (gameRunning)
return {
...gameRunning,
objectId: gameRunning.objectID,
sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000,
};
@ -255,6 +260,43 @@ export function ProfileHero() {
return userProfile?.currentGame;
}, [isMe, userProfile, gameRunning]);
const handleChangeCoverClick = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
const { imagePath } = await window.electron
.processProfileImage(path)
.catch(() => {
showErrorToast(t("image_process_failure"));
return { imagePath: null };
});
console.log("imagePath", imagePath);
setHero(imagePath);
// onChange(imagePath);
}
};
const getImageUrl = () => {
if (hero) return `local:${hero}`;
// if (userDetails?.profileImageUrl) return userDetails.profileImageUrl;
return null;
};
// const imageUrl = getImageUrl();
return (
<>
{/* <ConfirmationModal
@ -270,12 +312,9 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)}
/>
<section
className={styles.profileContentBox}
// style={{ background: heroBackground }}
>
<section className={styles.profileContentBox}>
<img
src="https://wallpapers.com/images/featured/cyberpunk-anime-dfyw8eb7bqkw278u.jpg"
src={getImageUrl()}
alt=""
style={{
position: "absolute",
@ -286,7 +325,8 @@ export function ProfileHero() {
/>
<div
style={{
background: heroBackground,
background:
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
width: "100%",
height: "100%",
zIndex: 1,
@ -324,7 +364,7 @@ export function ProfileHero() {
<Link
to={buildGameDetailsPath({
...currentGame,
objectID: currentGame.objectId,
objectId: currentGame.objectID,
})}
>
{currentGame.title}
@ -345,12 +385,30 @@ export function ProfileHero() {
</div>
)}
</div>
<Button
theme="outline"
style={{
position: "absolute",
top: 16,
right: 16,
borderColor: vars.color.body,
}}
onClick={handleChangeCoverClick}
>
<UploadIcon />
Upload cover
</Button>
</div>
</div>
<div
className={styles.heroPanel}
style={{ background: heroBackground }}
// style={{ background: heroBackground }}
style={{
background:
"linear-gradient(135deg, rgb(0 0 0 / 70%), rgb(0 0 0 / 60%))",
}}
>
<div
style={{

View file

@ -5,14 +5,14 @@ import flexSearch from "flexsearch";
const index = new flexSearch.Index();
const state = {
repacks: [] as any[],
};
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
uris: string;
}
const state = {
repacks: [] as SerializedGameRepack[],
};
self.onmessage = async (
event: MessageEvent<[string, string] | "INDEX_REPACKS">
) => {

View file

@ -91,14 +91,14 @@ export const getDownloadersForUris = (uris: string[]) => {
};
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
cover: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/library_600x900.jpg`,
icon: (objectID: string, clientIcon: string) =>
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`,
library: (objectId: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/header.jpg`,
libraryHero: (objectId: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/library_hero.jpg`,
logo: (objectId: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/logo.png`,
cover: (objectId: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/library_600x900.jpg`,
icon: (objectId: string, clientIcon: string) =>
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`,
};

View file

@ -29,7 +29,7 @@ export interface GameRepack {
}
export type ShopDetails = SteamAppDetails & {
objectID: string;
objectId: string;
};
export interface TorrentFile {
@ -39,7 +39,7 @@ export interface TorrentFile {
/* Used by the catalogue */
export interface CatalogueEntry {
objectID: string;
objectId: string;
shop: GameShop;
title: string;
/* Epic Games covers cannot be guessed with objectID */
@ -54,6 +54,8 @@ export interface UserGame {
cover: string;
playTimeInSeconds: number;
lastTimePlayed: Date | null;
unlockedAchievementCount: number;
achievementCount: number;
}
export interface DownloadQueue {
@ -126,7 +128,7 @@ export interface HowLongToBeatCategory {
export interface Steam250Game {
title: string;
objectID: string;
objectId: string;
}
export interface SteamGame {
@ -142,7 +144,7 @@ export type AppUpdaterEvent =
/* Events */
export interface StartGameDownloadPayload {
repackId: number;
objectID: string;
objectId: string;
title: string;
shop: GameShop;
uri: string;
@ -187,7 +189,7 @@ export interface UserRelation {
updatedAt: string;
}
export interface UserProfileCurrentGame extends Omit<GameRunning, "objectID"> {
export interface UserProfileCurrentGame extends Omit<GameRunning, "objectId"> {
objectId: string;
sessionDurationInSeconds: number;
}