Merge pull request #599 from hydralauncher/feature/user-profile

feat: user profile page
This commit is contained in:
Chubby Granny Chaser 2024-06-20 01:08:24 +01:00 committed by GitHub
commit 571947cd23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 713 additions and 308 deletions

View File

@ -53,6 +53,7 @@
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
"fetch-cookie": "^3.0.1",
"file-type": "^19.0.0",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "مميّز",
"recently_added": "مضاف مؤخراً",
"trending": "شائع",
"surprise_me": "فاجئني",
"no_results": "لم يتم العثور على نتائج"
@ -15,12 +14,7 @@
"paused": "{{title}} (متوقف)",
"downloading": "{{title}} ({{percentage}} - جارٍ التنزيل...)",
"filter": "بحث في المكتبة",
"follow_us": "تابعنا",
"home": "الرئيسية",
"discord": "انضم إلى الـDiscord الخاص بنا",
"telegram": "انضم إلى قناة Telegram الخاصة بنا",
"x": "تابعنا على X",
"github": "ساهم في مشروعنا على GitHub"
"home": "الرئيسية"
},
"header": {
"search": "ابحث عن الألعاب",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Рэкамэндаванае",
"recently_added": "Нядаўна дададзенае",
"trending": "Актуальнае",
"surprise_me": "Здзіві мяне",
"no_results": "Няма вынікаў"
@ -15,12 +14,7 @@
"paused": "{{title}} (Спынена)",
"downloading": "{{title}} ({{percentage}} - Сцягванне…)",
"filter": "Фільтар бібліятэкі",
"follow_us": "Падпісвайцеся на нас",
"home": "Галоўная",
"discord": "Далучайцеся да Discord",
"telegram": "Далучайцеся да Telegram",
"x": "Падпісвайцеся на X",
"github": "Зрабіць свой унёсак на GitHub"
"home": "Галоўная"
},
"header": {
"search": "Пошук",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Anbefalet",
"recently_added": "Nyligt tilføjet",
"trending": "Trender",
"surprise_me": "Overrask mig",
"no_results": "Ingen resultater fundet"
@ -15,12 +14,7 @@
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filtrer bibliotek",
"follow_us": "Følg os",
"home": "Hjem",
"discord": "Tilslut dig vores Discord",
"telegram": "Tilslut dig vores Telegram",
"x": "Følg på X",
"github": "Bidrag på GitHub"
"home": "Hjem"
},
"header": {
"search": "Søg spil",

View File

@ -228,11 +228,21 @@
"user_profile": {
"amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes",
"play_time": "Played for {{amount}}",
"last_time_played": "Last played {{period}}",
"sign_out": "Sign out",
"activity": "Recent Activity",
"activity": "Recent activity",
"library": "Library",
"total_play_time": "Total playtime: {{amount}}"
"total_play_time": "Total playtime: {{amount}}",
"no_recent_activity_title": "Hmmm… nothing here",
"no_recent_activity_description": "You haven't played any games recently. It's time to change that!",
"display_name": "Display name",
"saving": "Saving",
"save": "Save",
"edit_profile": "Edit Profile",
"saved_successfully": "Saved successfully",
"try_again": "Please, try again",
"signout_modal_title": "Are you sure?",
"cancel": "Cancel",
"signout": "Sign Out"
}
}

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "پیشنهادی",
"recently_added": "تازه اضافه شده",
"trending": "پرطرفدار",
"surprise_me": "سوپرایزم کن",
"no_results": "اتمام‌ای پیدا نشد"
@ -15,12 +14,7 @@
"paused": "{{title}} (متوقف شده)",
"downloading": "{{title}} ({{percentage}} - در حال دانلود…)",
"filter": "فیلتر کردن کتابخانه",
"follow_us": "دنبال کردن ما",
"home": "خانه",
"discord": "عضویت در دیسکورد ما",
"telegram": "عضویت در تلگرام ما",
"x": "دنبال کرد در ایکس",
"github": "مشارکت در گیتهاب"
"home": "خانه"
},
"header": {
"search": "جستجوی بازی‌ها",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "En vedette",
"recently_added": "Récemment ajouté",
"trending": "Tendance",
"surprise_me": "Surprenez-moi",
"no_results": "Aucun résultat trouvé"
@ -15,8 +14,7 @@
"paused": "{{title}} (En pause)",
"downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)",
"filter": "Filtrer la bibliothèque",
"home": "Page daccueil",
"follow_us": "Suivez-nous"
"home": "Page daccueil"
},
"header": {
"search": "Recherche",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Featured",
"recently_added": "Nemrég hozzáadott",
"trending": "Népszerű",
"surprise_me": "Lepj meg",
"no_results": "Nem található"
@ -15,7 +14,6 @@
"paused": "{{title}} (Szünet)",
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"follow_us": "Kövess minket",
"home": "Főoldal"
},
"header": {

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Unggulan",
"recently_added": "Terbaru",
"trending": "Trending",
"surprise_me": "Kejutkan Saya",
"no_results": "Tidak ada hasil"
@ -15,12 +14,7 @@
"paused": "{{title}} (Terhenti)",
"downloading": "{{title}} ({{percentage}} - Mengunduh…)",
"filter": "Filter koleksi",
"follow_us": "Ikuti kami",
"home": "Beranda",
"discord": "Gabung Discord kami",
"telegram": "Gabung Telegram kami",
"x": "Ikuti akun X kami",
"github": "Kontribusi di GitHub"
"home": "Beranda"
},
"header": {
"search": "Pencarian",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "In primo piano",
"recently_added": "Aggiunti di recente",
"trending": "Di tendenza",
"surprise_me": "Sorprendimi",
"no_results": "Nessun risultato trovato"
@ -15,12 +14,7 @@
"paused": "{{title}} (In pausa)",
"downloading": "{{title}} ({{percentage}} - Download…)",
"filter": "Filtra libreria",
"follow_us": "Seguici",
"home": "Home",
"discord": "Unisciti al nostro Discord",
"telegram": "Unisciti al nostro Telegram",
"x": "Segui su X",
"github": "Contribuisci su GitHub"
"home": "Home"
},
"header": {
"search": "Cerca",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "추천",
"recently_added": "최근 추가됨",
"trending": "인기",
"surprise_me": "무작위 추천",
"no_results": "결과 없음"
@ -15,12 +14,7 @@
"paused": "{{title}} (일시 정지됨)",
"downloading": "{{title}} ({{percentage}} - 다운로드 중…)",
"filter": "라이브러리 정렬",
"follow_us": "공식 SNS",
"home": "홈",
"discord": "공식 디스코드",
"telegram": "공식 텔레그램",
"x": "공식 X (구 트위터)",
"github": "GitHub에서 기여하기"
"home": "홈"
},
"header": {
"search": "게임 검색하기",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Uitgelicht",
"recently_added": "Recent Toegevoegd",
"trending": "Trending",
"surprise_me": "Verrasing",
"no_results": "Geen resultaten gevonden"
@ -15,12 +14,7 @@
"paused": "{{title}} (Gepauzeerd)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter Bibliotheek",
"follow_us": "volg ons",
"home": "Home",
"discord": "Volg onze Discord",
"telegram": "Volg onze Telegram",
"x": "Volg ons op X",
"github": "Contribute op GitHub"
"home": "Home"
},
"header": {
"search": "Zoek spellen",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Wyróżnione",
"recently_added": "Ostatnio dodane",
"trending": "Trendujące",
"surprise_me": "Zaskocz mnie",
"no_results": "Nie znaleziono wyników"
@ -15,12 +14,7 @@
"paused": "{{title}} (Zatrzymano)",
"downloading": "{{title}} ({{percentage}} - Pobieranie…)",
"filter": "Filtruj biblioteke",
"follow_us": "Śledź nas",
"home": "Główna",
"discord": "Dołącz nasz Discord",
"telegram": "Dołącz nasz Telegram",
"x": "Śledź na X",
"github": "Przyczyń się na GitHub"
"home": "Główna"
},
"header": {
"search": "Szukaj",

View File

@ -15,7 +15,6 @@
"downloading": "{{title}} ({{percentage}} - Baixando…)",
"filter": "Filtrar biblioteca",
"home": "Início",
"follow_us": "Acompanhe-nos",
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado"
},
@ -229,11 +228,21 @@
"user_profile": {
"amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos",
"play_time": "Jogado por {{amount}}",
"last_time_played": "Jogou {{period}}",
"sign_out": "Sair da conta",
"activity": "Atividade Recente",
"activity": "Atividade recente",
"library": "Biblioteca",
"total_play_time": "Tempo total de jogo: {{amount}}"
"total_play_time": "Tempo total de jogo: {{amount}}",
"no_recent_activity_title": "Hmmm… nada por aqui",
"no_recent_activity_description": "Parece que você não jogou nada recentemente. Que tal começar agora?",
"display_name": "Nome de exibição",
"saving": "Salvando…",
"save": "Salvar",
"edit_profile": "Editar Perfil",
"saved_successfully": "Salvo com sucesso",
"try_again": "Por favor, tente novamente",
"cancel": "Cancelar",
"signout": "Sair da conta",
"signout_modal_title": "Tem certeza?"
}
}

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Öne çıkan",
"recently_added": "Son eklenen",
"trending": "Popüler",
"surprise_me": "Şaşırt beni",
"no_results": "Sonuç bulunamadı"
@ -15,12 +14,7 @@
"paused": "{{title}} (Duraklatıldı)",
"downloading": "{{title}} ({{percentage}} - İndiriliyor…)",
"filter": "Kütüphaneyi filtrele",
"follow_us": "Bizi takip et",
"home": "Ana menü",
"discord": "Discord'umuza katıl",
"telegram": "Telegram'umuza katıl",
"x": "X'te bizi takip et",
"github": "GitHub'da bize katkı yap"
"home": "Ana menü"
},
"header": {
"search": "Ara",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "Рекомендоване",
"recently_added": "Нове",
"trending": "У тренді",
"surprise_me": "Здивуй мене",
"no_results": "Результатів не знайдено"
@ -15,12 +14,7 @@
"paused": "{{title}} (Призупинено)",
"downloading": "{{title}} ({{percentage}} - Завантаження…)",
"filter": "Фільтр бібліотеки",
"follow_us": "Підписуйтесь на нас",
"home": "Головна",
"discord": "Приєднуйтесь до Discord",
"telegram": "Приєднуйтесь до Telegram",
"x": "Підписуйтесь на X",
"github": "Зробіть свій внесок на GitHub"
"home": "Головна"
},
"header": {
"search": "Пошук",

View File

@ -1,7 +1,6 @@
{
"home": {
"featured": "特色推荐",
"recently_added": "最近添加",
"trending": "最近热门",
"surprise_me": "向我推荐",
"no_results": "没有找到结果"
@ -15,12 +14,7 @@
"paused": "{{title}} (已暂停)",
"downloading": "{{title}} ({{percentage}} - 正在下载…)",
"filter": "筛选游戏库",
"follow_us": "关注我们",
"home": "主页",
"discord": "加入我们的Discord",
"telegram": "加入我们的Telegram",
"x": "在X上关注我们",
"github": "在GitHub上贡献"
"home": "主页"
},
"header": {
"search": "搜索",

View File

@ -22,6 +22,7 @@ import "./library/open-game-installer-path";
import "./library/update-executable-path";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./misc/is-user-logged-in";
import "./misc/open-external";
import "./misc/show-open-dialog";
import "./torrenting/cancel-game-download";
@ -42,6 +43,7 @@ import "./download-sources/sync-download-sources";
import "./auth/signout";
import "./user/get-user";
import "./profile/get-me";
import "./profile/update-profile";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services/hydra-api";
const isUserLoggedIn = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.isLoggedIn();
};
registerEvent("isUserLoggedIn", isUserLoggedIn);

View File

@ -0,0 +1,63 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services/hydra-api";
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
import { fileTypeFromFile } from "file-type";
import { UserProfile } from "@types";
const patchUserProfile = async (
displayName: string,
profileImageUrl?: string
) => {
if (profileImageUrl) {
return HydraApi.patch("/profile", {
displayName,
profileImageUrl,
});
} else {
return HydraApi.patch("/profile", {
displayName,
});
}
};
const updateProfile = async (
_event: Electron.IpcMainInvokeEvent,
displayName: string,
newProfileImagePath: string | null
): Promise<UserProfile> => {
console.log(newProfileImagePath);
if (!newProfileImagePath) {
return (await patchUserProfile(displayName)).data;
}
const stats = fs.statSync(newProfileImagePath);
const fileBuffer = fs.readFileSync(newProfileImagePath);
const fileSizeInBytes = stats.size;
const profileImageUrl = await HydraApi.post(`/presigned-urls/profile-image`, {
imageExt: path.extname(newProfileImagePath).slice(1),
imageLength: fileSizeInBytes,
})
.then(async (preSignedResponse) => {
const { presignedUrl, profileImageUrl } = preSignedResponse.data;
const mimeType = await fileTypeFromFile(newProfileImagePath);
await axios.put(presignedUrl, fileBuffer, {
headers: {
"Content-Type": mimeType?.mime,
},
});
return profileImageUrl;
})
.catch(() => {
return undefined;
});
return (await patchUserProfile(displayName, profileImageUrl)).data;
};
registerEvent("updateProfile", updateProfile);

View File

@ -2,6 +2,7 @@ import { app, BrowserWindow, net, protocol } from "electron";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
import url from "node:url";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { DownloadManager, logger, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
@ -51,9 +52,10 @@ if (process.defaultApp) {
app.whenReady().then(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra");
protocol.handle("hydra", (request) =>
net.fetch("file://" + request.url.slice("hydra://".length))
);
protocol.handle("local", (request) => {
const filePath = request.url.slice("local://".length);
return net.fetch(url.pathToFileURL(filePath).toString());
});
await dataSource.initialize();
await dataSource.runMigrations();

View File

@ -11,6 +11,8 @@ export class HydraApi {
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5;
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
private static userAuth = {
authToken: "",
refreshToken: "",
@ -32,7 +34,9 @@ export class HydraApi {
const now = new Date();
const tokenExpirationTimestamp =
now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS;
now.getTime() +
this.secondsToMilliseconds(expiresIn) -
this.EXPIRATION_OFFSET_IN_MS;
this.userAuth = {
authToken: accessToken,
@ -119,7 +123,9 @@ export class HydraApi {
const { accessToken, expiresIn } = response.data;
const tokenExpirationTimestamp =
now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS;
now.getTime() +
this.secondsToMilliseconds(expiresIn) -
this.EXPIRATION_OFFSET_IN_MS;
this.userAuth.authToken = accessToken;
this.userAuth.expirationTimestamp = tokenExpirationTimestamp;

View File

@ -112,6 +112,7 @@ contextBridge.exposeInMainWorld("electron", {
getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
isUserLoggedIn: () => ipcRenderer.invoke("isUserLoggedIn"),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
platform: process.platform,
@ -134,6 +135,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Profile */
getMe: () => ipcRenderer.invoke("getMe"),
updateProfile: (displayName: string, newProfileImagePath: string | null) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
/* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),

View File

@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.losbroxas.org 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' data: https://cdn.losbroxas.org 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://*.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>
<body style="background-color: #1c1c1c">

View File

@ -19,6 +19,8 @@ import {
setUserPreferences,
toggleDraggingDisabled,
closeToast,
setUserDetails,
setProfileBackground,
} from "@renderer/features";
export interface AppProps {
@ -31,7 +33,8 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload();
const { updateUser, clearUser } = useUserDetails();
const { fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails();
const dispatch = useAppDispatch();
@ -73,26 +76,44 @@ export function App() {
}, [clearDownload, setLastPacket, updateLibrary]);
useEffect(() => {
updateUser();
}, [updateUser]);
const cachedUserDetails = window.localStorage.getItem("userDetails");
if (cachedUserDetails) {
const { profileBackground, ...userDetails } =
JSON.parse(cachedUserDetails);
dispatch(setUserDetails(userDetails));
dispatch(setProfileBackground(profileBackground));
}
window.electron.isUserLoggedIn().then((isLoggedIn) => {
if (isLoggedIn) {
fetchUserDetails().then((response) => {
if (response) setUserDetails(response);
});
}
});
}, [dispatch, fetchUserDetails]);
useEffect(() => {
const listeners = [
window.electron.onSignIn(() => {
updateUser();
fetchUserDetails().then((response) => {
if (response) updateUserDetails(response);
});
}),
window.electron.onLibraryBatchComplete(() => {
updateLibrary();
}),
window.electron.onSignOut(() => {
clearUser();
clearUserDetails();
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [updateUser, updateLibrary, clearUser]);
}, [fetchUserDetails, updateUserDetails, clearUserDetails]);
const handleSearch = useCallback(
(query: string) => {

View File

@ -39,7 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/user")) return headerTitle;
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);

View File

@ -0,0 +1,58 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px #000000",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButtonContent = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
height: "40px",
});
export const profileAvatar = style({
width: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
border: `solid 1px ${vars.color.border}`,
position: "relative",
objectFit: "cover",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
});
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,
});

View File

@ -1,6 +1,7 @@
import { useNavigate } from "react-router-dom";
import { PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar.css";
import * as styles from "./sidebar-profile.css";
import { useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
@ -9,12 +10,13 @@ export function SidebarProfile() {
const { userDetails, profileBackground } = useUserDetails();
const handleClickProfile = () => {
navigate(`/user/${userDetails!.id}`);
};
const handleButtonClick = () => {
if (userDetails === null) {
window.electron.openExternal("https://auth.hydra.losbroxas.org");
return;
}
const handleClickLogin = () => {
window.electron.openExternal("https://auth.hydra.losbroxas.org");
navigate(`/user/${userDetails!.id}`);
};
const profileButtonBackground = useMemo(() => {
@ -22,36 +24,16 @@ export function SidebarProfile() {
return undefined;
}, [profileBackground]);
if (userDetails == null) {
return (
<>
<button
type="button"
className={styles.profileButton}
onClick={handleClickLogin}
>
<div className={styles.profileAvatar}>
<PersonIcon />
</div>
<div className={styles.profileButtonInformation}>
<p style={{ fontWeight: "bold" }}>Fazer login</p>
</div>
</button>
</>
);
}
return (
<>
<button
type="button"
className={styles.profileButton}
style={{ background: profileButtonBackground }}
onClick={handleClickProfile}
>
<button
type="button"
className={styles.profileButton}
style={{ background: profileButtonBackground }}
onClick={handleButtonClick}
>
<div className={styles.profileButtonContent}>
<div className={styles.profileAvatar}>
{userDetails.profileImageUrl ? (
{userDetails?.profileImageUrl ? (
<img
className={styles.profileAvatar}
src={userDetails.profileImageUrl}
@ -63,9 +45,11 @@ export function SidebarProfile() {
</div>
<div className={styles.profileButtonInformation}>
<p style={{ fontWeight: "bold" }}>{userDetails.displayName}</p>
<p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : "Sign in"}
</p>
</div>
</button>
</>
</div>
</button>
);
}

View File

@ -125,48 +125,3 @@ export const section = style({
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
alignItems: "center",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px #000000",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileAvatar = style({
width: "30px",
height: "30px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
border: `solid 1px ${vars.color.border}`,
position: "relative",
objectFit: "cover",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});

View File

@ -97,6 +97,7 @@ declare global {
/* Misc */
openExternal: (src: string) => Promise<void>;
isUserLoggedIn: () => Promise<boolean>;
getVersion: () => Promise<string>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
@ -122,6 +123,10 @@ declare global {
/* Profile */
getMe: () => Promise<UserProfile | null>;
updateProfile: (
displayName: string,
newProfileImagePath: string | null
) => Promise<UserProfile>;
}
interface Window {

View File

@ -15,18 +15,14 @@ export const userDetailsSlice = createSlice({
name: "user-details",
initialState,
reducers: {
setUserDetails: (state, action: PayloadAction<UserDetails>) => {
setUserDetails: (state, action: PayloadAction<UserDetails | null>) => {
state.userDetails = action.payload;
},
setProfileBackground: (state, action: PayloadAction<string>) => {
setProfileBackground: (state, action: PayloadAction<string | null>) => {
state.profileBackground = action.payload;
},
clearUserDetails: (state) => {
state.userDetails = null;
state.profileBackground = null;
},
},
});
export const { setUserDetails, setProfileBackground, clearUserDetails } =
export const { setUserDetails, setProfileBackground } =
userDetailsSlice.actions;

View File

@ -2,12 +2,9 @@ import { useCallback } from "react";
import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux";
import {
clearUserDetails,
setProfileBackground,
setUserDetails,
} from "@renderer/features";
import { setProfileBackground, setUserDetails } from "@renderer/features";
import { darkenColor } from "@renderer/helpers";
import { UserDetails } from "@types";
export function useUserDetails() {
const dispatch = useAppDispatch();
@ -16,42 +13,69 @@ export function useUserDetails() {
(state) => state.userDetails
);
const clearUser = useCallback(async () => {
dispatch(clearUserDetails());
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
dispatch(setProfileBackground(null));
window.localStorage.removeItem("userDetails");
}, [dispatch]);
const signOut = useCallback(async () => {
clearUser();
clearUserDetails();
return window.electron.signOut();
}, [clearUser]);
}, [clearUserDetails]);
const updateUser = useCallback(async () => {
return window.electron.getMe().then(async (userDetails) => {
if (userDetails) {
dispatch(setUserDetails(userDetails));
const updateUserDetails = useCallback(
async (userDetails: UserDetails) => {
dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, {
amount: 1,
format: "hex",
});
if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, {
amount: 1,
format: "hex",
});
dispatch(
setProfileBackground(
`linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.7)})`
)
);
}
const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8)})`;
dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem(
"userDetails",
JSON.stringify({ ...userDetails, profileBackground })
);
} else {
dispatch(setProfileBackground(null));
window.localStorage.setItem("userDetails", JSON.stringify(userDetails));
}
});
}, [dispatch]);
},
[dispatch]
);
const fetchUserDetails = useCallback(async () => {
return window.electron.getMe();
}, []);
const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => {
const response = await window.electron.updateProfile(
displayName,
imageProfileUrl
);
return updateUserDetails(response);
},
[updateUserDetails]
);
return {
userDetails,
updateUser,
fetchUserDetails,
signOut,
clearUser,
clearUserDetails,
updateUserDetails,
patchUser,
profileBackground,
};
}

View File

@ -1,5 +1,4 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT } from "../../theme.css";
@ -17,11 +16,9 @@ export const content = style({
flex: "1",
});
export const cards = recipe({
base: {
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
},
export const cards = style({
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
});

View File

@ -3,26 +3,35 @@ import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { useDate, useUserDetails } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath } from "@renderer/helpers";
import { PersonIcon } from "@primer/octicons-react";
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface ProfileContentProps {
userProfile: UserProfile;
updateUserProfile: () => Promise<void>;
}
export function UserContent({ userProfile }: ProfileContentProps) {
export function UserContent({
userProfile,
updateUserProfile,
}: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails();
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
const navigate = useNavigate();
const numberFormatter = useMemo(() => {
@ -54,8 +63,12 @@ export function UserContent({ userProfile }: ProfileContentProps) {
navigate(buildGameDetailsPath(game));
};
const handleSignout = async () => {
await signOut();
const handleEditProfile = () => {
setShowEditProfileModal(true);
};
const handleConfirmSignout = async () => {
signOut();
navigate("/");
};
@ -69,10 +82,24 @@ export function UserContent({ userProfile }: ProfileContentProps) {
return (
<>
<UserEditProfileModal
visible={showEditProfileModal}
onClose={() => setShowEditProfileModal(false)}
updateUserProfile={updateUserProfile}
userProfile={userProfile}
/>
<UserSignOutModal
visible={showSignOutModal}
onClose={() => setShowSignOutModal(false)}
onConfirm={handleConfirmSignout}
/>
<section
className={styles.profileContentBox}
style={{
background: profileContentBoxBackground,
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
}}
>
<div className={styles.profileAvatarContainer}>
@ -93,27 +120,53 @@ export function UserContent({ userProfile }: ProfileContentProps) {
{isMe && (
<div style={{ flex: 1, display: "flex", justifyContent: "end" }}>
<Button theme="danger" onClick={handleSignout}>
{t("sign_out")}
</Button>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<>
<Button theme="outline" onClick={handleEditProfile}>
Editar perfil
</Button>
<Button
theme="danger"
onClick={() => setShowSignOutModal(true)}
>
{t("sign_out")}
</Button>
</>
</div>
</div>
)}
</section>
<div className={styles.profileContent}>
<div className={styles.profileGameSection}>
<div>
<h2>{t("activity")}</h2>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{userProfile.recentGames.map((game) => {
return (
<h2>{t("activity")}</h2>
{!userProfile.recentGames.length ? (
<div className={styles.noDownloads}>
<div className={styles.telescopeIcon}>
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}>
{t("no_recent_activity_description")}
</p>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{userProfile.recentGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.feedItem, styles.profileContentBox)}
@ -139,9 +192,9 @@ export function UserContent({ userProfile }: ProfileContentProps) {
</small>
</div>
</button>
);
})}
</div>
))}
</div>
)}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
@ -170,33 +223,28 @@ export function UserContent({ userProfile }: ProfileContentProps) {
<div
style={{
display: "grid",
gridTemplateColumns: "auto auto auto",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => {
return (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
style={{
padding: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
}}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
);
})}
{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>

View File

@ -0,0 +1,147 @@
import { Button, Modal, TextField } from "@renderer/components";
import { UserProfile } from "@types";
import * as styles from "./user.css";
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useMemo, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
userProfile: UserProfile;
visible: boolean;
onClose: () => void;
updateUserProfile: () => Promise<void>;
}
export const UserEditProfileModal = ({
userProfile,
visible,
onClose,
updateUserProfile,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
const [displayName, setDisplayName] = useState("");
const [newImagePath, setNewImagePath] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
setDisplayName(userProfile.displayName);
}, [userProfile.displayName]);
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "gif", "webp", "bmp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setNewImagePath(path);
}
};
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsSaving(true);
patchUser(displayName, newImagePath)
.then(async () => {
await updateUserProfile();
showSuccessToast(t("saved_successfully"));
cleanFormAndClose();
})
.catch(() => {
showErrorToast(t("try_again"));
})
.finally(() => {
setIsSaving(false);
});
};
const resetModal = () => {
setDisplayName(userProfile.displayName);
setNewImagePath(null);
};
const cleanFormAndClose = () => {
resetModal();
onClose();
};
const avatarUrl = useMemo(() => {
if (newImagePath) return `local:${newImagePath}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [newImagePath, userProfile.profileImageUrl]);
return (
<>
<Modal
visible={visible}
title={t("edit_profile")}
onClose={cleanFormAndClose}
>
<form
onSubmit={handleSaveProfile}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{avatarUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={avatarUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.editProfileImageBadge}>
<DeviceCameraIcon size={16} />
</div>
</button>
<TextField
label={t("display_name")}
value={displayName}
required
minLength={3}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setDisplayName(e.target.value)}
/>
<Button
disabled={isSaving}
style={{ alignSelf: "end" }}
type="submit"
>
{isSaving ? t("saving") : t("save")}
</Button>
</form>
</Modal>
</>
);
};

View File

@ -0,0 +1,37 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
visible: boolean;
onConfirm: () => void;
onClose: () => void;
}
export const UserSignOutModal = ({
visible,
onConfirm,
onClose,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
return (
<>
<Modal
visible={visible}
title={t("signout_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="outline">
{t("signout")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</Modal>
</>
);
};

View File

@ -1,13 +1,40 @@
import Skeleton from "react-loading-skeleton";
import cn from "classnames";
import * as styles from "./user.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export const UserSkeleton = () => {
const { t } = useTranslation("user_profile");
return (
<>
<Skeleton className={styles.profileHeaderSkeleton} />
<div className={styles.profileContent}>
<Skeleton height={140} style={{ flex: 1 }} />
<Skeleton width={300} className={styles.contentSidebar} />
<div className={styles.profileGameSection}>
<h2>{t("activity")}</h2>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton
key={index}
height={72}
style={{ flex: "1", width: "100%" }}
/>
))}
</div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}>
<h2>{t("library")}</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton key={index} style={{ aspectRatio: "1" }} />
))}
</div>
</div>
</div>
</>
);

View File

@ -4,6 +4,7 @@ import { style } from "@vanilla-extract/css";
export const wrapper = style({
padding: "24px",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
@ -12,12 +13,10 @@ export const wrapper = style({
export const profileContentBox = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 2}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
overflow: "hidden",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
transition: "all ease 0.3s",
});
@ -36,10 +35,38 @@ export const profileAvatarContainer = style({
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
});
export const profileAvatarEditContainer = style({
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({
width: "96px",
height: "96px",
height: "100%",
width: "100%",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover",
animationPlayState: "paused",
});
export const profileAvatarEditOverlay = style({
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "#00000055",
color: vars.color.muted,
zIndex: 1,
cursor: "pointer",
});
export const profileInformation = style({
@ -51,12 +78,14 @@ export const profileInformation = style({
export const profileContent = style({
display: "flex",
height: "100%",
flexDirection: "row",
gap: `${SPACING_UNIT * 4}px`,
});
export const profileGameSection = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
@ -73,28 +102,17 @@ export const contentSidebar = style({
maxWidth: "250px",
width: "100%",
},
"(min-width: 1280px)": {
width: "100%",
maxWidth: "350px",
},
},
});
export const feedGameIcon = style({
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
});
export const libraryGameIcon = style({
width: "100%",
height: "100%",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
});
export const feedItem = style({
@ -114,13 +132,11 @@ export const feedItem = style({
export const gameListItem = style({
color: vars.color.body,
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
aspectRatio: "1",
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)",
},
@ -134,5 +150,49 @@ export const gameInformation = style({
});
export const profileHeaderSkeleton = style({
height: "200px",
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 signOutModalButtonsContainer = style({
display: "flex",
width: "100%",
justifyContent: "end",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});

View File

@ -1,5 +1,5 @@
import { UserProfile } from "@types";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
@ -15,8 +15,8 @@ export const User = () => {
const dispatch = useAppDispatch();
useEffect(() => {
window.electron.getUser(userId!).then((userProfile) => {
const getUserProfile = useCallback(() => {
return window.electron.getUser(userId!).then((userProfile) => {
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile);
@ -24,11 +24,20 @@ export const User = () => {
});
}, [dispatch, userId]);
useEffect(() => {
getUserProfile();
}, [getUserProfile]);
console.log(userProfile);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div className={styles.wrapper}>
{userProfile ? (
<UserContent userProfile={userProfile} />
<UserContent
userProfile={userProfile}
updateUserProfile={getUserProfile}
/>
) : (
<UserSkeleton />
)}

View File

@ -3305,6 +3305,15 @@ file-type@^18.7.0:
strtok3 "^7.0.0"
token-types "^5.0.1"
file-type@^19.0.0:
version "19.0.0"
resolved "https://registry.yarnpkg.com/file-type/-/file-type-19.0.0.tgz#62a6cadc43f73ba38c53e1a174943a75fdafafa9"
integrity sha512-s7cxa7/leUWLiXO78DVVfBVse+milos9FitauDLG1pI7lNaJ2+5lzPnr2N24ym+84HVwJL6hVuGfgVE+ALvU8Q==
dependencies:
readable-web-to-node-stream "^3.0.2"
strtok3 "^7.0.0"
token-types "^5.0.1"
file-uri-to-path@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"