mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-02 16:23:48 +03:00
Merge branch 'main' into main
This commit is contained in:
commit
c6e313f5ad
@ -1,3 +1,4 @@
|
||||
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
|
||||
MAIN_VITE_API_URL=API_URL
|
||||
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@ -37,8 +37,6 @@ jobs:
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: yarn build:linux
|
||||
env:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
@ -48,8 +46,6 @@ jobs:
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: yarn build:win
|
||||
env:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
|
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -39,8 +39,6 @@ jobs:
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: yarn build:linux
|
||||
env:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
@ -50,8 +48,6 @@ jobs:
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: yarn build:win
|
||||
env:
|
||||
MAIN_VITE_ONLINEFIX_USERNAME: ${{ secrets.ONLINEFIX_USERNAME }}
|
||||
MAIN_VITE_ONLINEFIX_PASSWORD: ${{ secrets.ONLINEFIX_PASSWORD }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
|
@ -5,3 +5,4 @@ pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
src/main/migrations
|
||||
|
@ -174,12 +174,9 @@
|
||||
"validate_download_source": "Validate",
|
||||
"remove_download_source": "Remove",
|
||||
"add_download_source": "Add source",
|
||||
"download_count_zero": "No downloads in list",
|
||||
"download_count_one": "{{countFormatted}} download in list",
|
||||
"download_count_other": "{{countFormatted}} downloads in list",
|
||||
"download_options_zero": "No download available",
|
||||
"download_options_one": "{{countFormatted}} download available",
|
||||
"download_options_other": "{{countFormatted}} downloads available",
|
||||
"download_count_zero": "No download options",
|
||||
"download_count_one": "{{countFormatted}} download option",
|
||||
"download_count_other": "{{countFormatted}} download options",
|
||||
"download_source_url": "Download source URL",
|
||||
"add_download_source_description": "Insert the URL containing the .json file",
|
||||
"download_source_up_to_date": "Up-to-date",
|
||||
@ -261,6 +258,18 @@
|
||||
"undo_friendship": "Undo friendship",
|
||||
"request_accepted": "Request accepted",
|
||||
"user_blocked_successfully": "User blocked successfully",
|
||||
"user_block_modal_text": "This will block {{displayName}}"
|
||||
"user_block_modal_text": "This will block {{displayName}}",
|
||||
"settings": "Settings",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"friends_only": "Friends only",
|
||||
"privacy": "Privacy",
|
||||
"blocked_users": "Blocked users",
|
||||
"unblock": "Unblock",
|
||||
"no_friends_added": "You still don't have added friends",
|
||||
"pending": "Pending",
|
||||
"no_pending_invites": "You have no pending invites",
|
||||
"no_blocked_users": "You have no blocked users",
|
||||
"friend_code_copied": "Friend code copied"
|
||||
}
|
||||
}
|
||||
|
@ -261,6 +261,18 @@
|
||||
"undo_friendship": "Desfazer amizade",
|
||||
"request_accepted": "Pedido de amizade aceito",
|
||||
"user_blocked_successfully": "Usuário bloqueado com sucesso",
|
||||
"user_block_modal_text": "Bloquear {{displayName}}"
|
||||
"user_block_modal_text": "Bloquear {{displayName}}",
|
||||
"settings": "Configurações",
|
||||
"privacy": "Privacidade",
|
||||
"private": "Privado",
|
||||
"friends_only": "Apenas amigos",
|
||||
"public": "Público",
|
||||
"blocked_users": "Usuários bloqueados",
|
||||
"unblock": "Desbloquear",
|
||||
"no_friends_added": "Você ainda não possui amigos adicionados",
|
||||
"pending": "Pendentes",
|
||||
"no_pending_invites": "Você não possui convites de amizade pendentes",
|
||||
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
|
||||
"friend_code_copied": "Código de amigo copiado"
|
||||
}
|
||||
}
|
||||
|
@ -6,32 +6,24 @@ import {
|
||||
GameShopCache,
|
||||
Repack,
|
||||
UserPreferences,
|
||||
UserAuth,
|
||||
} from "@main/entity";
|
||||
import type { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
|
||||
|
||||
import { databasePath } from "./constants";
|
||||
import migrations from "./migrations";
|
||||
import { UserAuth } from "./entity/user-auth";
|
||||
import * as migrations from "./migrations";
|
||||
|
||||
export const createDataSource = (
|
||||
options: Partial<BetterSqlite3ConnectionOptions>
|
||||
) =>
|
||||
new DataSource({
|
||||
type: "better-sqlite3",
|
||||
entities: [
|
||||
Game,
|
||||
Repack,
|
||||
UserPreferences,
|
||||
GameShopCache,
|
||||
DownloadSource,
|
||||
DownloadQueue,
|
||||
UserAuth,
|
||||
],
|
||||
synchronize: true,
|
||||
database: databasePath,
|
||||
...options,
|
||||
});
|
||||
|
||||
export const dataSource = createDataSource({
|
||||
export const dataSource = new DataSource({
|
||||
type: "better-sqlite3",
|
||||
entities: [
|
||||
Game,
|
||||
Repack,
|
||||
UserPreferences,
|
||||
GameShopCache,
|
||||
DownloadSource,
|
||||
DownloadQueue,
|
||||
UserAuth,
|
||||
],
|
||||
synchronize: true,
|
||||
database: databasePath,
|
||||
migrations,
|
||||
});
|
||||
|
@ -16,11 +16,14 @@ export class Repack {
|
||||
@Column("text", { unique: true })
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* @deprecated Use uris instead
|
||||
*/
|
||||
@Column("text", { unique: true })
|
||||
magnet: string;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @deprecated Direct scraping capability has been removed
|
||||
*/
|
||||
@Column("int", { nullable: true })
|
||||
page: number;
|
||||
@ -37,6 +40,9 @@ export class Repack {
|
||||
@ManyToOne(() => DownloadSource, { nullable: true, onDelete: "CASCADE" })
|
||||
downloadSource: DownloadSource;
|
||||
|
||||
@Column("text", { default: "[]" })
|
||||
uris: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
@ -1,16 +1,11 @@
|
||||
import { downloadSourceRepository } from "@main/repository";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return downloadSourceRepository
|
||||
.createQueryBuilder("downloadSource")
|
||||
.leftJoin("downloadSource.repacks", "repacks")
|
||||
.orderBy("downloadSource.createdAt", "DESC")
|
||||
.loadRelationCountAndMap(
|
||||
"downloadSource.repackCount",
|
||||
"downloadSource.repacks"
|
||||
)
|
||||
.getMany();
|
||||
};
|
||||
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||
downloadSourceRepository.find({
|
||||
order: {
|
||||
createdAt: "DESC",
|
||||
},
|
||||
});
|
||||
|
||||
registerEvent("getDownloadSources", getDownloadSources);
|
||||
|
@ -43,6 +43,7 @@ import "./auth/sign-out";
|
||||
import "./auth/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
import "./user/get-user";
|
||||
import "./user/get-user-blocks";
|
||||
import "./user/block-user";
|
||||
import "./user/unblock-user";
|
||||
import "./user/get-user-friends";
|
||||
@ -52,11 +53,9 @@ import "./profile/undo-friendship";
|
||||
import "./profile/update-friend-request";
|
||||
import "./profile/update-profile";
|
||||
import "./profile/send-friend-request";
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
ipcMain.handle("getVersion", () => app.getVersion());
|
||||
ipcMain.handle(
|
||||
"isPortableVersion",
|
||||
() => process.env.PORTABLE_EXECUTABLE_FILE != null
|
||||
);
|
||||
ipcMain.handle("isPortableVersion", () => isPortableVersion());
|
||||
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
|
||||
|
@ -20,7 +20,7 @@ const removeRemoveGameFromLibrary = async (gameId: number) => {
|
||||
const game = await gameRepository.findOne({ where: { id: gameId } });
|
||||
|
||||
if (game?.remoteId) {
|
||||
HydraApi.delete(`/games/${game.remoteId}`).catch(() => {});
|
||||
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -4,33 +4,22 @@ import axios from "axios";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileTypeFromFile } from "file-type";
|
||||
import { UserProfile } from "@types";
|
||||
import { UpdateProfileProps, UserProfile } from "@types";
|
||||
|
||||
const patchUserProfile = async (
|
||||
displayName: string,
|
||||
profileImageUrl?: string
|
||||
) => {
|
||||
if (profileImageUrl) {
|
||||
return HydraApi.patch("/profile", {
|
||||
displayName,
|
||||
profileImageUrl,
|
||||
});
|
||||
} else {
|
||||
return HydraApi.patch("/profile", {
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
|
||||
return HydraApi.patch("/profile", updateProfile);
|
||||
};
|
||||
|
||||
const updateProfile = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
displayName: string,
|
||||
newProfileImagePath: string | null
|
||||
updateProfile: UpdateProfileProps
|
||||
): Promise<UserProfile> => {
|
||||
if (!newProfileImagePath) {
|
||||
return patchUserProfile(displayName);
|
||||
if (!updateProfile.profileImageUrl) {
|
||||
return patchUserProfile(updateProfile);
|
||||
}
|
||||
|
||||
const newProfileImagePath = updateProfile.profileImageUrl;
|
||||
|
||||
const stats = fs.statSync(newProfileImagePath);
|
||||
const fileBuffer = fs.readFileSync(newProfileImagePath);
|
||||
const fileSizeInBytes = stats.size;
|
||||
@ -53,7 +42,7 @@ const updateProfile = async (
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return patchUserProfile(displayName, profileImageUrl);
|
||||
return patchUserProfile({ ...updateProfile, profileImageUrl });
|
||||
};
|
||||
|
||||
registerEvent("updateProfile", updateProfile);
|
||||
|
@ -18,7 +18,8 @@ const startGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
payload: StartGameDownloadPayload
|
||||
) => {
|
||||
const { repackId, objectID, title, shop, downloadPath, downloader } = payload;
|
||||
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
|
||||
payload;
|
||||
|
||||
const [game, repack] = await Promise.all([
|
||||
gameRepository.findOne({
|
||||
@ -54,7 +55,7 @@ const startGameDownload = async (
|
||||
bytesDownloaded: 0,
|
||||
downloadPath,
|
||||
downloader,
|
||||
uri: repack.magnet,
|
||||
uri,
|
||||
isDeleted: false,
|
||||
}
|
||||
);
|
||||
@ -76,7 +77,7 @@ const startGameDownload = async (
|
||||
shop,
|
||||
status: "active",
|
||||
downloadPath,
|
||||
uri: repack.magnet,
|
||||
uri,
|
||||
})
|
||||
.then((result) => {
|
||||
if (iconUrl) {
|
||||
@ -100,6 +101,7 @@ const startGameDownload = async (
|
||||
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
|
||||
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
|
||||
|
||||
await DownloadManager.cancelDownload(updatedGame!.id);
|
||||
await DownloadManager.startDownload(updatedGame!);
|
||||
};
|
||||
|
||||
|
@ -5,7 +5,7 @@ const blockUser = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string
|
||||
) => {
|
||||
await HydraApi.post(`/user/${userId}/block`);
|
||||
await HydraApi.post(`/users/${userId}/block`);
|
||||
};
|
||||
|
||||
registerEvent("blockUser", blockUser);
|
||||
|
13
src/main/events/user/get-user-blocks.ts
Normal file
13
src/main/events/user/get-user-blocks.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserBlocks } from "@types";
|
||||
|
||||
export const getUserBlocks = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
take: number,
|
||||
skip: number
|
||||
): Promise<UserBlocks> => {
|
||||
return HydraApi.get(`/profile/blocks`, { take, skip });
|
||||
};
|
||||
|
||||
registerEvent("getUserBlocks", getUserBlocks);
|
@ -14,7 +14,7 @@ export const getUserFriends = async (
|
||||
return HydraApi.get(`/profile/friends`, { take, skip });
|
||||
}
|
||||
|
||||
return HydraApi.get(`/user/${userId}/friends`, { take, skip });
|
||||
return HydraApi.get(`/users/${userId}/friends`, { take, skip });
|
||||
};
|
||||
|
||||
const getUserFriendsEvent = async (
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { UserProfile } from "@types";
|
||||
import { GameRunning, UserGame, UserProfile } from "@types";
|
||||
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
||||
import { getSteamAppAsset } from "@main/helpers";
|
||||
import { getUserFriends } from "./get-user-friends";
|
||||
@ -12,7 +12,7 @@ const getUser = async (
|
||||
): Promise<UserProfile | null> => {
|
||||
try {
|
||||
const [profile, friends] = await Promise.all([
|
||||
HydraApi.get(`/user/${userId}`),
|
||||
HydraApi.get(`/users/${userId}`),
|
||||
getUserFriends(userId, 12, 0).catch(() => {
|
||||
return { totalFriends: 0, friends: [] };
|
||||
}),
|
||||
@ -20,48 +20,57 @@ const getUser = async (
|
||||
|
||||
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,
|
||||
};
|
||||
return getSteamUserGame(game);
|
||||
})
|
||||
);
|
||||
|
||||
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 getSteamUserGame(game);
|
||||
})
|
||||
);
|
||||
|
||||
const currentGame = await getGameRunning(profile.currentGame);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
libraryGames,
|
||||
recentGames,
|
||||
friends: friends.friends,
|
||||
totalFriends: friends.totalFriends,
|
||||
currentGame,
|
||||
};
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getGameRunning = async (currentGame): Promise<GameRunning | null> => {
|
||||
if (!currentGame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gameRunning = await getSteamUserGame(currentGame);
|
||||
|
||||
return {
|
||||
...gameRunning,
|
||||
sessionDurationInMillis: currentGame.sessionDurationInSeconds * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
const getSteamUserGame = async (game): Promise<UserGame> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
registerEvent("getUser", getUser);
|
||||
|
@ -5,7 +5,7 @@ const unblockUser = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string
|
||||
) => {
|
||||
await HydraApi.post(`/user/${userId}/unblock`);
|
||||
await HydraApi.post(`/users/${userId}/unblock`);
|
||||
};
|
||||
|
||||
registerEvent("unblockUser", unblockUser);
|
||||
|
@ -17,7 +17,8 @@ export const insertDownloadsFromSource = async (
|
||||
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
|
||||
(download) => ({
|
||||
title: download.title,
|
||||
magnet: download.uris[0],
|
||||
uris: JSON.stringify(download.uris),
|
||||
magnet: download.uris[0]!,
|
||||
fileSize: download.fileSize,
|
||||
repacker: downloadSource.name,
|
||||
uploadDate: download.uploadDate,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
import UserAgent from "user-agents";
|
||||
|
||||
export const getSteamAppAsset = (
|
||||
@ -48,13 +49,19 @@ export const sleep = (ms: number) =>
|
||||
export const requestWebPage = async (url: string) => {
|
||||
const userAgent = new UserAgent();
|
||||
|
||||
return axios
|
||||
const data = await axios
|
||||
.get(url, {
|
||||
headers: {
|
||||
"User-Agent": userAgent.toString(),
|
||||
},
|
||||
})
|
||||
.then((response) => response.data);
|
||||
|
||||
const { window } = new JSDOM(data);
|
||||
return window.document;
|
||||
};
|
||||
|
||||
export const isPortableVersion = () =>
|
||||
process.env.PORTABLE_EXECUTABLE_FILE != null;
|
||||
|
||||
export * from "./download-source";
|
||||
|
@ -20,8 +20,6 @@ autoUpdater.setFeedURL({
|
||||
|
||||
autoUpdater.logger = logger;
|
||||
|
||||
logger.log("Init Hydra");
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) app.quit();
|
||||
|
||||
@ -123,7 +121,6 @@ app.on("window-all-closed", () => {
|
||||
app.on("before-quit", () => {
|
||||
/* Disconnects libtorrent */
|
||||
PythonInstance.kill();
|
||||
logger.log("Quit Hydra");
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class FixRepackUploadDate1715900413313 implements MigrationInterface {
|
||||
public async up(_: QueryRunner): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async down(_: QueryRunner): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AlterLastTimePlayedToDatime1716776027208
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 2024-05-27 02:08:17
|
||||
// Mon, 27 May 2024 02:08:17 GMT
|
||||
const updateLastTimePlayedValues = `
|
||||
UPDATE game SET lastTimePlayed = (SELECT
|
||||
SUBSTR(lastTimePlayed, 13, 4) || '-' || -- Year
|
||||
CASE SUBSTR(lastTimePlayed, 9, 3)
|
||||
WHEN 'Jan' THEN '01'
|
||||
WHEN 'Feb' THEN '02'
|
||||
WHEN 'Mar' THEN '03'
|
||||
WHEN 'Apr' THEN '04'
|
||||
WHEN 'May' THEN '05'
|
||||
WHEN 'Jun' THEN '06'
|
||||
WHEN 'Jul' THEN '07'
|
||||
WHEN 'Aug' THEN '08'
|
||||
WHEN 'Sep' THEN '09'
|
||||
WHEN 'Oct' THEN '10'
|
||||
WHEN 'Nov' THEN '11'
|
||||
WHEN 'Dec' THEN '12'
|
||||
END || '-' || -- Month
|
||||
SUBSTR(lastTimePlayed, 6, 2) || ' ' || -- Day
|
||||
SUBSTR(lastTimePlayed, 18, 8) -- hh:mm:ss;
|
||||
FROM game)
|
||||
WHERE lastTimePlayed IS NOT NULL;
|
||||
`;
|
||||
|
||||
await queryRunner.query(updateLastTimePlayedValues);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const queryBuilder = queryRunner.manager.createQueryBuilder(Game, "game");
|
||||
|
||||
const result = await queryBuilder.getMany();
|
||||
|
||||
for (const game of result) {
|
||||
if (!game.lastTimePlayed) continue;
|
||||
await queryRunner.query(
|
||||
`UPDATE game set lastTimePlayed = ? WHERE id = ?;`,
|
||||
[game.lastTimePlayed.toUTCString(), game.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
50
src/main/migrations/1724081695967-Hydra_2_0_3.ts
Normal file
50
src/main/migrations/1724081695967-Hydra_2_0_3.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class Hydra2031724081695967 implements MigrationInterface {
|
||||
name = 'Hydra2031724081695967'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "game" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "objectID" text NOT NULL, "remoteId" text, "title" text NOT NULL, "iconUrl" text, "folderName" text, "downloadPath" text, "executablePath" text, "playTimeInMilliseconds" integer NOT NULL DEFAULT (0), "shop" text NOT NULL, "status" text, "downloader" integer NOT NULL DEFAULT (1), "progress" float NOT NULL DEFAULT (0), "bytesDownloaded" integer NOT NULL DEFAULT (0), "lastTimePlayed" datetime, "fileSize" float NOT NULL DEFAULT (0), "uri" text, "isDeleted" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "repackId" integer, CONSTRAINT "UQ_04293f46e8db3deaec8dfb69264" UNIQUE ("objectID"), CONSTRAINT "UQ_6dac8c3148e141251a4864e94d4" UNIQUE ("remoteId"), CONSTRAINT "REL_0c1d6445ad047d9bbd256f961f" UNIQUE ("repackId"))`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "download_source" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "url" text, "name" text NOT NULL, "etag" text, "downloadCount" integer NOT NULL DEFAULT (0), "status" text NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_aec2879321a87e9bb2ed477981a" UNIQUE ("url"))`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"))`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "user_preferences" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "downloadsPath" text, "language" text NOT NULL DEFAULT ('en'), "realDebridApiToken" text, "downloadNotificationsEnabled" boolean NOT NULL DEFAULT (0), "repackUpdatesNotificationsEnabled" boolean NOT NULL DEFAULT (0), "preferQuitInsteadOfHiding" boolean NOT NULL DEFAULT (0), "runAtStartup" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "game_shop_cache" ("objectID" text PRIMARY KEY NOT NULL, "shop" text NOT NULL, "serializedData" text, "howLongToBeatSerializedData" text, "language" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "download_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "gameId" integer, CONSTRAINT "REL_aed852c94d9ded617a7a07f541" UNIQUE ("gameId"))`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "user_auth" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "userId" text NOT NULL DEFAULT (''), "displayName" text NOT NULL DEFAULT (''), "profileImageUrl" text, "accessToken" text NOT NULL DEFAULT (''), "refreshToken" text NOT NULL DEFAULT (''), "tokenExpirationTimestamp" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "temporary_game" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "objectID" text NOT NULL, "remoteId" text, "title" text NOT NULL, "iconUrl" text, "folderName" text, "downloadPath" text, "executablePath" text, "playTimeInMilliseconds" integer NOT NULL DEFAULT (0), "shop" text NOT NULL, "status" text, "downloader" integer NOT NULL DEFAULT (1), "progress" float NOT NULL DEFAULT (0), "bytesDownloaded" integer NOT NULL DEFAULT (0), "lastTimePlayed" datetime, "fileSize" float NOT NULL DEFAULT (0), "uri" text, "isDeleted" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "repackId" integer, CONSTRAINT "UQ_04293f46e8db3deaec8dfb69264" UNIQUE ("objectID"), CONSTRAINT "UQ_6dac8c3148e141251a4864e94d4" UNIQUE ("remoteId"), CONSTRAINT "REL_0c1d6445ad047d9bbd256f961f" UNIQUE ("repackId"), CONSTRAINT "FK_0c1d6445ad047d9bbd256f961f6" FOREIGN KEY ("repackId") REFERENCES "repack" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
|
||||
await queryRunner.query(`INSERT INTO "temporary_game"("id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId") SELECT "id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId" FROM "game"`);
|
||||
await queryRunner.query(`DROP TABLE "game"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_game" RENAME TO "game"`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "temporary_repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"), CONSTRAINT "FK_13f131029be1dd361fd3cd9c2a6" FOREIGN KEY ("downloadSourceId") REFERENCES "download_source" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`);
|
||||
await queryRunner.query(`INSERT INTO "temporary_repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "repack"`);
|
||||
await queryRunner.query(`DROP TABLE "repack"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_repack" RENAME TO "repack"`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "temporary_download_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "gameId" integer, CONSTRAINT "REL_aed852c94d9ded617a7a07f541" UNIQUE ("gameId"), CONSTRAINT "FK_aed852c94d9ded617a7a07f5415" FOREIGN KEY ("gameId") REFERENCES "game" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);
|
||||
await queryRunner.query(`INSERT INTO "temporary_download_queue"("id", "createdAt", "updatedAt", "gameId") SELECT "id", "createdAt", "updatedAt", "gameId" FROM "download_queue"`);
|
||||
await queryRunner.query(`DROP TABLE "download_queue"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_download_queue" RENAME TO "download_queue"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "download_queue" RENAME TO "temporary_download_queue"`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "download_queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "gameId" integer, CONSTRAINT "REL_aed852c94d9ded617a7a07f541" UNIQUE ("gameId"))`);
|
||||
await queryRunner.query(`INSERT INTO "download_queue"("id", "createdAt", "updatedAt", "gameId") SELECT "id", "createdAt", "updatedAt", "gameId" FROM "temporary_download_queue"`);
|
||||
await queryRunner.query(`DROP TABLE "temporary_download_queue"`);
|
||||
await queryRunner.query(`ALTER TABLE "repack" RENAME TO "temporary_repack"`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"))`);
|
||||
await queryRunner.query(`INSERT INTO "repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "temporary_repack"`);
|
||||
await queryRunner.query(`DROP TABLE "temporary_repack"`);
|
||||
await queryRunner.query(`ALTER TABLE "game" RENAME TO "temporary_game"`);
|
||||
await queryRunner.query(`CREATE TABLE IF NOT EXISTS "game" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "objectID" text NOT NULL, "remoteId" text, "title" text NOT NULL, "iconUrl" text, "folderName" text, "downloadPath" text, "executablePath" text, "playTimeInMilliseconds" integer NOT NULL DEFAULT (0), "shop" text NOT NULL, "status" text, "downloader" integer NOT NULL DEFAULT (1), "progress" float NOT NULL DEFAULT (0), "bytesDownloaded" integer NOT NULL DEFAULT (0), "lastTimePlayed" datetime, "fileSize" float NOT NULL DEFAULT (0), "uri" text, "isDeleted" boolean NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "repackId" integer, CONSTRAINT "UQ_04293f46e8db3deaec8dfb69264" UNIQUE ("objectID"), CONSTRAINT "UQ_6dac8c3148e141251a4864e94d4" UNIQUE ("remoteId"), CONSTRAINT "REL_0c1d6445ad047d9bbd256f961f" UNIQUE ("repackId"))`);
|
||||
await queryRunner.query(`INSERT INTO "game"("id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId") SELECT "id", "objectID", "remoteId", "title", "iconUrl", "folderName", "downloadPath", "executablePath", "playTimeInMilliseconds", "shop", "status", "downloader", "progress", "bytesDownloaded", "lastTimePlayed", "fileSize", "uri", "isDeleted", "createdAt", "updatedAt", "repackId" FROM "temporary_game"`);
|
||||
await queryRunner.query(`DROP TABLE "temporary_game"`);
|
||||
await queryRunner.query(`DROP TABLE "user_auth"`);
|
||||
await queryRunner.query(`DROP TABLE "download_queue"`);
|
||||
await queryRunner.query(`DROP TABLE "game_shop_cache"`);
|
||||
await queryRunner.query(`DROP TABLE "user_preferences"`);
|
||||
await queryRunner.query(`DROP TABLE "repack"`);
|
||||
await queryRunner.query(`DROP TABLE "download_source"`);
|
||||
await queryRunner.query(`DROP TABLE "game"`);
|
||||
}
|
||||
|
||||
}
|
20
src/main/migrations/1724081984535-DowloadsRefactor.ts
Normal file
20
src/main/migrations/1724081984535-DowloadsRefactor.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class DowloadsRefactor1724081984535 implements MigrationInterface {
|
||||
name = 'DowloadsRefactor1724081984535'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "temporary_repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, "uris" text NOT NULL DEFAULT ('[]'), CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"), CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "FK_13f131029be1dd361fd3cd9c2a6" FOREIGN KEY ("downloadSourceId") REFERENCES "download_source" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`);
|
||||
await queryRunner.query(`INSERT INTO "temporary_repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "repack"`);
|
||||
await queryRunner.query(`DROP TABLE "repack"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_repack" RENAME TO "repack"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "repack" RENAME TO "temporary_repack"`);
|
||||
await queryRunner.query(`CREATE TABLE "repack" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "magnet" text NOT NULL, "page" integer, "repacker" text NOT NULL, "fileSize" text NOT NULL, "uploadDate" datetime NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "downloadSourceId" integer, CONSTRAINT "UQ_5e8d57798643e693bced32095d2" UNIQUE ("magnet"), CONSTRAINT "UQ_a46a68496754a4d429ddf9d48ec" UNIQUE ("title"), CONSTRAINT "FK_13f131029be1dd361fd3cd9c2a6" FOREIGN KEY ("downloadSourceId") REFERENCES "download_source" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`);
|
||||
await queryRunner.query(`INSERT INTO "repack"("id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId") SELECT "id", "title", "magnet", "page", "repacker", "fileSize", "uploadDate", "createdAt", "updatedAt", "downloadSourceId" FROM "temporary_repack"`);
|
||||
await queryRunner.query(`DROP TABLE "temporary_repack"`);
|
||||
}
|
||||
|
||||
}
|
@ -1,7 +1,2 @@
|
||||
import { FixRepackUploadDate1715900413313 } from "./1715900413313-fix_repack_uploadDate";
|
||||
import { AlterLastTimePlayedToDatime1716776027208 } from "./1716776027208-alter_lastTimePlayed_to_datime";
|
||||
|
||||
export default [
|
||||
FixRepackUploadDate1715900413313,
|
||||
AlterLastTimePlayedToDatime1716776027208,
|
||||
];
|
||||
export * from "./1724081695967-Hydra_2_0_3";
|
||||
export * from "./1724081984535-DowloadsRefactor";
|
||||
|
@ -6,8 +6,8 @@ import { downloadQueueRepository, gameRepository } from "@main/repository";
|
||||
import { publishDownloadCompleteNotification } from "../notifications";
|
||||
import { RealDebridDownloader } from "./real-debrid-downloader";
|
||||
import type { DownloadProgress } from "@types";
|
||||
import { GofileApi } from "../hosters";
|
||||
import { GenericHTTPDownloader } from "./generic-http-downloader";
|
||||
import { GofileApi, QiwiApi } from "../hosters";
|
||||
import { GenericHttpDownloader } from "./generic-http-downloader";
|
||||
|
||||
export class DownloadManager {
|
||||
private static currentDownloader: Downloader | null = null;
|
||||
@ -20,7 +20,7 @@ export class DownloadManager {
|
||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
status = await RealDebridDownloader.getStatus();
|
||||
} else {
|
||||
status = await GenericHTTPDownloader.getStatus();
|
||||
status = await GenericHttpDownloader.getStatus();
|
||||
}
|
||||
|
||||
if (status) {
|
||||
@ -71,7 +71,7 @@ export class DownloadManager {
|
||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
await RealDebridDownloader.pauseDownload();
|
||||
} else {
|
||||
await GenericHTTPDownloader.pauseDownload();
|
||||
await GenericHttpDownloader.pauseDownload();
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@ -88,7 +88,7 @@ export class DownloadManager {
|
||||
} else if (this.currentDownloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.cancelDownload(gameId);
|
||||
} else {
|
||||
GenericHTTPDownloader.cancelDownload(gameId);
|
||||
GenericHttpDownloader.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@ -96,26 +96,38 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (game.downloader === Downloader.Gofile) {
|
||||
const id = game!.uri!.split("/").pop();
|
||||
switch (game.downloader) {
|
||||
case Downloader.Gofile: {
|
||||
const id = game!.uri!.split("/").pop();
|
||||
|
||||
const token = await GofileApi.authorize();
|
||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||
const token = await GofileApi.authorize();
|
||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||
|
||||
GenericHTTPDownloader.startDownload(game, downloadLink, {
|
||||
Cookie: `accountToken=${token}`,
|
||||
});
|
||||
} else if (game.downloader === Downloader.PixelDrain) {
|
||||
const id = game!.uri!.split("/").pop();
|
||||
GenericHttpDownloader.startDownload(game, downloadLink, {
|
||||
Cookie: `accountToken=${token}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case Downloader.PixelDrain: {
|
||||
const id = game!.uri!.split("/").pop();
|
||||
|
||||
await GenericHTTPDownloader.startDownload(
|
||||
game,
|
||||
`https://pixeldrain.com/api/file/${id}?download`
|
||||
);
|
||||
} else if (game.downloader === Downloader.Torrent) {
|
||||
PythonInstance.startDownload(game);
|
||||
} else if (game.downloader === Downloader.RealDebrid) {
|
||||
RealDebridDownloader.startDownload(game);
|
||||
await GenericHttpDownloader.startDownload(
|
||||
game,
|
||||
`https://pixeldrain.com/api/file/${id}?download`
|
||||
);
|
||||
break;
|
||||
}
|
||||
case Downloader.Qiwi: {
|
||||
const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
|
||||
|
||||
await GenericHttpDownloader.startDownload(game, downloadUrl);
|
||||
break;
|
||||
}
|
||||
case Downloader.Torrent:
|
||||
PythonInstance.startDownload(game);
|
||||
break;
|
||||
case Downloader.RealDebrid:
|
||||
RealDebridDownloader.startDownload(game);
|
||||
}
|
||||
|
||||
this.currentDownloader = game.downloader;
|
||||
|
@ -4,14 +4,14 @@ import { calculateETA } from "./helpers";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { HttpDownload } from "./http-download";
|
||||
|
||||
export class GenericHTTPDownloader {
|
||||
private static downloads = new Map<number, string>();
|
||||
private static downloadingGame: Game | null = null;
|
||||
export class GenericHttpDownloader {
|
||||
public static downloads = new Map<number, HttpDownload>();
|
||||
public static downloadingGame: Game | null = null;
|
||||
|
||||
public static async getStatus() {
|
||||
if (this.downloadingGame) {
|
||||
const gid = this.downloads.get(this.downloadingGame.id)!;
|
||||
const status = HttpDownload.getStatus(gid);
|
||||
const download = this.downloads.get(this.downloadingGame.id)!;
|
||||
const status = download.getStatus();
|
||||
|
||||
if (status) {
|
||||
const progress =
|
||||
@ -57,10 +57,10 @@ export class GenericHTTPDownloader {
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.downloadingGame) {
|
||||
const gid = this.downloads.get(this.downloadingGame!.id!);
|
||||
const httpDownload = this.downloads.get(this.downloadingGame!.id!);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.pauseDownload(gid);
|
||||
if (httpDownload) {
|
||||
await httpDownload.pauseDownload();
|
||||
}
|
||||
|
||||
this.downloadingGame = null;
|
||||
@ -79,29 +79,31 @@ export class GenericHTTPDownloader {
|
||||
return;
|
||||
}
|
||||
|
||||
const gid = await HttpDownload.startDownload(
|
||||
const httpDownload = new HttpDownload(
|
||||
game.downloadPath!,
|
||||
downloadUrl,
|
||||
headers
|
||||
);
|
||||
|
||||
this.downloads.set(game.id!, gid);
|
||||
httpDownload.startDownload();
|
||||
|
||||
this.downloads.set(game.id!, httpDownload);
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
const httpDownload = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.cancelDownload(gid);
|
||||
if (httpDownload) {
|
||||
await httpDownload.cancelDownload();
|
||||
this.downloads.delete(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
const httpDownload = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.resumeDownload(gid);
|
||||
if (httpDownload) {
|
||||
await httpDownload.resumeDownload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,67 +1,52 @@
|
||||
import { DownloadItem } from "electron";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import path from "node:path";
|
||||
|
||||
export class HttpDownload {
|
||||
private static id = 0;
|
||||
private downloadItem: Electron.DownloadItem;
|
||||
|
||||
private static downloads: Record<string, DownloadItem> = {};
|
||||
constructor(
|
||||
private downloadPath: string,
|
||||
private downloadUrl: string,
|
||||
private headers?: Record<string, string>
|
||||
) {}
|
||||
|
||||
public static getStatus(gid: string): {
|
||||
completedLength: number;
|
||||
totalLength: number;
|
||||
downloadSpeed: number;
|
||||
folderName: string;
|
||||
} | null {
|
||||
const downloadItem = this.downloads[gid];
|
||||
if (downloadItem) {
|
||||
return {
|
||||
completedLength: downloadItem.getReceivedBytes(),
|
||||
totalLength: downloadItem.getTotalBytes(),
|
||||
downloadSpeed: downloadItem.getCurrentBytesPerSecond(),
|
||||
folderName: downloadItem.getFilename(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
public getStatus() {
|
||||
return {
|
||||
completedLength: this.downloadItem.getReceivedBytes(),
|
||||
totalLength: this.downloadItem.getTotalBytes(),
|
||||
downloadSpeed: this.downloadItem.getCurrentBytesPerSecond(),
|
||||
folderName: this.downloadItem.getFilename(),
|
||||
};
|
||||
}
|
||||
|
||||
static async cancelDownload(gid: string) {
|
||||
const downloadItem = this.downloads[gid];
|
||||
downloadItem?.cancel();
|
||||
delete this.downloads[gid];
|
||||
async cancelDownload() {
|
||||
this.downloadItem.cancel();
|
||||
}
|
||||
|
||||
static async pauseDownload(gid: string) {
|
||||
const downloadItem = this.downloads[gid];
|
||||
downloadItem?.pause();
|
||||
async pauseDownload() {
|
||||
this.downloadItem.pause();
|
||||
}
|
||||
|
||||
static async resumeDownload(gid: string) {
|
||||
const downloadItem = this.downloads[gid];
|
||||
downloadItem?.resume();
|
||||
async resumeDownload() {
|
||||
this.downloadItem.resume();
|
||||
}
|
||||
|
||||
static async startDownload(
|
||||
downloadPath: string,
|
||||
downloadUrl: string,
|
||||
headers?: Record<string, string>
|
||||
) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const options = headers ? { headers } : {};
|
||||
WindowManager.mainWindow?.webContents.downloadURL(downloadUrl, options);
|
||||
async startDownload() {
|
||||
return new Promise((resolve) => {
|
||||
const options = this.headers ? { headers: this.headers } : {};
|
||||
WindowManager.mainWindow?.webContents.downloadURL(
|
||||
this.downloadUrl,
|
||||
options
|
||||
);
|
||||
|
||||
const gid = ++this.id;
|
||||
|
||||
WindowManager.mainWindow?.webContents.session.on(
|
||||
WindowManager.mainWindow?.webContents.session.once(
|
||||
"will-download",
|
||||
(_event, item, _webContents) => {
|
||||
this.downloads[gid.toString()] = item;
|
||||
this.downloadItem = item;
|
||||
|
||||
// Set the save path, making Electron not to prompt a save dialog.
|
||||
item.setSavePath(path.join(downloadPath, item.getFilename()));
|
||||
item.setSavePath(path.join(this.downloadPath, item.getFilename()));
|
||||
|
||||
resolve(gid.toString());
|
||||
resolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
LibtorrentPayload,
|
||||
ProcessPayload,
|
||||
} from "./types";
|
||||
import { pythonInstanceLogger as logger } from "../logger";
|
||||
|
||||
export class PythonInstance {
|
||||
private static pythonProcess: cp.ChildProcess | null = null;
|
||||
@ -32,11 +33,13 @@ export class PythonInstance {
|
||||
});
|
||||
|
||||
public static spawn(args?: StartDownloadPayload) {
|
||||
logger.log("spawning python process with args:", args);
|
||||
this.pythonProcess = startRPCClient(args);
|
||||
}
|
||||
|
||||
public static kill() {
|
||||
if (this.pythonProcess) {
|
||||
logger.log("killing python process");
|
||||
this.pythonProcess.kill();
|
||||
this.pythonProcess = null;
|
||||
this.downloadingGameId = -1;
|
||||
@ -45,6 +48,7 @@ export class PythonInstance {
|
||||
|
||||
public static killTorrent() {
|
||||
if (this.pythonProcess) {
|
||||
logger.log("killing torrent in python process");
|
||||
this.rpc.post("/action", { action: "kill-torrent" });
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
@ -138,12 +142,14 @@ export class PythonInstance {
|
||||
save_path: game.downloadPath!,
|
||||
});
|
||||
} else {
|
||||
await this.rpc.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: game.uri,
|
||||
save_path: game.downloadPath,
|
||||
} as StartDownloadPayload);
|
||||
await this.rpc
|
||||
.post("/action", {
|
||||
action: "start",
|
||||
game_id: game.id,
|
||||
magnet: game.uri,
|
||||
save_path: game.downloadPath,
|
||||
} as StartDownloadPayload)
|
||||
.catch(this.handleRpcError);
|
||||
}
|
||||
|
||||
this.downloadingGameId = game.id;
|
||||
@ -159,4 +165,14 @@ export class PythonInstance {
|
||||
|
||||
this.downloadingGameId = -1;
|
||||
}
|
||||
|
||||
private static async handleRpcError(_error: unknown) {
|
||||
await this.rpc.get("/healthcheck").catch(() => {
|
||||
logger.error(
|
||||
"RPC healthcheck failed. Killing process and starting again"
|
||||
);
|
||||
this.kill();
|
||||
this.spawn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,9 @@
|
||||
import { Game } from "@main/entity";
|
||||
import { RealDebridClient } from "../real-debrid";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { calculateETA } from "./helpers";
|
||||
import { DownloadProgress } from "@types";
|
||||
import { HttpDownload } from "./http-download";
|
||||
import { GenericHttpDownloader } from "./generic-http-downloader";
|
||||
|
||||
export class RealDebridDownloader {
|
||||
private static downloads = new Map<number, string>();
|
||||
private static downloadingGame: Game | null = null;
|
||||
|
||||
export class RealDebridDownloader extends GenericHttpDownloader {
|
||||
private static realDebridTorrentId: string | null = null;
|
||||
|
||||
private static async getRealDebridDownloadUrl() {
|
||||
@ -48,66 +43,6 @@ export class RealDebridDownloader {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async getStatus() {
|
||||
if (this.downloadingGame) {
|
||||
const gid = this.downloads.get(this.downloadingGame.id)!;
|
||||
const status = HttpDownload.getStatus(gid);
|
||||
|
||||
if (status) {
|
||||
const progress =
|
||||
Number(status.completedLength) / Number(status.totalLength);
|
||||
|
||||
await gameRepository.update(
|
||||
{ id: this.downloadingGame!.id },
|
||||
{
|
||||
bytesDownloaded: Number(status.completedLength),
|
||||
fileSize: Number(status.totalLength),
|
||||
progress,
|
||||
status: "active",
|
||||
folderName: status.folderName,
|
||||
}
|
||||
);
|
||||
|
||||
const result = {
|
||||
numPeers: 0,
|
||||
numSeeds: 0,
|
||||
downloadSpeed: Number(status.downloadSpeed),
|
||||
timeRemaining: calculateETA(
|
||||
Number(status.totalLength),
|
||||
Number(status.completedLength),
|
||||
Number(status.downloadSpeed)
|
||||
),
|
||||
isDownloadingMetadata: false,
|
||||
isCheckingFiles: false,
|
||||
progress,
|
||||
gameId: this.downloadingGame!.id,
|
||||
} as DownloadProgress;
|
||||
|
||||
if (progress === 1) {
|
||||
this.downloads.delete(this.downloadingGame.id);
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async pauseDownload() {
|
||||
if (this.downloadingGame) {
|
||||
const gid = this.downloads.get(this.downloadingGame.id);
|
||||
if (gid) {
|
||||
await HttpDownload.pauseDownload(gid);
|
||||
}
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
static async startDownload(game: Game) {
|
||||
if (this.downloads.has(game.id)) {
|
||||
await this.resumeDownload(game.id!);
|
||||
@ -128,32 +63,10 @@ export class RealDebridDownloader {
|
||||
if (downloadUrl) {
|
||||
this.realDebridTorrentId = null;
|
||||
|
||||
const gid = await HttpDownload.startDownload(
|
||||
game.downloadPath!,
|
||||
downloadUrl
|
||||
);
|
||||
const httpDownload = new HttpDownload(game.downloadPath!, downloadUrl);
|
||||
httpDownload.startDownload();
|
||||
|
||||
this.downloads.set(game.id!, gid);
|
||||
}
|
||||
}
|
||||
|
||||
static async cancelDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.cancelDownload(gid);
|
||||
this.downloads.delete(gameId);
|
||||
}
|
||||
|
||||
this.realDebridTorrentId = null;
|
||||
this.downloadingGame = null;
|
||||
}
|
||||
|
||||
static async resumeDownload(gameId: number) {
|
||||
const gid = this.downloads.get(gameId);
|
||||
|
||||
if (gid) {
|
||||
await HttpDownload.resumeDownload(gid);
|
||||
this.downloads.set(game.id!, httpDownload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,8 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { app, dialog } from "electron";
|
||||
import type { StartDownloadPayload } from "./types";
|
||||
import { Readable } from "node:stream";
|
||||
import { pythonInstanceLogger as logger } from "../logger";
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
darwin: "hydra-download-manager",
|
||||
@ -15,6 +17,13 @@ export const BITTORRENT_PORT = "5881";
|
||||
export const RPC_PORT = "8084";
|
||||
export const RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
const logStderr = (readable: Readable | null) => {
|
||||
if (!readable) return;
|
||||
|
||||
readable.setEncoding("utf-8");
|
||||
readable.on("data", logger.log);
|
||||
};
|
||||
|
||||
export const startTorrentClient = (args?: StartDownloadPayload) => {
|
||||
const commonArgs = [
|
||||
BITTORRENT_PORT,
|
||||
@ -40,10 +49,14 @@ export const startTorrentClient = (args?: StartDownloadPayload) => {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
return cp.spawn(binaryPath, commonArgs, {
|
||||
stdio: "inherit",
|
||||
const childProcess = cp.spawn(binaryPath, commonArgs, {
|
||||
windowsHide: true,
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
logStderr(childProcess.stderr);
|
||||
|
||||
return childProcess;
|
||||
} else {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
@ -53,8 +66,12 @@ export const startTorrentClient = (args?: StartDownloadPayload) => {
|
||||
"main.py"
|
||||
);
|
||||
|
||||
return cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: "inherit",
|
||||
const childProcess = cp.spawn("python3", [scriptPath, ...commonArgs], {
|
||||
stdio: ["inherit", "inherit"],
|
||||
});
|
||||
|
||||
logStderr(childProcess.stderr);
|
||||
|
||||
return childProcess;
|
||||
}
|
||||
};
|
||||
|
@ -16,6 +16,8 @@ export interface GofileContentsResponse {
|
||||
children: Record<string, GofileContentChild>;
|
||||
}
|
||||
|
||||
export const WT = "4fd6sg89d7s6";
|
||||
|
||||
export class GofileApi {
|
||||
private static token: string;
|
||||
|
||||
@ -35,7 +37,7 @@ export class GofileApi {
|
||||
|
||||
public static async getDownloadLink(id: string) {
|
||||
const searchParams = new URLSearchParams({
|
||||
wt: "4fd6sg89d7s6",
|
||||
wt: WT,
|
||||
});
|
||||
|
||||
const response = await axios.get<{
|
||||
|
@ -1 +1,2 @@
|
||||
export * from "./gofile";
|
||||
export * from "./qiwi";
|
||||
|
15
src/main/services/hosters/qiwi.ts
Normal file
15
src/main/services/hosters/qiwi.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { requestWebPage } from "@main/helpers";
|
||||
|
||||
export class QiwiApi {
|
||||
public static async getDownloadUrl(url: string) {
|
||||
const document = await requestWebPage(url);
|
||||
const fileName = document.querySelector("h1")?.textContent;
|
||||
|
||||
const slug = url.split("/").pop();
|
||||
const extension = fileName?.split(".").pop();
|
||||
|
||||
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
|
||||
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { requestWebPage } from "@main/helpers";
|
||||
import { HowLongToBeatCategory } from "@types";
|
||||
import { formatName } from "@shared";
|
||||
@ -52,10 +51,7 @@ const parseListItems = ($lis: Element[]) => {
|
||||
export const getHowLongToBeatGame = async (
|
||||
id: string
|
||||
): Promise<HowLongToBeatCategory[]> => {
|
||||
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
|
||||
|
||||
const { window } = new JSDOM(response);
|
||||
const { document } = window;
|
||||
const document = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
|
||||
|
||||
const $ul = document.querySelector(".shadow_shadow ul");
|
||||
if (!$ul) return [];
|
||||
|
@ -3,7 +3,7 @@ import { HydraApi } from "../hydra-api";
|
||||
import { gameRepository } from "@main/repository";
|
||||
|
||||
export const createGame = async (game: Game) => {
|
||||
HydraApi.post(`/games`, {
|
||||
HydraApi.post(`/profile/games`, {
|
||||
objectId: game.objectID,
|
||||
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
|
||||
shop: game.shop,
|
||||
|
@ -4,7 +4,7 @@ import { steamGamesWorker } from "@main/workers";
|
||||
import { getSteamAppAsset } from "@main/helpers";
|
||||
|
||||
export const mergeWithRemoteGames = async () => {
|
||||
return HydraApi.get("/games")
|
||||
return HydraApi.get("/profile/games")
|
||||
.then(async (response) => {
|
||||
for (const game of response) {
|
||||
const localGame = await gameRepository.findOne({
|
||||
|
@ -6,7 +6,7 @@ export const updateGamePlaytime = async (
|
||||
deltaInMillis: number,
|
||||
lastTimePlayed: Date
|
||||
) => {
|
||||
HydraApi.put(`/games/${game.remoteId}`, {
|
||||
HydraApi.put(`/profile/games/${game.remoteId}`, {
|
||||
playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000),
|
||||
lastTimePlayed,
|
||||
}).catch(() => {});
|
||||
|
@ -14,7 +14,7 @@ export const uploadGamesBatch = async () => {
|
||||
|
||||
for (const chunk of gamesChunks) {
|
||||
await HydraApi.post(
|
||||
"/games/batch",
|
||||
"/profile/games/batch",
|
||||
chunk.map((game) => {
|
||||
return {
|
||||
objectId: game.objectID,
|
||||
|
@ -6,6 +6,10 @@ log.transports.file.resolvePathFn = (
|
||||
_: log.PathVariables,
|
||||
message?: log.LogMessage | undefined
|
||||
) => {
|
||||
if (message?.scope === "python-instance") {
|
||||
return path.join(logsPath, "pythoninstance.txt");
|
||||
}
|
||||
|
||||
if (message?.level === "error") {
|
||||
return path.join(logsPath, "error.txt");
|
||||
}
|
||||
@ -23,4 +27,5 @@ log.errorHandler.startCatching({
|
||||
|
||||
log.initialize();
|
||||
|
||||
export const pythonInstanceLogger = log.scope("python-instance");
|
||||
export const logger = log.scope("main");
|
||||
|
@ -10,6 +10,6 @@ export const startMainLoop = async () => {
|
||||
DownloadManager.watchDownloads(),
|
||||
]);
|
||||
|
||||
await sleep(500);
|
||||
await sleep(1000);
|
||||
}
|
||||
};
|
||||
|
@ -4,12 +4,16 @@ import { WindowManager } from "./window-manager";
|
||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||
import { GameRunning } from "@types";
|
||||
import { PythonInstance } from "./download";
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
export const gamesPlaytime = new Map<
|
||||
number,
|
||||
{ lastTick: number; firstTick: number }
|
||||
{ lastTick: number; firstTick: number; lastSyncTick: number }
|
||||
>();
|
||||
|
||||
const TICKS_TO_UPDATE_API = 120;
|
||||
let currentTick = 1;
|
||||
|
||||
export const watchProcesses = async () => {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
@ -30,48 +34,17 @@ export const watchProcesses = async () => {
|
||||
|
||||
if (gameProcess) {
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
const gamePlaytime = gamesPlaytime.get(game.id)!;
|
||||
|
||||
const zero = gamePlaytime.lastTick;
|
||||
const delta = performance.now() - zero;
|
||||
|
||||
await gameRepository.update(game.id, {
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
|
||||
lastTimePlayed: new Date(),
|
||||
});
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
...gamePlaytime,
|
||||
lastTick: performance.now(),
|
||||
});
|
||||
onTickGame(game);
|
||||
} else {
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date());
|
||||
} else {
|
||||
createGame({ ...game, lastTimePlayed: new Date() });
|
||||
}
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
lastTick: performance.now(),
|
||||
firstTick: performance.now(),
|
||||
});
|
||||
onOpenGame(game);
|
||||
}
|
||||
} else if (gamesPlaytime.has(game.id)) {
|
||||
const gamePlaytime = gamesPlaytime.get(game.id)!;
|
||||
gamesPlaytime.delete(game.id);
|
||||
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(
|
||||
game,
|
||||
performance.now() - gamePlaytime.firstTick,
|
||||
game.lastTimePlayed!
|
||||
);
|
||||
} else {
|
||||
createGame(game);
|
||||
}
|
||||
onCloseGame(game);
|
||||
}
|
||||
}
|
||||
|
||||
currentTick++;
|
||||
|
||||
if (WindowManager.mainWindow) {
|
||||
const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => {
|
||||
return {
|
||||
@ -86,3 +59,68 @@ export const watchProcesses = async () => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function onOpenGame(game: Game) {
|
||||
const now = performance.now();
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
lastTick: now,
|
||||
firstTick: now,
|
||||
lastSyncTick: now,
|
||||
});
|
||||
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(game, 0, new Date());
|
||||
} else {
|
||||
createGame({ ...game, lastTimePlayed: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
function onTickGame(game: Game) {
|
||||
const now = performance.now();
|
||||
const gamePlaytime = gamesPlaytime.get(game.id)!;
|
||||
|
||||
const delta = now - gamePlaytime.lastTick;
|
||||
|
||||
gameRepository.update(game.id, {
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
|
||||
lastTimePlayed: new Date(),
|
||||
});
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
...gamePlaytime,
|
||||
lastTick: now,
|
||||
});
|
||||
|
||||
if (currentTick % TICKS_TO_UPDATE_API === 0) {
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(
|
||||
game,
|
||||
now - gamePlaytime.lastSyncTick,
|
||||
game.lastTimePlayed!
|
||||
);
|
||||
} else {
|
||||
createGame(game);
|
||||
}
|
||||
|
||||
gamesPlaytime.set(game.id, {
|
||||
...gamePlaytime,
|
||||
lastSyncTick: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const onCloseGame = (game: Game) => {
|
||||
const gamePlaytime = gamesPlaytime.get(game.id)!;
|
||||
gamesPlaytime.delete(game.id);
|
||||
|
||||
if (game.remoteId) {
|
||||
updateGamePlaytime(
|
||||
game,
|
||||
performance.now() - gamePlaytime.firstTick,
|
||||
game.lastTimePlayed!
|
||||
);
|
||||
} else {
|
||||
createGame(game);
|
||||
}
|
||||
};
|
||||
|
@ -8,11 +8,25 @@ export class RepacksManager {
|
||||
private static repacksIndex = new flexSearch.Index();
|
||||
|
||||
public static async updateRepacks() {
|
||||
this.repacks = await repackRepository.find({
|
||||
order: {
|
||||
createdAt: "DESC",
|
||||
},
|
||||
});
|
||||
this.repacks = await repackRepository
|
||||
.find({
|
||||
order: {
|
||||
createdAt: "DESC",
|
||||
},
|
||||
})
|
||||
.then((repacks) =>
|
||||
repacks.map((repack) => {
|
||||
const uris: string[] = [];
|
||||
const magnet = repack?.magnet;
|
||||
|
||||
if (magnet) uris.push(magnet);
|
||||
|
||||
return {
|
||||
...repack,
|
||||
uris: [...uris, ...JSON.parse(repack.uris)],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
for (let i = 0; i < this.repacks.length; i++) {
|
||||
this.repacksIndex.remove(i);
|
||||
|
@ -158,7 +158,7 @@ export class WindowManager {
|
||||
|
||||
const recentlyPlayedGames: Array<MenuItemConstructorOptions | MenuItem> =
|
||||
games.map(({ title, executablePath }) => ({
|
||||
label: title,
|
||||
label: title.length > 15 ? `${title.slice(0, 15)}…` : title,
|
||||
type: "normal",
|
||||
click: async () => {
|
||||
if (!executablePath) return;
|
||||
|
@ -10,6 +10,7 @@ import type {
|
||||
StartGameDownloadPayload,
|
||||
GameRunning,
|
||||
FriendRequestAction,
|
||||
UpdateProfileProps,
|
||||
} from "@types";
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
@ -137,8 +138,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getMe: () => ipcRenderer.invoke("getMe"),
|
||||
undoFriendship: (userId: string) =>
|
||||
ipcRenderer.invoke("undoFriendship", userId),
|
||||
updateProfile: (displayName: string, newProfileImagePath: string | null) =>
|
||||
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
|
||||
updateProfile: (updateProfile: UpdateProfileProps) =>
|
||||
ipcRenderer.invoke("updateProfile", updateProfile),
|
||||
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
|
||||
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||
@ -151,6 +152,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
|
||||
getUserFriends: (userId: string, take: number, skip: number) =>
|
||||
ipcRenderer.invoke("getUserFriends", userId, take, skip),
|
||||
getUserBlocks: (take: number, skip: number) =>
|
||||
ipcRenderer.invoke("getUserBlocks", take, skip),
|
||||
|
||||
/* Auth */
|
||||
signOut: () => ipcRenderer.invoke("signOut"),
|
||||
|
@ -108,7 +108,7 @@ export function App() {
|
||||
fetchFriendRequests();
|
||||
}
|
||||
});
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
||||
}, [fetchUserDetails, fetchFriendRequests, updateUserDetails, dispatch]);
|
||||
|
||||
const onSignIn = useCallback(() => {
|
||||
fetchUserDetails().then((response) => {
|
||||
@ -118,7 +118,13 @@ export function App() {
|
||||
showSuccessToast(t("successfully_signed_in"));
|
||||
}
|
||||
});
|
||||
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]);
|
||||
}, [
|
||||
fetchUserDetails,
|
||||
fetchFriendRequests,
|
||||
t,
|
||||
showSuccessToast,
|
||||
updateUserDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {
|
||||
|
@ -78,7 +78,7 @@ export function SidebarProfile() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
{userDetails && gameRunning?.iconUrl && (
|
||||
<img
|
||||
alt={gameRunning.title}
|
||||
width={24}
|
||||
|
@ -7,4 +7,5 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Torrent]: "Torrent",
|
||||
[Downloader.Gofile]: "Gofile",
|
||||
[Downloader.PixelDrain]: "PixelDrain",
|
||||
[Downloader.Qiwi]: "Qiwi",
|
||||
};
|
||||
|
7
src/renderer/src/declaration.d.ts
vendored
7
src/renderer/src/declaration.d.ts
vendored
@ -17,6 +17,7 @@ import type {
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
UserFriends,
|
||||
UserBlocks,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
@ -135,14 +136,12 @@ declare global {
|
||||
take: number,
|
||||
skip: number
|
||||
) => Promise<UserFriends>;
|
||||
getUserBlocks: (take: number, skip: number) => Promise<UserBlocks>;
|
||||
|
||||
/* Profile */
|
||||
getMe: () => Promise<UserProfile | null>;
|
||||
undoFriendship: (userId: string) => Promise<void>;
|
||||
updateProfile: (
|
||||
displayName: string,
|
||||
newProfileImagePath: string | null
|
||||
) => Promise<UserProfile>;
|
||||
updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
|
||||
getFriendRequests: () => Promise<FriendRequest[]>;
|
||||
updateFriendRequest: (
|
||||
userId: string,
|
||||
|
@ -22,9 +22,10 @@ export function useDownload() {
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const startDownload = (payload: StartGameDownloadPayload) => {
|
||||
const startDownload = async (payload: StartGameDownloadPayload) => {
|
||||
dispatch(clearDownload());
|
||||
window.electron.startGameDownload(payload).then((game) => {
|
||||
|
||||
return window.electron.startGameDownload(payload).then((game) => {
|
||||
updateLibrary();
|
||||
|
||||
return game;
|
||||
|
@ -8,8 +8,9 @@ import {
|
||||
setFriendsModalHidden,
|
||||
} from "@renderer/features";
|
||||
import { profileBackgroundFromProfileImage } from "@renderer/helpers";
|
||||
import { FriendRequestAction, UserDetails } from "@types";
|
||||
import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
export function useUserDetails() {
|
||||
const dispatch = useAppDispatch();
|
||||
@ -43,7 +44,10 @@ export function useUserDetails() {
|
||||
if (userDetails.profileImageUrl) {
|
||||
const profileBackground = await profileBackgroundFromProfileImage(
|
||||
userDetails.profileImageUrl
|
||||
);
|
||||
).catch((err) => {
|
||||
logger.error("profileBackgroundFromProfileImage", err);
|
||||
return `#151515B3`;
|
||||
});
|
||||
dispatch(setProfileBackground(profileBackground));
|
||||
|
||||
window.localStorage.setItem(
|
||||
@ -74,12 +78,8 @@ export function useUserDetails() {
|
||||
}, [clearUserDetails]);
|
||||
|
||||
const patchUser = useCallback(
|
||||
async (displayName: string, imageProfileUrl: string | null) => {
|
||||
const response = await window.electron.updateProfile(
|
||||
displayName,
|
||||
imageProfileUrl
|
||||
);
|
||||
|
||||
async (props: UpdateProfileProps) => {
|
||||
const response = await window.electron.updateProfile(props);
|
||||
return updateUserDetails(response);
|
||||
},
|
||||
[updateUserDetails]
|
||||
@ -99,7 +99,7 @@ export function useUserDetails() {
|
||||
dispatch(setFriendsModalVisible({ initialTab, userId }));
|
||||
fetchFriendRequests();
|
||||
},
|
||||
[dispatch]
|
||||
[dispatch, fetchFriendRequests]
|
||||
);
|
||||
|
||||
const hideFriendsModal = useCallback(() => {
|
||||
|
@ -23,7 +23,7 @@ import {
|
||||
} from "@renderer/context";
|
||||
import { useDownload } from "@renderer/hooks";
|
||||
import { GameOptionsModal, RepacksModal } from "./modals";
|
||||
import { Downloader } from "@shared";
|
||||
import { Downloader, getDownloadersForUri } from "@shared";
|
||||
|
||||
export function GameDetails() {
|
||||
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
||||
@ -70,6 +70,9 @@ export function GameDetails() {
|
||||
}
|
||||
};
|
||||
|
||||
const selectRepackUri = (repack: GameRepack, downloader: Downloader) =>
|
||||
repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!;
|
||||
|
||||
return (
|
||||
<GameDetailsContextProvider>
|
||||
<GameDetailsContextConsumer>
|
||||
@ -96,6 +99,7 @@ export function GameDetails() {
|
||||
downloader,
|
||||
shop: shop as GameShop,
|
||||
downloadPath,
|
||||
uri: selectRepackUri(repack, downloader),
|
||||
});
|
||||
|
||||
await updateGame();
|
||||
|
@ -20,13 +20,16 @@ export const hintText = style({
|
||||
});
|
||||
|
||||
export const downloaders = style({
|
||||
display: "flex",
|
||||
display: "grid",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
});
|
||||
|
||||
export const downloaderOption = style({
|
||||
flex: "1",
|
||||
position: "relative",
|
||||
":only-child": {
|
||||
gridColumn: "1 / -1",
|
||||
},
|
||||
});
|
||||
|
||||
export const downloaderIcon = style({
|
||||
|
@ -5,7 +5,7 @@ import { DiskSpace } from "check-disk-space";
|
||||
import * as styles from "./download-settings-modal.css";
|
||||
import { Button, Link, Modal, TextField } from "@renderer/components";
|
||||
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
|
||||
import { Downloader, formatBytes, getDownloadersForUri } from "@shared";
|
||||
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
|
||||
|
||||
import type { GameRepack } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
@ -48,8 +48,8 @@ export function DownloadSettingsModal({
|
||||
}, [visible, selectedPath]);
|
||||
|
||||
const downloaders = useMemo(() => {
|
||||
return getDownloadersForUri(repack?.magnet ?? "");
|
||||
}, [repack?.magnet]);
|
||||
return getDownloadersForUris(repack?.uris ?? []);
|
||||
}, [repack?.uris]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences?.downloadsPath) {
|
||||
|
@ -76,6 +76,13 @@ export function RepacksModal({
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfLastDownloadedOption = (repack: GameRepack) => {
|
||||
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash));
|
||||
if (!game?.uri) return false;
|
||||
|
||||
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DownloadSettingsModal
|
||||
@ -97,9 +104,7 @@ export function RepacksModal({
|
||||
|
||||
<div className={styles.repacks}>
|
||||
{filteredRepacks.map((repack) => {
|
||||
const isLastDownloadedOption =
|
||||
infoHash !== null &&
|
||||
repack.magnet.toLowerCase().includes(infoHash);
|
||||
const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
@ -42,10 +42,3 @@ export const downloadSourcesHeader = style({
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
export const separator = style({
|
||||
height: "100%",
|
||||
width: "1px",
|
||||
backgroundColor: vars.color.border,
|
||||
margin: `${SPACING_UNIT}px 0`,
|
||||
});
|
||||
|
@ -134,15 +134,6 @@ export function SettingsDownloadSources() {
|
||||
downloadSource.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<small>
|
||||
{t("download_options", {
|
||||
count: downloadSource.repackCount,
|
||||
countFormatted: downloadSource.repackCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import * as styles from "./user-friend-modal.css";
|
||||
import cn from "classnames";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -12,21 +11,26 @@ export type UserFriendItemProps = {
|
||||
userId: string;
|
||||
profileImageUrl: string | null;
|
||||
displayName: string;
|
||||
onClickItem: (userId: string) => void;
|
||||
} & (
|
||||
| { type: "ACCEPTED"; onClickUndoFriendship: (userId: string) => void }
|
||||
| {
|
||||
type: "ACCEPTED";
|
||||
onClickUndoFriendship: (userId: string) => void;
|
||||
onClickItem: (userId: string) => void;
|
||||
}
|
||||
| { type: "BLOCKED"; onClickUnblock: (userId: string) => void }
|
||||
| {
|
||||
type: "SENT" | "RECEIVED";
|
||||
onClickCancelRequest: (userId: string) => void;
|
||||
onClickAcceptRequest: (userId: string) => void;
|
||||
onClickRefuseRequest: (userId: string) => void;
|
||||
onClickItem: (userId: string) => void;
|
||||
}
|
||||
| { type: null }
|
||||
| { type: null; onClickItem: (userId: string) => void }
|
||||
);
|
||||
|
||||
export const UserFriendItem = (props: UserFriendItemProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { userId, profileImageUrl, displayName, type, onClickItem } = props;
|
||||
const { userId, profileImageUrl, displayName, type } = props;
|
||||
|
||||
const getRequestDescription = () => {
|
||||
if (type === "ACCEPTED" || type === null) return null;
|
||||
@ -86,15 +90,69 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "BLOCKED") {
|
||||
return (
|
||||
<button
|
||||
className={styles.cancelRequestButton}
|
||||
onClick={() => props.onClickUnblock(userId)}
|
||||
title={t("unblock")}
|
||||
>
|
||||
<XCircleIcon size={28} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (type === "BLOCKED") {
|
||||
return (
|
||||
<div className={styles.friendListContainer}>
|
||||
<div className={styles.friendListButton} style={{ cursor: "inherit" }}>
|
||||
<div className={styles.friendAvatarContainer}>
|
||||
{profileImageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
alt={displayName}
|
||||
src={profileImageUrl}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
flex: "1",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<p className={styles.friendListDisplayName}>{displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "8px",
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{getRequestActions()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
|
||||
<div className={styles.friendListContainer}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.friendListButton}
|
||||
onClick={() => onClickItem(userId)}
|
||||
onClick={() => props.onClickItem(userId)}
|
||||
>
|
||||
<div className={styles.friendAvatarContainer}>
|
||||
{profileImageUrl ? (
|
||||
|
@ -40,20 +40,16 @@ export const UserFriendModalAddFriend = ({
|
||||
});
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setFriendCode("");
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleClickRequest = (userId: string) => {
|
||||
resetAndClose();
|
||||
closeModal();
|
||||
navigate(`/user/${userId}`);
|
||||
};
|
||||
|
||||
const handleClickSeeProfile = () => {
|
||||
resetAndClose();
|
||||
// TODO: add validation for this input?
|
||||
navigate(`/user/${friendCode}`);
|
||||
closeModal();
|
||||
if (friendCode.length === 8) {
|
||||
navigate(`/user/${friendCode}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelFriendRequest = (userId: string) => {
|
||||
@ -122,7 +118,8 @@ export const UserFriendModalAddFriend = ({
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h3>Pendentes</h3>
|
||||
<h3>{t("pending")}</h3>
|
||||
{friendRequests.length === 0 && <p>{t("no_pending_invites")}</p>}
|
||||
{friendRequests.map((request) => {
|
||||
return (
|
||||
<UserFriendItem
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { UserFriend } from "@types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { UserFriendItem } from "./user-friend-item";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
export interface UserFriendModalListProps {
|
||||
userId: string;
|
||||
@ -22,14 +23,17 @@ export const UserFriendModalList = ({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [maxPage, setMaxPage] = useState(0);
|
||||
const [friends, setFriends] = useState<UserFriend[]>([]);
|
||||
const listContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { userDetails, undoFriendship } = useUserDetails();
|
||||
const isMe = userDetails?.id == userId;
|
||||
|
||||
const loadNextPage = () => {
|
||||
if (page > maxPage) return;
|
||||
setIsLoading(true);
|
||||
window.electron
|
||||
.getUserFriends(userId, pageSize, page * pageSize)
|
||||
.then((newPage) => {
|
||||
@ -40,9 +44,29 @@ export const UserFriendModalList = ({
|
||||
setFriends([...friends, ...newPage.friends]);
|
||||
setPage(page + 1);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = listContainer.current?.scrollTop || 0;
|
||||
const scrollHeight = listContainer.current?.scrollHeight || 0;
|
||||
const clientHeight = listContainer.current?.clientHeight || 0;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
|
||||
if (scrollTop < maxScrollTop * 0.9 || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadNextPage();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = listContainer.current;
|
||||
container?.addEventListener("scroll", handleScroll);
|
||||
return () => container?.removeEventListener("scroll", handleScroll);
|
||||
}, [isLoading]);
|
||||
|
||||
const reloadList = () => {
|
||||
setPage(0);
|
||||
setMaxPage(0);
|
||||
@ -70,26 +94,42 @@ export const UserFriendModalList = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{friends.map((friend) => {
|
||||
return (
|
||||
<UserFriendItem
|
||||
userId={friend.id}
|
||||
displayName={friend.displayName}
|
||||
profileImageUrl={friend.profileImageUrl}
|
||||
onClickItem={handleClickFriend}
|
||||
onClickUndoFriendship={handleUndoFriendship}
|
||||
type={isMe ? "ACCEPTED" : null}
|
||||
key={friend.id}
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<div
|
||||
ref={listContainer}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
maxHeight: "400px",
|
||||
overflowY: "scroll",
|
||||
}}
|
||||
>
|
||||
{!isLoading && friends.length === 0 && <p>{t("no_friends_added")}</p>}
|
||||
{friends.map((friend) => {
|
||||
return (
|
||||
<UserFriendItem
|
||||
userId={friend.id}
|
||||
displayName={friend.displayName}
|
||||
profileImageUrl={friend.profileImageUrl}
|
||||
onClickItem={handleClickFriend}
|
||||
onClickUndoFriendship={handleUndoFriendship}
|
||||
type={isMe ? "ACCEPTED" : null}
|
||||
key={"modal" + friend.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "54px",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
};
|
||||
|
@ -1,17 +1,6 @@
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const profileContentBox = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
alignItems: "center",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
width: "100%",
|
||||
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
|
||||
transition: "all ease 0.3s",
|
||||
});
|
||||
|
||||
export const friendAvatarContainer = style({
|
||||
width: "35px",
|
||||
minWidth: "35px",
|
||||
@ -42,8 +31,14 @@ export const profileAvatar = style({
|
||||
});
|
||||
|
||||
export const friendListContainer = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
alignItems: "center",
|
||||
borderRadius: "4px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
width: "100%",
|
||||
height: "54px",
|
||||
minHeight: "54px",
|
||||
transition: "all ease 0.2s",
|
||||
position: "relative",
|
||||
":hover": {
|
||||
@ -90,3 +85,15 @@ export const cancelRequestButton = style({
|
||||
color: vars.color.danger,
|
||||
},
|
||||
});
|
||||
|
||||
export const friendCodeButton = style({
|
||||
color: vars.color.body,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
alignItems: "center",
|
||||
transition: "all ease 0.2s",
|
||||
":hover": {
|
||||
color: vars.color.muted,
|
||||
},
|
||||
});
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { UserFriendModalList } from "./user-friend-modal-list";
|
||||
import { CopyIcon } from "@primer/octicons-react";
|
||||
import * as styles from "./user-friend-modal.css";
|
||||
|
||||
export enum UserFriendModalTab {
|
||||
FriendsList,
|
||||
@ -32,6 +34,8 @@ export const UserFriendModal = ({
|
||||
initialTab || UserFriendModalTab.FriendsList
|
||||
);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
const isMe = userDetails?.id == userId;
|
||||
|
||||
@ -53,6 +57,11 @@ export const UserFriendModal = ({
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const copyToClipboard = useCallback(() => {
|
||||
navigator.clipboard.writeText(userDetails!.id);
|
||||
showSuccessToast(t("friend_code_copied"));
|
||||
}, [userDetails, showSuccessToast, t]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={t("friends")} onClose={onClose}>
|
||||
<div
|
||||
@ -64,19 +73,37 @@ export const UserFriendModal = ({
|
||||
}}
|
||||
>
|
||||
{isMe && (
|
||||
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={tab}
|
||||
theme={index === currentTab ? "primary" : "outline"}
|
||||
onClick={() => setCurrentTab(index)}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<p>Seu código de amigo: </p>
|
||||
<button
|
||||
className={styles.friendCodeButton}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
<h3>{userDetails.id}</h3>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
</div>
|
||||
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={tab}
|
||||
theme={index === currentTab ? "primary" : "outline"}
|
||||
onClick={() => setCurrentTab(index)}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{renderTab()}
|
||||
</div>
|
||||
|
@ -1,4 +1,9 @@
|
||||
import { FriendRequestAction, UserGame, UserProfile } from "@types";
|
||||
import {
|
||||
FriendRequestAction,
|
||||
GameRunning,
|
||||
UserGame,
|
||||
UserProfile,
|
||||
} from "@types";
|
||||
import cn from "classnames";
|
||||
import * as styles from "./user.css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
@ -25,7 +30,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Button, Link } from "@renderer/components";
|
||||
import { UserEditProfileModal } from "./user-edit-modal";
|
||||
import { UserProfileSettingsModal } from "./user-profile-settings-modal";
|
||||
import { UserSignOutModal } from "./user-sign-out-modal";
|
||||
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
|
||||
import { UserBlockModal } from "./user-block-modal";
|
||||
@ -44,7 +49,6 @@ export function UserContent({
|
||||
updateUserProfile,
|
||||
}: ProfileContentProps) {
|
||||
const { t, i18n } = useTranslation("user_profile");
|
||||
|
||||
const {
|
||||
userDetails,
|
||||
profileBackground,
|
||||
@ -60,9 +64,11 @@ export function UserContent({
|
||||
|
||||
const [profileContentBoxBackground, setProfileContentBoxBackground] =
|
||||
useState<string | undefined>();
|
||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||
const [showProfileSettingsModal, setShowProfileSettingsModal] =
|
||||
useState(false);
|
||||
const [showSignOutModal, setShowSignOutModal] = useState(false);
|
||||
const [showUserBlockModal, setShowUserBlockModal] = useState(false);
|
||||
const [currentGame, setCurrentGame] = useState<GameRunning | null>(null);
|
||||
|
||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||
|
||||
@ -95,7 +101,7 @@ export function UserContent({
|
||||
};
|
||||
|
||||
const handleEditProfile = () => {
|
||||
setShowEditProfileModal(true);
|
||||
setShowProfileSettingsModal(true);
|
||||
};
|
||||
|
||||
const handleOnClickFriend = (userId: string) => {
|
||||
@ -112,9 +118,18 @@ export function UserContent({
|
||||
|
||||
const isMe = userDetails?.id == userProfile.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isMe && gameRunning) {
|
||||
setCurrentGame(gameRunning);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentGame(userProfile.currentGame);
|
||||
}, [gameRunning, isMe, userProfile.currentGame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMe) fetchFriendRequests();
|
||||
}, [isMe]);
|
||||
}, [isMe, fetchFriendRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMe && profileBackground) {
|
||||
@ -128,7 +143,7 @@ export function UserContent({
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [profileBackground, isMe]);
|
||||
}, [profileBackground, isMe, userProfile.profileImageUrl]);
|
||||
|
||||
const handleFriendAction = (userId: string, action: FriendAction) => {
|
||||
try {
|
||||
@ -159,13 +174,18 @@ export function UserContent({
|
||||
};
|
||||
|
||||
const showFriends = isMe || userProfile.totalFriends > 0;
|
||||
const showProfileContent =
|
||||
isMe ||
|
||||
userProfile.profileVisibility === "PUBLIC" ||
|
||||
(userProfile.relation?.status === "ACCEPTED" &&
|
||||
userProfile.profileVisibility === "FRIENDS");
|
||||
|
||||
const getProfileActions = () => {
|
||||
if (isMe) {
|
||||
return (
|
||||
<>
|
||||
<Button theme="outline" onClick={handleEditProfile}>
|
||||
{t("edit_profile")}
|
||||
{t("settings")}
|
||||
</Button>
|
||||
|
||||
<Button theme="danger" onClick={() => setShowSignOutModal(true)}>
|
||||
@ -251,9 +271,9 @@ export function UserContent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserEditProfileModal
|
||||
visible={showEditProfileModal}
|
||||
onClose={() => setShowEditProfileModal(false)}
|
||||
<UserProfileSettingsModal
|
||||
visible={showProfileSettingsModal}
|
||||
onClose={() => setShowProfileSettingsModal(false)}
|
||||
updateUserProfile={updateUserProfile}
|
||||
userProfile={userProfile}
|
||||
/>
|
||||
@ -278,10 +298,10 @@ export function UserContent({
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{gameRunning && isMe && (
|
||||
{currentGame && (
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(gameRunning.objectID)}
|
||||
alt={gameRunning.title}
|
||||
src={steamUrlBuilder.libraryHero(currentGame.objectID)}
|
||||
alt={currentGame.title}
|
||||
className={styles.profileBackground}
|
||||
/>
|
||||
)}
|
||||
@ -309,7 +329,7 @@ export function UserContent({
|
||||
|
||||
<div className={styles.profileInformation}>
|
||||
<h2 style={{ fontWeight: "bold" }}>{userProfile.displayName}</h2>
|
||||
{isMe && gameRunning && (
|
||||
{currentGame && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@ -325,14 +345,14 @@ export function UserContent({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Link to={buildGameDetailsPath(gameRunning)}>
|
||||
{gameRunning.title}
|
||||
<Link to={buildGameDetailsPath(currentGame)}>
|
||||
{currentGame.title}
|
||||
</Link>
|
||||
</div>
|
||||
<small>
|
||||
{t("playing_for", {
|
||||
amount: formatDiffInMillis(
|
||||
gameRunning.sessionDurationInMillis,
|
||||
currentGame.sessionDurationInMillis,
|
||||
new Date()
|
||||
),
|
||||
})}
|
||||
@ -361,121 +381,69 @@ export function UserContent({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.profileContent}>
|
||||
<div className={styles.profileGameSection}>
|
||||
<h2>{t("activity")}</h2>
|
||||
|
||||
{!userProfile.recentGames.length ? (
|
||||
<div className={styles.noDownloads}>
|
||||
<div className={styles.telescopeIcon}>
|
||||
<TelescopeIcon size={24} />
|
||||
</div>
|
||||
<h2>{t("no_recent_activity_title")}</h2>
|
||||
{isMe && <p>{t("no_recent_activity_description")}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.recentGames.map((game) => (
|
||||
<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={styles.contentSidebar}>
|
||||
{showProfileContent && (
|
||||
<div className={styles.profileContent}>
|
||||
<div className={styles.profileGameSection}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h2>{t("library")}</h2>
|
||||
<h2>{t("activity")}</h2>
|
||||
|
||||
{!userProfile.recentGames.length ? (
|
||||
<div className={styles.noDownloads}>
|
||||
<div className={styles.telescopeIcon}>
|
||||
<TelescopeIcon size={24} />
|
||||
</div>
|
||||
<h2>{t("no_recent_activity_title")}</h2>
|
||||
{isMe && <p>{t("no_recent_activity_description")}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: vars.color.border,
|
||||
height: "1px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>
|
||||
{userProfile.libraryGames.length}
|
||||
</h3>
|
||||
</div>
|
||||
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.libraryGames.map((game) => (
|
||||
<button
|
||||
key={game.objectID}
|
||||
className={cn(styles.gameListItem, styles.profileContentBox)}
|
||||
onClick={() => handleGameClick(game)}
|
||||
title={game.title}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
>
|
||||
{userProfile.recentGames.map((game) => (
|
||||
<button
|
||||
key={game.objectID}
|
||||
className={cn(styles.feedItem, styles.profileContentBox)}
|
||||
onClick={() => handleGameClick(game)}
|
||||
>
|
||||
<img
|
||||
className={styles.libraryGameIcon}
|
||||
src={game.iconUrl}
|
||||
className={styles.feedGameIcon}
|
||||
src={game.cover}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.libraryGameIcon} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{showFriends && (
|
||||
<div className={styles.friendsSection}>
|
||||
<button
|
||||
className={styles.friendsSectionHeader}
|
||||
onClick={() =>
|
||||
showFriendsModal(
|
||||
UserFriendModalTab.FriendsList,
|
||||
userProfile.id
|
||||
)
|
||||
}
|
||||
<div className={styles.contentSidebar}>
|
||||
<div className={styles.profileGameSection}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<h2>{t("friends")}</h2>
|
||||
<h2>{t("library")}</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@ -485,64 +453,123 @@ export function UserContent({
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>
|
||||
{userProfile.totalFriends}
|
||||
{userProfile.libraryGames.length}
|
||||
</h3>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<small>
|
||||
{t("total_play_time", { amount: formatPlayTime() })}
|
||||
</small>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.friends.map((friend) => {
|
||||
return (
|
||||
<button
|
||||
key={friend.id}
|
||||
className={cn(
|
||||
styles.profileContentBox,
|
||||
styles.friendListContainer
|
||||
)}
|
||||
onClick={() => handleOnClickFriend(friend.id)}
|
||||
>
|
||||
<div className={styles.friendAvatarContainer}>
|
||||
{friend.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.friendProfileIcon}
|
||||
src={friend.profileImageUrl}
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={styles.friendListDisplayName}>
|
||||
{friend.displayName}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{isMe && (
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() =>
|
||||
showFriendsModal(
|
||||
UserFriendModalTab.AddFriend,
|
||||
userProfile.id
|
||||
)
|
||||
}
|
||||
{userProfile.libraryGames.map((game) => (
|
||||
<button
|
||||
key={game.objectID}
|
||||
className={cn(
|
||||
styles.gameListItem,
|
||||
styles.profileContentBox
|
||||
)}
|
||||
onClick={() => handleGameClick(game)}
|
||||
title={game.title}
|
||||
>
|
||||
<PlusIcon /> {t("add")}
|
||||
</Button>
|
||||
)}
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.libraryGameIcon}
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.libraryGameIcon} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFriends && (
|
||||
<div className={styles.friendsSection}>
|
||||
<button
|
||||
className={styles.friendsSectionHeader}
|
||||
onClick={() =>
|
||||
showFriendsModal(
|
||||
UserFriendModalTab.FriendsList,
|
||||
userProfile.id
|
||||
)
|
||||
}
|
||||
>
|
||||
<h2>{t("friends")}</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: vars.color.border,
|
||||
height: "1px",
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ fontWeight: "400" }}>
|
||||
{userProfile.totalFriends}
|
||||
</h3>
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{userProfile.friends.map((friend) => {
|
||||
return (
|
||||
<button
|
||||
key={friend.id}
|
||||
className={cn(
|
||||
styles.profileContentBox,
|
||||
styles.friendListContainer
|
||||
)}
|
||||
onClick={() => handleOnClickFriend(friend.id)}
|
||||
>
|
||||
<div className={styles.friendAvatarContainer}>
|
||||
{friend.profileImageUrl ? (
|
||||
<img
|
||||
className={styles.friendProfileIcon}
|
||||
src={friend.profileImageUrl}
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={styles.friendListDisplayName}>
|
||||
{friend.displayName}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{isMe && (
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() =>
|
||||
showFriendsModal(
|
||||
UserFriendModalTab.AddFriend,
|
||||
userProfile.id
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon /> {t("add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,147 +0,0 @@
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import { UserProfile } from "@types";
|
||||
import * as styles from "./user.css";
|
||||
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface UserEditProfileModalProps {
|
||||
userProfile: UserProfile;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
updateUserProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const UserEditProfileModal = ({
|
||||
userProfile,
|
||||
visible,
|
||||
onClose,
|
||||
updateUserProfile,
|
||||
}: UserEditProfileModalProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [newImagePath, setNewImagePath] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const { patchUser } = useUserDetails();
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(userProfile.displayName);
|
||||
}, [userProfile.displayName]);
|
||||
|
||||
const handleChangeProfileAvatar = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["jpg", "jpeg", "png", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
|
||||
setNewImagePath(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
patchUser(displayName, newImagePath)
|
||||
.then(async () => {
|
||||
await updateUserProfile();
|
||||
showSuccessToast(t("saved_successfully"));
|
||||
cleanFormAndClose();
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("try_again"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setDisplayName(userProfile.displayName);
|
||||
setNewImagePath(null);
|
||||
};
|
||||
|
||||
const cleanFormAndClose = () => {
|
||||
resetModal();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const avatarUrl = useMemo(() => {
|
||||
if (newImagePath) return `local:${newImagePath}`;
|
||||
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
|
||||
return null;
|
||||
}, [newImagePath, userProfile.profileImageUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("edit_profile")}
|
||||
onClose={cleanFormAndClose}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSaveProfile}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
width: "350px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileAvatarEditContainer}
|
||||
onClick={handleChangeProfileAvatar}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
alt={userProfile.displayName}
|
||||
src={avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={96} />
|
||||
)}
|
||||
<div className={styles.editProfileImageBadge}>
|
||||
<DeviceCameraIcon size={16} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<TextField
|
||||
label={t("display_name")}
|
||||
value={displayName}
|
||||
required
|
||||
minLength={3}
|
||||
containerProps={{ style: { width: "100%" } }}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
style={{ alignSelf: "end" }}
|
||||
type="submit"
|
||||
>
|
||||
{isSaving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from "./user-profile-settings-modal";
|
@ -0,0 +1,118 @@
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { UserFriend } from "@types";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendItem } from "@renderer/pages/shared-modals/user-friend-modal/user-friend-item";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
const pageSize = 12;
|
||||
|
||||
export const UserEditProfileBlockList = () => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { showErrorToast } = useToast();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [maxPage, setMaxPage] = useState(0);
|
||||
const [blocks, setBlocks] = useState<UserFriend[]>([]);
|
||||
const listContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { unblockUser } = useUserDetails();
|
||||
|
||||
const loadNextPage = () => {
|
||||
if (page > maxPage) return;
|
||||
setIsLoading(true);
|
||||
window.electron
|
||||
.getUserBlocks(pageSize, page * pageSize)
|
||||
.then((newPage) => {
|
||||
if (page === 0) {
|
||||
setMaxPage(newPage.totalBlocks / pageSize);
|
||||
}
|
||||
|
||||
setBlocks([...blocks, ...newPage.blocks]);
|
||||
setPage(page + 1);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = listContainer.current?.scrollTop || 0;
|
||||
const scrollHeight = listContainer.current?.scrollHeight || 0;
|
||||
const clientHeight = listContainer.current?.clientHeight || 0;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
|
||||
if (scrollTop < maxScrollTop * 0.9 || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadNextPage();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = listContainer.current;
|
||||
container?.addEventListener("scroll", handleScroll);
|
||||
return () => container?.removeEventListener("scroll", handleScroll);
|
||||
}, [isLoading]);
|
||||
|
||||
const reloadList = () => {
|
||||
setPage(0);
|
||||
setMaxPage(0);
|
||||
setBlocks([]);
|
||||
loadNextPage();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadList();
|
||||
}, []);
|
||||
|
||||
const handleUnblock = (userId: string) => {
|
||||
unblockUser(userId)
|
||||
.then(() => {
|
||||
reloadList();
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("try_again"));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<div
|
||||
ref={listContainer}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
maxHeight: "400px",
|
||||
overflowY: "scroll",
|
||||
}}
|
||||
>
|
||||
{!isLoading && blocks.length === 0 && <p>{t("no_blocked_users")}</p>}
|
||||
{blocks.map((friend) => {
|
||||
return (
|
||||
<UserFriendItem
|
||||
userId={friend.id}
|
||||
displayName={friend.displayName}
|
||||
profileImageUrl={friend.profileImageUrl}
|
||||
onClickUnblock={handleUnblock}
|
||||
type={"BLOCKED"}
|
||||
key={friend.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "54px",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
};
|
@ -0,0 +1,149 @@
|
||||
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
|
||||
import { Button, SelectField, TextField } from "@renderer/components";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { UserProfile } from "@types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "../user.css";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export interface UserEditProfileProps {
|
||||
userProfile: UserProfile;
|
||||
updateUserProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const UserEditProfile = ({
|
||||
userProfile,
|
||||
updateUserProfile,
|
||||
}: UserEditProfileProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const [form, setForm] = useState({
|
||||
displayName: userProfile.displayName,
|
||||
profileVisibility: userProfile.profileVisibility,
|
||||
imageProfileUrl: null as string | null,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const { patchUser } = useUserDetails();
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [profileVisibilityOptions, setProfileVisibilityOptions] = useState<
|
||||
{ value: string; label: string }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileVisibilityOptions([
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
{ value: "FRIENDS", label: t("friends_only") },
|
||||
{ value: "PRIVATE", label: t("private") },
|
||||
]);
|
||||
}, [t]);
|
||||
|
||||
const handleChangeProfileAvatar = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["jpg", "jpeg", "png", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
|
||||
setForm({ ...form, imageProfileUrl: path });
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileVisibilityChange = (event) => {
|
||||
setForm({
|
||||
...form,
|
||||
profileVisibility: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
patchUser(form)
|
||||
.then(async () => {
|
||||
await updateUserProfile();
|
||||
showSuccessToast(t("saved_successfully"));
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("try_again"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const avatarUrl = useMemo(() => {
|
||||
if (form.imageProfileUrl) return `local:${form.imageProfileUrl}`;
|
||||
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
|
||||
return null;
|
||||
}, [form, userProfile]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSaveProfile}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
width: "350px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileAvatarEditContainer}
|
||||
onClick={handleChangeProfileAvatar}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
alt={userProfile.displayName}
|
||||
src={avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={96} />
|
||||
)}
|
||||
<div className={styles.editProfileImageBadge}>
|
||||
<DeviceCameraIcon size={16} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<TextField
|
||||
label={t("display_name")}
|
||||
value={form.displayName}
|
||||
required
|
||||
minLength={3}
|
||||
containerProps={{ style: { width: "100%" } }}
|
||||
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
||||
/>
|
||||
|
||||
<SelectField
|
||||
label={t("privacy")}
|
||||
value={form.profileVisibility}
|
||||
onChange={handleProfileVisibilityChange}
|
||||
options={profileVisibilityOptions.map((visiblity) => ({
|
||||
key: visiblity.value,
|
||||
value: visiblity.value,
|
||||
label: visiblity.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Button disabled={isSaving} style={{ alignSelf: "end" }} type="submit">
|
||||
{isSaving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -0,0 +1,73 @@
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import { UserProfile } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserEditProfile } from "./user-edit-profile";
|
||||
import { UserEditProfileBlockList } from "./user-block-list";
|
||||
|
||||
export interface UserProfileSettingsModalProps {
|
||||
userProfile: UserProfile;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
updateUserProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const UserProfileSettingsModal = ({
|
||||
userProfile,
|
||||
visible,
|
||||
onClose,
|
||||
updateUserProfile,
|
||||
}: UserProfileSettingsModalProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const tabs = [t("edit_profile"), t("blocked_users")];
|
||||
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
|
||||
const renderTab = () => {
|
||||
if (currentTabIndex == 0) {
|
||||
return (
|
||||
<UserEditProfile
|
||||
userProfile={userProfile}
|
||||
updateUserProfile={updateUserProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentTabIndex == 1) {
|
||||
return <UserEditProfileBlockList />;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal visible={visible} title={t("settings")} onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={tab}
|
||||
theme={index === currentTabIndex ? "primary" : "outline"}
|
||||
onClick={() => setCurrentTabIndex(index)}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
{renderTab()}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
@ -60,6 +60,7 @@ export const friendListDisplayName = style({
|
||||
});
|
||||
|
||||
export const profileAvatarEditContainer = style({
|
||||
alignSelf: "center",
|
||||
width: "128px",
|
||||
height: "128px",
|
||||
display: "flex",
|
||||
|
@ -31,7 +31,7 @@ export const User = () => {
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}, [dispatch, userId, t]);
|
||||
}, [dispatch, navigate, showErrorToast, userId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserProfile();
|
||||
|
@ -3,6 +3,7 @@ export enum Downloader {
|
||||
Torrent,
|
||||
Gofile,
|
||||
PixelDrain,
|
||||
Qiwi,
|
||||
}
|
||||
|
||||
export enum DownloadSourceStatus {
|
||||
@ -73,13 +74,27 @@ const realDebridHosts = ["https://1fichier.com", "https://mediafire.com"];
|
||||
|
||||
export const getDownloadersForUri = (uri: string) => {
|
||||
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
|
||||
|
||||
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain];
|
||||
if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi];
|
||||
|
||||
if (realDebridHosts.some((host) => uri.startsWith(host)))
|
||||
return [Downloader.RealDebrid];
|
||||
|
||||
if (uri.startsWith("magnet:"))
|
||||
if (uri.startsWith("magnet:")) {
|
||||
return [Downloader.Torrent, Downloader.RealDebrid];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getDownloadersForUris = (uris: string[]) => {
|
||||
const downloadersSet = uris.reduce<Set<Downloader>>((prev, next) => {
|
||||
const downloaders = getDownloadersForUri(next);
|
||||
downloaders.forEach((downloader) => prev.add(downloader));
|
||||
|
||||
return prev;
|
||||
}, new Set());
|
||||
|
||||
return Array.from(downloadersSet);
|
||||
};
|
||||
|
@ -67,7 +67,11 @@ export interface SteamAppDetails {
|
||||
export interface GameRepack {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* @deprecated Use uris instead
|
||||
*/
|
||||
magnet: string;
|
||||
uris: string[];
|
||||
repacker: string;
|
||||
fileSize: string | null;
|
||||
uploadDate: Date | string | null;
|
||||
@ -137,9 +141,9 @@ export interface Game {
|
||||
export type LibraryGame = Omit<Game, "repacks">;
|
||||
|
||||
export interface GameRunning {
|
||||
id: number;
|
||||
id?: number;
|
||||
title: string;
|
||||
iconUrl: string;
|
||||
iconUrl: string | null;
|
||||
objectID: string;
|
||||
shop: GameShop;
|
||||
sessionDurationInMillis: number;
|
||||
@ -194,6 +198,7 @@ export interface StartGameDownloadPayload {
|
||||
objectID: string;
|
||||
title: string;
|
||||
shop: GameShop;
|
||||
uri: string;
|
||||
downloadPath: string;
|
||||
downloader: Downloader;
|
||||
}
|
||||
@ -282,6 +287,11 @@ export interface UserFriends {
|
||||
friends: UserFriend[];
|
||||
}
|
||||
|
||||
export interface UserBlocks {
|
||||
totalBlocks: number;
|
||||
blocks: UserFriend[];
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@ -308,6 +318,14 @@ export interface UserProfile {
|
||||
friends: UserFriend[];
|
||||
totalFriends: number;
|
||||
relation: UserRelation | null;
|
||||
currentGame: GameRunning | null;
|
||||
}
|
||||
|
||||
export interface UpdateProfileProps {
|
||||
displayName?: string;
|
||||
profileVisibility?: "PUBLIC" | "PRIVATE" | "FRIENDS";
|
||||
profileImageUrl?: string | null;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
export interface DownloadSource {
|
||||
|
@ -20,6 +20,23 @@ if start_download_payload:
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
rpc_password_header = 'x-hydra-rpc-password'
|
||||
|
||||
skip_log_routes = [
|
||||
"process-list",
|
||||
"status"
|
||||
]
|
||||
|
||||
def log_error(self, format, *args):
|
||||
sys.stderr.write("%s - - [%s] %s\n" %
|
||||
(self.address_string(),
|
||||
self.log_date_time_string(),
|
||||
format%args))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
for route in self.skip_log_routes:
|
||||
if route in args[0]: return
|
||||
|
||||
super().log_message(format, *args)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/status":
|
||||
if self.headers.get(self.rpc_password_header) != rpc_password:
|
||||
|
Loading…
Reference in New Issue
Block a user