Merge branch 'feat/new-catalogue' of https://github.com/hydralauncher/hydra into feat/new-catalogue

This commit is contained in:
Hachi-R
2024-12-23 18:16:01 -03:00
48 changed files with 935 additions and 169 deletions

View File

@@ -27,6 +27,8 @@ import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
export interface AppProps {
children: React.ReactNode;
@@ -43,21 +45,21 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const {
userDetails,
hasActiveSubscription,
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
syncFriendRequests,
hideFriendsModal,
} = useUserDetails();
const {
userDetails,
hasActiveSubscription,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
} = useUserDetails();
const { hideHydraCloudModal, isHydraCloudModalVisible, hydraCloudFeature } =
useSubscription();
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -255,6 +257,12 @@ export function App() {
onClose={handleToastClose}
/>
<HydraCloudModal
visible={isHydraCloudModalVisible}
onClose={hideHydraCloudModal}
feature={hydraCloudFeature}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}

View File

@@ -0,0 +1,13 @@
<svg width="55" height="49" viewBox="0 0 55 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.8501 29.1176L19.9196 28.3235L20.6957 25.6764L20.437 24.8823L18.1088 25.9411L14.487 24.3528L10.0891 23.0293L5.69128 23.5587L3.10431 25.6764L2.58691 29.1176L4.1391 33.6177L5.69128 36.7942L8.53695 38.9118L10.8652 38.3824L13.9696 34.9412V31.5L12.9348 29.1176V32.2941L10.8652 34.9412H7.50216L4.91519 32.2941L5.69128 28.3235L9.57174 26.4705L13.9696 27.5294L17.8501 29.1176Z" fill="white"/>
<path d="M36.9585 29.1176L34.889 28.3235L34.1129 25.6764L34.3716 24.8823L36.6998 25.9411L40.3216 24.3528L44.7195 23.0293L49.1173 23.5587L51.7043 25.6764L52.2217 29.1176L50.6695 33.6177L49.1173 36.7942L46.2716 38.9118L43.9434 38.3824L40.839 34.9412V31.5L41.8738 29.1176V32.2941L43.9434 34.9412H47.3064L49.8934 32.2941L49.1173 28.3235L45.2369 26.4705L40.839 27.5294L36.9585 29.1176Z" fill="white"/>
<path d="M40.3873 19.4005L38.8784 19.9071L38.7685 19.0593L38.5553 17.7049L39.811 14.777L41.6564 11.5721L44.483 9.44001L47.1023 9.23162L49.2244 10.9371L50.7089 14.4032L51.4925 17.1031L50.9665 19.9071L49.3381 20.8916L45.7182 20.6206L43.8957 18.6282L43.2332 16.675L44.9154 18.5142L47.5156 18.8992L49.4627 17.0344L49.5586 14.0673L47.0064 12.1987L43.7784 13.2776L41.7929 16.3293L40.3873 19.4005Z" fill="white"/>
<path d="M14.0238 19.4005L15.5327 19.9071L15.6426 19.0593L15.8559 17.7049L14.6001 14.777L12.7548 11.5721L9.92812 9.44001L7.30879 9.23162L5.18676 10.9371L3.70221 14.4032L2.91861 17.1031L3.44468 19.9071L5.07308 20.8916L8.69292 20.6206L10.5154 18.6282L11.178 16.675L9.4957 18.5142L6.89555 18.8992L4.94841 17.0344L4.8525 14.0673L7.4047 12.1987L10.6327 13.2776L12.6182 16.3293L14.0238 19.4005Z" fill="white"/>
<path d="M19.9494 36.4343L22.554 34.3372L21.9876 34.0884L20.9528 31.9707L19.9494 32.5001L17.5898 34.0884L15.3904 37.377L14.744 40.4414L15.2615 43.0885L17.0724 45.4709L20.9528 47.3238L23.6932 46.5297L25.435 44.7653L26.0028 42.9192L25.6093 39.0508L25.0919 37.7913L23.6932 37.0002L24.3158 39.105L24.3158 42.0296L22.3597 43.9181L19.9494 43.9181L18.0225 42.0296L17.4949 39.105L19.9494 36.4343Z" fill="white"/>
<path d="M35.0955 36.4343L32.4909 34.3372L33.0573 34.0884L34.0921 31.9707L35.0955 32.5001L37.4552 34.0884L39.6545 37.377L40.3009 40.4414L39.7834 43.0885L37.9725 45.4709L34.0921 47.3238L31.3518 46.5297L29.6099 44.7653L29.0421 42.9192L29.4356 39.0508L29.953 37.7913L31.3518 37.0002L30.7291 39.105L30.7291 42.0296L32.6852 43.9181L35.0955 43.9181L37.0224 42.0296L37.55 39.105L35.0955 36.4343Z" fill="white"/>
<path d="M18.8447 8.70593V5.79413L20.1382 5H22.9839L27.3817 5.79413L31.7796 5H34.6252L35.9187 5.79413V8.70593L38.7644 11.353L37.2122 15.0589L37.9883 20.8825L35.9187 23.0002L33.3317 23.7943L32.8144 26.1767L33.3317 28.8238L32.8144 30.9415L31.7796 33.0591L30.2274 33.8533H27.3817H24.536L22.9839 33.0591L21.9491 30.9415L21.4317 28.8238L21.9491 26.1767L21.4317 23.7943L18.8447 23.0002L16.7751 20.8825L17.5512 15.0589L15.999 11.353L18.8447 8.70593Z" fill="white"/>
<path d="M15.5205 6.88232L16.2966 8.73528L17.5901 7.67645V4.76465L15.5205 6.88232Z" fill="white"/>
<path d="M39.2861 6.88232L38.51 8.73528L37.2166 7.67645V4.76465L39.2861 6.88232Z" fill="white"/>
<path d="M18.3667 2.11767L18.6254 4.23534L19.4015 3.70593H20.9537L20.4363 2.11767L17.0732 0L18.3667 2.11767Z" fill="white"/>
<path d="M35.6997 2.11767L35.441 4.23534L34.6649 3.70593H33.1127L33.6301 2.11767L36.9932 0L35.6997 2.11767Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -67,10 +67,10 @@ export function Header() {
};
useEffect(() => {
if (!location.pathname.startsWith("/catalogue")) {
if (!location.pathname.startsWith("/catalogue") && searchValue) {
dispatch(setFilters({ title: "" }));
}
}, [location.pathname, dispatch]);
}, [location.pathname, searchValue, dispatch]);
return (
<>

View File

@@ -51,7 +51,6 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
setShowGameOptionsModal: () => {},
setShowRepacksModal: () => {},
setHasNSFWContentBlocked: () => {},
handleClickOpenCheckout: () => {},
});
const { Provider } = gameDetailsContext;
@@ -100,11 +99,6 @@ export function GameDetailsContextProvider({
(state) => state.userPreferences.value
);
const handleClickOpenCheckout = () => {
// TODO: show modal before redirecting to checkout page
window.electron.openCheckout();
};
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectId(objectId!)
@@ -279,7 +273,6 @@ export function GameDetailsContextProvider({
updateGame,
setShowRepacksModal,
setShowGameOptionsModal,
handleClickOpenCheckout,
}}
>
{children}

View File

@@ -29,5 +29,4 @@ export interface GameDetailsContext {
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
handleClickOpenCheckout: () => void;
}

View File

@@ -5,6 +5,7 @@ import type { CatalogueSearchPayload } from "@types";
export interface CatalogueSearchState {
filters: CatalogueSearchPayload;
page: number;
}
const initialState: CatalogueSearchState = {
@@ -16,6 +17,7 @@ const initialState: CatalogueSearchState = {
genres: [],
developers: [],
},
page: 1,
};
export const catalogueSearchSlice = createSlice({
@@ -27,11 +29,20 @@ export const catalogueSearchSlice = createSlice({
action: PayloadAction<Partial<CatalogueSearchPayload>>
) => {
state.filters = { ...state.filters, ...action.payload };
state.page = initialState.page;
},
clearFilters: (state) => {
state.filters = initialState.filters;
state.page = initialState.page;
},
setPage: (state, action: PayloadAction<number>) => {
state.page = action.payload;
},
clearPage: (state) => {
state.page = initialState.page;
},
},
});
export const { setFilters, clearFilters } = catalogueSearchSlice.actions;
export const { setFilters, clearFilters, setPage, clearPage } =
catalogueSearchSlice.actions;

View File

@@ -5,5 +5,6 @@ export * from "./window-slice";
export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./running-game-slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./catalogue-search";

View File

@@ -0,0 +1,32 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { HydraCloudFeature } from "@types";
export interface SubscriptionState {
isHydraCloudModalVisible: boolean;
feature: HydraCloudFeature | "";
}
const initialState: SubscriptionState = {
isHydraCloudModalVisible: false,
feature: "",
};
export const subscriptionSlice = createSlice({
name: "subscription",
initialState,
reducers: {
setHydraCloudModalVisible: (
state,
action: PayloadAction<HydraCloudFeature>
) => {
state.isHydraCloudModalVisible = true;
state.feature = action.payload;
},
setHydraCloudModalHidden: (state) => {
state.isHydraCloudModalVisible = false;
},
},
});
export const { setHydraCloudModalVisible, setHydraCloudModalHidden } =
subscriptionSlice.actions;

View File

@@ -0,0 +1,33 @@
import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import {
setHydraCloudModalVisible,
setHydraCloudModalHidden,
} from "@renderer/features";
import { HydraCloudFeature } from "@types";
export function useSubscription() {
const dispatch = useAppDispatch();
const { isHydraCloudModalVisible, feature } = useAppSelector(
(state) => state.subscription
);
const showHydraCloudModal = useCallback(
(feature: HydraCloudFeature) => {
dispatch(setHydraCloudModalVisible(feature));
},
[dispatch]
);
const hideHydraCloudModal = useCallback(() => {
dispatch(setHydraCloudModalHidden());
}, [dispatch]);
return {
isHydraCloudModalVisible,
hydraCloudFeature: feature,
showHydraCloudModal,
hideHydraCloudModal,
};
}

View File

@@ -0,0 +1,90 @@
import { useDate } from "@renderer/hooks";
import type { UserAchievement } from "@types";
import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css";
import { EyeClosedIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { vars } from "@renderer/theme.css";
interface AchievementListProps {
achievements: UserAchievement[];
}
export function AchievementList({ achievements }: AchievementListProps) {
const { t } = useTranslation("achievement");
const { showHydraCloudModal } = useSubscription();
const { formatDateTime } = useDate();
return (
<ul className={styles.list}>
{achievements.map((achievement, index) => (
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
<img
className={styles.listItemImage({
unlocked: achievement.unlocked,
})}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div style={{ flex: 1 }}>
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
{achievement.hidden && (
<span
style={{ display: "flex" }}
title={t("hidden_achievement_tooltip")}
>
<EyeClosedIcon size={12} />
</span>
)}
{achievement.displayName}
</h4>
<p>{achievement.description}</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{achievement.points != undefined ? (
<div
style={{ display: "flex", alignItems: "center", gap: "4px" }}
title={t("achievement_earn_points", {
points: achievement.points,
})}
>
<HydraIcon width={20} height={20} />
<p style={{ fontSize: "1.1em" }}>{achievement.points}</p>
</div>
) : (
<button
onClick={() => showHydraCloudModal("achievements")}
style={{
display: "flex",
alignItems: "center",
gap: "4px",
cursor: "pointer",
color: vars.color.warning,
}}
title={t("achievement_earn_points", {
points: "???",
})}
>
<HydraIcon width={20} height={20} />
<p style={{ fontSize: "1.1em" }}>???</p>
</button>
)}
{achievement.unlockTime && (
<div
title={t("unlocked_at", {
date: formatDateTime(achievement.unlockTime),
})}
style={{ whiteSpace: "nowrap", gap: "4px", display: "flex" }}
>
<small>{formatDateTime(achievement.unlockTime)}</small>
</div>
)}
</div>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,71 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const panel = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
backgroundColor: vars.color.background,
display: "flex",
flexDirection: "column",
alignItems: "start",
justifyContent: "space-between",
borderBottom: `solid 1px ${vars.color.border}`,
});
export const content = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadDetailsRow = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: vars.color.body,
alignItems: "center",
});
export const downloadsLink = style({
color: vars.color.body,
textDecoration: "underline",
});
export const progressBar = recipe({
base: {
position: "absolute",
bottom: "0",
left: "0",
width: "100%",
height: "3px",
transition: "all ease 0.2s",
"::-webkit-progress-bar": {
backgroundColor: "transparent",
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
},
variants: {
disabled: {
true: {
opacity: vars.opacity.disabled,
},
},
},
});
export const link = style({
textAlign: "start",
color: vars.color.body,
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});

View File

@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { UserAchievement } from "@types";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { useUserDetails } from "@renderer/hooks";
import { vars } from "@renderer/theme.css";
import * as styles from "./achievement-panel.css";
export interface AchievementPanelProps {
achievements: UserAchievement[];
}
export function AchievementPanel({ achievements }: AchievementPanelProps) {
const { t } = useTranslation("achievement");
const { hasActiveSubscription } = useUserDetails();
const { showHydraCloudModal } = useSubscription();
const achievementsPointsTotal = achievements.reduce(
(acc, achievement) => acc + (achievement.points ?? 0),
0
);
const achievementsPointsEarnedSum = achievements.reduce(
(acc, achievement) =>
acc + (achievement.unlocked ? (achievement.points ?? 0) : 0),
0
);
if (!hasActiveSubscription) {
return (
<div className={styles.panel}>
<div className={styles.content}>
{t("earned_points")} <HydraIcon width={20} height={20} />
??? / ???
</div>
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className={styles.link}
>
<small style={{ color: vars.color.warning }}>
{t("how_to_earn_achievements_points")}
</small>
</button>
</div>
);
}
return (
<div className={styles.panel}>
<div className={styles.content}>
{t("earned_points")} <HydraIcon width={20} height={20} />
{achievementsPointsEarnedSum} / {achievementsPointsTotal}
</div>
</div>
);
}

View File

@@ -1,9 +1,8 @@
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks";
import { useAppDispatch, useUserDetails } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
import { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css";
import {
buildGameDetailsPath,
formatDownloadProgress,
@@ -11,11 +10,16 @@ import {
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { gameDetailsContext } from "@renderer/context";
import type { ComparedAchievements, UserAchievement } from "@types";
import type { ComparedAchievements } from "@types";
import { average } from "color.js";
import Color from "color";
import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list";
import * as styles from "./achievements.css";
import { AchievementList } from "./achievement-list";
import { AchievementPanel } from "./achievement-panel";
import { ComparedAchievementPanel } from "./compared-achievement-panel";
import { useSubscription } from "@renderer/hooks/use-subscription";
interface UserInfo {
id: string;
@@ -30,10 +34,6 @@ interface AchievementsContentProps {
comparedAchievements: ComparedAchievements | null;
}
interface AchievementListProps {
achievements: UserAchievement[];
}
interface AchievementSummaryProps {
user: UserInfo;
isComparison?: boolean;
@@ -42,7 +42,7 @@ interface AchievementSummaryProps {
function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
const { t } = useTranslation("achievement");
const { userDetails, hasActiveSubscription } = useUserDetails();
const { handleClickOpenCheckout } = useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const getProfileImage = (
user: Pick<UserInfo, "profileImageUrl" | "displayName">
@@ -93,7 +93,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
<h3>
<button
className={styles.subscriptionRequiredButton}
onClick={handleClickOpenCheckout}
onClick={() => showHydraCloudModal("achievements")}
>
{t("subscription_needed")}
</button>
@@ -171,38 +171,6 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
);
}
function AchievementList({ achievements }: AchievementListProps) {
const { t } = useTranslation("achievement");
const { formatDateTime } = useDate();
return (
<ul className={styles.list}>
{achievements.map((achievement, index) => (
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
<img
className={styles.listItemImage({
unlocked: achievement.unlocked,
})}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div style={{ flex: 1 }}>
<h4>{achievement.displayName}</h4>
<p>{achievement.description}</p>
</div>
{achievement.unlockTime && (
<div style={{ whiteSpace: "nowrap" }}>
<small>{t("unlocked_at")}</small>
<p>{formatDateTime(achievement.unlockTime)}</p>
</div>
)}
</li>
))}
</ul>
);
}
export function AchievementsContent({
otherUser,
comparedAchievements,
@@ -355,9 +323,15 @@ export function AchievementsContent({
)}
{otherUser ? (
<ComparedAchievementList achievements={comparedAchievements!} />
<>
<ComparedAchievementPanel achievements={comparedAchievements!} />
<ComparedAchievementList achievements={comparedAchievements!} />
</>
) : (
<AchievementList achievements={achievements!} />
<>
<AchievementPanel achievements={achievements!} />
<AchievementList achievements={achievements!} />
</>
)}
</section>
</div>

View File

@@ -1,8 +1,13 @@
import type { ComparedAchievements } from "@types";
import * as styles from "./achievements.css";
import { CheckCircleIcon, LockIcon } from "@primer/octicons-react";
import {
CheckCircleIcon,
EyeClosedIcon,
LockIcon,
} from "@primer/octicons-react";
import { useDate } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export interface ComparedAchievementListProps {
achievements: ComparedAchievements;
@@ -11,6 +16,7 @@ export interface ComparedAchievementListProps {
export function ComparedAchievementList({
achievements,
}: ComparedAchievementListProps) {
const { t } = useTranslation("achievement");
const { formatDateTime } = useDate();
return (
@@ -43,7 +49,17 @@ export function ComparedAchievementList({
loading="lazy"
/>
<div>
<h4>{achievement.displayName}</h4>
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}>
{achievement.hidden && (
<span
style={{ display: "flex" }}
title={t("hidden_achievement_tooltip")}
>
<EyeClosedIcon size={12} />
</span>
)}
{achievement.displayName}
</h4>
<p>{achievement.description}</p>
</div>
</div>
@@ -58,11 +74,9 @@ export function ComparedAchievementList({
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
title={formatDateTime(achievement.ownerStat.unlockTime!)}
>
<CheckCircleIcon />
<small>
{formatDateTime(achievement.ownerStat.unlockTime!)}
</small>
</div>
) : (
<div
@@ -86,11 +100,9 @@ export function ComparedAchievementList({
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
title={formatDateTime(achievement.targetStat.unlockTime!)}
>
<CheckCircleIcon />
<small>
{formatDateTime(achievement.targetStat.unlockTime!)}
</small>
</div>
) : (
<div

View File

@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import * as styles from "./achievement-panel.css";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { ComparedAchievements } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useUserDetails } from "@renderer/hooks";
export interface ComparedAchievementPanelProps {
achievements: ComparedAchievements;
}
export function ComparedAchievementPanel({
achievements,
}: ComparedAchievementPanelProps) {
const { t } = useTranslation("achievement");
const { hasActiveSubscription } = useUserDetails();
return (
<div
className={styles.panel}
style={{
display: "grid",
gridTemplateColumns: hasActiveSubscription ? "3fr 1fr 1fr" : "3fr 2fr",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{t("available_points")} <HydraIcon width={20} height={20} />{" "}
{achievements.achievementsPointsTotal}
</div>
{hasActiveSubscription && (
<div className={styles.content}>
<HydraIcon width={20} height={20} />
{achievements.owner.achievementsPointsEarnedSum ?? 0}
</div>
)}
<div className={styles.content}>
<HydraIcon width={20} height={20} />
{achievements.target.achievementsPointsEarnedSum}
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@ import "./catalogue.scss";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { downloadSourcesTable } from "@renderer/dexie";
import { FilterSection } from "./filter-section";
import { setFilters } from "@renderer/features";
import { setFilters, setPage } from "@renderer/features";
import { useTranslation } from "react-i18next";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Pagination } from "./pagination";
@@ -42,12 +42,11 @@ export default function Catalogue() {
const [results, setResults] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [itemsCount, setItemsCount] = useState(0);
const { formatNumber } = useFormat();
const { filters } = useAppSelector((state) => state.catalogueSearch);
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
const dispatch = useAppDispatch();
@@ -103,10 +102,6 @@ export default function Catalogue() {
}));
}, [steamGenresMapping, filters.genres]);
useEffect(() => {
setPage(1);
}, [filters]);
const steamUserTagsFilterItems = useMemo(() => {
if (!steamUserTags[language]) return [];
@@ -240,7 +235,7 @@ export default function Catalogue() {
}}
>
{groupedFilters.map((filter) => (
<li key={filter.label}>
<li key={`${filter.key}-${filter.value}`}>
<FilterItem
filter={filter.label}
orbColor={filter.orbColor}
@@ -308,7 +303,7 @@ export default function Catalogue() {
<Pagination
page={page}
totalPages={Math.ceil(itemsCount / PAGE_SIZE)}
onPageChange={setPage}
onPageChange={(page) => dispatch(setPage(page))}
/>
</div>
</div>

View File

@@ -14,6 +14,7 @@ import { steamUrlBuilder } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
const HERO_ANIMATION_THRESHOLD = 25;
@@ -31,9 +32,10 @@ export function GameDetailsContent() {
gameColor,
setGameColor,
hasNSFWContentBlocked,
handleClickOpenCheckout,
} = useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails();
const { setShowCloudSyncModal, getGameArtifacts } =
@@ -104,7 +106,7 @@ export function GameDetailsContent() {
}
if (!hasActiveSubscription) {
handleClickOpenCheckout();
showHydraCloudModal("backup");
return;
}

View File

@@ -8,7 +8,7 @@ import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useToast } from "@renderer/hooks";
@@ -159,16 +159,6 @@ export function DownloadSettingsModal({
</Button>
))}
</div>
{selectedDownloader != null &&
selectedDownloader !== Downloader.Torrent && (
<p style={{ marginTop: `${SPACING_UNIT}px` }}>
<span style={{ color: vars.color.warning }}>
{t("warning")}
</span>{" "}
{t("hydra_needs_to_remain_open")}
</p>
)}
</div>
<div

View File

@@ -21,6 +21,7 @@ import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useSubscription } from "@renderer/hooks/use-subscription";
const fakeAchievements: UserAchievement[] = [
{
@@ -67,15 +68,10 @@ export function Sidebar() {
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const {
gameTitle,
shopDetails,
objectId,
shop,
stats,
achievements,
handleClickOpenCheckout,
} = useContext(gameDetailsContext);
const { gameTitle, shopDetails, objectId, shop, stats, achievements } =
useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { t } = useTranslation("game_details");
const { formatDateTime } = useDate();
@@ -179,7 +175,7 @@ export function Sidebar() {
{!hasActiveSubscription && (
<button
className={styles.subscriptionRequiredButton}
onClick={handleClickOpenCheckout}
onClick={() => showHydraCloudModal("achievements")}
>
<CloudOfflineIcon size={16} />
<span>{t("achievements_not_sync")}</span>

View File

@@ -2,7 +2,7 @@ import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./profile-content.css";
import { Avatar, Link } from "@renderer/components";
@@ -13,6 +13,21 @@ export function FriendsBox() {
const { numberFormatter } = useFormat();
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
return (
<img
alt={game.title}
width={16}
style={{ borderRadius: 4 }}
src={game.iconUrl}
/>
);
}
return <SteamLogo width={16} height={16} />;
};
if (!userProfile?.friends.length) return null;
return (
@@ -27,7 +42,14 @@ export function FriendsBox() {
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.friends.map((friend) => (
<li key={friend.id}>
<li
key={friend.id}
title={
friend.currentGame
? t("playing", { game: friend.currentGame.title })
: undefined
}
>
<Link to={`/profile/${friend.id}`} className={styles.listItem}>
<Avatar
size={32}
@@ -35,7 +57,19 @@ export function FriendsBox() {
alt={friend.displayName}
/>
<span className={styles.friendName}>{friend.displayName}</span>
<div
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span className={styles.friendName}>
{friend.displayName}
</span>
{friend.currentGame && (
<div style={{ display: "flex", gap: 4 }}>
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</Link>
</li>
))}

View File

@@ -105,6 +105,22 @@ export const listItem = style({
},
});
export const statsListItem = style({
display: "flex",
flexDirection: "column",
transition: "all ease 0.1s",
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
textDecoration: "none",
},
});
export const gamesGrid = style({
listStyle: "none",
margin: "0",
@@ -203,3 +219,12 @@ export const achievementsProgressBar = style({
borderRadius: "4px",
},
});
export const link = style({
textAlign: "start",
color: vars.color.body,
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});

View File

@@ -21,6 +21,8 @@ import {
formatDownloadProgress,
} from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { UserStatsBox } from "./user-stats-box";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext);
@@ -135,6 +137,7 @@ export function ProfileContent() {
position: "relative",
display: "flex",
}}
title={game.title}
className={styles.game}
>
<button
@@ -155,7 +158,7 @@ export function ProfileContent() {
height: "100%",
width: "100%",
background:
"linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%)",
"linear-gradient(0deg, rgba(0, 0, 0, 0.75) 25%, transparent 100%)",
padding: 8,
}}
>
@@ -185,6 +188,22 @@ export function ProfileContent() {
flexDirection: "column",
}}
>
{game.achievementsPointsEarnedSum > 0 && (
<div
style={{
display: "flex",
justifyContent: "start",
gap: 8,
marginBottom: 4,
color: vars.color.muted,
}}
>
<HydraIcon width={16} height={16} />
{numberFormatter.format(
game.achievementsPointsEarnedSum
)}
</div>
)}
<div
style={{
display: "flex",
@@ -249,6 +268,7 @@ export function ProfileContent() {
{shouldShowRightContent && (
<div className={styles.rightContent}>
<UserStatsBox />
<RecentGamesBox />
<FriendsBox />

View File

@@ -0,0 +1,125 @@
import * as styles from "./profile-content.css";
import { useCallback, useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { useFormat } from "@renderer/hooks";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { vars } from "@renderer/theme.css";
export function UserStatsBox() {
const { showHydraCloudModal } = useSubscription();
const { userStats, isMe } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const formatPlayTime = useCallback(
(playTimeInSeconds: number) => {
const seconds = playTimeInSeconds;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
if (!userStats) return null;
return (
<div>
<div className={styles.sectionHeader}>
<h2>{t("stats")}</h2>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>
{t("achievements_unlocked")}
</h3>
{userStats.unlockedAchievementSum !== undefined ? (
<div
style={{ display: "flex", justifyContent: "space-between" }}
>
<p className={styles.listItemDescription}>
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
{t("achievements")}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements")}
className={styles.link}
>
<small style={{ color: vars.color.warning }}>
{t("show_achievements_on_profile")}
</small>
</button>
)}
</li>
)}
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>{t("earned_points")}</h3>
{userStats.achievementsPointsEarnedSum !== undefined ? (
<div
style={{ display: "flex", justifyContent: "space-between" }}
>
<p className={styles.listItemDescription}>
<HydraIcon width={20} height={20} />
{numberFormatter.format(
userStats.achievementsPointsEarnedSum.value
)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile:
userStats.achievementsPointsEarnedSum.topPercentile,
})}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className={styles.link}
>
<small style={{ color: vars.color.warning }}>
{t("show_points_on_profile")}
</small>
</button>
)}
</li>
)}
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>{t("total_play_time")}</h3>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p className={styles.listItemDescription}>
<ClockIcon />
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
})}
</p>
</div>
</li>
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const friendListContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
height: "54px",
minHeight: "54px",
transition: "all ease 0.2s",
position: "relative",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendListButton = style({
display: "flex",
alignItems: "center",
position: "absolute",
cursor: "pointer",
height: "100%",
width: "100%",
flexDirection: "row",
color: vars.color.body,
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
padding: `0 ${SPACING_UNIT}px`,
});
export const friendRequestItem = style({
color: vars.color.body,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const acceptRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.success,
},
});
export const cancelRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.danger,
},
});
export const friendCodeButton = style({
color: vars.color.body,
cursor: "pointer",
display: "flex",
gap: `${SPACING_UNIT / 2}px`,
alignItems: "center",
transition: "all ease 0.2s",
":hover": {
color: vars.color.muted,
},
});

View File

@@ -0,0 +1,38 @@
import { Button, Modal } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export interface HydraCloudModalProps {
feature: string;
visible: boolean;
onClose: () => void;
}
export const HydraCloudModal = ({
feature,
visible,
onClose,
}: HydraCloudModalProps) => {
const { t } = useTranslation("hydra_cloud");
const handleClickOpenCheckout = () => {
window.electron.openCheckout();
};
return (
<Modal visible={visible} title={t("hydra_cloud")} onClose={onClose}>
<div
data-hydra-cloud-feature={feature}
style={{
display: "flex",
width: "500px",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{t("hydra_cloud_feature_found")}
<Button onClick={handleClickOpenCheckout}>{t("learn_more")}</Button>
</div>
</Modal>
);
};

View File

@@ -7,6 +7,7 @@ import {
toastSlice,
userDetailsSlice,
gameRunningSlice,
subscriptionSlice,
repacksSlice,
catalogueSearchSlice,
} from "@renderer/features";
@@ -20,6 +21,7 @@ export const store = configureStore({
toast: toastSlice.reducer,
userDetails: userDetailsSlice.reducer,
gameRunning: gameRunningSlice.reducer,
subscription: subscriptionSlice.reducer,
repacks: repacksSlice.reducer,
catalogueSearch: catalogueSearchSlice.reducer,
},