mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-03 08:43:48 +03:00
Merge pull request #601 from hydralauncher/feature/sync-library
feat: sync library with api
This commit is contained in:
commit
b337fd8d64
@ -224,5 +224,15 @@
|
|||||||
},
|
},
|
||||||
"forms": {
|
"forms": {
|
||||||
"toggle_password_visibility": "Toggle password visibility"
|
"toggle_password_visibility": "Toggle password visibility"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"amount_hours": "{{amount}} hours",
|
||||||
|
"amount_minutes": "{{amount}} minutes",
|
||||||
|
"play_time": "Played for {{amount}}",
|
||||||
|
"last_time_played": "Last played {{period}}",
|
||||||
|
"sign_out": "Sign out",
|
||||||
|
"activity": "Recent Activity",
|
||||||
|
"library": "Library",
|
||||||
|
"total_play_time": "Total playtime: {{amount}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -225,5 +225,15 @@
|
|||||||
},
|
},
|
||||||
"forms": {
|
"forms": {
|
||||||
"toggle_password_visibility": "Alternar visibilidade da senha"
|
"toggle_password_visibility": "Alternar visibilidade da senha"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"amount_hours": "{{amount}} horas",
|
||||||
|
"amount_minutes": "{{amount}} minutos",
|
||||||
|
"play_time": "Jogado por {{amount}}",
|
||||||
|
"last_time_played": "Jogou {{period}}",
|
||||||
|
"sign_out": "Sair da conta",
|
||||||
|
"activity": "Atividade Recente",
|
||||||
|
"library": "Biblioteca",
|
||||||
|
"total_play_time": "Tempo total de jogo: {{amount}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,9 @@ export class Game {
|
|||||||
@Column("text", { unique: true })
|
@Column("text", { unique: true })
|
||||||
objectID: string;
|
objectID: string;
|
||||||
|
|
||||||
|
@Column("text", { unique: true, nullable: true })
|
||||||
|
remoteId: string | null;
|
||||||
|
|
||||||
@Column("text")
|
@Column("text")
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
@ -11,6 +11,15 @@ export class UserAuth {
|
|||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
|
@Column("text", { default: "" })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column("text", { default: "" })
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@Column("text", { nullable: true })
|
||||||
|
profileImageUrl: string | null;
|
||||||
|
|
||||||
@Column("text", { default: "" })
|
@Column("text", { default: "" })
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|
||||||
|
12
src/main/events/auth/signout.ts
Normal file
12
src/main/events/auth/signout.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { userAuthRepository } from "@main/repository";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { HydraApi } from "@main/services/hydra-api";
|
||||||
|
|
||||||
|
const signOut = async (_event: Electron.IpcMainInvokeEvent): Promise<void> => {
|
||||||
|
await Promise.all([
|
||||||
|
userAuthRepository.delete({ id: 1 }),
|
||||||
|
HydraApi.post("/auth/logout"),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("signOut", signOut);
|
@ -39,6 +39,9 @@ import "./download-sources/validate-download-source";
|
|||||||
import "./download-sources/add-download-source";
|
import "./download-sources/add-download-source";
|
||||||
import "./download-sources/remove-download-source";
|
import "./download-sources/remove-download-source";
|
||||||
import "./download-sources/sync-download-sources";
|
import "./download-sources/sync-download-sources";
|
||||||
|
import "./auth/signout";
|
||||||
|
import "./user/get-user";
|
||||||
|
import "./profile/get-me";
|
||||||
|
|
||||||
ipcMain.handle("ping", () => "pong");
|
ipcMain.handle("ping", () => "pong");
|
||||||
ipcMain.handle("getVersion", () => app.getVersion());
|
ipcMain.handle("getVersion", () => app.getVersion());
|
||||||
|
@ -6,6 +6,7 @@ import type { GameShop } from "@types";
|
|||||||
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
import { getFileBase64, getSteamAppAsset } from "@main/helpers";
|
||||||
|
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
import { createGame } from "@main/services/library-sync";
|
||||||
|
|
||||||
const addGameToLibrary = async (
|
const addGameToLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -49,6 +50,21 @@ const addGameToLibrary = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const game = await gameRepository.findOne({ where: { objectID } });
|
||||||
|
|
||||||
|
createGame(game!).then((response) => {
|
||||||
|
const {
|
||||||
|
id: remoteId,
|
||||||
|
playTimeInMilliseconds,
|
||||||
|
lastTimePlayed,
|
||||||
|
} = response.data;
|
||||||
|
|
||||||
|
gameRepository.update(
|
||||||
|
{ objectID },
|
||||||
|
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { gameRepository } from "../../repository";
|
import { gameRepository } from "../../repository";
|
||||||
|
import { HydraApi } from "@main/services/hydra-api";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
const removeGameFromLibrary = async (
|
const removeGameFromLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -9,6 +11,18 @@ const removeGameFromLibrary = async (
|
|||||||
{ id: gameId },
|
{ id: gameId },
|
||||||
{ isDeleted: true, executablePath: null }
|
{ isDeleted: true, executablePath: null }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
removeRemoveGameFromLibrary(gameId).catch((err) => {
|
||||||
|
logger.error("removeRemoveGameFromLibrary", err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRemoveGameFromLibrary = async (gameId: number) => {
|
||||||
|
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||||
|
|
||||||
|
if (game?.remoteId) {
|
||||||
|
HydraApi.delete(`/games/${game.remoteId}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("removeGameFromLibrary", removeGameFromLibrary);
|
registerEvent("removeGameFromLibrary", removeGameFromLibrary);
|
||||||
|
32
src/main/events/profile/get-me.ts
Normal file
32
src/main/events/profile/get-me.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { HydraApi } from "@main/services/hydra-api";
|
||||||
|
import { UserProfile } from "@types";
|
||||||
|
import { userAuthRepository } from "@main/repository";
|
||||||
|
import { logger } from "@main/services";
|
||||||
|
|
||||||
|
const getMe = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent
|
||||||
|
): Promise<UserProfile | null> => {
|
||||||
|
return HydraApi.get(`/profile/me`)
|
||||||
|
.then((response) => {
|
||||||
|
const me = response.data;
|
||||||
|
|
||||||
|
userAuthRepository.upsert(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
displayName: me.displayName,
|
||||||
|
profileImageUrl: me.profileImageUrl,
|
||||||
|
userId: me.id,
|
||||||
|
},
|
||||||
|
["id"]
|
||||||
|
);
|
||||||
|
|
||||||
|
return me;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error("getMe", err);
|
||||||
|
return userAuthRepository.findOne({ where: { id: 1 } });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getMe", getMe);
|
@ -12,6 +12,7 @@ import { DownloadManager } from "@main/services";
|
|||||||
|
|
||||||
import { Not } from "typeorm";
|
import { Not } from "typeorm";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
import { createGame } from "@main/services/library-sync";
|
||||||
|
|
||||||
const startGameDownload = async (
|
const startGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
@ -94,6 +95,19 @@ const startGameDownload = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createGame(updatedGame!).then((response) => {
|
||||||
|
const {
|
||||||
|
id: remoteId,
|
||||||
|
playTimeInMilliseconds,
|
||||||
|
lastTimePlayed,
|
||||||
|
} = response.data;
|
||||||
|
|
||||||
|
gameRepository.update(
|
||||||
|
{ objectID },
|
||||||
|
{ remoteId, playTimeInMilliseconds, lastTimePlayed }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
||||||
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
||||||
|
|
||||||
|
56
src/main/events/user/get-user.ts
Normal file
56
src/main/events/user/get-user.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { HydraApi } from "@main/services/hydra-api";
|
||||||
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
import { UserProfile } from "@types";
|
||||||
|
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
||||||
|
import { getSteamAppAsset } from "@main/helpers";
|
||||||
|
|
||||||
|
const getUser = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
userId: string
|
||||||
|
): Promise<UserProfile | null> => {
|
||||||
|
try {
|
||||||
|
const response = await HydraApi.get(`/user/${userId}`);
|
||||||
|
const profile = response.data;
|
||||||
|
|
||||||
|
const recentGames = await Promise.all(
|
||||||
|
profile.recentGames.map(async (game) => {
|
||||||
|
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||||
|
name: "getById",
|
||||||
|
});
|
||||||
|
const iconUrl = steamGame?.clientIcon
|
||||||
|
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
...convertSteamGameToCatalogueEntry(steamGame),
|
||||||
|
iconUrl,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const libraryGames = await Promise.all(
|
||||||
|
profile.libraryGames.map(async (game) => {
|
||||||
|
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||||
|
name: "getById",
|
||||||
|
});
|
||||||
|
const iconUrl = steamGame?.clientIcon
|
||||||
|
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...game,
|
||||||
|
...convertSteamGameToCatalogueEntry(steamGame),
|
||||||
|
iconUrl,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...profile, libraryGames, recentGames };
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getUser", getUser);
|
@ -7,6 +7,7 @@ import { DownloadManager, logger, WindowManager } from "@main/services";
|
|||||||
import { dataSource } from "@main/data-source";
|
import { dataSource } from "@main/data-source";
|
||||||
import * as resources from "@locales";
|
import * as resources from "@locales";
|
||||||
import { userPreferencesRepository } from "@main/repository";
|
import { userPreferencesRepository } from "@main/repository";
|
||||||
|
import { HydraApi } from "./services/hydra-api";
|
||||||
|
|
||||||
const { autoUpdater } = updater;
|
const { autoUpdater } = updater;
|
||||||
|
|
||||||
@ -83,7 +84,13 @@ app.on("second-instance", (_event, commandLine) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [, path] = commandLine.pop()?.split("://") ?? [];
|
const [, path] = commandLine.pop()?.split("://") ?? [];
|
||||||
if (path) WindowManager.redirect(path);
|
if (path) {
|
||||||
|
if (path.startsWith("auth")) {
|
||||||
|
HydraApi.handleExternalAuth(path);
|
||||||
|
} else {
|
||||||
|
WindowManager.redirect(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("open-url", (_event, url) => {
|
app.on("open-url", (_event, url) => {
|
||||||
|
@ -10,6 +10,7 @@ import { fetchDownloadSourcesAndUpdate } from "./helpers";
|
|||||||
import { publishNewRepacksNotifications } from "./services/notifications";
|
import { publishNewRepacksNotifications } from "./services/notifications";
|
||||||
import { MoreThan } from "typeorm";
|
import { MoreThan } from "typeorm";
|
||||||
import { HydraApi } from "./services/hydra-api";
|
import { HydraApi } from "./services/hydra-api";
|
||||||
|
import { uploadGamesBatch } from "./services/library-sync";
|
||||||
|
|
||||||
startMainLoop();
|
startMainLoop();
|
||||||
|
|
||||||
@ -21,7 +22,9 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
|||||||
if (userPreferences?.realDebridApiToken)
|
if (userPreferences?.realDebridApiToken)
|
||||||
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||||
|
|
||||||
HydraApi.setupApi();
|
HydraApi.setupApi().then(async () => {
|
||||||
|
if (HydraApi.isLoggedIn()) uploadGamesBatch();
|
||||||
|
});
|
||||||
|
|
||||||
const [nextQueueItem] = await downloadQueueRepository.find({
|
const [nextQueueItem] = await downloadQueueRepository.find({
|
||||||
order: {
|
order: {
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { userAuthRepository } from "@main/repository";
|
import { userAuthRepository } from "@main/repository";
|
||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||||
|
import { WindowManager } from "./window-manager";
|
||||||
|
import url from "url";
|
||||||
|
import { uploadGamesBatch } from "./library-sync";
|
||||||
|
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
export class HydraApi {
|
export class HydraApi {
|
||||||
private static instance: AxiosInstance;
|
private static instance: AxiosInstance;
|
||||||
@ -12,11 +17,80 @@ export class HydraApi {
|
|||||||
expirationTimestamp: 0,
|
expirationTimestamp: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static isLoggedIn() {
|
||||||
|
return this.userAuth.authToken !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
static async handleExternalAuth(auth: string) {
|
||||||
|
const { payload } = url.parse(auth, true).query;
|
||||||
|
|
||||||
|
const decodedBase64 = atob(payload as string);
|
||||||
|
const jsonData = JSON.parse(decodedBase64);
|
||||||
|
|
||||||
|
const { accessToken, expiresIn, refreshToken } = jsonData;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const tokenExpirationTimestamp =
|
||||||
|
now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS;
|
||||||
|
|
||||||
|
this.userAuth = {
|
||||||
|
authToken: accessToken,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
expirationTimestamp: tokenExpirationTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
await userAuthRepository.upsert(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
accessToken,
|
||||||
|
tokenExpirationTimestamp,
|
||||||
|
refreshToken,
|
||||||
|
},
|
||||||
|
["id"]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (WindowManager.mainWindow) {
|
||||||
|
WindowManager.mainWindow.webContents.send("on-signin");
|
||||||
|
await clearGamesRemoteIds();
|
||||||
|
uploadGamesBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async setupApi() {
|
static async setupApi() {
|
||||||
this.instance = axios.create({
|
this.instance = axios.create({
|
||||||
baseURL: import.meta.env.MAIN_VITE_API_URL,
|
baseURL: import.meta.env.MAIN_VITE_API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.instance.interceptors.request.use(
|
||||||
|
(request) => {
|
||||||
|
logger.log(" ---- REQUEST -----");
|
||||||
|
logger.log(request.method, request.url, request.data);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
logger.log("request error", error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
logger.log(" ---- RESPONSE -----");
|
||||||
|
logger.log(
|
||||||
|
response.status,
|
||||||
|
response.config.method,
|
||||||
|
response.config.url,
|
||||||
|
response.data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
logger.error("response error", error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const userAuth = await userAuthRepository.findOne({
|
const userAuth = await userAuthRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
});
|
});
|
||||||
@ -29,8 +103,15 @@ export class HydraApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async revalidateAccessTokenIfExpired() {
|
private static async revalidateAccessTokenIfExpired() {
|
||||||
|
if (!this.userAuth.authToken) {
|
||||||
|
userAuthRepository.delete({ id: 1 });
|
||||||
|
logger.error("user is not logged in");
|
||||||
|
throw new Error("user is not logged in");
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (this.userAuth.expirationTimestamp < now.getTime()) {
|
if (this.userAuth.expirationTimestamp < now.getTime()) {
|
||||||
|
try {
|
||||||
const response = await this.instance.post(`/auth/refresh`, {
|
const response = await this.instance.post(`/auth/refresh`, {
|
||||||
refreshToken: this.userAuth.refreshToken,
|
refreshToken: this.userAuth.refreshToken,
|
||||||
});
|
});
|
||||||
@ -51,6 +132,28 @@ export class HydraApi {
|
|||||||
},
|
},
|
||||||
["id"]
|
["id"]
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof AxiosError &&
|
||||||
|
(err?.response?.status === 401 || err?.response?.status === 403)
|
||||||
|
) {
|
||||||
|
this.userAuth = {
|
||||||
|
authToken: "",
|
||||||
|
expirationTimestamp: 0,
|
||||||
|
refreshToken: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
userAuthRepository.delete({ id: 1 });
|
||||||
|
|
||||||
|
if (WindowManager.mainWindow) {
|
||||||
|
WindowManager.mainWindow.webContents.send("on-signout");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("user refresh token expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,13 +175,18 @@ export class HydraApi {
|
|||||||
return this.instance.post(url, data, this.getAxiosConfig());
|
return this.instance.post(url, data, this.getAxiosConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
static async put(url, data?: any) {
|
static async put(url: string, data?: any) {
|
||||||
await this.revalidateAccessTokenIfExpired();
|
await this.revalidateAccessTokenIfExpired();
|
||||||
return this.instance.put(url, data, this.getAxiosConfig());
|
return this.instance.put(url, data, this.getAxiosConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
static async patch(url, data?: any) {
|
static async patch(url: string, data?: any) {
|
||||||
await this.revalidateAccessTokenIfExpired();
|
await this.revalidateAccessTokenIfExpired();
|
||||||
return this.instance.patch(url, data, this.getAxiosConfig());
|
return this.instance.patch(url, data, this.getAxiosConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async delete(url: string) {
|
||||||
|
await this.revalidateAccessTokenIfExpired();
|
||||||
|
return this.instance.delete(url, this.getAxiosConfig());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
5
src/main/services/library-sync/clear-games-remote-id.ts
Normal file
5
src/main/services/library-sync/clear-games-remote-id.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
|
export const clearGamesRemoteIds = () => {
|
||||||
|
return gameRepository.update({}, { remoteId: null });
|
||||||
|
};
|
11
src/main/services/library-sync/create-game.ts
Normal file
11
src/main/services/library-sync/create-game.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Game } from "@main/entity";
|
||||||
|
import { HydraApi } from "../hydra-api";
|
||||||
|
|
||||||
|
export const createGame = async (game: Game) => {
|
||||||
|
return HydraApi.post(`/games`, {
|
||||||
|
objectId: game.objectID,
|
||||||
|
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||||
|
shop: game.shop,
|
||||||
|
lastTimePlayed: game.lastTimePlayed,
|
||||||
|
});
|
||||||
|
};
|
4
src/main/services/library-sync/index.ts
Normal file
4
src/main/services/library-sync/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./merge-with-remote-games";
|
||||||
|
export * from "./upload-games-batch";
|
||||||
|
export * from "./update-game-playtime";
|
||||||
|
export * from "./create-game";
|
69
src/main/services/library-sync/merge-with-remote-games.ts
Normal file
69
src/main/services/library-sync/merge-with-remote-games.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
import { HydraApi } from "../hydra-api";
|
||||||
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
import { getSteamAppAsset } from "@main/helpers";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
export const mergeWithRemoteGames = async () => {
|
||||||
|
try {
|
||||||
|
const games = await HydraApi.get("/games");
|
||||||
|
|
||||||
|
for (const game of games.data) {
|
||||||
|
const localGame = await gameRepository.findOne({
|
||||||
|
where: {
|
||||||
|
objectID: game.objectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (localGame) {
|
||||||
|
const updatedLastTimePlayed =
|
||||||
|
localGame.lastTimePlayed == null ||
|
||||||
|
new Date(game.lastTimePlayed) > localGame.lastTimePlayed
|
||||||
|
? new Date(game.lastTimePlayed)
|
||||||
|
: localGame.lastTimePlayed;
|
||||||
|
|
||||||
|
const updatedPlayTime =
|
||||||
|
localGame.playTimeInMilliseconds < game.playTimeInMilliseconds
|
||||||
|
? game.playTimeInMilliseconds
|
||||||
|
: localGame.playTimeInMilliseconds;
|
||||||
|
|
||||||
|
gameRepository.update(
|
||||||
|
{
|
||||||
|
objectID: game.objectId,
|
||||||
|
shop: "steam",
|
||||||
|
lastTimePlayed: updatedLastTimePlayed,
|
||||||
|
playTimeInMilliseconds: updatedPlayTime,
|
||||||
|
},
|
||||||
|
{ remoteId: game.id }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
|
||||||
|
name: "getById",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (steamGame) {
|
||||||
|
const iconUrl = steamGame?.clientIcon
|
||||||
|
? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
gameRepository.insert({
|
||||||
|
objectID: game.objectId,
|
||||||
|
title: steamGame?.name,
|
||||||
|
remoteId: game.id,
|
||||||
|
shop: game.shop,
|
||||||
|
iconUrl,
|
||||||
|
lastTimePlayed: game.lastTimePlayed,
|
||||||
|
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
logger.error("getRemoteGames", err.response, err.message);
|
||||||
|
} else {
|
||||||
|
logger.error("getRemoteGames", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
13
src/main/services/library-sync/update-game-playtime.ts
Normal file
13
src/main/services/library-sync/update-game-playtime.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Game } from "@main/entity";
|
||||||
|
import { HydraApi } from "../hydra-api";
|
||||||
|
|
||||||
|
export const updateGamePlaytime = async (
|
||||||
|
game: Game,
|
||||||
|
delta: number,
|
||||||
|
lastTimePlayed: Date
|
||||||
|
) => {
|
||||||
|
return HydraApi.put(`/games/${game.remoteId}`, {
|
||||||
|
playTimeDeltaInSeconds: Math.trunc(delta),
|
||||||
|
lastTimePlayed,
|
||||||
|
});
|
||||||
|
};
|
44
src/main/services/library-sync/upload-games-batch.ts
Normal file
44
src/main/services/library-sync/upload-games-batch.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
import { chunk } from "lodash-es";
|
||||||
|
import { IsNull } from "typeorm";
|
||||||
|
import { HydraApi } from "../hydra-api";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
||||||
|
import { WindowManager } from "../window-manager";
|
||||||
|
|
||||||
|
export const uploadGamesBatch = async () => {
|
||||||
|
try {
|
||||||
|
const games = await gameRepository.find({
|
||||||
|
where: { remoteId: IsNull(), isDeleted: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const gamesChunks = chunk(games, 200);
|
||||||
|
|
||||||
|
for (const chunk of gamesChunks) {
|
||||||
|
await HydraApi.post(
|
||||||
|
"/games/batch",
|
||||||
|
chunk.map((game) => {
|
||||||
|
return {
|
||||||
|
objectId: game.objectID,
|
||||||
|
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||||
|
shop: game.shop,
|
||||||
|
lastTimePlayed: game.lastTimePlayed,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mergeWithRemoteGames();
|
||||||
|
|
||||||
|
if (WindowManager.mainWindow)
|
||||||
|
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
logger.error("uploadGamesBatch", err.response, err.message);
|
||||||
|
} else {
|
||||||
|
logger.error("uploadGamesBatch", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -4,8 +4,12 @@ import { IsNull, Not } from "typeorm";
|
|||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
import { getProcesses } from "@main/helpers";
|
import { getProcesses } from "@main/helpers";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
|
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||||
|
|
||||||
const gamesPlaytime = new Map<number, number>();
|
const gamesPlaytime = new Map<
|
||||||
|
number,
|
||||||
|
{ lastTick: number; firstTick: number }
|
||||||
|
>();
|
||||||
|
|
||||||
export const watchProcesses = async () => {
|
export const watchProcesses = async () => {
|
||||||
const games = await gameRepository.find({
|
const games = await gameRepository.find({
|
||||||
@ -37,7 +41,9 @@ export const watchProcesses = async () => {
|
|||||||
|
|
||||||
if (gameProcess) {
|
if (gameProcess) {
|
||||||
if (gamesPlaytime.has(game.id)) {
|
if (gamesPlaytime.has(game.id)) {
|
||||||
const zero = gamesPlaytime.get(game.id) ?? 0;
|
const gamePlaytime = gamesPlaytime.get(game.id)!;
|
||||||
|
|
||||||
|
const zero = gamePlaytime.lastTick;
|
||||||
const delta = performance.now() - zero;
|
const delta = performance.now() - zero;
|
||||||
|
|
||||||
if (WindowManager.mainWindow) {
|
if (WindowManager.mainWindow) {
|
||||||
@ -48,12 +54,45 @@ export const watchProcesses = async () => {
|
|||||||
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
|
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
|
||||||
lastTimePlayed: new Date(),
|
lastTimePlayed: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
gamesPlaytime.set(game.id, {
|
||||||
|
...gamePlaytime,
|
||||||
|
lastTick: performance.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (game.remoteId) {
|
||||||
|
updateGamePlaytime(game, 0, new Date());
|
||||||
|
} else {
|
||||||
|
createGame({ ...game, lastTimePlayed: new Date() }).then(
|
||||||
|
(response) => {
|
||||||
|
const { id: remoteId } = response.data;
|
||||||
|
gameRepository.update({ objectID: game.objectID }, { remoteId });
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
gamesPlaytime.set(game.id, performance.now());
|
gamesPlaytime.set(game.id, {
|
||||||
|
lastTick: performance.now(),
|
||||||
|
firstTick: performance.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (gamesPlaytime.has(game.id)) {
|
} else if (gamesPlaytime.has(game.id)) {
|
||||||
|
const gamePlaytime = gamesPlaytime.get(game.id)!;
|
||||||
gamesPlaytime.delete(game.id);
|
gamesPlaytime.delete(game.id);
|
||||||
|
|
||||||
|
if (game.remoteId) {
|
||||||
|
updateGamePlaytime(
|
||||||
|
game,
|
||||||
|
performance.now() - gamePlaytime.firstTick,
|
||||||
|
game.lastTimePlayed!
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createGame(game).then((response) => {
|
||||||
|
const { id: remoteId } = response.data;
|
||||||
|
gameRepository.update({ objectID: game.objectID }, { remoteId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (WindowManager.mainWindow) {
|
if (WindowManager.mainWindow) {
|
||||||
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
|
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
|
||||||
}
|
}
|
||||||
|
@ -96,6 +96,12 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.on("on-game-close", listener);
|
ipcRenderer.on("on-game-close", listener);
|
||||||
return () => ipcRenderer.removeListener("on-game-close", listener);
|
return () => ipcRenderer.removeListener("on-game-close", listener);
|
||||||
},
|
},
|
||||||
|
onLibraryBatchComplete: (cb: () => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||||
|
ipcRenderer.on("on-library-batch-complete", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("on-library-batch-complete", listener);
|
||||||
|
},
|
||||||
|
|
||||||
/* Hardware */
|
/* Hardware */
|
||||||
getDiskFreeSpace: (path: string) =>
|
getDiskFreeSpace: (path: string) =>
|
||||||
@ -125,4 +131,23 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
},
|
},
|
||||||
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
|
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
|
||||||
restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"),
|
restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"),
|
||||||
|
|
||||||
|
/* Profile */
|
||||||
|
getMe: () => ipcRenderer.invoke("getMe"),
|
||||||
|
|
||||||
|
/* User */
|
||||||
|
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
signOut: () => ipcRenderer.invoke("signOut"),
|
||||||
|
onSignIn: (cb: () => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||||
|
ipcRenderer.on("on-signin", listener);
|
||||||
|
return () => ipcRenderer.removeListener("on-signin", listener);
|
||||||
|
},
|
||||||
|
onSignOut: (cb: () => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||||
|
ipcRenderer.on("on-signout", listener);
|
||||||
|
return () => ipcRenderer.removeListener("on-signout", listener);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<title>Hydra</title>
|
<title>Hydra</title>
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.losbroxas.org https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' data: https://cdn.losbroxas.org https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body style="background-color: #1c1c1c">
|
<body style="background-color: #1c1c1c">
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
useAppSelector,
|
useAppSelector,
|
||||||
useDownload,
|
useDownload,
|
||||||
useLibrary,
|
useLibrary,
|
||||||
|
useUserDetails,
|
||||||
} from "@renderer/hooks";
|
} from "@renderer/hooks";
|
||||||
|
|
||||||
import * as styles from "./app.css";
|
import * as styles from "./app.css";
|
||||||
@ -30,15 +31,19 @@ export function App() {
|
|||||||
|
|
||||||
const { clearDownload, setLastPacket } = useDownload();
|
const { clearDownload, setLastPacket } = useDownload();
|
||||||
|
|
||||||
|
const { updateUser, clearUser } = useUserDetails();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const search = useAppSelector((state) => state.search.value);
|
const search = useAppSelector((state) => state.search.value);
|
||||||
|
|
||||||
const draggingDisabled = useAppSelector(
|
const draggingDisabled = useAppSelector(
|
||||||
(state) => state.window.draggingDisabled
|
(state) => state.window.draggingDisabled
|
||||||
);
|
);
|
||||||
|
|
||||||
const toast = useAppSelector((state) => state.toast);
|
const toast = useAppSelector((state) => state.toast);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -67,6 +72,28 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, [clearDownload, setLastPacket, updateLibrary]);
|
}, [clearDownload, setLastPacket, updateLibrary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateUser();
|
||||||
|
}, [updateUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listeners = [
|
||||||
|
window.electron.onSignIn(() => {
|
||||||
|
updateUser();
|
||||||
|
}),
|
||||||
|
window.electron.onLibraryBatchComplete(() => {
|
||||||
|
updateLibrary();
|
||||||
|
}),
|
||||||
|
window.electron.onSignOut(() => {
|
||||||
|
clearUser();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.forEach((unsubscribe) => unsubscribe());
|
||||||
|
};
|
||||||
|
}, [updateUser, updateLibrary, clearUser]);
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
(query: string) => {
|
(query: string) => {
|
||||||
dispatch(setSearch(query));
|
dispatch(setSearch(query));
|
||||||
|
@ -39,6 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||||
|
if (location.pathname.startsWith("/profile")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||||
|
|
||||||
return t(pathTitle[location.pathname]);
|
return t(pathTitle[location.pathname]);
|
||||||
|
71
src/renderer/src/components/sidebar/sidebar-profile.tsx
Normal file
71
src/renderer/src/components/sidebar/sidebar-profile.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { PersonIcon } from "@primer/octicons-react";
|
||||||
|
import * as styles from "./sidebar.css";
|
||||||
|
import { useUserDetails } from "@renderer/hooks";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function SidebarProfile() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { userDetails, profileBackground } = useUserDetails();
|
||||||
|
|
||||||
|
const handleClickProfile = () => {
|
||||||
|
navigate(`/user/${userDetails!.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickLogin = () => {
|
||||||
|
window.electron.openExternal("https://auth.hydra.losbroxas.org");
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileButtonBackground = useMemo(() => {
|
||||||
|
if (profileBackground) return profileBackground;
|
||||||
|
return undefined;
|
||||||
|
}, [profileBackground]);
|
||||||
|
|
||||||
|
if (userDetails == null) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.profileButton}
|
||||||
|
onClick={handleClickLogin}
|
||||||
|
>
|
||||||
|
<div className={styles.profileAvatar}>
|
||||||
|
<PersonIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.profileButtonInformation}>
|
||||||
|
<p style={{ fontWeight: "bold" }}>Fazer login</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.profileButton}
|
||||||
|
style={{ background: profileButtonBackground }}
|
||||||
|
onClick={handleClickProfile}
|
||||||
|
>
|
||||||
|
<div className={styles.profileAvatar}>
|
||||||
|
{userDetails.profileImageUrl ? (
|
||||||
|
<img
|
||||||
|
className={styles.profileAvatar}
|
||||||
|
src={userDetails.profileImageUrl}
|
||||||
|
alt={userDetails.displayName}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PersonIcon />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.profileButtonInformation}>
|
||||||
|
<p style={{ fontWeight: "bold" }}>{userDetails.displayName}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -149,7 +149,9 @@ export const profileAvatar = style({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
|
border: `solid 1px ${vars.color.border}`,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
objectFit: "cover",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileButtonInformation = style({
|
export const profileButtonInformation = style({
|
||||||
|
@ -13,7 +13,7 @@ import * as styles from "./sidebar.css";
|
|||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
import { PersonIcon } from "@primer/octicons-react";
|
import { SidebarProfile } from "./sidebar-profile";
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 200;
|
const SIDEBAR_MIN_WIDTH = 200;
|
||||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||||
@ -154,18 +154,7 @@ export function Sidebar() {
|
|||||||
maxWidth: sidebarWidth,
|
maxWidth: sidebarWidth,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button type="button" className={styles.profileButton}>
|
<SidebarProfile />
|
||||||
<div className={styles.profileAvatar}>
|
|
||||||
<PersonIcon />
|
|
||||||
|
|
||||||
<div className={styles.statusBadge} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.profileButtonInformation}>
|
|
||||||
<p style={{ fontWeight: "bold" }}>hydra</p>
|
|
||||||
<p style={{ fontSize: 12 }}>Jogando ABC</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.content({
|
className={styles.content({
|
||||||
|
13
src/renderer/src/declaration.d.ts
vendored
13
src/renderer/src/declaration.d.ts
vendored
@ -13,6 +13,7 @@ import type {
|
|||||||
StartGameDownloadPayload,
|
StartGameDownloadPayload,
|
||||||
RealDebridUser,
|
RealDebridUser,
|
||||||
DownloadSource,
|
DownloadSource,
|
||||||
|
UserProfile,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
|
|
||||||
@ -72,6 +73,7 @@ declare global {
|
|||||||
getGameByObjectID: (objectID: string) => Promise<Game | null>;
|
getGameByObjectID: (objectID: string) => Promise<Game | null>;
|
||||||
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
|
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
|
||||||
onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
|
onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
|
||||||
|
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
|
||||||
|
|
||||||
/* User preferences */
|
/* User preferences */
|
||||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||||
@ -109,6 +111,17 @@ declare global {
|
|||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
checkForUpdates: () => Promise<boolean>;
|
checkForUpdates: () => Promise<boolean>;
|
||||||
restartAndInstallUpdate: () => Promise<void>;
|
restartAndInstallUpdate: () => Promise<void>;
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
|
||||||
|
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
|
||||||
|
|
||||||
|
/* User */
|
||||||
|
getUser: (userId: string) => Promise<UserProfile | null>;
|
||||||
|
|
||||||
|
/* Profile */
|
||||||
|
getMe: () => Promise<UserProfile | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
@ -4,3 +4,4 @@ export * from "./use-preferences-slice";
|
|||||||
export * from "./download-slice";
|
export * from "./download-slice";
|
||||||
export * from "./window-slice";
|
export * from "./window-slice";
|
||||||
export * from "./toast-slice";
|
export * from "./toast-slice";
|
||||||
|
export * from "./user-details-slice";
|
||||||
|
32
src/renderer/src/features/user-details-slice.ts
Normal file
32
src/renderer/src/features/user-details-slice.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
|
import type { UserDetails } from "@types";
|
||||||
|
|
||||||
|
export interface UserDetailsState {
|
||||||
|
userDetails: UserDetails | null;
|
||||||
|
profileBackground: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UserDetailsState = {
|
||||||
|
userDetails: null,
|
||||||
|
profileBackground: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userDetailsSlice = createSlice({
|
||||||
|
name: "user-details",
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setUserDetails: (state, action: PayloadAction<UserDetails>) => {
|
||||||
|
state.userDetails = action.payload;
|
||||||
|
},
|
||||||
|
setProfileBackground: (state, action: PayloadAction<string>) => {
|
||||||
|
state.profileBackground = action.payload;
|
||||||
|
},
|
||||||
|
clearUserDetails: (state) => {
|
||||||
|
state.userDetails = null;
|
||||||
|
state.profileBackground = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setUserDetails, setProfileBackground, clearUserDetails } =
|
||||||
|
userDetailsSlice.actions;
|
@ -1,5 +1,7 @@
|
|||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
|
import Color from "color";
|
||||||
|
|
||||||
export const steamUrlBuilder = {
|
export const steamUrlBuilder = {
|
||||||
library: (objectID: string) =>
|
library: (objectID: string) =>
|
||||||
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
|
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
|
||||||
@ -40,3 +42,6 @@ export const buildGameDetailsPath = (
|
|||||||
const searchParams = new URLSearchParams({ title: game.title, ...params });
|
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) =>
|
||||||
|
new Color(color).darken(amount).toString();
|
||||||
|
@ -3,3 +3,4 @@ export * from "./use-library";
|
|||||||
export * from "./use-date";
|
export * from "./use-date";
|
||||||
export * from "./use-toast";
|
export * from "./use-toast";
|
||||||
export * from "./redux";
|
export * from "./redux";
|
||||||
|
export * from "./use-user-details";
|
||||||
|
57
src/renderer/src/hooks/use-user-details.ts
Normal file
57
src/renderer/src/hooks/use-user-details.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { average } from "color.js";
|
||||||
|
|
||||||
|
import { useAppDispatch, useAppSelector } from "./redux";
|
||||||
|
import {
|
||||||
|
clearUserDetails,
|
||||||
|
setProfileBackground,
|
||||||
|
setUserDetails,
|
||||||
|
} from "@renderer/features";
|
||||||
|
import { darkenColor } from "@renderer/helpers";
|
||||||
|
|
||||||
|
export function useUserDetails() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { userDetails, profileBackground } = useAppSelector(
|
||||||
|
(state) => state.userDetails
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearUser = useCallback(async () => {
|
||||||
|
dispatch(clearUserDetails());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const signOut = useCallback(async () => {
|
||||||
|
clearUser();
|
||||||
|
|
||||||
|
return window.electron.signOut();
|
||||||
|
}, [clearUser]);
|
||||||
|
|
||||||
|
const updateUser = useCallback(async () => {
|
||||||
|
return window.electron.getMe().then(async (userDetails) => {
|
||||||
|
if (userDetails) {
|
||||||
|
dispatch(setUserDetails(userDetails));
|
||||||
|
|
||||||
|
if (userDetails.profileImageUrl) {
|
||||||
|
const output = await average(userDetails.profileImageUrl, {
|
||||||
|
amount: 1,
|
||||||
|
format: "hex",
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setProfileBackground(
|
||||||
|
`linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.7)})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userDetails,
|
||||||
|
updateUser,
|
||||||
|
signOut,
|
||||||
|
clearUser,
|
||||||
|
profileBackground,
|
||||||
|
};
|
||||||
|
}
|
@ -27,6 +27,7 @@ import {
|
|||||||
import { store } from "./store";
|
import { store } from "./store";
|
||||||
|
|
||||||
import * as resources from "@locales";
|
import * as resources from "@locales";
|
||||||
|
import { User } from "./pages/user/user";
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
@ -54,6 +55,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/game/:shop/:objectID" Component={GameDetails} />
|
<Route path="/game/:shop/:objectID" Component={GameDetails} />
|
||||||
<Route path="/search" Component={SearchResults} />
|
<Route path="/search" Component={SearchResults} />
|
||||||
<Route path="/settings" Component={Settings} />
|
<Route path="/settings" Component={Settings} />
|
||||||
|
<Route path="/user/:userId" Component={User} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
|
205
src/renderer/src/pages/user/user-content.tsx
Normal file
205
src/renderer/src/pages/user/user-content.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { UserGame, UserProfile } from "@types";
|
||||||
|
import cn from "classnames";
|
||||||
|
|
||||||
|
import * as styles from "./user.css";
|
||||||
|
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
|
import { useDate, useUserDetails } from "@renderer/hooks";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
import { PersonIcon } from "@primer/octicons-react";
|
||||||
|
import { Button } from "@renderer/components";
|
||||||
|
|
||||||
|
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||||
|
|
||||||
|
export interface ProfileContentProps {
|
||||||
|
userProfile: UserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserContent({ userProfile }: ProfileContentProps) {
|
||||||
|
const { t, i18n } = useTranslation("user_profile");
|
||||||
|
|
||||||
|
const { userDetails, profileBackground, signOut } = useUserDetails();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const numberFormatter = useMemo(() => {
|
||||||
|
return new Intl.NumberFormat(i18n.language, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
|
const { formatDistance } = useDate();
|
||||||
|
|
||||||
|
const formatPlayTime = () => {
|
||||||
|
const seconds = userProfile.libraryGames.reduce(
|
||||||
|
(acc, game) => acc + game.playTimeInSeconds,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const minutes = seconds / 60;
|
||||||
|
|
||||||
|
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||||
|
return t("amount_minutes", {
|
||||||
|
amount: minutes.toFixed(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = minutes / 60;
|
||||||
|
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameClick = (game: UserGame) => {
|
||||||
|
navigate(buildGameDetailsPath(game));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignout = async () => {
|
||||||
|
await signOut();
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMe = userDetails?.id == userProfile.id;
|
||||||
|
|
||||||
|
const profileContentBoxBackground = useMemo(() => {
|
||||||
|
if (profileBackground) return profileBackground;
|
||||||
|
/* TODO: Render background colors for other users */
|
||||||
|
return undefined;
|
||||||
|
}, [profileBackground]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className={styles.profileContentBox}
|
||||||
|
style={{
|
||||||
|
background: profileContentBoxBackground,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.profileAvatarContainer}>
|
||||||
|
{userProfile.profileImageUrl ? (
|
||||||
|
<img
|
||||||
|
className={styles.profileAvatar}
|
||||||
|
alt={userProfile.displayName}
|
||||||
|
src={userProfile.profileImageUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PersonIcon size={72} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.profileInformation}>
|
||||||
|
<h2 style={{ fontWeight: "bold" }}>{userProfile.displayName}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMe && (
|
||||||
|
<div style={{ flex: 1, display: "flex", justifyContent: "end" }}>
|
||||||
|
<Button theme="danger" onClick={handleSignout}>
|
||||||
|
{t("sign_out")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.profileContent}>
|
||||||
|
<div className={styles.profileGameSection}>
|
||||||
|
<div>
|
||||||
|
<h2>{t("activity")}</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userProfile.recentGames.map((game) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={game.objectID}
|
||||||
|
className={cn(styles.feedItem, styles.profileContentBox)}
|
||||||
|
onClick={() => handleGameClick(game)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={styles.feedGameIcon}
|
||||||
|
src={game.cover}
|
||||||
|
alt={game.title}
|
||||||
|
/>
|
||||||
|
<div className={styles.gameInformation}>
|
||||||
|
<h4>{game.title}</h4>
|
||||||
|
<small>
|
||||||
|
{t("last_time_played", {
|
||||||
|
period: formatDistance(
|
||||||
|
game.lastTimePlayed!,
|
||||||
|
new Date(),
|
||||||
|
{
|
||||||
|
addSuffix: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>{t("library")}</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: vars.color.border,
|
||||||
|
height: "1px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h3 style={{ fontWeight: "400" }}>
|
||||||
|
{userProfile.libraryGames.length}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto auto auto",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userProfile.libraryGames.map((game) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={game.objectID}
|
||||||
|
className={cn(styles.gameListItem, styles.profileContentBox)}
|
||||||
|
style={{
|
||||||
|
padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
|
||||||
|
}}
|
||||||
|
onClick={() => handleGameClick(game)}
|
||||||
|
title={game.title}
|
||||||
|
>
|
||||||
|
{game.iconUrl ? (
|
||||||
|
<img
|
||||||
|
className={styles.libraryGameIcon}
|
||||||
|
src={game.iconUrl}
|
||||||
|
alt={game.title}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SteamLogo className={styles.libraryGameIcon} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
14
src/renderer/src/pages/user/user-skeleton.tsx
Normal file
14
src/renderer/src/pages/user/user-skeleton.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import Skeleton from "react-loading-skeleton";
|
||||||
|
import * as styles from "./user.css";
|
||||||
|
|
||||||
|
export const UserSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Skeleton className={styles.profileHeaderSkeleton} />
|
||||||
|
<div className={styles.profileContent}>
|
||||||
|
<Skeleton height={140} style={{ flex: 1 }} />
|
||||||
|
<Skeleton width={300} className={styles.contentSidebar} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
138
src/renderer/src/pages/user/user.css.ts
Normal file
138
src/renderer/src/pages/user/user.css.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||||
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
export const wrapper = style({
|
||||||
|
padding: "24px",
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: `${SPACING_UNIT * 3}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileContentBox = style({
|
||||||
|
display: "flex",
|
||||||
|
gap: `${SPACING_UNIT * 3}px`,
|
||||||
|
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 2}px`,
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: `solid 1px ${vars.color.border}`,
|
||||||
|
width: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
|
||||||
|
transition: "all ease 0.3s",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileAvatarContainer = style({
|
||||||
|
width: "96px",
|
||||||
|
height: "96px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: vars.color.background,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `solid 1px ${vars.color.border}`,
|
||||||
|
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileAvatar = style({
|
||||||
|
width: "96px",
|
||||||
|
height: "96px",
|
||||||
|
objectFit: "cover",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileInformation = style({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
color: "#c0c1c7",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileContent = style({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: `${SPACING_UNIT * 4}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileGameSection = style({
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const contentSidebar = style({
|
||||||
|
width: "100%",
|
||||||
|
"@media": {
|
||||||
|
"(min-width: 768px)": {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "150px",
|
||||||
|
},
|
||||||
|
"(min-width: 1024px)": {
|
||||||
|
maxWidth: "250px",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
"(min-width: 1280px)": {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "350px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const feedGameIcon = style({
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "relative",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const libraryGameIcon = style({
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "relative",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const feedItem = style({
|
||||||
|
color: vars.color.body,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
|
width: "100%",
|
||||||
|
height: "72px",
|
||||||
|
transition: "all ease 0.2s",
|
||||||
|
cursor: "pointer",
|
||||||
|
zIndex: "1",
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gameListItem = style({
|
||||||
|
color: vars.color.body,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
aspectRatio: "1",
|
||||||
|
transition: "all ease 0.2s",
|
||||||
|
cursor: "pointer",
|
||||||
|
zIndex: "1",
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gameInformation = style({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: `${SPACING_UNIT / 2}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileHeaderSkeleton = style({
|
||||||
|
height: "200px",
|
||||||
|
});
|
38
src/renderer/src/pages/user/user.tsx
Normal file
38
src/renderer/src/pages/user/user.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { UserProfile } from "@types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
|
import { useAppDispatch } from "@renderer/hooks";
|
||||||
|
import { UserSkeleton } from "./user-skeleton";
|
||||||
|
import { UserContent } from "./user-content";
|
||||||
|
import { SkeletonTheme } from "react-loading-skeleton";
|
||||||
|
import { vars } from "@renderer/theme.css";
|
||||||
|
import * as styles from "./user.css";
|
||||||
|
|
||||||
|
export const User = () => {
|
||||||
|
const { userId } = useParams();
|
||||||
|
const [userProfile, setUserProfile] = useState<UserProfile>();
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electron.getUser(userId!).then((userProfile) => {
|
||||||
|
if (userProfile) {
|
||||||
|
dispatch(setHeaderTitle(userProfile.displayName));
|
||||||
|
setUserProfile(userProfile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [dispatch, userId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
{userProfile ? (
|
||||||
|
<UserContent userProfile={userProfile} />
|
||||||
|
) : (
|
||||||
|
<UserSkeleton />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SkeletonTheme>
|
||||||
|
);
|
||||||
|
};
|
@ -6,6 +6,7 @@ import {
|
|||||||
searchSlice,
|
searchSlice,
|
||||||
userPreferencesSlice,
|
userPreferencesSlice,
|
||||||
toastSlice,
|
toastSlice,
|
||||||
|
userDetailsSlice,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
@ -16,6 +17,7 @@ export const store = configureStore({
|
|||||||
userPreferences: userPreferencesSlice.reducer,
|
userPreferences: userPreferencesSlice.reducer,
|
||||||
download: downloadSlice.reducer,
|
download: downloadSlice.reducer,
|
||||||
toast: toastSlice.reducer,
|
toast: toastSlice.reducer,
|
||||||
|
userDetails: userDetailsSlice.reducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -85,6 +85,16 @@ export interface CatalogueEntry {
|
|||||||
repacks: GameRepack[];
|
repacks: GameRepack[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserGame {
|
||||||
|
objectID: string;
|
||||||
|
shop: GameShop;
|
||||||
|
title: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
cover: string;
|
||||||
|
playTimeInSeconds: number;
|
||||||
|
lastTimePlayed: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadQueue {
|
export interface DownloadQueue {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@ -234,6 +244,20 @@ export interface RealDebridUser {
|
|||||||
expiration: string;
|
expiration: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserDetails {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
profileImageUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
profileImageUrl: string | null;
|
||||||
|
libraryGames: UserGame[];
|
||||||
|
recentGames: UserGame[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadSource {
|
export interface DownloadSource {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user