diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b5162431..14241cdf 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -454,6 +454,9 @@ "activity": "Recent Activity", "library": "Library", "pinned": "Pinned", + "achievements_earned": "Achievements earned", + "played_recently": "Played recently", + "playtime": "Playtime", "total_play_time": "Total playtime", "manual_playtime_tooltip": "This playtime has been manually updated", "no_recent_activity_title": "Hmmm… nothing here", @@ -530,7 +533,9 @@ "show_achievements_on_profile": "Show your achievements on your profile", "show_points_on_profile": "Show your earned points on your profile", "error_adding_friend": "Could not send friend request. Please check friend code", - "friend_code_length_error": "Friend code must have 8 characters" + "friend_code_length_error": "Friend code must have 8 characters", + "game_removed_from_pinned": "Game removed from pinned", + "game_added_to_pinned": "Game added to pinned" }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 00b387d2..6bd74b69 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -16,8 +16,7 @@ import "./hardware/check-folder-write-permission"; import "./library/add-game-to-library"; import "./library/add-game-to-favorites"; import "./library/remove-game-from-favorites"; -import "./library/add-game-to-pinned"; -import "./library/remove-game-from-pinned"; +import "./library/toggle-game-pin"; import "./library/create-game-shortcut"; import "./library/close-game"; import "./library/delete-game-folder"; diff --git a/src/main/events/library/add-game-to-pinned.ts b/src/main/events/library/add-game-to-pinned.ts deleted file mode 100644 index 82b62d7b..00000000 --- a/src/main/events/library/add-game-to-pinned.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { registerEvent } from "../register-event"; -import { gamesSublevel, levelKeys } from "@main/level"; -import { HydraApi } from "@main/services"; -import type { GameShop } from "@types"; - -const addGameToPinned = async ( - _event: Electron.IpcMainInvokeEvent, - shop: GameShop, - objectId: string -) => { - const gameKey = levelKeys.game(shop, objectId); - - const game = await gamesSublevel.get(gameKey); - if (!game) return; - - const response = await HydraApi.put(`/profile/games/${shop}/${objectId}/pin`); - - try { - await gamesSublevel.put(gameKey, { - ...game, - pinned: true, - pinnedDate: new Date(response.pinnedDate), - }); - } catch (error) { - throw new Error(`Failed to update game pinned status: ${error}`); - } -}; - -registerEvent("addGameToPinned", addGameToPinned); diff --git a/src/main/events/library/remove-game-from-pinned.ts b/src/main/events/library/remove-game-from-pinned.ts deleted file mode 100644 index 658b9d6d..00000000 --- a/src/main/events/library/remove-game-from-pinned.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { registerEvent } from "../register-event"; -import { gamesSublevel, levelKeys } from "@main/level"; -import { HydraApi } from "@main/services"; -import type { GameShop } from "@types"; - -const removeGameFromPinned = async ( - _event: Electron.IpcMainInvokeEvent, - shop: GameShop, - objectId: string -) => { - const gameKey = levelKeys.game(shop, objectId); - - const game = await gamesSublevel.get(gameKey); - if (!game) return; - - HydraApi.put(`/profile/games/${shop}/${objectId}/unpin`).catch(() => {}); - - try { - await gamesSublevel.put(gameKey, { - ...game, - pinned: false, - pinnedDate: null, - }); - } catch (error) { - throw new Error(`Failed to update game pinned status: ${error}`); - } -}; - -registerEvent("removeGameFromPinned", removeGameFromPinned); diff --git a/src/main/events/library/toggle-game-pin.ts b/src/main/events/library/toggle-game-pin.ts new file mode 100644 index 00000000..addedddd --- /dev/null +++ b/src/main/events/library/toggle-game-pin.ts @@ -0,0 +1,43 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, levelKeys } from "@main/level"; +import { HydraApi, logger } from "@main/services"; +import type { GameShop, UserGame } from "@types"; + +const toggleGamePin = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string, + pin: boolean +) => { + try { + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + if (!game) return; + + if (pin) { + const response = await HydraApi.put( + `/profile/games/${shop}/${objectId}/pin` + ); + + await gamesSublevel.put(gameKey, { + ...game, + isPinned: pin, + pinnedDate: new Date(response.pinnedDate!), + }); + } else { + await HydraApi.put(`/profile/games/${shop}/${objectId}/unpin`); + + await gamesSublevel.put(gameKey, { + ...game, + isPinned: pin, + pinnedDate: null, + }); + } + } catch (error) { + logger.error("Failed to update game pinned status", error); + throw new Error(`Failed to update game pinned status: ${error}`); + } +}; + +registerEvent("toggleGamePin", toggleGamePin); diff --git a/src/main/events/user/get-user-library.ts b/src/main/events/user/get-user-library.ts index 8a715a49..f3c3eed5 100644 --- a/src/main/events/user/get-user-library.ts +++ b/src/main/events/user/get-user-library.ts @@ -6,13 +6,18 @@ const getUserLibrary = async ( _event: Electron.IpcMainInvokeEvent, userId: string, take: number = 12, - skip: number = 0 + skip: number = 0, + sortBy?: string ): Promise => { const params = new URLSearchParams(); params.append("take", take.toString()); params.append("skip", skip.toString()); + if (sortBy) { + params.append("sortBy", sortBy); + } + const queryString = params.toString(); const baseUrl = `/users/${userId}/library`; const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 0d5d92f8..152e1138 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -37,7 +37,7 @@ export const mergeWithRemoteGames = async () => { lastTimePlayed: updatedLastTimePlayed, playTimeInMilliseconds: updatedPlayTime, favorite: game.isFavorite ?? localGame.favorite, - pinned: game.isPinned ?? localGame.pinned, + isPinned: game.isPinned ?? localGame.isPinned, }); } else { await gamesSublevel.put(gameKey, { @@ -51,7 +51,7 @@ export const mergeWithRemoteGames = async () => { hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime, isDeleted: false, favorite: game.isFavorite ?? false, - pinned: game.isPinned ?? false, + isPinned: game.isPinned ?? false, }); } diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index beab164f..d4febfea 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -27,7 +27,7 @@ export const uploadGamesBatch = async () => { shop: game.shop, lastTimePlayed: game.lastTimePlayed, isFavorite: game.favorite, - isPinned: game.pinned ?? false, + isPinned: game.isPinned ?? false, }; }) ).catch(() => {}); diff --git a/src/preload/index.ts b/src/preload/index.ts index b32fd6b0..ca275c91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -143,10 +143,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("addGameToFavorites", shop, objectId), removeGameFromFavorites: (shop: GameShop, objectId: string) => ipcRenderer.invoke("removeGameFromFavorites", shop, objectId), - addGameToPinned: (shop: GameShop, objectId: string) => - ipcRenderer.invoke("addGameToPinned", shop, objectId), - removeGameFromPinned: (shop: GameShop, objectId: string) => - ipcRenderer.invoke("removeGameFromPinned", shop, objectId), + toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) => + ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned), updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -370,8 +368,12 @@ contextBridge.exposeInMainWorld("electron", { /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), - getUserLibrary: (userId: string, take?: number, skip?: number) => - ipcRenderer.invoke("getUserLibrary", userId, take, skip), + getUserLibrary: ( + userId: string, + take?: number, + skip?: number, + sortBy?: string + ) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy), blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), getUserFriends: (userId: string, take: number, skip: number) => 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 499b85ee..2750442a 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -14,7 +14,7 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; - getUserLibraryGames: () => Promise; + getUserLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; @@ -30,7 +30,7 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, - getUserLibraryGames: async () => {}, + getUserLibraryGames: async (_sortBy?: string) => {}, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], @@ -93,21 +93,30 @@ export function UserProfileContextProvider({ }); }, [userId]); - const getUserLibraryGames = useCallback(async () => { - try { - const response = await window.electron.getUserLibrary(userId); - if (response) { - setLibraryGames(response.library); - setPinnedGames(response.pinnedGames); - } else { + const getUserLibraryGames = useCallback( + async (sortBy?: string) => { + try { + const response = await window.electron.getUserLibrary( + userId, + 12, + 0, + sortBy + ); + + if (response) { + setLibraryGames(response.library); + setPinnedGames(response.pinnedGames); + } else { + setLibraryGames([]); + setPinnedGames([]); + } + } catch (error) { setLibraryGames([]); setPinnedGames([]); } - } catch (error) { - setLibraryGames([]); - setPinnedGames([]); - } - }, [userId]); + }, + [userId] + ); const getUserProfile = useCallback(async () => { getUserStats(); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 115841d4..87b2d63d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -127,8 +127,11 @@ declare global { shop: GameShop, objectId: string ) => Promise; - addGameToPinned: (shop: GameShop, objectId: string) => Promise; - removeGameFromPinned: (shop: GameShop, objectId: string) => Promise; + toggleGamePin: ( + shop: GameShop, + objectId: string, + pinned: boolean + ) => Promise; updateLaunchOptions: ( shop: GameShop, objectId: string, @@ -293,7 +296,8 @@ declare global { getUserLibrary: ( userId: string, take?: number, - skip?: number + skip?: number, + sortBy?: string ) => Promise; blockUser: (userId: string) => Promise; unblockUser: (userId: string) => Promise; diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index bdc8cf83..307de108 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -94,14 +94,14 @@ export function HeroPanelActions() { setToggleLibraryGameDisabled(true); try { - if (game?.pinned && objectId) { - await window.electron.removeGameFromPinned(shop, objectId).then(() => { + if (game?.isPinned && objectId) { + await window.electron.toggleGamePin(shop, objectId, false).then(() => { showSuccessToast(t("game_removed_from_pinned")); }); } else { if (!objectId) return; - await window.electron.addGameToPinned(shop, objectId).then(() => { + await window.electron.toggleGamePin(shop, objectId, true).then(() => { showSuccessToast(t("game_added_to_pinned")); }); } @@ -236,7 +236,7 @@ export function HeroPanelActions() { disabled={deleting} className="hero-panel-actions__action" > - {game.pinned ? : } + {game.isPinned ? : } )} diff --git a/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx index c9d26b94..7355461a 100644 --- a/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx @@ -72,7 +72,6 @@ export function ChangeGamePlaytimeModal({ onSuccess?.(t("update_playtime_success")); onClose(); } catch (error) { - console.log(error); onError?.(t("update_playtime_error")); } finally { setIsSubmitting(false); 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 8f2fcf6f..7faae2db 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -65,8 +65,103 @@ flex: 1; } - &__section-count { - margin-left: auto; + &__section-badge { + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + padding: 4px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + flex-shrink: 0; + } + + &__sort-container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: calc(globals.$spacing-unit); + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__sort-label { + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + font-weight: 400; + } + + &__sort-options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + font-size: 14px; + } + + &__sort-option { + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + padding: 4px 0; + font-size: 14px; + font-weight: 300; + transition: all ease 0.2s; + display: flex; + align-items: center; + gap: 6px; + + &:hover:not(:disabled) { + color: rgba(255, 255, 255, 0.6); + } + + &.active { + color: rgba(255, 255, 255, 0.9); + font-weight: 500; + } + + &.loading { + color: rgba(201, 170, 113, 0.8); + font-weight: 500; + position: relative; + + &::after { + content: ""; + position: absolute; + right: -20px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + border: 2px solid rgba(201, 170, 113, 0.3); + border-top: 2px solid rgba(201, 170, 113, 0.8); + border-radius: 50%; + animation: spin 1s linear infinite; + } + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + span { + display: inline-block; + } + } + + @keyframes spin { + 0% { + transform: translateY(-50%) rotate(0deg); + } + 100% { + transform: translateY(-50%) rotate(360deg); + } + } + + &__sort-separator { + color: rgba(255, 255, 255, 0.3); + font-size: 14px; } &__collapse-button { 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 f1bb2ab8..f330bf97 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -3,8 +3,15 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; import { useAppDispatch, useFormat } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; -import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react"; +import { + TelescopeIcon, + ChevronRightIcon, + TrophyIcon, + ClockIcon, + HistoryIcon, +} from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; +import { UserGame } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; @@ -59,6 +66,35 @@ const gameCardVariants = { ease: [0.25, 0.1, 0.25, 1], }, }, + exit: { + opacity: 0, + y: -20, + scale: 0.95, + transition: { + duration: 0.3, + ease: [0.25, 0.1, 0.25, 1], + }, + }, +}; + +const gameGridVariants = { + hidden: { + opacity: 0, + }, + visible: { + opacity: 1, + transition: { + duration: 0.3, + staggerChildren: 0.1, + delayChildren: 0.1, + }, + }, + exit: { + opacity: 0, + transition: { + duration: 0.2, + }, + }, }; const chevronVariants = { @@ -78,11 +114,23 @@ const chevronVariants = { }, }; +type SortOption = "playtime" | "achievementCount" | "playedRecently"; + export function ProfileContent() { - const { userProfile, isMe, userStats, libraryGames, pinnedGames } = - useContext(userProfileContext); + const { + userProfile, + isMe, + userStats, + libraryGames, + pinnedGames, + getUserLibraryGames, + } = useContext(userProfileContext); const [statsIndex, setStatsIndex] = useState(0); const [isAnimationRunning, setIsAnimationRunning] = useState(true); + const [sortBy, setSortBy] = useState("playedRecently"); + const [isLoadingSort, setIsLoadingSort] = useState(false); + const [prevLibraryGames, setPrevLibraryGames] = useState([]); + const [prevPinnedGames, setPrevPinnedGames] = useState([]); const statsAnimation = useRef(-1); const { toggleSection, isPinnedCollapsed } = useSectionCollapse(); @@ -98,6 +146,15 @@ export function ProfileContent() { } }, [userProfile, dispatch]); + useEffect(() => { + if (userProfile) { + setIsLoadingSort(true); + getUserLibraryGames(sortBy).finally(() => { + setIsLoadingSort(false); + }); + } + }, [sortBy, getUserLibraryGames, userProfile]); + const handleOnMouseEnterGameCard = () => { setIsAnimationRunning(false); }; @@ -129,10 +186,68 @@ export function ProfileContent() { const { numberFormatter } = useFormat(); + // Function to check if game lists have changed + const gamesHaveChanged = ( + current: UserGame[], + previous: UserGame[] + ): boolean => { + if (current.length !== previous.length) return true; + return current.some( + (game, index) => game.objectId !== previous[index]?.objectId + ); + }; + + // Check if animations should run + const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames); + const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames); + + // Update previous games when lists change + useEffect(() => { + setPrevLibraryGames(libraryGames); + }, [libraryGames]); + + useEffect(() => { + setPrevPinnedGames(pinnedGames); + }, [pinnedGames]); + const usersAreFriends = useMemo(() => { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); + const SortOptions = () => ( +
+ Sort by: +
+ + | + + | + +
+
+ ); + const content = useMemo(() => { if (!userProfile) return null; @@ -154,6 +269,8 @@ export function ProfileContent() { return (
+ {hasAnyGames && } + {!hasAnyGames && (
@@ -188,10 +305,10 @@ export function ProfileContent() {

{t("pinned")}

+ + {pinnedGames.length} +
- - {pinnedGames.length} -
@@ -204,25 +321,57 @@ export function ProfileContent() { exit="collapsed" layout > -
    - {pinnedGames?.map((game, index) => ( - - - - ))} -
+ + {shouldAnimatePinned ? ( + + {pinnedGames?.map((game, index) => ( + + + + ))} + + ) : ( + pinnedGames?.map((game) => ( +
  • + +
  • + )) + )} +
    )}
    @@ -234,33 +383,62 @@ export function ProfileContent() {

    {t("library")}

    + {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )}
    - {userStats && ( - - {numberFormatter.format(userStats.libraryCount)} - - )}
    -
      - {libraryGames?.map((game, index) => ( - - - - ))} -
    + + {shouldAnimateLibrary ? ( + + {libraryGames?.map((game, index) => ( + + + + ))} + + ) : ( + libraryGames?.map((game) => ( +
  • + +
  • + )) + )} +
    )} diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx index e00b5863..860c6758 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx @@ -38,7 +38,6 @@ export function UserLibraryGameCard({ const { userProfile, isMe, getUserLibraryGames } = useContext(userProfileContext); const { t } = useTranslation("user_profile"); - const { t: tGame } = useTranslation("game_details"); const { numberFormatter } = useFormat(); const { showSuccessToast } = useToast(); const navigate = useNavigate(); @@ -99,23 +98,19 @@ export function UserLibraryGameCard({ setIsPinning(true); try { - if (game.isPinned) { - await window.electron - .removeGameFromPinned(game.shop, game.objectId) - .then(() => { - showSuccessToast(tGame("game_removed_from_pinned")); - }); - } else { - await window.electron - .addGameToPinned(game.shop, game.objectId) - .then(() => { - showSuccessToast(tGame("game_added_to_pinned")); - }); - } - - await new Promise((resolve) => setTimeout(resolve, 1000)); + await window.electron.toggleGamePin( + game.shop, + game.objectId, + !game.isPinned + ); await getUserLibraryGames(); + + if (game.isPinned) { + showSuccessToast(t("game_removed_from_pinned")); + } else { + showSuccessToast(t("game_added_to_pinned")); + } } finally { setIsPinning(false); } diff --git a/src/types/level.types.ts b/src/types/level.types.ts index da702b70..c5bd3454 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -44,7 +44,7 @@ export interface Game { executablePath?: string | null; launchOptions?: string | null; favorite?: boolean; - pinned?: boolean; + isPinned?: boolean; pinnedDate?: Date | null; automaticCloudSync?: boolean; hasManuallyUpdatedPlaytime?: boolean;