diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index bf2793c5..55931f2f 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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:", diff --git a/src/main/events/achievements/upload-achievement-image.ts b/src/main/events/achievements/upload-achievement-image.ts new file mode 100644 index 00000000..73f4bbf5 --- /dev/null +++ b/src/main/events/achievements/upload-achievement-image.ts @@ -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 => { + 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 => { + 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 => { + 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(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); \ No newline at end of file diff --git a/src/main/events/user/get-unlocked-achievements.ts b/src/main/events/user/get-unlocked-achievements.ts index 9e6f044d..be0ce61e 100644 --- a/src/main/events/user/get-unlocked-achievements.ts +++ b/src/main/events/user/get-unlocked-achievements.ts @@ -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(levelKeys.user, { + valueEncoding: "json", + }); + + if (userDetails?.id) { + remoteUserAchievements = await HydraApi.get( + `/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) => { diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index f2ea03ac..9162ddc4 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -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) { diff --git a/src/main/services/index.ts b/src/main/services/index.ts index c98f09e1..c6acb18d 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -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"; diff --git a/src/main/services/screenshot.ts b/src/main/services/screenshot.ts new file mode 100644 index 00000000..907d945a --- /dev/null +++ b/src/main/services/screenshot.ts @@ -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 { + 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 { + 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); + } + } +} \ No newline at end of file diff --git a/src/main/services/user/get-user-data.ts b/src/main/services/user/get-user-data.ts index 6bf3fffc..26e7a1a3 100644 --- a/src/main/services/user/get-user-data.ts +++ b/src/main/services/user/get-user-data.ts @@ -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(`/profile/me`) + // Get user language preference for API call + let language = "en"; // Default fallback + try { + const userPreferences = await db.get( + 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(`/profile/me?${params.toString()}`) .then(async (me) => { try { const user = await db.get(levelKeys.user, { diff --git a/src/renderer/src/components/fullscreen-image-modal/fullscreen-image-modal.scss b/src/renderer/src/components/fullscreen-image-modal/fullscreen-image-modal.scss new file mode 100644 index 00000000..3cd2fb24 --- /dev/null +++ b/src/renderer/src/components/fullscreen-image-modal/fullscreen-image-modal.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/src/renderer/src/components/fullscreen-image-modal/fullscreen-image-modal.tsx b/src/renderer/src/components/fullscreen-image-modal/fullscreen-image-modal.tsx new file mode 100644 index 00000000..43bcb154 --- /dev/null +++ b/src/renderer/src/components/fullscreen-image-modal/fullscreen-image-modal.tsx @@ -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 ( +
+
+ + +
+ {imageAlt} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/renderer/src/components/fullscreen-image-modal/index.ts b/src/renderer/src/components/fullscreen-image-modal/index.ts new file mode 100644 index 00000000..48f84547 --- /dev/null +++ b/src/renderer/src/components/fullscreen-image-modal/index.ts @@ -0,0 +1 @@ +export { FullscreenImageModal } from "./fullscreen-image-modal"; \ No newline at end of file diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 87e2a669..215ce600 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -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(`/users/${userId}`) + .get(`/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]; diff --git a/src/renderer/src/pages/achievements/achievement-list.tsx b/src/renderer/src/pages/achievements/achievement-list.tsx index 8862ce48..def100b6 100644 --- a/src/renderer/src/pages/achievements/achievement-list.tsx +++ b/src/renderer/src/pages/achievements/achievement-list.tsx @@ -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 (
    {achievements.map((achievement) => (
  • - {achievement.displayName} +
    + {achievement.displayName} +

    @@ -44,6 +63,24 @@ export function AchievementList({

    + {achievement.achievementImageUrl && achievement.unlocked && ( +
    +
    + {`${achievement.displayName} handleImageClick(achievement.achievementImageUrl!, achievement.displayName)} + style={{ cursor: 'pointer' }} + /> +
    + +
    +
    +
    + )} + {achievement.points != undefined ? (
    ???

    )} + {achievement.unlockTime != null && (
  • ))} + +
); } diff --git a/src/renderer/src/pages/achievements/achievements-content.tsx b/src/renderer/src/pages/achievements/achievements-content.tsx index ab50f2f1..7cbd5ba4 100644 --- a/src/renderer/src/pages/achievements/achievements-content.tsx +++ b/src/renderer/src/pages/achievements/achievements-content.tsx @@ -50,6 +50,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) { className="achievements-content__profile-avatar" src={user.profileImageUrl} alt={user.displayName} + loading="lazy" /> ) : ( @@ -150,6 +151,7 @@ export function AchievementsContent({ className="achievements-content__comparison__small-avatar" src={user.profileImageUrl} alt={user.displayName} + loading="lazy" /> ) : ( @@ -166,6 +168,7 @@ export function AchievementsContent({ src={shopDetails?.assets?.libraryHeroImageUrl ?? ""} className="achievements-content__achievements-list__image" alt={gameTitle} + loading="lazy" />
diff --git a/src/renderer/src/pages/achievements/achievements.scss b/src/renderer/src/pages/achievements/achievements.scss index 9b1deea5..e3264ac5 100644 --- a/src/renderer/src/pages/achievements/achievements.scss +++ b/src/renderer/src/pages/achievements/achievements.scss @@ -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 { diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index 3056e414..9c75f343 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -114,6 +114,7 @@ export function Sidebar() { }`} src={achievement.icon} alt={achievement.displayName} + loading="lazy" />

{achievement.displayName}

@@ -164,6 +165,7 @@ export function Sidebar() { }`} src={achievement.icon} alt={achievement.displayName} + loading="lazy" />

{achievement.displayName}

diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index c3c71d9a..5365ba66 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -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); + } + } } diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index d2f1f074..76240ce2 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -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("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 (
- {hasAnyGames && ( - - )} - {!hasAnyGames && (
@@ -189,6 +202,84 @@ export function ProfileContent() {
)} + {userProfile?.achievements && userProfile.achievements.length > 0 && ( +
+
+
+

{t("souvenirs")}

+ + {userProfile.achievements.length} + +
+
+ +
+ {userProfile.achievements.map((achievement, index) => ( +
+
+
+ {achievement.name} handleImageClick(achievement.achievementImageUrl, achievement.name)} + style={{ cursor: 'pointer' }} + /> +
+ +
+
+
+ +
+
+ + + {achievement.name} + +
+ +
+
+ + + {achievement.gameTitle} + +
+ + {achievement.unlockTime && ( +
+ {formatDateTime(achievement.unlockTime)} +
+ )} +
+ +
+
+
+ ))} +
+
+ )} + + {hasAnyGames && ( + + )} + {hasGames && (
@@ -252,6 +343,13 @@ export function ProfileContent() { {content} + +
); } diff --git a/src/renderer/src/pages/settings/settings-behavior.tsx b/src/renderer/src/pages/settings/settings-behavior.tsx index 64df52d7..5f654407 100644 --- a/src/renderer/src/pages/settings/settings-behavior.tsx +++ b/src/renderer/src/pages/settings/settings-behavior.tsx @@ -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() {
+ + + handleChange({ + enableAchievementScreenshots: !form.enableAchievementScreenshots, + }) + } + /> ); } diff --git a/src/types/game.types.ts b/src/types/game.types.ts index ed8fb852..0494f2d3 100644 --- a/src/types/game.types.ts +++ b/src/types/game.types.ts @@ -20,4 +20,5 @@ export interface SteamAchievement { export interface UserAchievement extends SteamAchievement { unlocked: boolean; unlockTime: number | null; + achievementImageUrl?: string | null; } diff --git a/src/types/index.ts b/src/types/index.ts index 94b9701f..c702f8a9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; }; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 053bd218..17dbdf2c 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -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 {