diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 99f9d206..90489b6d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,7 +7,7 @@ module.exports = { "plugin:jsx-a11y/recommended", "@electron-toolkit/eslint-config-ts/recommended", "plugin:prettier/recommended", - "plugin:storybook/recommended" + "plugin:storybook/recommended", ], rules: { "@typescript-eslint/explicit-function-return-type": "off", diff --git a/python_rpc/main.py b/python_rpc/main.py index 03df83de..cc28d623 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -94,7 +94,7 @@ def seed_status(): @app.route("/healthcheck", methods=["GET"]) def healthcheck(): - return "", 200 + return "ok", 200 @app.route("/process-list", methods=["GET"]) def process_list(): diff --git a/scripts/upload-build.cjs b/scripts/upload-build.cjs index 37fcd7a1..f950908f 100644 --- a/scripts/upload-build.cjs +++ b/scripts/upload-build.cjs @@ -49,14 +49,14 @@ fs.readdir(dist, async (err, files) => { }) ); - if (uploads.length > 0) { + for (const upload of uploads) { await fetch(process.env.BUILD_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - uploads, + upload, branchName: process.env.BRANCH_NAME, version: packageJson.version, githubActor: process.env.GITHUB_ACTOR, diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 9e1021fc..0af6dd9d 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -172,7 +172,8 @@ "reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}", "reset_achievements_title": "Tem certeza?", "reset_achievements_success": "Conquistas resetadas com sucesso", - "reset_achievements_error": "Falha ao resetar conquistas" + "reset_achievements_error": "Falha ao resetar conquistas", + "no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais." }, "activation": { "title": "Ativação", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f96ef495..1b48c5e0 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -7,8 +7,8 @@ "featured": "Рекомендации", "surprise_me": "Удиви меня", "no_results": "Ничего не найдено", - "hot": "Сейчас в топе", - "start_typing": "Начинаю вводить текст для поиска...", + "hot": "Сейчас популярно", + "start_typing": "Начинаю вводить текст...", "weekly": "📅 Лучшие игры недели", "achievements": "🏆 Игры, в которых нужно победить" }, @@ -424,7 +424,7 @@ "subscribe_now": "Подпишитесь прямо сейчас", "cloud_saving": "Сохранение в облаке", "cloud_achievements": "Сохраняйте свои достижения в облаке", - "animated_profile_picture": "Анимированные фотографии профиля", + "animated_profile_picture": "Анимированные аватарки", "premium_support": "Премиальная поддержка", "show_and_compare_achievements": "Показывайте и сравнивайте свои достижения с достижениями других пользователей", "animated_profile_banner": "Анимированный баннер профиля", diff --git a/src/main/events/autoupdater/check-for-updates.ts b/src/main/events/autoupdater/check-for-updates.ts index 1dcc80f3..7ea60d0b 100644 --- a/src/main/events/autoupdater/check-for-updates.ts +++ b/src/main/events/autoupdater/check-for-updates.ts @@ -1,47 +1,8 @@ -import type { AppUpdaterEvent } from "@types"; import { registerEvent } from "../register-event"; -import updater, { UpdateInfo } from "electron-updater"; -import { WindowManager } from "@main/services"; -import { app } from "electron"; -import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications"; - -const { autoUpdater } = updater; - -const sendEvent = (event: AppUpdaterEvent) => { - WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event); -}; - -const sendEventsForDebug = false; - -const isAutoInstallAvailable = - process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null; - -const mockValuesForDebug = () => { - sendEvent({ type: "update-available", info: { version: "1.3.0" } }); - sendEvent({ type: "update-downloaded" }); -}; - -const newVersionInfo = { version: "" }; +import { UpdateManager } from "@main/services/update-manager"; const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => { - autoUpdater - .once("update-available", (info: UpdateInfo) => { - sendEvent({ type: "update-available", info }); - newVersionInfo.version = info.version; - }) - .once("update-downloaded", () => { - sendEvent({ type: "update-downloaded" }); - publishNotificationUpdateReadyToInstall(newVersionInfo.version); - }); - - if (app.isPackaged) { - autoUpdater.autoDownload = isAutoInstallAvailable; - autoUpdater.checkForUpdates(); - } else if (sendEventsForDebug) { - mockValuesForDebug(); - } - - return isAutoInstallAvailable; + return UpdateManager.checkForUpdates(); }; registerEvent("checkForUpdates", checkForUpdates); diff --git a/src/main/events/hardware/check-folder-write-permission.ts b/src/main/events/hardware/check-folder-write-permission.ts index c74f01e7..af896e98 100644 --- a/src/main/events/hardware/check-folder-write-permission.ts +++ b/src/main/events/hardware/check-folder-write-permission.ts @@ -1,15 +1,21 @@ import fs from "node:fs"; +import path from "node:path"; import { registerEvent } from "../register-event"; const checkFolderWritePermission = async ( _event: Electron.IpcMainInvokeEvent, - path: string -) => - new Promise((resolve) => { - fs.access(path, fs.constants.W_OK, (err) => { - resolve(!err); - }); - }); + testPath: string +) => { + const testFilePath = path.join(testPath, ".hydra-write-test"); + + try { + fs.writeFileSync(testFilePath, ""); + fs.rmSync(testFilePath); + return true; + } catch (err) { + return false; + } +}; registerEvent("checkFolderWritePermission", checkFolderWritePermission); diff --git a/src/main/events/helpers/get-downloads-path.ts b/src/main/events/helpers/get-downloads-path.ts index 782ea599..0403095f 100644 --- a/src/main/events/helpers/get-downloads-path.ts +++ b/src/main/events/helpers/get-downloads-path.ts @@ -3,15 +3,14 @@ import { db, levelKeys } from "@main/level"; import type { UserPreferences } from "@types"; export const getDownloadsPath = async () => { - const userPreferences = await db.get( + const userPreferences = await db.get( levelKeys.userPreferences, { valueEncoding: "json", } ); - if (userPreferences && userPreferences.downloadsPath) - return userPreferences.downloadsPath; + if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; return defaultDownloadsPath; }; diff --git a/src/main/events/helpers/parse-launch-options.ts b/src/main/events/helpers/parse-launch-options.ts new file mode 100644 index 00000000..89a0c611 --- /dev/null +++ b/src/main/events/helpers/parse-launch-options.ts @@ -0,0 +1,7 @@ +export const parseLaunchOptions = (params?: string | null): string[] => { + if (!params) { + return []; + } + + return params.split(" "); +}; diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index 3bbf7cdc..64e3d5fb 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -1,8 +1,10 @@ import { registerEvent } from "../register-event"; import { shell } from "electron"; +import { spawn } from "child_process"; import { parseExecutablePath } from "../helpers/parse-executable-path"; import { gamesSublevel, levelKeys } from "@main/level"; import { GameShop } from "@types"; +import { parseLaunchOptions } from "../helpers/parse-launch-options"; const openGame = async ( _event: Electron.IpcMainInvokeEvent, @@ -11,8 +13,8 @@ const openGame = async ( executablePath: string, launchOptions?: string | null ) => { - // TODO: revisit this for launchOptions const parsedPath = parseExecutablePath(executablePath); + const parsedParams = parseLaunchOptions(launchOptions); const gameKey = levelKeys.game(shop, objectId); @@ -26,7 +28,12 @@ const openGame = async ( launchOptions, }); - shell.openPath(parsedPath); + if (parsedParams.length === 0) { + shell.openPath(parsedPath); + return; + } + + spawn(parsedPath, parsedParams, { shell: false, detached: true }); }; registerEvent("openGame", openGame); diff --git a/src/main/events/notifications/publish-new-repacks-notification.ts b/src/main/events/notifications/publish-new-repacks-notification.ts index 1f23eeb1..356b1b16 100644 --- a/src/main/events/notifications/publish-new-repacks-notification.ts +++ b/src/main/events/notifications/publish-new-repacks-notification.ts @@ -10,7 +10,7 @@ const publishNewRepacksNotification = async ( ) => { if (newRepacksCount < 1) return; - const userPreferences = await db.get( + const userPreferences = await db.get( levelKeys.userPreferences, { valueEncoding: "json", diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index 7b90e483..f5a04f0d 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -7,7 +7,7 @@ import { omit } from "lodash-es"; import axios from "axios"; import { fileTypeFromFile } from "file-type"; -const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { +export const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { return HydraApi.patch("/profile", updateProfile); }; diff --git a/src/main/events/user-preferences/get-user-preferences.ts b/src/main/events/user-preferences/get-user-preferences.ts index 19458496..b40d6780 100644 --- a/src/main/events/user-preferences/get-user-preferences.ts +++ b/src/main/events/user-preferences/get-user-preferences.ts @@ -5,11 +5,11 @@ import type { UserPreferences } from "@types"; const getUserPreferences = async () => db - .get(levelKeys.userPreferences, { + .get(levelKeys.userPreferences, { valueEncoding: "json", }) .then((userPreferences) => { - if (userPreferences.realDebridApiToken) { + if (userPreferences?.realDebridApiToken) { userPreferences.realDebridApiToken = Crypto.decrypt( userPreferences.realDebridApiToken ); diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index 433e7742..31193558 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -4,12 +4,13 @@ import type { UserPreferences } from "@types"; import i18next from "i18next"; import { db, levelKeys } from "@main/level"; import { Crypto } from "@main/services"; +import { patchUserProfile } from "../profile/update-profile"; const updateUserPreferences = async ( _event: Electron.IpcMainInvokeEvent, preferences: Partial ) => { - const userPreferences = await db.get( + const userPreferences = await db.get( levelKeys.userPreferences, { valueEncoding: "json" } ); @@ -20,6 +21,7 @@ const updateUserPreferences = async ( }); i18next.changeLanguage(preferences.language); + patchUserProfile({ language: preferences.language }).catch(() => {}); } if (preferences.realDebridApiToken) { @@ -28,6 +30,10 @@ const updateUserPreferences = async ( ); } + if (!preferences.downloadsPath) { + preferences.downloadsPath = null; + } + await db.put( levelKeys.userPreferences, { diff --git a/src/main/events/user/get-compared-unlocked-achievements.ts b/src/main/events/user/get-compared-unlocked-achievements.ts index 33b37584..697ad716 100644 --- a/src/main/events/user/get-compared-unlocked-achievements.ts +++ b/src/main/events/user/get-compared-unlocked-achievements.ts @@ -10,7 +10,7 @@ const getComparedUnlockedAchievements = async ( shop: GameShop, userId: string ) => { - const userPreferences = await db.get( + const userPreferences = await db.get( levelKeys.userPreferences, { valueEncoding: "json", @@ -25,7 +25,7 @@ const getComparedUnlockedAchievements = async ( { shop, objectId, - language: userPreferences?.language || "en", + language: userPreferences?.language ?? "en", } ).then((achievements) => { const sortedAchievements = achievements.achievements diff --git a/src/main/events/user/get-unlocked-achievements.ts b/src/main/events/user/get-unlocked-achievements.ts index 9cb44423..6deecbad 100644 --- a/src/main/events/user/get-unlocked-achievements.ts +++ b/src/main/events/user/get-unlocked-achievements.ts @@ -12,7 +12,7 @@ export const getUnlockedAchievements = async ( levelKeys.game(shop, objectId) ); - const userPreferences = await db.get( + const userPreferences = await db.get( levelKeys.userPreferences, { valueEncoding: "json", diff --git a/src/main/index.ts b/src/main/index.ts index 0f7c0297..2a18fa31 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import resources from "@locales"; import { PythonRPC } from "./services/python-rpc"; import { Aria2 } from "./services/aria2"; import { db, levelKeys } from "./level"; +import { loadState } from "./main"; const { autoUpdater } = updater; @@ -57,7 +58,7 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); - await import("./main"); + await loadState(); const language = await db.get(levelKeys.language, { valueEncoding: "utf-8", diff --git a/src/main/main.ts b/src/main/main.ts index f7ad596d..0777a28e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,8 +21,18 @@ import { import { Auth, User, type UserPreferences } from "@types"; import { knexClient } from "./knex-client"; -const loadState = async (userPreferences: UserPreferences | null) => { - import("./events"); +export const loadState = async () => { + const userPreferences = await migrateFromSqlite().then(async () => { + await db.put(levelKeys.sqliteMigrationDone, true, { + valueEncoding: "json", + }); + + return db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }); + }); + + await import("./events"); Aria2.spawn(); @@ -104,24 +114,29 @@ const migrateFromSqlite = async () => { if (userPreferences.length > 0) { const { realDebridApiToken, ...rest } = userPreferences[0]; - await db.put(levelKeys.userPreferences, { - ...rest, - realDebridApiToken: realDebridApiToken - ? Crypto.encrypt(realDebridApiToken) - : null, - preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, - runAtStartup: rest.runAtStartup === 1, - startMinimized: rest.startMinimized === 1, - disableNsfwAlert: rest.disableNsfwAlert === 1, - seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, - showHiddenAchievementsDescription: - rest.showHiddenAchievementsDescription === 1, - downloadNotificationsEnabled: rest.downloadNotificationsEnabled === 1, - repackUpdatesNotificationsEnabled: - rest.repackUpdatesNotificationsEnabled === 1, - achievementNotificationsEnabled: - rest.achievementNotificationsEnabled === 1, - }); + await db.put( + levelKeys.userPreferences, + { + ...rest, + realDebridApiToken: realDebridApiToken + ? Crypto.encrypt(realDebridApiToken) + : null, + preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, + runAtStartup: rest.runAtStartup === 1, + startMinimized: rest.startMinimized === 1, + disableNsfwAlert: rest.disableNsfwAlert === 1, + seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, + showHiddenAchievementsDescription: + rest.showHiddenAchievementsDescription === 1, + downloadNotificationsEnabled: + rest.downloadNotificationsEnabled === 1, + repackUpdatesNotificationsEnabled: + rest.repackUpdatesNotificationsEnabled === 1, + achievementNotificationsEnabled: + rest.achievementNotificationsEnabled === 1, + }, + { valueEncoding: "json" } + ); if (rest.language) { await db.put(levelKeys.language, rest.language); @@ -192,15 +207,3 @@ const migrateFromSqlite = async () => { migrateUser, ]); }; - -migrateFromSqlite().then(async () => { - await db.put(levelKeys.sqliteMigrationDone, true, { - valueEncoding: "json", - }); - - db.get(levelKeys.userPreferences, { - valueEncoding: "json", - }).then((userPreferences) => { - loadState(userPreferences); - }); -}); diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index d1111b0d..8b076d9e 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -141,7 +141,7 @@ const processAchievementFileDiff = async ( export class AchievementWatcherManager { private static hasFinishedMergingWithRemote = false; - public static watchAchievements = () => { + public static watchAchievements() { if (!this.hasFinishedMergingWithRemote) return; if (process.platform === "win32") { @@ -149,12 +149,12 @@ export class AchievementWatcherManager { } return watchAchievementsWithWine(); - }; + } - private static preProcessGameAchievementFiles = ( + private static preProcessGameAchievementFiles( game: Game, gameAchievementFiles: AchievementFile[] - ) => { + ) { const unlockedAchievements: UnlockedAchievement[] = []; for (const achievementFile of gameAchievementFiles) { const parsedAchievements = parseAchievementFile( @@ -182,7 +182,7 @@ export class AchievementWatcherManager { } return mergeAchievements(game, unlockedAchievements, false); - }; + } private static preSearchAchievementsWindows = async () => { const games = await gamesSublevel @@ -230,7 +230,7 @@ export class AchievementWatcherManager { ); }; - public static preSearchAchievements = async () => { + public static async preSearchAchievements() { try { const newAchievementsCount = process.platform === "win32" @@ -256,5 +256,5 @@ export class AchievementWatcherManager { } this.hasFinishedMergingWithRemote = true; - }; + } } diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index 4c03c7e1..0d0c58f9 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -40,7 +40,7 @@ export const getGameAchievementData = async ( throw err; } - logger.error("Failed to get game achievements", err); + logger.error("Failed to get game achievements for", objectId, err); return []; }); diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index a2541d5e..7e6ebf0a 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -59,7 +59,7 @@ export const mergeAchievements = async ( const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? []; const newAchievementsMap = new Map( - achievements.reverse().map((achievement) => { + achievements.toReversed().map((achievement) => { return [achievement.name.toUpperCase(), achievement]; }) ); @@ -87,7 +87,7 @@ export const mergeAchievements = async ( userPreferences?.achievementNotificationsEnabled ) { const achievementsInfo = newAchievements - .sort((a, b) => { + .toSorted((a, b) => { return a.unlockTime - b.unlockTime; }) .map((achievement) => { diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 4d5623a0..ba972b44 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -3,7 +3,7 @@ import { WindowManager } from "./window-manager"; import url from "url"; import { uploadGamesBatch } from "./library-sync"; import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; -import { logger } from "./logger"; +import { networkLogger as logger } from "./logger"; import { UserNotLoggedInError, SubscriptionRequiredError } from "@shared"; import { omit } from "lodash-es"; import { appVersion } from "@main/constants"; @@ -32,7 +32,8 @@ export class HydraApi { private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static readonly ADD_LOG_INTERCEPTOR = true; - private static secondsToMilliseconds = (seconds: number) => seconds * 1000; + private static readonly secondsToMilliseconds = (seconds: number) => + seconds * 1000; private static userAuth: HydraApiUserAuth = { authToken: "", @@ -153,7 +154,8 @@ export class HydraApi { (error) => { logger.error(" ---- RESPONSE ERROR -----"); const { config } = error; - const data = JSON.parse(config.data); + + const data = JSON.parse(config.data ?? null); logger.error( config.method, @@ -174,14 +176,22 @@ export class HydraApi { error.response.status, error.response.data ); - } else if (error.request) { - const errorData = error.toJSON(); - logger.error("Request error:", errorData.message); - } else { - logger.error("Error", error.message); + + return Promise.reject(error as Error); } - logger.error(" ----- END RESPONSE ERROR -------"); - return Promise.reject(error); + + if (error.request) { + const errorData = error.toJSON(); + logger.error("Request error:", errorData.code, errorData.message); + return Promise.reject( + new Error( + `Request failed with ${errorData.code} ${errorData.message}` + ) + ); + } + + logger.error("Error", error.message); + return Promise.reject(error as Error); } ); } diff --git a/src/main/services/logger.ts b/src/main/services/logger.ts index 95a399ea..03bf6ad7 100644 --- a/src/main/services/logger.ts +++ b/src/main/services/logger.ts @@ -6,8 +6,12 @@ log.transports.file.resolvePathFn = ( _: log.PathVariables, message?: log.LogMessage | undefined ) => { - if (message?.scope === "python-instance") { - return path.join(logsPath, "pythoninstance.txt"); + if (message?.scope === "python-rpc") { + return path.join(logsPath, "pythonrpc.txt"); + } + + if (message?.scope === "network") { + return path.join(logsPath, "network.txt"); } if (message?.scope == "achievements") { @@ -34,3 +38,4 @@ log.initialize(); export const pythonRpcLogger = log.scope("python-rpc"); export const logger = log.scope("main"); export const achievementsLogger = log.scope("achievements"); +export const networkLogger = log.scope("network"); diff --git a/src/main/services/main-loop.ts b/src/main/services/main-loop.ts index a1c2b449..12b6e3a7 100644 --- a/src/main/services/main-loop.ts +++ b/src/main/services/main-loop.ts @@ -2,6 +2,7 @@ import { sleep } from "@main/helpers"; import { DownloadManager } from "./download"; import { watchProcesses } from "./process-watcher"; import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; +import { UpdateManager } from "./update-manager"; export const startMainLoop = async () => { // eslint-disable-next-line no-constant-condition @@ -11,6 +12,7 @@ export const startMainLoop = async () => { DownloadManager.watchDownloads(), AchievementWatcherManager.watchAchievements(), DownloadManager.getSeedStatus(), + UpdateManager.checkForUpdatePeriodically(), ]); await sleep(1500); diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index c9be2f54..63c666dc 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -9,6 +9,7 @@ import { achievementSoundPath } from "@main/constants"; import icon from "@resources/icon.png?asset"; import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; +import { WindowManager } from "../window-manager"; import type { Game, UserPreferences } from "@types"; import { db, levelKeys } from "@main/level"; @@ -96,7 +97,9 @@ export const publishCombinedNewAchievementNotification = async ( toastXml: toXmlString(options), }).show(); - if (process.platform !== "linux") { + if (WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); + } else if (process.platform !== "linux") { sound.play(achievementSoundPath); } }; @@ -143,7 +146,9 @@ export const publishNewAchievementNotification = async (info: { toastXml: toXmlString(options), }).show(); - if (process.platform !== "linux") { + if (WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); + } else if (process.platform !== "linux") { sound.play(achievementSoundPath); } }; diff --git a/src/main/services/update-manager.ts b/src/main/services/update-manager.ts new file mode 100644 index 00000000..9a277dd7 --- /dev/null +++ b/src/main/services/update-manager.ts @@ -0,0 +1,60 @@ +import updater, { UpdateInfo } from "electron-updater"; +import { logger, WindowManager } from "@main/services"; +import { AppUpdaterEvent } from "@types"; +import { app } from "electron"; +import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications"; + +const isAutoInstallAvailable = + process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null; + +const { autoUpdater } = updater; +const sendEventsForDebug = false; + +export class UpdateManager { + private static hasNotified = false; + private static newVersion = ""; + private static checkTick = 0; + + private static mockValuesForDebug() { + this.sendEvent({ type: "update-available", info: { version: "1.3.0" } }); + this.sendEvent({ type: "update-downloaded" }); + } + + private static sendEvent(event: AppUpdaterEvent) { + WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event); + } + + public static checkForUpdates() { + autoUpdater + .once("update-available", (info: UpdateInfo) => { + this.sendEvent({ type: "update-available", info }); + this.newVersion = info.version; + }) + .once("update-downloaded", () => { + this.sendEvent({ type: "update-downloaded" }); + + if (!this.hasNotified) { + this.hasNotified = true; + publishNotificationUpdateReadyToInstall(this.newVersion); + } + }); + + if (app.isPackaged) { + autoUpdater.autoDownload = isAutoInstallAvailable; + autoUpdater.checkForUpdates().then((result) => { + logger.log(`Check for updates result: ${result}`); + }); + } else if (sendEventsForDebug) { + this.mockValuesForDebug(); + } + + return isAutoInstallAvailable; + } + + public static checkForUpdatePeriodically() { + if (this.checkTick % 2000 == 0) { + this.checkForUpdates(); + } + this.checkTick++; + } +} diff --git a/src/main/services/user/get-user-data.ts b/src/main/services/user/get-user-data.ts index ed07c61e..d26c995d 100644 --- a/src/main/services/user/get-user-data.ts +++ b/src/main/services/user/get-user-data.ts @@ -29,7 +29,6 @@ export const getUserData = async () => { }) .catch(async (err) => { if (err instanceof UserNotLoggedInError) { - logger.info("User is not logged in", err); return null; } logger.error("Failed to get logged user"); @@ -59,6 +58,7 @@ export const getUserData = async () => { expiresAt: loggedUser.subscription.expiresAt, } : null, + featurebaseJwt: "", } as UserDetails; } diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index cf9089b7..70e7255c 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -50,7 +50,7 @@ export class WindowManager { minHeight: 540, backgroundColor: "#1c1c1c", titleBarStyle: process.platform === "linux" ? "default" : "hidden", - ...(process.platform === "linux" ? { icon } : {}), + icon, trafficLightPosition: { x: 16, y: 16 }, titleBarOverlay: { symbolColor: "#DADBE1", @@ -145,6 +145,11 @@ export class WindowManager { WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow = null; }); + + this.mainWindow.webContents.setWindowOpenHandler((handler) => { + shell.openExternal(handler.url); + return { action: "deny" }; + }); } public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 588becdc..eac3c0a1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -169,6 +169,12 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-library-batch-complete", listener); }, + onAchievementUnlocked: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-achievement-unlocked", listener); + return () => + ipcRenderer.removeListener("on-achievement-unlocked", listener); + }, /* Hardware */ getDiskFreeSpace: (path: string) => diff --git a/src/renderer/src/app.css.ts b/src/renderer/src/app.css.ts index 25c453c8..b5c4740e 100644 --- a/src/renderer/src/app.css.ts +++ b/src/renderer/src/app.css.ts @@ -123,7 +123,7 @@ export const titleBar = style({ alignItems: "center", padding: `0 ${SPACING_UNIT * 2}px`, WebkitAppRegion: "drag", - zIndex: "4", + zIndex: vars.zIndex.titleBar, borderBottom: `1px solid ${vars.color.border}`, } as ComplexStyleRule); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index ac6c2293..00a1d399 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; - +import achievementSound from "@renderer/assets/audio/achievement.wav"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { @@ -234,6 +234,22 @@ export function App() { downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); }, [updateRepacks]); + const playAudio = useCallback(() => { + const audio = new Audio(achievementSound); + audio.volume = 0.2; + audio.play(); + }, []); + + useEffect(() => { + const unsubscribe = window.electron.onAchievementUnlocked(() => { + playAudio(); + }); + + return () => { + unsubscribe(); + }; + }, [playAudio]); + const handleToastClose = useCallback(() => { dispatch(closeToast()); }, [dispatch]); @@ -262,6 +278,7 @@ export function App() { > {status} - - {sessionHash ? `${sessionHash} -` : ""} v{version} " - {VERSION_CODENAME}" - + ); } diff --git a/src/renderer/src/components/sidebar/sidebar.css.ts b/src/renderer/src/components/sidebar/sidebar.css.ts deleted file mode 100644 index 4fd4b981..00000000 --- a/src/renderer/src/components/sidebar/sidebar.css.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { style } from "@vanilla-extract/css"; -import { recipe } from "@vanilla-extract/recipes"; - -import { SPACING_UNIT, vars } from "../../theme.css"; - -export const sidebar = recipe({ - base: { - backgroundColor: vars.color.darkBackground, - color: vars.color.muted, - flexDirection: "column", - display: "flex", - transition: "opacity ease 0.2s", - borderRight: `solid 1px ${vars.color.border}`, - position: "relative", - overflow: "hidden", - justifyContent: "space-between", - }, - variants: { - resizing: { - true: { - opacity: vars.opacity.active, - pointerEvents: "none", - }, - }, - darwin: { - true: { - paddingTop: `${SPACING_UNIT * 6}px`, - }, - false: { - paddingTop: `${SPACING_UNIT}px`, - }, - }, - }, -}); - -export const content = style({ - display: "flex", - flexDirection: "column", - padding: `${SPACING_UNIT * 2}px`, - gap: `${SPACING_UNIT * 2}px`, - width: "100%", - overflow: "auto", -}); - -export const handle = style({ - width: "5px", - height: "100%", - cursor: "col-resize", - position: "absolute", - right: "0", -}); - -export const menu = style({ - listStyle: "none", - padding: "0", - margin: "0", - gap: `${SPACING_UNIT / 2}px`, - display: "flex", - flexDirection: "column", - overflow: "hidden", -}); - -export const menuItem = recipe({ - base: { - transition: "all ease 0.1s", - cursor: "pointer", - textWrap: "nowrap", - display: "flex", - color: vars.color.muted, - borderRadius: "4px", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, - }, - variants: { - active: { - true: { - backgroundColor: "rgba(255, 255, 255, 0.1)", - }, - }, - muted: { - true: { - opacity: vars.opacity.disabled, - ":hover": { - opacity: "1", - }, - }, - }, - }, -}); - -export const menuItemButton = style({ - color: "inherit", - display: "flex", - alignItems: "center", - gap: `${SPACING_UNIT}px`, - cursor: "pointer", - overflow: "hidden", - width: "100%", - padding: `9px ${SPACING_UNIT}px`, -}); - -export const menuItemButtonLabel = style({ - textOverflow: "ellipsis", - overflow: "hidden", -}); - -export const gameIcon = style({ - width: "20px", - height: "20px", - minWidth: "20px", - minHeight: "20px", - borderRadius: "4px", - backgroundSize: "cover", -}); - -export const sectionTitle = style({ - textTransform: "uppercase", - fontWeight: "bold", -}); - -export const section = style({ - gap: `${SPACING_UNIT * 2}px`, - display: "flex", - flexDirection: "column", - paddingBottom: `${SPACING_UNIT}px`, -}); - -export const helpButton = style({ - color: vars.color.muted, - padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`, - gap: "9px", - display: "flex", - alignItems: "center", - cursor: "pointer", - borderTop: `solid 1px ${vars.color.border}`, - transition: "background-color ease 0.1s", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, -}); - -export const helpButtonIcon = style({ - background: "linear-gradient(0deg, #16B195 50%, #3E62C0 100%)", - width: "24px", - height: "24px", - display: "flex", - alignItems: "center", - justifyContent: "center", - color: "#fff", - borderRadius: "50%", -}); diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss new file mode 100644 index 00000000..c11a1041 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -0,0 +1,136 @@ +@use "../../scss/globals.scss"; + +.sidebar { + background-color: globals.$dark-background-color; + color: globals.$muted-color; + flex-direction: column; + display: flex; + transition: opacity ease 0.2s; + border-right: solid 1px globals.$border-color; + position: relative; + overflow: hidden; + padding-top: globals.$spacing-unit; + + &--resizing { + opacity: globals.$active-opacity; + pointer-events: none; + } + + &--darwin { + padding-top: calc(globals.$spacing-unit * 6); + } + + &__content { + display: flex; + flex-direction: column; + padding: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit * 2); + width: 100%; + overflow: auto; + } + + &__handle { + width: 5px; + height: 100%; + cursor: col-resize; + position: absolute; + right: 0; + } + + &__menu { + list-style: none; + padding: 0; + margin: 0; + gap: calc(globals.$spacing-unit / 2); + display: flex; + flex-direction: column; + overflow: hidden; + } + + &__menu-item { + transition: all ease 0.1s; + cursor: pointer; + text-wrap: nowrap; + display: flex; + color: globals.$muted-color; + border-radius: 4px; + &:hover { + background-color: rgba(255, 255, 255, 0.15); + } + + &--active { + background-color: rgba(255, 255, 255, 0.1); + } + + &--muted { + opacity: globals.$disabled-opacity; + + &:hover { + opacity: 1; + } + } + } + + &__menu-item-button { + color: inherit; + display: flex; + align-items: center; + gap: globals.$spacing-unit; + cursor: pointer; + overflow: hidden; + width: 100%; + padding: 9px globals.$spacing-unit; + } + + &__menu-item-button-label { + text-overflow: ellipsis; + overflow: hidden; + } + + &__game-icon { + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + border-radius: 4px; + background-size: cover; + } + + &__section-title { + text-transform: uppercase; + font-weight: bold; + } + + &__section { + gap: calc(globals.$spacing-unit * 2); + display: flex; + flex-direction: column; + padding-bottom: globals.$spacing-unit; + } + + &__help-button { + color: globals.$muted-color; + padding: globals.$spacing-unit calc(globals.$spacing-unit * 2); + gap: 9px; + display: flex; + align-items: center; + cursor: pointer; + border-top: solid 1px globals.$border-color; + transition: background-color ease 0.1s; + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + } + } + + &__help-button-icon { + background: linear-gradient(0deg, #16b195 50%, #3e62c0 100%); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + border-radius: 50%; + } +} diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 28118c83..585f164e 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -181,7 +181,12 @@ export function Sidebar() { }} >
diff --git a/src/renderer/src/components/toast/toast.scss b/src/renderer/src/components/toast/toast.scss new file mode 100644 index 00000000..5cb801e8 --- /dev/null +++ b/src/renderer/src/components/toast/toast.scss @@ -0,0 +1,85 @@ +@use "../../scss/globals.scss"; + +.toast { + animation-duration: 0.2s; + animation-timing-function: ease-in-out; + position: absolute; + background-color: globals.$dark-background-color; + border-radius: 4px; + border: solid 1px globals.$border-color; + right: 0; + bottom: 0; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: space-between; + z-index: globals.$toast-z-index; + max-width: 420px; + animation-name: enter; + transform: translateY(0); + + &--closing { + animation-name: exit; + transform: translateY(100%); + } + + &__content { + display: flex; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2); + justify-content: center; + align-items: center; + } + + &__progress { + width: 100%; + height: 5px; + + &::-webkit-progress-bar { + background-color: globals.$dark-background-color; + } + &::-webkit-progress-value { + background-color: globals.$muted-color; + } + } + + &__close-button { + color: globals.$body-color; + cursor: pointer; + padding: 0; + margin: 0; + transition: color 0.2s ease-in-out; + + &:hover { + color: globals.$muted-color; + } + } + + &__icon { + &--success { + color: globals.$success-color; + } + + &--error { + color: globals.$danger-color; + } + + &--warning { + color: globals.$warning-color; + } + } +} + +@keyframes enter { + 0% { + opacity: 0; + transform: translateY(100%); + } +} + +@keyframes exit { + 0% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 390b8c5e..eaf5cb49 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -142,6 +142,7 @@ declare global { minimized: boolean; }) => Promise; authenticateRealDebrid: (apiToken: string) => Promise; + onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer; /* Download sources */ putDownloadSource: ( diff --git a/src/renderer/src/features/toast-slice.ts b/src/renderer/src/features/toast-slice.ts index e27480fa..44abf53a 100644 --- a/src/renderer/src/features/toast-slice.ts +++ b/src/renderer/src/features/toast-slice.ts @@ -3,12 +3,14 @@ import type { PayloadAction } from "@reduxjs/toolkit"; import { ToastProps } from "@renderer/components/toast/toast"; export interface ToastState { - message: string; + title: string; + message?: string; type: ToastProps["type"]; visible: boolean; } const initialState: ToastState = { + title: "", message: "", type: "success", visible: false, @@ -19,6 +21,7 @@ export const toastSlice = createSlice({ initialState, reducers: { showToast: (state, action: PayloadAction>) => { + state.title = action.payload.title; state.message = action.payload.message; state.type = action.payload.type; state.visible = true; diff --git a/src/renderer/src/hooks/use-toast.ts b/src/renderer/src/hooks/use-toast.ts index 485470f0..5e08a7ab 100644 --- a/src/renderer/src/hooks/use-toast.ts +++ b/src/renderer/src/hooks/use-toast.ts @@ -6,9 +6,10 @@ export function useToast() { const dispatch = useAppDispatch(); const showSuccessToast = useCallback( - (message: string) => { + (title: string, message?: string) => { dispatch( showToast({ + title, message, type: "success", }) @@ -18,9 +19,10 @@ export function useToast() { ); const showErrorToast = useCallback( - (message: string) => { + (title: string, message?: string) => { dispatch( showToast({ + title, message, type: "error", }) @@ -30,9 +32,10 @@ export function useToast() { ); const showWarningToast = useCallback( - (message: string) => { + (title: string, message?: string) => { dispatch( showToast({ + title, message, type: "warning", }) diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 0679cde8..2fdac382 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -78,9 +78,15 @@ export function useUserDetails() { ...response, username: userDetails?.username || "", subscription: userDetails?.subscription || null, + featurebaseJwt: userDetails?.featurebaseJwt || "", }); }, - [updateUserDetails, userDetails?.username, userDetails?.subscription] + [ + updateUserDetails, + userDetails?.username, + userDetails?.subscription, + userDetails?.featurebaseJwt, + ] ); const syncFriendRequests = useCallback(async () => { diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 61c561f1..cb6ba45f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -45,6 +45,7 @@ Sentry.init({ tracesSampleRate: 1.0, replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, + release: await window.electron.getVersion(), }); console.log = logger.log; diff --git a/src/renderer/src/pages/achievements/achievements.scss b/src/renderer/src/pages/achievements/achievements.scss new file mode 100644 index 00000000..5a5de8e6 --- /dev/null +++ b/src/renderer/src/pages/achievements/achievements.scss @@ -0,0 +1,262 @@ +@use "../../scss/globals.scss"; +@use "sass:math"; + +$hero-height: 150px; +$logo-height: 100px; +$logo-max-width: 200px; + +.achievements { + display: flex; + flex-direction: column; + overflow: hidden; + width: 100%; + height: 100%; + transition: all ease 0.3s; + + &__hero { + width: 100%; + height: $hero-height; + min-height: $hero-height; + display: flex; + flex-direction: column; + position: relative; + transition: all ease 0.2s; + + &-content { + padding: globals.$spacing-unit * 2; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + } + + &-logo-backdrop { + width: 100%; + height: 100%; + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + &-image-skeleton { + height: 150px; + } + } + + &__game-logo { + width: $logo-max-width; + height: $logo-height; + object-fit: contain; + transition: all ease 0.2s; + + &:hover { + transform: scale(1.05); + } + } + + &__container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + z-index: 1; + } + + &__table-header { + width: 100%; + background-color: var(--color-dark-background); + transition: all ease 0.2s; + border-bottom: solid 1px var(--color-border); + position: sticky; + top: 0; + z-index: 1; + + &--stuck { + box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8); + } + } + + &__list { + list-style: none; + margin: 0; + display: flex; + flex-direction: column; + gap: globals.$spacing-unit * 2; + padding: globals.$spacing-unit * 2; + width: 100%; + background-color: var(--color-background); + } + + &__item { + display: flex; + transition: all ease 0.1s; + color: var(--color-muted); + width: 100%; + overflow: hidden; + border-radius: 4px; + padding: globals.$spacing-unit globals.$spacing-unit; + gap: globals.$spacing-unit * 2; + align-items: center; + text-align: left; + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + text-decoration: none; + } + + &-image { + width: 54px; + height: 54px; + border-radius: 4px; + object-fit: cover; + + &--locked { + filter: grayscale(100%); + } + } + + &-content { + flex: 1; + } + + &-title { + display: flex; + align-items: center; + gap: 4px; + } + + &-hidden-icon { + display: flex; + color: var(--color-warning); + opacity: 0.8; + + &:hover { + opacity: 1; + } + + svg { + width: 12px; + height: 12px; + } + } + + &-eye-closed { + width: 12px; + height: 12px; + color: globals.$warning-color; + scale: 4; + } + + &-meta { + display: flex; + flex-direction: column; + gap: 8px; + } + + &-points { + display: flex; + align-items: center; + gap: 4px; + margin-right: 4px; + font-weight: 600; + + &--locked { + cursor: pointer; + color: var(--color-warning); + } + + &-icon { + width: 18px; + height: 18px; + } + + &-value { + font-size: 1.1em; + } + } + + &-unlock-time { + white-space: nowrap; + gap: 4px; + display: flex; + } + + &-compared { + display: grid; + grid-template-columns: 3fr 1fr 1fr; + + &--no-owner { + grid-template-columns: 3fr 2fr; + } + } + + &-main { + display: flex; + flex-direction: row; + align-items: center; + gap: globals.$spacing-unit; + } + + &-status { + display: flex; + padding: globals.$spacing-unit; + justify-content: center; + + &--unlocked { + white-space: nowrap; + flex-direction: row; + gap: globals.$spacing-unit; + padding: 0; + } + } + } + + &__progress-bar { + width: 100%; + height: 8px; + transition: all ease 0.2s; + + &::-webkit-progress-bar { + background-color: rgba(255, 255, 255, 0.15); + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: var(--color-muted); + border-radius: 4px; + } + } + + &__profile-avatar { + height: 54px; + width: 54px; + border-radius: 4px; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--color-background); + position: relative; + object-fit: cover; + + &--small { + height: 32px; + width: 32px; + } + } + + &__subscription-button { + text-decoration: none; + display: flex; + justify-content: center; + width: 100%; + gap: math.div(globals.$spacing-unit, 2); + color: var(--color-body); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/src/renderer/src/pages/downloads/download-group.css.ts b/src/renderer/src/pages/downloads/download-group.css.ts deleted file mode 100644 index cbbb4f8e..00000000 --- a/src/renderer/src/pages/downloads/download-group.css.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { style } from "@vanilla-extract/css"; - -import { SPACING_UNIT, vars } from "../../theme.css"; - -export const downloadTitleWrapper = style({ - display: "flex", - alignItems: "center", - marginBottom: `${SPACING_UNIT}px`, - gap: `${SPACING_UNIT}px`, -}); - -export const downloadTitle = style({ - fontWeight: "bold", - cursor: "pointer", - color: vars.color.body, - textAlign: "left", - fontSize: "16px", - display: "block", - ":hover": { - textDecoration: "underline", - }, -}); - -export const downloads = style({ - width: "100%", - gap: `${SPACING_UNIT * 2}px`, - display: "flex", - flexDirection: "column", - margin: "0", - padding: "0", - marginTop: `${SPACING_UNIT}px`, -}); - -export const downloadCover = style({ - width: "280px", - minWidth: "280px", - height: "auto", - borderRight: `solid 1px ${vars.color.border}`, - position: "relative", - zIndex: "1", -}); - -export const downloadCoverContent = style({ - width: "100%", - height: "100%", - padding: `${SPACING_UNIT}px`, - display: "flex", - alignItems: "flex-end", - justifyContent: "flex-end", -}); - -export const downloadCoverBackdrop = style({ - width: "100%", - height: "100%", - background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, transparent 100%)", - display: "flex", - overflow: "hidden", - zIndex: "1", -}); - -export const downloadCoverImage = style({ - width: "100%", - height: "100%", - position: "absolute", - zIndex: "-1", -}); - -export const download = style({ - width: "100%", - backgroundColor: vars.color.background, - display: "flex", - borderRadius: "8px", - border: `solid 1px ${vars.color.border}`, - overflow: "hidden", - boxShadow: "0px 0px 5px 0px #000000", - transition: "all ease 0.2s", - height: "140px", - minHeight: "140px", - maxHeight: "140px", -}); - -export const downloadDetails = style({ - display: "flex", - flexDirection: "column", - flex: "1", - justifyContent: "center", - gap: `${SPACING_UNIT / 2}px`, - fontSize: "14px", -}); - -export const downloadRightContent = style({ - display: "flex", - padding: `${SPACING_UNIT * 2}px`, - flex: "1", - gap: `${SPACING_UNIT}px`, - background: "linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%)", -}); - -export const downloadActions = style({ - display: "flex", - alignItems: "center", - gap: `${SPACING_UNIT}px`, -}); - -export const downloadGroup = style({ - display: "flex", - flexDirection: "column", - gap: `${SPACING_UNIT * 2}px`, -}); diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss new file mode 100644 index 00000000..2c5e9701 --- /dev/null +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -0,0 +1,140 @@ +@use "../../scss/globals.scss"; + +.download-group { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(globals.$spacing-unit * 2); + + &-divider { + flex: 1; + background-color: globals.$border-color; + height: 1px; + } + + &-count { + font-weight: 400; + } + } + + &__title-wrapper { + display: flex; + align-items: center; + margin-bottom: globals.$spacing-unit; + gap: globals.$spacing-unit; + } + + &__title { + font-weight: bold; + cursor: pointer; + color: globals.$body-color; + text-align: left; + font-size: 16px; + display: block; + + &:hover { + text-decoration: underline; + } + } + + &__downloads { + width: 100%; + gap: calc(globals.$spacing-unit * 2); + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + margin-top: globals.$spacing-unit; + } + + &__item { + width: 100%; + background-color: globals.$background-color; + display: flex; + border-radius: 8px; + border: solid 1px globals.$border-color; + overflow: hidden; + box-shadow: 0px 0px 5px 0px #000000; + transition: all ease 0.2s; + height: 140px; + min-height: 140px; + max-height: 140px; + position: relative; + } + + &__cover { + width: 280px; + min-width: 280px; + height: auto; + border-right: solid 1px globals.$border-color; + position: relative; + z-index: 1; + + &-content { + width: 100%; + height: 100%; + padding: globals.$spacing-unit; + display: flex; + align-items: flex-end; + justify-content: flex-end; + } + + &-backdrop { + width: 100%; + height: 100%; + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.8) 5%, + transparent 100% + ); + display: flex; + overflow: hidden; + z-index: 1; + } + + &-image { + width: 100%; + height: 100%; + position: absolute; + z-index: -1; + } + } + + &__right-content { + display: flex; + padding: calc(globals.$spacing-unit * 2); + flex: 1; + gap: globals.$spacing-unit; + background: linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%); + } + + &__details { + display: flex; + flex-direction: column; + flex: 1; + justify-content: center; + gap: calc(globals.$spacing-unit / 2); + font-size: 14px; + } + + &__actions { + display: flex; + align-items: center; + gap: globals.$spacing-unit; + } + + &__menu-button { + position: absolute; + top: 12px; + right: 12px; + border-radius: 50%; + border: none; + padding: 8px; + min-height: unset; + } +} diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss new file mode 100644 index 00000000..c8a1de09 --- /dev/null +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss @@ -0,0 +1,15 @@ +@use "../../../scss/globals.scss"; + +.hero-panel-playtime { + &__download-details { + gap: globals.$spacing-unit; + display: flex; + color: globals.$body-color; + align-items: center; + } + + &__downloads-link { + color: globals.$body-color; + text-decoration: underline; + } +} diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.css.ts b/src/renderer/src/pages/game-details/hero/hero-panel.css.ts deleted file mode 100644 index 3fdbc73b..00000000 --- a/src/renderer/src/pages/game-details/hero/hero-panel.css.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { style } from "@vanilla-extract/css"; -import { recipe } from "@vanilla-extract/recipes"; - -import { SPACING_UNIT, vars } from "../../../theme.css"; - -export const panel = recipe({ - base: { - width: "100%", - height: "72px", - minHeight: "72px", - padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`, - backgroundColor: vars.color.darkBackground, - display: "flex", - alignItems: "center", - justifyContent: "space-between", - transition: "all ease 0.2s", - borderBottom: `solid 1px ${vars.color.border}`, - position: "sticky", - overflow: "hidden", - top: "0", - zIndex: "2", - }, - variants: { - stuck: { - true: { - boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.8)", - }, - }, - }, -}); - -export const content = style({ - display: "flex", - flexDirection: "column", - gap: `${SPACING_UNIT}px`, -}); - -export const actions = style({ - display: "flex", - gap: `${SPACING_UNIT}px`, -}); - -export const downloadDetailsRow = style({ - gap: `${SPACING_UNIT}px`, - display: "flex", - color: vars.color.body, - alignItems: "center", -}); - -export const downloadsLink = style({ - color: vars.color.body, - textDecoration: "underline", -}); - -export const progressBar = recipe({ - base: { - position: "absolute", - bottom: "0", - left: "0", - width: "100%", - height: "3px", - transition: "all ease 0.2s", - "::-webkit-progress-bar": { - backgroundColor: "transparent", - }, - "::-webkit-progress-value": { - backgroundColor: vars.color.muted, - }, - }, - variants: { - disabled: { - true: { - opacity: vars.opacity.disabled, - }, - }, - }, -}); diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss new file mode 100644 index 00000000..066ce196 --- /dev/null +++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss @@ -0,0 +1,66 @@ +@use "../../../scss/globals.scss"; + +.hero-panel { + width: 100%; + height: 72px; + min-height: 72px; + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3); + background-color: globals.$dark-background-color; + display: flex; + align-items: center; + justify-content: space-between; + transition: all ease 0.2s; + border-bottom: solid 1px globals.$border-color; + position: sticky; + overflow: hidden; + top: 0; + z-index: 2; + + &--stuck { + box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8); + } + + &__content { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__actions { + display: flex; + gap: globals.$spacing-unit; + } + + &__download-details { + gap: globals.$spacing-unit; + display: flex; + color: globals.$body-color; + align-items: center; + } + + &__downloads-link { + color: globals.$body-color; + text-decoration: underline; + } + + &__progress-bar { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 3px; + transition: all ease 0.2s; + + &::-webkit-progress-bar { + background-color: transparent; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + } + + &--disabled { + opacity: globals.$disabled-opacity; + } + } +} diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index 541bd01c..9da8ea2e 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -98,9 +98,7 @@ export function DownloadSettingsModal({ ? Downloader.RealDebrid : filteredDownloaders[0]; - setSelectedDownloader( - selectedDownloader === undefined ? null : selectedDownloader - ); + setSelectedDownloader(selectedDownloader ?? null); }, [ userPreferences?.downloadsPath, downloaders, @@ -127,8 +125,8 @@ export function DownloadSettingsModal({ .then(() => { onClose(); }) - .catch(() => { - showErrorToast(t("download_error")); + .catch((error) => { + showErrorToast(t("download_error"), error.message); }) .finally(() => { setDownloadStarting(false); diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 6d788095..3478a663 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -2,6 +2,7 @@ import { useContext, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Modal, TextField } from "@renderer/components"; import type { LibraryGame } from "@types"; +import * as styles from "./game-options-modal.css"; import { gameDetailsContext } from "@renderer/context"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; @@ -9,7 +10,6 @@ import { RemoveGameFromLibraryModal } from "./remove-from-library-modal"; import { ResetAchievementsModal } from "./reset-achievements-modal"; import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; import { debounce } from "lodash-es"; -import "./game-options-modal.scss"; export interface GameOptionsModalProps { visible: boolean; @@ -183,8 +183,6 @@ export function GameOptionsModal({ } }; - const shouldShowLaunchOptionsConfiguration = false; - return ( <> setShowDeleteModal(false)} deleteGame={handleDeleteGame} /> + setShowRemoveGameModal(false)} removeGameFromLibrary={handleRemoveGameFromLibrary} game={game} /> + setShowResetAchievementsModal(false)} @@ -211,66 +211,59 @@ export function GameOptionsModal({ onClose={onClose} large={true} > -
-
-
-

{t("executable_section_title")}

-

- {t("executable_section_description")} -

-
- -
- - - {game.executablePath && ( - - )} - - } - /> - - {game.executablePath && ( -
- - -
- )} -
+
+
+

{t("executable_section_title")}

+

+ {t("executable_section_description")} +

+ + + {game.executablePath && ( + + )} + + } + /> + + {game.executablePath && ( +
+ + +
+ )} + {shouldShowWinePrefixConfiguration && ( -
-
+
+

{t("wine_prefix")}

-

+

{t("wine_prefix_description")}

@@ -304,100 +297,95 @@ export function GameOptionsModal({
)} - {shouldShowLaunchOptionsConfiguration && ( -
-
-

{t("launch_options")}

-

- {t("launch_options_description")} -

-
- - {t("clear")} - - ) - } - /> -
- )} - -
-
-

{t("downloads_secion_title")}

-

- {t("downloads_section_description")} +
+
+

{t("launch_options")}

+

+ {t("launch_options_description")}

-
- - {game.download?.downloadPath && ( - - )} -
+ + {t("clear")} + + ) + } + />
-
-
-

{t("danger_zone_section_title")}

-

- {t("danger_zone_section_description")} -

-
+
+

{t("downloads_secion_title")}

+

+ {t("downloads_section_description")} +

+
-
+
+ + {game.download?.downloadPath && ( + )} +
- +
+

{t("danger_zone_section_title")}

+

+ {t("danger_zone_section_description")} +

+
- -
+
+ + + + +
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.scss b/src/renderer/src/pages/game-details/sidebar/sidebar.scss new file mode 100644 index 00000000..15bc74c3 --- /dev/null +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.scss @@ -0,0 +1,174 @@ +@use "../../../scss/globals.scss"; + +.content-sidebar { + border-left: solid 1px globals.$border-color; + background-color: globals.$dark-background-color; + width: 100%; + height: 100%; + + @media (min-width: 1024px) { + max-width: 300px; + width: 100%; + } + + @media (min-width: 1280px) { + width: 100%; + max-width: 400px; + } +} + +.requirement { + &__button-container { + width: 100%; + display: flex; + } + + &__button { + border: solid 1px globals.$border-color; + border-left: none; + border-right: none; + border-radius: 0; + width: 100%; + } + + &__details { + padding: calc(globals.$spacing-unit * 2); + line-height: 22px; + font-size: globals.$body-font-size; + + a { + display: flex; + color: globals.$body-color; + } + } + + &__details-skeleton { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + padding: calc(globals.$spacing-unit * 2); + font-size: globals.$body-font-size; + } +} + +.how-long-to-beat { + &__categories-list { + margin: 0; + padding: calc(globals.$spacing-unit * 2); + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__category { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit / 2); + background: linear-gradient( + 90deg, + transparent 20%, + rgb(255 255 255 / 2%) 100% + ); + border-radius: 4px; + padding: globals.$spacing-unit calc(globals.$spacing-unit * 2); + border: solid 1px globals.$border-color; + } + + &__category-label { + color: globals.$muted-color; + } + + &__category-skeleton { + border: solid 1px globals.$border-color; + border-radius: 4px; + height: 76px; + } +} + +.stats { + &__section { + display: flex; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2); + justify-content: space-between; + transition: max-height ease 0.5s; + overflow: hidden; + + @media (min-width: 1024px) { + flex-direction: column; + } + + @media (min-width: 1280px) { + flex-direction: row; + } + } + + &__category-title { + font-size: globals.$small-font-size; + font-weight: bold; + display: flex; + align-items: center; + gap: globals.$spacing-unit; + } + + &__category { + display: flex; + flex-direction: row; + gap: calc(globals.$spacing-unit / 2); + justify-content: space-between; + align-items: center; + } +} + +.list { + list-style: none; + margin: 0; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2); + + &__item { + display: flex; + cursor: pointer; + transition: all ease 0.1s; + color: globals.$muted-color; + width: 100%; + overflow: hidden; + border-radius: 4px; + padding: globals.$spacing-unit; + gap: calc(globals.$spacing-unit * 2); + align-items: center; + text-align: left; + + &:hover { + background-color: rgba(255, 255, 255, 0.15); + text-decoration: none; + } + } + + &__item-image { + width: 54px; + height: 54px; + border-radius: 4px; + object-fit: cover; + + &--locked { + filter: grayscale(100%); + } + } +} + +.subscription-required-button { + text-decoration: none; + display: flex; + justify-content: center; + width: 100%; + gap: calc(globals.$spacing-unit / 2); + color: globals.$warning-color; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/renderer/src/pages/settings/settings-account.tsx b/src/renderer/src/pages/settings/settings-account.tsx index d43e5b2d..e8bac125 100644 --- a/src/renderer/src/pages/settings/settings-account.tsx +++ b/src/renderer/src/pages/settings/settings-account.tsx @@ -65,7 +65,7 @@ export function SettingsAccount() { return () => { unsubscribe(); }; - }, [fetchUserDetails, updateUserDetails]); + }, [fetchUserDetails, updateUserDetails, showSuccessToast]); const visibilityOptions = [ { value: "PUBLIC", label: t("public") }, diff --git a/src/renderer/src/scss/globals.scss b/src/renderer/src/scss/globals.scss index cc01c197..12bad37c 100644 --- a/src/renderer/src/scss/globals.scss +++ b/src/renderer/src/scss/globals.scss @@ -19,3 +19,8 @@ $bottom-panel-z-index: 3; $title-bar-z-index: 4; $backdrop-z-index: 4; $modal-z-index: 5; + +$body-font-size: 14px; +$small-font-size: 12px; + +$app-container: app-container; diff --git a/src/renderer/src/theme.css.ts b/src/renderer/src/theme.css.ts index b9fbaf55..7cd92ef3 100644 --- a/src/renderer/src/theme.css.ts +++ b/src/renderer/src/theme.css.ts @@ -24,7 +24,7 @@ export const vars = createGlobalTheme(":root", { zIndex: { toast: "5", bottomPanel: "3", - titleBar: "4", + titleBar: "1900000001", backdrop: "4", }, }); diff --git a/src/shared/index.ts b/src/shared/index.ts index 7d612a17..cb481150 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -39,7 +39,7 @@ export const pipe = fns.reduce((prev, fn) => fn(prev), arg); export const removeReleaseYearFromName = (name: string) => - name.replace(/\([0-9]{4}\)/g, ""); + name.replace(/\(\d{4}\)/g, ""); export const removeSymbolsFromName = (name: string) => name.replace(/[^A-Za-z 0-9]/g, ""); diff --git a/src/types/index.ts b/src/types/index.ts index fdca8009..1e089d08 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -139,6 +139,7 @@ export interface UserDetails { backgroundImageUrl: string | null; profileVisibility: ProfileVisibility; bio: string; + featurebaseJwt: string; subscription: Subscription | null; quirks?: { backupsPerGameLimit: number; @@ -171,6 +172,7 @@ export interface UpdateProfileRequest { profileImageUrl?: string | null; backgroundImageUrl?: string | null; bio?: string; + language?: string; } export interface DownloadSourceDownload { diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 06fc79e3..28d8b0b6 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -66,16 +66,16 @@ export interface GameAchievement { } export interface UserPreferences { - downloadsPath: string | null; - language: string; - realDebridApiToken: string | null; - preferQuitInsteadOfHiding: boolean; - runAtStartup: boolean; - startMinimized: boolean; - disableNsfwAlert: boolean; - seedAfterDownloadComplete: boolean; - showHiddenAchievementsDescription: boolean; - downloadNotificationsEnabled: boolean; - repackUpdatesNotificationsEnabled: boolean; - achievementNotificationsEnabled: boolean; + downloadsPath?: string | null; + language?: string; + realDebridApiToken?: string | null; + preferQuitInsteadOfHiding?: boolean; + runAtStartup?: boolean; + startMinimized?: boolean; + disableNsfwAlert?: boolean; + seedAfterDownloadComplete?: boolean; + showHiddenAchievementsDescription?: boolean; + downloadNotificationsEnabled?: boolean; + repackUpdatesNotificationsEnabled?: boolean; + achievementNotificationsEnabled?: boolean; }