From 4097869ae8c5cdc8143cd4304e56513561f6d0d3 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 25 Nov 2025 21:21:57 +0200 Subject: [PATCH] feat: displaying achievements in game accordion and proper styling --- src/locales/en/translation.json | 6 +- .../achievements/merge-achievements.ts | 4 +- .../profile-content/delete-souvenir-modal.tsx | 41 +++ .../profile-content/profile-content.scss | 106 ++++-- .../profile/profile-content/souvenirs-tab.tsx | 307 ++++++++++++------ src/types/game.types.ts | 1 + src/types/index.ts | 1 + 7 files changed, 335 insertions(+), 131 deletions(-) create mode 100644 src/renderer/src/pages/profile/profile-content/delete-souvenir-modal.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6da94356..13515c21 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -715,7 +715,11 @@ "delete_review": "Delete Review", "loading_reviews": "Loading reviews...", "souvenir_deleted_successfully": "Souvenir deleted successfully", - "souvenir_deletion_failed": "Failed to delete souvenir" + "souvenir_deletion_failed": "Failed to delete souvenir", + "delete_souvenir_modal_title": "Are you sure you want to delete this souvenir?", + "delete_souvenir_modal_description": "This action cannot be undone.", + "delete_souvenir_modal_delete_button": "Delete", + "delete_souvenir_modal_cancel_button": "Cancel" }, "library": { "library": "Library", diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index 3ff6f419..2567091e 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -226,10 +226,10 @@ const addImagesToNewAchievementsIfEnabled = async ( const achievementIndex = achievementsWithImages.findIndex( (a) => a.name.toUpperCase() === achievement.name.toUpperCase() ); - if (achievementIndex !== -1 && uploadResult.imageUrl) { + if (achievementIndex !== -1 && uploadResult.imageKey) { achievementsWithImages[achievementIndex] = { ...achievementsWithImages[achievementIndex], - imageUrl: uploadResult.imageUrl, + imageKey: uploadResult.imageKey, }; } } catch (error) { diff --git a/src/renderer/src/pages/profile/profile-content/delete-souvenir-modal.tsx b/src/renderer/src/pages/profile/profile-content/delete-souvenir-modal.tsx new file mode 100644 index 00000000..febda2f7 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/delete-souvenir-modal.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; +import { Button, Modal } from "@renderer/components"; +import "../../../pages/game-details/modals/delete-review-modal.scss"; + +interface DeleteSouvenirModalProps { + visible: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export function DeleteSouvenirModal({ + visible, + onClose, + onConfirm, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + const handleDeleteSouvenir = () => { + onConfirm(); + onClose(); + }; + + return ( + +
+ + + +
+
+ ); +} 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 adb00cfc..073eca11 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -218,23 +218,23 @@ grid-template-columns: repeat(2, 1fr); @container #{globals.$app-container} (min-width: 1000px) { - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); } @container #{globals.$app-container} (min-width: 1300px) { - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(4, 1fr); } @container #{globals.$app-container} (min-width: 2000px) { - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(5, 1fr); } @container #{globals.$app-container} (min-width: 2600px) { - grid-template-columns: repeat(8, 1fr); + grid-template-columns: repeat(6, 1fr); } @container #{globals.$app-container} (min-width: 3000px) { - grid-template-columns: repeat(12, 1fr); + grid-template-columns: repeat(8, 1fr); } } @@ -327,40 +327,35 @@ } &__image-delete-button { - position: absolute; - top: 8px; - right: 8px; - width: 32px; - height: 32px; display: flex; align-items: center; justify-content: center; - background: rgba(244, 67, 54, 0.9); + gap: 0; + background: transparent; border: 1px solid rgba(244, 67, 54, 0.5); border-radius: 6px; - color: white; + padding: 0; + color: rgba(244, 67, 54, 0.9); + font-size: 12px; + font-weight: 500; cursor: pointer; transition: all 0.2s ease; - z-index: 3; - opacity: 0; + width: 32px; + height: 32px; &:hover { - background: rgba(244, 67, 54, 1); - border-color: rgba(244, 67, 54, 0.7); - transform: scale(1.1); + background: rgba(244, 67, 54, 0.1); + border-color: rgba(244, 67, 54, 0.8); + color: #ff7961; } &:disabled { opacity: 0.5; cursor: not-allowed; - transform: none; + border-color: rgba(244, 67, 54, 0.2); } } - &__image-achievement-image-wrapper:hover &__image-delete-button { - opacity: 1; - } - // Show overlay on keyboard focus for accessibility &__image-button:focus-visible + &__image-achievement-image-overlay { opacity: 1; @@ -368,28 +363,62 @@ &__image-card-content { padding: 16px; - background: rgba(0, 0, 0, 0.8); + background: #121212; backdrop-filter: blur(10px); display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit); overflow: hidden; } - &__image-card-gradient-overlay { + &__image-card-row { + display: flex; + gap: calc(globals.$spacing-unit * 1.5); + align-items: center; + } + + &__image-achievement-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + + &__image-achievement-description { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + } + + &__image-card-right { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 8px; + flex-shrink: 0; + height: 100%; + } + + &__image-unlock-time { position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 60px; - background: linear-gradient( - to top, - rgba(27, 59, 52, 0.6) 1%, - transparent 100% - ); + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + color: rgba(255, 255, 255, 0.9); + padding: 4px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + z-index: 2; pointer-events: none; - border-bottom-left-radius: 12px; - border-bottom-right-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); } &__image-achievement-info { @@ -405,6 +434,11 @@ height: 24px; border-radius: 4px; flex-shrink: 0; + + &--large { + width: 36px; + height: 36px; + } } &__image-achievement-name { diff --git a/src/renderer/src/pages/profile/profile-content/souvenirs-tab.tsx b/src/renderer/src/pages/profile/profile-content/souvenirs-tab.tsx index a88b653e..7e8f6d40 100644 --- a/src/renderer/src/pages/profile/profile-content/souvenirs-tab.tsx +++ b/src/renderer/src/pages/profile/profile-content/souvenirs-tab.tsx @@ -1,72 +1,80 @@ import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; -import { SearchIcon, XIcon } from "@primer/octicons-react"; -import { useState } from "react"; +import { ChevronDownIcon, ChevronRightIcon } from "@primer/octicons-react"; +import { TrashIcon, Maximize2 } from "lucide-react"; +import { useState, useMemo } from "react"; import type { ProfileAchievement } from "@types"; -import { useToast } from "@renderer/hooks"; +import { useToast, useDate } from "@renderer/hooks"; import { logger } from "@renderer/logger"; +import { DeleteSouvenirModal } from "./delete-souvenir-modal"; import "./profile-content.scss"; -interface SouvenirsTabProps { +interface SouvenirGameGroupProps { + gameTitle: string; + gameIconUrl: string | null; achievements: ProfileAchievement[]; - onImageClick: (imageUrl: string, achievementName: string) => void; isMe: boolean; - onAchievementDeleted: () => void; + deletingIds: Set; + onImageClick: (imageUrl: string, achievementName: string) => void; + onDeleteClick: (achievement: ProfileAchievement) => void; } -export function SouvenirsTab({ +function SouvenirGameGroup({ + gameTitle, + gameIconUrl, achievements, - onImageClick, isMe, - onAchievementDeleted, -}: Readonly) { - const { t } = useTranslation("user_profile"); - const { showSuccessToast, showErrorToast } = useToast(); - const [deletingIds, setDeletingIds] = useState>(new Set()); - - const handleDeleteAchievement = async (achievement: ProfileAchievement) => { - if (deletingIds.has(achievement.id)) return; - - setDeletingIds((prev) => new Set(prev).add(achievement.id)); - - try { - await window.electron.hydraApi.delete( - `/profile/games/achievements/${achievement.gameId}/${achievement.name}/image` - ); - - showSuccessToast( - t("souvenir_deleted_successfully", "Souvenir deleted successfully") - ); - onAchievementDeleted(); - } catch (error) { - logger.error("Failed to delete souvenir:", error); - showErrorToast( - t("souvenir_deletion_failed", "Failed to delete souvenir") - ); - setDeletingIds((prev) => { - const next = new Set(prev); - next.delete(achievement.id); - return next; - }); - } - }; + deletingIds, + onImageClick, + onDeleteClick, +}: Readonly) { + const { formatDistance } = useDate(); + const [isExpanded, setIsExpanded] = useState(true); return ( - - {achievements.length === 0 && ( -
-

{t("no_souvenirs", "No souvenirs yet")}

+
+ + + {isExpanded && (
{achievements.map((achievement, index) => (
- +
- {isMe && ( - - )} + + {formatDistance( + new Date(achievement.unlockTime), + new Date(), + { + addSuffix: true, + } + )} +
-
+
{achievement.achievementIcon && ( )} - - {achievement.displayName} - -
-
-
- {achievement.gameIconUrl && ( - - )} - - {achievement.gameTitle} +
+ + {achievement.displayName} +

+ {achievement.description} +

+
+ +
+ {isMe && ( + + )}
- -
))}
)} - +
+ ); +} + +interface SouvenirsTabProps { + achievements: ProfileAchievement[]; + onImageClick: (imageUrl: string, achievementName: string) => void; + isMe: boolean; + onAchievementDeleted: () => void; +} + +export function SouvenirsTab({ + achievements, + onImageClick, + isMe, + onAchievementDeleted, +}: Readonly) { + const { t } = useTranslation("user_profile"); + const { showSuccessToast, showErrorToast } = useToast(); + const [deletingIds, setDeletingIds] = useState>(new Set()); + const [achievementToDelete, setAchievementToDelete] = + useState(null); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + + const handleDeleteAchievement = async (achievement: ProfileAchievement) => { + if (deletingIds.has(achievement.id)) return; + + setDeletingIds((prev) => new Set(prev).add(achievement.id)); + + try { + await window.electron.hydraApi.delete( + `/profile/games/achievements/${achievement.gameId}/${achievement.name}/image` + ); + + showSuccessToast( + t("souvenir_deleted_successfully", "Souvenir deleted successfully") + ); + onAchievementDeleted(); + } catch (error) { + logger.error("Failed to delete souvenir:", error); + showErrorToast( + t("souvenir_deletion_failed", "Failed to delete souvenir") + ); + setDeletingIds((prev) => { + const next = new Set(prev); + next.delete(achievement.id); + return next; + }); + } + }; + + const handleDeleteClick = (achievement: ProfileAchievement) => { + setAchievementToDelete(achievement); + setDeleteModalVisible(true); + }; + + const handleDeleteConfirm = () => { + if (achievementToDelete) { + handleDeleteAchievement(achievementToDelete); + setAchievementToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalVisible(false); + setAchievementToDelete(null); + }; + + const groupedAchievements = useMemo(() => { + const groups: Record = {}; + for (const achievement of achievements) { + if (!groups[achievement.gameId]) { + groups[achievement.gameId] = []; + } + groups[achievement.gameId].push(achievement); + } + return groups; + }, [achievements]); + + return ( + <> + + {achievements.length === 0 && ( +
+

{t("no_souvenirs", "No souvenirs yet")}

+
+ )} + + {Object.entries(groupedAchievements).map( + ([gameId, groupAchievements]) => { + const firstAchievement = groupAchievements[0]; + return ( + + ); + } + )} +
+ + + ); } diff --git a/src/types/game.types.ts b/src/types/game.types.ts index 0d39a4a1..1a21cbe9 100644 --- a/src/types/game.types.ts +++ b/src/types/game.types.ts @@ -5,6 +5,7 @@ export type ShortcutLocation = "desktop" | "start_menu"; export interface UnlockedAchievement { name: string; unlockTime: number; + imageKey?: string | null; imageUrl?: string | null; } diff --git a/src/types/index.ts b/src/types/index.ts index b632fcfb..4c9f5d01 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -200,6 +200,7 @@ export interface ProfileAchievement { gameIconUrl: string | null; achievementIcon: string | null; gameId: string; + description: string; } export interface UserProfile {