Compare commits

..

30 Commits

Author SHA1 Message Date
Chubby Granny Chaser
47bfc1648f Merge branch 'main' into fix/fixing-level-events 2025-12-10 17:34:01 +00:00
Chubby Granny Chaser
a69a6ec510 Merge pull request #1889 from Lianela/main
feat: new strings
2025-12-10 17:15:45 +00:00
Chubby Granny Chaser
fada6507c3 Merge branch 'main' into main 2025-12-10 17:15:21 +00:00
Chubby Granny Chaser
0479f1347b Merge pull request #1887 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-a3f223628e
chore(deps): bump jws from 3.2.2 to 3.2.3 in the npm_and_yarn group across 1 directory
2025-12-10 17:14:44 +00:00
dependabot[bot]
f44d5c8b49 chore(deps): bump jws in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [jws](https://github.com/brianloveswords/node-jws).


Updates `jws` from 3.2.2 to 3.2.3
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 01:04:55 +00:00
Zamitto
c36109c092 chore: bump version
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-12-07 22:03:02 -03:00
Zamitto
b59fb7dc36 feat: support workwonders 2025-12-07 20:38:53 -03:00
Kyatto
214a7af408 Fix JSON formatting in translation file 2025-12-07 13:14:50 -06:00
Kyatto
14679fc31e Add new translation strings in Spanish 2025-12-07 13:05:59 -06:00
Chubby Granny Chaser
b5445b3dfa chore: updating lock 2025-12-01 06:51:08 +00:00
Chubby Granny Chaser
1ccf70af12 chore: updating lock 2025-11-30 23:57:13 +00:00
Chubby Granny Chaser
bb45b95820 chore: updating lock 2025-11-30 23:39:47 +00:00
Chubby Granny Chaser
361c158a44 fix: fixing level events 2025-11-30 23:17:56 +00:00
Chubby Granny Chaser
1f5e84b32c fix: fixing level events 2025-11-30 23:17:09 +00:00
Chubby Granny Chaser
e49d885b30 chore: update package.json to use yarn commands for type checking and building
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled
2025-11-30 15:21:50 +00:00
Chubby Granny Chaser
cb01301a0d feat: add new translation keys for network statistics in multiple languages 2025-11-30 15:07:32 +00:00
Chubby Granny Chaser
e872b2ea8a chore: bump version to 3.7.5
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
2025-11-30 06:26:43 +00:00
Chubby Granny Chaser
dd7c84b433 Merge pull request #1881 from hydralauncher/fix/downloads-ui
fix: auto-resuming download isnt working after restart
2025-11-30 06:26:08 +00:00
Chubby Granny Chaser
1546da29cf Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra into fix/downloads-ui 2025-11-30 06:25:39 +00:00
Chubby Granny Chaser
a89b0bb2a8 style: refactor download group component to optimize download state management and improve UI responsiveness 2025-11-30 06:25:17 +00:00
Moyasee
9bdb216e0f fix: deleted comment 2025-11-30 08:23:49 +02:00
Moyasee
9779aed8c1 fix: auto-resuming download isnt working after restart 2025-11-30 08:05:45 +02:00
Chubby Granny Chaser
058a148c7f style: add button styling and refactor logo click handling in download group for improved accessibility and user experience 2025-11-30 05:44:18 +00:00
Chubby Granny Chaser
16e3d52508 style: enhance download group styling for improved layout, responsiveness, and user interaction 2025-11-30 05:39:01 +00:00
Chubby Granny Chaser
7e0002cf95 style: format imports in download-group.tsx for improved readability 2025-11-30 05:14:48 +00:00
Chubby Granny Chaser
bf8b3ca836 style: update download group layout and styling for improved responsiveness 2025-11-30 05:14:26 +00:00
Moyasee
77e376e742 fix: peak spead not working 2025-11-30 07:13:12 +02:00
Chubby Granny Chaser
bd28b202c4 Merge branch 'fix/downloads-ui' of https://github.com/hydralauncher/hydra 2025-11-30 05:06:59 +00:00
Moyasee
153b954e78 fix: progress bar, context menu, repacks modal, responsiveness and styling fix 2025-11-30 07:05:19 +02:00
Chubby Granny Chaser
a9e63730be Merge pull request #1880 from hydralauncher/fix/fixing-hls-videos
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Fix/fixing hls videos
2025-11-30 03:45:10 +00:00
45 changed files with 869 additions and 701 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -414,7 +414,11 @@
"resume_seeding": "Продолжить раздачу",
"options": "Управлять",
"extract": "Распаковать файлы",
"extracting": "Распаковка файлов…"
"extracting": "Распаковка файлов…",
"network": "Сеть",
"peak": "Пик",
"seeds": "Seeds",
"peers": "Peers"
},
"settings": {
"downloads_path": "Путь загрузок",

View File

@@ -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);

View File

@@ -1,3 +1,2 @@
import "./get-session-hash";
import "./open-auth-window";
import "./sign-out";

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -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);

View File

@@ -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";

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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";

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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";

View File

@@ -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()) {

View File

@@ -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);

View File

@@ -1,5 +1,4 @@
import "./authenticate-real-debrid";
import "./authenticate-torbox";
import "./auto-launch";
import "./get-user-preferences";
import "./update-user-preferences";

View File

@@ -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);

View File

@@ -1,3 +1,2 @@
import "./get-auth";
import "./get-compared-unlocked-achievements";
import "./get-unlocked-achievements";

View File

@@ -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) => {

View File

@@ -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"
);
});

View File

@@ -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" }
);

View File

@@ -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);
}

View File

@@ -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) =>

View File

@@ -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} &quot;
{VERSION_CODENAME}&quot;
</small>

View File

@@ -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 && (

View File

@@ -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 =

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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;
}
}

View File

@@ -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)}>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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={{

View File

@@ -20,6 +20,8 @@ export interface Auth {
accessToken: string;
refreshToken: string;
tokenExpirationTimestamp: number;
featurebaseJwt: string;
workwondersJwt: string;
}
export interface User {

View File

@@ -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: