mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 22:06:17 +00:00
feat: displaying achievements in game accordion and proper styling
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ShortcutLocation = "desktop" | "start_menu";
|
||||
export interface UnlockedAchievement {
|
||||
name: string;
|
||||
unlockTime: number;
|
||||
imageKey?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ export interface ProfileAchievement {
|
||||
gameIconUrl: string | null;
|
||||
achievementIcon: string | null;
|
||||
gameId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
|
||||
Reference in New Issue
Block a user