feat: displaying achievements in game accordion and proper styling

This commit is contained in:
Moyasee
2025-11-25 21:21:57 +02:00
parent bc3d47ed0e
commit 4097869ae8
7 changed files with 335 additions and 131 deletions

View File

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

View File

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

View File

@@ -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<DeleteSouvenirModalProps>) {
const { t } = useTranslation("user_profile");
const handleDeleteSouvenir = () => {
onConfirm();
onClose();
};
return (
<Modal
visible={visible}
title={t("delete_souvenir_modal_title")}
description={t("delete_souvenir_modal_description")}
onClose={onClose}
>
<div className="delete-review-modal__actions">
<Button onClick={onClose} theme="outline">
{t("delete_souvenir_modal_cancel_button")}
</Button>
<Button onClick={handleDeleteSouvenir} theme="danger">
{t("delete_souvenir_modal_delete_button")}
</Button>
</div>
</Modal>
);
}

View File

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

View File

@@ -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<string>;
onImageClick: (imageUrl: string, achievementName: string) => void;
onDeleteClick: (achievement: ProfileAchievement) => void;
}
export function SouvenirsTab({
function SouvenirGameGroup({
gameTitle,
gameIconUrl,
achievements,
onImageClick,
isMe,
onAchievementDeleted,
}: Readonly<SouvenirsTabProps>) {
const { t } = useTranslation("user_profile");
const { showSuccessToast, showErrorToast } = useToast();
const [deletingIds, setDeletingIds] = useState<Set<string>>(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<SouvenirGameGroupProps>) {
const { formatDistance } = useDate();
const [isExpanded, setIsExpanded] = useState(true);
return (
<motion.div
key="souvenirs"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{achievements.length === 0 && (
<div className="profile-content__no-souvenirs">
<p>{t("no_souvenirs", "No souvenirs yet")}</p>
<div className="profile-content__images-section">
<button
className="profile-content__section-header"
onClick={() => setIsExpanded(!isExpanded)}
type="button"
style={{
width: "100%",
background: "none",
border: "none",
cursor: "pointer",
color: "inherit",
padding: 0,
}}
>
<div className="profile-content__section-title-group">
<div className="profile-content__collapse-button">
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
</div>
{gameIconUrl && (
<img
src={gameIconUrl}
alt=""
style={{
width: 24,
height: 24,
borderRadius: 4,
objectFit: "cover",
}}
/>
)}
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{gameTitle}
</h3>
<span className="profile-content__section-badge">
{achievements.length}
</span>
</div>
)}
{achievements.length > 0 && (
</button>
{isExpanded && (
<div className="profile-content__images-grid">
{achievements.map((achievement, index) => (
<div
@@ -100,64 +108,179 @@ export function SouvenirsTab({
/>
</button>
<div className="profile-content__image-achievement-image-overlay">
<SearchIcon size={20} />
<Maximize2 size={24} />
</div>
{isMe && (
<button
type="button"
className="profile-content__image-delete-button"
onClick={() => handleDeleteAchievement(achievement)}
aria-label={`Delete ${achievement.displayName} souvenir`}
disabled={deletingIds.has(achievement.id)}
style={{
cursor: deletingIds.has(achievement.id)
? "not-allowed"
: "pointer",
}}
>
<XIcon size={16} />
</button>
)}
<span className="profile-content__image-unlock-time">
{formatDistance(
new Date(achievement.unlockTime),
new Date(),
{
addSuffix: true,
}
)}
</span>
</div>
</div>
<div className="profile-content__image-card-content">
<div className="profile-content__image-achievement-info">
<div className="profile-content__image-card-row">
{achievement.achievementIcon && (
<img
src={achievement.achievementIcon}
alt=""
className="profile-content__image-achievement-icon"
className="profile-content__image-achievement-icon profile-content__image-achievement-icon--large"
loading="lazy"
/>
)}
<span className="profile-content__image-achievement-name">
{achievement.displayName}
</span>
</div>
<div className="profile-content__image-game-info">
<div className="profile-content__image-game-left">
{achievement.gameIconUrl && (
<img
src={achievement.gameIconUrl}
alt=""
className="profile-content__image-game-icon"
loading="lazy"
/>
)}
<span className="profile-content__image-game-title">
{achievement.gameTitle}
<div className="profile-content__image-achievement-text">
<span className="profile-content__image-achievement-name">
{achievement.displayName}
</span>
<p className="profile-content__image-achievement-description">
{achievement.description}
</p>
</div>
<div className="profile-content__image-card-right">
{isMe && (
<button
type="button"
className="profile-content__image-delete-button"
onClick={() => onDeleteClick(achievement)}
aria-label={`Delete ${achievement.displayName} souvenir`}
disabled={deletingIds.has(achievement.id)}
>
<TrashIcon size={14} />
</button>
)}
</div>
</div>
</div>
<div className="profile-content__image-card-gradient-overlay"></div>
</div>
))}
</div>
)}
</motion.div>
</div>
);
}
interface SouvenirsTabProps {
achievements: ProfileAchievement[];
onImageClick: (imageUrl: string, achievementName: string) => void;
isMe: boolean;
onAchievementDeleted: () => void;
}
export function SouvenirsTab({
achievements,
onImageClick,
isMe,
onAchievementDeleted,
}: Readonly<SouvenirsTabProps>) {
const { t } = useTranslation("user_profile");
const { showSuccessToast, showErrorToast } = useToast();
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const [achievementToDelete, setAchievementToDelete] =
useState<ProfileAchievement | null>(null);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const handleDeleteAchievement = async (achievement: ProfileAchievement) => {
if (deletingIds.has(achievement.id)) return;
setDeletingIds((prev) => new Set(prev).add(achievement.id));
try {
await window.electron.hydraApi.delete(
`/profile/games/achievements/${achievement.gameId}/${achievement.name}/image`
);
showSuccessToast(
t("souvenir_deleted_successfully", "Souvenir deleted successfully")
);
onAchievementDeleted();
} catch (error) {
logger.error("Failed to delete souvenir:", error);
showErrorToast(
t("souvenir_deletion_failed", "Failed to delete souvenir")
);
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(achievement.id);
return next;
});
}
};
const handleDeleteClick = (achievement: ProfileAchievement) => {
setAchievementToDelete(achievement);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = () => {
if (achievementToDelete) {
handleDeleteAchievement(achievementToDelete);
setAchievementToDelete(null);
}
};
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
setAchievementToDelete(null);
};
const groupedAchievements = useMemo(() => {
const groups: Record<string, ProfileAchievement[]> = {};
for (const achievement of achievements) {
if (!groups[achievement.gameId]) {
groups[achievement.gameId] = [];
}
groups[achievement.gameId].push(achievement);
}
return groups;
}, [achievements]);
return (
<>
<motion.div
key="souvenirs"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{achievements.length === 0 && (
<div className="profile-content__no-souvenirs">
<p>{t("no_souvenirs", "No souvenirs yet")}</p>
</div>
)}
{Object.entries(groupedAchievements).map(
([gameId, groupAchievements]) => {
const firstAchievement = groupAchievements[0];
return (
<SouvenirGameGroup
key={gameId}
gameTitle={firstAchievement.gameTitle}
gameIconUrl={firstAchievement.gameIconUrl}
achievements={groupAchievements}
isMe={isMe}
deletingIds={deletingIds}
onImageClick={onImageClick}
onDeleteClick={handleDeleteClick}
/>
);
}
)}
</motion.div>
<DeleteSouvenirModal
visible={deleteModalVisible}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -5,6 +5,7 @@ export type ShortcutLocation = "desktop" | "start_menu";
export interface UnlockedAchievement {
name: string;
unlockTime: number;
imageKey?: string | null;
imageUrl?: string | null;
}

View File

@@ -200,6 +200,7 @@ export interface ProfileAchievement {
gameIconUrl: string | null;
achievementIcon: string | null;
gameId: string;
description: string;
}
export interface UserProfile {