mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 16:53:57 +00:00
feat: displaying recent achievements on profile
This commit is contained in:
@@ -521,6 +521,7 @@
|
||||
"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",
|
||||
"achievement_custom_notification_position": "Achievement custom notification position",
|
||||
"top-left": "Top left",
|
||||
"top-center": "Top center",
|
||||
@@ -585,6 +586,7 @@
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"last_time_played": "Last played {{period}}",
|
||||
"activity": "Recent Activity",
|
||||
"souvenirs": "Souvenirs",
|
||||
"library": "Library",
|
||||
"pinned": "Pinned",
|
||||
"sort_by": "Sort by:",
|
||||
|
||||
158
src/main/events/achievements/upload-achievement-image.ts
Normal file
158
src/main/events/achievements/upload-achievement-image.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { fileTypeFromFile } from "file-type";
|
||||
import { HydraApi } from "@main/services/hydra-api";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gameAchievementsSublevel, levelKeys, db } from "@main/level";
|
||||
import { logger } from "@main/services/logger";
|
||||
import type { GameShop, User } from "@types";
|
||||
|
||||
/**
|
||||
* Uploads an achievement image to CDN using presigned URL
|
||||
*/
|
||||
const uploadImageToCDN = async (imagePath: string): Promise<string> => {
|
||||
const stat = fs.statSync(imagePath);
|
||||
const fileBuffer = fs.readFileSync(imagePath);
|
||||
const fileSizeInBytes = stat.size;
|
||||
|
||||
// Get presigned URL for achievement image
|
||||
const response = await HydraApi.post<{
|
||||
presignedUrl: string;
|
||||
achievementImageUrl: string;
|
||||
}>("/presigned-urls/achievement-image", {
|
||||
imageExt: path.extname(imagePath).slice(1),
|
||||
imageLength: fileSizeInBytes,
|
||||
});
|
||||
|
||||
const mimeType = await fileTypeFromFile(imagePath);
|
||||
|
||||
// Upload to CDN
|
||||
await axios.put(response.presignedUrl, fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": mimeType?.mime,
|
||||
},
|
||||
});
|
||||
|
||||
return response.achievementImageUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores achievement image locally in the database
|
||||
*/
|
||||
const storeImageLocally = async (imagePath: string): Promise<string> => {
|
||||
const fileBuffer = fs.readFileSync(imagePath);
|
||||
const base64Image = fileBuffer.toString('base64');
|
||||
const mimeType = await fileTypeFromFile(imagePath);
|
||||
|
||||
// Create a data URL for local storage
|
||||
return `data:${mimeType?.mime || 'image/jpeg'};base64,${base64Image}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the achievement with the image URL via API
|
||||
*/
|
||||
const updateAchievementWithImageUrl = async (
|
||||
shop: GameShop,
|
||||
gameId: string,
|
||||
achievementName: string,
|
||||
imageUrl: string
|
||||
): Promise<void> => {
|
||||
await HydraApi.patch(
|
||||
`/profile/games/achievements/${shop}/${gameId}/${achievementName}/image`,
|
||||
{ achievementImageUrl: imageUrl }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function for uploading achievement images (called from mergeAchievements)
|
||||
*/
|
||||
export const uploadAchievementImage = async (
|
||||
gameId: string,
|
||||
achievementName: string,
|
||||
imagePath: string,
|
||||
shop?: GameShop
|
||||
): Promise<{ success: boolean; imageUrl: string }> => {
|
||||
try {
|
||||
let imageUrl: string;
|
||||
|
||||
// Check if user has active subscription
|
||||
const hasSubscription = await db
|
||||
.get<string, User>(levelKeys.user, { valueEncoding: "json" })
|
||||
.then((user) => {
|
||||
const expiresAt = new Date(user?.subscription?.expiresAt ?? 0);
|
||||
return expiresAt > new Date();
|
||||
})
|
||||
.catch(() => false);
|
||||
|
||||
if (hasSubscription) {
|
||||
// Upload to CDN and update via API
|
||||
imageUrl = await uploadImageToCDN(imagePath);
|
||||
if (shop) {
|
||||
await updateAchievementWithImageUrl(shop, gameId, achievementName, imageUrl);
|
||||
}
|
||||
logger.log(`Achievement image uploaded to CDN for ${gameId}:${achievementName}`);
|
||||
} else {
|
||||
// Store locally
|
||||
imageUrl = await 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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* IPC event handler for uploading achievement images
|
||||
*/
|
||||
const uploadAchievementImageEvent = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
params: {
|
||||
imagePath: string;
|
||||
gameId: string;
|
||||
achievementName: string;
|
||||
shop: GameShop;
|
||||
}
|
||||
) => {
|
||||
const { imagePath, gameId, achievementName, shop } = params;
|
||||
|
||||
try {
|
||||
const result = await uploadAchievementImage(gameId, achievementName, imagePath, shop);
|
||||
|
||||
// Update local database with image URL
|
||||
const achievementKey = levelKeys.game(shop, gameId);
|
||||
const existingData = await gameAchievementsSublevel.get(achievementKey).catch(() => null);
|
||||
|
||||
if (existingData) {
|
||||
await gameAchievementsSublevel.put(achievementKey, {
|
||||
...existingData,
|
||||
achievementImageUrl: result.imageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up the temporary screenshot file
|
||||
try {
|
||||
fs.unlinkSync(imagePath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup screenshot file ${imagePath}:`, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
// Clean up the temporary screenshot file even on error
|
||||
try {
|
||||
fs.unlinkSync(imagePath);
|
||||
} catch (cleanupError) {
|
||||
logger.error(`Failed to cleanup screenshot file ${imagePath}:`, cleanupError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("uploadAchievementImage", uploadAchievementImageEvent);
|
||||
@@ -3,6 +3,8 @@ 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,
|
||||
@@ -31,6 +33,30 @@ export const getUnlockedAchievements = async (
|
||||
|
||||
const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? [];
|
||||
|
||||
// Try to get user achievements with image URLs from remote API
|
||||
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 API call fails, continue with local data only
|
||||
if (!(error instanceof UserNotLoggedInError)) {
|
||||
console.warn("Failed to fetch remote user achievements:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return achievementsData
|
||||
.map((achievementData) => {
|
||||
const unlockedAchievementData = unlockedAchievements.find(
|
||||
@@ -42,6 +68,16 @@ 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;
|
||||
@@ -51,6 +87,7 @@ export const getUnlockedAchievements = async (
|
||||
...achievementData,
|
||||
unlocked: true,
|
||||
unlockTime: unlockedAchievementData.unlockTime,
|
||||
achievementImageUrl: remoteAchievementData?.achievementImageUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +100,7 @@ export const getUnlockedAchievements = async (
|
||||
!achievementData.hidden || showHiddenAchievementsDescription
|
||||
? achievementData.description
|
||||
: undefined,
|
||||
achievementImageUrl: remoteAchievementData?.achievementImageUrl || null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
|
||||
const isRareAchievement = (points: number) => {
|
||||
const rawPercentage = (50 - Math.sqrt(points)) * 2;
|
||||
@@ -103,6 +104,8 @@ export const mergeAchievements = async (
|
||||
publishNotification &&
|
||||
userPreferences.achievementNotificationsEnabled !== false
|
||||
) {
|
||||
|
||||
|
||||
const filteredAchievements = newAchievements
|
||||
.toSorted((a, b) => {
|
||||
return a.unlockTime - b.unlockTime;
|
||||
@@ -171,22 +174,57 @@ export const mergeAchievements = async (
|
||||
},
|
||||
{ needsSubscription: !newAchievements.length }
|
||||
)
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (response) {
|
||||
return saveAchievementsOnLocal(
|
||||
await saveAchievementsOnLocal(
|
||||
response.objectId,
|
||||
response.shop,
|
||||
response.achievements,
|
||||
publishNotification
|
||||
);
|
||||
} else {
|
||||
await saveAchievementsOnLocal(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
mergedLocalAchievements,
|
||||
publishNotification
|
||||
);
|
||||
}
|
||||
|
||||
return saveAchievementsOnLocal(
|
||||
game.objectId,
|
||||
game.shop,
|
||||
mergedLocalAchievements,
|
||||
publishNotification
|
||||
);
|
||||
// Capture and upload screenshot AFTER achievements are synced to server
|
||||
if (newAchievements.length && userPreferences.enableAchievementScreenshots === true) {
|
||||
try {
|
||||
// Import and trigger the upload process
|
||||
const { uploadAchievementImage } = await import("@main/events/achievements/upload-achievement-image");
|
||||
|
||||
// Upload the screenshot for each new achievement
|
||||
for (const achievement of newAchievements) {
|
||||
try {
|
||||
// Find the achievement data to get the display name
|
||||
const achievementData = achievementsData.find((steamAchievement) => {
|
||||
return (
|
||||
achievement.name.toUpperCase() ===
|
||||
steamAchievement.name.toUpperCase()
|
||||
);
|
||||
});
|
||||
|
||||
const achievementDisplayName = achievementData?.displayName || achievement.name;
|
||||
|
||||
// Capture screenshot with game title and achievement name
|
||||
const screenshotPath = await ScreenshotService.captureDesktopScreenshot(
|
||||
game.title,
|
||||
achievementDisplayName
|
||||
);
|
||||
|
||||
await uploadAchievementImage(game.objectId, achievement.name, screenshotPath, game.shop);
|
||||
} catch (error) {
|
||||
achievementsLogger.error("Failed to upload achievement image", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
achievementsLogger.error("Failed to capture screenshot for achievement", error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof SubscriptionRequiredError) {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
|
||||
190
src/main/services/screenshot.ts
Normal file
190
src/main/services/screenshot.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { desktopCapturer, nativeImage } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export class ScreenshotService {
|
||||
private static readonly SCREENSHOT_QUALITY = 60; // Reduced for better compression
|
||||
private static readonly SCREENSHOT_FORMAT = "jpeg";
|
||||
private static readonly MAX_WIDTH = 1280; // Maximum width for compression
|
||||
private static readonly MAX_HEIGHT = 720; // Maximum height for compression
|
||||
|
||||
|
||||
/**
|
||||
* Compresses an image by resizing and adjusting quality
|
||||
*/
|
||||
private static compressImage(image: Electron.NativeImage): Electron.NativeImage {
|
||||
const size = image.getSize();
|
||||
|
||||
// Calculate new dimensions while maintaining aspect ratio
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Resize the image if dimensions changed
|
||||
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 {
|
||||
// Get all available desktop sources
|
||||
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");
|
||||
}
|
||||
|
||||
// Use the primary screen (first source)
|
||||
const primaryScreen = sources[0];
|
||||
|
||||
// Convert the thumbnail to a higher quality image
|
||||
const originalImage = nativeImage.createFromDataURL(primaryScreen.thumbnail.toDataURL());
|
||||
|
||||
// Compress the image to reduce file size
|
||||
const compressedImage = this.compressImage(originalImage);
|
||||
|
||||
// Create screenshots directory structure
|
||||
const userDataPath = app.getPath("userData");
|
||||
const screenshotsDir = path.join(userDataPath, "screenshots");
|
||||
|
||||
let finalDir = screenshotsDir;
|
||||
let filename: string;
|
||||
|
||||
if (gameTitle && achievementName) {
|
||||
// Create game-specific directory
|
||||
const sanitizedGameTitle = gameTitle.replace(/[<>:"/\\|?*]/g, '_');
|
||||
const gameDir = path.join(screenshotsDir, sanitizedGameTitle);
|
||||
finalDir = gameDir;
|
||||
|
||||
// Use achievement name as filename (sanitized)
|
||||
const sanitizedAchievementName = achievementName.replace(/[<>:"/\\|?*]/g, '_');
|
||||
filename = `${sanitizedAchievementName}.${this.SCREENSHOT_FORMAT}`;
|
||||
} else {
|
||||
// Fallback to timestamp-based naming
|
||||
const timestamp = Date.now();
|
||||
filename = `achievement_screenshot_${timestamp}.${this.SCREENSHOT_FORMAT}`;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(finalDir)) {
|
||||
fs.mkdirSync(finalDir, { recursive: true });
|
||||
}
|
||||
|
||||
const screenshotPath = path.join(finalDir, filename);
|
||||
|
||||
// Save the compressed screenshot as JPEG with specified quality
|
||||
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;
|
||||
}
|
||||
|
||||
// Get all files recursively from screenshots directory and subdirectories
|
||||
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()) {
|
||||
// Recursively get files from subdirectories
|
||||
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());
|
||||
|
||||
// Keep only the 50 most recent screenshots (increased from 10 to accommodate multiple games)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty directories
|
||||
const cleanupEmptyDirs = (dir: string) => {
|
||||
if (dir === screenshotsDir) return; // Don't delete the main screenshots directory
|
||||
|
||||
try {
|
||||
const items = fs.readdirSync(dir);
|
||||
if (items.length === 0) {
|
||||
fs.rmdirSync(dir);
|
||||
logger.log(`Cleaned up empty directory: ${dir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might not be empty or might not exist, ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Check for empty game directories and clean them up
|
||||
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,4 +1,4 @@
|
||||
import { User, type ProfileVisibility, type UserDetails } from "@types";
|
||||
import { User, type ProfileVisibility, type UserDetails, type UserPreferences } from "@types";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import { logger } from "../logger";
|
||||
@@ -6,7 +6,26 @@ import { db } from "@main/level";
|
||||
import { levelKeys } from "@main/level/sublevels";
|
||||
|
||||
export const getUserData = async () => {
|
||||
return HydraApi.get<UserDetails>(`/profile/me`)
|
||||
// Get user language preference for API call
|
||||
let language = "en"; // Default fallback
|
||||
try {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
if (userPreferences?.language) {
|
||||
// Map supported languages (pt, ru, es) or fallback to en
|
||||
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()}`)
|
||||
.then(async (me) => {
|
||||
try {
|
||||
const user = await db.get<string, User>(levelKeys.user, {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
.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;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: 0;
|
||||
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;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile responsiveness
|
||||
@media (max-width: 768px) {
|
||||
.fullscreen-image-modal {
|
||||
&__close-button {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__container {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
padding: 60px 20px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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,
|
||||
}: 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;
|
||||
|
||||
const handleBackdropClick = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fullscreen-image-modal" onClick={handleBackdropClick}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FullscreenImageModal } from "./fullscreen-image-modal";
|
||||
@@ -131,8 +131,15 @@ export function UserProfileContextProvider({
|
||||
getUserStats();
|
||||
getUserLibraryGames();
|
||||
|
||||
// Get current language for API call
|
||||
const currentLanguage = i18n.language.split("-")[0];
|
||||
const supportedLanguages = ["pt", "ru", "es"];
|
||||
const language = supportedLanguages.includes(currentLanguage) ? currentLanguage : "en";
|
||||
|
||||
const params = new URLSearchParams({ language });
|
||||
|
||||
return window.electron.hydraApi
|
||||
.get<UserProfile>(`/users/${userId}`)
|
||||
.get<UserProfile>(`/users/${userId}?${params.toString()}`)
|
||||
.then((userProfile) => {
|
||||
setUserProfile(userProfile);
|
||||
|
||||
@@ -146,7 +153,7 @@ export function UserProfileContextProvider({
|
||||
showErrorToast(t("user_not_found"));
|
||||
navigate(-1);
|
||||
});
|
||||
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
|
||||
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t, i18n]);
|
||||
|
||||
const getBadges = useCallback(async () => {
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useDate } from "@renderer/hooks";
|
||||
import type { UserAchievement } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./achievements.scss";
|
||||
import { EyeClosedIcon } from "@primer/octicons-react";
|
||||
import { EyeClosedIcon, SearchIcon } 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[];
|
||||
@@ -16,17 +18,34 @@ 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">
|
||||
<img
|
||||
className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="achievements__item-content">
|
||||
<h4 className="achievements__item-title">
|
||||
@@ -44,6 +63,24 @@ export function AchievementList({
|
||||
</div>
|
||||
|
||||
<div className="achievements__item-meta">
|
||||
{achievement.achievementImageUrl && achievement.unlocked && (
|
||||
<div className="achievements__item-image-container">
|
||||
<div className="achievements__item-custom-image-wrapper">
|
||||
<img
|
||||
className="achievements__item-custom-image"
|
||||
src={achievement.achievementImageUrl}
|
||||
alt={`${achievement.displayName} screenshot`}
|
||||
loading="lazy"
|
||||
onClick={() => handleImageClick(achievement.achievementImageUrl!, achievement.displayName)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<div className="achievements__item-custom-image-overlay">
|
||||
<SearchIcon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{achievement.points != undefined ? (
|
||||
<div
|
||||
className="achievements__item-points"
|
||||
@@ -66,6 +103,7 @@ export function AchievementList({
|
||||
<p className="achievements__item-points-value">???</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{achievement.unlockTime != null && (
|
||||
<div
|
||||
className="achievements__item-unlock-time"
|
||||
@@ -79,6 +117,13 @@ export function AchievementList({
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<FullscreenImageModal
|
||||
isOpen={fullscreenImage !== null}
|
||||
imageUrl={fullscreenImage?.url || ""}
|
||||
imageAlt={fullscreenImage?.alt || ""}
|
||||
onClose={closeFullscreenImage}
|
||||
/>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
|
||||
className="achievements-content__profile-avatar"
|
||||
src={user.profileImageUrl}
|
||||
alt={user.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
@@ -150,6 +151,7 @@ export function AchievementsContent({
|
||||
className="achievements-content__comparison__small-avatar"
|
||||
src={user.profileImageUrl}
|
||||
alt={user.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={24} />
|
||||
@@ -166,6 +168,7 @@ export function AchievementsContent({
|
||||
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
|
||||
className="achievements-content__achievements-list__image"
|
||||
alt={gameTitle}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<section
|
||||
@@ -186,6 +189,7 @@ 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,6 +117,70 @@ $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;
|
||||
}
|
||||
@@ -153,6 +217,7 @@ $logo-max-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
&-points {
|
||||
|
||||
@@ -114,6 +114,7 @@ export function Sidebar() {
|
||||
}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
@@ -164,6 +165,7 @@ export function Sidebar() {
|
||||
}`}
|
||||
src={achievement.icon}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
|
||||
@@ -176,4 +176,216 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__souvenirs-section {
|
||||
margin-bottom: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__souvenirs-grid {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding-bottom: calc(globals.$spacing-unit);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__souvenir-card {
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
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;
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
|
||||
&__souvenir-card-header {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__souvenir-achievement-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:hover .profile-content__souvenir-achievement-image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__souvenir-achievement-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
|
||||
}
|
||||
|
||||
&__souvenir-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;
|
||||
}
|
||||
}
|
||||
|
||||
&__souvenir-card-content {
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__souvenir-card-gradient-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(27, 59, 52, 0.6) 1%,
|
||||
transparent 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__souvenir-achievement-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__souvenir-achievement-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__souvenir-achievement-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&__souvenir-game-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__souvenir-game-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 0.75);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__souvenir-game-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__souvenir-game-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__souvenir-unlock-time {
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
&__souvenir-item {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&__souvenir-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||
import { useAppDispatch, useFormat, useDate } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||
import { TelescopeIcon, ChevronRightIcon, SearchIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LockedProfile } from "./locked-profile";
|
||||
import { ReportProfile } from "../report-profile/report-profile";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
chevronVariants,
|
||||
GAME_STATS_ANIMATION_DURATION_IN_MS,
|
||||
} from "./profile-animations";
|
||||
import { FullscreenImageModal } from "@renderer/components/fullscreen-image-modal";
|
||||
import "./profile-content.scss";
|
||||
|
||||
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
||||
@@ -36,6 +37,10 @@ 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 { toggleSection, isPinnedCollapsed } = useSectionCollapse();
|
||||
|
||||
@@ -65,6 +70,17 @@ 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;
|
||||
@@ -87,6 +103,7 @@ export function ProfileContent() {
|
||||
}, [setStatsIndex, isAnimationRunning]);
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
const { formatDateTime } = useDate();
|
||||
|
||||
const usersAreFriends = useMemo(() => {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
@@ -113,10 +130,6 @@ export function ProfileContent() {
|
||||
return (
|
||||
<section className="profile-content__section">
|
||||
<div className="profile-content__main">
|
||||
{hasAnyGames && (
|
||||
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
|
||||
)}
|
||||
|
||||
{!hasAnyGames && (
|
||||
<div className="profile-content__no-games">
|
||||
<div className="profile-content__telescope-icon">
|
||||
@@ -189,6 +202,84 @@ export function ProfileContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userProfile?.achievements && userProfile.achievements.length > 0 && (
|
||||
<div className="profile-content__souvenirs-section">
|
||||
<div className="profile-content__section-header">
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("souvenirs")}</h2>
|
||||
<span className="profile-content__section-badge">
|
||||
{userProfile.achievements.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-content__souvenirs-grid">
|
||||
{userProfile.achievements.map((achievement, index) => (
|
||||
<div
|
||||
key={`${achievement.gameTitle}-${achievement.name}-${index}`}
|
||||
className="profile-content__souvenir-card"
|
||||
>
|
||||
<div className="profile-content__souvenir-card-header">
|
||||
<div className="profile-content__souvenir-achievement-image-wrapper">
|
||||
<img
|
||||
src={achievement.achievementImageUrl}
|
||||
alt={achievement.name}
|
||||
className="profile-content__souvenir-achievement-image"
|
||||
loading="lazy"
|
||||
onClick={() => handleImageClick(achievement.achievementImageUrl, achievement.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<div className="profile-content__souvenir-achievement-image-overlay">
|
||||
<SearchIcon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-content__souvenir-card-content">
|
||||
<div className="profile-content__souvenir-achievement-info">
|
||||
<img
|
||||
src={achievement.achievementIcon}
|
||||
alt=""
|
||||
className="profile-content__souvenir-achievement-icon"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="profile-content__souvenir-achievement-name">
|
||||
{achievement.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-content__souvenir-game-info">
|
||||
<div className="profile-content__souvenir-game-left">
|
||||
<img
|
||||
src={achievement.gameIconUrl}
|
||||
alt=""
|
||||
className="profile-content__souvenir-game-icon"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="profile-content__souvenir-game-title">
|
||||
{achievement.gameTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{achievement.unlockTime && (
|
||||
<div className="profile-content__souvenir-unlock-time">
|
||||
<small>{formatDateTime(achievement.unlockTime)}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-content__souvenir-card-gradient-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyGames && (
|
||||
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
|
||||
)}
|
||||
|
||||
{hasGames && (
|
||||
<div>
|
||||
<div className="profile-content__section-header">
|
||||
@@ -252,6 +343,13 @@ export function ProfileContent() {
|
||||
<ProfileHero />
|
||||
|
||||
{content}
|
||||
|
||||
<FullscreenImageModal
|
||||
isOpen={fullscreenImage !== null}
|
||||
imageUrl={fullscreenImage?.url || ""}
|
||||
imageAlt={fullscreenImage?.alt || ""}
|
||||
onClose={closeFullscreenImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function SettingsBehavior() {
|
||||
showDownloadSpeedInMegabytes: false,
|
||||
extractFilesByDefault: true,
|
||||
enableSteamAchievements: false,
|
||||
enableAchievementScreenshots: false,
|
||||
});
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -49,6 +50,8 @@ export function SettingsBehavior() {
|
||||
extractFilesByDefault: userPreferences.extractFilesByDefault ?? true,
|
||||
enableSteamAchievements:
|
||||
userPreferences.enableSteamAchievements ?? false,
|
||||
enableAchievementScreenshots:
|
||||
userPreferences.enableAchievementScreenshots ?? false,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
@@ -187,6 +190,16 @@ export function SettingsBehavior() {
|
||||
<QuestionIcon size={12} />
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_achievement_screenshots")}
|
||||
checked={form.enableAchievementScreenshots}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
enableAchievementScreenshots: !form.enableAchievementScreenshots,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,4 +20,5 @@ export interface SteamAchievement {
|
||||
export interface UserAchievement extends SteamAchievement {
|
||||
unlocked: boolean;
|
||||
unlockTime: number | null;
|
||||
achievementImageUrl?: string | null;
|
||||
}
|
||||
|
||||
@@ -188,11 +188,21 @@ export interface UserDetails {
|
||||
featurebaseJwt: string;
|
||||
subscription: Subscription | null;
|
||||
karma: number;
|
||||
achievements: ProfileAchievement[] | null;
|
||||
quirks?: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProfileAchievement {
|
||||
name: string;
|
||||
achievementImageUrl: string;
|
||||
unlockTime: number;
|
||||
gameTitle: string;
|
||||
gameIconUrl: string;
|
||||
achievementIcon: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -209,6 +219,7 @@ export interface UserProfile {
|
||||
bio: string;
|
||||
hasActiveSubscription: boolean;
|
||||
karma: number;
|
||||
achievements: ProfileAchievement[] | null;
|
||||
quirks: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface GameAchievement {
|
||||
achievements: SteamAchievement[];
|
||||
unlockedAchievements: UnlockedAchievement[];
|
||||
updatedAt: number | undefined;
|
||||
achievementImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export type AchievementCustomNotificationPosition =
|
||||
@@ -117,6 +118,7 @@ export interface UserPreferences {
|
||||
showDownloadSpeedInMegabytes?: boolean;
|
||||
extractFilesByDefault?: boolean;
|
||||
enableSteamAchievements?: boolean;
|
||||
enableAchievementScreenshots?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenState {
|
||||
|
||||
Reference in New Issue
Block a user