mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-20 09:43:57 +00:00
Compare commits
6 Commits
feat/souve
...
feat/addin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ba72a0e2 | ||
|
|
2029f861f6 | ||
|
|
46e248c62a | ||
|
|
605d064ec0 | ||
|
|
c0956b1bc1 | ||
|
|
2ccc93ea61 |
@@ -443,7 +443,6 @@
|
||||
"launch_with_system": "Launch Hydra on system start-up",
|
||||
"general": "General",
|
||||
"behavior": "Behavior",
|
||||
"achievements": "Achievements",
|
||||
"download_sources": "Download sources",
|
||||
"language": "Language",
|
||||
"api_token": "API Token",
|
||||
@@ -567,8 +566,6 @@
|
||||
"show_download_speed_in_megabytes": "Show download speed in megabytes per second",
|
||||
"extract_files_by_default": "Extract files by default after download",
|
||||
"enable_steam_achievements": "Enable search for Steam achievements",
|
||||
"enable_achievement_screenshots": "Enable achievement screenshots",
|
||||
"open_screenshots_directory": "Open screenshots directory",
|
||||
"enable_new_download_options_badges": "Show new download options badges",
|
||||
"achievement_custom_notification_position": "Achievement custom notification position",
|
||||
"top-left": "Top left",
|
||||
@@ -645,7 +642,6 @@
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"last_time_played": "Last played {{period}}",
|
||||
"activity": "Recent Activity",
|
||||
"souvenirs": "Souvenirs",
|
||||
"library": "Library",
|
||||
"pinned": "Pinned",
|
||||
"sort_by": "Sort by:",
|
||||
@@ -693,6 +689,7 @@
|
||||
"blocked_users": "Blocked users",
|
||||
"unblock": "Unblock",
|
||||
"no_friends_added": "You have no added friends",
|
||||
"no_friends_yet": "You haven't added any friends yet",
|
||||
"view_all": "View all",
|
||||
"load_more": "Load more",
|
||||
"loading": "Loading",
|
||||
@@ -720,8 +717,15 @@
|
||||
"profile_reported": "Profile reported",
|
||||
"your_friend_code": "Your friend code:",
|
||||
"copy_friend_code": "Copy friend code",
|
||||
"copied": "Copied!",
|
||||
"upload_banner": "Upload banner",
|
||||
"uploading_banner": "Uploading banner…",
|
||||
"change_banner": "Change banner",
|
||||
"replace_banner": "Replace banner",
|
||||
"remove_banner": "Remove banner",
|
||||
"remove_banner_modal_title": "Remove banner?",
|
||||
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
|
||||
"remove": "Remove",
|
||||
"background_image_updated": "Background image updated",
|
||||
"stats": "Stats",
|
||||
"achievements": "achievements",
|
||||
@@ -742,15 +746,7 @@
|
||||
"user_reviews": "Reviews",
|
||||
"delete_review": "Delete Review",
|
||||
"loading_reviews": "Loading reviews...",
|
||||
"souvenir_deleted_successfully": "Souvenir deleted successfully",
|
||||
"souvenir_deletion_failed": "Failed to delete souvenir",
|
||||
"delete_souvenir_modal_title": "Are you sure you want to delete this souvenir?",
|
||||
"delete_souvenir_modal_description": "This action cannot be undone.",
|
||||
"delete_souvenir_modal_delete_button": "Delete",
|
||||
"delete_souvenir_modal_cancel_button": "Cancel",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "View My Wrapped 2025",
|
||||
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
|
||||
"wrapped_2025": "Wrapped 2025"
|
||||
},
|
||||
"library": {
|
||||
"library": "Library",
|
||||
|
||||
@@ -702,8 +702,15 @@
|
||||
"profile_reported": "Perfil reportado",
|
||||
"your_friend_code": "Tu código de amistad:",
|
||||
"copy_friend_code": "Copiar código de amistad",
|
||||
"copied": "¡Copiado!",
|
||||
"upload_banner": "Subir banner",
|
||||
"uploading_banner": "Subiendo banner…",
|
||||
"change_banner": "Cambiar banner",
|
||||
"replace_banner": "Reemplazar banner",
|
||||
"remove_banner": "Eliminar banner",
|
||||
"remove_banner_modal_title": "¿Eliminar banner?",
|
||||
"remove_banner_confirmation": "¿Estás seguro de que querés eliminar tu banner? Siempre podés elegir uno nuevo cuando quieras.",
|
||||
"remove": "Eliminar",
|
||||
"background_image_updated": "Imagen de fondo actualizada",
|
||||
"stats": "Estadísticas",
|
||||
"achievements": "logros",
|
||||
@@ -727,8 +734,6 @@
|
||||
"user_reviews": "Reseñas",
|
||||
"loading_reviews": "Cargando reseñas...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "Ver Mi Wrapped 2025",
|
||||
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
|
||||
"no_reviews": "Sin reseñas aún",
|
||||
"delete_review": "Eliminar reseña"
|
||||
},
|
||||
|
||||
@@ -707,8 +707,15 @@
|
||||
"profile_reported": "Perfil reportado",
|
||||
"your_friend_code": "Seu código de amigo:",
|
||||
"copy_friend_code": "Copiar código de amigo",
|
||||
"copied": "Copiado!",
|
||||
"upload_banner": "Carregar banner",
|
||||
"uploading_banner": "Carregando banner…",
|
||||
"change_banner": "Alterar banner",
|
||||
"replace_banner": "Substituir banner",
|
||||
"remove_banner": "Remover banner",
|
||||
"remove_banner_modal_title": "Remover banner?",
|
||||
"remove_banner_confirmation": "Tem certeza de que deseja remover seu banner? Você sempre pode escolher um novo quando quiser.",
|
||||
"remove": "Remover",
|
||||
"background_image_updated": "Imagem de fundo salva",
|
||||
"stats": "Estatísticas",
|
||||
"achievements": "conquistas",
|
||||
@@ -736,8 +743,6 @@
|
||||
"user_reviews": "Avaliações",
|
||||
"loading_reviews": "Carregando avaliações...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "Ver Meu Wrapped 2025",
|
||||
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
|
||||
"no_reviews": "Ainda não há avaliações",
|
||||
"delete_review": "Excluir avaliação"
|
||||
},
|
||||
|
||||
@@ -702,8 +702,15 @@
|
||||
"profile_reported": "Жалоба на профиль отправлена",
|
||||
"your_friend_code": "Код вашего друга:",
|
||||
"copy_friend_code": "Копировать код друга",
|
||||
"copied": "Скопировано!",
|
||||
"upload_banner": "Загрузить баннер",
|
||||
"uploading_banner": "Загрузка баннера...",
|
||||
"change_banner": "Изменить баннер",
|
||||
"replace_banner": "Заменить баннер",
|
||||
"remove_banner": "Удалить баннер",
|
||||
"remove_banner_modal_title": "Удалить баннер?",
|
||||
"remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.",
|
||||
"remove": "Удалить",
|
||||
"background_image_updated": "Фоновое изображение обновлено",
|
||||
"stats": "Статистика",
|
||||
"achievements": "Достижения",
|
||||
@@ -724,8 +731,6 @@
|
||||
"user_reviews": "Отзывы",
|
||||
"loading_reviews": "Загрузка отзывов...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "Просмотреть мой Wrapped 2025",
|
||||
"view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}",
|
||||
"no_reviews": "Пока нет отзывов",
|
||||
"delete_review": "Удалить отзыв"
|
||||
},
|
||||
|
||||
@@ -31,11 +31,6 @@ export const logsPath = path.join(
|
||||
`logs${isStaging ? "-staging" : ""}`
|
||||
);
|
||||
|
||||
export const screenshotsPath = path.join(
|
||||
SystemPath.getPath("userData"),
|
||||
"Screenshots"
|
||||
);
|
||||
|
||||
export const achievementSoundPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "achievement.wav")
|
||||
: path.join(__dirname, "..", "..", "resources", "achievement.wav");
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
appVersion,
|
||||
defaultDownloadsPath,
|
||||
isStaging,
|
||||
screenshotsPath,
|
||||
} from "@main/constants";
|
||||
import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants";
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import "./auth";
|
||||
@@ -21,6 +16,7 @@ import "./themes";
|
||||
import "./torrenting";
|
||||
import "./user";
|
||||
import "./user-preferences";
|
||||
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
@@ -28,4 +24,3 @@ ipcMain.handle("getVersion", () => appVersion);
|
||||
ipcMain.handle("isStaging", () => isStaging);
|
||||
ipcMain.handle("isPortableVersion", () => isPortableVersion());
|
||||
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
|
||||
ipcMain.handle("getScreenshotsPath", () => screenshotsPath);
|
||||
|
||||
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { getDownloadsPath } from "../helpers/get-downloads-path";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { downloadsSublevel, levelKeys } from "@main/level";
|
||||
import { GameShop } from "@types";
|
||||
|
||||
const getGameInstallerActionType = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
): Promise<"install" | "open-folder"> => {
|
||||
const downloadKey = levelKeys.game(shop, objectId);
|
||||
const download = await downloadsSublevel.get(downloadKey);
|
||||
|
||||
if (!download?.folderName) return "open-folder";
|
||||
|
||||
const gamePath = path.join(
|
||||
download.downloadPath ?? (await getDownloadsPath()),
|
||||
download.folderName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gamePath)) {
|
||||
await downloadsSublevel.del(downloadKey);
|
||||
return "open-folder";
|
||||
}
|
||||
|
||||
// macOS always opens folder
|
||||
if (process.platform === "darwin") {
|
||||
return "open-folder";
|
||||
}
|
||||
|
||||
// If path is a file, it will show in folder (open-folder behavior)
|
||||
if (fs.lstatSync(gamePath).isFile()) {
|
||||
return "open-folder";
|
||||
}
|
||||
|
||||
// Check for setup.exe
|
||||
const setupPath = path.join(gamePath, "setup.exe");
|
||||
if (fs.existsSync(setupPath)) {
|
||||
return "install";
|
||||
}
|
||||
|
||||
// Check if there's exactly one .exe file
|
||||
const gamePathFileNames = fs.readdirSync(gamePath);
|
||||
const gamePathExecutableFiles = gamePathFileNames.filter(
|
||||
(fileName: string) => path.extname(fileName).toLowerCase() === ".exe"
|
||||
);
|
||||
|
||||
if (gamePathExecutableFiles.length === 1) {
|
||||
return "install";
|
||||
}
|
||||
|
||||
// Otherwise, opens folder
|
||||
return "open-folder";
|
||||
};
|
||||
|
||||
registerEvent("getGameInstallerActionType", getGameInstallerActionType);
|
||||
@@ -13,6 +13,7 @@ import "./delete-game-folder";
|
||||
import "./extract-game-download";
|
||||
import "./get-default-wine-prefix-selection-path";
|
||||
import "./get-game-by-object-id";
|
||||
import "./get-game-installer-action-type";
|
||||
import "./get-library";
|
||||
import "./open-game-executable-path";
|
||||
import "./open-game-installer-path";
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { shell } from "electron";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const openFolder = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
folderPath: string
|
||||
) => {
|
||||
return shell.openPath(folderPath);
|
||||
};
|
||||
|
||||
registerEvent("openFolder", openFolder);
|
||||
@@ -51,22 +51,30 @@ const updateProfile = async (
|
||||
"backgroundImageUrl",
|
||||
]);
|
||||
|
||||
if (updateProfile.profileImageUrl) {
|
||||
const profileImageUrl = await uploadImage(
|
||||
"profile-image",
|
||||
updateProfile.profileImageUrl
|
||||
).catch(() => undefined);
|
||||
if (updateProfile.profileImageUrl !== undefined) {
|
||||
if (updateProfile.profileImageUrl === null) {
|
||||
payload["profileImageUrl"] = null;
|
||||
} else {
|
||||
const profileImageUrl = await uploadImage(
|
||||
"profile-image",
|
||||
updateProfile.profileImageUrl
|
||||
).catch(() => undefined);
|
||||
|
||||
payload["profileImageUrl"] = profileImageUrl;
|
||||
payload["profileImageUrl"] = profileImageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateProfile.backgroundImageUrl) {
|
||||
const backgroundImageUrl = await uploadImage(
|
||||
"background-image",
|
||||
updateProfile.backgroundImageUrl
|
||||
).catch(() => undefined);
|
||||
if (updateProfile.backgroundImageUrl !== undefined) {
|
||||
if (updateProfile.backgroundImageUrl === null) {
|
||||
payload["backgroundImageUrl"] = null;
|
||||
} else {
|
||||
const backgroundImageUrl = await uploadImage(
|
||||
"background-image",
|
||||
updateProfile.backgroundImageUrl
|
||||
).catch(() => undefined);
|
||||
|
||||
payload["backgroundImageUrl"] = backgroundImageUrl;
|
||||
payload["backgroundImageUrl"] = backgroundImageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return patchUserProfile(payload);
|
||||
|
||||
@@ -3,8 +3,6 @@ import { registerEvent } from "../register-event";
|
||||
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
|
||||
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
|
||||
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
|
||||
export const getUnlockedAchievements = async (
|
||||
objectId: string,
|
||||
@@ -33,28 +31,6 @@ export const getUnlockedAchievements = async (
|
||||
|
||||
const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? [];
|
||||
|
||||
let remoteUserAchievements: UserAchievement[] = [];
|
||||
try {
|
||||
const userDetails = await db.get<string, any>(levelKeys.user, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
if (userDetails?.id) {
|
||||
remoteUserAchievements = await HydraApi.get<UserAchievement[]>(
|
||||
`/users/${userDetails.id}/games/achievements`,
|
||||
{
|
||||
shop,
|
||||
objectId,
|
||||
language: userPreferences?.language ?? "en",
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof UserNotLoggedInError)) {
|
||||
console.warn("Failed to fetch remote user achievements:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return achievementsData
|
||||
.map((achievementData) => {
|
||||
const unlockedAchievementData = unlockedAchievements.find(
|
||||
@@ -66,16 +42,6 @@ export const getUnlockedAchievements = async (
|
||||
}
|
||||
);
|
||||
|
||||
// Find corresponding remote achievement data for image URL
|
||||
const remoteAchievementData = remoteUserAchievements.find(
|
||||
(remoteAchievement) => {
|
||||
return (
|
||||
remoteAchievement.name.toUpperCase() ==
|
||||
achievementData.name.toUpperCase()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const icongray = achievementData.icongray.endsWith("/")
|
||||
? achievementData.icon
|
||||
: achievementData.icongray;
|
||||
@@ -85,7 +51,6 @@ export const getUnlockedAchievements = async (
|
||||
...achievementData,
|
||||
unlocked: true,
|
||||
unlockTime: unlockedAchievementData.unlockTime,
|
||||
imageUrl: remoteAchievementData?.imageUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +63,6 @@ export const getUnlockedAchievements = async (
|
||||
!achievementData.hidden || showHiddenAchievementsDescription
|
||||
? achievementData.description
|
||||
: undefined,
|
||||
imageUrl: remoteAchievementData?.imageUrl || null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import axios from "axios";
|
||||
import { fileTypeFromFile } from "file-type";
|
||||
import { HydraApi } from "@main/services/hydra-api";
|
||||
import { gameAchievementsSublevel, levelKeys, db } from "@main/level";
|
||||
import { logger } from "@main/services/logger";
|
||||
import type { GameShop, User } from "@types";
|
||||
|
||||
export class AchievementImageService {
|
||||
private static async uploadImageToCDN(imagePath: string): Promise<string> {
|
||||
const stat = fs.statSync(imagePath);
|
||||
const fileBuffer = fs.readFileSync(imagePath);
|
||||
const fileSizeInBytes = stat.size;
|
||||
|
||||
const response = await HydraApi.post<{
|
||||
presignedUrl: string;
|
||||
imageKey: string;
|
||||
}>("/presigned-urls/achievement-image", {
|
||||
imageExt: path.extname(imagePath).slice(1),
|
||||
imageLength: fileSizeInBytes,
|
||||
});
|
||||
|
||||
const mimeType = await fileTypeFromFile(imagePath);
|
||||
|
||||
await axios.put(response.presignedUrl, fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": mimeType?.mime,
|
||||
},
|
||||
});
|
||||
|
||||
return response.imageKey;
|
||||
}
|
||||
|
||||
private static async storeImageLocally(imagePath: string): Promise<string> {
|
||||
const fileBuffer = fs.readFileSync(imagePath);
|
||||
const base64Image = fileBuffer.toString("base64");
|
||||
const mimeType = await fileTypeFromFile(imagePath);
|
||||
|
||||
return `data:${mimeType?.mime || "image/jpeg"};base64,${base64Image}`;
|
||||
}
|
||||
|
||||
private static async hasActiveSubscription(): Promise<boolean> {
|
||||
return db
|
||||
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
|
||||
.then((user) => {
|
||||
const expiresAt = new Date(user?.subscription?.expiresAt ?? 0);
|
||||
return expiresAt > new Date();
|
||||
})
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
private static async updateLocalAchievementData(
|
||||
shop: GameShop,
|
||||
gameId: string,
|
||||
imageUrl: string
|
||||
): Promise<void> {
|
||||
const achievementKey = levelKeys.game(shop, gameId);
|
||||
const existingData = await gameAchievementsSublevel
|
||||
.get(achievementKey)
|
||||
.catch(() => null);
|
||||
|
||||
if (existingData) {
|
||||
await gameAchievementsSublevel.put(achievementKey, {
|
||||
...existingData,
|
||||
imageUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static cleanupImageFile(imagePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(imagePath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup screenshot file ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an achievement image either to CDN (for subscribers) or stores locally
|
||||
* @param gameId - The game identifier
|
||||
* @param achievementName - The achievement name
|
||||
* @param imagePath - Path to the image file to upload
|
||||
* @param shop - The game shop (optional)
|
||||
* @returns Promise with success status and imageKey (for subscribers) or imageUrl (for non-subscribers)
|
||||
*/
|
||||
static async uploadAchievementImage(
|
||||
gameId: string,
|
||||
achievementName: string,
|
||||
imagePath: string
|
||||
): Promise<{ success: boolean; imageKey?: string; imageUrl?: string }> {
|
||||
try {
|
||||
const hasSubscription = await this.hasActiveSubscription();
|
||||
|
||||
if (hasSubscription) {
|
||||
const imageKey = await this.uploadImageToCDN(imagePath);
|
||||
logger.log(
|
||||
`Achievement image uploaded to CDN for ${gameId}:${achievementName}`
|
||||
);
|
||||
return { success: true, imageKey };
|
||||
} else {
|
||||
const imageUrl = await this.storeImageLocally(imagePath);
|
||||
logger.log(
|
||||
`Achievement image stored locally for ${gameId}:${achievementName}`
|
||||
);
|
||||
return { success: true, imageUrl };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to upload achievement image for ${gameId}:${achievementName}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads achievement image and updates local database, with automatic cleanup
|
||||
* @param gameId - The game identifier
|
||||
* @param achievementName - The achievement name
|
||||
* @param imagePath - Path to the image file to upload
|
||||
* @param shop - The game shop
|
||||
* @returns Promise with success status and imageKey or imageUrl
|
||||
*/
|
||||
static async uploadAndUpdateAchievementImage(
|
||||
gameId: string,
|
||||
achievementName: string,
|
||||
imagePath: string,
|
||||
shop: GameShop
|
||||
): Promise<{ success: boolean; imageKey?: string; imageUrl?: string }> {
|
||||
try {
|
||||
const result = await this.uploadAchievementImage(
|
||||
gameId,
|
||||
achievementName,
|
||||
imagePath
|
||||
);
|
||||
|
||||
if (result.imageUrl) {
|
||||
await this.updateLocalAchievementData(shop, gameId, result.imageUrl);
|
||||
}
|
||||
|
||||
this.cleanupImageFile(imagePath);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.cleanupImageFile(imagePath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const getModifiedSinceHeader = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (userLanguage !== cachedAchievements.language) {
|
||||
if (userLanguage != cachedAchievements.language) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import { achievementsLogger } from "../logger";
|
||||
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
|
||||
import { getGameAchievementData } from "./get-game-achievement-data";
|
||||
import { AchievementWatcherManager } from "./achievement-watcher-manager";
|
||||
import { ScreenshotService } from "../screenshot";
|
||||
import { AchievementImageService } from "./achievement-image-service";
|
||||
|
||||
const isRareAchievement = (points: number) => {
|
||||
const rawPercentage = (50 - Math.sqrt(points)) * 2;
|
||||
@@ -55,8 +53,11 @@ const saveAchievementsOnLocal = async (
|
||||
});
|
||||
};
|
||||
|
||||
// Helpers extracted to lower cognitive complexity
|
||||
const getLocalData = async (game: Game) => {
|
||||
export const mergeAchievements = async (
|
||||
game: Game,
|
||||
achievements: UnlockedAchievement[],
|
||||
publishNotification: boolean
|
||||
) => {
|
||||
const gameKey = levelKeys.game(game.shop, game.objectId);
|
||||
|
||||
let localGameAchievement = await gameAchievementsSublevel.get(gameKey);
|
||||
@@ -72,20 +73,11 @@ const getLocalData = async (game: Game) => {
|
||||
localGameAchievement = await gameAchievementsSublevel.get(gameKey);
|
||||
}
|
||||
|
||||
return {
|
||||
achievementsData: localGameAchievement?.achievements ?? [],
|
||||
unlockedAchievements: localGameAchievement?.unlockedAchievements ?? [],
|
||||
userPreferences,
|
||||
gameKey,
|
||||
};
|
||||
};
|
||||
const achievementsData = localGameAchievement?.achievements ?? [];
|
||||
const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? [];
|
||||
|
||||
const computeNewAndMergedAchievements = (
|
||||
incoming: UnlockedAchievement[],
|
||||
unlockedAchievements: UnlockedAchievement[]
|
||||
) => {
|
||||
const newAchievementsMap = new Map(
|
||||
incoming.toReversed().map((achievement) => {
|
||||
achievements.toReversed().map((achievement) => {
|
||||
return [achievement.name.toUpperCase(), achievement];
|
||||
})
|
||||
);
|
||||
@@ -105,154 +97,68 @@ const computeNewAndMergedAchievements = (
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
newAchievements,
|
||||
mergedLocalAchievements: unlockedAchievements.concat(newAchievements),
|
||||
};
|
||||
};
|
||||
|
||||
const publishAchievementNotificationIfNeeded = (
|
||||
game: Game,
|
||||
newAchievements: UnlockedAchievement[],
|
||||
unlockedAchievements: UnlockedAchievement[],
|
||||
achievementsData: any[],
|
||||
userPreferences: UserPreferences,
|
||||
mergedLocalCount: number,
|
||||
publishNotification: boolean
|
||||
) => {
|
||||
if (
|
||||
!newAchievements.length ||
|
||||
!publishNotification ||
|
||||
userPreferences.achievementNotificationsEnabled === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredAchievements = newAchievements
|
||||
.toSorted((a, b) => a.unlockTime - b.unlockTime)
|
||||
.map((achievement) => {
|
||||
return achievementsData.find((steamAchievement: any) => {
|
||||
return (
|
||||
achievement.name.toUpperCase() === steamAchievement.name.toUpperCase()
|
||||
);
|
||||
});
|
||||
})
|
||||
.filter((achievement) => !!achievement);
|
||||
|
||||
const achievementsInfo: AchievementNotificationInfo[] =
|
||||
filteredAchievements.map((achievement: any, index: number) => {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
achievementsLogger.log(
|
||||
"Publishing achievement notification",
|
||||
game.objectId,
|
||||
game.title
|
||||
);
|
||||
|
||||
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
|
||||
WindowManager.notificationWindow?.webContents.send(
|
||||
"on-achievement-unlocked",
|
||||
userPreferences.achievementCustomNotificationPosition ?? "top-left",
|
||||
achievementsInfo
|
||||
);
|
||||
} else {
|
||||
publishNewAchievementNotification({
|
||||
achievements: achievementsInfo,
|
||||
unlockedAchievementCount: mergedLocalCount,
|
||||
totalAchievementCount: achievementsData.length,
|
||||
gameTitle: game.title,
|
||||
gameIcon: game.iconUrl,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addImagesToNewAchievementsIfEnabled = async (
|
||||
newAchievements: UnlockedAchievement[],
|
||||
achievementsData: any[],
|
||||
mergedLocalAchievements: UnlockedAchievement[],
|
||||
game: Game,
|
||||
userPreferences: UserPreferences
|
||||
): Promise<UnlockedAchievement[]> => {
|
||||
const achievementsWithImages = [...mergedLocalAchievements];
|
||||
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
|
||||
|
||||
if (
|
||||
!newAchievements.length ||
|
||||
userPreferences.enableAchievementScreenshots !== true
|
||||
newAchievements.length &&
|
||||
publishNotification &&
|
||||
userPreferences.achievementNotificationsEnabled !== false
|
||||
) {
|
||||
return achievementsWithImages;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const achievement of newAchievements) {
|
||||
try {
|
||||
const achievementData = achievementsData.find(
|
||||
(steamAchievement: any) => {
|
||||
return (
|
||||
achievement.name.toUpperCase() ===
|
||||
steamAchievement.name.toUpperCase()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const achievementDisplayName =
|
||||
achievementData?.displayName || achievement.name;
|
||||
|
||||
const screenshotPath = await ScreenshotService.captureDesktopScreenshot(
|
||||
game.title,
|
||||
achievementDisplayName
|
||||
);
|
||||
|
||||
const uploadResult =
|
||||
await AchievementImageService.uploadAchievementImage(
|
||||
game.objectId,
|
||||
achievement.name,
|
||||
screenshotPath
|
||||
const filteredAchievements = newAchievements
|
||||
.toSorted((a, b) => {
|
||||
return a.unlockTime - b.unlockTime;
|
||||
})
|
||||
.map((achievement) => {
|
||||
return achievementsData.find((steamAchievement) => {
|
||||
return (
|
||||
achievement.name.toUpperCase() ===
|
||||
steamAchievement.name.toUpperCase()
|
||||
);
|
||||
});
|
||||
})
|
||||
.filter((achievement) => !!achievement);
|
||||
|
||||
const achievementIndex = achievementsWithImages.findIndex(
|
||||
(a) => a.name.toUpperCase() === achievement.name.toUpperCase()
|
||||
);
|
||||
if (achievementIndex !== -1 && uploadResult.imageKey) {
|
||||
achievementsWithImages[achievementIndex] = {
|
||||
...achievementsWithImages[achievementIndex],
|
||||
imageKey: uploadResult.imageKey,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
achievementsLogger.error("Failed to upload achievement image", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
achievementsLogger.error(
|
||||
"Failed to capture screenshot for achievement",
|
||||
error
|
||||
const achievementsInfo: AchievementNotificationInfo[] =
|
||||
filteredAchievements.map((achievement, index) => {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
achievementsLogger.log(
|
||||
"Publishing achievement notification",
|
||||
game.objectId,
|
||||
game.title
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return achievementsWithImages;
|
||||
};
|
||||
|
||||
const syncAchievements = async (
|
||||
game: Game,
|
||||
publishNotification: boolean,
|
||||
achievementsWithImages: UnlockedAchievement[],
|
||||
newAchievements: UnlockedAchievement[],
|
||||
gameKey: string
|
||||
) => {
|
||||
const shouldSyncWithRemote =
|
||||
game.remoteId &&
|
||||
(newAchievements.length || AchievementWatcherManager.hasFinishedPreSearch);
|
||||
@@ -262,26 +168,26 @@ const syncAchievements = async (
|
||||
"/profile/games/achievements",
|
||||
{
|
||||
id: game.remoteId,
|
||||
achievements: achievementsWithImages,
|
||||
achievements: mergedLocalAchievements,
|
||||
},
|
||||
{ needsSubscription: !newAchievements.length }
|
||||
)
|
||||
.then(async (response) => {
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
await saveAchievementsOnLocal(
|
||||
return saveAchievementsOnLocal(
|
||||
response.objectId,
|
||||
response.shop,
|
||||
response.achievements,
|
||||
publishNotification
|
||||
);
|
||||
} else {
|
||||
await saveAchievementsOnLocal(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
achievementsWithImages,
|
||||
publishNotification
|
||||
);
|
||||
}
|
||||
|
||||
return saveAchievementsOnLocal(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
mergedLocalAchievements,
|
||||
publishNotification
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof SubscriptionRequiredError) {
|
||||
@@ -295,7 +201,7 @@ const syncAchievements = async (
|
||||
return saveAchievementsOnLocal(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
achievementsWithImages,
|
||||
mergedLocalAchievements,
|
||||
publishNotification
|
||||
);
|
||||
})
|
||||
@@ -306,48 +212,10 @@ const syncAchievements = async (
|
||||
await saveAchievementsOnLocal(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
achievementsWithImages,
|
||||
mergedLocalAchievements,
|
||||
publishNotification
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const mergeAchievements = async (
|
||||
game: Game,
|
||||
achievements: UnlockedAchievement[],
|
||||
publishNotification: boolean
|
||||
) => {
|
||||
const { achievementsData, unlockedAchievements, userPreferences, gameKey } =
|
||||
await getLocalData(game);
|
||||
|
||||
const { newAchievements, mergedLocalAchievements } =
|
||||
computeNewAndMergedAchievements(achievements, unlockedAchievements);
|
||||
|
||||
publishAchievementNotificationIfNeeded(
|
||||
game,
|
||||
newAchievements,
|
||||
unlockedAchievements,
|
||||
achievementsData,
|
||||
userPreferences,
|
||||
mergedLocalAchievements.length,
|
||||
publishNotification
|
||||
);
|
||||
|
||||
const achievementsWithImages = await addImagesToNewAchievementsIfEnabled(
|
||||
newAchievements,
|
||||
achievementsData,
|
||||
mergedLocalAchievements,
|
||||
game,
|
||||
userPreferences
|
||||
);
|
||||
|
||||
await syncAchievements(
|
||||
game,
|
||||
publishNotification,
|
||||
achievementsWithImages,
|
||||
newAchievements,
|
||||
gameKey
|
||||
);
|
||||
|
||||
return newAchievements.length;
|
||||
};
|
||||
|
||||
@@ -4,11 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
|
||||
import type { Download, DownloadProgress, UserPreferences } from "@types";
|
||||
import {
|
||||
GofileApi,
|
||||
QiwiApi,
|
||||
DatanodesApi,
|
||||
MediafireApi,
|
||||
PixelDrainApi,
|
||||
VikingFileApi,
|
||||
RootzApi,
|
||||
} from "../hosters";
|
||||
import { PythonRPC } from "../python-rpc";
|
||||
import {
|
||||
@@ -400,15 +400,6 @@ export class DownloadManager {
|
||||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
case Downloader.Qiwi: {
|
||||
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: downloadUrl,
|
||||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
case Downloader.Datanodes: {
|
||||
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
|
||||
return {
|
||||
@@ -537,6 +528,15 @@ export class DownloadManager {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.Rootz: {
|
||||
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: downloadUrl,
|
||||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * from "./gofile";
|
||||
export * from "./qiwi";
|
||||
export * from "./datanodes";
|
||||
export * from "./mediafire";
|
||||
export * from "./pixeldrain";
|
||||
export * from "./buzzheavier";
|
||||
export * from "./fuckingfast";
|
||||
export * from "./vikingfile";
|
||||
export * from "./rootz";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { requestWebPage } from "@main/helpers";
|
||||
|
||||
export class QiwiApi {
|
||||
public static async getDownloadUrl(url: string) {
|
||||
const document = await requestWebPage(url);
|
||||
const fileName = document.querySelector("h1")?.textContent;
|
||||
|
||||
const slug = url.split("/").pop();
|
||||
const extension = fileName?.split(".").pop();
|
||||
|
||||
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
|
||||
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
||||
58
src/main/services/hosters/rootz.ts
Normal file
58
src/main/services/hosters/rootz.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface RootzApiResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
url: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
expiresIn: number;
|
||||
expiresAt: string | null;
|
||||
downloads: number;
|
||||
canDelete: boolean;
|
||||
fileId: string;
|
||||
isMirrored: boolean;
|
||||
sourceService: string | null;
|
||||
adsEnabled: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class RootzApi {
|
||||
public static async getDownloadUrl(uri: string): Promise<string> {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||||
|
||||
if (pathSegments.length < 2 || pathSegments[0] !== "d") {
|
||||
throw new Error("Invalid rootz URL format");
|
||||
}
|
||||
|
||||
const id = pathSegments[1];
|
||||
const apiUrl = `https://www.rootz.so/api/files/download-by-short/${id}`;
|
||||
|
||||
const response = await axios.get<RootzApiResponse>(apiUrl);
|
||||
|
||||
if (response.data.success && response.data.data?.url) {
|
||||
return response.data.data.url;
|
||||
}
|
||||
|
||||
throw new Error("Failed to get download URL from rootz API");
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError<RootzApiResponse>;
|
||||
if (axiosError.response?.status === 404) {
|
||||
const errorMessage =
|
||||
axiosError.response.data?.error || "File not found";
|
||||
logger.error(`[Rootz] ${errorMessage}`);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("[Rootz] Error fetching download URL:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export * from "./game-files-manager";
|
||||
export * from "./common-redist-manager";
|
||||
export * from "./aria2";
|
||||
export * from "./ws";
|
||||
export * from "./screenshot";
|
||||
export * from "./system-path";
|
||||
export * from "./library-sync";
|
||||
export * from "./wine";
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { desktopCapturer, nativeImage, app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "./logger";
|
||||
import { screenshotsPath } from "@main/constants";
|
||||
|
||||
export class ScreenshotService {
|
||||
private static readonly SCREENSHOT_QUALITY = 80;
|
||||
private static readonly SCREENSHOT_FORMAT = "jpeg";
|
||||
private static readonly MAX_WIDTH = 1280;
|
||||
private static readonly MAX_HEIGHT = 720;
|
||||
|
||||
private static compressImage(
|
||||
image: Electron.NativeImage
|
||||
): Electron.NativeImage {
|
||||
const size = image.getSize();
|
||||
|
||||
let newWidth = size.width;
|
||||
let newHeight = size.height;
|
||||
|
||||
if (newWidth > this.MAX_WIDTH || newHeight > this.MAX_HEIGHT) {
|
||||
const aspectRatio = newWidth / newHeight;
|
||||
|
||||
if (newWidth > newHeight) {
|
||||
newWidth = this.MAX_WIDTH;
|
||||
newHeight = Math.round(newWidth / aspectRatio);
|
||||
} else {
|
||||
newHeight = this.MAX_HEIGHT;
|
||||
newWidth = Math.round(newHeight * aspectRatio);
|
||||
}
|
||||
}
|
||||
|
||||
if (newWidth !== size.width || newHeight !== size.height) {
|
||||
return image.resize({ width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
public static async captureDesktopScreenshot(
|
||||
gameTitle?: string,
|
||||
achievementName?: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen"],
|
||||
thumbnailSize: { width: 1920, height: 1080 },
|
||||
});
|
||||
|
||||
if (sources.length === 0) {
|
||||
throw new Error("No desktop sources available for screenshot");
|
||||
}
|
||||
|
||||
console.log("sources", sources);
|
||||
|
||||
const primaryScreen = sources[0];
|
||||
|
||||
const originalImage = nativeImage.createFromDataURL(
|
||||
primaryScreen.thumbnail.toDataURL()
|
||||
);
|
||||
|
||||
const compressedImage = this.compressImage(originalImage);
|
||||
|
||||
let finalDir = screenshotsPath;
|
||||
let filename: string;
|
||||
|
||||
if (gameTitle && achievementName) {
|
||||
const sanitizedGameTitle = gameTitle.replaceAll(/[<>:"/\\|?*]/g, "_");
|
||||
const gameDir = path.join(screenshotsPath, sanitizedGameTitle);
|
||||
finalDir = gameDir;
|
||||
|
||||
const sanitizedAchievementName = achievementName.replaceAll(
|
||||
/[<>:"/\\|?*]/g,
|
||||
"_"
|
||||
);
|
||||
filename = `${sanitizedAchievementName}.${this.SCREENSHOT_FORMAT}`;
|
||||
} else {
|
||||
const timestamp = Date.now();
|
||||
filename = `achievement_screenshot_${timestamp}.${this.SCREENSHOT_FORMAT}`;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(finalDir)) {
|
||||
fs.mkdirSync(finalDir, { recursive: true });
|
||||
}
|
||||
|
||||
const screenshotPath = path.join(finalDir, filename);
|
||||
|
||||
const jpegBuffer = compressedImage.toJPEG(this.SCREENSHOT_QUALITY);
|
||||
fs.writeFileSync(screenshotPath, jpegBuffer);
|
||||
|
||||
logger.log(`Compressed screenshot saved to: ${screenshotPath}`);
|
||||
return screenshotPath;
|
||||
} catch (error) {
|
||||
logger.error("Failed to capture desktop screenshot:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async cleanupOldScreenshots(): Promise<void> {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const screenshotsDir = path.join(userDataPath, "screenshots");
|
||||
|
||||
if (!fs.existsSync(screenshotsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getAllFiles = (
|
||||
dir: string
|
||||
): Array<{ name: string; path: string; mtime: Date }> => {
|
||||
const files: Array<{ name: string; path: string; mtime: Date }> = [];
|
||||
|
||||
const items = fs.readdirSync(dir);
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dir, item);
|
||||
const stat = fs.statSync(itemPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...getAllFiles(itemPath));
|
||||
} else if (item.endsWith(`.${this.SCREENSHOT_FORMAT}`)) {
|
||||
files.push({
|
||||
name: item,
|
||||
path: itemPath,
|
||||
mtime: stat.mtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const allFiles = getAllFiles(screenshotsDir).sort(
|
||||
(a, b) => b.mtime.getTime() - a.mtime.getTime()
|
||||
);
|
||||
|
||||
const filesToDelete = allFiles.slice(50);
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
logger.log(`Cleaned up old screenshot: ${file.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete screenshot ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupEmptyDirs = (dir: string) => {
|
||||
if (dir === screenshotsDir) return;
|
||||
|
||||
try {
|
||||
const items = fs.readdirSync(dir);
|
||||
if (items.length === 0) {
|
||||
fs.rmdirSync(dir);
|
||||
logger.log(`Cleaned up empty directory: ${dir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read directory ${dir}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const gameDirectories = fs
|
||||
.readdirSync(screenshotsDir)
|
||||
.map((item) => path.join(screenshotsDir, item))
|
||||
.filter((itemPath) => {
|
||||
try {
|
||||
return fs.statSync(itemPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
for (const gameDir of gameDirectories) {
|
||||
cleanupEmptyDirs(gameDir);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to cleanup old screenshots:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
User,
|
||||
type ProfileVisibility,
|
||||
type UserDetails,
|
||||
type UserPreferences,
|
||||
} from "@types";
|
||||
import { User, type ProfileVisibility, type UserDetails } from "@types";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import { logger } from "../logger";
|
||||
@@ -11,24 +6,7 @@ import { db } from "@main/level";
|
||||
import { levelKeys } from "@main/level/sublevels";
|
||||
|
||||
export const getUserData = async () => {
|
||||
let language = "en";
|
||||
try {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
if (userPreferences?.language) {
|
||||
const supportedLanguages = ["pt", "ru", "es"];
|
||||
const userLang = userPreferences.language.split("-")[0];
|
||||
language = supportedLanguages.includes(userLang) ? userLang : "en";
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to get user preferences for language", error);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ language });
|
||||
return HydraApi.get<UserDetails>(`/profile/me?${params.toString()}`)
|
||||
return HydraApi.get<UserDetails>(`/profile/me`)
|
||||
.then(async (me) => {
|
||||
try {
|
||||
const user = await db.get<string, User>(levelKeys.user, {
|
||||
|
||||
@@ -206,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
|
||||
openGameInstaller: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("openGameInstaller", shop, objectId),
|
||||
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
|
||||
openGameInstallerPath: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
|
||||
openGameExecutablePath: (shop: GameShop, objectId: string) =>
|
||||
@@ -363,11 +365,9 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
getVersion: () => ipcRenderer.invoke("getVersion"),
|
||||
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
|
||||
getScreenshotsPath: () => ipcRenderer.invoke("getScreenshotsPath"),
|
||||
isStaging: () => ipcRenderer.invoke("isStaging"),
|
||||
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
|
||||
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
|
||||
openFolder: (path: string) => ipcRenderer.invoke("openFolder", path),
|
||||
openCheckout: () => ipcRenderer.invoke("openCheckout"),
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions) =>
|
||||
ipcRenderer.invoke("showOpenDialog", options),
|
||||
|
||||
@@ -47,17 +47,6 @@ button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
dialog {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
min-width: 200px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
animation: dropdown-menu-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
&__group {
|
||||
@@ -66,3 +67,14 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-menu-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
.fullscreen-image-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
&__backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
right: 32px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 10000;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&__image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fullscreen-image-modal {
|
||||
&__close-button {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__container {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
padding: 48px 16px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 420px) {
|
||||
.fullscreen-image-modal {
|
||||
&__close-button {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&__container {
|
||||
padding-top: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
import "./fullscreen-image-modal.scss";
|
||||
|
||||
interface FullscreenImageModalProps {
|
||||
isOpen: boolean;
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FullscreenImageModal({
|
||||
isOpen,
|
||||
imageUrl,
|
||||
imageAlt,
|
||||
onClose,
|
||||
}: Readonly<FullscreenImageModalProps>) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<dialog className="fullscreen-image-modal" aria-modal="true" open>
|
||||
<button
|
||||
type="button"
|
||||
className="fullscreen-image-modal__backdrop"
|
||||
onClick={onClose}
|
||||
aria-label="Close fullscreen image"
|
||||
/>
|
||||
<div className="fullscreen-image-modal__container">
|
||||
<button
|
||||
className="fullscreen-image-modal__close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close fullscreen image"
|
||||
>
|
||||
<XIcon size={24} />
|
||||
</button>
|
||||
|
||||
<div className="fullscreen-image-modal__image-container">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
className="fullscreen-image-modal__image"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { FullscreenImageModal } from "./fullscreen-image-modal";
|
||||
@@ -224,21 +224,6 @@ export function Header() {
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const prevPath = sessionStorage.getItem("prevPath");
|
||||
const currentPath = location.pathname;
|
||||
|
||||
if (
|
||||
prevPath?.startsWith("/catalogue") &&
|
||||
!currentPath.startsWith("/catalogue") &&
|
||||
catalogueSearchValue
|
||||
) {
|
||||
dispatch(setFilters({ title: "" }));
|
||||
}
|
||||
|
||||
sessionStorage.setItem("prevPath", currentPath);
|
||||
}, [location.pathname, catalogueSearchValue, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownVisible) return;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Torrent]: "Torrent",
|
||||
[Downloader.Gofile]: "Gofile",
|
||||
[Downloader.PixelDrain]: "PixelDrain",
|
||||
[Downloader.Qiwi]: "Qiwi",
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.Buzzheavier]: "Buzzheavier",
|
||||
@@ -15,6 +14,7 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.Hydra]: "Nimbus",
|
||||
[Downloader.VikingFile]: "VikingFile",
|
||||
[Downloader.Rootz]: "Rootz",
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { darkenColor } from "@renderer/helpers";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import type {
|
||||
Badge,
|
||||
UserProfile,
|
||||
UserStats,
|
||||
UserGame,
|
||||
ProfileAchievement,
|
||||
} from "@types";
|
||||
import type { Badge, UserProfile, UserStats, UserGame } from "@types";
|
||||
import { average } from "color.js";
|
||||
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
@@ -208,56 +202,22 @@ export function UserProfileContextProvider({
|
||||
getUserStats();
|
||||
getUserLibraryGames();
|
||||
|
||||
const currentLanguage = i18n.language.split("-")[0];
|
||||
const supportedLanguages = ["pt", "ru", "es"];
|
||||
const language = supportedLanguages.includes(currentLanguage)
|
||||
? currentLanguage
|
||||
: "en";
|
||||
|
||||
const params = new URLSearchParams({ language });
|
||||
|
||||
// Fetch main profile data
|
||||
const profilePromise = window.electron.hydraApi
|
||||
.get<UserProfile>(`/users/${userId}?${params.toString()}`)
|
||||
.catch(() => {
|
||||
showErrorToast(t("user_not_found"));
|
||||
navigate(-1);
|
||||
throw new Error("Profile not found");
|
||||
});
|
||||
|
||||
// Fetch achievements separately
|
||||
const achievementsPromise = window.electron.hydraApi
|
||||
.get<
|
||||
ProfileAchievement[]
|
||||
>(`/users/${userId}/achievements?${params.toString()}`)
|
||||
.catch(() => null); // If achievements fail, just return null
|
||||
|
||||
return Promise.all([profilePromise, achievementsPromise]).then(
|
||||
([userProfile, achievements]) => {
|
||||
// Merge achievements into the profile
|
||||
const profileWithAchievements = {
|
||||
...userProfile,
|
||||
achievements: achievements || null,
|
||||
};
|
||||
|
||||
setUserProfile(profileWithAchievements);
|
||||
return window.electron.hydraApi
|
||||
.get<UserProfile>(`/users/${userId}`)
|
||||
.then((userProfile) => {
|
||||
setUserProfile(userProfile);
|
||||
|
||||
if (userProfile.profileImageUrl) {
|
||||
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
|
||||
(color) => setHeroBackground(color)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [
|
||||
navigate,
|
||||
getUserStats,
|
||||
getUserLibraryGames,
|
||||
showErrorToast,
|
||||
userId,
|
||||
t,
|
||||
i18n,
|
||||
]);
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("user_not_found"));
|
||||
navigate(-1);
|
||||
});
|
||||
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
|
||||
|
||||
const getBadges = useCallback(async () => {
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
6
src/renderer/src/declaration.d.ts
vendored
6
src/renderer/src/declaration.d.ts
vendored
@@ -167,6 +167,10 @@ declare global {
|
||||
getLibrary: () => Promise<LibraryGame[]>;
|
||||
refreshLibraryAssets: () => Promise<void>;
|
||||
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
getGameInstallerActionType: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<"install" | "open-folder">;
|
||||
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
openGame: (
|
||||
@@ -282,9 +286,7 @@ declare global {
|
||||
isStaging: () => Promise<boolean>;
|
||||
ping: () => string;
|
||||
getDefaultDownloadsPath: () => Promise<string>;
|
||||
getScreenshotsPath: () => Promise<string>;
|
||||
isPortableVersion: () => Promise<boolean>;
|
||||
openFolder: (path: string) => Promise<string>;
|
||||
showOpenDialog: (
|
||||
options: Electron.OpenDialogOptions
|
||||
) => Promise<Electron.OpenDialogReturnValue>;
|
||||
|
||||
@@ -2,11 +2,9 @@ import { useDate } from "@renderer/hooks";
|
||||
import type { UserAchievement } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./achievements.scss";
|
||||
import { EyeClosedIcon, SearchIcon } from "@primer/octicons-react";
|
||||
import { EyeClosedIcon } from "@primer/octicons-react";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { useState } from "react";
|
||||
import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal";
|
||||
|
||||
interface AchievementListProps {
|
||||
achievements: UserAchievement[];
|
||||
@@ -18,34 +16,17 @@ export function AchievementList({
|
||||
const { t } = useTranslation("achievement");
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
const { formatDateTime } = useDate();
|
||||
const [fullscreenImage, setFullscreenImage] = useState<{
|
||||
url: string;
|
||||
alt: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleImageClick = (imageUrl: string, achievementName: string) => {
|
||||
setFullscreenImage({
|
||||
url: imageUrl,
|
||||
alt: `${achievementName} screenshot`,
|
||||
});
|
||||
};
|
||||
|
||||
const closeFullscreenImage = () => {
|
||||
setFullscreenImage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="achievements__list">
|
||||
{achievements.map((achievement) => (
|
||||
<li key={achievement.name} className="achievements__item">
|
||||
<div className="achievements__item-icon-container">
|
||||
<img
|
||||
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div className="achievements__item-content">
|
||||
<h4 className="achievements__item-title">
|
||||
@@ -63,41 +44,6 @@ export function AchievementList({
|
||||
</div>
|
||||
|
||||
<div className="achievements__item-meta">
|
||||
{achievement.imageUrl && achievement.unlocked && (
|
||||
<div className="achievements__item-image-container">
|
||||
<div className="achievements__item-custom-image-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="achievements__item-image-button"
|
||||
onClick={() =>
|
||||
achievement.imageUrl &&
|
||||
handleImageClick(
|
||||
achievement.imageUrl,
|
||||
achievement.displayName
|
||||
)
|
||||
}
|
||||
aria-label={`View ${achievement.displayName} screenshot in fullscreen`}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className="achievements__item-custom-image"
|
||||
src={achievement.imageUrl}
|
||||
alt={`${achievement.displayName} screenshot`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
<div className="achievements__item-custom-image-overlay">
|
||||
<SearchIcon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{achievement.points != undefined ? (
|
||||
<div
|
||||
className="achievements__item-points"
|
||||
@@ -120,7 +66,6 @@ export function AchievementList({
|
||||
<p className="achievements__item-points-value">???</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{achievement.unlockTime != null && (
|
||||
<div
|
||||
className="achievements__item-unlock-time"
|
||||
@@ -134,13 +79,6 @@ export function AchievementList({
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<FullscreenImageModal
|
||||
isOpen={fullscreenImage !== null}
|
||||
imageUrl={fullscreenImage?.url || ""}
|
||||
imageAlt={fullscreenImage?.alt || ""}
|
||||
onClose={closeFullscreenImage}
|
||||
/>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||
className="achievements-content__profile-avatar"
|
||||
src={user.profileImageUrl}
|
||||
alt={user.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
@@ -151,7 +150,6 @@ export function AchievementsContent({
|
||||
className="achievements-content__comparison__small-avatar"
|
||||
src={user.profileImageUrl}
|
||||
alt={user.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
@@ -168,7 +166,6 @@ export function AchievementsContent({
|
||||
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
|
||||
className="achievements-content__achievements-list__image"
|
||||
alt={gameTitle}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<section
|
||||
@@ -189,7 +186,6 @@ export function AchievementsContent({
|
||||
src={shopDetails?.assets?.logoImageUrl ?? ""}
|
||||
className="achievements-content__achievements-list__section__container__hero__content__game-logo"
|
||||
alt={gameTitle}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -117,70 +117,6 @@ $logo-max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
&-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&-custom-image {
|
||||
width: 120px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
opacity: 0.9;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-custom-image-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.achievements__item-custom-image {
|
||||
filter: grayscale(50%) brightness(0.7);
|
||||
}
|
||||
|
||||
.achievements__item-custom-image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-custom-image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
|
||||
svg {
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -217,7 +153,6 @@ $logo-max-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
&-points {
|
||||
|
||||
@@ -511,6 +511,13 @@
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
&__simple-action-btn {
|
||||
padding: calc(globals.$spacing-unit);
|
||||
min-height: unset;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
&__progress-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -32,12 +32,12 @@ import {
|
||||
FileDirectoryIcon,
|
||||
LinkIcon,
|
||||
PlayIcon,
|
||||
ThreeBarsIcon,
|
||||
TrashIcon,
|
||||
UnlinkIcon,
|
||||
XCircleIcon,
|
||||
GraphIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { MoreVertical, Folder } from "lucide-react";
|
||||
import { average } from "color.js";
|
||||
|
||||
interface AnimatedPercentageProps {
|
||||
@@ -452,6 +452,7 @@ export function DownloadGroup({
|
||||
seedingStatus,
|
||||
}: Readonly<DownloadGroupProps>) {
|
||||
const { t } = useTranslation("downloads");
|
||||
const { t: tGameDetails } = useTranslation("game_details");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
@@ -523,6 +524,9 @@ export function DownloadGroup({
|
||||
const [optimisticallyResumed, setOptimisticallyResumed] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [gameActionTypes, setGameActionTypes] = useState<
|
||||
Record<string, "install" | "open-folder">
|
||||
>({});
|
||||
|
||||
const extractDominantColor = useCallback(
|
||||
async (imageUrl: string, gameId: string) => {
|
||||
@@ -770,6 +774,37 @@ export function DownloadGroup({
|
||||
]
|
||||
);
|
||||
|
||||
// Fetch action types for completed games
|
||||
useEffect(() => {
|
||||
const fetchActionTypes = async () => {
|
||||
const completedGames = library.filter(
|
||||
(game) => game.download?.progress === 1
|
||||
);
|
||||
|
||||
const actionTypesPromises = completedGames.map(async (game) => {
|
||||
try {
|
||||
const actionType = await window.electron.getGameInstallerActionType(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
return { gameId: game.id, actionType };
|
||||
} catch {
|
||||
return { gameId: game.id, actionType: "open-folder" as const };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(actionTypesPromises);
|
||||
const newActionTypes: Record<string, "install" | "open-folder"> = {};
|
||||
results.forEach(({ gameId, actionType }) => {
|
||||
newActionTypes[gameId] = actionType;
|
||||
});
|
||||
|
||||
setGameActionTypes((prev) => ({ ...prev, ...newActionTypes }));
|
||||
};
|
||||
|
||||
fetchActionTypes();
|
||||
}, [library]);
|
||||
|
||||
if (!library.length) return null;
|
||||
|
||||
const isDownloadingGroup = title === t("download_in_progress");
|
||||
@@ -901,16 +936,35 @@ export function DownloadGroup({
|
||||
)}
|
||||
|
||||
<div className="download-group__simple-actions">
|
||||
{game.download?.progress === 1 && (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => openGameInstaller(game.shop, game.objectId)}
|
||||
disabled={isGameDeleting(game.id)}
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{game.download?.progress === 1 &&
|
||||
(() => {
|
||||
const actionType =
|
||||
gameActionTypes[game.id] || "open-folder";
|
||||
const isInstall = actionType === "install";
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() =>
|
||||
openGameInstaller(game.shop, game.objectId)
|
||||
}
|
||||
disabled={isGameDeleting(game.id)}
|
||||
className="download-group__simple-action-btn"
|
||||
>
|
||||
{isInstall ? (
|
||||
<>
|
||||
<DownloadIcon size={16} />
|
||||
{t("install")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Folder size={16} />
|
||||
{tGameDetails("open_folder")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
{isQueuedGroup && game.download?.progress !== 1 && (
|
||||
<Button
|
||||
theme="primary"
|
||||
@@ -926,7 +980,7 @@ export function DownloadGroup({
|
||||
theme="outline"
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,6 @@ export function Sidebar() {
|
||||
}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
@@ -165,7 +164,6 @@ export function Sidebar() {
|
||||
}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import "../../../pages/game-details/modals/delete-review-modal.scss";
|
||||
|
||||
interface DeleteSouvenirModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteSouvenirModal({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Readonly<DeleteSouvenirModalProps>) {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const handleDeleteSouvenir = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("delete_souvenir_modal_title")}
|
||||
description={t("delete_souvenir_modal_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-review-modal__actions">
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("delete_souvenir_modal_cancel_button")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleDeleteSouvenir} theme="danger">
|
||||
{t("delete_souvenir_modal_delete_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,20 @@
|
||||
&__box {
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
position: relative;
|
||||
|
||||
&--empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
color: globals.$muted-color;
|
||||
font-size: globals.$small-font-size;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__add-friend-button {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function FriendsBox() {
|
||||
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
|
||||
|
||||
const isMe = userDetails?.id === userProfile?.id;
|
||||
const hasFriends = userProfile?.friends && userProfile.friends.length > 0;
|
||||
|
||||
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
|
||||
if (game.iconUrl) {
|
||||
@@ -35,7 +36,15 @@ export function FriendsBox() {
|
||||
return <SteamLogo width={16} height={16} />;
|
||||
};
|
||||
|
||||
if (!userProfile?.friends.length) return null;
|
||||
if (!hasFriends) {
|
||||
if (!isMe) return null;
|
||||
|
||||
return (
|
||||
<div className="friends-box__box friends-box__box--empty">
|
||||
<p className="friends-box__empty-text">{t("no_friends_yet")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS);
|
||||
const totalFriends = userProfile.friends.length;
|
||||
|
||||
@@ -206,313 +206,6 @@
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__images-section {
|
||||
margin-bottom: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__images-grid {
|
||||
display: grid;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding-bottom: calc(globals.$spacing-unit);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1000px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 1300px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 2000px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 2600px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
@container #{globals.$app-container} (min-width: 3000px) {
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__image-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all ease 0.2s;
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@container (max-width: 240px) {
|
||||
.profile-content__image-achievement-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.profile-content__image-achievement-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profile-content__image-game-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 280px) {
|
||||
.profile-content__image-card-content {
|
||||
gap: calc(globals.$spacing-unit * 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__image-card-header {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__image-achievement-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.profile-content__image-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover .profile-content__image-achievement-image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-achievement-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s ease;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__image-achievement-image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-delete-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(244, 67, 54, 0.5);
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
color: rgba(244, 67, 54, 0.9);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-color: rgba(244, 67, 54, 0.8);
|
||||
color: #ff7961;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Show overlay on keyboard focus for accessibility
|
||||
&__image-button:focus-visible + &__image-achievement-image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__image-card-content {
|
||||
padding: 16px;
|
||||
background: #121212;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__image-card-row {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__image-achievement-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__image-achievement-description {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__image-card-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__image-unlock-time {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&__image-achievement-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__image-achievement-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--large {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
&__image-achievement-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__image-game-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__image-game-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 0.75);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__image-game-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__image-game-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__image-item {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
transition: border-color ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reviews minimal styles
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||
import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { TelescopeIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GameShop } from "@types";
|
||||
import { LockedProfile } from "./locked-profile";
|
||||
@@ -19,17 +18,14 @@ import { BadgesBox } from "./badges-box";
|
||||
import { FriendsBox, FriendsBoxAddButton } from "./friends-box";
|
||||
import { RecentGamesBox } from "./recent-games-box";
|
||||
import { UserStatsBox } from "./user-stats-box";
|
||||
import { logger } from "@renderer/logger";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { ProfileSection } from "../profile-section/profile-section";
|
||||
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
|
||||
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
|
||||
import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { ProfileTabs, type ProfileTabType } from "./profile-tabs";
|
||||
import { LibraryTab } from "./library-tab";
|
||||
import { ReviewsTab } from "./reviews-tab";
|
||||
import { SouvenirsTab } from "./souvenirs-tab";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import "./profile-content.scss";
|
||||
|
||||
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
||||
@@ -89,7 +85,6 @@ export function ProfileContent() {
|
||||
userStats,
|
||||
libraryGames,
|
||||
pinnedGames,
|
||||
getUserProfile,
|
||||
getUserLibraryGames,
|
||||
loadMoreLibraryGames,
|
||||
hasMoreLibraryGames,
|
||||
@@ -99,10 +94,6 @@ export function ProfileContent() {
|
||||
const [statsIndex, setStatsIndex] = useState(0);
|
||||
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
|
||||
const [fullscreenImage, setFullscreenImage] = useState<{
|
||||
url: string;
|
||||
alt: string;
|
||||
} | null>(null);
|
||||
const statsAnimation = useRef(-1);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ProfileTabType>("library");
|
||||
@@ -215,7 +206,7 @@ export function ProfileContent() {
|
||||
setReviews((prev) => prev.filter((review) => review.id !== reviewId));
|
||||
setReviewsTotalCount((prev) => prev - 1);
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete review:", error);
|
||||
console.error("Failed to delete review:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -310,7 +301,7 @@ export function ProfileContent() {
|
||||
`/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to vote on review:", error);
|
||||
console.error("Failed to vote on review:", error);
|
||||
|
||||
// Rollback optimistic update on error
|
||||
setReviews((prev) =>
|
||||
@@ -344,17 +335,6 @@ export function ProfileContent() {
|
||||
setIsAnimationRunning(true);
|
||||
};
|
||||
|
||||
const handleImageClick = (imageUrl: string, achievementName: string) => {
|
||||
setFullscreenImage({
|
||||
url: imageUrl,
|
||||
alt: `${achievementName} screenshot`,
|
||||
});
|
||||
};
|
||||
|
||||
const closeFullscreenImage = () => {
|
||||
setFullscreenImage(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let zero = performance.now();
|
||||
if (!isAnimationRunning) return;
|
||||
@@ -396,75 +376,51 @@ export function ProfileContent() {
|
||||
const hasAnyGames = hasGames || hasPinnedGames;
|
||||
|
||||
const shouldShowRightContent =
|
||||
hasAnyGames || userProfile.friends.length > 0;
|
||||
hasAnyGames || userProfile.friends.length > 0 || isMe;
|
||||
|
||||
return (
|
||||
<section className="profile-content__section">
|
||||
<div className="profile-content__main">
|
||||
{!hasAnyGames && (
|
||||
<div className="profile-content__no-games">
|
||||
<div className="profile-content__telescope-icon">
|
||||
<TelescopeIcon size={24} />
|
||||
</div>
|
||||
<h2>{t("no_recent_activity_title")}</h2>
|
||||
{isMe && <p>{t("no_recent_activity_description")}</p>}
|
||||
</div>
|
||||
)}
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
reviewsTotalCount={reviewsTotalCount}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{hasAnyGames && (
|
||||
<div>
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
reviewsTotalCount={reviewsTotalCount}
|
||||
souvenirsCount={userProfile?.achievements?.length || 0}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
<div className="profile-content__tab-panels">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === "library" && (
|
||||
<LibraryTab
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
pinnedGames={pinnedGames}
|
||||
libraryGames={libraryGames}
|
||||
hasMoreLibraryGames={hasMoreLibraryGames}
|
||||
isLoadingLibraryGames={isLoadingLibraryGames}
|
||||
statsIndex={statsIndex}
|
||||
userStats={userStats}
|
||||
animatedGameIdsRef={animatedGameIdsRef}
|
||||
onLoadMore={handleLoadMore}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
isMe={isMe}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="profile-content__tab-panels">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === "library" && (
|
||||
<LibraryTab
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
pinnedGames={pinnedGames}
|
||||
libraryGames={libraryGames}
|
||||
hasMoreLibraryGames={hasMoreLibraryGames}
|
||||
isLoadingLibraryGames={isLoadingLibraryGames}
|
||||
statsIndex={statsIndex}
|
||||
userStats={userStats}
|
||||
animatedGameIdsRef={animatedGameIdsRef}
|
||||
onLoadMore={handleLoadMore}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
isMe={isMe}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "reviews" && (
|
||||
<ReviewsTab
|
||||
reviews={reviews}
|
||||
isLoadingReviews={isLoadingReviews}
|
||||
votingReviews={votingReviews}
|
||||
userDetailsId={userDetails?.id}
|
||||
formatPlayTime={formatPlayTime}
|
||||
getRatingText={getRatingText}
|
||||
onVote={handleVoteReview}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "souvenirs" && (
|
||||
<SouvenirsTab
|
||||
achievements={userProfile?.achievements || []}
|
||||
onImageClick={handleImageClick}
|
||||
isMe={isMe}
|
||||
onAchievementDeleted={getUserProfile}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "reviews" && (
|
||||
<ReviewsTab
|
||||
reviews={reviews}
|
||||
isLoadingReviews={isLoadingReviews}
|
||||
votingReviews={votingReviews}
|
||||
userDetailsId={userDetails?.id}
|
||||
formatPlayTime={formatPlayTime}
|
||||
getRatingText={getRatingText}
|
||||
onVote={handleVoteReview}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldShowRightContent && (
|
||||
@@ -488,7 +444,7 @@ export function ProfileContent() {
|
||||
<RecentGamesBox />
|
||||
</ProfileSection>
|
||||
)}
|
||||
{userProfile?.friends.length > 0 && (
|
||||
{(userProfile?.friends.length > 0 || isMe) && (
|
||||
<ProfileSection
|
||||
title={t("friends")}
|
||||
count={userStats?.friendsCount || userProfile.friends.length}
|
||||
@@ -519,25 +475,15 @@ export function ProfileContent() {
|
||||
statsIndex,
|
||||
libraryGames,
|
||||
pinnedGames,
|
||||
|
||||
sortBy,
|
||||
activeTab,
|
||||
// ensure reviews UI updates correctly
|
||||
reviews,
|
||||
reviewsTotalCount,
|
||||
isLoadingReviews,
|
||||
votingReviews,
|
||||
deleteModalVisible,
|
||||
handleOnMouseEnterGameCard,
|
||||
handleOnMouseLeaveGameCard,
|
||||
handleImageClick,
|
||||
handleLoadMore,
|
||||
formatPlayTime,
|
||||
getRatingText,
|
||||
handleVoteReview,
|
||||
handleDeleteClick,
|
||||
userDetails,
|
||||
animatedGameIdsRef,
|
||||
hasMoreLibraryGames,
|
||||
isLoadingLibraryGames,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -545,13 +491,6 @@ export function ProfileContent() {
|
||||
<ProfileHero />
|
||||
|
||||
{content}
|
||||
|
||||
<FullscreenImageModal
|
||||
isOpen={fullscreenImage !== null}
|
||||
imageUrl={fullscreenImage?.url || ""}
|
||||
imageAlt={fullscreenImage?.alt || ""}
|
||||
onClose={closeFullscreenImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,17 @@ import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./profile-content.scss";
|
||||
|
||||
export type ProfileTabType = "library" | "reviews" | "souvenirs";
|
||||
export type ProfileTabType = "library" | "reviews";
|
||||
|
||||
interface ProfileTabsProps {
|
||||
activeTab: ProfileTabType;
|
||||
reviewsTotalCount: number;
|
||||
souvenirsCount: number;
|
||||
onTabChange: (tab: ProfileTabType) => void;
|
||||
}
|
||||
|
||||
export function ProfileTabs({
|
||||
activeTab,
|
||||
reviewsTotalCount,
|
||||
souvenirsCount,
|
||||
onTabChange,
|
||||
}: Readonly<ProfileTabsProps>) {
|
||||
const { t } = useTranslation("user_profile");
|
||||
@@ -66,29 +64,6 @@ export function ProfileTabs({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-content__tab-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className={`profile-content__tab ${activeTab === "souvenirs" ? "profile-content__tab--active" : ""}`}
|
||||
onClick={() => onTabChange("souvenirs")}
|
||||
>
|
||||
{t("souvenirs")}
|
||||
{souvenirsCount > 0 && (
|
||||
<span className="profile-content__tab-badge">{souvenirsCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{activeTab === "souvenirs" && (
|
||||
<motion.div
|
||||
className="profile-content__tab-underline"
|
||||
layoutId="tab-underline"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||
import { TrashIcon, Maximize2 } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import type { ProfileAchievement } from "@types";
|
||||
import { useToast, useDate } from "@renderer/hooks";
|
||||
import { logger } from "@renderer/logger";
|
||||
import { DeleteSouvenirModal } from "./delete-souvenir-modal";
|
||||
import "./profile-content.scss";
|
||||
|
||||
interface SouvenirGameGroupProps {
|
||||
gameTitle: string;
|
||||
gameIconUrl: string | null;
|
||||
achievements: ProfileAchievement[];
|
||||
isMe: boolean;
|
||||
deletingIds: Set<string>;
|
||||
onImageClick: (imageUrl: string, achievementName: string) => void;
|
||||
onDeleteClick: (achievement: ProfileAchievement) => void;
|
||||
}
|
||||
|
||||
function SouvenirGameGroup({
|
||||
gameTitle,
|
||||
gameIconUrl,
|
||||
achievements,
|
||||
isMe,
|
||||
deletingIds,
|
||||
onImageClick,
|
||||
onDeleteClick,
|
||||
}: Readonly<SouvenirGameGroupProps>) {
|
||||
const { formatDistance } = useDate();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="profile-content__images-section">
|
||||
<button
|
||||
className="profile-content__section-header"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
type="button"
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<div className="profile-content__section-title-group">
|
||||
<div className="profile-content__collapse-button">
|
||||
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
</div>
|
||||
|
||||
{gameIconUrl && (
|
||||
<img
|
||||
src={gameIconUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
|
||||
{gameTitle}
|
||||
</h3>
|
||||
|
||||
<span className="profile-content__section-badge">
|
||||
{achievements.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="profile-content__images-grid">
|
||||
{achievements.map((achievement, index) => (
|
||||
<div
|
||||
key={`${achievement.gameTitle}-${achievement.name}-${index}`}
|
||||
className="profile-content__image-card"
|
||||
>
|
||||
<div className="profile-content__image-card-header">
|
||||
<div className="profile-content__image-achievement-image-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="profile-content__image-button"
|
||||
onClick={() =>
|
||||
onImageClick(
|
||||
achievement.imageUrl,
|
||||
achievement.displayName
|
||||
)
|
||||
}
|
||||
aria-label={`View ${achievement.displayName} screenshot in fullscreen`}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={achievement.imageUrl}
|
||||
alt={achievement.displayName}
|
||||
className="profile-content__image-achievement-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
<div className="profile-content__image-achievement-image-overlay">
|
||||
<Maximize2 size={24} />
|
||||
</div>
|
||||
<span className="profile-content__image-unlock-time">
|
||||
{formatDistance(
|
||||
new Date(achievement.unlockTime),
|
||||
new Date(),
|
||||
{
|
||||
addSuffix: true,
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-content__image-card-content">
|
||||
<div className="profile-content__image-card-row">
|
||||
{achievement.achievementIcon && (
|
||||
<img
|
||||
src={achievement.achievementIcon}
|
||||
alt=""
|
||||
className="profile-content__image-achievement-icon profile-content__image-achievement-icon--large"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="profile-content__image-achievement-text">
|
||||
<span className="profile-content__image-achievement-name">
|
||||
{achievement.displayName}
|
||||
</span>
|
||||
<p className="profile-content__image-achievement-description">
|
||||
{achievement.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="profile-content__image-card-right">
|
||||
{isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="profile-content__image-delete-button"
|
||||
onClick={() => onDeleteClick(achievement)}
|
||||
aria-label={`Delete ${achievement.displayName} souvenir`}
|
||||
disabled={deletingIds.has(achievement.id)}
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SouvenirsTabProps {
|
||||
achievements: ProfileAchievement[];
|
||||
onImageClick: (imageUrl: string, achievementName: string) => void;
|
||||
isMe: boolean;
|
||||
onAchievementDeleted: () => void;
|
||||
}
|
||||
|
||||
export function SouvenirsTab({
|
||||
achievements,
|
||||
onImageClick,
|
||||
isMe,
|
||||
onAchievementDeleted,
|
||||
}: Readonly<SouvenirsTabProps>) {
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
||||
const [achievementToDelete, setAchievementToDelete] =
|
||||
useState<ProfileAchievement | null>(null);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
|
||||
const handleDeleteAchievement = async (achievement: ProfileAchievement) => {
|
||||
if (deletingIds.has(achievement.id)) return;
|
||||
|
||||
setDeletingIds((prev) => new Set(prev).add(achievement.id));
|
||||
|
||||
try {
|
||||
await window.electron.hydraApi.delete(
|
||||
`/profile/games/achievements/${achievement.gameId}/${achievement.name}/image`
|
||||
);
|
||||
|
||||
showSuccessToast(
|
||||
t("souvenir_deleted_successfully", "Souvenir deleted successfully")
|
||||
);
|
||||
onAchievementDeleted();
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete souvenir:", error);
|
||||
showErrorToast(
|
||||
t("souvenir_deletion_failed", "Failed to delete souvenir")
|
||||
);
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(achievement.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (achievement: ProfileAchievement) => {
|
||||
setAchievementToDelete(achievement);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (achievementToDelete) {
|
||||
handleDeleteAchievement(achievementToDelete);
|
||||
setAchievementToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteModalVisible(false);
|
||||
setAchievementToDelete(null);
|
||||
};
|
||||
|
||||
const groupedAchievements = useMemo(() => {
|
||||
const groups: Record<string, ProfileAchievement[]> = {};
|
||||
for (const achievement of achievements) {
|
||||
if (!groups[achievement.gameId]) {
|
||||
groups[achievement.gameId] = [];
|
||||
}
|
||||
groups[achievement.gameId].push(achievement);
|
||||
}
|
||||
return groups;
|
||||
}, [achievements]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
key="souvenirs"
|
||||
className="profile-content__tab-panel"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
aria-hidden={false}
|
||||
>
|
||||
{achievements.length === 0 && (
|
||||
<div className="profile-content__no-souvenirs">
|
||||
<p>{t("no_souvenirs", "No souvenirs yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedAchievements).map(
|
||||
([gameId, groupAchievements]) => {
|
||||
const firstAchievement = groupAchievements[0];
|
||||
return (
|
||||
<SouvenirGameGroup
|
||||
key={gameId}
|
||||
gameTitle={firstAchievement.gameTitle}
|
||||
gameIconUrl={firstAchievement.gameIconUrl}
|
||||
achievements={groupAchievements}
|
||||
isMe={isMe}
|
||||
deletingIds={deletingIds}
|
||||
onImageClick={onImageClick}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<DeleteSouvenirModal
|
||||
visible={deleteModalVisible}
|
||||
onClose={handleDeleteCancel}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,12 @@
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&--wrapped {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__list-title {
|
||||
@@ -70,4 +76,15 @@
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__wrapped-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
color: globals.$body-color;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext } from "react";
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormat, useUserDetails } from "@renderer/hooks";
|
||||
@@ -7,9 +7,11 @@ import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { Award } from "lucide-react";
|
||||
import { WrappedFullscreenModal } from "./wrapped-tab";
|
||||
import "./user-stats-box.scss";
|
||||
|
||||
export function UserStatsBox() {
|
||||
const [showWrappedModal, setShowWrappedModal] = useState(false);
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
const { userStats, isMe, userProfile } = useContext(userProfileContext);
|
||||
const { userDetails } = useUserDetails();
|
||||
@@ -41,6 +43,18 @@ export function UserStatsBox() {
|
||||
return (
|
||||
<div className="user-stats__box">
|
||||
<ul className="user-stats__list">
|
||||
{userProfile?.hasCompletedWrapped2025 && (
|
||||
<li className="user-stats__list-item user-stats__list-item--wrapped">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWrappedModal(true)}
|
||||
className="user-stats__wrapped-link"
|
||||
>
|
||||
Wrapped 2025
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
|
||||
<li className="user-stats__list-item">
|
||||
<h3 className="user-stats__list-title">
|
||||
@@ -126,6 +140,14 @@ export function UserStatsBox() {
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{userProfile && (
|
||||
<WrappedFullscreenModal
|
||||
userId={userProfile.id}
|
||||
isOpen={showWrappedModal}
|
||||
onClose={() => setShowWrappedModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,11 +144,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__left-actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
@@ -160,35 +155,5 @@
|
||||
&--outline {
|
||||
border-color: globals.$body-color;
|
||||
}
|
||||
|
||||
&--wrapped {
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
#2a57ff 0%,
|
||||
#2951e6 11%,
|
||||
#2f5bff 16%,
|
||||
#2c56e8 29%,
|
||||
#244acc 34%,
|
||||
#2245c2 40%,
|
||||
#3a6bff 45%,
|
||||
#3766f2 50%,
|
||||
#2444b8 56%,
|
||||
#122a73 82%,
|
||||
#2348b3 86%,
|
||||
#1f429e 87%,
|
||||
#10286a 93%,
|
||||
#0e2a63 100%
|
||||
);
|
||||
background-color: #2a57ff;
|
||||
background-size: 105% 100%;
|
||||
background-position: 100% 50%;
|
||||
border: none;
|
||||
color: white;
|
||||
transition: background-position 0.4s ease;
|
||||
|
||||
&:hover {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
PencilIcon,
|
||||
PersonAddIcon,
|
||||
SignOutIcon,
|
||||
TrophyIcon,
|
||||
XCircleFillIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
@@ -30,7 +29,6 @@ import { motion } from "framer-motion";
|
||||
|
||||
import type { FriendRequestAction } from "@types";
|
||||
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
|
||||
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
|
||||
import "./profile-hero.scss";
|
||||
@@ -41,10 +39,10 @@ type FriendAction =
|
||||
|
||||
export function ProfileHero() {
|
||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||
const [showWrappedModal, setShowWrappedModal] = useState(false);
|
||||
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
|
||||
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
||||
const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
|
||||
useContext(userProfileContext);
|
||||
@@ -261,9 +259,23 @@ export function ProfileHero() {
|
||||
const copyFriendCode = useCallback(() => {
|
||||
if (userProfile?.id) {
|
||||
navigator.clipboard.writeText(userProfile.id);
|
||||
showSuccessToast(t("friend_code_copied"));
|
||||
setIsCopied(true);
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 1200; // 1.2 seconds
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
if (elapsed < duration) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
setIsCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}, [userProfile, showSuccessToast, t]);
|
||||
}, [userProfile]);
|
||||
|
||||
const currentGame = useMemo(() => {
|
||||
if (isMe) {
|
||||
@@ -286,13 +298,6 @@ export function ProfileHero() {
|
||||
onClose={() => setShowEditProfileModal(false)}
|
||||
/>
|
||||
|
||||
{userProfile && (
|
||||
<WrappedFullscreenModal
|
||||
userId={userProfile.id}
|
||||
isOpen={showWrappedModal}
|
||||
onClose={() => setShowWrappedModal(false)}
|
||||
/>
|
||||
)}
|
||||
<FullscreenMediaModal
|
||||
visible={showFullscreenAvatar}
|
||||
onClose={() => setShowFullscreenAvatar(false)}
|
||||
@@ -348,7 +353,7 @@ export function ProfileHero() {
|
||||
onMouseLeave={() => setIsCopyButtonHovered(false)}
|
||||
initial={{ width: 28 }}
|
||||
animate={{
|
||||
width: isCopyButtonHovered ? 105 : 28,
|
||||
width: isCopyButtonHovered || isCopied ? 105 : 28,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
@@ -356,12 +361,12 @@ export function ProfileHero() {
|
||||
className="profile-hero__friend-code"
|
||||
initial={{ opacity: 0, marginRight: 0 }}
|
||||
animate={{
|
||||
opacity: isCopyButtonHovered ? 1 : 0,
|
||||
marginRight: isCopyButtonHovered ? 8 : 0,
|
||||
opacity: isCopyButtonHovered || isCopied ? 1 : 0,
|
||||
marginRight: isCopyButtonHovered || isCopied ? 8 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
{userProfile?.id}
|
||||
{isCopied ? t("copied") : userProfile?.id}
|
||||
</motion.span>
|
||||
<CopyIcon size={16} />
|
||||
</motion.button>
|
||||
@@ -410,22 +415,6 @@ export function ProfileHero() {
|
||||
background: !backgroundImage ? heroBackground : undefined,
|
||||
}}
|
||||
>
|
||||
{userProfile?.hasCompletedWrapped2025 && (
|
||||
<div className="profile-hero__left-actions">
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShowWrappedModal(true)}
|
||||
className="profile-hero__button--wrapped"
|
||||
>
|
||||
<TrophyIcon />
|
||||
{isMe
|
||||
? t("view_my_wrapped_button")
|
||||
: t("view_wrapped_button", {
|
||||
displayName: userProfile.displayName,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="profile-hero__actions">{profileActions}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,86 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.upload-background-image-button {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
&__wrapper {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
border-color: globals.$body-color;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
&__menu {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: menu-fade-in 0.2s ease-out;
|
||||
|
||||
&--closing {
|
||||
animation: menu-fade-out 0.15s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease-in-out;
|
||||
font-size: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: globals.$body-color;
|
||||
text-align: left;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menu-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menu-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { UploadIcon } from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useContext, useState } from "react";
|
||||
import { TrashIcon, UploadIcon } from "@primer/octicons-react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { Button, ConfirmationModal } from "@renderer/components";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -9,16 +11,33 @@ import "./upload-background-image-button.scss";
|
||||
export function UploadBackgroundImageButton() {
|
||||
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
|
||||
useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isMenuClosing, setIsMenuClosing] = useState(false);
|
||||
const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { hasActiveSubscription } = useUserDetails();
|
||||
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
|
||||
const { isMe, setSelectedBackgroundImage, userProfile, getUserProfile } =
|
||||
useContext(userProfileContext);
|
||||
const { patchUser, fetchUserDetails } = useUserDetails();
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const handleChangeCoverClick = async () => {
|
||||
const hasBanner = !!userProfile?.backgroundImageUrl;
|
||||
|
||||
const closeMenu = () => {
|
||||
setIsMenuClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsMenuOpen(false);
|
||||
setIsMenuClosing(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleReplaceBanner = async () => {
|
||||
closeMenu();
|
||||
try {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
@@ -40,23 +59,159 @@ export function UploadBackgroundImageButton() {
|
||||
|
||||
showSuccessToast(t("background_image_updated"));
|
||||
await fetchUserDetails();
|
||||
await getUserProfile();
|
||||
}
|
||||
} finally {
|
||||
setIsUploadingBackgorundImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveBannerClick = () => {
|
||||
closeMenu();
|
||||
setShowRemoveBannerModal(true);
|
||||
};
|
||||
|
||||
const handleRemoveBannerConfirm = async () => {
|
||||
setShowRemoveBannerModal(false);
|
||||
try {
|
||||
setIsUploadingBackgorundImage(true);
|
||||
setSelectedBackgroundImage("");
|
||||
await patchUser({ backgroundImageUrl: null });
|
||||
showSuccessToast(t("background_image_updated"));
|
||||
await fetchUserDetails();
|
||||
await getUserProfile();
|
||||
} finally {
|
||||
setIsUploadingBackgorundImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click outside, scroll, and escape key to close menu
|
||||
useEffect(() => {
|
||||
if (!isMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(target) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(target)
|
||||
) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
if (!isMe || !hasActiveSubscription) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme="outline"
|
||||
className="upload-background-image-button"
|
||||
onClick={handleChangeCoverClick}
|
||||
disabled={isUploadingBackgroundImage}
|
||||
// If no banner exists, show the original upload button
|
||||
if (!hasBanner) {
|
||||
return (
|
||||
<div className="upload-background-image-button__wrapper">
|
||||
<Button
|
||||
theme="outline"
|
||||
className="upload-background-image-button"
|
||||
onClick={handleReplaceBanner}
|
||||
disabled={isUploadingBackgroundImage}
|
||||
>
|
||||
<UploadIcon />
|
||||
{isUploadingBackgroundImage
|
||||
? t("uploading_banner")
|
||||
: t("upload_banner")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate menu position
|
||||
const getMenuPosition = () => {
|
||||
if (!buttonRef.current) return { top: 0, right: 0 };
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.bottom + 5,
|
||||
right: window.innerWidth - rect.right,
|
||||
};
|
||||
};
|
||||
|
||||
const menuPosition = isMenuOpen ? getMenuPosition() : { top: 0, right: 0 };
|
||||
|
||||
const menuContent = isMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`upload-background-image-button__menu ${
|
||||
isMenuClosing ? "upload-background-image-button__menu--closing" : ""
|
||||
}`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${menuPosition.top}px`,
|
||||
right: `${menuPosition.right}px`,
|
||||
}}
|
||||
>
|
||||
<UploadIcon />
|
||||
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="upload-background-image-button__menu-item"
|
||||
onClick={handleReplaceBanner}
|
||||
disabled={isUploadingBackgroundImage}
|
||||
>
|
||||
<UploadIcon size={16} />
|
||||
{t("replace_banner")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="upload-background-image-button__menu-item"
|
||||
onClick={handleRemoveBannerClick}
|
||||
disabled={isUploadingBackgroundImage}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
{t("remove_banner")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={buttonRef} className="upload-background-image-button__wrapper">
|
||||
<Button
|
||||
theme="outline"
|
||||
className="upload-background-image-button"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
disabled={isUploadingBackgroundImage}
|
||||
>
|
||||
{t("change_banner")}
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{createPortal(menuContent, document.body)}
|
||||
<ConfirmationModal
|
||||
visible={showRemoveBannerModal}
|
||||
title={t("remove_banner_modal_title")}
|
||||
descriptionText={t("remove_banner_confirmation")}
|
||||
onClose={() => setShowRemoveBannerModal(false)}
|
||||
onConfirm={handleRemoveBannerConfirm}
|
||||
cancelButtonLabel={t("cancel")}
|
||||
confirmButtonLabel={t("remove")}
|
||||
buttonsIsDisabled={isUploadingBackgroundImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.settings-achievements {
|
||||
&__checkbox-container {
|
||||
opacity: globals.$disabled-opacity;
|
||||
cursor: not-allowed;
|
||||
|
||||
&--enabled {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--with-tooltip {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&--tooltip {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-container {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&--achievements {
|
||||
// First section sits flush with container top
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__achievement-custom-notification-position__select-variation {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
&__test-achievement-notification-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&__volume-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__volume-slider-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 200px;
|
||||
position: relative;
|
||||
--volume-percent: 0%;
|
||||
}
|
||||
|
||||
&__volume-icon {
|
||||
color: globals.$muted-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__volume-value {
|
||||
font-size: 14px;
|
||||
color: globals.$body-color;
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__volume-slider {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: globals.$dark-background-color;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
border: 2px solid globals.$background-color;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
margin-top: -6px;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
border: 2px solid globals.$background-color;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
margin-top: -6px;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
globals.$muted-color 0%,
|
||||
globals.$muted-color var(--volume-percent),
|
||||
globals.$dark-background-color var(--volume-percent),
|
||||
globals.$dark-background-color 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: globals.$dark-background-color;
|
||||
}
|
||||
|
||||
&::-moz-range-progress {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: globals.$muted-color;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&::-ms-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
border: 2px solid globals.$background-color;
|
||||
}
|
||||
|
||||
&::-ms-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&::-ms-fill-lower {
|
||||
background: globals.$muted-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-ms-fill-upper {
|
||||
background: globals.$dark-background-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckboxField, Button, SelectField } from "@renderer/components";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import "./settings-achievements.scss";
|
||||
import { QuestionIcon, UnmuteIcon } from "@primer/octicons-react";
|
||||
import { AchievementCustomNotificationPosition } from "@types";
|
||||
|
||||
export function SettingsAchievements() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
const { updateUserPreferences } = useContext(settingsContext);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
showHiddenAchievementsDescription: false,
|
||||
enableSteamAchievements: false,
|
||||
enableAchievementScreenshots: false,
|
||||
achievementNotificationsEnabled: true,
|
||||
achievementCustomNotificationsEnabled: true,
|
||||
achievementCustomNotificationPosition:
|
||||
"top-left" as AchievementCustomNotificationPosition,
|
||||
achievementSoundVolume: 15,
|
||||
});
|
||||
|
||||
const volumeUpdateTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
showHiddenAchievementsDescription:
|
||||
userPreferences.showHiddenAchievementsDescription ?? false,
|
||||
enableSteamAchievements:
|
||||
userPreferences.enableSteamAchievements ?? false,
|
||||
enableAchievementScreenshots:
|
||||
userPreferences.enableAchievementScreenshots ?? false,
|
||||
achievementNotificationsEnabled:
|
||||
userPreferences.achievementNotificationsEnabled ?? true,
|
||||
achievementCustomNotificationsEnabled:
|
||||
userPreferences.achievementCustomNotificationsEnabled ?? true,
|
||||
achievementCustomNotificationPosition:
|
||||
userPreferences.achievementCustomNotificationPosition ?? "top-left",
|
||||
achievementSoundVolume: Math.round(
|
||||
(userPreferences.achievementSoundVolume ?? 0.15) * 100
|
||||
),
|
||||
}));
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
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 handleChange = async (values: Partial<typeof form>) => {
|
||||
setForm((prev) => ({ ...prev, ...values }));
|
||||
await updateUserPreferences(values);
|
||||
};
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(newVolume: number) => {
|
||||
setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume }));
|
||||
|
||||
if (volumeUpdateTimeoutRef.current) {
|
||||
clearTimeout(volumeUpdateTimeoutRef.current);
|
||||
}
|
||||
|
||||
volumeUpdateTimeoutRef.current = setTimeout(() => {
|
||||
updateUserPreferences({ achievementSoundVolume: newVolume / 100 });
|
||||
}, 300);
|
||||
},
|
||||
[updateUserPreferences]
|
||||
);
|
||||
|
||||
const handleChangeAchievementCustomNotificationPosition = async (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
const value = event.target.value as AchievementCustomNotificationPosition;
|
||||
|
||||
await handleChange({ achievementCustomNotificationPosition: value });
|
||||
window.electron.updateAchievementCustomNotificationWindow();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-achievements">
|
||||
<div className="settings-achievements__section settings-achievements__section--achievements">
|
||||
<CheckboxField
|
||||
label={t("show_hidden_achievement_description")}
|
||||
checked={form.showHiddenAchievementsDescription}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
showHiddenAchievementsDescription:
|
||||
!form.showHiddenAchievementsDescription,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="settings-achievements__checkbox-container--with-tooltip">
|
||||
<CheckboxField
|
||||
label={t("enable_steam_achievements")}
|
||||
checked={form.enableSteamAchievements}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
enableSteamAchievements: !form.enableSteamAchievements,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<small
|
||||
className="settings-achievements__checkbox-container--tooltip"
|
||||
data-open-article="steam-achievements"
|
||||
>
|
||||
<QuestionIcon size={12} />
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="settings-achievements__checkbox-container--with-tooltip">
|
||||
<CheckboxField
|
||||
label={t("enable_achievement_screenshots")}
|
||||
checked={form.enableAchievementScreenshots}
|
||||
disabled={window.electron.platform === "linux"}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
enableAchievementScreenshots:
|
||||
!form.enableAchievementScreenshots,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<small
|
||||
className="settings-achievements__checkbox-container--tooltip"
|
||||
data-open-article="achievement-souvenirs"
|
||||
>
|
||||
<QuestionIcon size={12} />
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="settings-achievements__button-container">
|
||||
<Button
|
||||
theme="outline"
|
||||
disabled={window.electron.platform === "linux"}
|
||||
onClick={async () => {
|
||||
const screenshotsPath =
|
||||
await window.electron.getScreenshotsPath();
|
||||
window.electron.openFolder(screenshotsPath);
|
||||
}}
|
||||
>
|
||||
{t("open_screenshots_directory")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-achievements__section settings-achievements__section--notifications">
|
||||
<h3 className="settings-achievements__section-title">
|
||||
{t("notifications")}
|
||||
</h3>
|
||||
|
||||
<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-achievements__achievement-custom-notification-position__select-variation"
|
||||
label={t("achievement_custom_notification_position")}
|
||||
value={form.achievementCustomNotificationPosition}
|
||||
onChange={handleChangeAchievementCustomNotificationPosition}
|
||||
options={achievementCustomNotificationPositionOptions}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="settings-achievements__test-achievement-notification-button"
|
||||
onClick={() =>
|
||||
window.electron.showAchievementTestNotification()
|
||||
}
|
||||
>
|
||||
{t("test_notification")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.achievementNotificationsEnabled && (
|
||||
<div className="settings-achievements__volume-control">
|
||||
<label htmlFor="achievement-volume">
|
||||
{t("achievement_sound_volume")}
|
||||
</label>
|
||||
<div className="settings-achievements__volume-slider-wrapper">
|
||||
<UnmuteIcon
|
||||
size={16}
|
||||
className="settings-achievements__volume-icon"
|
||||
/>
|
||||
<input
|
||||
id="achievement-volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={form.achievementSoundVolume}
|
||||
onChange={(e) => {
|
||||
const volumePercent = parseInt(e.target.value, 10);
|
||||
if (!isNaN(volumePercent)) {
|
||||
handleVolumeChange(volumePercent);
|
||||
}
|
||||
}}
|
||||
className="settings-achievements__volume-slider"
|
||||
style={
|
||||
{
|
||||
"--volume-percent": `${form.achievementSoundVolume}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<span className="settings-achievements__volume-value">
|
||||
{form.achievementSoundVolume}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,25 +21,4 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__button-container {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// Add spacing between elements in the section
|
||||
> * + * {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CheckboxField } from "@renderer/components";
|
||||
import { useAppSelector } from "@renderer/hooks";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import "./settings-behavior.scss";
|
||||
import { QuestionIcon } from "@primer/octicons-react";
|
||||
|
||||
export function SettingsBehavior() {
|
||||
const userPreferences = useAppSelector(
|
||||
@@ -22,8 +23,10 @@ export function SettingsBehavior() {
|
||||
disableNsfwAlert: false,
|
||||
enableAutoInstall: false,
|
||||
seedAfterDownloadComplete: false,
|
||||
showHiddenAchievementsDescription: false,
|
||||
showDownloadSpeedInMegabytes: false,
|
||||
extractFilesByDefault: true,
|
||||
enableSteamAchievements: false,
|
||||
autoplayGameTrailers: true,
|
||||
hideToTrayOnGameStart: false,
|
||||
enableNewDownloadOptionsBadges: true,
|
||||
@@ -42,9 +45,13 @@ export function SettingsBehavior() {
|
||||
enableAutoInstall: userPreferences.enableAutoInstall ?? false,
|
||||
seedAfterDownloadComplete:
|
||||
userPreferences.seedAfterDownloadComplete ?? false,
|
||||
showHiddenAchievementsDescription:
|
||||
userPreferences.showHiddenAchievementsDescription ?? false,
|
||||
showDownloadSpeedInMegabytes:
|
||||
userPreferences.showDownloadSpeedInMegabytes ?? false,
|
||||
extractFilesByDefault: userPreferences.extractFilesByDefault ?? true,
|
||||
enableSteamAchievements:
|
||||
userPreferences.enableSteamAchievements ?? false,
|
||||
autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true,
|
||||
hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false,
|
||||
enableNewDownloadOptionsBadges:
|
||||
@@ -156,6 +163,17 @@ export function SettingsBehavior() {
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("show_hidden_achievement_description")}
|
||||
checked={form.showHiddenAchievementsDescription}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
showHiddenAchievementsDescription:
|
||||
!form.showHiddenAchievementsDescription,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("show_download_speed_in_megabytes")}
|
||||
checked={form.showDownloadSpeedInMegabytes}
|
||||
@@ -176,6 +194,25 @@ export function SettingsBehavior() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`settings-behavior__checkbox-container--with-tooltip`}>
|
||||
<CheckboxField
|
||||
label={t("enable_steam_achievements")}
|
||||
checked={form.enableSteamAchievements}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
enableSteamAchievements: !form.enableSteamAchievements,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<small
|
||||
className="settings-behavior__checkbox-container--tooltip"
|
||||
data-open-article="steam-achievements"
|
||||
>
|
||||
<QuestionIcon size={12} />
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_new_download_options_badges")}
|
||||
checked={form.enableNewDownloadOptionsBadges}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
@@ -12,8 +19,9 @@ import languageResources from "@locales";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import "./settings-general.scss";
|
||||
import { DesktopDownloadIcon } from "@primer/octicons-react";
|
||||
import { DesktopDownloadIcon, UnmuteIcon } from "@primer/octicons-react";
|
||||
import { logger } from "@renderer/logger";
|
||||
import { AchievementCustomNotificationPosition } from "@types";
|
||||
|
||||
interface LanguageOption {
|
||||
option: string;
|
||||
@@ -38,6 +46,11 @@ export function SettingsGeneral() {
|
||||
repackUpdatesNotificationsEnabled: false,
|
||||
friendRequestNotificationsEnabled: false,
|
||||
friendStartGameNotificationsEnabled: true,
|
||||
achievementNotificationsEnabled: true,
|
||||
achievementCustomNotificationsEnabled: true,
|
||||
achievementCustomNotificationPosition:
|
||||
"top-left" as AchievementCustomNotificationPosition,
|
||||
achievementSoundVolume: 15,
|
||||
language: "",
|
||||
customStyles: window.localStorage.getItem("customStyles") || "",
|
||||
});
|
||||
@@ -46,6 +59,8 @@ export function SettingsGeneral() {
|
||||
|
||||
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
|
||||
|
||||
const volumeUpdateTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getDefaultDownloadsPath().then((path) => {
|
||||
setDefaultDownloadsPath(path);
|
||||
@@ -76,6 +91,9 @@ export function SettingsGeneral() {
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (volumeUpdateTimeoutRef.current) {
|
||||
clearTimeout(volumeUpdateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -99,6 +117,15 @@ export function SettingsGeneral() {
|
||||
userPreferences.downloadNotificationsEnabled ?? false,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
userPreferences.repackUpdatesNotificationsEnabled ?? false,
|
||||
achievementNotificationsEnabled:
|
||||
userPreferences.achievementNotificationsEnabled ?? true,
|
||||
achievementCustomNotificationsEnabled:
|
||||
userPreferences.achievementCustomNotificationsEnabled ?? true,
|
||||
achievementCustomNotificationPosition:
|
||||
userPreferences.achievementCustomNotificationPosition ?? "top-left",
|
||||
achievementSoundVolume: Math.round(
|
||||
(userPreferences.achievementSoundVolume ?? 0.15) * 100
|
||||
),
|
||||
friendRequestNotificationsEnabled:
|
||||
userPreferences.friendRequestNotificationsEnabled ?? false,
|
||||
friendStartGameNotificationsEnabled:
|
||||
@@ -108,6 +135,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>
|
||||
) => {
|
||||
@@ -122,6 +164,31 @@ export function SettingsGeneral() {
|
||||
await updateUserPreferences(values);
|
||||
};
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(newVolume: number) => {
|
||||
setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume }));
|
||||
|
||||
if (volumeUpdateTimeoutRef.current) {
|
||||
clearTimeout(volumeUpdateTimeoutRef.current);
|
||||
}
|
||||
|
||||
volumeUpdateTimeoutRef.current = setTimeout(() => {
|
||||
updateUserPreferences({ achievementSoundVolume: newVolume / 100 });
|
||||
}, 300);
|
||||
},
|
||||
[updateUserPreferences]
|
||||
);
|
||||
|
||||
const handleChangeAchievementCustomNotificationPosition = async (
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
const value = event.target.value as AchievementCustomNotificationPosition;
|
||||
|
||||
await handleChange({ achievementCustomNotificationPosition: value });
|
||||
|
||||
window.electron.updateAchievementCustomNotificationWindow();
|
||||
};
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
defaultPath: form.downloadsPath,
|
||||
@@ -226,6 +293,86 @@ 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.achievementNotificationsEnabled && (
|
||||
<div className="settings-general__volume-control">
|
||||
<label htmlFor="achievement-volume">
|
||||
{t("achievement_sound_volume")}
|
||||
</label>
|
||||
<div className="settings-general__volume-slider-wrapper">
|
||||
<UnmuteIcon size={16} className="settings-general__volume-icon" />
|
||||
<input
|
||||
id="achievement-volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={form.achievementSoundVolume}
|
||||
onChange={(e) => {
|
||||
const volumePercent = parseInt(e.target.value, 10);
|
||||
if (!isNaN(volumePercent)) {
|
||||
handleVolumeChange(volumePercent);
|
||||
}
|
||||
}}
|
||||
className="settings-general__volume-slider"
|
||||
style={
|
||||
{
|
||||
"--volume-percent": `${form.achievementSoundVolume}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<span className="settings-general__volume-value">
|
||||
{form.achievementSoundVolume}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="settings-general__section-title">{t("common_redist")}</h2>
|
||||
|
||||
<p className="settings-general__common-redist-description">
|
||||
|
||||
@@ -2,35 +2,26 @@
|
||||
|
||||
.settings {
|
||||
&__container {
|
||||
padding: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
margin-right: 12px;
|
||||
background-color: globals.$background-color;
|
||||
border: solid 1px globals.$border-color;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&__content {
|
||||
background-color: globals.$background-color;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: calc(globals.$spacing-unit * 3);
|
||||
border: solid 1px globals.$border-color;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0px 0px 15px 0px #000000;
|
||||
border-radius: 8px;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__categories {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||
import { SettingsAchievements } from "./settings-achievements";
|
||||
import {
|
||||
SettingsContextConsumer,
|
||||
SettingsContextProvider,
|
||||
@@ -13,16 +13,6 @@ import { useMemo } from "react";
|
||||
import "./settings.scss";
|
||||
import { SettingsAppearance } from "./aparence/settings-appearance";
|
||||
import { SettingsDebrid } from "./settings-debrid";
|
||||
import cn from "classnames";
|
||||
import {
|
||||
GearIcon,
|
||||
ToolsIcon,
|
||||
TrophyIcon,
|
||||
DownloadIcon,
|
||||
PaintbrushIcon,
|
||||
CloudIcon,
|
||||
PersonIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -31,34 +21,20 @@ export default function Settings() {
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const categories = [
|
||||
{ tabLabel: t("general"), contentTitle: t("general"), Icon: GearIcon },
|
||||
{ tabLabel: t("behavior"), contentTitle: t("behavior"), Icon: ToolsIcon },
|
||||
{
|
||||
tabLabel: t("achievements"),
|
||||
contentTitle: t("achievements"),
|
||||
Icon: TrophyIcon,
|
||||
},
|
||||
{
|
||||
tabLabel: t("download_sources"),
|
||||
contentTitle: t("download_sources"),
|
||||
Icon: DownloadIcon,
|
||||
},
|
||||
{ tabLabel: t("general"), contentTitle: t("general") },
|
||||
{ tabLabel: t("behavior"), contentTitle: t("behavior") },
|
||||
{ tabLabel: t("download_sources"), contentTitle: t("download_sources") },
|
||||
{
|
||||
tabLabel: t("appearance"),
|
||||
contentTitle: t("appearance"),
|
||||
Icon: PaintbrushIcon,
|
||||
},
|
||||
{ tabLabel: t("debrid"), contentTitle: t("debrid"), Icon: CloudIcon },
|
||||
{ tabLabel: t("debrid"), contentTitle: t("debrid") },
|
||||
];
|
||||
|
||||
if (userDetails)
|
||||
return [
|
||||
...categories,
|
||||
{
|
||||
tabLabel: t("account"),
|
||||
contentTitle: t("account"),
|
||||
Icon: PersonIcon,
|
||||
},
|
||||
{ tabLabel: t("account"), contentTitle: t("account") },
|
||||
];
|
||||
return categories;
|
||||
}, [userDetails, t]);
|
||||
@@ -77,18 +53,14 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 2) {
|
||||
return <SettingsAchievements />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 3) {
|
||||
return <SettingsDownloadSources />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 4) {
|
||||
if (currentCategoryIndex === 3) {
|
||||
return <SettingsAppearance appearance={appearance} />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 5) {
|
||||
if (currentCategoryIndex === 4) {
|
||||
return <SettingsDebrid />;
|
||||
}
|
||||
|
||||
@@ -97,32 +69,21 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<section className="settings__container">
|
||||
<aside className="settings__sidebar">
|
||||
<ul className="settings__categories sidebar__menu">
|
||||
{categories.map((category, index) => (
|
||||
<li
|
||||
key={category.contentTitle}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
currentCategoryIndex === index,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={() => setCurrentCategoryIndex(index)}
|
||||
>
|
||||
<category.Icon size={16} />
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{category.tabLabel}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div className="settings__content">
|
||||
<section className="settings__categories">
|
||||
{categories.map((category, index) => (
|
||||
<Button
|
||||
key={category.contentTitle}
|
||||
theme={
|
||||
currentCategoryIndex === index ? "primary" : "outline"
|
||||
}
|
||||
onClick={() => setCurrentCategoryIndex(index)}
|
||||
>
|
||||
{category.tabLabel}
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<h2>{categories[currentCategoryIndex].contentTitle}</h2>
|
||||
{renderCategory()}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ export enum Downloader {
|
||||
Torrent,
|
||||
Gofile,
|
||||
PixelDrain,
|
||||
Qiwi,
|
||||
Datanodes,
|
||||
Mediafire,
|
||||
TorBox,
|
||||
@@ -11,6 +10,7 @@ export enum Downloader {
|
||||
Buzzheavier,
|
||||
FuckingFast,
|
||||
VikingFile,
|
||||
Rootz,
|
||||
}
|
||||
|
||||
export enum DownloadSourceStatus {
|
||||
|
||||
@@ -110,7 +110,6 @@ export const getDownloadersForUri = (uri: string) => {
|
||||
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
|
||||
|
||||
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain];
|
||||
if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi];
|
||||
if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes];
|
||||
if (uri.startsWith("https://www.mediafire.com"))
|
||||
return [Downloader.Mediafire];
|
||||
@@ -127,6 +126,9 @@ export const getDownloadersForUri = (uri: string) => {
|
||||
if (uri.startsWith("https://vikingfile.com")) {
|
||||
return [Downloader.VikingFile];
|
||||
}
|
||||
if (uri.startsWith("https://www.rootz.so")) {
|
||||
return [Downloader.Rootz];
|
||||
}
|
||||
|
||||
if (realDebridHosts.some((host) => uri.startsWith(host)))
|
||||
return [Downloader.RealDebrid];
|
||||
|
||||
@@ -5,8 +5,6 @@ export type ShortcutLocation = "desktop" | "start_menu";
|
||||
export interface UnlockedAchievement {
|
||||
name: string;
|
||||
unlockTime: number;
|
||||
imageKey?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SteamAchievement {
|
||||
@@ -22,5 +20,4 @@ export interface SteamAchievement {
|
||||
export interface UserAchievement extends SteamAchievement {
|
||||
unlocked: boolean;
|
||||
unlockTime: number | null;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
@@ -191,25 +191,11 @@ export interface UserDetails {
|
||||
workwondersJwt: string;
|
||||
subscription: Subscription | null;
|
||||
karma: number;
|
||||
achievements: ProfileAchievement[] | null;
|
||||
quirks?: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProfileAchievement {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
imageUrl: string;
|
||||
unlockTime: number;
|
||||
gameTitle: string;
|
||||
gameIconUrl: string | null;
|
||||
achievementIcon: string | null;
|
||||
gameId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -226,7 +212,6 @@ export interface UserProfile {
|
||||
bio: string;
|
||||
hasActiveSubscription: boolean;
|
||||
karma: number;
|
||||
achievements: ProfileAchievement[] | null;
|
||||
quirks: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
|
||||
@@ -89,8 +89,7 @@ export interface GameAchievement {
|
||||
achievements: SteamAchievement[];
|
||||
unlockedAchievements: UnlockedAchievement[];
|
||||
updatedAt: number | undefined;
|
||||
imageUrl?: string | null;
|
||||
language?: string;
|
||||
language: string | undefined;
|
||||
}
|
||||
|
||||
export type AchievementCustomNotificationPosition =
|
||||
@@ -126,7 +125,6 @@ export interface UserPreferences {
|
||||
showDownloadSpeedInMegabytes?: boolean;
|
||||
extractFilesByDefault?: boolean;
|
||||
enableSteamAchievements?: boolean;
|
||||
enableAchievementScreenshots?: boolean;
|
||||
autoplayGameTrailers?: boolean;
|
||||
hideToTrayOnGameStart?: boolean;
|
||||
enableNewDownloadOptionsBadges?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user