refactor: migrate profile page styles from VE to SCSS + BEM

This commit is contained in:
Hachi-R 2025-01-20 12:45:24 -03:00
parent c44b5fa6af
commit e457950761
22 changed files with 786 additions and 237 deletions

View File

@ -0,0 +1,57 @@
@use "../../../scss/globals.scss";
.edit-profile-modal {
&__form {
display: flex;
flex-direction: column;
justify-content: center;
width: 350px;
}
&__content {
gap: calc(globals.$spacing-unit * 3);
display: flex;
flex-direction: column;
}
&__hint {
margin-top: calc(globals.$spacing-unit * 2);
}
&__submit {
align-self: end;
margin-top: calc(globals.$spacing-unit * 3);
width: 100%;
}
&__avatar-container {
align-self: center;
display: flex;
color: globals.$body-color;
justify-content: center;
align-items: center;
background-color: globals.$background-color;
position: relative;
cursor: pointer;
}
&__avatar-overlay {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
color: globals.$muted-color;
z-index: 1;
cursor: pointer;
display: flex;
justify-content: center;
transition: all ease 0.2s;
align-items: center;
border-radius: 4px;
opacity: 0;
}
&__avatar-container:hover &__avatar-overlay {
opacity: 1;
}
}

View File

@ -13,13 +13,12 @@ import {
} from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import * as styles from "./edit-profile-modal.css";
import { userProfileContext } from "@renderer/context";
import "./edit-profile-modal.scss";
interface FormValues {
profileImageUrl?: string;
@ -80,20 +79,9 @@ export function EditProfileModal(
<Modal {...props} title={t("edit_profile")} clickOutsideToClose={false}>
<form
onSubmit={handleSubmit(onSubmit)}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
width: "350px",
}}
className="edit-profile-modal__form"
>
<div
style={{
gap: `${SPACING_UNIT * 3}px`,
display: "flex",
flexDirection: "column",
}}
>
<div className="edit-profile-modal__content">
<Controller
control={control}
name="profileImageUrl"
@ -140,7 +128,7 @@ export function EditProfileModal(
return (
<button
type="button"
className={styles.profileAvatarEditContainer}
className="edit-profile-modal__avatar-container"
onClick={handleChangeProfileAvatar}
>
<Avatar
@ -149,7 +137,7 @@ export function EditProfileModal(
alt={userDetails?.displayName}
/>
<div className={styles.profileAvatarEditOverlay}>
<div className="edit-profile-modal__avatar-overlay">
<DeviceCameraIcon size={38} />
</div>
</button>
@ -167,7 +155,7 @@ export function EditProfileModal(
/>
</div>
<small style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
<small className="edit-profile-modal__hint">
<Trans i18nKey="privacy_hint" ns="user_profile">
<Link to="/settings" />
</Trans>
@ -175,7 +163,7 @@ export function EditProfileModal(
<Button
disabled={isSubmitting}
style={{ alignSelf: "end", marginTop: `${SPACING_UNIT * 3}px` }}
className="edit-profile-modal__submit"
type="submit"
>
{isSubmitting ? t("saving") : t("save")}

View File

@ -0,0 +1,62 @@
@use "../../../scss/globals.scss";
.friends-box {
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__list-item {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 2);
align-items: center;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
}
&__friend-name {
color: globals.$muted-color;
font-weight: bold;
font-size: globals.$body-font-size;
}
&__game-info {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
&__friend-details {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
}

View File

@ -3,14 +3,12 @@ import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./profile-content.css";
import { Avatar, Link } from "@renderer/components";
import "./friends-box.scss";
export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
@ -32,15 +30,15 @@ export function FriendsBox() {
return (
<div>
<div className={styles.sectionHeader}>
<div className="friends-box__section-header">
<h2>{t("friends")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.friendsCount)}</span>
)}
</div>
<div className={styles.box}>
<ul className={styles.list}>
<div className="friends-box__box">
<ul className="friends-box__list">
{userProfile?.friends.map((friend) => (
<li
key={friend.id}
@ -50,21 +48,22 @@ export function FriendsBox() {
: undefined
}
>
<Link to={`/profile/${friend.id}`} className={styles.listItem}>
<Link
to={`/profile/${friend.id}`}
className="friends-box__list-item"
>
<Avatar
size={32}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<div
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<span className={styles.friendName}>
<div className="friends-box__friend-details">
<span className="friends-box__friend-name">
{friend.displayName}
</span>
{friend.currentGame && (
<div style={{ display: "flex", gap: 4 }}>
<div className="friends-box__game-info">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>

View File

@ -0,0 +1,24 @@
@use "../../../scss/globals.scss";
.locked-profile {
&__container {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__lock-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: calc(globals.$spacing-unit * 2);
}
}

View File

@ -1,14 +1,13 @@
import { LockIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import * as styles from "./locked-profile.css";
import "./locked-profile.scss";
export function LockedProfile() {
const { t } = useTranslation("user_profile");
return (
<div className={styles.container}>
<div className={styles.lockIcon}>
<div className="locked-profile__container">
<div className="locked-profile__lock-icon">
<LockIcon size={24} />
</div>

View File

@ -0,0 +1,89 @@
@use "../../../scss/globals.scss";
.profile-content {
&__section {
display: flex;
gap: calc(globals.$spacing-unit * 3);
padding: calc(globals.$spacing-unit * 3);
}
&__main {
flex: 1;
}
&__right-content {
width: 100%;
height: 100%;
display: flex;
gap: calc(globals.$spacing-unit * 2);
flex-direction: column;
transition: all ease 0.2s;
@media (min-width: 1024px) {
max-width: 300px;
width: 100%;
}
@media (min-width: 1280px) {
width: 100%;
max-width: 400px;
}
}
&__no-games {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__telescope-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__games-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: calc(globals.$spacing-unit * 2);
grid-template-columns: repeat(2, 1fr);
@container #{globals.$app-container} (min-width: 900px) {
grid-template-columns: repeat(4, 1fr);
}
@container #{globals.$app-container} (min-width: 1300px) {
grid-template-columns: repeat(5, 1fr);
}
@container #{globals.$app-container} (min-width: 2000px) {
grid-template-columns: repeat(6, 1fr);
}
@container #{globals.$app-container} (min-width: 2600px) {
grid-template-columns: repeat(8, 1fr);
}
@container #{globals.$app-container} (min-width: 3000px) {
grid-template-columns: repeat(12, 1fr);
}
}
}

View File

@ -3,8 +3,6 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@ -14,6 +12,7 @@ import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { UserLibraryGameCard } from "./user-library-game-card";
import "./profile-content.scss";
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
@ -84,21 +83,14 @@ export function ProfileContent() {
}
const hasGames = userProfile?.libraryGames.length > 0;
const shouldShowRightContent = hasGames || userProfile.friends.length > 0;
return (
<section
style={{
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 3}px`,
}}
>
<div style={{ flex: 1 }}>
<section className="profile-content__section">
<div className="profile-content__main">
{!hasGames && (
<div className={styles.noGames}>
<div className={styles.telescopeIcon}>
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
@ -108,15 +100,14 @@ export function ProfileContent() {
{hasGames && (
<>
<div className={styles.sectionHeader}>
<div className="profile-content__section-header">
<h2>{t("library")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.libraryCount)}</span>
)}
</div>
<ul className={styles.gamesGrid}>
<ul className="profile-content__games-grid">
{userProfile?.libraryGames?.map((game) => (
<UserLibraryGameCard
game={game}
@ -132,7 +123,7 @@ export function ProfileContent() {
</div>
{shouldShowRightContent && (
<div className={styles.rightContent}>
<div className="profile-content__right-content">
<UserStatsBox />
<RecentGamesBox />
<FriendsBox />
@ -155,7 +146,6 @@ export function ProfileContent() {
return (
<div>
<ProfileHero />
{content}
</div>
);

View File

@ -0,0 +1,71 @@
@use "../../../scss/globals.scss";
.recent-games {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__list-item {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 2);
align-items: center;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
}
&__game-image {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
}
&__game-details {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
overflow: hidden;
}
&__game-title {
font-weight: bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__game-description {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@ -1,6 +1,4 @@
import { buildGameDetailsPath } from "@renderer/helpers";
import * as styles from "./profile-content.css";
import { Link } from "@renderer/components";
import { useCallback, useContext } from "react";
import { userProfileContext } from "@renderer/context";
@ -9,12 +7,11 @@ import { ClockIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks";
import type { UserGame } from "@types";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./recent-games-box.scss";
export function RecentGamesBox() {
const { userProfile } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const formatPlayTime = useCallback(
@ -44,28 +41,28 @@ export function RecentGamesBox() {
return (
<div>
<div className={styles.sectionHeader}>
<div className="recent-games__section-header">
<h2>{t("activity")}</h2>
</div>
<div className={styles.box}>
<ul className={styles.list}>
<div className="recent-games__box">
<ul className="recent-games__list">
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className={styles.listItem}
className="recent-games__list-item"
>
<img
src={game.iconUrl!}
alt={game.title}
className={styles.listItemImage}
className="recent-games__game-image"
/>
<div className={styles.listItemDetails}>
<span className={styles.listItemTitle}>{game.title}</span>
<div className="recent-games__game-details">
<span className="recent-games__game-title">{game.title}</span>
<div className={styles.listItemDescription}>
<div className="recent-games__game-description">
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>

View File

@ -0,0 +1,134 @@
@use "../../../scss/globals.scss";
.user-library-game {
&__wrapper {
border-radius: 4px;
overflow: hidden;
position: relative;
display: flex;
transition: all ease 0.2s;
&:hover {
transform: scale(1.05);
}
}
&__cover {
cursor: pointer;
transition: all ease 0.2s;
box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5);
width: 100%;
position: relative;
&:before {
content: "";
top: 0;
left: 0;
width: 100%;
height: 172%;
position: absolute;
background: linear-gradient(
35deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.07) 51.5%,
rgba(255, 255, 255, 0.15) 54%,
rgba(255, 255, 255, 0.15) 100%
);
transition: all ease 0.3s;
transform: translateY(-36%);
opacity: 0.5;
}
&:hover::before {
opacity: 1;
transform: translateY(-20%);
}
}
&__container {
transition: all ease 0.2s;
&:hover {
transform: scale(1.05);
}
}
&__overlay {
position: absolute;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
height: 100%;
width: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 20%, transparent 100%);
padding: 8px;
}
&__playtime {
background-color: globals.$background-color;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
}
&__stats {
width: 100%;
display: flex;
flex-direction: column;
}
&__stats-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
color: globals.$muted-color;
overflow: hidden;
height: 18px;
}
&__stats-content {
display: flex;
flex-direction: column;
}
&__stats-item {
width: 100%;
height: 100%;
transition: transform 0.5s ease-in-out;
flex-shrink: 0;
flex-grow: 0;
display: flex;
align-items: center;
gap: 8px;
}
&__game-image {
object-fit: cover;
border-radius: 4px;
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
}
&__achievements-progress {
width: 100%;
height: 4px;
transition: all ease 0.2s;
&::-webkit-progress-bar {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
border-radius: 4px;
}
}
}

View File

@ -1,5 +1,4 @@
import { UserGame } from "@types";
import * as styles from "./profile-content.css";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
@ -10,11 +9,11 @@ import {
formatDownloadProgress,
} from "@renderer/helpers";
import { userProfileContext } from "@renderer/context";
import { vars } from "@renderer/theme.css";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { useTranslation } from "react-i18next";
import { steamUrlBuilder } from "@shared";
import "./user-library-game-card.scss";
interface UserLibraryGameCardProps {
game: UserGame;
@ -62,9 +61,7 @@ export function UserLibraryGameCard({
const formatAchievementPoints = (number: number) => {
if (number < 100_000) return numberFormatter.format(number);
if (number < 1_000_000) return `${(number / 1000).toFixed(1)}K`;
return `${(number / 1_000_000).toFixed(1)}M`;
};
@ -88,83 +85,27 @@ export function UserLibraryGameCard({
<li
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{
borderRadius: 4,
overflow: "hidden",
position: "relative",
display: "flex",
}}
className="user-library-game__wrapper"
title={game.title}
className={styles.game}
>
<button
type="button"
style={{
cursor: "pointer",
}}
className={styles.gameCover}
className="user-library-game__cover"
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div
style={{
position: "absolute",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "space-between",
height: "100%",
width: "100%",
background:
"linear-gradient(0deg, rgba(0, 0, 0, 0.70) 20%, transparent 100%)",
padding: 8,
}}
>
<small
style={{
backgroundColor: vars.color.background,
color: vars.color.muted,
border: `solid 1px ${vars.color.border}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px",
}}
>
<div className="user-library-game__overlay">
<small className="user-library-game__playtime">
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
{userProfile?.hasActiveSubscription && game.achievementCount > 0 && (
<div
style={{
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
overflow: "hidden",
height: 18,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
}}
>
<div className="user-library-game__stats">
<div className="user-library-game__stats-header">
<div className="user-library-game__stats-content">
<div
className={styles.gameCardStats}
className="user-library-game__stats-item"
style={{
display: "flex",
alignItems: "center",
gap: 8,
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
}}
>
@ -176,12 +117,9 @@ export function UserLibraryGameCard({
{game.achievementsPointsEarnedSum > 0 && (
<div
className={styles.gameCardStats}
className="user-library-game__stats-item"
style={{
display: "flex",
gap: 5,
transform: `translateY(${-100 * (statIndex % getStatsItemCount())}%)`,
alignItems: "center",
}}
>
<HydraIcon width={16} height={16} />
@ -203,7 +141,7 @@ export function UserLibraryGameCard({
<progress
max={1}
value={game.unlockedAchievementCount / game.achievementCount}
className={styles.achievementsProgressBar}
className="user-library-game__achievements-progress"
/>
</div>
)}
@ -212,14 +150,7 @@ export function UserLibraryGameCard({
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
style={{
objectFit: "cover",
borderRadius: 4,
width: "100%",
height: "100%",
minWidth: "100%",
minHeight: "100%",
}}
className="user-library-game__game-image"
/>
</button>
</li>

View File

@ -0,0 +1,75 @@
@use "../../../scss/globals.scss";
.user-stats {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__list-item {
display: flex;
flex-direction: column;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit;
gap: globals.$spacing-unit;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
}
&__list-title {
font-weight: bold;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__list-description {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__stats-row {
display: flex;
justify-content: space-between;
}
&__link {
text-align: start;
color: globals.$body-color;
&--warning {
color: globals.$warning-color;
}
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}

View File

@ -1,4 +1,3 @@
import * as styles from "./profile-content.css";
import { useCallback, useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
@ -7,7 +6,7 @@ import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { vars } from "@renderer/theme.css";
import "./user-stats-box.scss";
export function UserStatsBox() {
const { showHydraCloudModal } = useSubscription();
@ -36,22 +35,20 @@ export function UserStatsBox() {
return (
<div>
<div className={styles.sectionHeader}>
<div className="user-stats__section-header">
<h2>{t("stats")}</h2>
</div>
<div className={styles.box}>
<ul className={styles.list}>
<div className="user-stats__box">
<ul className="user-stats__list">
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">
{t("achievements_unlocked")}
</h3>
{userStats.unlockedAchievementSum !== undefined ? (
<div
style={{ display: "flex", justifyContent: "space-between" }}
>
<p className={styles.listItemDescription}>
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
{t("achievements")}
</p>
@ -60,9 +57,9 @@ export function UserStatsBox() {
<button
type="button"
onClick={() => showHydraCloudModal("achievements")}
className={styles.link}
className="user-stats__link"
>
<small style={{ color: vars.color.warning }}>
<small style={{ color: "var(--color-warning)" }}>
{t("show_achievements_on_profile")}
</small>
</button>
@ -71,13 +68,11 @@ export function UserStatsBox() {
)}
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>{t("earned_points")}</h3>
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("earned_points")}</h3>
{userStats.achievementsPointsEarnedSum !== undefined ? (
<div
style={{ display: "flex", justifyContent: "space-between" }}
>
<p className={styles.listItemDescription}>
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<HydraIcon width={20} height={20} />
{numberFormatter.format(
userStats.achievementsPointsEarnedSum.value
@ -94,9 +89,9 @@ export function UserStatsBox() {
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className={styles.link}
className="user-stats__link"
>
<small style={{ color: vars.color.warning }}>
<small className="user-stats__link--warning">
{t("show_points_on_profile")}
</small>
</button>
@ -104,10 +99,10 @@ export function UserStatsBox() {
</li>
)}
<li className={styles.statsListItem}>
<h3 className={styles.listItemTitle}>{t("total_play_time")}</h3>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p className={styles.listItemDescription}>
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("total_play_time")}</h3>
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<ClockIcon />
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
</p>

View File

@ -0,0 +1,124 @@
@use "../../../scss/globals.scss";
.profile-hero {
&__content-box {
display: flex;
flex-direction: column;
position: relative;
}
&__background {
&-image {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
&-overlay {
width: 100%;
height: 100%;
z-index: 1;
background: linear-gradient(135deg, rgb(0 0 0 / 40%), rgb(0 0 0 / 30%));
&--transparent {
background: transparent;
}
}
}
&__user-information {
display: flex;
padding: calc(globals.$spacing-unit * 7) calc(globals.$spacing-unit * 3);
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
&__avatar-button {
width: 96px;
min-width: 96px;
height: 96px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: globals.$background-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.7);
cursor: pointer;
transition: all ease 0.3s;
color: globals.$muted-color;
position: relative;
&:hover {
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.7);
}
}
&__information {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
align-items: flex-start;
color: globals.$muted-color;
z-index: 1;
overflow: hidden;
}
&__display-name {
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
display: flex;
align-items: center;
position: relative;
text-shadow: 0 0 5px rgb(0 0 0 / 40%);
}
&__current-game {
&-wrapper {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&-details {
display: flex;
flex-direction: row;
gap: globals.$spacing-unit;
align-items: center;
}
}
&__hero-panel {
width: 100%;
height: 72px;
min-height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
display: flex;
gap: globals.$spacing-unit;
justify-content: space-between;
backdrop-filter: blur(15px);
border-top: solid 1px rgba(255, 255, 255, 0.1);
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0.3);
&--transparent {
background: transparent;
}
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
justify-content: flex-end;
flex: 1;
}
&__button {
&--outline {
border-color: globals.$body-color;
}
}
}

View File

@ -1,6 +1,3 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import * as styles from "./profile-hero.css";
import { useCallback, useContext, useMemo, useState } from "react";
import { userProfileContext } from "@renderer/context";
import {
@ -27,14 +24,12 @@ import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import "./profile-hero.scss";
type FriendAction =
| FriendRequestAction
| ("BLOCK" | "UNDO_FRIENDSHIP" | "SEND");
const backgroundImageLayer =
"linear-gradient(135deg, rgb(0 0 0 / 40%), rgb(0 0 0 / 30%))";
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
@ -127,7 +122,7 @@ export function ProfileHero() {
theme="outline"
onClick={() => setShowEditProfileModal(true)}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
className="profile-hero__button--outline"
>
<PencilIcon />
{t("edit_profile")}
@ -152,7 +147,7 @@ export function ProfileHero() {
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
className="profile-hero__button--outline"
>
<PersonAddIcon />
{t("add_friend")}
@ -187,7 +182,7 @@ export function ProfileHero() {
handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")
}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
className="profile-hero__button--outline"
>
<XCircleFillIcon />
{t("undo_friendship")}
@ -201,10 +196,10 @@ export function ProfileHero() {
<Button
theme="outline"
onClick={() =>
handleFriendAction(userProfile.relation!.BId, "CANCEL")
handleFriendAction(userProfile.relation!.AId, "CANCEL")
}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
className="profile-hero__button--outline"
>
<XCircleFillIcon /> {t("cancel_request")}
</Button>
@ -219,7 +214,7 @@ export function ProfileHero() {
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
}
disabled={isPerformingAction}
style={{ borderColor: vars.color.body }}
className="profile-hero__button--outline"
>
<CheckCircleFillIcon /> {t("accept_request")}
</Button>
@ -279,34 +274,28 @@ export function ProfileHero() {
/>
<section
className={styles.profileContentBox}
style={{ background: heroBackground }}
className="profile-hero__content-box"
style={{ background: !backgroundImage ? heroBackground : undefined }}
>
{backgroundImage && (
<img
src={backgroundImage}
alt=""
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
}}
className="profile-hero__background-image"
/>
)}
<div
style={{
background: backgroundImage ? backgroundImageLayer : "transparent",
width: "100%",
height: "100%",
zIndex: 1,
}}
className={`profile-hero__background-overlay ${
!backgroundImage
? "profile-hero__background-overlay--transparent"
: ""
}`}
>
<div className={styles.userInformation}>
<div className="profile-hero__user-information">
<button
type="button"
className={styles.profileAvatarButton}
className="profile-hero__avatar-button"
onClick={handleAvatarClick}
>
<Avatar
@ -316,9 +305,9 @@ export function ProfileHero() {
/>
</button>
<div className={styles.profileInformation}>
<div className="profile-hero__information">
{userProfile ? (
<h2 className={styles.profileDisplayName}>
<h2 className="profile-hero__display-name">
{userProfile?.displayName}
</h2>
) : (
@ -326,8 +315,8 @@ export function ProfileHero() {
)}
{currentGame && (
<div className={styles.currentGameWrapper}>
<div className={styles.currentGameDetails}>
<div className="profile-hero__current-game-wrapper">
<div className="profile-hero__current-game-details">
<Link
to={buildGameDetailsPath({
...currentGame,
@ -358,21 +347,14 @@ export function ProfileHero() {
</div>
<div
className={styles.heroPanel}
className={`profile-hero__hero-panel ${
!backgroundImage ? "profile-hero__hero-panel--transparent" : ""
}`}
style={{
background: backgroundImage ? backgroundImageLayer : heroBackground,
background: !backgroundImage ? heroBackground : undefined,
}}
>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "flex-end",
flex: 1,
}}
>
{profileActions}
</div>
<div className="profile-hero__actions">{profileActions}</div>
</div>
</section>
</>

View File

@ -0,0 +1,10 @@
@use "../../scss/globals.scss";
.profile {
&__wrapper {
width: 100%;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
}
}

View File

@ -1,18 +1,16 @@
import { ProfileContent } from "./profile-content/profile-content";
import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css";
import * as styles from "./profile.css";
import { UserProfileContextProvider } from "@renderer/context";
import { useParams } from "react-router-dom";
import "./profile.scss";
export default function Profile() {
const { userId } = useParams();
return (
<UserProfileContextProvider userId={userId!}>
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div className={styles.wrapper}>
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
<div className="profile__wrapper">
<ProfileContent />
</div>
</SkeletonTheme>

View File

@ -0,0 +1,24 @@
@use "../../../scss/globals.scss";
.report-profile {
&__button {
align-self: flex-end;
color: globals.$muted-color;
gap: globals.$spacing-unit;
display: flex;
cursor: pointer;
align-items: center;
font-size: globals.$small-font-size;
}
&__form {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__submit {
margin-top: globals.$spacing-unit;
align-self: flex-end;
}
}

View File

@ -1,15 +1,14 @@
import { ReportIcon } from "@primer/octicons-react";
import * as styles from "./report-profile.css";
import { Button, Modal, SelectField, TextField } from "@renderer/components";
import { useCallback, useContext, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import * as yup from "yup";
import { SPACING_UNIT } from "@renderer/theme.css";
import { userProfileContext } from "@renderer/context";
import { yupResolver } from "@hookform/resolvers/yup";
import { useToast } from "@renderer/hooks";
import "./report-profile.scss";
const reportReasons = ["hate", "sexual_content", "violence", "spam", "other"];
@ -75,13 +74,7 @@ export function ReportProfile() {
title={t("report_profile")}
clickOutsideToClose={false}
>
<form
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<form className="report-profile__form">
<Controller
control={control}
name="reason"
@ -109,7 +102,7 @@ export function ReportProfile() {
/>
<Button
style={{ marginTop: `${SPACING_UNIT}px`, alignSelf: "flex-end" }}
className="report-profile__submit"
onClick={handleSubmit(onSubmit)}
>
{t("report")}
@ -119,7 +112,7 @@ export function ReportProfile() {
<button
type="button"
className={styles.reportButton}
className="report-profile__button"
onClick={() => setShowReportProfileModal(true)}
disabled={isSubmitting}
>

View File

@ -0,0 +1,11 @@
@use "../../../scss/globals.scss";
.upload-background-image-button {
position: absolute;
top: 16px;
right: 16px;
border-color: globals.$body-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
background-color: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px);
}

View File

@ -2,21 +2,17 @@ import { UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useContext, useState } from "react";
import { userProfileContext } from "@renderer/context";
import * as styles from "./upload-background-image-button.css";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false);
const { hasActiveSubscription } = useUserDetails();
const { t } = useTranslation("user_profile");
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast();
const handleChangeCoverClick = async () => {
@ -52,7 +48,7 @@ export function UploadBackgroundImageButton() {
return (
<Button
theme="outline"
className={styles.uploadBackgroundImageButton}
className="upload-background-image-button"
onClick={handleChangeCoverClick}
disabled={isUploadingBackgroundImage}
>