feat: displaying recent achievements on profile

This commit is contained in:
Moyasee
2025-10-20 01:42:50 +03:00
parent 97b27a1785
commit cc9d98c360
21 changed files with 1104 additions and 25 deletions

View File

@@ -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:",

View 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);

View File

@@ -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) => {

View File

@@ -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) {

View File

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

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

View File

@@ -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, {

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { FullscreenImageModal } from "./fullscreen-image-modal";

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,4 +20,5 @@ export interface SteamAchievement {
export interface UserAchievement extends SteamAchievement {
unlocked: boolean;
unlockTime: number | null;
achievementImageUrl?: string | null;
}

View File

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

View File

@@ -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 {