mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-17 16:33:55 +00:00
Compare commits
30 Commits
fix/fixing
...
fix/fixing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47bfc1648f | ||
|
|
a69a6ec510 | ||
|
|
fada6507c3 | ||
|
|
0479f1347b | ||
|
|
f44d5c8b49 | ||
|
|
c36109c092 | ||
|
|
b59fb7dc36 | ||
|
|
214a7af408 | ||
|
|
14679fc31e | ||
|
|
b5445b3dfa | ||
|
|
1ccf70af12 | ||
|
|
bb45b95820 | ||
|
|
361c158a44 | ||
|
|
1f5e84b32c | ||
|
|
e49d885b30 | ||
|
|
cb01301a0d | ||
|
|
e872b2ea8a | ||
|
|
dd7c84b433 | ||
|
|
1546da29cf | ||
|
|
a89b0bb2a8 | ||
|
|
9bdb216e0f | ||
|
|
9779aed8c1 | ||
|
|
058a148c7f | ||
|
|
16e3d52508 | ||
|
|
7e0002cf95 | ||
|
|
bf8b3ca836 | ||
|
|
77e376e742 | ||
|
|
bd28b202c4 | ||
|
|
153b954e78 | ||
|
|
a9e63730be |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.7.4",
|
||||
"version": "3.7.6",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -19,12 +19,12 @@
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build": "yarn run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps && node ./scripts/postinstall.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:unpack": "yarn run build && electron-builder --dir",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
|
||||
@@ -414,7 +414,11 @@
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage",
|
||||
"extract": "Extract files",
|
||||
"extracting": "Extracting files…"
|
||||
"extracting": "Extracting files…",
|
||||
"network": "Network",
|
||||
"peak": "Peak",
|
||||
"seeds": "Seeds",
|
||||
"peers": "Peers"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
|
||||
@@ -414,7 +414,11 @@
|
||||
"resume_seeding": "Continuar sembrando",
|
||||
"options": "Administrar",
|
||||
"extract": "Extraer archivos",
|
||||
"extracting": "Extrayendo archivos…"
|
||||
"extracting": "Extrayendo archivos…",
|
||||
"network": "Red",
|
||||
"peak": "Pico",
|
||||
"seeds": "Seeds",
|
||||
"peers": "Peers"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Ruta de descarga",
|
||||
@@ -458,6 +462,7 @@
|
||||
"description_confirmation_delete_all_sources": "Vas a eliminar todas las fuentes de descargas",
|
||||
"button_delete_all_sources": "Eliminar todo",
|
||||
"added_download_source": "Añadir fuente de descarga",
|
||||
"adding": "Añadiendo…",
|
||||
"download_sources_synced": "Todas las fuentes de descarga están sincronizadas",
|
||||
"insert_valid_json_url": "Introducí una URL de json válida",
|
||||
"found_download_option_zero": "Sin opciones de descargas encontrada",
|
||||
@@ -563,6 +568,19 @@
|
||||
"debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.",
|
||||
"enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego",
|
||||
"autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego",
|
||||
"change_achievement_sound": "Cambiar sonido de logro",
|
||||
"download_source_already_exists": "Esta fuente de descarga URL ya existe.",
|
||||
"download_source_failed": "Error",
|
||||
"download_source_matched": "Actualizado",
|
||||
"download_source_matching": "Actualizando",
|
||||
"download_source_no_information": "Sin información disponible",
|
||||
"download_source_pending_matching": "Actualizando pronto",
|
||||
"download_sources_synced_successfully": "Todas las fuentes de descarga están sincronizadas",
|
||||
"failed_add_download_source": "Error al añadir la fuente de descarga. Por favor intentá de nuevo.",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"preview_sound": "Vista previa de sonido",
|
||||
"remove_achievement_sound": "Eliminar sonido de logros",
|
||||
"removed_all_download_sources": "Todas las fuentes de descarga eliminadas",
|
||||
"hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego"
|
||||
},
|
||||
"notifications": {
|
||||
|
||||
@@ -402,7 +402,11 @@
|
||||
"resume_seeding": "Semear",
|
||||
"options": "Gerenciar",
|
||||
"extract": "Extrair arquivos",
|
||||
"extracting": "Extraindo arquivos…"
|
||||
"extracting": "Extraindo arquivos…",
|
||||
"network": "Rede",
|
||||
"peak": "Pico",
|
||||
"seeds": "Seeds",
|
||||
"peers": "Peers"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
|
||||
@@ -229,7 +229,13 @@
|
||||
"seeding": "A semear",
|
||||
"stop_seeding": "Parar de semear",
|
||||
"resume_seeding": "Semear",
|
||||
"options": "Opções"
|
||||
"options": "Opções",
|
||||
"extract": "Extrair ficheiros",
|
||||
"extracting": "A extrair ficheiros…",
|
||||
"network": "Rede",
|
||||
"peak": "Pico",
|
||||
"seeds": "Seeds",
|
||||
"peers": "Peers"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Local das transferências",
|
||||
|
||||
@@ -414,7 +414,11 @@
|
||||
"resume_seeding": "Продолжить раздачу",
|
||||
"options": "Управлять",
|
||||
"extract": "Распаковать файлы",
|
||||
"extracting": "Распаковка файлов…"
|
||||
"extracting": "Распаковка файлов…",
|
||||
"network": "Сеть",
|
||||
"peak": "Пик",
|
||||
"seeds": "Seeds",
|
||||
"peers": "Peers"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Путь загрузок",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import type { Auth } from "@types";
|
||||
|
||||
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
const auth = await db.get<string, Auth>(levelKeys.auth, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
if (!auth) return null;
|
||||
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
return payload.sessionId;
|
||||
};
|
||||
|
||||
registerEvent("getSessionHash", getSessionHash);
|
||||
@@ -1,3 +1,2 @@
|
||||
import "./get-session-hash";
|
||||
import "./open-auth-window";
|
||||
import "./sign-out";
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getDownloadSourcesCheckBaseline } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDownloadSourcesCheckBaselineHandler = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
) => {
|
||||
return await getDownloadSourcesCheckBaseline();
|
||||
};
|
||||
|
||||
registerEvent(
|
||||
"getDownloadSourcesCheckBaseline",
|
||||
getDownloadSourcesCheckBaselineHandler
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getDownloadSourcesSinceValue } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDownloadSourcesSinceValueHandler = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
) => {
|
||||
return await getDownloadSourcesSinceValue();
|
||||
};
|
||||
|
||||
registerEvent(
|
||||
"getDownloadSourcesSinceValue",
|
||||
getDownloadSourcesSinceValueHandler
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
import { downloadSourcesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { orderBy } from "lodash-es";
|
||||
|
||||
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
const allSources = await downloadSourcesSublevel.values().all();
|
||||
return orderBy(allSources, "createdAt", "desc");
|
||||
};
|
||||
|
||||
registerEvent("getDownloadSources", getDownloadSources);
|
||||
@@ -1,6 +1,3 @@
|
||||
import "./add-download-source";
|
||||
import "./get-download-sources-check-baseline";
|
||||
import "./get-download-sources-since-value";
|
||||
import "./get-download-sources";
|
||||
import "./remove-download-source";
|
||||
import "./sync-download-sources";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { logger } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const clearNewDownloadOptions = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
if (!game) return;
|
||||
|
||||
try {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
newDownloadOptionsCount: undefined,
|
||||
});
|
||||
logger.info(`Cleared newDownloadOptionsCount for game ${gameKey}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to clear newDownloadOptionsCount: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("clearNewDownloadOptions", clearNewDownloadOptions);
|
||||
@@ -1,21 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const getGameByObjectId = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
const [game, download] = await Promise.all([
|
||||
gamesSublevel.get(gameKey),
|
||||
downloadsSublevel.get(gameKey),
|
||||
]);
|
||||
|
||||
if (!game || game.isDeleted) return null;
|
||||
|
||||
return { id: gameKey, ...game, download };
|
||||
};
|
||||
|
||||
registerEvent("getGameByObjectId", getGameByObjectId);
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { LibraryGame } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import {
|
||||
downloadsSublevel,
|
||||
gameAchievementsSublevel,
|
||||
gamesShopAssetsSublevel,
|
||||
gamesSublevel,
|
||||
} from "@main/level";
|
||||
|
||||
const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
return gamesSublevel
|
||||
.iterator()
|
||||
.all()
|
||||
.then((results) => {
|
||||
return Promise.all(
|
||||
results
|
||||
.filter(([_key, game]) => game.isDeleted === false)
|
||||
.map(async ([key, game]) => {
|
||||
const download = await downloadsSublevel.get(key);
|
||||
const gameAssets = await gamesShopAssetsSublevel.get(key);
|
||||
|
||||
let unlockedAchievementCount = game.unlockedAchievementCount ?? 0;
|
||||
|
||||
if (!game.unlockedAchievementCount) {
|
||||
const achievements = await gameAchievementsSublevel.get(key);
|
||||
|
||||
unlockedAchievementCount =
|
||||
achievements?.unlockedAchievements.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...game,
|
||||
download: download ?? null,
|
||||
unlockedAchievementCount,
|
||||
achievementCount: game.achievementCount ?? 0,
|
||||
// Spread gameAssets last to ensure all image URLs are properly set
|
||||
...gameAssets,
|
||||
// Preserve custom image URLs from game if they exist
|
||||
customIconUrl: game.customIconUrl,
|
||||
customLogoImageUrl: game.customLogoImageUrl,
|
||||
customHeroImageUrl: game.customHeroImageUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getLibrary", getLibrary);
|
||||
@@ -3,7 +3,6 @@ import "./add-game-to-favorites";
|
||||
import "./add-game-to-library";
|
||||
import "./change-game-playtime";
|
||||
import "./cleanup-unused-assets";
|
||||
import "./clear-new-download-options";
|
||||
import "./close-game";
|
||||
import "./copy-custom-game-asset";
|
||||
import "./create-game-shortcut";
|
||||
@@ -11,8 +10,6 @@ import "./create-steam-shortcut";
|
||||
import "./delete-game-folder";
|
||||
import "./extract-game-download";
|
||||
import "./get-default-wine-prefix-selection-path";
|
||||
import "./get-game-by-object-id";
|
||||
import "./get-library";
|
||||
import "./open-game-executable-path";
|
||||
import "./open-game-installer-path";
|
||||
import "./open-game-installer";
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Theme } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { themesSublevel } from "@main/level";
|
||||
|
||||
const addCustomTheme = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
theme: Theme
|
||||
) => {
|
||||
await themesSublevel.put(theme.id, theme);
|
||||
};
|
||||
|
||||
registerEvent("addCustomTheme", addCustomTheme);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
await themesSublevel.clear();
|
||||
};
|
||||
|
||||
registerEvent("deleteAllCustomThemes", deleteAllCustomThemes);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteCustomTheme = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
) => {
|
||||
await themesSublevel.del(themeId);
|
||||
};
|
||||
|
||||
registerEvent("deleteCustomTheme", deleteCustomTheme);
|
||||
@@ -1,9 +0,0 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getActiveCustomTheme = async () => {
|
||||
const allThemes = await themesSublevel.values().all();
|
||||
return allThemes.find((theme) => theme.isActive);
|
||||
};
|
||||
|
||||
registerEvent("getActiveCustomTheme", getActiveCustomTheme);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return themesSublevel.values().all();
|
||||
};
|
||||
|
||||
registerEvent("getAllCustomThemes", getAllCustomThemes);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { themesSublevel } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getCustomThemeById = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
) => {
|
||||
return themesSublevel.get(themeId);
|
||||
};
|
||||
|
||||
registerEvent("getCustomThemeById", getCustomThemeById);
|
||||
@@ -1,11 +1,5 @@
|
||||
import "./add-custom-theme";
|
||||
import "./close-editor-window";
|
||||
import "./copy-theme-achievement-sound";
|
||||
import "./delete-all-custom-themes";
|
||||
import "./delete-custom-theme";
|
||||
import "./get-active-custom-theme";
|
||||
import "./get-all-custom-themes";
|
||||
import "./get-custom-theme-by-id";
|
||||
import "./get-theme-sound-data-url";
|
||||
import "./get-theme-sound-path";
|
||||
import "./import-theme-sound-from-store";
|
||||
|
||||
@@ -13,7 +13,11 @@ const resumeGameDownload = async (
|
||||
|
||||
const download = await downloadsSublevel.get(gameKey);
|
||||
|
||||
if (download?.status === "paused") {
|
||||
if (
|
||||
download &&
|
||||
(download.status === "paused" || download.status === "active") &&
|
||||
download.progress !== 1
|
||||
) {
|
||||
await DownloadManager.pauseDownload();
|
||||
|
||||
for await (const [key, value] of downloadsSublevel.iterator()) {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import type { UserPreferences } from "@types";
|
||||
|
||||
const getUserPreferences = async () =>
|
||||
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
registerEvent("getUserPreferences", getUserPreferences);
|
||||
@@ -1,5 +1,4 @@
|
||||
import "./authenticate-real-debrid";
|
||||
import "./authenticate-torbox";
|
||||
import "./auto-launch";
|
||||
import "./get-user-preferences";
|
||||
import "./update-user-preferences";
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import type { Auth } from "@types";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getAuth = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||
db.get<string, Auth>(levelKeys.auth, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
registerEvent("getAuth", getAuth);
|
||||
@@ -1,3 +1,2 @@
|
||||
import "./get-auth";
|
||||
import "./get-compared-unlocked-achievements";
|
||||
import "./get-unlocked-achievements";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { downloadsSublevel } from "./level/sublevels/downloads";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { Downloader } from "@shared";
|
||||
import { levelKeys, db } from "./level";
|
||||
import type { UserPreferences } from "@types";
|
||||
@@ -68,7 +68,7 @@ export const loadState = async () => {
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return sortBy(games, "timestamp", "DESC");
|
||||
return orderBy(games, "timestamp", "desc");
|
||||
});
|
||||
|
||||
downloads.forEach((download) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { RealDebridClient } from "./real-debrid";
|
||||
import path from "path";
|
||||
import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { GameFilesManager } from "../game-files-manager";
|
||||
import { HydraDebridClient } from "./hydra-debrid";
|
||||
@@ -194,10 +194,10 @@ export class DownloadManager {
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return sortBy(
|
||||
return orderBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"DESC"
|
||||
"desc"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,13 @@ export class HydraApi {
|
||||
const decodedBase64 = atob(payload as string);
|
||||
const jsonData = JSON.parse(decodedBase64);
|
||||
|
||||
const { accessToken, expiresIn, refreshToken } = jsonData;
|
||||
const {
|
||||
accessToken,
|
||||
expiresIn,
|
||||
refreshToken,
|
||||
featurebaseJwt,
|
||||
workwondersJwt,
|
||||
} = jsonData;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -85,6 +91,8 @@ export class HydraApi {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenExpirationTimestamp,
|
||||
featurebaseJwt,
|
||||
workwondersJwt,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
@@ -138,7 +138,8 @@ export class WindowManager {
|
||||
(details, callback) => {
|
||||
if (
|
||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||
details.url.includes("chatwoot")
|
||||
details.url.includes("chatwoot") ||
|
||||
details.url.includes("workwonders")
|
||||
) {
|
||||
return callback(details);
|
||||
}
|
||||
@@ -159,7 +160,8 @@ export class WindowManager {
|
||||
if (
|
||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||
details.url.includes("featurebase") ||
|
||||
details.url.includes("chatwoot")
|
||||
details.url.includes("chatwoot") ||
|
||||
details.url.includes("workwonders")
|
||||
) {
|
||||
return callback(details);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
UpdateProfileRequest,
|
||||
SeedingStatus,
|
||||
GameAchievement,
|
||||
Theme,
|
||||
FriendRequestSync,
|
||||
ShortcutLocation,
|
||||
AchievementCustomNotificationPosition,
|
||||
@@ -86,7 +85,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
},
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||
getUserPreferences: () =>
|
||||
ipcRenderer.invoke("leveldbGet", "userPreferences", null, "json"),
|
||||
updateUserPreferences: (preferences: UserPreferences) =>
|
||||
ipcRenderer.invoke("updateUserPreferences", preferences),
|
||||
autoLaunch: (autoLaunchProps: { enabled: boolean; minimized: boolean }) =>
|
||||
@@ -101,12 +101,7 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("addDownloadSource", url),
|
||||
removeDownloadSource: (url: string, removeAll?: boolean) =>
|
||||
ipcRenderer.invoke("removeDownloadSource", url, removeAll),
|
||||
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
|
||||
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
|
||||
getDownloadSourcesCheckBaseline: () =>
|
||||
ipcRenderer.invoke("getDownloadSourcesCheckBaseline"),
|
||||
getDownloadSourcesSinceValue: () =>
|
||||
ipcRenderer.invoke("getDownloadSourcesSinceValue"),
|
||||
|
||||
/* Library */
|
||||
toggleAutomaticCloudSync: (
|
||||
@@ -183,8 +178,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
||||
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
||||
clearNewDownloadOptions: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("clearNewDownloadOptions", shop, objectId),
|
||||
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
|
||||
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
|
||||
updateLaunchOptions: (
|
||||
@@ -201,7 +194,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("selectGameWinePrefix", shop, objectId, winePrefixPath),
|
||||
verifyExecutablePathInUse: (executablePath: string) =>
|
||||
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
|
||||
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
||||
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
||||
openGameInstaller: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
||||
@@ -230,8 +222,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("removeGame", shop, objectId),
|
||||
deleteGameFolder: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("deleteGameFolder", shop, objectId),
|
||||
getGameByObjectId: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("getGameByObjectId", shop, objectId),
|
||||
resetGameAchievements: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("resetGameAchievements", shop, objectId),
|
||||
changeGamePlayTime: (shop: GameShop, objectId: string, playtime: number) =>
|
||||
@@ -287,8 +277,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
gameArtifactId: string
|
||||
) =>
|
||||
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
|
||||
getGameArtifacts: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
|
||||
getGameBackupPreview: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
|
||||
selectGameBackupPath: (
|
||||
@@ -503,11 +491,10 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
|
||||
|
||||
/* Auth */
|
||||
getAuth: () => ipcRenderer.invoke("getAuth"),
|
||||
getAuth: () => ipcRenderer.invoke("leveldbGet", "auth", null, "json"),
|
||||
signOut: () => ipcRenderer.invoke("signOut"),
|
||||
openAuthWindow: (page: AuthPage) =>
|
||||
ipcRenderer.invoke("openAuthWindow", page),
|
||||
getSessionHash: () => ipcRenderer.invoke("getSessionHash"),
|
||||
onSignIn: (cb: () => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||
ipcRenderer.on("on-signin", listener);
|
||||
@@ -565,16 +552,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("showAchievementTestNotification"),
|
||||
|
||||
/* Themes */
|
||||
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),
|
||||
getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"),
|
||||
deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"),
|
||||
deleteCustomTheme: (themeId: string) =>
|
||||
ipcRenderer.invoke("deleteCustomTheme", themeId),
|
||||
updateCustomTheme: (themeId: string, code: string) =>
|
||||
ipcRenderer.invoke("updateCustomTheme", themeId, code),
|
||||
getCustomThemeById: (themeId: string) =>
|
||||
ipcRenderer.invoke("getCustomThemeById", themeId),
|
||||
getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"),
|
||||
toggleCustomTheme: (themeId: string, isActive: boolean) =>
|
||||
ipcRenderer.invoke("toggleCustomTheme", themeId, isActive),
|
||||
copyThemeAchievementSound: (themeId: string, sourcePath: string) =>
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
|
||||
import "./bottom-panel.scss";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VERSION_CODENAME } from "@renderer/constants";
|
||||
import type jwt from "jsonwebtoken";
|
||||
|
||||
export function BottomPanel() {
|
||||
const { t } = useTranslation("bottom_panel");
|
||||
@@ -60,7 +62,28 @@ export function BottomPanel() {
|
||||
}, [t, showSuccessToast]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getSessionHash().then((result) => setSessionHash(result));
|
||||
const getSessionHash = async () => {
|
||||
const auth = (await levelDBService.get("auth", null, "json")) as {
|
||||
accessToken?: string;
|
||||
} | null;
|
||||
|
||||
if (!auth?.accessToken) {
|
||||
setSessionHash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtModule = await import("jsonwebtoken");
|
||||
const payload = jwtModule.decode(
|
||||
auth.accessToken
|
||||
) as jwt.JwtPayload | null;
|
||||
setSessionHash(payload?.sessionId ?? null);
|
||||
} catch {
|
||||
setSessionHash(null);
|
||||
}
|
||||
};
|
||||
|
||||
getSessionHash();
|
||||
}, [userDetails?.id]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
@@ -122,10 +145,10 @@ export function BottomPanel() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-featurebase-changelog
|
||||
data-open-workwonders-changelog-mini
|
||||
className="bottom-panel__version-button"
|
||||
>
|
||||
<small data-featurebase-changelog>
|
||||
<small>
|
||||
{sessionHash ? `${sessionHash} -` : ""} v{version} "
|
||||
{VERSION_CODENAME}"
|
||||
</small>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface DropdownMenuProps {
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
collisionPadding?: number;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
@@ -29,6 +30,7 @@ export function DropdownMenu({
|
||||
loop = true,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
collisionPadding = 16,
|
||||
}: Readonly<DropdownMenuProps>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root>
|
||||
@@ -43,6 +45,7 @@ export function DropdownMenu({
|
||||
loop={loop}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className="dropdown-menu__content"
|
||||
>
|
||||
{title && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Download,
|
||||
DownloadSource,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
@@ -95,9 +96,19 @@ export function GameDetailsContextProvider({
|
||||
);
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectId(shop, objectId)
|
||||
.then((result) => setGame(result));
|
||||
const gameKey = `${shop}:${objectId}`;
|
||||
const [game, download] = await Promise.all([
|
||||
levelDBService.get(gameKey, "games") as Promise<LibraryGame | null>,
|
||||
levelDBService.get(gameKey, "downloads") as Promise<Download | null>,
|
||||
]);
|
||||
|
||||
if (!game || game.isDeleted) {
|
||||
setGame(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id: _id, ...gameWithoutId } = game;
|
||||
setGame({ id: gameKey, ...gameWithoutId, download: download ?? null });
|
||||
}, [shop, objectId]);
|
||||
|
||||
const isGameDownloading =
|
||||
|
||||
28
src/renderer/src/declaration.d.ts
vendored
28
src/renderer/src/declaration.d.ts
vendored
@@ -14,14 +14,11 @@ import type {
|
||||
GameStats,
|
||||
UserDetails,
|
||||
FriendRequestSync,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
ComparedAchievements,
|
||||
LibraryGame,
|
||||
GameRunning,
|
||||
TorBoxUser,
|
||||
Theme,
|
||||
Auth,
|
||||
ShortcutLocation,
|
||||
ShopAssets,
|
||||
@@ -142,10 +139,6 @@ declare global {
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
clearNewDownloadOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
toggleGamePin: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -162,7 +155,6 @@ declare global {
|
||||
winePrefixPath: string | null
|
||||
) => Promise<void>;
|
||||
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
|
||||
getLibrary: () => Promise<LibraryGame[]>;
|
||||
refreshLibraryAssets: () => Promise<void>;
|
||||
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
@@ -177,10 +169,6 @@ declare global {
|
||||
removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
removeGame: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>;
|
||||
getGameByObjectId: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<LibraryGame | null>;
|
||||
onGamesRunning: (
|
||||
cb: (
|
||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||
@@ -194,9 +182,9 @@ declare global {
|
||||
playtimeInSeconds: number
|
||||
) => Promise<void>;
|
||||
/* User preferences */
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
preferences: Partial<UserPreferences>
|
||||
) => Promise<void>;
|
||||
@@ -217,10 +205,7 @@ declare global {
|
||||
removeAll = false,
|
||||
downloadSourceId?: string
|
||||
) => Promise<void>;
|
||||
getDownloadSources: () => Promise<DownloadSource[]>;
|
||||
syncDownloadSources: () => Promise<void>;
|
||||
getDownloadSourcesCheckBaseline: () => Promise<string | null>;
|
||||
getDownloadSourcesSinceValue: () => Promise<string | null>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||
@@ -237,10 +222,6 @@ declare global {
|
||||
shop: GameShop,
|
||||
gameArtifactId: string
|
||||
) => Promise<void>;
|
||||
getGameArtifacts: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<GameArtifact[]>;
|
||||
getGameBackupPreview: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
@@ -355,7 +336,6 @@ declare global {
|
||||
getAuth: () => Promise<Auth | null>;
|
||||
signOut: () => Promise<void>;
|
||||
openAuthWindow: (page: AuthPage) => Promise<void>;
|
||||
getSessionHash: () => Promise<string | null>;
|
||||
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
@@ -408,13 +388,7 @@ declare global {
|
||||
showAchievementTestNotification: () => Promise<void>;
|
||||
|
||||
/* Themes */
|
||||
addCustomTheme: (theme: Theme) => Promise<void>;
|
||||
getAllCustomThemes: () => Promise<Theme[]>;
|
||||
deleteAllCustomThemes: () => Promise<void>;
|
||||
deleteCustomTheme: (themeId: string) => Promise<void>;
|
||||
updateCustomTheme: (themeId: string, code: string) => Promise<void>;
|
||||
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
|
||||
getActiveCustomTheme: () => Promise<Theme | null>;
|
||||
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
|
||||
copyThemeAchievementSound: (
|
||||
themeId: string,
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "./redux";
|
||||
import { setLibrary } from "@renderer/features";
|
||||
import { levelDBService } from "@renderer/services/leveldb.service";
|
||||
import type {
|
||||
LibraryGame,
|
||||
Game,
|
||||
Download,
|
||||
ShopAssets,
|
||||
GameAchievement,
|
||||
} from "@types";
|
||||
|
||||
export function useLibrary() {
|
||||
const dispatch = useAppDispatch();
|
||||
const library = useAppSelector((state) => state.library.value);
|
||||
|
||||
const updateLibrary = useCallback(async () => {
|
||||
return window.electron
|
||||
.getLibrary()
|
||||
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary)));
|
||||
const results = await levelDBService.iterator("games");
|
||||
|
||||
const libraryGames = await Promise.all(
|
||||
results
|
||||
.filter(([_key, game]) => (game as Game).isDeleted === false)
|
||||
.map(async ([key, game]) => {
|
||||
const gameData = game as Game;
|
||||
const download = (await levelDBService.get(
|
||||
key,
|
||||
"downloads"
|
||||
)) as Download | null;
|
||||
const gameAssets = (await levelDBService.get(
|
||||
key,
|
||||
"gameShopAssets"
|
||||
)) as (ShopAssets & { updatedAt: number }) | null;
|
||||
|
||||
let unlockedAchievementCount = gameData.unlockedAchievementCount ?? 0;
|
||||
|
||||
if (!gameData.unlockedAchievementCount) {
|
||||
const achievements = (await levelDBService.get(
|
||||
key,
|
||||
"gameAchievements"
|
||||
)) as GameAchievement | null;
|
||||
|
||||
unlockedAchievementCount =
|
||||
achievements?.unlockedAchievements.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...gameData,
|
||||
download: download ?? null,
|
||||
unlockedAchievementCount,
|
||||
achievementCount: gameData.achievementCount ?? 0,
|
||||
// Spread gameAssets last to ensure all image URLs are properly set
|
||||
...gameAssets,
|
||||
// Preserve custom image URLs from game if they exist
|
||||
customIconUrl: gameData.customIconUrl,
|
||||
customLogoImageUrl: gameData.customLogoImageUrl,
|
||||
customHeroImageUrl: gameData.customHeroImageUrl,
|
||||
} as LibraryGame;
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(setLibrary(libraryGames));
|
||||
}, [dispatch]);
|
||||
|
||||
return { library, updateLibrary };
|
||||
|
||||
@@ -18,17 +18,32 @@
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit);
|
||||
|
||||
&-divider {
|
||||
&-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
flex: 1;
|
||||
background-color: globals.$border-color;
|
||||
height: 1px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
&-count {
|
||||
font-weight: 400;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
&--hero {
|
||||
@@ -37,7 +52,7 @@
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-bottom: calc(globals.$spacing-unit * 3);
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__hero-background {
|
||||
@@ -80,65 +95,166 @@
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__hero-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__hero-logo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 600px;
|
||||
max-height: 200px;
|
||||
max-width: 180px;
|
||||
max-height: 60px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
max-width: 220px;
|
||||
max-height: 75px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 900px) {
|
||||
max-width: 280px;
|
||||
max-height: 95px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1200px) {
|
||||
max-width: 340px;
|
||||
max-height: 115px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1500px) {
|
||||
max-width: 400px;
|
||||
max-height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 64px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.9);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&__hero-actions {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
align-items: center;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 700px) {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 900px) {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1200px) {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1500px) {
|
||||
font-size: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-action-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
margin-bottom: calc(globals.$spacing-unit * 3);
|
||||
margin-top: calc(globals.$spacing-unit * 4);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__hero-menu-btn {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: calc(globals.$spacing-unit * 1);
|
||||
min-height: unset;
|
||||
&__hero-buttons {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&__hero-menu-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
|
||||
&__glass-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
margin-bottom: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__progress-header {
|
||||
&__progress-info-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__progress-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&--bar {
|
||||
margin-top: calc(globals.$spacing-unit);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-status {
|
||||
@@ -153,22 +269,36 @@
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
align-self: flex-end;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
|
||||
&__progress-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-top: calc(globals.$spacing-unit / 2);
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
&__progress-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 13px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
@@ -190,6 +320,7 @@
|
||||
min-width: 200px;
|
||||
padding-right: calc(globals.$spacing-unit * 2);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&__speed-chart {
|
||||
@@ -202,7 +333,7 @@
|
||||
|
||||
&__speed-chart-canvas {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
height: 80px;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
@@ -219,7 +350,9 @@
|
||||
|
||||
&__stat-content {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
@@ -251,14 +384,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
border-radius: 8px;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__simple-thumbnail {
|
||||
@@ -268,6 +394,7 @@
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid globals.$border-color;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -281,7 +408,7 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
gap: calc(globals.$spacing-unit / 1);
|
||||
}
|
||||
|
||||
&__simple-title {
|
||||
@@ -295,6 +422,12 @@
|
||||
}
|
||||
|
||||
&__simple-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__simple-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
@@ -303,9 +436,20 @@
|
||||
}
|
||||
|
||||
&__simple-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__simple-extracting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-weight: 500;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__simple-seeding {
|
||||
color: #4ade80;
|
||||
font-weight: 600;
|
||||
@@ -342,13 +486,20 @@
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
&__progress-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin-top: calc(globals.$spacing-unit / 2);
|
||||
|
||||
&--small {
|
||||
height: 6px;
|
||||
@@ -357,10 +508,8 @@
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background-color: globals.$muted-color;
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
background 0.35s ease;
|
||||
background-color: #fff;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
|
||||
|
||||
import { Badge, Button } from "@renderer/components";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import {
|
||||
formatDownloadProgress,
|
||||
buildGameDetailsPath,
|
||||
} from "@renderer/helpers";
|
||||
|
||||
import { Downloader, formatBytes, formatBytesToMbps } from "@shared";
|
||||
import { formatDistance, addMilliseconds } from "date-fns";
|
||||
import { addMilliseconds } from "date-fns";
|
||||
import { DOWNLOADER_NAME } from "@renderer/constants";
|
||||
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
|
||||
import {
|
||||
useAppSelector,
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useDate,
|
||||
} from "@renderer/hooks";
|
||||
|
||||
import "./download-group.scss";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
} from "@renderer/components/dropdown-menu/dropdown-menu";
|
||||
import {
|
||||
ClockIcon,
|
||||
ColumnsIcon,
|
||||
DownloadIcon,
|
||||
FileDirectoryIcon,
|
||||
@@ -29,22 +40,46 @@ import {
|
||||
} from "@primer/octicons-react";
|
||||
import { average } from "color.js";
|
||||
|
||||
const getProgressGradient = (
|
||||
colorHex: string,
|
||||
isPaused = false
|
||||
): string | undefined => {
|
||||
const hex = isPaused ? "#ffffff" : colorHex || "#08ea79";
|
||||
if (!hex.startsWith("#")) return undefined;
|
||||
interface AnimatedPercentageProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16);
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16);
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16);
|
||||
return `linear-gradient(90deg, rgba(${r},${g},${b},0.95) 0%, rgba(${r},${g},${b},0.65) 100%)`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
function AnimatedPercentage({ value }: Readonly<AnimatedPercentageProps>) {
|
||||
const percentageText = formatDownloadProgress(value);
|
||||
const prevTextRef = useRef<string>(percentageText);
|
||||
const chars = percentageText.split("");
|
||||
const prevChars = prevTextRef.current.split("");
|
||||
|
||||
useEffect(() => {
|
||||
prevTextRef.current = percentageText;
|
||||
}, [percentageText]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{chars.map((char, index) => {
|
||||
const prevChar = prevChars[index];
|
||||
const charChanged = prevChar !== char;
|
||||
|
||||
return (
|
||||
<AnimatePresence key={`${index}`} mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={`${char}-${value}-${index}`}
|
||||
initial={
|
||||
charChanged ? { y: 10, opacity: 0 } : { y: 0, opacity: 1 }
|
||||
}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={charChanged ? { y: -10, opacity: 0 } : undefined}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
style={{ display: "inline-block" }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpeedChartProps {
|
||||
speeds: number[];
|
||||
@@ -67,6 +102,7 @@ function SpeedChart({
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const draw = () => {
|
||||
const clientWidth = canvas.clientWidth;
|
||||
@@ -78,10 +114,12 @@ function SpeedChart({
|
||||
|
||||
const width = clientWidth;
|
||||
const height = 100;
|
||||
const totalBars = 120;
|
||||
const barWidth = 4;
|
||||
const barGap = 10;
|
||||
const barSpacing = barWidth + barGap;
|
||||
|
||||
// Calculate how many bars can fit in the available width
|
||||
const totalBars = Math.max(1, Math.floor((width + barGap) / barSpacing));
|
||||
const maxHeight = peakSpeed || Math.max(...speeds, 1);
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
@@ -138,8 +176,22 @@ function SpeedChart({
|
||||
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
|
||||
// Handle resize - trigger redraw when canvas size changes
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
// Cancel any pending animation frame to force immediate redraw
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
// Trigger a redraw that will recalculate bars based on new width
|
||||
draw();
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [speeds, peakSpeed, color]);
|
||||
|
||||
@@ -158,12 +210,11 @@ interface HeroDownloadViewProps {
|
||||
dominantColor: string;
|
||||
lastPacket: ReturnType<typeof useDownload>["lastPacket"];
|
||||
speedHistory: number[];
|
||||
getGameActions: (game: LibraryGame) => DropdownMenuItem[];
|
||||
getStatusText: (game: LibraryGame) => string;
|
||||
formatSpeed: (speed: number) => string;
|
||||
calculateETA: () => string;
|
||||
pauseDownload: (shop: GameShop, objectId: string) => void;
|
||||
resumeDownload: (shop: GameShop, objectId: string) => void;
|
||||
cancelDownload: (shop: GameShop, objectId: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
@@ -177,14 +228,19 @@ function HeroDownloadView({
|
||||
dominantColor,
|
||||
lastPacket,
|
||||
speedHistory,
|
||||
getGameActions,
|
||||
getStatusText,
|
||||
formatSpeed,
|
||||
calculateETA,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
cancelDownload,
|
||||
t,
|
||||
}: Readonly<HeroDownloadViewProps>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
navigate(buildGameDetailsPath(game));
|
||||
}, [navigate, game]);
|
||||
|
||||
return (
|
||||
<div className="download-group download-group--hero">
|
||||
<div className="download-group__hero-background">
|
||||
@@ -196,88 +252,103 @@ function HeroDownloadView({
|
||||
</div>
|
||||
|
||||
<div className="download-group__hero-content">
|
||||
<div className="download-group__hero-header">
|
||||
<div className="download-group__hero-actions">
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
<Button className="download-group__hero-menu-btn" theme="outline">
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="download-group__hero-action-row">
|
||||
<div className="download-group__hero-logo">
|
||||
{game.logoImageUrl ? (
|
||||
<img src={game.logoImageUrl} alt={game.title} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogoClick}
|
||||
className="download-group__hero-logo-button"
|
||||
>
|
||||
<img src={game.logoImageUrl} alt={game.title} />
|
||||
</button>
|
||||
) : (
|
||||
<h1>{game.title}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogoClick}
|
||||
className="download-group__hero-logo-button"
|
||||
>
|
||||
<h1>{game.title}</h1>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGameDownloading ? (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => pauseDownload(game.shop, game.objectId)}
|
||||
className="download-group__hero-action-btn"
|
||||
style={{
|
||||
backgroundColor: dominantColor,
|
||||
borderColor: dominantColor,
|
||||
}}
|
||||
>
|
||||
<ColumnsIcon size={16} />
|
||||
{t("pause")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => resumeDownload(game.shop, game.objectId)}
|
||||
className="download-group__hero-action-btn"
|
||||
style={{
|
||||
backgroundColor: dominantColor,
|
||||
borderColor: dominantColor,
|
||||
}}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
{t("resume")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="download-group__hero-progress">
|
||||
<div className="download-group__progress-header">
|
||||
<span className="download-group__progress-status">
|
||||
{getStatusText(game)}
|
||||
</span>
|
||||
<span className="download-group__progress-percentage">
|
||||
{formatDownloadProgress(currentProgress)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="download-group__progress-bar">
|
||||
<div
|
||||
className="download-group__progress-fill"
|
||||
style={{
|
||||
width: `${currentProgress * 100}%`,
|
||||
background: getProgressGradient(
|
||||
dominantColor,
|
||||
game.download?.status === "paused"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-group__progress-details">
|
||||
<span className="download-group__progress-size">
|
||||
{isGameDownloading && lastPacket
|
||||
? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}`
|
||||
: `0 B / ${finalDownloadSize}`}
|
||||
</span>
|
||||
<span className="download-group__progress-time">
|
||||
{isGameDownloading &&
|
||||
lastPacket?.timeRemaining &&
|
||||
lastPacket.timeRemaining > 0
|
||||
? calculateETA()
|
||||
: ""}
|
||||
</span>
|
||||
<div className="download-group__progress-row download-group__progress-row--bar">
|
||||
<div className="download-group__progress-wrapper">
|
||||
<div className="download-group__progress-info-row">
|
||||
{lastPacket?.isCheckingFiles ? (
|
||||
<span className="download-group__progress-status">
|
||||
{t("checking_files")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="download-group__progress-size">
|
||||
<DownloadIcon size={14} />
|
||||
{isGameDownloading && lastPacket
|
||||
? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}`
|
||||
: `0 B / ${finalDownloadSize}`}
|
||||
</span>
|
||||
)}
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="download-group__progress-info-row">
|
||||
{!lastPacket?.isCheckingFiles && (
|
||||
<span className="download-group__progress-time">
|
||||
{isGameDownloading &&
|
||||
lastPacket?.timeRemaining &&
|
||||
lastPacket.timeRemaining > 0 && (
|
||||
<>
|
||||
<ClockIcon size={14} />
|
||||
{calculateETA()}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{(!lastPacket?.isCheckingFiles || currentProgress > 0) && (
|
||||
<span className="download-group__progress-percentage">
|
||||
<AnimatedPercentage value={currentProgress} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="download-group__progress-bar">
|
||||
<div
|
||||
className="download-group__progress-fill"
|
||||
style={{
|
||||
width: `${currentProgress * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="download-group__hero-buttons">
|
||||
{isGameDownloading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pauseDownload(game.shop, game.objectId)}
|
||||
className="download-group__glass-btn"
|
||||
>
|
||||
<ColumnsIcon size={14} />
|
||||
{t("pause")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resumeDownload(game.shop, game.objectId)}
|
||||
className="download-group__glass-btn"
|
||||
>
|
||||
<PlayIcon size={14} />
|
||||
{t("resume")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelDownload(game.shop, game.objectId)}
|
||||
className="download-group__glass-btn"
|
||||
>
|
||||
<XCircleIcon size={14} />
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,7 +360,7 @@ function HeroDownloadView({
|
||||
</span>
|
||||
<div className="download-group__stat-content">
|
||||
<span className="download-group__stat-label">
|
||||
{t("network")}:
|
||||
{t("network")}
|
||||
</span>
|
||||
<span className="download-group__stat-value">
|
||||
{isGameDownloading ? formatSpeed(downloadSpeed) : "0 B/s"}
|
||||
@@ -302,32 +373,41 @@ function HeroDownloadView({
|
||||
<GraphIcon size={16} />
|
||||
</span>
|
||||
<div className="download-group__stat-content">
|
||||
<span className="download-group__stat-label">{t("peak")}:</span>
|
||||
<span className="download-group__stat-label">{t("peak")}</span>
|
||||
<span className="download-group__stat-value">
|
||||
{peakSpeed > 0 ? formatSpeed(peakSpeed) : "0 B/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{game.download?.downloader === Downloader.Torrent &&
|
||||
isGameDownloading &&
|
||||
lastPacket &&
|
||||
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
|
||||
<div className="download-group__stat-item">
|
||||
<div className="download-group__stat-content">
|
||||
<span className="download-group__stat-label">
|
||||
Seeds:{" "}
|
||||
<span className="download-group__stat-value">
|
||||
{lastPacket.numSeeds}
|
||||
{game.download?.downloader && (
|
||||
<div className="download-group__stat-item">
|
||||
<div
|
||||
className="download-group__stat-content"
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
|
||||
{game.download?.downloader === Downloader.Torrent &&
|
||||
isGameDownloading &&
|
||||
lastPacket &&
|
||||
(lastPacket.numSeeds > 0 || lastPacket.numPeers > 0) && (
|
||||
<span className="download-group__stat-label">
|
||||
{t("seeds")}{" "}
|
||||
<span className="download-group__stat-value">
|
||||
{lastPacket.numSeeds}
|
||||
</span>
|
||||
, {t("peers")}{" "}
|
||||
<span className="download-group__stat-value">
|
||||
{lastPacket.numPeers}
|
||||
</span>
|
||||
</span>
|
||||
, Peers:{" "}
|
||||
<span className="download-group__stat-value">
|
||||
{lastPacket.numPeers}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="download-group__speed-chart">
|
||||
@@ -359,6 +439,7 @@ export function DownloadGroup({
|
||||
seedingStatus,
|
||||
}: Readonly<DownloadGroupProps>) {
|
||||
const { t } = useTranslation("downloads");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
@@ -368,19 +449,64 @@ export function DownloadGroup({
|
||||
|
||||
const {
|
||||
lastPacket,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
pauseDownload: pauseDownloadOriginal,
|
||||
resumeDownload: resumeDownloadOriginal,
|
||||
cancelDownload,
|
||||
isGameDeleting,
|
||||
pauseSeeding,
|
||||
resumeSeeding,
|
||||
} = useDownload();
|
||||
|
||||
const peakSpeedsRef = useRef<Record<string, number>>({});
|
||||
// Wrap resumeDownload with optimistic update
|
||||
const resumeDownload = useCallback(
|
||||
async (shop: GameShop, objectId: string) => {
|
||||
const gameId = `${shop}:${objectId}`;
|
||||
|
||||
// Optimistically mark as downloading
|
||||
setOptimisticallyResumed((prev) => ({ ...prev, [gameId]: true }));
|
||||
|
||||
try {
|
||||
await resumeDownloadOriginal(shop, objectId);
|
||||
} catch (error) {
|
||||
// If resume fails, remove optimistic state
|
||||
setOptimisticallyResumed((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[gameId];
|
||||
return next;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[resumeDownloadOriginal]
|
||||
);
|
||||
|
||||
// Wrap pauseDownload to clear optimistic state
|
||||
const pauseDownload = useCallback(
|
||||
async (shop: GameShop, objectId: string) => {
|
||||
const gameId = `${shop}:${objectId}`;
|
||||
|
||||
// Clear optimistic state when pausing
|
||||
setOptimisticallyResumed((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[gameId];
|
||||
return next;
|
||||
});
|
||||
|
||||
await pauseDownloadOriginal(shop, objectId);
|
||||
},
|
||||
[pauseDownloadOriginal]
|
||||
);
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
|
||||
const speedHistoryRef = useRef<Record<string, number[]>>({});
|
||||
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
const [optimisticallyResumed, setOptimisticallyResumed] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const extractDominantColor = useCallback(
|
||||
async (imageUrl: string, gameId: string) => {
|
||||
@@ -398,13 +524,55 @@ export function DownloadGroup({
|
||||
[dominantColors]
|
||||
);
|
||||
|
||||
// Clear optimistic state when actual download starts or library updates
|
||||
useEffect(() => {
|
||||
if (lastPacket?.gameId) {
|
||||
const gameId = lastPacket.gameId;
|
||||
|
||||
// Clear optimistic state when actual download starts
|
||||
setOptimisticallyResumed((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[gameId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [lastPacket?.gameId]);
|
||||
|
||||
// Clear optimistic state for games that are no longer active after library update
|
||||
useEffect(() => {
|
||||
setOptimisticallyResumed((prev) => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
|
||||
for (const gameId in next) {
|
||||
if (next[gameId]) {
|
||||
const game = library.find((g) => g.id === gameId);
|
||||
// Clear if game doesn't exist or download status is not active
|
||||
if (
|
||||
!game ||
|
||||
game.download?.status !== "active" ||
|
||||
lastPacket?.gameId === gameId
|
||||
) {
|
||||
delete next[gameId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [library, lastPacket?.gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
|
||||
const gameId = lastPacket.gameId;
|
||||
|
||||
const currentPeak = peakSpeedsRef.current[gameId] || 0;
|
||||
const currentPeak = peakSpeeds[gameId] || 0;
|
||||
if (lastPacket.downloadSpeed > currentPeak) {
|
||||
peakSpeedsRef.current[gameId] = lastPacket.downloadSpeed;
|
||||
setPeakSpeeds((prev) => ({
|
||||
...prev,
|
||||
[gameId]: lastPacket.downloadSpeed,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!speedHistoryRef.current[gameId]) {
|
||||
@@ -417,7 +585,7 @@ export function DownloadGroup({
|
||||
speedHistoryRef.current[gameId].shift();
|
||||
}
|
||||
}
|
||||
}, [lastPacket?.gameId, lastPacket?.downloadSpeed]);
|
||||
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const game of library) {
|
||||
@@ -429,7 +597,7 @@ export function DownloadGroup({
|
||||
// Fresh download - clear any old data
|
||||
if (speedHistoryRef.current[game.id]?.length > 0) {
|
||||
speedHistoryRef.current[game.id] = [];
|
||||
peakSpeedsRef.current[game.id] = 0;
|
||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,7 +613,7 @@ export function DownloadGroup({
|
||||
) {
|
||||
const timeout = setTimeout(() => {
|
||||
speedHistoryRef.current[game.id] = [];
|
||||
peakSpeedsRef.current[game.id] = 0;
|
||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
||||
}, 10_000);
|
||||
timeouts.push(timeout);
|
||||
}
|
||||
@@ -478,10 +646,12 @@ export function DownloadGroup({
|
||||
const isGameDownloadingMap = useMemo(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const game of library) {
|
||||
map[game.id] = lastPacket?.gameId === game.id;
|
||||
map[game.id] =
|
||||
lastPacket?.gameId === game.id ||
|
||||
optimisticallyResumed[game.id] === true;
|
||||
}
|
||||
return map;
|
||||
}, [library, lastPacket?.gameId]);
|
||||
}, [library, lastPacket?.gameId, optimisticallyResumed]);
|
||||
|
||||
const getFinalDownloadSize = (game: LibraryGame) => {
|
||||
const download = game.download!;
|
||||
@@ -517,53 +687,6 @@ export function DownloadGroup({
|
||||
);
|
||||
};
|
||||
|
||||
const getCompletedStatusText = (game: LibraryGame) => {
|
||||
const isTorrent = game.download?.downloader === Downloader.Torrent;
|
||||
if (isTorrent) {
|
||||
return isGameSeeding(game)
|
||||
? `${t("completed")} (${t("seeding")})`
|
||||
: `${t("completed")} (${t("paused")})`;
|
||||
}
|
||||
return t("completed");
|
||||
};
|
||||
|
||||
const getStatusText = (game: LibraryGame) => {
|
||||
const isGameDownloading = isGameDownloadingMap[game.id];
|
||||
const status = game.download?.status;
|
||||
|
||||
if (game.download?.extracting) {
|
||||
return t("extracting");
|
||||
}
|
||||
|
||||
if (isGameDeleting(game.id)) {
|
||||
return t("deleting");
|
||||
}
|
||||
|
||||
if (game.download?.progress === 1) {
|
||||
return getCompletedStatusText(game);
|
||||
}
|
||||
|
||||
if (isGameDownloading && lastPacket) {
|
||||
if (lastPacket.isDownloadingMetadata) {
|
||||
return t("downloading_metadata");
|
||||
}
|
||||
if (lastPacket.isCheckingFiles) {
|
||||
return t("checking_files");
|
||||
}
|
||||
return t("download_in_progress");
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case "paused":
|
||||
case "error":
|
||||
return t("paused");
|
||||
case "waiting":
|
||||
return t("calculating_eta");
|
||||
default:
|
||||
return t("paused");
|
||||
}
|
||||
};
|
||||
|
||||
const extractGameDownload = useCallback(
|
||||
async (shop: GameShop, objectId: string) => {
|
||||
await window.electron.extractGameDownload(shop, objectId);
|
||||
@@ -682,7 +805,13 @@ export function DownloadGroup({
|
||||
progress: game.download?.progress || 0,
|
||||
isSeeding: isGameSeeding(game),
|
||||
})),
|
||||
[library, lastPacket?.gameId]
|
||||
[
|
||||
library,
|
||||
lastPacket?.gameId,
|
||||
lastPacket?.download.fileSize,
|
||||
isGameDownloadingMap,
|
||||
seedingStatus,
|
||||
]
|
||||
);
|
||||
|
||||
if (!library.length) return null;
|
||||
@@ -698,7 +827,7 @@ export function DownloadGroup({
|
||||
? (lastPacket?.downloadSpeed ?? 0)
|
||||
: 0;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const peakSpeed = peakSpeedsRef.current[game.id] || 0;
|
||||
const peakSpeed = peakSpeeds[game.id] || 0;
|
||||
const currentProgress =
|
||||
isGameDownloading && lastPacket
|
||||
? lastPacket.progress
|
||||
@@ -717,12 +846,11 @@ export function DownloadGroup({
|
||||
dominantColor={dominantColor}
|
||||
lastPacket={lastPacket}
|
||||
speedHistory={speedHistoryRef.current[game.id] || []}
|
||||
getGameActions={getGameActions}
|
||||
getStatusText={getStatusText}
|
||||
formatSpeed={formatSpeed}
|
||||
calculateETA={calculateETA}
|
||||
pauseDownload={pauseDownload}
|
||||
resumeDownload={resumeDownload}
|
||||
cancelDownload={cancelDownload}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
@@ -733,29 +861,67 @@ export function DownloadGroup({
|
||||
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
|
||||
>
|
||||
<div className="download-group__header">
|
||||
<h2>{title}</h2>
|
||||
<div className="download-group__header-divider" />
|
||||
<h3 className="download-group__header-count">{library.length}</h3>
|
||||
<div className="download-group__header-title-group">
|
||||
<h2>{title}</h2>
|
||||
<h3 className="download-group__header-count">{library.length}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="download-group__simple-list">
|
||||
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
|
||||
return (
|
||||
<li key={game.id} className="download-group__simple-card">
|
||||
<div className="download-group__simple-thumbnail">
|
||||
<button
|
||||
type="button"
|
||||
className="download-group__simple-thumbnail"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<img src={game.libraryImageUrl || ""} alt={game.title} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="download-group__simple-info">
|
||||
<h3 className="download-group__simple-title">{game.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="download-group__simple-title"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
<div className="download-group__simple-meta">
|
||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
||||
<span className="download-group__simple-size">{size}</span>
|
||||
{game.download?.progress === 1 && seeding && (
|
||||
<span className="download-group__simple-seeding">
|
||||
{t("seeding")}
|
||||
</span>
|
||||
)}
|
||||
<div className="download-group__simple-meta-row">
|
||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
||||
</div>
|
||||
<div className="download-group__simple-meta-row">
|
||||
{game.download?.extracting ? (
|
||||
<span className="download-group__simple-extracting">
|
||||
{t("extracting")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="download-group__simple-size">
|
||||
<DownloadIcon size={14} />
|
||||
{size}
|
||||
</span>
|
||||
)}
|
||||
{game.download?.progress === 1 && seeding && (
|
||||
<span className="download-group__simple-seeding">
|
||||
{t("seeding")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -784,7 +950,7 @@ export function DownloadGroup({
|
||||
disabled={isGameDeleting(game.id)}
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
<PlayIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{isQueuedGroup && game.download?.progress !== 1 && (
|
||||
@@ -792,8 +958,9 @@ export function DownloadGroup({
|
||||
theme="primary"
|
||||
onClick={() => resumeDownload(game.shop, game.objectId)}
|
||||
className="download-group__simple-menu-btn"
|
||||
tooltip={t("resume")}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
|
||||
@@ -231,9 +231,19 @@ export function RepacksModal({
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastCheckUtc = new Date(lastCheckTimestamp).toISOString();
|
||||
try {
|
||||
const lastCheckDate = new Date(lastCheckTimestamp);
|
||||
|
||||
return repack.createdAt > lastCheckUtc;
|
||||
if (isNaN(lastCheckDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastCheckUtc = lastCheckDate.toISOString();
|
||||
|
||||
return repack.createdAt > lastCheckUtc;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||
|
||||
@@ -87,12 +87,16 @@ export function LibraryTab({
|
||||
|
||||
<ul className="profile-content__games-grid">
|
||||
{pinnedGames?.map((game) => (
|
||||
<li key={game.objectId} style={{ listStyle: "none" }}>
|
||||
<li
|
||||
key={game.objectId}
|
||||
style={{ listStyle: "none" }}
|
||||
className="user-library-game__wrapper"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</li>
|
||||
@@ -134,6 +138,9 @@ export function LibraryTab({
|
||||
<motion.li
|
||||
key={`${sortBy}-${game.objectId}`}
|
||||
style={{ listStyle: "none" }}
|
||||
className="user-library-game__wrapper"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
initial={
|
||||
isNewGame
|
||||
? { opacity: 0.5, y: 15, scale: 0.96 }
|
||||
@@ -160,8 +167,6 @@ export function LibraryTab({
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</motion.li>
|
||||
|
||||
@@ -25,16 +25,12 @@ import "./user-library-game-card.scss";
|
||||
interface UserLibraryGameCardProps {
|
||||
game: UserGame;
|
||||
statIndex: number;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export function UserLibraryGameCard({
|
||||
game,
|
||||
statIndex,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
sortBy,
|
||||
}: UserLibraryGameCardProps) {
|
||||
const { userProfile, isMe, getUserLibraryGames } =
|
||||
@@ -130,129 +126,126 @@ export function UserLibraryGameCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="user-library-game__wrapper"
|
||||
<div
|
||||
className="user-library-game__cover"
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(buildUserGameDetailsPath(game));
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={isTooltipHovered ? undefined : game.title}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__cover"
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
>
|
||||
<div className="user-library-game__overlay">
|
||||
{isMe && (
|
||||
<div className="user-library-game__actions-container">
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__pin-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleGamePinned();
|
||||
}}
|
||||
disabled={isPinning}
|
||||
>
|
||||
{game.isPinned ? (
|
||||
<PinSlashIcon size={12} />
|
||||
) : (
|
||||
<PinIcon size={12} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="user-library-game__playtime"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
: undefined
|
||||
}
|
||||
data-tooltip-id={game.objectId}
|
||||
>
|
||||
{game.hasManuallyUpdatedPlaytime ? (
|
||||
<AlertFillIcon
|
||||
size={11}
|
||||
className="user-library-game__manual-playtime"
|
||||
/>
|
||||
) : (
|
||||
<ClockIcon size={11} />
|
||||
)}
|
||||
<span className="user-library-game__playtime-long">
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</span>
|
||||
<span className="user-library-game__playtime-short">
|
||||
{formatPlayTime(game.playTimeInSeconds, true)}
|
||||
</span>
|
||||
<div className="user-library-game__overlay">
|
||||
{isMe && (
|
||||
<div className="user-library-game__actions-container">
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__pin-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleGamePinned();
|
||||
}}
|
||||
disabled={isPinning}
|
||||
>
|
||||
{game.isPinned ? (
|
||||
<PinSlashIcon size={12} />
|
||||
) : (
|
||||
<PinIcon size={12} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="user-library-game__playtime"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
: undefined
|
||||
}
|
||||
data-tooltip-id={game.objectId}
|
||||
>
|
||||
{game.hasManuallyUpdatedPlaytime ? (
|
||||
<AlertFillIcon
|
||||
size={11}
|
||||
className="user-library-game__manual-playtime"
|
||||
/>
|
||||
) : (
|
||||
<ClockIcon size={11} />
|
||||
)}
|
||||
<span className="user-library-game__playtime-long">
|
||||
{formatPlayTime(game.playTimeInSeconds)}
|
||||
</span>
|
||||
<span className="user-library-game__playtime-short">
|
||||
{formatPlayTime(game.playTimeInSeconds, true)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{userProfile?.hasActiveSubscription &&
|
||||
game.achievementCount > 0 && (
|
||||
<div className="user-library-game__stats">
|
||||
<div className="user-library-game__stats-header">
|
||||
<div className="user-library-game__stats-content">
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
<span>
|
||||
{game.unlockedAchievementCount} /{" "}
|
||||
{game.achievementCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{game.achievementsPointsEarnedSum > 0 && (
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<HydraIcon width={16} height={16} />
|
||||
{formatAchievementPoints(
|
||||
game.achievementsPointsEarnedSum
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
|
||||
<div className="user-library-game__stats">
|
||||
<div className="user-library-game__stats-header">
|
||||
<div className="user-library-game__stats-content">
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<TrophyIcon size={13} />
|
||||
<span>
|
||||
{formatDownloadProgress(
|
||||
game.unlockedAchievementCount / game.achievementCount,
|
||||
1
|
||||
)}
|
||||
{game.unlockedAchievementCount} / {game.achievementCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
max={1}
|
||||
value={
|
||||
game.unlockedAchievementCount / game.achievementCount
|
||||
}
|
||||
className="user-library-game__achievements-progress"
|
||||
/>
|
||||
{game.achievementsPointsEarnedSum > 0 && (
|
||||
<div
|
||||
className="user-library-game__stats-item"
|
||||
style={{
|
||||
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
|
||||
}}
|
||||
>
|
||||
<HydraIcon width={16} height={16} />
|
||||
{formatAchievementPoints(
|
||||
game.achievementsPointsEarnedSum
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{imageError || !game.coverImageUrl ? (
|
||||
<div className="user-library-game__cover-placeholder">
|
||||
<ImageIcon size={48} />
|
||||
<span>
|
||||
{formatDownloadProgress(
|
||||
game.unlockedAchievementCount / game.achievementCount,
|
||||
1
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
max={1}
|
||||
value={game.unlockedAchievementCount / game.achievementCount}
|
||||
className="user-library-game__achievements-progress"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={game.coverImageUrl}
|
||||
alt={game.title}
|
||||
className="user-library-game__game-image"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
{imageError || !game.coverImageUrl ? (
|
||||
<div className="user-library-game__cover-placeholder">
|
||||
<ImageIcon size={48} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={game.coverImageUrl}
|
||||
alt={game.title}
|
||||
className="user-library-game__game-image"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip
|
||||
id={game.objectId}
|
||||
style={{
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Auth {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenExpirationTimestamp: number;
|
||||
featurebaseJwt: string;
|
||||
workwondersJwt: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -6330,7 +6330,7 @@ jsonwebtoken@^9.0.2:
|
||||
object.assign "^4.1.4"
|
||||
object.values "^1.1.6"
|
||||
|
||||
jwa@^1.4.1:
|
||||
jwa@^1.4.2:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9"
|
||||
integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==
|
||||
@@ -6340,11 +6340,11 @@ jwa@^1.4.1:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jws@^3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
||||
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1"
|
||||
integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==
|
||||
dependencies:
|
||||
jwa "^1.4.1"
|
||||
jwa "^1.4.2"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
keyv@^4.0.0, keyv@^4.5.3:
|
||||
|
||||
Reference in New Issue
Block a user