mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: returning with edit profile modal
This commit is contained in:
commit
2304e19558
78 changed files with 1810 additions and 906 deletions
|
@ -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",
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,6 +21,11 @@ export const sidebar = recipe({
|
|||
pointerEvents: "none",
|
||||
},
|
||||
},
|
||||
darwin: {
|
||||
true: {
|
||||
paddingTop: `${SPACING_UNIT * 6}px`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -9,3 +9,5 @@ export const DOWNLOADER_NAME = {
|
|||
[Downloader.PixelDrain]: "PixelDrain",
|
||||
[Downloader.Qiwi]: "Qiwi",
|
||||
};
|
||||
|
||||
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
|
||||
|
|
|
@ -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}
|
||||
|
|
5
src/renderer/src/declaration.d.ts
vendored
5
src/renderer/src/declaration.d.ts
vendored
|
@ -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: (
|
||||
|
|
|
@ -27,7 +27,7 @@ import {
|
|||
|
||||
import { store } from "./store";
|
||||
|
||||
import * as resources from "@locales";
|
||||
import resources from "@locales";
|
||||
|
||||
Sentry.init({});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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`,
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)",
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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>
|
|
@ -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`,
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -47,7 +47,7 @@ export const UserEditProfile = ({
|
|||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["jpg", "jpeg", "png", "webp"],
|
||||
extensions: ["jpg", "jpeg", "png"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`,
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue