fix: fixing css issues

This commit is contained in:
Chubby Granny Chaser 2025-02-01 19:11:58 +00:00
commit d5a3e3fae5
No known key found for this signature in database
58 changed files with 1307 additions and 639 deletions

View File

@ -7,7 +7,7 @@ module.exports = {
"plugin:jsx-a11y/recommended", "plugin:jsx-a11y/recommended",
"@electron-toolkit/eslint-config-ts/recommended", "@electron-toolkit/eslint-config-ts/recommended",
"plugin:prettier/recommended", "plugin:prettier/recommended",
"plugin:storybook/recommended" "plugin:storybook/recommended",
], ],
rules: { rules: {
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",

View File

@ -94,7 +94,7 @@ def seed_status():
@app.route("/healthcheck", methods=["GET"]) @app.route("/healthcheck", methods=["GET"])
def healthcheck(): def healthcheck():
return "", 200 return "ok", 200
@app.route("/process-list", methods=["GET"]) @app.route("/process-list", methods=["GET"])
def process_list(): def process_list():

View File

@ -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, { await fetch(process.env.BUILD_WEBHOOK_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
uploads, upload,
branchName: process.env.BRANCH_NAME, branchName: process.env.BRANCH_NAME,
version: packageJson.version, version: packageJson.version,
githubActor: process.env.GITHUB_ACTOR, githubActor: process.env.GITHUB_ACTOR,

View File

@ -172,7 +172,8 @@
"reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}", "reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}",
"reset_achievements_title": "Tem certeza?", "reset_achievements_title": "Tem certeza?",
"reset_achievements_success": "Conquistas resetadas com sucesso", "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": { "activation": {
"title": "Ativação", "title": "Ativação",

View File

@ -7,8 +7,8 @@
"featured": "Рекомендации", "featured": "Рекомендации",
"surprise_me": "Удиви меня", "surprise_me": "Удиви меня",
"no_results": "Ничего не найдено", "no_results": "Ничего не найдено",
"hot": "Сейчас в топе", "hot": "Сейчас популярно",
"start_typing": "Начинаю вводить текст для поиска...", "start_typing": "Начинаю вводить текст...",
"weekly": "📅 Лучшие игры недели", "weekly": "📅 Лучшие игры недели",
"achievements": "🏆 Игры, в которых нужно победить" "achievements": "🏆 Игры, в которых нужно победить"
}, },
@ -424,7 +424,7 @@
"subscribe_now": "Подпишитесь прямо сейчас", "subscribe_now": "Подпишитесь прямо сейчас",
"cloud_saving": "Сохранение в облаке", "cloud_saving": "Сохранение в облаке",
"cloud_achievements": "Сохраняйте свои достижения в облаке", "cloud_achievements": "Сохраняйте свои достижения в облаке",
"animated_profile_picture": "Анимированные фотографии профиля", "animated_profile_picture": "Анимированные аватарки",
"premium_support": "Премиальная поддержка", "premium_support": "Премиальная поддержка",
"show_and_compare_achievements": "Показывайте и сравнивайте свои достижения с достижениями других пользователей", "show_and_compare_achievements": "Показывайте и сравнивайте свои достижения с достижениями других пользователей",
"animated_profile_banner": "Анимированный баннер профиля", "animated_profile_banner": "Анимированный баннер профиля",

View File

@ -1,47 +1,8 @@
import type { AppUpdaterEvent } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import updater, { UpdateInfo } from "electron-updater"; import { UpdateManager } from "@main/services/update-manager";
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: "" };
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => { const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
autoUpdater return UpdateManager.checkForUpdates();
.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;
}; };
registerEvent("checkForUpdates", checkForUpdates); registerEvent("checkForUpdates", checkForUpdates);

View File

@ -1,15 +1,21 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
const checkFolderWritePermission = async ( const checkFolderWritePermission = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
path: string testPath: string
) => ) => {
new Promise((resolve) => { const testFilePath = path.join(testPath, ".hydra-write-test");
fs.access(path, fs.constants.W_OK, (err) => {
resolve(!err); try {
}); fs.writeFileSync(testFilePath, "");
}); fs.rmSync(testFilePath);
return true;
} catch (err) {
return false;
}
};
registerEvent("checkFolderWritePermission", checkFolderWritePermission); registerEvent("checkFolderWritePermission", checkFolderWritePermission);

View File

@ -3,15 +3,14 @@ import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
export const getDownloadsPath = async () => { export const getDownloadsPath = async () => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",
} }
); );
if (userPreferences && userPreferences.downloadsPath) if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return userPreferences.downloadsPath;
return defaultDownloadsPath; return defaultDownloadsPath;
}; };

View File

@ -0,0 +1,7 @@
export const parseLaunchOptions = (params?: string | null): string[] => {
if (!params) {
return [];
}
return params.split(" ");
};

View File

@ -1,8 +1,10 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { shell } from "electron"; import { shell } from "electron";
import { spawn } from "child_process";
import { parseExecutablePath } from "../helpers/parse-executable-path"; import { parseExecutablePath } from "../helpers/parse-executable-path";
import { gamesSublevel, levelKeys } from "@main/level"; import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types"; import { GameShop } from "@types";
import { parseLaunchOptions } from "../helpers/parse-launch-options";
const openGame = async ( const openGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@ -11,8 +13,8 @@ const openGame = async (
executablePath: string, executablePath: string,
launchOptions?: string | null launchOptions?: string | null
) => { ) => {
// TODO: revisit this for launchOptions
const parsedPath = parseExecutablePath(executablePath); const parsedPath = parseExecutablePath(executablePath);
const parsedParams = parseLaunchOptions(launchOptions);
const gameKey = levelKeys.game(shop, objectId); const gameKey = levelKeys.game(shop, objectId);
@ -26,7 +28,12 @@ const openGame = async (
launchOptions, launchOptions,
}); });
shell.openPath(parsedPath); if (parsedParams.length === 0) {
shell.openPath(parsedPath);
return;
}
spawn(parsedPath, parsedParams, { shell: false, detached: true });
}; };
registerEvent("openGame", openGame); registerEvent("openGame", openGame);

View File

@ -10,7 +10,7 @@ const publishNewRepacksNotification = async (
) => { ) => {
if (newRepacksCount < 1) return; if (newRepacksCount < 1) return;
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",

View File

@ -7,7 +7,7 @@ import { omit } from "lodash-es";
import axios from "axios"; import axios from "axios";
import { fileTypeFromFile } from "file-type"; import { fileTypeFromFile } from "file-type";
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { export const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
return HydraApi.patch<UserProfile>("/profile", updateProfile); return HydraApi.patch<UserProfile>("/profile", updateProfile);
}; };

View File

@ -5,11 +5,11 @@ import type { UserPreferences } from "@types";
const getUserPreferences = async () => const getUserPreferences = async () =>
db db
.get<string, UserPreferences>(levelKeys.userPreferences, { .get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json", valueEncoding: "json",
}) })
.then((userPreferences) => { .then((userPreferences) => {
if (userPreferences.realDebridApiToken) { if (userPreferences?.realDebridApiToken) {
userPreferences.realDebridApiToken = Crypto.decrypt( userPreferences.realDebridApiToken = Crypto.decrypt(
userPreferences.realDebridApiToken userPreferences.realDebridApiToken
); );

View File

@ -4,12 +4,13 @@ import type { UserPreferences } from "@types";
import i18next from "i18next"; import i18next from "i18next";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
import { Crypto } from "@main/services"; import { Crypto } from "@main/services";
import { patchUserProfile } from "../profile/update-profile";
const updateUserPreferences = async ( const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences> preferences: Partial<UserPreferences>
) => { ) => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ valueEncoding: "json" } { valueEncoding: "json" }
); );
@ -20,6 +21,7 @@ const updateUserPreferences = async (
}); });
i18next.changeLanguage(preferences.language); i18next.changeLanguage(preferences.language);
patchUserProfile({ language: preferences.language }).catch(() => {});
} }
if (preferences.realDebridApiToken) { if (preferences.realDebridApiToken) {
@ -28,6 +30,10 @@ const updateUserPreferences = async (
); );
} }
if (!preferences.downloadsPath) {
preferences.downloadsPath = null;
}
await db.put<string, UserPreferences>( await db.put<string, UserPreferences>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {

View File

@ -10,7 +10,7 @@ const getComparedUnlockedAchievements = async (
shop: GameShop, shop: GameShop,
userId: string userId: string
) => { ) => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",
@ -25,7 +25,7 @@ const getComparedUnlockedAchievements = async (
{ {
shop, shop,
objectId, objectId,
language: userPreferences?.language || "en", language: userPreferences?.language ?? "en",
} }
).then((achievements) => { ).then((achievements) => {
const sortedAchievements = achievements.achievements const sortedAchievements = achievements.achievements

View File

@ -12,7 +12,7 @@ export const getUnlockedAchievements = async (
levelKeys.game(shop, objectId) levelKeys.game(shop, objectId)
); );
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
valueEncoding: "json", valueEncoding: "json",

View File

@ -9,6 +9,7 @@ import resources from "@locales";
import { PythonRPC } from "./services/python-rpc"; import { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2"; import { Aria2 } from "./services/aria2";
import { db, levelKeys } from "./level"; import { db, levelKeys } from "./level";
import { loadState } from "./main";
const { autoUpdater } = updater; const { autoUpdater } = updater;
@ -57,7 +58,7 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
}); });
await import("./main"); await loadState();
const language = await db.get<string, string>(levelKeys.language, { const language = await db.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8", valueEncoding: "utf-8",

View File

@ -21,8 +21,18 @@ import {
import { Auth, User, type UserPreferences } from "@types"; import { Auth, User, type UserPreferences } from "@types";
import { knexClient } from "./knex-client"; import { knexClient } from "./knex-client";
const loadState = async (userPreferences: UserPreferences | null) => { export const loadState = async () => {
import("./events"); const userPreferences = await migrateFromSqlite().then(async () => {
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
valueEncoding: "json",
});
return db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
});
});
await import("./events");
Aria2.spawn(); Aria2.spawn();
@ -104,24 +114,29 @@ const migrateFromSqlite = async () => {
if (userPreferences.length > 0) { if (userPreferences.length > 0) {
const { realDebridApiToken, ...rest } = userPreferences[0]; const { realDebridApiToken, ...rest } = userPreferences[0];
await db.put(levelKeys.userPreferences, { await db.put<string, UserPreferences>(
...rest, levelKeys.userPreferences,
realDebridApiToken: realDebridApiToken {
? Crypto.encrypt(realDebridApiToken) ...rest,
: null, realDebridApiToken: realDebridApiToken
preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, ? Crypto.encrypt(realDebridApiToken)
runAtStartup: rest.runAtStartup === 1, : null,
startMinimized: rest.startMinimized === 1, preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1,
disableNsfwAlert: rest.disableNsfwAlert === 1, runAtStartup: rest.runAtStartup === 1,
seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1, startMinimized: rest.startMinimized === 1,
showHiddenAchievementsDescription: disableNsfwAlert: rest.disableNsfwAlert === 1,
rest.showHiddenAchievementsDescription === 1, seedAfterDownloadComplete: rest.seedAfterDownloadComplete === 1,
downloadNotificationsEnabled: rest.downloadNotificationsEnabled === 1, showHiddenAchievementsDescription:
repackUpdatesNotificationsEnabled: rest.showHiddenAchievementsDescription === 1,
rest.repackUpdatesNotificationsEnabled === 1, downloadNotificationsEnabled:
achievementNotificationsEnabled: rest.downloadNotificationsEnabled === 1,
rest.achievementNotificationsEnabled === 1, repackUpdatesNotificationsEnabled:
}); rest.repackUpdatesNotificationsEnabled === 1,
achievementNotificationsEnabled:
rest.achievementNotificationsEnabled === 1,
},
{ valueEncoding: "json" }
);
if (rest.language) { if (rest.language) {
await db.put(levelKeys.language, rest.language); await db.put(levelKeys.language, rest.language);
@ -192,15 +207,3 @@ const migrateFromSqlite = async () => {
migrateUser, migrateUser,
]); ]);
}; };
migrateFromSqlite().then(async () => {
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
valueEncoding: "json",
});
db.get<string, UserPreferences>(levelKeys.userPreferences, {
valueEncoding: "json",
}).then((userPreferences) => {
loadState(userPreferences);
});
});

View File

@ -141,7 +141,7 @@ const processAchievementFileDiff = async (
export class AchievementWatcherManager { export class AchievementWatcherManager {
private static hasFinishedMergingWithRemote = false; private static hasFinishedMergingWithRemote = false;
public static watchAchievements = () => { public static watchAchievements() {
if (!this.hasFinishedMergingWithRemote) return; if (!this.hasFinishedMergingWithRemote) return;
if (process.platform === "win32") { if (process.platform === "win32") {
@ -149,12 +149,12 @@ export class AchievementWatcherManager {
} }
return watchAchievementsWithWine(); return watchAchievementsWithWine();
}; }
private static preProcessGameAchievementFiles = ( private static preProcessGameAchievementFiles(
game: Game, game: Game,
gameAchievementFiles: AchievementFile[] gameAchievementFiles: AchievementFile[]
) => { ) {
const unlockedAchievements: UnlockedAchievement[] = []; const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) { for (const achievementFile of gameAchievementFiles) {
const parsedAchievements = parseAchievementFile( const parsedAchievements = parseAchievementFile(
@ -182,7 +182,7 @@ export class AchievementWatcherManager {
} }
return mergeAchievements(game, unlockedAchievements, false); return mergeAchievements(game, unlockedAchievements, false);
}; }
private static preSearchAchievementsWindows = async () => { private static preSearchAchievementsWindows = async () => {
const games = await gamesSublevel const games = await gamesSublevel
@ -230,7 +230,7 @@ export class AchievementWatcherManager {
); );
}; };
public static preSearchAchievements = async () => { public static async preSearchAchievements() {
try { try {
const newAchievementsCount = const newAchievementsCount =
process.platform === "win32" process.platform === "win32"
@ -256,5 +256,5 @@ export class AchievementWatcherManager {
} }
this.hasFinishedMergingWithRemote = true; this.hasFinishedMergingWithRemote = true;
}; }
} }

View File

@ -40,7 +40,7 @@ export const getGameAchievementData = async (
throw err; throw err;
} }
logger.error("Failed to get game achievements", err); logger.error("Failed to get game achievements for", objectId, err);
return []; return [];
}); });

View File

@ -59,7 +59,7 @@ export const mergeAchievements = async (
const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? []; const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? [];
const newAchievementsMap = new Map( const newAchievementsMap = new Map(
achievements.reverse().map((achievement) => { achievements.toReversed().map((achievement) => {
return [achievement.name.toUpperCase(), achievement]; return [achievement.name.toUpperCase(), achievement];
}) })
); );
@ -87,7 +87,7 @@ export const mergeAchievements = async (
userPreferences?.achievementNotificationsEnabled userPreferences?.achievementNotificationsEnabled
) { ) {
const achievementsInfo = newAchievements const achievementsInfo = newAchievements
.sort((a, b) => { .toSorted((a, b) => {
return a.unlockTime - b.unlockTime; return a.unlockTime - b.unlockTime;
}) })
.map((achievement) => { .map((achievement) => {

View File

@ -3,7 +3,7 @@ import { WindowManager } from "./window-manager";
import url from "url"; import url from "url";
import { uploadGamesBatch } from "./library-sync"; import { uploadGamesBatch } from "./library-sync";
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; 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 { UserNotLoggedInError, SubscriptionRequiredError } from "@shared";
import { omit } from "lodash-es"; import { omit } from "lodash-es";
import { appVersion } from "@main/constants"; 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 EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true; 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 = { private static userAuth: HydraApiUserAuth = {
authToken: "", authToken: "",
@ -153,7 +154,8 @@ export class HydraApi {
(error) => { (error) => {
logger.error(" ---- RESPONSE ERROR -----"); logger.error(" ---- RESPONSE ERROR -----");
const { config } = error; const { config } = error;
const data = JSON.parse(config.data);
const data = JSON.parse(config.data ?? null);
logger.error( logger.error(
config.method, config.method,
@ -174,14 +176,22 @@ export class HydraApi {
error.response.status, error.response.status,
error.response.data error.response.data
); );
} else if (error.request) {
const errorData = error.toJSON(); return Promise.reject(error as Error);
logger.error("Request error:", errorData.message);
} else {
logger.error("Error", error.message);
} }
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);
} }
); );
} }

View File

@ -6,8 +6,12 @@ log.transports.file.resolvePathFn = (
_: log.PathVariables, _: log.PathVariables,
message?: log.LogMessage | undefined message?: log.LogMessage | undefined
) => { ) => {
if (message?.scope === "python-instance") { if (message?.scope === "python-rpc") {
return path.join(logsPath, "pythoninstance.txt"); return path.join(logsPath, "pythonrpc.txt");
}
if (message?.scope === "network") {
return path.join(logsPath, "network.txt");
} }
if (message?.scope == "achievements") { if (message?.scope == "achievements") {
@ -34,3 +38,4 @@ log.initialize();
export const pythonRpcLogger = log.scope("python-rpc"); export const pythonRpcLogger = log.scope("python-rpc");
export const logger = log.scope("main"); export const logger = log.scope("main");
export const achievementsLogger = log.scope("achievements"); export const achievementsLogger = log.scope("achievements");
export const networkLogger = log.scope("network");

View File

@ -2,6 +2,7 @@ import { sleep } from "@main/helpers";
import { DownloadManager } from "./download"; import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher"; import { watchProcesses } from "./process-watcher";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
import { UpdateManager } from "./update-manager";
export const startMainLoop = async () => { export const startMainLoop = async () => {
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
@ -11,6 +12,7 @@ export const startMainLoop = async () => {
DownloadManager.watchDownloads(), DownloadManager.watchDownloads(),
AchievementWatcherManager.watchAchievements(), AchievementWatcherManager.watchAchievements(),
DownloadManager.getSeedStatus(), DownloadManager.getSeedStatus(),
UpdateManager.checkForUpdatePeriodically(),
]); ]);
await sleep(1500); await sleep(1500);

View File

@ -9,6 +9,7 @@ import { achievementSoundPath } from "@main/constants";
import icon from "@resources/icon.png?asset"; import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml"; import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger"; import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, UserPreferences } from "@types"; import type { Game, UserPreferences } from "@types";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
@ -96,7 +97,9 @@ export const publishCombinedNewAchievementNotification = async (
toastXml: toXmlString(options), toastXml: toXmlString(options),
}).show(); }).show();
if (process.platform !== "linux") { if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath); sound.play(achievementSoundPath);
} }
}; };
@ -143,7 +146,9 @@ export const publishNewAchievementNotification = async (info: {
toastXml: toXmlString(options), toastXml: toXmlString(options),
}).show(); }).show();
if (process.platform !== "linux") { if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath); sound.play(achievementSoundPath);
} }
}; };

View File

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

View File

@ -29,7 +29,6 @@ export const getUserData = async () => {
}) })
.catch(async (err) => { .catch(async (err) => {
if (err instanceof UserNotLoggedInError) { if (err instanceof UserNotLoggedInError) {
logger.info("User is not logged in", err);
return null; return null;
} }
logger.error("Failed to get logged user"); logger.error("Failed to get logged user");
@ -59,6 +58,7 @@ export const getUserData = async () => {
expiresAt: loggedUser.subscription.expiresAt, expiresAt: loggedUser.subscription.expiresAt,
} }
: null, : null,
featurebaseJwt: "",
} as UserDetails; } as UserDetails;
} }

View File

@ -50,7 +50,7 @@ export class WindowManager {
minHeight: 540, minHeight: 540,
backgroundColor: "#1c1c1c", backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden", titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}), icon,
trafficLightPosition: { x: 16, y: 16 }, trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: { titleBarOverlay: {
symbolColor: "#DADBE1", symbolColor: "#DADBE1",
@ -145,6 +145,11 @@ export class WindowManager {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow = null; WindowManager.mainWindow = null;
}); });
this.mainWindow.webContents.setWindowOpenHandler((handler) => {
shell.openExternal(handler.url);
return { action: "deny" };
});
} }
public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) { public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) {

View File

@ -169,6 +169,12 @@ contextBridge.exposeInMainWorld("electron", {
return () => return () =>
ipcRenderer.removeListener("on-library-batch-complete", listener); 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 */ /* Hardware */
getDiskFreeSpace: (path: string) => getDiskFreeSpace: (path: string) =>

View File

@ -123,7 +123,7 @@ export const titleBar = style({
alignItems: "center", alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`, padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag", WebkitAppRegion: "drag",
zIndex: "4", zIndex: vars.zIndex.titleBar,
borderBottom: `1px solid ${vars.color.border}`, borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule); } as ComplexStyleRule);

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import { import {
@ -234,6 +234,22 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]); }, [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(() => { const handleToastClose = useCallback(() => {
dispatch(closeToast()); dispatch(closeToast());
}, [dispatch]); }, [dispatch]);
@ -262,6 +278,7 @@ export function App() {
> >
<Toast <Toast
visible={toast.visible} visible={toast.visible}
title={toast.title}
message={toast.message} message={toast.message}
type={toast.type} type={toast.type}
onClose={handleToastClose} onClose={handleToastClose}

Binary file not shown.

View File

@ -21,4 +21,14 @@
cursor: pointer; cursor: pointer;
} }
} }
&__version-button {
color: globals.$body-color;
border-bottom: solid 1px transparent;
&:hover {
border-bottom: solid 1px globals.$body-color;
cursor: pointer;
}
}
} }

View File

@ -76,10 +76,15 @@ export function BottomPanel() {
<small>{status}</small> <small>{status}</small>
</button> </button>
<small> <button
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot; data-featurebase-changelog
{VERSION_CODENAME}&quot; className="bottom-panel__version-button"
</small> >
<small data-featurebase-changelog>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>
</button>
</footer> </footer>
); );
} }

View File

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

View File

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

View File

@ -181,7 +181,12 @@ export function Sidebar() {
}} }}
> >
<div <div
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }} style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
flex: 1,
}}
> >
<SidebarProfile /> <SidebarProfile />

View File

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

View File

@ -142,6 +142,7 @@ declare global {
minimized: boolean; minimized: boolean;
}) => Promise<void>; }) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>; authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
/* Download sources */ /* Download sources */
putDownloadSource: ( putDownloadSource: (

View File

@ -3,12 +3,14 @@ import type { PayloadAction } from "@reduxjs/toolkit";
import { ToastProps } from "@renderer/components/toast/toast"; import { ToastProps } from "@renderer/components/toast/toast";
export interface ToastState { export interface ToastState {
message: string; title: string;
message?: string;
type: ToastProps["type"]; type: ToastProps["type"];
visible: boolean; visible: boolean;
} }
const initialState: ToastState = { const initialState: ToastState = {
title: "",
message: "", message: "",
type: "success", type: "success",
visible: false, visible: false,
@ -19,6 +21,7 @@ export const toastSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
showToast: (state, action: PayloadAction<Omit<ToastState, "visible">>) => { showToast: (state, action: PayloadAction<Omit<ToastState, "visible">>) => {
state.title = action.payload.title;
state.message = action.payload.message; state.message = action.payload.message;
state.type = action.payload.type; state.type = action.payload.type;
state.visible = true; state.visible = true;

View File

@ -6,9 +6,10 @@ export function useToast() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const showSuccessToast = useCallback( const showSuccessToast = useCallback(
(message: string) => { (title: string, message?: string) => {
dispatch( dispatch(
showToast({ showToast({
title,
message, message,
type: "success", type: "success",
}) })
@ -18,9 +19,10 @@ export function useToast() {
); );
const showErrorToast = useCallback( const showErrorToast = useCallback(
(message: string) => { (title: string, message?: string) => {
dispatch( dispatch(
showToast({ showToast({
title,
message, message,
type: "error", type: "error",
}) })
@ -30,9 +32,10 @@ export function useToast() {
); );
const showWarningToast = useCallback( const showWarningToast = useCallback(
(message: string) => { (title: string, message?: string) => {
dispatch( dispatch(
showToast({ showToast({
title,
message, message,
type: "warning", type: "warning",
}) })

View File

@ -78,9 +78,15 @@ export function useUserDetails() {
...response, ...response,
username: userDetails?.username || "", username: userDetails?.username || "",
subscription: userDetails?.subscription || null, subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
}); });
}, },
[updateUserDetails, userDetails?.username, userDetails?.subscription] [
updateUserDetails,
userDetails?.username,
userDetails?.subscription,
userDetails?.featurebaseJwt,
]
); );
const syncFriendRequests = useCallback(async () => { const syncFriendRequests = useCallback(async () => {

View File

@ -45,6 +45,7 @@ Sentry.init({
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1, replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
release: await window.electron.getVersion(),
}); });
console.log = logger.log; console.log = logger.log;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -98,9 +98,7 @@ export function DownloadSettingsModal({
? Downloader.RealDebrid ? Downloader.RealDebrid
: filteredDownloaders[0]; : filteredDownloaders[0];
setSelectedDownloader( setSelectedDownloader(selectedDownloader ?? null);
selectedDownloader === undefined ? null : selectedDownloader
);
}, [ }, [
userPreferences?.downloadsPath, userPreferences?.downloadsPath,
downloaders, downloaders,
@ -127,8 +125,8 @@ export function DownloadSettingsModal({
.then(() => { .then(() => {
onClose(); onClose();
}) })
.catch(() => { .catch((error) => {
showErrorToast(t("download_error")); showErrorToast(t("download_error"), error.message);
}) })
.finally(() => { .finally(() => {
setDownloadStarting(false); setDownloadStarting(false);

View File

@ -2,6 +2,7 @@ import { useContext, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components"; import { Button, Modal, TextField } from "@renderer/components";
import type { LibraryGame } from "@types"; import type { LibraryGame } from "@types";
import * as styles from "./game-options-modal.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; 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 { ResetAchievementsModal } from "./reset-achievements-modal";
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import "./game-options-modal.scss";
export interface GameOptionsModalProps { export interface GameOptionsModalProps {
visible: boolean; visible: boolean;
@ -183,8 +183,6 @@ export function GameOptionsModal({
} }
}; };
const shouldShowLaunchOptionsConfiguration = false;
return ( return (
<> <>
<DeleteGameModal <DeleteGameModal
@ -192,12 +190,14 @@ export function GameOptionsModal({
onClose={() => setShowDeleteModal(false)} onClose={() => setShowDeleteModal(false)}
deleteGame={handleDeleteGame} deleteGame={handleDeleteGame}
/> />
<RemoveGameFromLibraryModal <RemoveGameFromLibraryModal
visible={showRemoveGameModal} visible={showRemoveGameModal}
onClose={() => setShowRemoveGameModal(false)} onClose={() => setShowRemoveGameModal(false)}
removeGameFromLibrary={handleRemoveGameFromLibrary} removeGameFromLibrary={handleRemoveGameFromLibrary}
game={game} game={game}
/> />
<ResetAchievementsModal <ResetAchievementsModal
visible={showResetAchievementsModal} visible={showResetAchievementsModal}
onClose={() => setShowResetAchievementsModal(false)} onClose={() => setShowResetAchievementsModal(false)}
@ -211,66 +211,59 @@ export function GameOptionsModal({
onClose={onClose} onClose={onClose}
large={true} large={true}
> >
<div className="game-options-modal__container"> <div className={styles.optionsContainer}>
<div className="game-options-modal__section"> <div className={styles.gameOptionHeader}>
<div className="game-options-modal__header"> <h2>{t("executable_section_title")}</h2>
<h2>{t("executable_section_title")}</h2> <h4 className={styles.gameOptionHeaderDescription}>
<h4 className="game-options-modal__header-description"> {t("executable_section_description")}
{t("executable_section_description")} </h4>
</h4>
</div>
<div className="game-options-modal__executable-field">
<TextField
value={game.executablePath || ""}
readOnly
theme="dark"
disabled
placeholder={t("no_executable_selected")}
rightContent={
<>
<Button
type="button"
theme="outline"
onClick={handleChangeExecutableLocation}
>
<FileIcon />
{t("select_executable")}
</Button>
{game.executablePath && (
<Button
onClick={handleClearExecutablePath}
theme="outline"
>
{t("clear")}
</Button>
)}
</>
}
/>
{game.executablePath && (
<div className="game-options-modal__executable-field-buttons">
<Button
type="button"
theme="outline"
onClick={handleOpenGameExecutablePath}
>
{t("open_folder")}
</Button>
<Button onClick={handleCreateShortcut} theme="outline">
{t("create_shortcut")}
</Button>
</div>
)}
</div>
</div> </div>
<TextField
value={game.executablePath || ""}
readOnly
theme="dark"
disabled
placeholder={t("no_executable_selected")}
rightContent={
<>
<Button
type="button"
theme="outline"
onClick={handleChangeExecutableLocation}
>
<FileIcon />
{t("select_executable")}
</Button>
{game.executablePath && (
<Button onClick={handleClearExecutablePath} theme="outline">
{t("clear")}
</Button>
)}
</>
}
/>
{game.executablePath && (
<div className={styles.gameOptionRow}>
<Button
type="button"
theme="outline"
onClick={handleOpenGameExecutablePath}
>
{t("open_folder")}
</Button>
<Button onClick={handleCreateShortcut} theme="outline">
{t("create_shortcut")}
</Button>
</div>
)}
{shouldShowWinePrefixConfiguration && ( {shouldShowWinePrefixConfiguration && (
<div className="game-options-modal__wine-prefix"> <div className={styles.optionsContainer}>
<div className="game-options-modal__header"> <div className={styles.gameOptionHeader}>
<h2>{t("wine_prefix")}</h2> <h2>{t("wine_prefix")}</h2>
<h4 className="game-options-modal__header-description"> <h4 className={styles.gameOptionHeaderDescription}>
{t("wine_prefix_description")} {t("wine_prefix_description")}
</h4> </h4>
</div> </div>
@ -304,100 +297,95 @@ export function GameOptionsModal({
</div> </div>
)} )}
{shouldShowLaunchOptionsConfiguration && ( <div className={styles.optionsContainer}>
<div className="game-options-modal__launch-options"> <div className={styles.gameOptionHeader}>
<div className="game-options-modal__header"> <h2>{t("launch_options")}</h2>
<h2>{t("launch_options")}</h2> <h4 className={styles.gameOptionHeaderDescription}>
<h4 className="game-options-modal__header-description"> {t("launch_options_description")}
{t("launch_options_description")}
</h4>
</div>
<TextField
value={launchOptions}
theme="dark"
placeholder={t("launch_options_placeholder")}
onChange={handleChangeLaunchOptions}
rightContent={
game.launchOptions && (
<Button onClick={handleClearLaunchOptions} theme="outline">
{t("clear")}
</Button>
)
}
/>
</div>
)}
<div className="game-options-modal__downloads">
<div className="game-options-modal__header">
<h2>{t("downloads_secion_title")}</h2>
<h4 className="game-options-modal__header-description">
{t("downloads_section_description")}
</h4> </h4>
</div> </div>
<div className="game-options-modal__row"> <TextField
<Button value={launchOptions}
onClick={() => setShowRepacksModal(true)} theme="dark"
theme="outline" placeholder={t("launch_options_placeholder")}
disabled={deleting || isGameDownloading || !repacks.length} onChange={handleChangeLaunchOptions}
> rightContent={
{t("open_download_options")} game.launchOptions && (
</Button> <Button onClick={handleClearLaunchOptions} theme="outline">
{game.download?.downloadPath && ( {t("clear")}
<Button </Button>
onClick={handleOpenDownloadFolder} )
theme="outline" }
disabled={deleting} />
>
{t("open_download_location")}
</Button>
)}
</div>
</div> </div>
<div className="game-options-modal__danger-zone"> <div className={styles.gameOptionHeader}>
<div className="game-options-modal__header"> <h2>{t("downloads_secion_title")}</h2>
<h2>{t("danger_zone_section_title")}</h2> <h4 className={styles.gameOptionHeaderDescription}>
<h4 className="game-options-modal__danger-zone-description"> {t("downloads_section_description")}
{t("danger_zone_section_description")} </h4>
</h4> </div>
</div>
<div className="game-options-modal__danger-zone-buttons"> <div className={styles.gameOptionRow}>
<Button
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting || isGameDownloading || !repacks.length}
>
{t("open_download_options")}
</Button>
{game.download?.downloadPath && (
<Button <Button
onClick={() => setShowRemoveGameModal(true)} onClick={handleOpenDownloadFolder}
theme="danger" theme="outline"
disabled={deleting} disabled={deleting}
> >
{t("remove_from_library")} {t("open_download_location")}
</Button> </Button>
)}
</div>
<Button <div className={styles.gameOptionHeader}>
onClick={() => setShowResetAchievementsModal(true)} <h2>{t("danger_zone_section_title")}</h2>
theme="danger" <h4 className={styles.gameOptionHeaderDescription}>
disabled={ {t("danger_zone_section_description")}
deleting || </h4>
isDeletingAchievements || </div>
!hasAchievements ||
!userDetails
}
>
{t("reset_achievements")}
</Button>
<Button <div className={styles.gameOptionRow}>
onClick={() => { <Button
setShowDeleteModal(true); onClick={() => setShowRemoveGameModal(true)}
}} theme="danger"
theme="danger" disabled={deleting}
disabled={ >
isGameDownloading || deleting || !game.download?.downloadPath {t("remove_from_library")}
} </Button>
>
{t("remove_files")} <Button
</Button> onClick={() => setShowResetAchievementsModal(true)}
</div> theme="danger"
disabled={
deleting ||
isDeletingAchievements ||
!hasAchievements ||
!userDetails
}
>
{t("reset_achievements")}
</Button>
<Button
onClick={() => {
setShowDeleteModal(true);
}}
theme="danger"
disabled={
isGameDownloading || deleting || !game.download?.downloadPath
}
>
{t("remove_files")}
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

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

View File

@ -65,7 +65,7 @@ export function SettingsAccount() {
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, [fetchUserDetails, updateUserDetails]); }, [fetchUserDetails, updateUserDetails, showSuccessToast]);
const visibilityOptions = [ const visibilityOptions = [
{ value: "PUBLIC", label: t("public") }, { value: "PUBLIC", label: t("public") },

View File

@ -19,3 +19,8 @@ $bottom-panel-z-index: 3;
$title-bar-z-index: 4; $title-bar-z-index: 4;
$backdrop-z-index: 4; $backdrop-z-index: 4;
$modal-z-index: 5; $modal-z-index: 5;
$body-font-size: 14px;
$small-font-size: 12px;
$app-container: app-container;

View File

@ -24,7 +24,7 @@ export const vars = createGlobalTheme(":root", {
zIndex: { zIndex: {
toast: "5", toast: "5",
bottomPanel: "3", bottomPanel: "3",
titleBar: "4", titleBar: "1900000001",
backdrop: "4", backdrop: "4",
}, },
}); });

View File

@ -39,7 +39,7 @@ export const pipe =
fns.reduce((prev, fn) => fn(prev), arg); fns.reduce((prev, fn) => fn(prev), arg);
export const removeReleaseYearFromName = (name: string) => export const removeReleaseYearFromName = (name: string) =>
name.replace(/\([0-9]{4}\)/g, ""); name.replace(/\(\d{4}\)/g, "");
export const removeSymbolsFromName = (name: string) => export const removeSymbolsFromName = (name: string) =>
name.replace(/[^A-Za-z 0-9]/g, ""); name.replace(/[^A-Za-z 0-9]/g, "");

View File

@ -139,6 +139,7 @@ export interface UserDetails {
backgroundImageUrl: string | null; backgroundImageUrl: string | null;
profileVisibility: ProfileVisibility; profileVisibility: ProfileVisibility;
bio: string; bio: string;
featurebaseJwt: string;
subscription: Subscription | null; subscription: Subscription | null;
quirks?: { quirks?: {
backupsPerGameLimit: number; backupsPerGameLimit: number;
@ -171,6 +172,7 @@ export interface UpdateProfileRequest {
profileImageUrl?: string | null; profileImageUrl?: string | null;
backgroundImageUrl?: string | null; backgroundImageUrl?: string | null;
bio?: string; bio?: string;
language?: string;
} }
export interface DownloadSourceDownload { export interface DownloadSourceDownload {

View File

@ -66,16 +66,16 @@ export interface GameAchievement {
} }
export interface UserPreferences { export interface UserPreferences {
downloadsPath: string | null; downloadsPath?: string | null;
language: string; language?: string;
realDebridApiToken: string | null; realDebridApiToken?: string | null;
preferQuitInsteadOfHiding: boolean; preferQuitInsteadOfHiding?: boolean;
runAtStartup: boolean; runAtStartup?: boolean;
startMinimized: boolean; startMinimized?: boolean;
disableNsfwAlert: boolean; disableNsfwAlert?: boolean;
seedAfterDownloadComplete: boolean; seedAfterDownloadComplete?: boolean;
showHiddenAchievementsDescription: boolean; showHiddenAchievementsDescription?: boolean;
downloadNotificationsEnabled: boolean; downloadNotificationsEnabled?: boolean;
repackUpdatesNotificationsEnabled: boolean; repackUpdatesNotificationsEnabled?: boolean;
achievementNotificationsEnabled: boolean; achievementNotificationsEnabled?: boolean;
} }