diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2902218e..93e18f17 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 5509e07b..c99fab6f 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -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", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index ad72163e..d0f900d9 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/events/notifications/show-achievement-test-notification.ts b/src/main/events/notifications/show-achievement-test-notification.ts new file mode 100644 index 00000000..b6f425f3 --- /dev/null +++ b/src/main/events/notifications/show-achievement-test-notification.ts @@ -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 +); diff --git a/src/main/events/notifications/update-achievement-notification-window.ts b/src/main/events/notifications/update-achievement-notification-window.ts new file mode 100644 index 00000000..48fba272 --- /dev/null +++ b/src/main/events/notifications/update-achievement-notification-window.ts @@ -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( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); + + WindowManager.closeNotificationWindow(); + + if ( + userPreferences.achievementNotificationsEnabled && + userPreferences.achievementCustomNotificationsEnabled !== false + ) { + WindowManager.createNotificationWindow(); + } +}; + +registerEvent( + "updateAchievementCustomNotificationWindow", + updateAchievementCustomNotificationWindow +); diff --git a/src/main/events/themes/update-custom-theme.ts b/src/main/events/themes/update-custom-theme.ts index b9a8e048..92d85b8b 100644 --- a/src/main/events/themes/update-custom-theme.ts +++ b/src/main/events/themes/update-custom-theme.ts @@ -21,6 +21,7 @@ const updateCustomTheme = async ( if (theme.isActive) { WindowManager.mainWindow?.webContents.send("css-injected", code); + WindowManager.notificationWindow?.webContents.send("css-injected", code); } }; diff --git a/src/main/index.ts b/src/main/index.ts index 3b223299..5151b956 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -73,6 +73,7 @@ app.whenReady().then(async () => { WindowManager.createMainWindow(); } + WindowManager.createNotificationWindow(); WindowManager.createSystemTray(language || "en"); }); diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index 8b076d9e..c010f984 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -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 = new Map(); const fltFiles: Map> = 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( + 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); diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index 0f351dcb..f4d66b6a 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -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; diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index 0385f2fc..6b40ad72 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -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) { diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index 77866a47..fa9ac593 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -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, }; diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 9841eb6e..0c58c867 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -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 = 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( + 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( + 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", diff --git a/src/preload/index.ts b/src/preload/index.ts index 6695fa2b..482b9d19 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/assets/icons/ellipses.png b/src/renderer/src/assets/icons/ellipses.png new file mode 100644 index 00000000..9c8eca1d Binary files /dev/null and b/src/renderer/src/assets/icons/ellipses.png differ diff --git a/src/renderer/src/assets/icons/trophy.svg b/src/renderer/src/assets/icons/trophy.svg new file mode 100644 index 00000000..7be58814 --- /dev/null +++ b/src/renderer/src/assets/icons/trophy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/src/components/achievements/notification/achievement-notification.scss b/src/renderer/src/components/achievements/notification/achievement-notification.scss new file mode 100644 index 00000000..4dbcc7d4 --- /dev/null +++ b/src/renderer/src/components/achievements/notification/achievement-notification.scss @@ -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; + } +} diff --git a/src/renderer/src/components/achievements/notification/achievement-notification.tsx b/src/renderer/src/components/achievements/notification/achievement-notification.tsx new file mode 100644 index 00000000..1eefda8e --- /dev/null +++ b/src/renderer/src/components/achievements/notification/achievement-notification.tsx @@ -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) { + const baseClassName = "achievement-notification"; + + return ( +
+ {achievement.points && ( +
+ + + +{achievement.points} + +
+ )} + +
+
+
+ {achievement.title} +
+

+ {achievement.isHidden && ( + + + + )} + {achievement.title} +

+

+ {achievement.description} +

+
+
+ +
+
+ Ellipses effect +
+
+
+
+
+ ); +} diff --git a/src/renderer/src/components/collapsed-menu/collapsed-menu.scss b/src/renderer/src/components/collapsed-menu/collapsed-menu.scss new file mode 100644 index 00000000..c209940e --- /dev/null +++ b/src/renderer/src/components/collapsed-menu/collapsed-menu.scss @@ -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; + } +} diff --git a/src/renderer/src/components/collapsed-menu/collapsed-menu.tsx b/src/renderer/src/components/collapsed-menu/collapsed-menu.tsx new file mode 100644 index 00000000..67058598 --- /dev/null +++ b/src/renderer/src/components/collapsed-menu/collapsed-menu.tsx @@ -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) { + const content = useRef(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 ( +
+ + +
+ {children} +
+
+ ); +} diff --git a/src/renderer/src/components/select-field/select-field.tsx b/src/renderer/src/components/select-field/select-field.tsx index 69e3d74b..08334b9f 100644 --- a/src/renderer/src/components/select-field/select-field.tsx +++ b/src/renderer/src/components/select-field/select-field.tsx @@ -18,12 +18,13 @@ export function SelectField({ options = [{ key: "-", value: value?.toString() || "-", label: "-" }], theme = "primary", onChange, -}: SelectProps) { + className, +}: Readonly) { const [isFocused, setIsFocused] = useState(false); const id = useId(); return ( -
+
{label && (