feat: returning with edit profile modal

This commit is contained in:
Chubby Granny Chaser 2024-09-13 00:03:58 +01:00
commit 2304e19558
No known key found for this signature in database
78 changed files with 1810 additions and 906 deletions

View file

@ -0,0 +1,13 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const actions = style({
display: "flex",
alignSelf: "flex-end",
gap: `${SPACING_UNIT * 2}px`,
});
export const descriptionText = style({
fontSize: "16px",
lineHeight: "24px",
});

View file

@ -0,0 +1,30 @@
import { Button } from "../button/button";
import { Modal, type ModalProps } from "../modal/modal";
import * as styles from "./confirmation-modal.css";
export interface ConfirmationModalProps extends ModalProps {
confirmButtonLabel: string;
cancelButtonLabel: string;
descriptionText: string;
}
export function ConfirmationModal({
confirmButtonLabel,
cancelButtonLabel,
descriptionText,
...props
}: ConfirmationModalProps) {
return (
<Modal {...props}>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<p className={styles.descriptionText}>{descriptionText}</p>
<div className={styles.actions}>
<Button theme="danger">{cancelButtonLabel}</Button>
<Button>{confirmButtonLabel}</Button>
</div>
</div>
</Modal>
);
}

View file

@ -1,11 +1,16 @@
import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
import type { CatalogueEntry } from "@types";
import {
DownloadIcon,
FileDirectoryIcon,
PeopleIcon,
} from "@primer/octicons-react";
import type { CatalogueEntry, GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { useCallback, useMemo, useState } from "react";
export interface GameCardProps
extends React.DetailedHTMLProps<
@ -22,12 +27,35 @@ const shopIcon = {
export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const [stats, setStats] = useState<GameStats | null>(null);
const { i18n } = useTranslation();
const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker))
);
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectID, game.shop).then((stats) => {
setStats(stats);
});
}
}, [game, stats]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
return (
<button {...props} type="button" className={styles.card}>
<button
{...props}
type="button"
className={styles.card}
onMouseEnter={handleHover}
>
<div className={styles.backdrop}>
<img src={game.cover} alt={game.title} className={styles.cover} />
@ -48,19 +76,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
) : (
<p className={styles.noDownloadsLabel}>{t("no_downloads")}</p>
)}
<div className={styles.specifics}>
<div className={styles.specificsItem}>
<DownloadIcon />
<span>{game.repacks.length}</span>
<span>
{stats ? numberFormatter.format(stats.downloadCount) : "…"}
</span>
</div>
{game.repacks.length > 0 && (
<div className={styles.specificsItem}>
<FileDirectoryIcon />
<span>{game.repacks.at(0)?.fileSize}</span>
</div>
)}
<div className={styles.specificsItem}>
<PeopleIcon />
<span>
{stats ? numberFormatter.format(stats?.playerCount) : "…"}
</span>
</div>
</div>
</div>
</div>

View file

@ -11,3 +11,4 @@ export * from "./link/link";
export * from "./select-field/select-field";
export * from "./toast/toast";
export * from "./badge/badge";
export * from "./confirmation-modal/confirmation-modal";

View file

@ -34,7 +34,7 @@ export const profileButtonContent = style({
export const profileAvatar = style({
width: "35px",
height: "35px",
borderRadius: "50%",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
@ -52,17 +52,6 @@ export const profileButtonInformation = style({
minWidth: 0,
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});
export const profileButtonTitle = style({
fontWeight: "bold",
fontSize: vars.size.body,
@ -85,11 +74,11 @@ export const friendsButton = style({
position: "relative",
transition: "all ease 0.3s",
":hover": {
backgroundColor: "#DADBE1",
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendsButtonLabel = style({
export const friendsButtonBadge = style({
backgroundColor: vars.color.success,
display: "flex",
justifyContent: "center",

View file

@ -2,10 +2,9 @@ import { useNavigate } from "react-router-dom";
import { PeopleIcon, PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { FriendRequest } from "@types";
export function SidebarProfile() {
const navigate = useNavigate();
@ -14,17 +13,13 @@ export function SidebarProfile() {
const { userDetails, friendRequests, showFriendsModal } = useUserDetails();
const [receivedRequests, setReceivedRequests] = useState<FriendRequest[]>([]);
useEffect(() => {
setReceivedRequests(
friendRequests.filter((request) => request.type === "RECEIVED")
);
const receivedRequests = useMemo(() => {
return friendRequests.filter((request) => request.type === "RECEIVED");
}, [friendRequests]);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const handleButtonClick = () => {
const handleProfileClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow();
return;
@ -33,12 +28,35 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails!.id}`);
};
const friendsButton = useMemo(() => {
if (!userDetails) return null;
return (
<button
type="button"
className={styles.friendsButton}
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
>
{receivedRequests.length > 0 && (
<small className={styles.friendsButtonBadge}>
{receivedRequests.length > 99 ? "99+" : receivedRequests.length}
</small>
)}
<PeopleIcon size={16} />
</button>
);
}, [userDetails, t, receivedRequests, showFriendsModal]);
return (
<div className={styles.profileContainer}>
<button
type="button"
className={styles.profileButton}
onClick={handleButtonClick}
onClick={handleProfileClick}
>
<div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}>
@ -76,17 +94,7 @@ export function SidebarProfile() {
</div>
</button>
<button
type="button"
className={styles.friendsButton}
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
>
<small className={styles.friendsButtonLabel}>10</small>
<PeopleIcon size={16} />
</button>
{friendsButton}
</div>
);
}

View file

@ -21,6 +21,11 @@ export const sidebar = recipe({
pointerEvents: "none",
},
},
darwin: {
true: {
paddingTop: `${SPACING_UNIT * 6}px`,
},
},
},
});

View file

@ -153,12 +153,14 @@ export function Sidebar() {
<>
<aside
ref={sidebarRef}
className={styles.sidebar({ resizing: isResizing })}
className={styles.sidebar({
resizing: isResizing,
darwin: window.electron.platform === "darwin",
})}
style={{
width: sidebarWidth,
minWidth: sidebarWidth,
maxWidth: sidebarWidth,
paddingTop: 8 * 6,
}}
>
<SidebarProfile />
@ -180,8 +182,6 @@ export function Sidebar() {
>
{render(isDownloading)}
<span>{t(nameKey)}</span>
<ChevronDownIcon />
</button>
</li>
))}

View file

@ -9,3 +9,5 @@ export const DOWNLOADER_NAME = {
[Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View file

@ -12,6 +12,8 @@ export interface UserProfileContext {
heroBackground: string;
/* Indicates if the current user is viewing their own profile */
isMe: boolean;
getUserProfile: () => Promise<void>;
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@ -20,6 +22,7 @@ export const userProfileContext = createContext<UserProfileContext>({
userProfile: null,
heroBackground: DEFAULT_USER_PROFILE_BACKGROUND,
isMe: false,
getUserProfile: async () => {},
});
const { Provider } = userProfileContext;
@ -47,7 +50,7 @@ export function UserProfileContextProvider({
format: "hex",
});
return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
return `linear-gradient(135deg, ${darkenColor(output as string, 0.5)}, ${darkenColor(output as string, 0.6, 0.5)})`;
};
const { t } = useTranslation("user_profile");
@ -73,6 +76,9 @@ export function UserProfileContextProvider({
}, [navigate, showErrorToast, userId, t]);
useEffect(() => {
setUserProfile(null);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
getUserProfile();
}, [getUserProfile]);
@ -82,6 +88,7 @@ export function UserProfileContextProvider({
userProfile,
heroBackground,
isMe: userDetails?.id === userProfile?.id,
getUserProfile,
}}
>
{children}

View file

@ -1,3 +1,4 @@
import type { CatalogueCategory } from "@shared";
import type {
AppUpdaterEvent,
CatalogueEntry,
@ -19,6 +20,7 @@ import type {
UserFriends,
UserBlocks,
UpdateProfileRequest,
GameStats,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -40,7 +42,7 @@ declare global {
/* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: () => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
getGameShopDetails: (
objectID: string,
shop: GameShop,
@ -57,6 +59,7 @@ declare global {
prevCursor?: number
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
/* Library */
addGameToLibrary: (

View file

@ -27,7 +27,7 @@ import {
import { store } from "./store";
import * as resources from "@locales";
import resources from "@locales";
Sentry.init({});

View file

@ -6,8 +6,7 @@ import { useDate, useDownload } from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
@ -36,7 +35,7 @@ export function HeroPanelPlaytime() {
});
}, [i18n.language]);
const formatPlayTime = () => {
const formattedPlayTime = useMemo(() => {
const milliseconds = game?.playTimeInMilliseconds || 0;
const seconds = milliseconds / 1000;
const minutes = seconds / 60;
@ -49,7 +48,7 @@ export function HeroPanelPlaytime() {
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
}, [game?.playTimeInMilliseconds, numberFormatter, t]);
if (!game) return null;
@ -96,7 +95,7 @@ export function HeroPanelPlaytime() {
<>
<p>
{t("play_time", {
amount: formatPlayTime(),
amount: formattedPlayTime,
})}
</p>

View file

@ -60,3 +60,11 @@ export const noResults = style({
gap: "16px",
gridColumn: "1 / -1",
});
export const buttonsList = style({
display: "flex",
listStyle: "none",
margin: "0",
padding: "0",
gap: `${SPACING_UNIT}px`,
});

View file

@ -13,6 +13,7 @@ import * as styles from "./home.css";
import { vars } from "@renderer/theme.css";
import Lottie from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared";
export function Home() {
const { t } = useTranslation("home");
@ -21,15 +22,25 @@ export function Home() {
const [isLoading, setIsLoading] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [catalogue, setCatalogue] = useState<CatalogueEntry[]>([]);
const [currentCatalogueCategory, setCurrentCatalogueCategory] = useState(
CatalogueCategory.Hot
);
const getCatalogue = useCallback(() => {
const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, CatalogueEntry[]>
>({
[CatalogueCategory.Hot]: [],
[CatalogueCategory.Weekly]: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
setCurrentCatalogueCategory(category);
setIsLoading(true);
window.electron
.getCatalogue()
.getCatalogue(category)
.then((catalogue) => {
setCatalogue(catalogue);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
@ -58,11 +69,13 @@ export function Home() {
useEffect(() => {
setIsLoading(true);
getCatalogue();
getCatalogue(CatalogueCategory.Hot);
getRandomGame();
}, [getCatalogue, getRandomGame]);
const categories = Object.values(CatalogueCategory);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
@ -71,7 +84,22 @@ export function Home() {
<Hero />
<section className={styles.homeHeader}>
<h2>{t("trending")}</h2>
<ul className={styles.buttonsList}>
{categories.map((category) => (
<li key={category}>
<Button
theme={
category === currentCatalogueCategory
? "primary"
: "outline"
}
onClick={() => getCatalogue(category)}
>
{t(category)}
</Button>
</li>
))}
</ul>
<Button
onClick={handleRandomizerClick}
@ -89,12 +117,14 @@ export function Home() {
</Button>
</section>
<h2>{t(currentCatalogueCategory)}</h2>
<section className={styles.cards}>
{isLoading
? Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))
: catalogue.map((result) => (
: catalogue[currentCatalogueCategory].map((result) => (
<GameCard
key={result.objectID}
game={result}

View file

@ -1,9 +1,10 @@
import { vars, SPACING_UNIT } from "../../theme.css";
import { vars, SPACING_UNIT } from "../../../theme.css";
import { globalStyle, style } from "@vanilla-extract/css";
export const gameCover = style({
transition: "all ease 0.2s",
boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)",
width: "100%",
":before": {
content: "",
top: "0",
@ -60,14 +61,66 @@ export const friend = style({
alignItems: "center",
});
export const friendAvatar = style({
width: "50px",
height: "50px",
borderRadius: "4px",
});
export const friendName = style({
color: vars.color.muted,
fontWeight: "bold",
fontSize: vars.size.body,
});
export const rightContent = style({
width: "100%",
height: "100%",
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
flexDirection: "column",
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "200px",
},
"(min-width: 1024px)": {
maxWidth: "300px",
width: "100%",
},
"(min-width: 1280px)": {
width: "100%",
maxWidth: "400px",
},
},
});
export const listItem = style({
display: "flex",
cursor: "pointer",
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 * 2}px`,
alignItems: "center",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
textDecoration: "none",
},
});
export const gamesGrid = style({
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gap: `${SPACING_UNIT * 2}px`,
"@media": {
"(min-width: 768px)": {
gridTemplateColumns: "repeat(2, 1fr)",
},
"(min-width: 1250px)": {
gridTemplateColumns: "repeat(3, 1fr)",
},
"(min-width: 1600px)": {
gridTemplateColumns: "repeat(8, 1fr)",
},
},
});

View file

@ -1,19 +1,27 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useEffect, useMemo } from "react";
import { ProfileHero } from "./profile-hero/profile-hero";
import { useCallback, useContext, useEffect, useMemo } from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { ClockIcon, PeopleIcon } from "@primer/octicons-react";
import { ClockIcon } from "@primer/octicons-react";
import { Link } from "@renderer/components";
import { useTranslation } from "react-i18next";
import { UserGame } from "@types";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useNavigate } from "react-router-dom";
export function ProfileContent() {
const { userProfile } = useContext(userProfileContext);
const dispatch = useAppDispatch();
const { i18n, t } = useTranslation("user_profile");
useEffect(() => {
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
@ -21,8 +29,42 @@ export function ProfileContent() {
}, [userProfile, dispatch]);
const truncatedGamesList = useMemo(() => {
if (!userProfile) return [];
return userProfile?.libraryGames.slice(0, 12);
}, [userProfile?.libraryGames]);
}, [userProfile]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
const navigate = useNavigate();
const formatPlayTime = useCallback(
(game: UserGame) => {
const seconds = game?.playTimeInSeconds || 0;
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]
);
const buildUserGameDetailsPath = (game: UserGame) =>
buildGameDetailsPath({
...game,
objectID: game.objectId,
});
if (!userProfile) return null;
return (
<div>
@ -35,23 +77,14 @@ export function ProfileContent() {
padding: `${SPACING_UNIT * 3}px`,
}}
>
<div style={{}}>
<div style={{ flex: 1 }}>
<div className={styles.sectionHeader}>
<h2>Library</h2>
<h2>{t("library")}</h2>
<h3>{userProfile?.libraryGames.length}</h3>
<h3>{numberFormatter.format(userProfile.libraryGames.length)}</h3>
</div>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<ul className={styles.gamesGrid}>
{truncatedGamesList.map((game) => (
<li
key={game.objectId}
@ -64,13 +97,20 @@ export function ProfileContent() {
>
<button
type="button"
style={{ cursor: "pointer" }}
style={{
cursor: "pointer",
}}
className={styles.gameCover}
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
style={{ width: "100%" }}
style={{
width: "100%",
objectFit: "cover",
borderRadius: 4,
}}
/>
</button>
</li>
@ -78,14 +118,7 @@ export function ProfileContent() {
</ul>
</div>
<div
style={{
minWidth: 350,
display: "flex",
gap: SPACING_UNIT * 2,
flexDirection: "column",
}}
>
<div className={styles.rightContent}>
<div>
<div className={styles.sectionHeader}>
<h2>Played recently</h2>
@ -94,14 +127,10 @@ export function ProfileContent() {
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.recentGames.map((game) => (
<li>
<button
type="button"
style={{
cursor: "pointer",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className={styles.listItem}
>
<img
src={game.iconUrl}
@ -113,21 +142,27 @@ export function ProfileContent() {
}}
/>
<div style={{ display: "flex", flexDirection: "column" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<span style={{ fontWeight: "bold" }}>{game.title}</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT / 2}px`,
gap: `${SPACING_UNIT}px`,
}}
>
<ClockIcon />
<span>{game.playTimeInSeconds}</span>
<small>{formatPlayTime(game)}</small>
</div>
</div>
</button>
</Link>
</li>
))}
</ul>
@ -136,30 +171,31 @@ export function ProfileContent() {
<div>
<div className={styles.sectionHeader}>
<h2>Friends</h2>
<h2>{t("friends")}</h2>
<span>{userProfile?.totalFriends}</span>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.friends.map((friend) => (
<li>
<button
type="button"
style={{ cursor: "pointer" }}
className={styles.friend}
<li key={friend.id}>
<Link
to={`/profile/${friend.id}`}
className={styles.listItem}
>
<img
src={friend.profileImageUrl}
alt={friend.displayName}
style={{ width: "100%" }}
className={styles.friendAvatar}
style={{
width: "30px",
height: "30px",
borderRadius: "4px",
}}
/>
<span className={styles.friendName}>
{friend.displayName}
</span>
</button>
</Link>
</li>
))}
</ul>

View file

@ -6,11 +6,11 @@ export const profileContentBox = style({
flexDirection: "column",
});
export const profileAvatarContainer = style({
export const profileAvatarButton = style({
width: "96px",
minWidth: "96px",
height: "96px",
borderRadius: "50%",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
@ -18,7 +18,11 @@ export const profileAvatarContainer = style({
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
zIndex: 1,
cursor: "pointer",
transition: "all ease 0.3s",
":hover": {
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.7)",
},
});
export const profileAvatar = style({
@ -44,3 +48,24 @@ export const profileDisplayName = style({
textOverflow: "ellipsis",
width: "100%",
});
export const heroPanel = style({
width: "100%",
height: "72px",
minHeight: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
backdropFilter: `blur(10px)`,
borderTop: `solid 1px rgba(255, 255, 255, 0.1)`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
backgroundColor: "rgba(0, 0, 0, 0.3)",
});
export const userInformation = style({
display: "flex",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
});

View file

@ -1,101 +1,164 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-hero.css";
import { useContext, useMemo } from "react";
import { useContext, useMemo, useState } from "react";
import { userProfileContext } from "@renderer/context";
import {
CheckCircleFillIcon,
PencilIcon,
PersonIcon,
SignOutIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { Button, Link } from "@renderer/components";
import { useTranslation } from "react-i18next";
import { useDate } from "@renderer/hooks";
import { useDate, useToast, useUserDetails } from "@renderer/hooks";
import { addSeconds } from "date-fns";
import { useNavigate } from "react-router-dom";
import type { FriendRequestAction } from "@types";
import { UserProfileSettingsModal } from "../user-profile-settings-modal";
type FriendAction =
| FriendRequestAction
| ("BLOCK" | "UNDO_FRIENDSHIP" | "SEND");
export function ProfileHero() {
const { userProfile, heroBackground, isMe } = useContext(userProfileContext);
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const context = useContext(userProfileContext);
const {
signOut,
updateFriendRequestState,
sendFriendRequest,
undoFriendship,
blockUser,
} = useUserDetails();
const { isMe, heroBackground, getUserProfile } = context;
const userProfile = context.userProfile!;
const { currentGame } = userProfile;
const { t } = useTranslation("user_profile");
const { formatDistance } = useDate();
if (!userProfile) return null;
const { showSuccessToast, showErrorToast } = useToast();
const { currentGame } = userProfile;
const navigate = useNavigate();
console.log(userProfile);
const handleSignOut = async () => {
await signOut();
showSuccessToast(t("successfully_signed_out"));
navigate("/");
};
const handleFriendAction = (userId: string, action: FriendAction) => {
try {
if (action === "UNDO_FRIENDSHIP") {
undoFriendship(userId).then(getUserProfile);
return;
}
if (action === "BLOCK") {
blockUser(userId).then(() => {
showSuccessToast(t("user_blocked_successfully"));
navigate(-1);
});
return;
}
if (action === "SEND") {
sendFriendRequest(userProfile.id).then(getUserProfile);
return;
}
updateFriendRequestState(userId, action).then(getUserProfile);
} catch (err) {
showErrorToast(t("try_again"));
}
};
const profileActions = useMemo(() => {
if (isMe) {
return (
<>
<Button theme="outline">{t("settings")}</Button>
<Button theme="outline" onClick={() => setShowEditProfileModal(true)}>
<PencilIcon />
{t("edit_profile")}
</Button>
<Button theme="danger">{t("sign_out")}</Button>
<Button theme="danger" onClick={handleSignOut}>
<SignOutIcon />
{t("sign_out")}
</Button>
</>
);
}
// if (userProfile.relation == null) {
// return (
// <>
// <Button
// theme="outline"
// onClick={() => handleFriendAction(userProfile.id, "SEND")}
// >
// {t("add_friend")}
// </Button>
if (userProfile.relation == null) {
return (
<>
<Button
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")}
>
{t("add_friend")}
</Button>
// <Button theme="danger" onClick={() => setShowUserBlockModal(true)}>
// {t("block_user")}
// </Button>
// </>
// );
// }
<Button
theme="danger"
onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
>
{t("block_user")}
</Button>
</>
);
}
// if (userProfile.relation.status === "ACCEPTED") {
// return (
// <>
// <Button
// theme="outline"
// // className={styles.cancelRequestButton}
// // onClick={() => setShowUndoFriendshipModal(true)}
// >
// <XCircleFillIcon size={28} /> {t("undo_friendship")}
// </Button>
// </>
// );
// }
if (userProfile.relation.status === "ACCEPTED") {
return (
<Button
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")}
>
<XCircleFillIcon />
{t("undo_friendship")}
</Button>
);
}
// if (userProfile.relation.BId === userProfile.id) {
// return (
// <Button
// theme="outline"
// // className={styles.cancelRequestButton}
// // onClick={() =>
// // handleFriendAction(userProfile.relation!.BId, "CANCEL")
// // }
// >
// <XCircleFillIcon size={28} /> {t("cancel_request")}
// </Button>
// );
// }
if (userProfile.relation.BId === userProfile.id) {
return (
<Button
theme="outline"
onClick={() =>
handleFriendAction(userProfile.relation!.BId, "CANCEL")
}
>
<XCircleFillIcon size={28} /> {t("cancel_request")}
</Button>
);
}
return (
<>
<Button
theme="outline"
// onClick={() =>
// handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
// }
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
}
>
<CheckCircleFillIcon size={28} /> {t("accept_request")}
</Button>
<Button
theme="outline"
// onClick={() =>
// handleFriendAction(userProfile.relation!.AId, "REFUSED")
// }
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "REFUSED")
}
>
<XCircleFillIcon size={28} /> {t("ignore_request")}
</Button>
@ -105,19 +168,27 @@ export function ProfileHero() {
return (
<>
{/* <ConfirmationModal
visible
title={t("sign_out_modal_title")}
descriptionText={t("sign_out_modal_text")}
confirmButtonLabel={t("sign_out")}
cancelButtonLabel={t("cancel")}
/> */}
<UserProfileSettingsModal
visible={showEditProfileModal}
userProfile={userProfile}
updateUserProfile={getUserProfile}
onClose={() => setShowEditProfileModal(false)}
/>
<section
className={styles.profileContentBox}
style={{ background: heroBackground }}
>
<div
style={{
display: "flex",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<div className={styles.profileAvatarContainer}>
<div className={styles.userInformation}>
<button type="button" className={styles.profileAvatarButton}>
{userProfile.profileImageUrl ? (
<img
className={styles.profileAvatar}
@ -127,12 +198,13 @@ export function ProfileHero() {
) : (
<PersonIcon size={72} />
)}
</div>
</button>
<div className={styles.profileInformation}>
<h2 className={styles.profileDisplayName}>
{userProfile.displayName}
</h2>
{currentGame && (
<div
style={{
@ -149,14 +221,23 @@ export function ProfileHero() {
alignItems: "center",
}}
>
<Link to={buildGameDetailsPath(currentGame)}>
<Link
to={buildGameDetailsPath({
...currentGame,
objectID: currentGame.objectId,
})}
>
{currentGame.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDistance(
currentGame.sessionDurationInSeconds,
addSeconds(
new Date(),
-currentGame.sessionDurationInSeconds
),
new Date()
),
})}
@ -166,20 +247,7 @@ export function ProfileHero() {
</div>
</div>
<div
style={{
width: "100%",
height: "72px",
minHeight: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
backdropFilter: `blur(10px)`,
borderTop: `solid 1px rgba(255, 255, 255, 0.1)`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
}}
>
<div className={styles.heroPanel}>
<div></div>
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{profileActions}

View file

@ -1,4 +1,4 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const wrapper = style({
@ -7,297 +7,3 @@ export const wrapper = style({
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
});
export const profileContentBox = style({
display: "flex",
cursor: "pointer",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
transition: "all ease 0.3s",
});
export const profileAvatarContainer = style({
width: "96px",
minWidth: "96px",
height: "96px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
zIndex: 1,
});
export const friendAvatarContainer = style({
width: "35px",
minWidth: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
});
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const profileAvatarEditContainer = style({
alignSelf: "center",
width: "128px",
height: "128px",
display: "flex",
borderRadius: "50%",
color: vars.color.body,
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
cursor: "pointer",
});
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
borderRadius: "50%",
overflow: "hidden",
});
export const profileAvatarEditOverlay = style({
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "#00000055",
color: vars.color.muted,
zIndex: 1,
cursor: "pointer",
});
export const profileInformation = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
alignItems: "flex-start",
color: "#c0c1c7",
zIndex: 1,
overflow: "hidden",
});
export const profileDisplayName = style({
fontWeight: "bold",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
});
export const profileContent = style({
display: "flex",
height: "100%",
flexDirection: "row",
gap: `${SPACING_UNIT * 4}px`,
});
export const profileGameSection = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSection = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSectionHeader = style({
fontSize: vars.size.body,
color: vars.color.body,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
":hover": {
color: vars.color.muted,
},
});
export const contentSidebar = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "150px",
},
"(min-width: 1024px)": {
maxWidth: "250px",
width: "100%",
},
},
});
export const feedGameIcon = style({
height: "100%",
});
export const libraryGameIcon = style({
width: "100%",
height: "100%",
borderRadius: "4px",
});
export const friendProfileIcon = style({
height: "100%",
});
export const feedItem = style({
color: vars.color.body,
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`,
width: "100%",
overflow: "hidden",
height: "72px",
transition: "all ease 0.2s",
cursor: "pointer",
zIndex: "1",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameListItem = style({
color: vars.color.body,
transition: "all ease 0.2s",
cursor: "pointer",
zIndex: "1",
overflow: "hidden",
padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendListContainer = style({
color: vars.color.body,
width: "100%",
height: "54px",
padding: `0 ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
transition: "all ease 0.2s",
position: "relative",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: `${SPACING_UNIT / 2}px`,
});
export const profileHeaderSkeleton = style({
height: "144px",
});
export const editProfileImageBadge = style({
width: "28px",
height: "28px",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: vars.color.background,
backgroundColor: vars.color.muted,
position: "absolute",
bottom: "0px",
right: "0px",
zIndex: "1",
});
export const telescopeIcon = style({
width: "60px",
height: "60px",
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const noDownloads = style({
display: "flex",
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const signOutModalContent = style({
display: "flex",
width: "100%",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const signOutModalButtonsContainer = style({
display: "flex",
width: "100%",
justifyContent: "end",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
paddingTop: `${SPACING_UNIT}px`,
});
export const profileBackground = style({
width: "100%",
height: "100%",
position: "absolute",
objectFit: "cover",
left: "0",
top: "0",
borderRadius: "4px",
});
export const cancelRequestButton = style({
cursor: "pointer",
color: vars.color.body,
":hover": {
color: vars.color.danger,
},
});
export const acceptRequestButton = style({
cursor: "pointer",
color: vars.color.success,
});

View file

@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { ProfileSkeleton } from "./profile-skeleton";
import { ProfileContent } from "./profile-content";
import { ProfileContent } from "./profile-content/profile-content";
import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css";

View file

@ -1,42 +0,0 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./profile.css";
import { useTranslation } from "react-i18next";
export interface UserBlockModalProps {
visible: boolean;
displayName: string;
onConfirm: () => void;
onClose: () => void;
}
export const UserBlockModal = ({
visible,
displayName,
onConfirm,
onClose,
}: UserBlockModalProps) => {
const { t } = useTranslation("user_profile");
return (
<>
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p>{t("user_block_modal_text", { displayName })}</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("block_user")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
</>
);
};

View file

@ -1,40 +0,0 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./profile.css";
import { useTranslation } from "react-i18next";
export interface UserConfirmUndoFriendshipModalProps {
visible: boolean;
displayName: string;
onConfirm: () => void;
onClose: () => void;
}
export function UserConfirmUndoFriendshipModal({
visible,
displayName,
onConfirm,
onClose,
}: UserConfirmUndoFriendshipModalProps) {
const { t } = useTranslation("user_profile");
return (
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p>{t("undo_friendship_modal_text", { displayName })}</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("undo_friendship")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
);
}

View file

@ -47,7 +47,7 @@ export const UserEditProfile = ({
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "webp"],
extensions: ["jpg", "jpeg", "png"],
},
],
});

View file

@ -44,7 +44,12 @@ export const UserProfileSettingsModal = ({
return (
<>
<Modal visible={visible} title={t("settings")} onClose={onClose}>
<Modal
visible={visible}
title={t("settings")}
onClose={onClose}
clickOutsideToClose={false}
>
<div
style={{
display: "flex",

View file

@ -1,40 +0,0 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./profile.css";
import { useTranslation } from "react-i18next";
export interface UserSignOutModalProps {
visible: boolean;
onConfirm: () => void;
onClose: () => void;
}
export const UserSignOutModal = ({
visible,
onConfirm,
onClose,
}: UserSignOutModalProps) => {
const { t } = useTranslation("user_profile");
return (
<>
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p>{t("sign_out_modal_text")}</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("sign_out")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
</>
);
};

View file

@ -1,6 +1,4 @@
import { useContext, useEffect, useState } from "react";
import ISO6391 from "iso-639-1";
import {
TextField,
Button,
@ -8,11 +6,9 @@ import {
SelectField,
} from "@renderer/components";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "@renderer/hooks";
import { changeLanguage } from "i18next";
import * as languageResources from "@locales";
import languageResources from "@locales";
import { orderBy } from "lodash-es";
import { settingsContext } from "@renderer/context";
@ -50,9 +46,9 @@ export function SettingsGeneral() {
setLanguageOptions(
orderBy(
Object.keys(languageResources).map((language) => {
Object.entries(languageResources).map(([language, value]) => {
return {
nativeName: ISO6391.getNativeName(language),
nativeName: value.language_name,
option: language,
};
}),
@ -93,8 +89,6 @@ export function SettingsGeneral() {
function updateFormWithUserPreferences() {
if (userPreferences) {
const parsedLanguage = userPreferences.language.split("-")[0];
setForm((prev) => ({
...prev,
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
@ -102,7 +96,7 @@ export function SettingsGeneral() {
userPreferences.downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled:
userPreferences.repackUpdatesNotificationsEnabled,
language: parsedLanguage,
language: userPreferences.language,
}));
}
}

View file

@ -1,106 +1,27 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const container = style({
padding: "24px",
width: "100%",
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "flex-start",
});
export const content = style({
backgroundColor: vars.color.background,
width: "100%",
height: "100%",
padding: `${SPACING_UNIT * 3}px`,
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
borderRadius: "4px",
boxShadow: "0px 0px 15px 0px #000000",
borderRadius: "8px",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
flex: "1",
});
export const sidebar = style({
width: "200px",
export const settingsCategories = style({
display: "flex",
border: `solid 1px ${vars.color.border}`,
borderRadius: "4px",
backgroundColor: vars.color.background,
minHeight: "500px",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT * 2}px`,
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.5)",
});
export const menuGroup = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
});
export const menu = style({
listStyle: "none",
margin: "0",
padding: "0",
gap: `${SPACING_UNIT / 2}px`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
});
export const menuItem = recipe({
base: {
transition: "all ease 0.1s",
cursor: "pointer",
textWrap: "nowrap",
display: "flex",
color: vars.color.muted,
borderRadius: "4px",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
},
variants: {
active: {
true: {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
},
muted: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const menuItemButton = style({
color: "inherit",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
overflow: "hidden",
width: "100%",
padding: `9px ${SPACING_UNIT}px`,
});
export const menuItemButtonLabel = style({
textOverflow: "ellipsis",
overflow: "hidden",
});
export const categoryTitle = style({
color: "#ff",
fontWeight: "bold",
fontSize: "18px",
paddingBottom: `${SPACING_UNIT}px`,
});

View file

@ -1,3 +1,5 @@
import { Button } from "@renderer/components";
import * as styles from "./settings.css";
import { useTranslation } from "react-i18next";
import { SettingsRealDebrid } from "./settings-real-debrid";
@ -13,10 +15,12 @@ import {
export function Settings() {
const { t } = useTranslation("settings");
const categories = {
[t("account")]: [t("my_profile"), t("friends")],
Hydra: [t("general"), t("behavior"), t("download_sources"), "Real-Debrid"],
};
const categories = [
t("general"),
t("behavior"),
t("download_sources"),
"Real-Debrid",
];
return (
<SettingsContextProvider>
@ -40,34 +44,21 @@ export function Settings() {
return (
<section className={styles.container}>
<aside className={styles.sidebar}>
{Object.entries(categories).map(([category, items]) => (
<div key={category} className={styles.menuGroup}>
<span className={styles.categoryTitle}>{category}</span>
<ul className={styles.menu}>
{items.map((item, index) => (
<li
key={`item-${index}`}
className={styles.menuItem({
active: currentCategoryIndex === index,
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => setCurrentCategoryIndex(index)}
>
{item}
</button>
</li>
))}
</ul>
</div>
))}
</aside>
<div className={styles.content}>
<section className={styles.settingsCategories}>
{categories.map((category, index) => (
<Button
key={category}
theme={
currentCategoryIndex === index ? "primary" : "outline"
}
onClick={() => setCurrentCategoryIndex(index)}
>
{category}
</Button>
))}
</section>
<h2>{categories[currentCategoryIndex]}</h2>
{renderCategory()}
</div>