feat: creating friends section

This commit is contained in:
Zamitto 2024-07-08 22:07:14 -03:00
parent 202f5b60de
commit 6ccbff0160
9 changed files with 307 additions and 52 deletions

View File

@ -241,6 +241,13 @@
"successfully_signed_out": "Successfully signed out", "successfully_signed_out": "Successfully signed out",
"sign_out": "Sign out", "sign_out": "Sign out",
"playing_for": "Playing for {{amount}}", "playing_for": "Playing for {{amount}}",
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?" "sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?",
"add_friends": "Add Friends",
"friend_code": "Friend code",
"see_profile": "See profile",
"sending": "Sending",
"send": "Add friend",
"friend_request_sent": "Friend request sent",
"friends": "Friends"
} }
} }

View File

@ -241,6 +241,11 @@
"sign_out": "Sair da conta", "sign_out": "Sair da conta",
"sign_out_modal_title": "Tem certeza?", "sign_out_modal_title": "Tem certeza?",
"playing_for": "Jogando por {{amount}}", "playing_for": "Jogando por {{amount}}",
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?" "sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?",
"add_friends": "Adicionar Amigos",
"friend_code": "Código de amigo",
"see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos"
} }
} }

View File

@ -98,9 +98,9 @@ export class HydraApi {
logger.error(config.method, config.baseURL, config.url, config.headers); logger.error(config.method, config.baseURL, config.url, config.headers);
if (error.response) { if (error.response) {
logger.error(error.response.status, error.response.data); logger.error("Response", error.response.status, error.response.data);
} else if (error.request) { } else if (error.request) {
logger.error(error.request); logger.error("Request", error.request);
} else { } else {
logger.error("Error", error.message); logger.error("Error", error.message);
} }

View File

@ -6,7 +6,7 @@
<title>Hydra</title> <title>Hydra</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://cdn.discordapp.com https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
/> />
</head> </head>
<body style="background-color: #1c1c1c"> <body style="background-color: #1c1c1c">

View File

@ -78,6 +78,10 @@ export function useUserDetails() {
[updateUserDetails] [updateUserDetails]
); );
const sendFriendRequest = useCallback(async (userId: string) => {
console.log("sending friend request to", userId);
}, []);
return { return {
userDetails, userDetails,
fetchUserDetails, fetchUserDetails,
@ -85,6 +89,7 @@ export function useUserDetails() {
clearUserDetails, clearUserDetails,
updateUserDetails, updateUserDetails,
patchUser, patchUser,
sendFriendRequest,
profileBackground, profileBackground,
}; };
} }

View File

@ -0,0 +1,123 @@
import { Button, Modal, TextField } from "@renderer/components";
import { PendingFriendRequest } from "@types";
import * as styles from "./user.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export interface UserAddFriendsModalProps {
visible: boolean;
onClose: () => void;
}
export const UserAddFriendsModal = ({
visible,
onClose,
}: UserAddFriendsModalProps) => {
const { t } = useTranslation("user_profile");
const [friendCode, setFriendCode] = useState("");
const [isAddingFriend, setIsAddingFriend] = useState(false);
const [pendingRequests, setPendingRequests] = useState<
PendingFriendRequest[]
>([]);
const navigate = useNavigate();
const { sendFriendRequest } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const handleAddFriend: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsAddingFriend(true);
sendFriendRequest(friendCode)
.then(() => {
showSuccessToast(t("friend_request_sent"));
})
.catch(() => {
showErrorToast("falhaaaa");
})
.finally(() => {
setIsAddingFriend(false);
});
};
useEffect(() => {
setPendingRequests([]);
});
const handleSeeProfileClick = () => {
navigate(`profile/${friendCode}`);
};
const resetModal = () => {
setFriendCode("");
};
const cleanFormAndClose = () => {
resetModal();
onClose();
};
return (
<>
<Modal
visible={visible}
title={t("add_friends")}
onClose={cleanFormAndClose}
>
<form
onSubmit={handleAddFriend}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<TextField
label={t("friend_code")}
value={friendCode}
required
minLength={8}
maxLength={8}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setFriendCode(e.target.value)}
/>
<Button
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="submit"
>
{isAddingFriend ? t("sending") : t("send")}
</Button>
<Button
onClick={handleSeeProfileClick}
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="button"
>
{t("see_profile")}
</Button>
</form>
<div>
{pendingRequests.map((request) => {
return (
<p>
{request.AId} - {request.BId}
</p>
);
})}
</div>
</Modal>
</>
);
};

View File

@ -14,10 +14,16 @@ import {
} from "@renderer/hooks"; } from "@renderer/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; import {
PersonAddIcon,
PersonIcon,
PlusCircleIcon,
TelescopeIcon,
} from "@primer/octicons-react";
import { Button, Link } from "@renderer/components"; import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal"; import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal"; import { UserSignOutModal } from "./user-signout-modal";
import { UserAddFriendsModal } from "./user-add-friends-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -37,6 +43,7 @@ export function UserContent({
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showAddFriendsModal, setShowAddFriendsModal] = useState(false);
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -103,6 +110,11 @@ export function UserContent({
onConfirm={handleConfirmSignout} onConfirm={handleConfirmSignout}
/> />
<UserAddFriendsModal
visible={showAddFriendsModal}
onClose={() => setShowAddFriendsModal(false)}
/>
<section <section
className={styles.profileContentBox} className={styles.profileContentBox}
style={{ style={{
@ -210,7 +222,7 @@ export function UserContent({
<div className={styles.profileGameSection}> <div className={styles.profileGameSection}>
<h2>{t("activity")}</h2> <h2>{t("activity")}</h2>
{!userProfile.recentGames.length ? ( {!userProfile.recentGames?.length ? (
<div className={styles.noDownloads}> <div className={styles.noDownloads}>
<div className={styles.telescopeIcon}> <div className={styles.telescopeIcon}>
<TelescopeIcon size={24} /> <TelescopeIcon size={24} />
@ -259,54 +271,116 @@ export function UserContent({
)} )}
</div> </div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}> <div className={styles.contentSidebar}>
<div <div className={styles.profileGameSection}>
style={{ <div
display: "flex", style={{
alignItems: "center", display: "flex",
justifyContent: "space-between", alignItems: "center",
gap: `${SPACING_UNIT * 2}px`, justifyContent: "space-between",
}} gap: `${SPACING_UNIT * 2}px`,
> }}
<h2>{t("library")}</h2> >
<h2>{t("library")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames?.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames?.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div>
</div>
<div className={styles.friendsSection}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("friends")}</h2>
<div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<button
type="button"
style={{ color: vars.color.body, cursor: "pointer" }}
onClick={() => setShowAddFriendsModal(true)}
>
<PersonAddIcon />
</button>
</div>
<div <div
style={{ style={{
flex: 1, display: "flex",
backgroundColor: vars.color.border, flexDirection: "column",
height: "1px", gap: `${SPACING_UNIT}px`,
}} }}
/> >
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button <button
key={game.objectID} className={cn(styles.friendListItem, styles.profileContentBox)}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
> >
{game.iconUrl ? ( <img
<img className={styles.friendProfileIcon}
className={styles.libraryGameIcon} src={
src={game.iconUrl} "https://cdn.discordapp.com/avatars/1239959140785455295/4aff4b901c7a9f5f814b4379b6cfd58a.webp"
alt={game.title} }
/> alt={"Punheta Master 123"}
) : ( />
<SteamLogo className={styles.libraryGameIcon} /> <h4>Punheta Master 123</h4>
)}
</button> </button>
))}
<button
className={cn(styles.friendListItem, styles.profileContentBox)}
>
<img
className={styles.friendProfileIcon}
src={userProfile.profileImageUrl || ""}
alt={"Hydra Launcher"}
/>
<h4>Hydra Launcher</h4>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -86,7 +86,13 @@ export const profileContent = style({
export const profileGameSection = style({ export const profileGameSection = style({
width: "100%", width: "100%",
height: "100%", display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSection = style({
width: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
@ -94,6 +100,9 @@ export const profileGameSection = style({
export const contentSidebar = style({ export const contentSidebar = style({
width: "100%", width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
"@media": { "@media": {
"(min-width: 768px)": { "(min-width: 768px)": {
width: "100%", width: "100%",
@ -116,12 +125,17 @@ export const libraryGameIcon = style({
borderRadius: "4px", borderRadius: "4px",
}); });
export const friendProfileIcon = style({
height: "100%",
});
export const feedItem = style({ export const feedItem = style({
color: vars.color.body, color: vars.color.body,
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
width: "100%", width: "100%",
overflow: "hidden",
height: "72px", height: "72px",
transition: "all ease 0.2s", transition: "all ease 0.2s",
cursor: "pointer", cursor: "pointer",
@ -143,6 +157,22 @@ export const gameListItem = style({
}, },
}); });
export const friendListItem = style({
color: vars.color.body,
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
width: "100%",
height: "48px",
transition: "all ease 0.2s",
cursor: "pointer",
zIndex: "1",
overflow: "hidden",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameInformation = style({ export const gameInformation = style({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",

View File

@ -269,14 +269,25 @@ export interface UserDetails {
profileImageUrl: string | null; profileImageUrl: string | null;
} }
export interface UserFriend {
id: string;
displayName: string;
profileImageUrl: string | null;
}
export interface PendingFriendRequest {
AId: string;
BId: string;
}
export interface UserProfile { export interface UserProfile {
id: string; id: string;
displayName: string; displayName: string;
username: string;
profileImageUrl: string | null; profileImageUrl: string | null;
totalPlayTimeInSeconds: number; totalPlayTimeInSeconds: number;
libraryGames: UserGame[]; libraryGames: UserGame[] | null;
recentGames: UserGame[]; recentGames: UserGame[] | null;
friends: UserFriend[] | null;
} }
export interface DownloadSource { export interface DownloadSource {