feat: adding change hero

This commit is contained in:
Chubby Granny Chaser 2024-10-05 02:25:46 +01:00
commit 58502aeb1f
No known key found for this signature in database
47 changed files with 3572 additions and 1461 deletions

View file

@ -9,7 +9,7 @@
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
/>
</head>
<body style="background-color: #1c1c1c">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View file

@ -39,7 +39,6 @@ globalStyle("body", {
userSelect: "none",
fontFamily: "Noto Sans, sans-serif",
fontSize: vars.size.body,
background: vars.color.background,
color: vars.color.body,
margin: "0",
});

Binary file not shown.

View file

@ -12,6 +12,7 @@ import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
import type {
Game,
GameAchievement,
GameRepack,
GameShop,
GameStats,
@ -36,6 +37,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
showRepacksModal: false,
showGameOptionsModal: false,
stats: null,
achievements: [],
hasNSFWContentBlocked: false,
setGameColor: () => {},
selectGameExecutable: async () => null,
@ -62,6 +64,7 @@ export function GameDetailsContextProvider({
shop,
}: GameDetailsContextProps) {
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
@ -133,6 +136,15 @@ export function GameDetailsContextProvider({
setStats(result);
});
window.electron
.getGameAchievements(objectId!, shop as GameShop)
.then((achievements) => {
setAchievements(achievements);
})
.catch(() => {
// TODO: handle user not logged in error
});
updateGame();
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
@ -141,6 +153,7 @@ export function GameDetailsContextProvider({
setGame(null);
setIsLoading(true);
setisGameRunning(false);
setAchievements([]);
dispatch(setHeaderTitle(gameTitle));
}, [objectId, gameTitle, dispatch]);
@ -161,6 +174,23 @@ export function GameDetailsContextProvider({
};
}, [game?.id, isGameRunning, updateGame]);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(objectId, shop) => {
if (objectId !== objectId || shop !== shop) return;
window.electron
.getGameAchievements(objectId!, shop as GameShop)
.then(setAchievements)
.catch(() => {});
}
);
return () => {
unsubscribe();
};
}, [objectId, shop]);
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@ -204,6 +234,7 @@ export function GameDetailsContextProvider({
showGameOptionsModal,
showRepacksModal,
stats,
achievements,
hasNSFWContentBlocked,
setHasNSFWContentBlocked,
setGameColor,

View file

@ -1,5 +1,6 @@
import type {
Game,
GameAchievement,
GameRepack,
GameShop,
GameStats,
@ -19,6 +20,7 @@ export interface GameDetailsContext {
showRepacksModal: boolean;
showGameOptionsModal: boolean;
stats: GameStats | null;
achievements: GameAchievement[];
hasNSFWContentBlocked: boolean;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>;

View file

@ -25,6 +25,7 @@ import type {
UserStats,
UserDetails,
FriendRequestSync,
GameAchievement,
GameArtifact,
LudusaviBackup,
} from "@types";
@ -65,6 +66,17 @@ declare global {
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
getGameAchievements: (
objectId: string,
shop: GameShop
) => Promise<GameAchievement[]>;
onAchievementUnlocked: (
cb: (
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => void
) => () => Electron.IpcRenderer;
/* Library */
addGameToLibrary: (

View file

@ -1,4 +1,4 @@
import { formatDistance, subMilliseconds } from "date-fns";
import { format, formatDistance, subMilliseconds } from "date-fns";
import type { FormatDistanceOptions } from "date-fns";
import {
ptBR,
@ -67,5 +67,13 @@ export function useDate() {
return "";
}
},
format: (timestamp: number): string => {
const locale = getDateLocale();
return format(
timestamp,
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy - HH:mm"
);
},
};
}

View file

@ -28,6 +28,7 @@ import {
import { store } from "./store";
import resources from "@locales";
import { Achievement } from "./pages/achievement/achievement";
import "./workers";
import { RepacksContextProvider } from "./context";
@ -69,6 +70,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" Component={Settings} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
<Route path="/achievement-notification" Component={Achievement} />
</Routes>
</HashRouter>
</RepacksContextProvider>

View file

@ -0,0 +1,44 @@
import { recipe } from "@vanilla-extract/recipes";
import { vars } from "../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
const animationIn = keyframes({
"0%": { transform: `translateY(-240px)` },
"100%": { transform: "translateY(0)" },
});
const animationOut = keyframes({
"0%": { transform: `translateY(0)` },
"100%": { transform: "translateY(-240px)" },
});
export const container = recipe({
base: {
marginTop: "24px",
marginLeft: "24px",
animationDuration: "1.0s",
height: "60px",
display: "flex",
},
variants: {
closing: {
true: {
animationName: animationOut,
transform: "translateY(-240px)",
},
false: {
animationName: animationIn,
transform: "translateY(0)",
},
},
},
});
export const content = style({
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
background: vars.color.background,
paddingRight: "8px",
});

View file

@ -0,0 +1,117 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import * as styles from "./achievement.css";
interface AchievementInfo {
displayName: string;
iconUrl: string;
}
const NOTIFICATION_TIMEOUT = 4000;
export function Achievement() {
const { t } = useTranslation("achievement");
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [achievements, setAchievements] = useState<AchievementInfo[]>([]);
const [currentAchievement, setCurrentAchievement] =
useState<AchievementInfo | null>(null);
const achievementAnimation = useRef(-1);
const closingAnimation = useRef(-1);
const visibleAnimation = useRef(-1);
const audio = useMemo(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
audio.preload = "auto";
return audio;
}, []);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(_object, _shop, achievements) => {
if (!achievements || !achievements.length) return;
setAchievements((ach) => ach.concat(achievements));
audio.play();
}
);
return () => {
unsubscribe();
};
}, [audio]);
const hasAchievementsPending = achievements.length > 0;
const startAnimateClosing = useCallback(() => {
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
setIsClosing(true);
const zero = performance.now();
closingAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= 1000) {
closingAnimation.current = requestAnimationFrame(animateClosing);
} else {
setIsVisible(false);
}
}
);
}, []);
useEffect(() => {
if (hasAchievementsPending) {
setIsClosing(false);
setIsVisible(true);
let zero = performance.now();
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
achievementAnimation.current = requestAnimationFrame(
function animateLock(time) {
if (time - zero > NOTIFICATION_TIMEOUT) {
zero = performance.now();
setAchievements((ach) => ach.slice(1));
}
achievementAnimation.current = requestAnimationFrame(animateLock);
}
);
} else {
startAnimateClosing();
}
}, [hasAchievementsPending]);
useEffect(() => {
if (achievements.length) {
setCurrentAchievement(achievements[0]);
}
}, [achievements]);
if (!isVisible || !currentAchievement) return null;
return (
<div className={styles.container({ closing: isClosing })}>
<div className={styles.content}>
<img
src={currentAchievement.iconUrl}
alt={currentAchievement.displayName}
style={{ flex: 1, width: "60px" }}
/>
<div>
<p>{t("achievement_unlocked")}</p>
<p>{currentAchievement.displayName}</p>
</div>
</div>
</div>
);
}

View file

@ -5,8 +5,9 @@ import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useDate, useFormat } from "@renderer/hooks";
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import { SPACING_UNIT } from "@renderer/theme.css";
export function Sidebar() {
const [_howLongToBeat, _setHowLongToBeat] = useState<{
@ -17,9 +18,11 @@ export function Sidebar() {
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, stats } = useContext(gameDetailsContext);
const { gameTitle, shopDetails, stats, achievements } =
useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const { format } = useDate();
const { numberFormatter } = useFormat();
@ -45,6 +48,47 @@ export function Sidebar() {
isLoading={howLongToBeat.isLoading}
/> */}
{achievements.length > 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px`,
}}
>
{achievements.map((achievement, index) => (
<div
key={index}
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
title={achievement.description}
>
<img
style={{
height: "72px",
width: "72px",
filter: achievement.unlocked ? "none" : "grayscale(100%)",
}}
src={
achievement.unlocked ? achievement.icon : achievement.icongray
}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<p>{achievement.displayName}</p>
{achievement.unlockTime && format(achievement.unlockTime)}
</div>
</div>
))}
</div>
)}
{stats && (
<>
<div