Merge pull request #1698 from hydralauncher/feat/HYD-822

feat: new game achievement notification [hyd-822]
This commit is contained in:
Zamitto
2025-05-17 19:33:53 -03:00
committed by GitHub
31 changed files with 1553 additions and 113 deletions

View File

@@ -363,7 +363,23 @@
"install_common_redist": "Install",
"installing_common_redist": "Installing…",
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download"
"extract_files_by_default": "Extract files by default after download",
"achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left",
"top-center": "Top center",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-center": "Bottom center",
"bottom-right": "Bottom right",
"enable_achievement_custom_notifications": "Enable achievement custom notifications",
"alignment": "Alignment",
"variation": "Variation",
"default": "Default",
"rare": "Rare",
"platinum": "Platinum",
"hidden": "Hidden",
"test_notification": "Test notification",
"notification_preview": "Achievement Notification Preview"
},
"notifications": {
"download_complete": "Download complete",
@@ -379,7 +395,9 @@
"new_friend_request_title": "New friend request",
"extraction_complete": "Extraction complete",
"game_extracted": "{{title}} extracted successfully",
"friend_started_playing_game": "{{displayName}} started playing a game"
"friend_started_playing_game": "{{displayName}} started playing a game",
"test_achievement_notification_title": "This is a test notification",
"test_achievement_notification_description": "Pretty cool, huh?"
},
"system_tray": {
"open": "Open Hydra",

View File

@@ -349,7 +349,23 @@
"install_common_redist": "Instalar",
"installing_common_redist": "Instalando…",
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
"extract_files_by_default": "Extrair arquivos automaticamente após o download"
"extract_files_by_default": "Extrair arquivos automaticamente após o download",
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
"top-left": "Superior esquerdo",
"top-center": "Superior central",
"top-right": "Superior direito",
"bottom-left": "Inferior esquerdo",
"bottom-right": "Inferior direito",
"bottom-center": "Inferior central",
"achievement_custom_notification_position": "Posição das notificações customizadas de conquista",
"alignment": "Alinhamento",
"variation": "Variação",
"default": "Padrão",
"rare": "Rara",
"platinum": "Platina",
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas"
},
"notifications": {
"download_complete": "Download concluído",
@@ -363,7 +379,9 @@
"new_friend_request_description": "{{displayName}} te enviou um pedido de amizade",
"extraction_complete": "Extração concluída",
"game_extracted": "{{title}} extraído com sucesso",
"friend_started_playing_game": "{{displayName}} começou a jogar"
"friend_started_playing_game": "{{displayName}} começou a jogar",
"test_achievement_notification_title": "Esta é uma notificação de teste",
"test_achievement_notification_description": "Bem legal, né?"
},
"system_tray": {
"open": "Abrir Hydra",

View File

@@ -87,6 +87,8 @@ import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification";
import "./notifications/update-achievement-notification-window";
import "./notifications/show-achievement-test-notification";
import "./themes/add-custom-theme";
import "./themes/delete-custom-theme";
import "./themes/get-all-custom-themes";

View File

@@ -0,0 +1,15 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const showAchievementTestNotification = async (
_event: Electron.IpcMainInvokeEvent
) => {
setTimeout(() => {
WindowManager.showAchievementTestNotification();
}, 1000);
};
registerEvent(
"showAchievementTestNotification",
showAchievementTestNotification
);

View File

@@ -0,0 +1,29 @@
import { db, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
import { UserPreferences } from "@types";
const updateAchievementCustomNotificationWindow = async (
_event: Electron.IpcMainInvokeEvent
) => {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
WindowManager.closeNotificationWindow();
if (
userPreferences.achievementNotificationsEnabled &&
userPreferences.achievementCustomNotificationsEnabled !== false
) {
WindowManager.createNotificationWindow();
}
};
registerEvent(
"updateAchievementCustomNotificationWindow",
updateAchievementCustomNotificationWindow
);

View File

@@ -21,6 +21,7 @@ const updateCustomTheme = async (
if (theme.isActive) {
WindowManager.mainWindow?.webContents.send("css-injected", code);
WindowManager.notificationWindow?.webContents.send("css-injected", code);
}
};

View File

@@ -73,6 +73,7 @@ app.whenReady().then(async () => {
WindowManager.createMainWindow();
}
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(language || "en");
});

View File

@@ -7,11 +7,18 @@ import {
findAllAchievementFiles,
getAlternativeObjectIds,
} from "./find-achivement-files";
import type { AchievementFile, Game, UnlockedAchievement } from "@types";
import type {
AchievementFile,
Game,
UnlockedAchievement,
UserPreferences,
} from "@types";
import { achievementsLogger } from "../logger";
import { Cracker } from "@shared";
import { publishCombinedNewAchievementNotification } from "../notifications";
import { gamesSublevel } from "@main/level";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { WindowManager } from "../window-manager";
import { sleep } from "@main/helpers";
const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map();
@@ -184,7 +191,7 @@ export class AchievementWatcherManager {
return mergeAchievements(game, unlockedAchievements, false);
}
private static preSearchAchievementsWindows = async () => {
private static async getGameAchievementFilesWindows() {
const games = await gamesSublevel
.values()
.all()
@@ -194,24 +201,24 @@ export class AchievementWatcherManager {
return Promise.all(
games.map((game) => {
const gameAchievementFiles: AchievementFile[] = [];
const achievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectId)) {
gameAchievementFiles.push(
achievementFiles.push(
...(gameAchievementFilesMap.get(objectId) || [])
);
gameAchievementFiles.push(
achievementFiles.push(
...findAchievementFileInExecutableDirectory(game)
);
}
return this.preProcessGameAchievementFiles(game, gameAchievementFiles);
return { game, achievementFiles };
})
);
};
}
private static preSearchAchievementsWithWine = async () => {
private static async getGameAchievementFilesLinux() {
const games = await gamesSublevel
.values()
.all()
@@ -219,37 +226,70 @@ export class AchievementWatcherManager {
return Promise.all(
games.map((game) => {
const gameAchievementFiles = findAchievementFiles(game);
const achievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
achievementFiles.push(...achievementFileInsideDirectory);
return this.preProcessGameAchievementFiles(game, gameAchievementFiles);
return { game, achievementFiles };
})
);
};
}
public static async preSearchAchievements() {
await sleep(2000);
try {
const newAchievementsCount =
const gameAchievementFiles =
process.platform === "win32"
? await this.preSearchAchievementsWindows()
: await this.preSearchAchievementsWithWine();
? await this.getGameAchievementFilesWindows()
: await this.getGameAchievementFilesLinux();
const newAchievementsCount: number[] = [];
for (const { game, achievementFiles } of gameAchievementFiles) {
const result = await this.preProcessGameAchievementFiles(
game,
achievementFiles
);
newAchievementsCount.push(result);
}
const totalNewGamesWithAchievements = newAchievementsCount.filter(
(achievements) => achievements
).length;
const totalNewAchievements = newAchievementsCount.reduce(
(acc, val) => acc + val,
0
);
if (totalNewAchievements > 0) {
publishCombinedNewAchievementNotification(
totalNewAchievements,
totalNewGamesWithAchievements
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (userPreferences.achievementNotificationsEnabled) {
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-combined-achievements-unlocked",
totalNewGamesWithAchievements,
totalNewAchievements,
userPreferences.achievementCustomNotificationPosition ??
"top-left"
);
} else {
publishCombinedNewAchievementNotification(
totalNewAchievements,
totalNewGamesWithAchievements
);
}
}
}
} catch (err) {
achievementsLogger.error("Error on preSearchAchievements", err);

View File

@@ -38,7 +38,9 @@ export const getGameAchievementData = async (
await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), {
unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [],
achievements,
cacheExpiresTimestamp: Date.now() + 1000 * 60 * 30, // 30 minutes
cacheExpiresTimestamp: achievements.length
? Date.now() + 1000 * 60 * 30 // 30 minutes
: undefined,
});
return achievements;

View File

@@ -1,4 +1,5 @@
import type {
AchievementNotificationInfo,
Game,
GameShop,
UnlockedAchievement,
@@ -13,6 +14,12 @@ import { SubscriptionRequiredError } from "@shared";
import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
const isRareAchievement = (points: number) => {
const rawPercentage = (50 - Math.sqrt(points)) * 2;
return rawPercentage < 10;
};
const saveAchievementsOnLocal = async (
objectId: string,
shop: GameShop,
@@ -86,7 +93,7 @@ export const mergeAchievements = async (
publishNotification &&
userPreferences?.achievementNotificationsEnabled
) {
const achievementsInfo = newAchievements
const filteredAchievements = newAchievements
.toSorted((a, b) => {
return a.unlockTime - b.unlockTime;
})
@@ -98,21 +105,41 @@ export const mergeAchievements = async (
);
});
})
.filter((achievement) => Boolean(achievement))
.map((achievement) => {
.filter((achievement) => !!achievement);
const achievementsInfo: AchievementNotificationInfo[] =
filteredAchievements.map((achievement, index) => {
return {
displayName: achievement!.displayName,
iconUrl: achievement!.icon,
title: achievement.displayName,
description: achievement.description,
points: achievement.points,
isHidden: achievement.hidden,
isRare: achievement.points
? isRareAchievement(achievement.points)
: false,
isPlatinum:
index === filteredAchievements.length - 1 &&
newAchievements.length + unlockedAchievements.length ===
achievementsData.length,
iconUrl: achievement.icon,
};
});
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
if (userPreferences?.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementsInfo
);
} else {
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
}
}
if (game.remoteId) {

View File

@@ -162,7 +162,7 @@ export const publishExtractionCompleteNotification = async (game: Game) => {
};
export const publishNewAchievementNotification = async (info: {
achievements: { displayName: string; iconUrl: string }[];
achievements: { title: string; iconUrl: string }[];
unlockedAchievementCount: number;
totalAchievementCount: number;
gameTitle: string;
@@ -176,12 +176,12 @@ export const publishNewAchievementNotification = async (info: {
gameTitle: info.gameTitle,
achievementCount: info.achievements.length,
}),
body: info.achievements.map((a) => a.displayName).join(", "),
body: info.achievements.map((a) => a.title).join(", "),
icon: (await downloadImage(info.gameIcon)) ?? icon,
}
: {
title: t("achievement_unlocked", { ns: "achievement" }),
body: info.achievements[0].displayName,
body: info.achievements[0].title,
icon: (await downloadImage(info.achievements[0].iconUrl)) ?? icon,
};

View File

@@ -6,6 +6,7 @@ import {
Tray,
app,
nativeImage,
screen,
shell,
} from "electron";
import { is } from "@electron-toolkit/utils";
@@ -17,12 +18,17 @@ import { HydraApi } from "./hydra-api";
import UserAgent from "user-agents";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { orderBy, slice } from "lodash-es";
import type { ScreenState, UserPreferences } from "@types";
import { AuthPage } from "@shared";
import type {
AchievementCustomNotificationPosition,
ScreenState,
UserPreferences,
} from "@types";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import { isStaging } from "@main/constants";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
@@ -259,6 +265,133 @@ export class WindowManager {
}
}
private static loadNotificationWindowURL() {
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.notificationWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
);
} else {
this.notificationWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "achievement-notification",
}
);
}
}
private static readonly NOTIFICATION_WINDOW_WIDTH = 360;
private static readonly NOTIFICATION_WINDOW_HEIGHT = 140;
private static async getNotificationWindowPosition(
position: AchievementCustomNotificationPosition | undefined
) {
const display = screen.getPrimaryDisplay();
const { width, height } = display.workAreaSize;
if (position === "bottom-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "top-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: 0,
};
}
if (position === "bottom-left") {
return {
x: 0,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "top-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: 0,
};
}
return {
x: 0,
y: 0,
};
}
public static async createNotificationWindow() {
if (this.notificationWindow) return;
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
const { x, y } = await this.getNotificationWindowPosition(
userPreferences.achievementCustomNotificationPosition
);
this.notificationWindow = new BrowserWindow({
transparent: true,
maximizable: false,
autoHideMenuBar: true,
minimizable: false,
focusable: false,
skipTaskbar: true,
frame: false,
width: this.NOTIFICATION_WINDOW_WIDTH,
height: this.NOTIFICATION_WINDOW_HEIGHT,
x,
y,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
});
this.notificationWindow.setIgnoreMouseEvents(true);
// this.notificationWindow.setVisibleOnAllWorkspaces(true, {
// visibleOnFullScreen: true,
// });
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL();
}
public static async showAchievementTestNotification() {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
const language = userPreferences.language ?? "en";
this.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
[generateAchievementCustomNotificationTest(t, language)]
);
}
public static async closeNotificationWindow() {
if (this.notificationWindow) {
this.notificationWindow.close();
this.notificationWindow = null;
}
}
public static openEditorWindow(themeId: string) {
if (this.mainWindow) {
const existingWindow = this.editorWindows.get(themeId);
@@ -271,13 +404,13 @@ export class WindowManager {
}
const editorWindow = new BrowserWindow({
width: 600,
width: 720,
height: 720,
minWidth: 600,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}),
icon,
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",

View File

@@ -18,6 +18,8 @@ import type {
FriendRequestSync,
ShortcutLocation,
ShopAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
@@ -209,12 +211,6 @@ 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);
},
onExtractionComplete: (cb: (shop: GameShop, objectId: string) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -412,6 +408,42 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,
achievements?: AchievementNotificationInfo[]
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
position?: AchievementCustomNotificationPosition,
achievements?: AchievementNotificationInfo[]
) => cb(position, achievements);
ipcRenderer.on("on-achievement-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
onCombinedAchievementsUnlocked: (
cb: (
gameCount: number,
achievementsCount: number,
position: AchievementCustomNotificationPosition
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
gameCount: number,
achievementCount: number,
position: AchievementCustomNotificationPosition
) => cb(gameCount, achievementCount, position);
ipcRenderer.on("on-combined-achievements-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-combined-achievements-unlocked", listener);
},
updateAchievementCustomNotificationWindow: () =>
ipcRenderer.invoke("updateAchievementCustomNotificationWindow"),
showAchievementTestNotification: () =>
ipcRenderer.invoke("showAchievementTestNotification"),
/* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,5 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 933">
<path id="Vector" d="M29.3333 10.5H26.8333V8.83333C26.8333 8.61232 26.7455 8.40036 26.5893 8.24408C26.433 8.0878 26.221 8 26 8H11C10.779 8 10.567 8.0878 10.4107 8.24408C10.2545 8.40036 10.1667 8.61232 10.1667 8.83333V10.5H7.66667C7.22464 10.5 6.80072 10.6756 6.48816 10.9882C6.17559 11.3007 6 11.7246 6 12.1667V13.8333C6 14.9384 6.43899 15.9982 7.22039 16.7796C7.6073 17.1665 8.06663 17.4734 8.57215 17.6828C9.07768 17.8922 9.61949 18 10.1667 18H10.5469C11.0378 19.5556 11.9737 20.9333 13.2391 21.9628C14.5044 22.9923 16.0437 23.6285 17.6667 23.7927V26.3333H15.1667C14.9457 26.3333 14.7337 26.4211 14.5774 26.5774C14.4211 26.7337 14.3333 26.9457 14.3333 27.1667C14.3333 27.3877 14.4211 27.5996 14.5774 27.7559C14.7337 27.9122 14.9457 28 15.1667 28H21.8333C22.0543 28 22.2663 27.9122 22.4226 27.7559C22.5789 27.5996 22.6667 27.3877 22.6667 27.1667C22.6667 26.9457 22.5789 26.7337 22.4226 26.5774C22.2663 26.4211 22.0543 26.3333 21.8333 26.3333H19.3333V23.7896C22.6604 23.4531 25.4208 21.1187 26.425 18H26.8333C27.9384 18 28.9982 17.561 29.7796 16.7796C30.561 15.9982 31 14.9384 31 13.8333V12.1667C31 11.7246 30.8244 11.3007 30.5118 10.9882C30.1993 10.6756 29.7754 10.5 29.3333 10.5ZM10.1667 16.3333C9.50363 16.3333 8.86774 16.0699 8.3989 15.6011C7.93006 15.1323 7.66667 14.4964 7.66667 13.8333V12.1667H10.1667V15.5C10.1667 15.7778 10.1802 16.0556 10.2073 16.3333H10.1667ZM29.3333 13.8333C29.3333 14.4964 29.0699 15.1323 28.6011 15.6011C28.1323 16.0699 27.4964 16.3333 26.8333 16.3333H26.7812C26.8154 16.0255 26.8328 15.716 26.8333 15.4062V12.1667H29.3333V13.8333Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,519 @@
@use "../../../scss/globals.scss";
$margin-horizontal: 40px;
$margin-top: 52px;
$margin-bottom: 28px;
@keyframes content-in {
0% {
width: 80px;
opacity: 0;
transform: scale(0);
}
100% {
width: 80px;
opacity: 1;
transform: scale(1);
}
}
@keyframes content-wait {
0% {
width: 80px;
}
100% {
width: 80px;
}
}
@keyframes trophy-out {
0% {
opacity: 1;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes ellipses-stand-by {
0% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ellipses-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
scale: 1.5;
}
}
@keyframes content-expand {
0% {
width: 80px;
}
100% {
width: calc(360px - $margin-horizontal);
}
}
@keyframes chip-stand-by {
0% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes chip-in {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes title-in {
0% {
transform: translateY(10px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes description-in {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes dark-overlay {
0% {
opacity: 0.7;
}
50% {
opacity: 0.7;
}
100% {
opacity: 0;
}
}
@keyframes content-out {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-20px);
opacity: 0;
}
}
@keyframes shine {
from {
transform: translateX(0px) rotate(36deg);
}
to {
transform: translateX(420px) rotate(36deg);
}
}
.achievement-notification {
width: 360px;
height: 192px;
display: flex;
&--top-left {
align-items: start;
}
&--top-center {
align-items: start;
}
&--top-right {
justify-content: end;
align-items: start;
}
&--bottom-left {
align-items: end;
}
&--bottom-center {
align-items: end;
}
&--bottom-right {
justify-content: end;
align-items: end;
}
&__outer-container {
position: relative;
display: grid;
width: calc(360px - $margin-horizontal);
overflow: clip;
border: 1px solid #ffffff1a;
animation:
content-in 450ms ease-in-out,
content-wait 450ms ease-in-out 450ms,
content-expand 450ms ease-in-out 900ms;
box-shadow: 0px 2px 16px 0px rgba(0, 0, 0, 0.25);
}
&--top-left &__outer-container {
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-center &__outer-container {
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-right &__outer-container {
margin: $margin-top $margin-horizontal 0 0;
}
&--bottom-left &__outer-container {
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-center &__outer-container {
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-right &__outer-container {
margin: 0 $margin-horizontal $margin-bottom 0;
}
&--closing .achievement-notification__outer-container {
animation: content-out 450ms ease-in-out;
animation-fill-mode: forwards;
}
&__container {
width: calc(360px - $margin-horizontal);
display: flex;
padding: 8px 16px 8px 8px;
background: globals.$background-color;
}
&--platinum &__container {
background: linear-gradient(94deg, #1c1c1c -25%, #044838 100%);
}
&--rare &__container {
&::before {
content: "";
position: absolute;
top: -50%;
left: -60px;
width: 29px;
height: 134px;
transform: translateX(0px) rotate(36deg);
opacity: 0.2;
background: #d9d9d9;
filter: blur(8px);
animation: shine 450ms ease-in-out 1350ms;
}
}
&__content {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
width: 100%;
z-index: 1;
}
&__icon {
box-sizing: border-box;
min-width: 64px;
min-height: 64px;
width: 64px;
height: 64px;
border-radius: 2px;
flex: 1;
}
&--rare &__icon {
outline: 1px solid #f4a510;
box-shadow: 0px 0px 12px 0px rgba(244, 165, 16, 0.25);
}
&--platinum &__icon {
outline: 1px solid #0cf1ca;
box-shadow: 0px 0px 12px 0px rgba(12, 241, 202, 0.25);
}
&__additional-overlay {
position: absolute;
top: 0;
left: 0;
width: 80px;
height: 80px;
}
&__dark-overlay {
position: absolute;
top: 8px;
left: 8px;
width: 64px;
height: 64px;
background: #000;
opacity: 0;
z-index: 1;
animation: dark-overlay 900ms ease-in-out;
}
&__trophy-overlay {
position: absolute;
mask-image: url("/src/assets/icons/trophy.svg");
top: 22px;
left: 22px;
width: 36px;
height: 36px;
opacity: 0;
z-index: 1;
animation: trophy-out 900ms ease-in-out;
background: #fff;
}
&--rare &__trophy-overlay {
background: linear-gradient(
118deg,
#e8ad15 18.96%,
#d5900f 26.41%,
#e8ad15 29.99%,
#e4aa15 38.89%,
#ca890e 42.43%,
#ca880e 46.59%,
#ecbe1a 50.08%,
#ecbd1a 53.48%,
#b3790d 57.39%,
#66470a 75.64%,
#a37a13 78.2%,
#987112 79.28%,
#503808 83.6%,
#3e2d08 85.77%
),
#fff;
}
&--platinum &__trophy-overlay {
background: linear-gradient(
118deg,
#15e8d6 18.96%,
#0fd5a7 26.41%,
#15e8b7 29.99%,
#15e4b4 38.89%,
#0eca7f 42.43%,
#0eca9e 46.59%,
#1aecbb 50.08%,
#1aecb0 53.48%,
#0db392 57.39%,
#0a6648 75.64%,
#13a38b 78.2%,
#129862 79.28%,
#085042 83.6%,
#083e31 85.77%
);
}
&__ellipses-overlay {
position: absolute;
top: 8px;
left: 8px;
width: 64px;
height: 64px;
z-index: 2;
opacity: 0;
animation: ellipses-out 900ms ease-in-out;
}
&__text-container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
overflow: hidden;
}
&__title {
font-size: 14px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: globals.$muted-color;
animation: title-in 450ms ease-in-out 900ms;
}
&__hidden-icon {
margin-right: 4px;
opacity: 0.5;
}
&__description {
font-size: 14px;
font-weight: 400;
overflow: hidden;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
color: globals.$body-color;
animation: description-in 450ms ease-in-out 900ms;
}
&--closing &__chip {
animation: content-out 450ms ease-in-out;
animation-fill-mode: forwards;
}
&__chip {
position: absolute;
right: 8px;
display: flex;
gap: 4px;
padding: 0 8px;
border-radius: 300px;
align-items: center;
background: globals.$muted-color;
height: 24px;
animation:
chip-stand-by 900ms ease-in-out,
chip-in 450ms ease-in-out 900ms;
z-index: 2;
&__icon {
width: 16px;
height: 16px;
path {
fill: globals.$background-color;
}
}
&__label {
color: globals.$background-color;
font-weight: 700;
}
}
&--top-left &__chip {
top: -12px;
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-center &__chip {
top: -12px;
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-right &__chip {
top: -12px;
margin: $margin-top $margin-horizontal 0 0;
}
&--bottom-left &__chip {
bottom: 70px;
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-center &__chip {
bottom: 70px;
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-right &__chip {
bottom: 70px;
margin: 0 $margin-horizontal $margin-bottom 0;
}
&--rare &__chip {
background: linear-gradient(
160deg,
#e8ad15 18.96%,
#d5900f 26.41%,
#e8ad15 29.99%,
#e4aa15 38.89%,
#ca890e 42.43%,
#ca880e 46.59%,
#ecbe1a 50.08%,
#ecbd1a 53.48%,
#b3790d 57.39%,
#66470a 75.64%,
#a37a13 78.2%,
#987112 79.28%,
#503808 83.6%,
#3e2d08 85.77%
);
&__icon {
path {
fill: #fff;
}
}
&__label {
color: #fff;
}
}
&--platinum &__chip {
background: linear-gradient(
118deg,
#15e8d6 18.96%,
#0fd5a7 26.41%,
#15e8b7 29.99%,
#15e4b4 38.89%,
#0eca7f 42.43%,
#0eca9e 46.59%,
#1aecbb 50.08%,
#1aecb0 53.48%,
#0db392 57.39%,
#0a6648 75.64%,
#13a38b 78.2%,
#129862 79.28%,
#085042 83.6%,
#083e31 85.77%
);
&__icon {
path {
fill: #fff;
}
}
&__label {
color: #fff;
}
}
&--closing * {
animation: none;
}
&--closing *::before,
&--closing *::after {
animation: none !important;
}
}

View File

@@ -0,0 +1,79 @@
import {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import cn from "classnames";
import "./achievement-notification.scss";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { EyeClosedIcon } from "@primer/octicons-react";
import Ellipses from "@renderer/assets/icons/ellipses.png";
interface AchievementNotificationProps {
position: AchievementCustomNotificationPosition;
achievement: AchievementNotificationInfo;
isClosing: boolean;
}
export function AchievementNotificationItem({
position,
achievement,
isClosing,
}: Readonly<AchievementNotificationProps>) {
const baseClassName = "achievement-notification";
return (
<div
className={cn("achievement-notification", {
[`${baseClassName}--${position}`]: true,
[`${baseClassName}--closing`]: isClosing,
[`${baseClassName}--hidden`]: achievement.isHidden,
[`${baseClassName}--rare`]: achievement.isRare,
[`${baseClassName}--platinum`]: achievement.isPlatinum,
})}
>
{achievement.points && (
<div className="achievement-notification__chip">
<HydraIcon className="achievement-notification__chip__icon" />
<span className="achievement-notification__chip__label">
+{achievement.points}
</span>
</div>
)}
<div className="achievement-notification__outer-container">
<div className="achievement-notification__container">
<div className="achievement-notification__content">
<img
src={achievement.iconUrl}
alt={achievement.title}
className="achievement-notification__icon"
/>
<div className="achievement-notification__text-container">
<p className="achievement-notification__title">
{achievement.isHidden && (
<span className="achievement-notification__hidden-icon">
<EyeClosedIcon size={16} />
</span>
)}
{achievement.title}
</p>
<p className="achievement-notification__description">
{achievement.description}
</p>
</div>
</div>
<div className="achievement-notification__additional-overlay">
<div className="achievement-notification__dark-overlay"></div>
<img
className="achievement-notification__ellipses-overlay"
src={Ellipses}
alt="Ellipses effect"
/>
<div className="achievement-notification__trophy-overlay"></div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
@use "../../scss/globals.scss";
.collapsed-menu {
&__button {
height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;
color: globals.$muted-color;
width: 100%;
cursor: pointer;
transition: all ease 0.2s;
gap: globals.$spacing-unit;
font-size: globals.$body-font-size;
font-weight: bold;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&:active {
opacity: globals.$active-opacity;
}
}
&__chevron {
transition: transform ease 0.2s;
&--open {
transform: rotate(180deg);
}
}
&__content {
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
position: relative;
}
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "@primer/octicons-react";
import "./collapsed-menu.scss";
export interface CollapsedMenuProps {
title: string;
children: React.ReactNode;
}
export function CollapsedMenu({
title,
children,
}: Readonly<CollapsedMenuProps>) {
const content = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(true);
const [height, setHeight] = useState(0);
useEffect(() => {
if (content.current && content.current.scrollHeight !== height) {
setHeight(isOpen ? content.current.scrollHeight : 0);
} else if (!isOpen) {
setHeight(0);
}
}, [isOpen, children, height]);
return (
<div className="collapsed-menu">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="collapsed-menu__button"
>
<ChevronDownIcon
className={`collapsed-menu__chevron ${
isOpen ? "collapsed-menu__chevron--open" : ""
}`}
/>
<span>{title}</span>
</button>
<div
ref={content}
className="collapsed-menu__content"
style={{
maxHeight: `${height}px`,
}}
>
{children}
</div>
</div>
);
}

View File

@@ -18,12 +18,13 @@ export function SelectField({
options = [{ key: "-", value: value?.toString() || "-", label: "-" }],
theme = "primary",
onChange,
}: SelectProps) {
className,
}: Readonly<SelectProps>) {
const [isFocused, setIsFocused] = useState(false);
const id = useId();
return (
<div className="select-field__container">
<div className={cn("select-field__container", className)}>
{label && (
<label htmlFor={id} className="select-field__label">
{label}

View File

@@ -35,6 +35,8 @@ import type {
CatalogueSearchResult,
ShopAssets,
ShopDetailsWithAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -175,7 +177,6 @@ declare global {
minimized: boolean;
}) => Promise<void>;
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer;
@@ -323,6 +324,21 @@ declare global {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,
achievements?: AchievementNotificationInfo[]
) => void
) => () => Electron.IpcRenderer;
onCombinedAchievementsUnlocked: (
cb: (
gameCount: number,
achievementCount: number,
position: AchievementCustomNotificationPosition
) => void
) => () => Electron.IpcRenderer;
updateAchievementCustomNotificationWindow: () => Promise<void>;
showAchievementTestNotification: () => Promise<void>;
/* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;

View File

@@ -30,6 +30,7 @@ import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
Sentry.init({
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
@@ -84,6 +85,10 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />
<Route
path="/achievement-notification"
element={<AchievementNotification />}
/>
</Routes>
</HashRouter>
</Provider>

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import { injectCustomCss } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
const NOTIFICATION_TIMEOUT = 4000;
export function AchievementNotification() {
const { t } = useTranslation("achievement");
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] =
useState<AchievementCustomNotificationPosition>("top-left");
const [achievements, setAchievements] = useState<
AchievementNotificationInfo[]
>([]);
const [currentAchievement, setCurrentAchievement] =
useState<AchievementNotificationInfo | null>(null);
const achievementAnimation = useRef(-1);
const closingAnimation = useRef(-1);
const visibleAnimation = useRef(-1);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.1;
audio.play();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onCombinedAchievementsUnlocked(
(gameCount, achievementCount, position) => {
if (gameCount === 0 || achievementCount === 0) return;
setPosition(position);
setAchievements([
{
title: t("new_achievements_unlocked", {
gameCount,
achievementCount,
}),
isHidden: false,
isRare: false,
isPlatinum: false,
points: 0,
iconUrl: "https://cdn.losbroxas.org/favicon.svg",
},
]);
playAudio();
}
);
return () => {
unsubscribe();
};
}, [t, playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(position, achievements) => {
if (!achievements?.length) return;
if (position) {
setPosition(position);
}
setAchievements((ach) => ach.concat(achievements));
playAudio();
}
);
return () => {
unsubscribe();
};
}, [playAudio]);
const hasAchievementsPending = achievements.length > 0;
const startAnimateClosing = useCallback(() => {
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
setIsClosing(true);
const zero = performance.now();
closingAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= 450) {
closingAnimation.current = requestAnimationFrame(animateClosing);
} else {
setIsVisible(false);
setAchievements((ach) => ach.slice(1));
}
}
);
}, []);
useEffect(() => {
if (hasAchievementsPending) {
setIsClosing(false);
setIsVisible(true);
let zero = performance.now();
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
achievementAnimation.current = requestAnimationFrame(
function animateLock(time) {
if (time - zero > NOTIFICATION_TIMEOUT) {
zero = performance.now();
startAnimateClosing();
}
achievementAnimation.current = requestAnimationFrame(animateLock);
}
);
}
}, [hasAchievementsPending, startAnimateClosing, currentAchievement]);
useEffect(() => {
if (achievements.length) {
setCurrentAchievement(achievements[0]);
}
}, [achievements]);
useEffect(() => {
const loadAndApplyTheme = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
console.log("activeTheme", activeTheme);
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
}
};
loadAndApplyTheme();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected((cssString) => {
injectCustomCss(cssString);
});
return () => unsubscribe();
}, []);
if (!isVisible || !currentAchievement) return null;
return (
<AchievementNotificationItem
achievement={currentAchievement}
isClosing={isClosing}
position={position}
/>
);
}

View File

@@ -22,36 +22,34 @@ interface FormValues {
name: string;
}
const DEFAULT_THEME_CODE = `
/*
Here you can edit CSS for your theme and apply it on Hydra.
There are a few classes already in place, you can use them to style the launcher.
const DEFAULT_THEME_CODE = `/*
Here you can edit CSS for your theme and apply it on Hydra.
There are a few classes already in place, you can use them to style the launcher.
If you want to learn more about how to run Hydra in dev mode (which will allow you to inspect the DOM and view the classes)
or how to publish your theme in the theme store, you can check the docs:
https://docs.hydralauncher.gg/
If you want to learn more about how to run Hydra in dev mode (which will allow you to inspect the DOM and view the classes)
or how to publish your theme in the theme store, you can check the docs:
https://docs.hydralauncher.gg/themes.html
Happy hacking!
*/
Happy hacking!
*/
/* Header */
.header {}
/* Header */
.header {}
/* Sidebar */
.sidebar {}
/* Sidebar */
.sidebar {}
/* Main content */
.container__content {}
/* Main content */
.container__content {}
/* Bottom panel */
.bottom-panel {}
/* Bottom panel */
.bottom-panel {}
/* Toast */
.toast {}
/* Button */
.button {}
/* Toast */
.toast {}
/* Button */
.button {}
`;
export function AddThemeModal({

View File

@@ -13,4 +13,8 @@
&__common-redist-button {
align-self: flex-start;
}
&__test-achievement-notification-button {
align-self: flex-start;
}
}

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import {
TextField,
Button,
@@ -14,6 +14,7 @@ import { settingsContext } from "@renderer/context";
import "./settings-general.scss";
import { DesktopDownloadIcon } from "@primer/octicons-react";
import { logger } from "@renderer/logger";
import { AchievementCustomNotificationPosition } from "@types";
interface LanguageOption {
option: string;
@@ -36,10 +37,12 @@ export function SettingsGeneral() {
downloadsPath: "",
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
achievementNotificationsEnabled: false,
friendRequestNotificationsEnabled: false,
achievementNotificationsEnabled: false,
achievementCustomNotificationsEnabled: true,
achievementCustomNotificationPosition:
"top-left" as AchievementCustomNotificationPosition,
language: "",
customStyles: window.localStorage.getItem("customStyles") || "",
});
@@ -102,6 +105,10 @@ export function SettingsGeneral() {
userPreferences.repackUpdatesNotificationsEnabled ?? false,
achievementNotificationsEnabled:
userPreferences.achievementNotificationsEnabled ?? false,
achievementCustomNotificationsEnabled:
userPreferences.achievementCustomNotificationsEnabled ?? true,
achievementCustomNotificationPosition:
userPreferences.achievementCustomNotificationPosition ?? "top-left",
friendRequestNotificationsEnabled:
userPreferences.friendRequestNotificationsEnabled ?? false,
language: language ?? "en",
@@ -109,6 +116,21 @@ export function SettingsGeneral() {
}
}, [userPreferences, defaultDownloadsPath]);
const achievementCustomNotificationPositionOptions = useMemo(() => {
return [
"top-left",
"top-center",
"top-right",
"bottom-left",
"bottom-center",
"bottom-right",
].map((position) => ({
key: position,
value: position,
label: t(position),
}));
}, [t]);
const handleLanguageChange = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
@@ -118,9 +140,19 @@ export function SettingsGeneral() {
changeLanguage(value);
};
const handleChange = (values: Partial<typeof form>) => {
const handleChange = async (values: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...values }));
updateUserPreferences(values);
await updateUserPreferences(values);
};
const handleChangeAchievementCustomNotificationPosition = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const value = event.target.value as AchievementCustomNotificationPosition;
await handleChange({ achievementCustomNotificationPosition: value });
window.electron.updateAchievementCustomNotificationWindow();
};
const handleChooseDownloadsPath = async () => {
@@ -205,17 +237,6 @@ export function SettingsGeneral() {
}
/>
<CheckboxField
label={t("enable_achievement_notifications")}
checked={form.achievementNotificationsEnabled}
onChange={() =>
handleChange({
achievementNotificationsEnabled:
!form.achievementNotificationsEnabled,
})
}
/>
<CheckboxField
label={t("enable_friend_request_notifications")}
checked={form.friendRequestNotificationsEnabled}
@@ -227,6 +248,53 @@ export function SettingsGeneral() {
}
/>
<CheckboxField
label={t("enable_achievement_notifications")}
checked={form.achievementNotificationsEnabled}
onChange={async () => {
await handleChange({
achievementNotificationsEnabled:
!form.achievementNotificationsEnabled,
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
<CheckboxField
label={t("enable_achievement_custom_notifications")}
checked={form.achievementCustomNotificationsEnabled}
disabled={!form.achievementNotificationsEnabled}
onChange={async () => {
await handleChange({
achievementCustomNotificationsEnabled:
!form.achievementCustomNotificationsEnabled,
});
window.electron.updateAchievementCustomNotificationWindow();
}}
/>
{form.achievementNotificationsEnabled &&
form.achievementCustomNotificationsEnabled && (
<>
<SelectField
className="settings-general__achievement-custom-notification-position__select-variation"
label={t("achievement_custom_notification_position")}
value={form.achievementCustomNotificationPosition}
onChange={handleChangeAchievementCustomNotificationPosition}
options={achievementCustomNotificationPositionOptions}
/>
<Button
className="settings-general__test-achievement-notification-button"
onClick={() => window.electron.showAchievementTestNotification()}
>
{t("test_notification")}
</Button>
</>
)}
<h2 className="settings-general__section-title">{t("common_redist")}</h2>
<p className="settings-general__common-redist-description">

View File

@@ -36,14 +36,25 @@
}
}
&__editor {
position: relative;
width: 100%;
height: 100%;
flex: 1;
}
&__notification-preview-wrapper {
position: relative;
border: 1px solid globals.$muted-color;
border-radius: 2px;
}
&__footer {
display: flex;
flex-direction: column;
background-color: globals.$dark-background-color;
padding: globals.$spacing-unit globals.$spacing-unit * 2;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
gap: 24px;
&-actions {
display: flex;
@@ -78,4 +89,16 @@
margin-bottom: 8px;
}
}
&__notification-preview {
padding-top: 12px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
&__select-variation {
flex: inherit;
}
}
}

View File

@@ -1,12 +1,23 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import "./theme-editor.scss";
import Editor from "@monaco-editor/react";
import { Theme } from "@types";
import { AchievementCustomNotificationPosition, Theme } from "@types";
import { useSearchParams } from "react-router-dom";
import { Button } from "@renderer/components";
import { Button, SelectField } from "@renderer/components";
import { CheckIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import { injectCustomCss } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import { generateAchievementCustomNotificationTest } from "@shared";
import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu";
const notificationVariations = {
default: "default",
rare: "rare",
platinum: "platinum",
hidden: "hidden",
};
export default function ThemeEditor() {
const [searchParams] = useSearchParams();
@@ -14,9 +25,32 @@ export default function ThemeEditor() {
const [code, setCode] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isClosingNotifications, setIsClosingNotifications] = useState(false);
const themeId = searchParams.get("themeId");
const { t } = useTranslation("settings");
const { t, i18n } = useTranslation("settings");
const [notificationVariation, setNotificationVariation] =
useState<keyof typeof notificationVariations>("default");
const [notificationAlignment, setNotificationAlignment] =
useState<AchievementCustomNotificationPosition>("top-left");
const achievementPreview = useMemo(() => {
return {
achievement: {
...generateAchievementCustomNotificationTest(t, i18n.language),
isRare: notificationVariation === "rare",
isHidden: notificationVariation === "hidden",
isPlatinum: notificationVariation === "platinum",
},
position: notificationAlignment,
};
}, [t, i18n.language, notificationVariation, notificationAlignment]);
useEffect(() => {
window.document.title = "Hydra - Theme Editor";
}, []);
useEffect(() => {
if (themeId) {
@@ -33,12 +67,17 @@ export default function ThemeEditor() {
if (theme) {
await window.electron.updateCustomTheme(theme.id, code);
setHasUnsavedChanges(false);
setIsClosingNotifications(true);
setTimeout(() => {
injectCustomCss(code);
setIsClosingNotifications(false);
}, 450);
}
}, [code, theme]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
handleSave();
}
@@ -58,6 +97,21 @@ export default function ThemeEditor() {
}
};
const achievementCustomNotificationPositionOptions = useMemo(() => {
return [
"top-left",
"top-center",
"top-right",
"bottom-left",
"bottom-center",
"bottom-right",
].map((position) => ({
key: position,
value: position,
label: t(position),
}));
}, [t]);
return (
<div className="theme-editor">
<div
@@ -71,21 +125,75 @@ export default function ThemeEditor() {
)}
</div>
<Editor
theme="vs-dark"
defaultLanguage="css"
value={code}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
wordWrap: "on",
automaticLayout: true,
}}
/>
<div className="theme-editor__editor">
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<Editor
theme="vs-dark"
defaultLanguage="css"
value={code}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
wordWrap: "on",
automaticLayout: true,
}}
/>
</div>
</div>
<div className="theme-editor__footer">
<CollapsedMenu title={t("notification_preview")}>
<div className="theme-editor__notification-preview">
<SelectField
className="theme-editor__notification-preview__select-variation"
label={t("variation")}
options={Object.values(notificationVariations).map(
(variation) => {
return {
key: variation,
value: variation,
label: t(variation),
};
}
)}
onChange={(value) =>
setNotificationVariation(
value.target.value as keyof typeof notificationVariations
)
}
/>
<SelectField
label={t("alignment")}
value={notificationAlignment}
onChange={(e) =>
setNotificationAlignment(
e.target.value as AchievementCustomNotificationPosition
)
}
options={achievementCustomNotificationPositionOptions}
/>
<div className="theme-editor__notification-preview-wrapper">
<AchievementNotificationItem
position={achievementPreview.position}
achievement={achievementPreview.achievement}
isClosing={isClosingNotifications}
/>
</div>
</div>
</CollapsedMenu>
<div className="theme-editor__footer-actions">
<Button onClick={handleSave}>
<CheckIcon />

View File

@@ -16,6 +16,7 @@ import {
import { charMap } from "./char-map";
import { Downloader } from "./constants";
import { format } from "date-fns";
import { AchievementNotificationInfo } from "@types";
export * from "./constants";
@@ -175,3 +176,24 @@ export const formatDate = (
if (isNaN(new Date(date).getDate())) return "N/A";
return format(date, language == "en" ? "MM-dd-yyyy" : "dd/MM/yyyy");
};
export const generateAchievementCustomNotificationTest = (
t: any,
language?: string
): AchievementNotificationInfo => {
return {
title: t("test_achievement_notification_title", {
ns: "notifications",
lng: language ?? "en",
}),
description: t("test_achievement_notification_description", {
ns: "notifications",
lng: language ?? "en",
}),
iconUrl: "https://cdn.losbroxas.org/favicon.svg",
points: 2440,
isHidden: false,
isRare: false,
isPlatinum: false,
};
};

View File

@@ -263,6 +263,15 @@ export type GameAchievementFiles = {
[id: string]: AchievementFile[];
};
export interface AchievementNotificationInfo {
title: string;
description?: string;
iconUrl: string;
isHidden: boolean;
isRare: boolean;
isPlatinum: boolean;
points?: number;
}
export interface GameArtifact {
id: string;
artifactLengthInBytes: number;

View File

@@ -70,6 +70,14 @@ export interface GameAchievement {
cacheExpiresTimestamp: number | undefined;
}
export type AchievementCustomNotificationPosition =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
export interface UserPreferences {
downloadsPath?: string | null;
language?: string;
@@ -86,6 +94,8 @@ export interface UserPreferences {
downloadNotificationsEnabled?: boolean;
repackUpdatesNotificationsEnabled?: boolean;
achievementNotificationsEnabled?: boolean;
achievementCustomNotificationsEnabled?: boolean;
achievementCustomNotificationPosition?: AchievementCustomNotificationPosition;
friendRequestNotificationsEnabled?: boolean;
showDownloadSpeedInMegabytes?: boolean;
extractFilesByDefault?: boolean;